Files
rmutr/docs/superpowers/plans/2026-09-08-request-budget-approve-workflow.md
T
Nut.ไปเรื่อยandClaude Sonnet 5 4471b224cf docs: add remaining implementation plans
Adds plan docs that were pending in the working tree, matching the
existing docs/superpowers/plans/ pattern.

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

69 KiB

คำขอแผ่นดิน (request-budget) 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: Give /app/request-budget (คำขอแผ่นดิน — key=1) a self-service "ระบุผู้อนุมัติ → ส่งอนุมัติ → อนุมัติ 1/2 → ส่งแก้ไข" chain (mirroring the proven request_expense code "04"/"05"/"06"/"07" pattern), and give /app/request-budget-plan-01 (key=3) a "ตรวจสอบส่วนกลาง" stage (mirroring the already-shipped income_budget_review feature exactly) so that: only fully-approved items show there, กองแผน staff can send an item back to the last approver with a mandatory reason, mark it reviewed, and see a small clickable dashboard of pending/returned/reviewed counts.

Architecture: Add one new request_approve_type code ("08") and one new request_expense.type_id (4) for a self-service approval envelope scoped to budget_year_uid + faculty_uid + budget_location_uid (same shape as the existing type_id=2 "สรุปคำของบประมาณรายได้" envelope). Add a brand-new RequestBudgetReview backend module that is a near-exact copy of the already-shipped IncomeBudgetReview module (table, model, service, controller, DI registration) — only the request_approve_type codes it triggers on differ. Extend ApproveState.cs's existing request_expense branches (row_order==1 gate, chain-completion trigger) with two small additive if conditions. On the frontend, reuse the existing shared SetApproversDialogComponent for "ระบุผู้อนุมัติ", and copy IncomeBudgetReviewDashboardComponent/IncomeBudgetReviewStatusComponent into new RequestBudgetReviewDashboardComponent/RequestBudgetReviewStatusComponent siblings, wiring them into the existing RequestBudgetContainer/RequestBudgetListComponent (the single shared module behind both /app/request-budget and /app/request-budget-plan-01).

Tech Stack: .NET 8 / EF Core (raw idempotent SQL migration, matching this repo's convention for new tables) on rmutr-api; Angular 15 (RxJS, Angular Material, SweetAlert2) on rmutr-web; PostgreSQL, schema "BUDGET".

Spec: docs/superpowers/specs/2026-09-07-approve-state-workflow-reference.md (base approve_state mechanics) and docs/superpowers/specs/2026-09-07-income-budget-review-stage-design.md (the exact review-stage pattern this plan clones). This plan's own research additionally confirmed, by reading the live current source (not just the specs):

  • request_approve_type codes "01"-"07" are all already in use; "08" is free.
  • /app/request-budget (key=1), /app/request-budget-plan (key=2) and /app/request-budget-plan-01 (key=3) are the same Angular module (feature/budget-request/request-budget/request-budget.module.ts), branched purely by route-data key → this.keyUrl throughout request-budget.container.ts and its children. This is a different module from feature/budget-request/request-budget-plan/ (which backs income-budget-plan* and already hosts the shipped IncomeBudgetReview UI — useful as a live reference, not a file to edit).
  • The real approval engine behind /app/request-budget today is t_request_expense (type_id=1), not t_request_budget — t_request_budget's own approve_state wiring (code "01", via reference_uid) is dead/orphaned (created in RequestBudgetService.NewEntity(), never read back anywhere).
  • Today, type_id=1 request_expense records auto-seed their approve_state queue from the admin-configured code "03" queue (RequestExpenseService.NewEntity() → getApproveState(..., "03", ...)), which is exactly the config-burden the reference spec recommends moving away from. This plan does not remove or modify that type_id=1/code "03" path — it is left completely untouched. Instead this plan adds a new, separate type_id=4 self-service envelope that /app/request-budget switches to using going forward. Any historical type_id=1 records are unaffected and simply won't appear in the new review dashboard (which filters on type_id==4).
  • request-budget-plan-01's current "only show fully-approved" check is a fragile hardcoded name match (state.display_name_th === 'พนักงานทั่วไป') instead of checking request_expense.is_approve == true — this plan fixes that as part of Task 8.

Global Constraints

  • Every new backend file goes under rmutr-api/Modules/RequestBudgetReview/ (mirrors Modules/IncomeBudgetReview/ exactly).
  • New DB table request_budget_review — create via a raw idempotent .sql migration file (this repo's established pattern for new tables added outside the EF Core scaffold flow — see Migrations/add_income_budget_review.sql), not dotnet ef migrations add.
  • New request_approve_type code is the literal string "08". New request_expense type_id is the literal int 4. Do not reuse "01"-"07" or type_id 1/2/3.
  • Every new C# file must build: verify with dotnet build from rmutr-api/ after each backend task.
  • Every new/changed TS file must type-check: verify with npx tsc -p tsconfig.app.json --noEmit from rmutr-web/ after each frontend task.
  • Reuse existing shared components/services wherever the codebase already has one for this exact shape (SetApproversDialogComponent, SweetalertService, thaiDate pipe) — do not create new ones.
  • Do not modify any invest_asset/invest_construct/existing request_expense type_id 1/2/3 behavior — every change to shared files (ApproveState.cs, RequestExpenseController.cs, RequestExpenseService.cs) must be strictly additive (new if branches, new methods, new routes), never edits to existing conditions.
  • This codebase has no automated test suite for either project (confirmed during research) — "tests" in this plan mean a manual curl/browser verification step, not an xUnit/Jasmine test file. Follow the existing code's manual-verification convention.

Task 1: Migration — request_budget_review table

Files:

  • Create: rmutr-api/Migrations/add_request_budget_review.sql

Interfaces:

  • Produces: table "BUDGET".request_budget_review with columns request_budget_review_uid uuid PK, request_expense_uid uuid, review_status_id smallint, remark text, plus base_table columns (status_id, created_by, created_datetime, updated_by, updated_datetime, owner_agency_uid).

  • Step 1: Write the migration file

-- Migration: สร้างตาราง request_budget_review — เก็บ log การตรวจสอบส่วนกลางของคำขอแผ่นดิน
--            (request_expense, request_approve_type in ("03","08"), type_id=4) หลังเชนอนุมัติผ่านครบ
--            ดู docs/superpowers/plans/2026-09-08-request-budget-approve-workflow.md
-- 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}".request_budget_review (
    request_budget_review_uid uuid PRIMARY KEY,
    request_expense_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_request_budget_review_request_expense_uid
    ON "{0}".request_budget_review (request_expense_uid);
  • Step 2: Find how .sql migrations under Migrations/ get executed on startup

Run: grep -rn "add_income_budget_review\|\.sql\"" /Users/nut.looknut/Project/rmutr/rmutr-api/Startup.cs /Users/nut.looknut/Project/rmutr/rmutr-api/Program.cs 2>/dev/null

This tells you whether .sql files in Migrations/ are auto-discovered by filename glob or must be listed explicitly somewhere. If explicit, add add_request_budget_review.sql to that same list/array right next to add_income_budget_review.sql.

  • Step 3: Build and run once locally to confirm the table is created

Run: cd rmutr-api && dotnet build Expected: build succeeds (this is a pure .sql file, nothing to compile yet — this step just confirms Task 1 didn't break the existing build).

  • Step 4: Commit
git add Migrations/add_request_budget_review.sql
git commit -m "feat: add request_budget_review table for คำขอแผ่นดิน central-review stage"

Task 2: Backend model — request_budget_review

Files:

  • Create: rmutr-api/Modules/RequestBudgetReview/Databases/Models/request_budget_review.cs

Interfaces:

  • Produces: t_request_budget_review class (used by Task 3's Db.cs registration and Task 4's service).

  • Step 1: Write the model, copying income_budget_review.cs exactly with names swapped

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

namespace rmutr_budget_api.Modules.RequestBudgetReview.Databases.Models
{
    public class request_budget_review : base_table
    {
        [Key] public Guid? request_budget_review_uid { get; set; }
        public Guid? request_expense_uid { get; set; }
        /// <summary>1 = รอตรวจสอบ, 2 = ส่งแก้ไข, 3 = ตรวจสอบแล้ว — ดูรายละเอียดที่
        /// docs/superpowers/plans/2026-09-08-request-budget-approve-workflow.md</summary>
        public short review_status_id { get; set; }
        public string remark { get; set; }
    }

    public class t_request_budget_review : request_budget_review
    {
    }
}
  • Step 2: Build

Run: cd rmutr-api && dotnet build Expected: succeeds (new file compiles standalone, no references to it yet).

  • Step 3: Commit
git add Modules/RequestBudgetReview/Databases/Models/request_budget_review.cs
git commit -m "feat: add t_request_budget_review model"

Task 3: Wire t_request_budget_review into the shared Db DbContext

Files:

  • Create: rmutr-api/Modules/RequestBudgetReview/Databases/Db.cs
  • Modify: rmutr-api/Databases/Db.cs (find the exact line via the grep below — it currently calls OnIncomeBudgetReviewModelCreating(builder,schema);)

Interfaces:

  • Consumes: t_request_budget_review from Task 2.

  • Produces: _context.t_request_budget_review (a DbSet<t_request_budget_review>) usable by Task 4's service.

  • Step 1: Create the partial-class DbContext file, copying Modules/IncomeBudgetReview/Databases/Db.cs

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

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

        private void OnRequestBudgetReviewModelCreating(ModelBuilder builder, string schema)
        {
            builder.Entity<t_request_budget_review>().ToTable("request_budget_review", schema);
        }
    }
}
  • Step 2: Register the model-creating call in the main Db.cs

Run: grep -n "OnIncomeBudgetReviewModelCreating" /Users/nut.looknut/Project/rmutr/rmutr-api/Databases/Db.cs

Add a new line immediately after the matched line:

      OnRequestBudgetReviewModelCreating(builder,schema);

(match the exact indentation of the line above it.)

  • Step 3: Build

Run: cd rmutr-api && dotnet build Expected: succeeds. If it fails with a duplicate-table or ambiguous-DbSet error, confirm you didn't accidentally paste this into the same partial file as t_income_budget_review.

  • Step 4: Commit
git add Modules/RequestBudgetReview/Databases/Db.cs Databases/Db.cs
git commit -m "feat: register t_request_budget_review DbSet"

Task 4: Backend service — RequestBudgetReviewService

Files:

  • Create: rmutr-api/Modules/RequestBudgetReview/Services/RequestBudgetReview.cs

Interfaces:

  • Consumes: t_request_budget_review/request_budget_review (Task 2), Db (Task 3), t_request_expense/v_request_expense (existing, rmutr_budget_api.Modules.Request.Databases.Models), t_approve_state (existing).

  • Produces: RequestBudgetReviewService with public methods GetStatus(Guid), GetDashboard(), ReturnForEdit(Guid, string, ClaimsIdentity), MarkReviewed(Guid, ClaimsIdentity) — same signatures as IncomeBudgetReviewService, consumed by Task 5's controller.

  • Step 1: Write the service, copying IncomeBudgetReview.cs (service) with these exact changes:

    • Namespace/class names IncomeBudgetReview* → RequestBudgetReview*.
    • review_status_id field stays the same name (matches Task 2's model).
    • GetDashboard()'s candidate filter: e.type_id == 2 → e.type_id == 4 (the new self-service envelope from Task 6), and allow the legacy type_id == 1 records that already fully approved through the old code "03" path, so nothing already-approved silently disappears from the review queue. Filter becomes (e.type_id == 4 || e.type_id == 1).
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.RequestBudgetReview.Databases.Models;
using rmutr_budget_api.Modules.Request.Databases.Models;
using SeventyOneDev.Utilities;

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

    public class request_budget_review_status_response
    {
        public Guid? request_expense_uid { get; set; }
        public string current_status { get; set; }
        public List<request_budget_review_history_item> history { get; set; } = new();
    }

    public class request_budget_review_dashboard_item
    {
        public Guid? request_expense_uid { get; set; }
        public Guid? budget_year_uid { get; set; }
        public string budget_year_name_th { get; set; }
        public Guid? faculty_uid { get; set; }
        public string faculty_name_th { get; set; }
        public Guid? budget_location_uid { get; set; }
        public string budget_location_name_th { get; set; }
        public DateTime? updated_datetime { get; set; }
    }

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

    public class RequestBudgetReviewService
    {
        private readonly Db _context;

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

        private string ComputeStatus(t_request_expense entity, t_request_budget_review latestLog)
        {
            if (entity.is_draft == true && 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<request_budget_review_status_response> GetStatus(Guid requestExpenseUid)
        {
            var entity = await _context.t_request_expense
                .FirstOrDefaultAsync(c => c.request_expense_uid == requestExpenseUid);
            if (entity == null) return null;

            var logs = await _context.t_request_budget_review.AsNoTracking()
                .Where(c => c.request_expense_uid == requestExpenseUid)
                .OrderByDescending(c => c.created_datetime)
                .ToListAsync();
            var latestLog = logs.FirstOrDefault();

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

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

            return new request_budget_review_status_response
            {
                request_expense_uid = requestExpenseUid,
                current_status = ComputeStatus(entity, latestLog),
                history = history,
            };
        }

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

            var candidates = await _context.v_request_expense.AsNoTracking()
                .Where(e => (e.type_id == 4 || e.type_id == 1)
                    && (e.is_approve == true || (e.is_draft == true && e.is_approve == false)))
                .ToListAsync();
            var candidateIds = candidates.Select(c => c.request_expense_uid).ToList();
            var allLogs = await _context.t_request_budget_review.AsNoTracking()
                .Where(r => candidateIds.Contains(r.request_expense_uid))
                .ToListAsync();
            var latestLogByRequest = allLogs
                .GroupBy(r => r.request_expense_uid)
                .ToDictionary(g => g.Key, g => g.OrderByDescending(r => r.created_datetime).First());

            var years = await _context.v_budget_year.AsNoTracking()
                .ToDictionaryAsync(c => c.budget_year_uid, c => c.budget_year_name_th);
            var locations = await _context.v_budget_location.AsNoTracking()
                .ToDictionaryAsync(c => c.budget_location_uid, c => c.budget_location_name_th);
            var faculties = await _context.v_faculty.AsNoTracking()
                .ToDictionaryAsync(c => c.faculty_uid, c => c.faculty_name_th);

            request_budget_review_dashboard_item ToItem(v_request_expense e) => new()
            {
                request_expense_uid = e.request_expense_uid,
                budget_year_uid = e.budget_year_uid,
                budget_year_name_th = e.budget_year_uid.HasValue && years.TryGetValue(e.budget_year_uid, out var y) ? y : null,
                faculty_uid = e.faculty_uid,
                faculty_name_th = e.faculty_uid.HasValue && faculties.TryGetValue(e.faculty_uid, out var f) ? f : e.faculty_name_th,
                budget_location_uid = e.budget_location_uid,
                budget_location_name_th = e.budget_location_uid.HasValue && locations.TryGetValue(e.budget_location_uid, out var l) ? l : null,
                updated_datetime = e.updated_datetime,
            };

            foreach (var e in candidates)
            {
                latestLogByRequest.TryGetValue(e.request_expense_uid, out var latestLog);
                var status = ComputeStatus(new t_request_expense
                {
                    is_draft = e.is_draft,
                    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 requestExpenseUid, string remark,
            ClaimsIdentity claimsIdentity)
        {
            var entity = await _context.t_request_expense
                .FirstOrDefaultAsync(c => c.request_expense_uid == requestExpenseUid);
            if (entity == null) return (false, "ไม่พบรายการ");
            if (entity.is_approve != true)
                return (false, "รายการนี้ยังไม่ผ่านการอนุมัติครบทุกลำดับ ไม่สามารถส่งแก้ไขจากการตรวจสอบได้");
            if (string.IsNullOrWhiteSpace(remark))
                return (false, "กรุณาระบุรายละเอียดที่ส่งแก้ไข");

            var currentRound = await _context.t_approve_state
                .Where(c => c.request_expense_uid == requestExpenseUid)
                .Select(c => (int?)c.round_no).MaxAsync() ?? 1;
            var lastRow = await _context.t_approve_state
                .Where(c => c.request_expense_uid == requestExpenseUid && c.round_no == currentRound)
                .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_draft = true;
            entity.is_approve = false;
            entity.current_approve_name = lastRow.display_name_th;
            if (lastRow.row_order == 1) entity.is_sent = false;
            _context.t_request_expense.Update(entity);

            await _context.t_remark_history.AddAsync(new t_remark_history
            {
                remark_history_uid = Guid.NewGuid(),
                request_expense_uid = requestExpenseUid,
                remark_history_name_th = remark,
                updated_date = DateTime.Now,
                updated_name = claimsIdentity?.FindFirst("display_name")?.Value,
            });

            await _context.t_request_budget_review.AddAsync(new t_request_budget_review
            {
                request_budget_review_uid = Guid.NewGuid(),
                request_expense_uid = requestExpenseUid,
                review_status_id = 2,
                remark = remark,
            });

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

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

            var latestLog = await _context.t_request_budget_review.AsNoTracking()
                .Where(c => c.request_expense_uid == requestExpenseUid)
                .OrderByDescending(c => c.created_datetime)
                .FirstOrDefaultAsync();
            if (latestLog == null || latestLog.review_status_id != 1)
                return (false, "รายการนี้ไม่ได้อยู่ในสถานะรอตรวจสอบ");

            await _context.t_request_budget_review.AddAsync(new t_request_budget_review
            {
                request_budget_review_uid = Guid.NewGuid(),
                request_expense_uid = requestExpenseUid,
                review_status_id = 3,
            });
            await _context.SaveChangesAsync(claimsIdentity);
            return (true, null);
        }
    }
}
  • Step 2: Build

Run: cd rmutr-api && dotnet build Expected: succeeds. If v_request_expense/t_remark_history/t_approve_state namespaces don't resolve, check the exact using lines at the top of Modules/IncomeBudgetReview/Services/IncomeBudgetReview.cs and match them (this plan's using rmutr_budget_api.Modules.Request.Databases.Models; is the one confirmed during research, but confirm before assuming).

  • Step 3: Commit
git add Modules/RequestBudgetReview/Services/RequestBudgetReview.cs
git commit -m "feat: add RequestBudgetReviewService (status/dashboard/return_for_edit/mark_reviewed)"

Task 5: Backend controller + DI registration — RequestBudgetReviewController

Files:

  • Create: rmutr-api/Modules/RequestBudgetReview/Controllers/RequestBudgetReview.cs
  • Create: rmutr-api/Modules/RequestBudgetReview/Configures/RequestBudgetReview.cs
  • Modify: rmutr-api/Startup.cs (find via grep below)

Interfaces:

  • Consumes: RequestBudgetReviewService (Task 4).

  • Produces: GET /api/request_budget_review/status/{request_expense_uid}, GET /api/request_budget_review/dashboard, POST /api/request_budget_review/return_for_edit/{request_expense_uid} (body { remark }), POST /api/request_budget_review/mark_reviewed/{request_expense_uid} — consumed by Task 9's Angular service.

  • Step 1: Write the controller, copying IncomeBudgetReview.cs (controller) with names swapped

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.RequestBudgetReview.Services;

namespace rmutr_budget_api.Modules.RequestBudgetReview.Controllers
{
    public class request_budget_return_for_edit_input
    {
        public string remark { get; set; }
    }

    [Route("api/request_budget_review", Name = "request_budget_review")]
    public class RequestBudgetReviewController : Controller
    {
        private readonly RequestBudgetReviewService _service;

        public RequestBudgetReviewController(RequestBudgetReviewService service)
        {
            _service = service;
        }

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

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

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

        [Authorize]
        [HttpPost("mark_reviewed/{request_expense_uid:guid}")]
        [ProducesResponseType(StatusCodes.Status200OK)]
        [ProducesResponseType(StatusCodes.Status400BadRequest)]
        public async Task<ActionResult> MarkReviewed([FromRoute] Guid request_expense_uid)
        {
            var result = await _service.MarkReviewed(request_expense_uid, User.Identity as ClaimsIdentity);
            if (!result.ok) return BadRequest(new { description = result.error });
            return Ok();
        }
    }
}
  • Step 2: Write the DI configure extension, copying Configures/IncomeBudgetReview.cs
using Microsoft.Extensions.DependencyInjection;
using rmutr_budget_api.Modules.RequestBudgetReview.Services;

namespace rmutr_budget_api.Modules.RequestBudgetReview.Configures
{
    public static class RequestBudgetReviewCollectionExtension
    {
        public static IServiceCollection AddRequestBudgetReviewServices(this IServiceCollection services)
        {
            services.AddScoped<RequestBudgetReviewService>();
            return services;
        }
    }
}
  • Step 3: Register in Startup.cs

Run: grep -n "AddIncomeBudgetReviewServices" /Users/nut.looknut/Project/rmutr/rmutr-api/Startup.cs

Add a new line immediately after the matched line (matching its indentation and any using statement it needs at the top of Startup.cs — check whether IncomeBudgetReview's Configures namespace has an explicit using at the top of the file, and add the equivalent one for RequestBudgetReview.Configures if so):

            services.AddRequestBudgetReviewServices();
  • Step 4: Build and smoke-test the new endpoints

Run: cd rmutr-api && dotnet build Expected: succeeds.

Run the API locally, then:

curl -s http://localhost:5000/api/request_budget_review/dashboard -H "Authorization: Bearer <a-valid-token>"

Expected: 200 OK with {"pending":[],"returned":[],"reviewed":[]} (empty, since no type_id==4 records exist yet).

  • Step 5: Commit
git add Modules/RequestBudgetReview/Controllers/RequestBudgetReview.cs Modules/RequestBudgetReview/Configures/RequestBudgetReview.cs Startup.cs
git commit -m "feat: add RequestBudgetReviewController + DI registration"

Task 6: Backend — self-service approvers for type_id=4 on RequestExpenseController

Files:

  • Modify: rmutr-api/Modules/Requests/Controllers/RequestExpense.cs (add new actions right after the existing type_3/send_approve/{uid:guid} action, i.e. right after line ~638 per current file — confirm exact line via grep below)
  • Modify: rmutr-api/Modules/Requests/Services/RequestExpense.cs (add one new method right after NewEntityType3, i.e. right after line ~154 — confirm via grep below)

Interfaces:

  • Consumes: existing generic SetCustomApprovers(Guid?, List<ApproverInput>, ClaimsIdentity, string approveTypeCode = "04") and SendApprove2(Guid?, ClaimsIdentity) on RequestExpenseService — no changes needed to either, both already accept any code / any type_id.

  • Produces: POST /api/request/request_expense/type_4 (creates the envelope, idempotent per year+faculty+location), POST /api/request/request_expense/type_4/set_approvers/{uid}, POST /api/request/request_expense/type_4/send_approve/{uid} — consumed by Task 9's Angular service.

  • Step 1: Confirm exact insertion points

Run: grep -n "NewEntityType3\|type_3/send_approve\|class RequestExpenseController" /Users/nut.looknut/Project/rmutr/rmutr-api/Modules/Requests/Controllers/RequestExpense.cs /Users/nut.looknut/Project/rmutr/rmutr-api/Modules/Requests/Services/RequestExpense.cs

  • Step 2: Add NewEntityType4 to RequestExpenseService.cs, right after NewEntityType3
        /// <summary>
        /// สร้างชุดอนุมัติของคำขอแผ่นดิน (type_id = 4) ผูกด้วย budget_year_uid+faculty_uid+budget_location_uid
        /// เหมือน type_2 — สร้างแบบ lazy (idempotent: คืนของเดิมถ้ามีอยู่แล้ว) ให้หน้า request-budget เรียกตอน
        /// กด "ระบุผู้อนุมัติ" ครั้งแรก ไม่ผ่าน getApproveState/code "03" (คนละ path จาก type_id=1 เดิม โดยตั้งใจ
        /// — ดู docs/superpowers/plans/2026-09-08-request-budget-approve-workflow.md)
        /// </summary>
        public async Task<t_request_expense> NewEntityType4(t_request_expense entity, ClaimsIdentity claimsIdentity)
        {
            var exist = await _context.t_request_expense.FirstOrDefaultAsync(c =>
                c.type_id == 4 && c.budget_year_uid == entity.budget_year_uid
                && c.faculty_uid == entity.faculty_uid && c.budget_location_uid == entity.budget_location_uid);
            if (exist != null) return exist;

            var agency = Helper.GetDataFilter(claimsIdentity, new List<string>() { "agency_uid" });
            if (agency.Any())
            {
                entity.owner_agency_uid = agency.FirstOrDefault();
            }

            entity.type_id = 4;
            entity.status_id ??= 1;
            entity.request_expense_uid = Guid.NewGuid();
            await _context.t_request_expense.AddAsync(entity);
            await _context.SaveChangesAsync(claimsIdentity);
            return entity;
        }
  • Step 3: Add the three new controller actions to RequestExpenseController.cs, right after the existing SendApprove3 action
    /// <summary>
    /// สร้าง/ดึงชุดอนุมัติของคำขอแผ่นดิน (type_id = 4) 1 ใบต่อ ปีงบ+คณะ+พื้นที่ — idempotent
    /// </summary>
    [Authorize]
    [HttpPost("type_4")]
    [ProducesResponseType(StatusCodes.Status201Created)]
    public async Task<ActionResult<t_request_expense>> NewEntityUid4([FromBody] t_request_expense entity)
    {
        var result = await _entityUidService.NewEntityType4(entity, User.Identity as ClaimsIdentity);
        return Created("", result);
    }

    /// <summary>กำหนดผู้อนุมัติเองสำหรับคำขอแผ่นดิน — ตรรกะเดียวกับ type_2/type_3 ทุกอย่าง ต่างแค่ code เป็น "08"</summary>
    [Authorize]
    [HttpPost("type_4/set_approvers/{uid:guid}")]
    [ProducesResponseType(StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
    public async Task<ActionResult> SetApprovers4([FromRoute] Guid uid, [FromBody] List<ApproverInput> approvers)
    {
        var result = await _entityUidService.SetCustomApprovers(uid, approvers,
            User.Identity as ClaimsIdentity, "08");
        if (!result.ok)
        {
            return BadRequest(new { description = result.error });
        }

        return Ok();
    }

    /// <summary>ส่งอนุมัติลำดับ 1 ของคำขอแผ่นดิน — ใครก็ได้กดได้ แค่ตั้ง is_sent=true เหมือน type_2/type_3</summary>
    [Authorize]
    [HttpPost("type_4/send_approve/{uid:guid}")]
    [ProducesResponseType(StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
    public async Task<ActionResult> SendApprove4([FromRoute] Guid uid)
    {
        var result = await _entityUidService.SendApprove2(uid, User.Identity as ClaimsIdentity);
        if (!result.ok)
        {
            return BadRequest(new { description = result.error });
        }

        return Ok();
    }
  • Step 4: Build

Run: cd rmutr-api && dotnet build Expected: succeeds.

  • Step 5: Manual smoke test
# create the envelope
curl -s -X POST http://localhost:5000/api/request/request_expense/type_4 \
  -H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
  -d '{"budget_year_uid":"<a-real-budget-year-uid>","faculty_uid":"<a-real-faculty-uid>","budget_location_uid":"<a-real-location-uid>"}'
# expect 201 with a request_expense_uid in the body

# set approvers on it
curl -s -X POST http://localhost:5000/api/request/request_expense/type_4/set_approvers/<uid-from-above> \
  -H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
  -d '[{"personnel_uid":"<a-real-personnel-uid>","display_name_th":"ทดสอบ ผู้อนุมัติ"}]'
# expect 200

# send for approval
curl -s -X POST http://localhost:5000/api/request/request_expense/type_4/send_approve/<uid-from-above> \
  -H "Authorization: Bearer <token>"
# expect 200
  • Step 6: Commit
git add Modules/Requests/Controllers/RequestExpense.cs Modules/Requests/Services/RequestExpense.cs
git commit -m "feat: add type_4 self-service approvers flow for คำขอแผ่นดิน (request-budget)"

Task 7: Backend — extend ApproveState.cs for code "08" + trigger the review stage

Files:

  • Modify: rmutr-api/Modules/Setting/Controllers/ApproveState.cs

Interfaces:

  • Consumes: RequestBudgetReviewService? — no, do NOT inject the service here; insert the log row directly via _db.t_request_budget_review.AddAsync(...) exactly like the existing "04" → t_income_budget_review trigger does (keeps ApproveStateController free of a new service dependency, matching the existing pattern precisely).

  • Produces: code "08" now gets the same row_order==1 is_sent+identity gate as "04"/"05"; chain completion for codes "03" or "08" auto-inserts a request_budget_review row with review_status_id=1.

  • Step 1: Add using for the new model namespace

At the top of ApproveState.cs, alongside the existing using rmutr_budget_api.Modules.IncomeBudgetReview.Databases.Models;, add:

using rmutr_budget_api.Modules.RequestBudgetReview.Databases.Models;
  • Step 2: Extend the row_order==1 gate (line ~174-175) to include "08"

Find (exact current text, confirmed during research):

                    if (getResult.row_order == 1
                        && (getResult.request_approve_type == "04" || getResult.request_approve_type == "05"))

Replace with:

                    if (getResult.row_order == 1
                        && (getResult.request_approve_type == "04" || getResult.request_approve_type == "05"
                            || getResult.request_approve_type == "08"))
  • Step 3: Add the review-stage trigger right after the existing "04" → income_budget_review block (line ~209-218)

Find (exact current text):

                        if (getResult.request_approve_type == "04")
                        {
                            await _db.t_income_budget_review.AddAsync(new t_income_budget_review
                            {
                                income_budget_review_uid = Guid.NewGuid(),
                                request_expense_uid = uid,
                                review_status_id = 1,
                            });
                            await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);
                        }

Add immediately after it (same indentation, sibling if — do not change the existing block at all):

                        // เชนอนุมัติของคำขอแผ่นดินผ่านครบ (ทั้งทาง config เดิม "03" และทาง self-service ใหม่
                        // "08") — ส่งเข้าสู่ขั้นตอนตรวจสอบส่วนกลางโดยอัตโนมัติเหมือน "04" ทำกับคำขอเงินรายได้ ดู
                        // docs/superpowers/plans/2026-09-08-request-budget-approve-workflow.md
                        if (getResult.request_approve_type == "03" || getResult.request_approve_type == "08")
                        {
                            await _db.t_request_budget_review.AddAsync(new t_request_budget_review
                            {
                                request_budget_review_uid = Guid.NewGuid(),
                                request_expense_uid = uid,
                                review_status_id = 1,
                            });
                            await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);
                        }
  • Step 4: Build

Run: cd rmutr-api && dotnet build Expected: succeeds.

  • Step 5: Manual smoke test — complete the chain from Task 6's test and confirm a review row appears
curl -s http://localhost:5000/api/setting/approve_state/approve/<uid-from-task-6> \
  -H "Authorization: Bearer <the-approver's-own-token>"
# expect 200; the approver in this test only has 1 row (row_order=1), so this single call
# both satisfies row_order==1 AND completes the chain (no row_order==2 to advance to)

curl -s http://localhost:5000/api/request_budget_review/dashboard -H "Authorization: Bearer <token>"
# expect the item to now appear in "pending"
  • Step 6: Commit
git add Modules/Setting/Controllers/ApproveState.cs
git commit -m "feat: gate code 08 + auto-trigger request_budget_review on คำขอแผ่นดิน chain completion"

Task 8: Backend — fix the "only fully-approved" filter used by request-budget-plan-01

Files:

  • Modify: rmutr-api/Modules/Requests/Controllers/RequestExpense.cs (the base [HttpGet] list action that queryString(...) hits — the one at line ~199 per the earlier controller listing)

Interfaces:

  • No signature change — this is a behavior-only fix so that filtering by is_approve==true server-side becomes possible without the frontend needing to guess. Actually the simplest, lowest-risk fix belongs on the frontend (Task 11) — the existing GET api/request/request_expense?budget_year_uid=...&faculty_uid=...&budget_location_uid=... endpoint already returns full approve_states/is_approve on each v_request_expense row (confirmed: this is the base query-by-example list action, no changes needed here). Skip this backend task — no server change needed. Leave this task's checkbox here only as a record that it was considered and intentionally not needed; proceed directly to Task 9.

  • Step 1: Confirm no backend change is needed

Run: grep -n "class v_request_expense" -A 5 /Users/nut.looknut/Project/rmutr/rmutr-api/Modules/Requests/Databases/Models/request_expense.cs Expected: confirms is_approve, is_draft, type_id are all plain scalar columns on v_request_expense, already returned by the existing list endpoint with no extra work — the actual fix is purely in the Angular filter logic (Task 11).


Task 9: Angular service — RequestBudgetReviewService + RequestExpenseService additions

Files:

  • Create: rmutr-web/src/app/core/service/request-budget-review/request-budget-review.service.ts
  • Modify: rmutr-web/src/app/core/service/request/request-expense.service.ts

Interfaces:

  • Produces: RequestBudgetReviewService (getStatus, getDashboard, returnForEdit, markReviewed) and 4 new methods on RequestExpenseService (newType4, setApprovers4, sendApprove4 — approve/reject are already generic and reused as-is).

  • Step 1: Create the Angular service, copying income-budget-review.service.ts exactly with names swapped

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

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

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

  constructor(private http: HttpClient) { }

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

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

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

  markReviewed(requestExpenseUid: string) {
    return this.http.post(`${this.base}/mark_reviewed/${requestExpenseUid}`, {});
  }
}
  • Step 2: Add the 3 new methods to RequestExpenseService

Run: grep -n "setApprovers3\|sendApprove3\|getMyDashboard" /Users/nut.looknut/Project/rmutr/rmutr-web/src/app/core/service/request/request-expense.service.ts

Add right after the matched methods:

  newType4(payload: { budget_year_uid: string, faculty_uid: string, budget_location_uid: string }) {
    return this.http.post(`${this.fullUrl}/type_4`, payload)
  }
  setApprovers4(id, approvers: { personnel_uid: string, display_name_th: string }[]) {
    return this.http.post(`${this.fullUrl}/type_4/set_approvers/${id}`, approvers)
  }
  sendApprove4(id) {
    return this.http.post(`${this.fullUrl}/type_4/send_approve/${id}`, {})
  }
  • Step 3: Type-check

Run: cd rmutr-web && npx tsc -p tsconfig.app.json --noEmit Expected: no errors.

  • Step 4: Commit
git add src/app/core/service/request-budget-review/request-budget-review.service.ts src/app/core/service/request/request-expense.service.ts
git commit -m "feat: add Angular services for คำขอแผ่นดิน type_4 approvers + review stage"

Task 10: Angular components — RequestBudgetReviewDashboardComponent + RequestBudgetReviewStatusComponent

Files:

  • Create: rmutr-web/src/app/feature/budget-request/request-budget/presenter/dashboard/request-budget-review-dashboard/request-budget-review-dashboard.component.ts
  • Create: rmutr-web/src/app/feature/budget-request/request-budget/presenter/dashboard/request-budget-review-dashboard/request-budget-review-dashboard.component.html
  • Create: rmutr-web/src/app/feature/budget-request/request-budget/presenter/dashboard/request-budget-review-dashboard/request-budget-review-dashboard.component.scss
  • Create: rmutr-web/src/app/feature/budget-request/request-budget/presenter/status/request-budget-review-status/request-budget-review-status.component.ts
  • Create: rmutr-web/src/app/feature/budget-request/request-budget/presenter/status/request-budget-review-status/request-budget-review-status.component.html
  • Create: rmutr-web/src/app/feature/budget-request/request-budget/presenter/status/request-budget-review-status/request-budget-review-status.component.scss
  • Modify: rmutr-web/src/app/feature/budget-request/request-budget/request-budget.module.ts (declare the 2 new components)

Interfaces:

  • Consumes: RequestBudgetReviewService (Task 9).

  • Produces: <app-request-budget-review-dashboard (onselectitem)="..."> emitting ReviewDashboardItem { request_expense_uid, budget_year_uid, budget_year_name_th, faculty_uid, faculty_name_th, budget_location_uid, budget_location_name_th, updated_datetime }; <app-request-budget-review-status [requestExpenseUid] [showActions] (onchanged)="..."> — both consumed by Task 11.

  • Step 1: Copy the .scss files verbatim (styling is generic, no renames needed)

Run:

mkdir -p "rmutr-web/src/app/feature/budget-request/request-budget/presenter/dashboard/request-budget-review-dashboard"
mkdir -p "rmutr-web/src/app/feature/budget-request/request-budget/presenter/status/request-budget-review-status"
cp "rmutr-web/src/app/feature/budget-request/request-budget-plan/presenter/dashboard/income-budget-review-dashboard/income-budget-review-dashboard.component.scss" \
   "rmutr-web/src/app/feature/budget-request/request-budget/presenter/dashboard/request-budget-review-dashboard/request-budget-review-dashboard.component.scss"
cp "rmutr-web/src/app/feature/budget-request/request-budget-plan/presenter/status/income-budget-review-status/income-budget-review-status.component.scss" \
   "rmutr-web/src/app/feature/budget-request/request-budget/presenter/status/request-budget-review-status/request-budget-review-status.component.scss"
  • Step 2: Write the dashboard component (.ts), copying income-budget-review-dashboard.component.ts with names swapped
import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef, EventEmitter, Output } from '@angular/core';
import { RequestBudgetReviewService } from 'src/app/core/service/request-budget-review/request-budget-review.service';

export interface ReviewDashboardItem {
  request_expense_uid: string;
  budget_year_uid: string;
  budget_year_name_th: string;
  faculty_uid: string;
  faculty_name_th: string;
  budget_location_uid: string;
  budget_location_name_th: string;
  updated_datetime: string;
}

interface ReviewDashboardData {
  pending: ReviewDashboardItem[];
  returned: ReviewDashboardItem[];
  reviewed: ReviewDashboardItem[];
}

const EMPTY_DATA: ReviewDashboardData = { pending: [], returned: [], reviewed: [] };

@Component({
  selector: 'app-request-budget-review-dashboard',
  templateUrl: './request-budget-review-dashboard.component.html',
  styleUrls: ['./request-budget-review-dashboard.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class RequestBudgetReviewDashboardComponent implements OnInit {

  @Output() onselectitem = new EventEmitter<ReviewDashboardItem>();

  isLoading = true;
  data: ReviewDashboardData = EMPTY_DATA;
  expandedKey: keyof ReviewDashboardData | null = null;

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

  ngOnInit(): void {
    this.reviewSV.getDashboard().subscribe({
      next: (res: any) => {
        this.data = { ...EMPTY_DATA, ...res };
        this.isLoading = false;
        this.cd.markForCheck();
      },
      error: () => {
        this.isLoading = false;
        this.cd.markForCheck();
      }
    });
  }

  get hasData(): boolean {
    return this.data.pending.length > 0 || this.data.returned.length > 0 || this.data.reviewed.length > 0;
  }

  toggle(key: keyof ReviewDashboardData) {
    this.expandedKey = this.expandedKey === key ? null : key;
  }

  selectItem(item: ReviewDashboardItem) {
    this.expandedKey = null;
    this.onselectitem.emit(item);
  }

}
  • Step 3: Write the dashboard component (.html), copying income-budget-review-dashboard.component.html verbatim (no renames needed inside)
<div class="dashboard-card" *ngIf="!isLoading && hasData">
  <div class="chip-row">
    <button type="button" class="chip" [class.active]="expandedKey === 'pending'"
      *ngIf="data.pending.length" (click)="toggle('pending')">
      รอตรวจสอบ <span class="count">{{data.pending.length}}</span>
    </button>
    <button type="button" class="chip chip-warn" [class.active]="expandedKey === 'returned'"
      *ngIf="data.returned.length" (click)="toggle('returned')">
      ถูกส่งแก้ไข <span class="count">{{data.returned.length}}</span>
    </button>
    <button type="button" class="chip chip-success" [class.active]="expandedKey === 'reviewed'"
      *ngIf="data.reviewed.length" (click)="toggle('reviewed')">
      ตรวจสอบแล้ว <span class="count">{{data.reviewed.length}}</span>
    </button>
  </div>

  <div class="item-list" *ngIf="expandedKey">
    <div class="item-row" *ngFor="let item of data[expandedKey]" (click)="selectItem(item)">
      <span class="item-faculty">{{item.faculty_name_th}}</span>
      <span class="item-location">{{item.budget_location_name_th}}</span>
      <span class="item-year">ปีงบ {{item.budget_year_name_th}}</span>
      <span class="item-date">{{item.updated_datetime | thaiDate}}</span>
    </div>
    <div class="item-empty" *ngIf="!data[expandedKey].length">ไม่มีรายการ</div>
  </div>
</div>
  • Step 4: Write the status component (.ts), copying income-budget-review-status.component.ts with names swapped
import { Component, Input, OnChanges, SimpleChanges, ChangeDetectionStrategy, ChangeDetectorRef, EventEmitter, Output } from '@angular/core';
import { catchError, tap } from 'rxjs/operators';
import { throwError } from 'rxjs';
import { RequestBudgetReviewService } from 'src/app/core/service/request-budget-review/request-budget-review.service';
import Swal from 'sweetalert2';

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

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

  isLoading = false;
  status: any = null;

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

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

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

  load() {
    this.status = null;
    if (!this.requestExpenseUid) {
      this.cd.markForCheck();
      return;
    }
    this.isLoading = true;
    this.reviewSV.getStatus(this.requestExpenseUid).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();
  }

  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.requestExpenseUid, 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.requestExpenseUid).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();
        });
      });
    });
  }

}

Note: unlike the copied income-budget-review-status.component.ts, this version adds inputValidator to onReturnForEdit()'s Swal.fire so the reason is mandatory — matching this task's explicit "ระบุรายละเอียดที่ต้องแก้ไข" requirement (the service-layer ReturnForEdit already rejects a blank remark too, so this is defense-in-depth, not the only guard).

  • Step 5: Write the status component (.html), copying income-budget-review-status.component.html verbatim
<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">
    <table class="revision-table">
      <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>
  • Step 6: Declare both components in request-budget.module.ts

Add imports and add both class names to the declarations array, mirroring how request-budget-plan.module.ts declares IncomeBudgetReviewDashboardComponent/IncomeBudgetReviewStatusComponent (confirmed pattern: import { X } from './presenter/.../x.component'; ... declarations: [..., X, Y]).

  • Step 7: Type-check

Run: cd rmutr-web && npx tsc -p tsconfig.app.json --noEmit Expected: no errors.

  • Step 8: Commit
git add "src/app/feature/budget-request/request-budget/presenter/dashboard/request-budget-review-dashboard" \
        "src/app/feature/budget-request/request-budget/presenter/status/request-budget-review-status" \
        src/app/feature/budget-request/request-budget/request-budget.module.ts
git commit -m "feat: add RequestBudgetReviewDashboard/Status components"

Task 11: Wire it all into RequestBudgetContainer — self-service approvers on key=1, review stage on key=3

Files:

  • Modify: rmutr-web/src/app/feature/budget-request/request-budget/container/request-budget/request-budget.container.ts
  • Modify: rmutr-web/src/app/feature/budget-request/request-budget/container/request-budget/request-budget.container.html
  • Modify: rmutr-web/src/app/feature/budget-request/request-budget/presenter/list/request-budget-list/request-budget-list.component.ts
  • Modify: rmutr-web/src/app/feature/budget-request/request-budget/presenter/list/request-budget-list/request-budget-list.component.html

Interfaces:

  • Consumes: RequestExpenseService.newType4/setApprovers4/sendApprove4 (Task 9), shared SetApproversDialogComponent (existing, src/app/shared/components/set-approvers-dialog/set-approvers-dialog.component.ts), <app-request-budget-review-dashboard>/<app-request-budget-review-status> (Task 10).

  • Step 1: Add container state + the "ระบุผู้อนุมัติ"/"ส่งอนุมัติ" flow for keyUrl==1

In request-budget.container.ts, add these fields near the other approve_button-related fields (around line 40):

  request_expense_4: any = null

Add a new MatDialog import and inject it in the constructor (private dialog: MatDialog), then add these methods (place them right after on_resubmit):

  ensureType4Envelope() {
    if (!this.searchInput?.budget_year_uid || !this.searchInput?.faculty_uid || !this.searchInput?.budget_location_uid) {
      return
    }
    this.RequestExpenseSV.newType4({
      budget_year_uid: this.searchInput.budget_year_uid,
      faculty_uid: this.searchInput.faculty_uid,
      budget_location_uid: this.searchInput.budget_location_uid,
    }).pipe(
      tap((x: any) => {
        this.request_expense_4 = x
        this.cdRef.detectChanges()
      }),
    ).subscribe()
  }

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

  onSendApprove4() {
    Swal.fire({
      icon: 'warning',
      title: 'ต้องการส่งอนุมัติหรือไม่',
      showCancelButton: true,
      confirmButtonText: 'ส่งอนุมัติ',
      cancelButtonText: 'ยกเลิก',
    }).then((result) => {
      if (!result.isConfirmed) return
      this.RequestExpenseSV.sendApprove4(this.request_expense_4.request_expense_uid).pipe(
        catchError(err => {
          Swal.fire(err.error?.description || 'เกิดข้อผิดพลาด', '', 'error')
          return throwError(err)
        })
      ).subscribe(() => {
        Swal.fire({ title: 'ส่งอนุมัติสำเร็จ', icon: 'success', confirmButtonText: 'ตกลง' }).then(() => {
          this.ensureType4Envelope()
        })
      })
    })
  }

Add the two new imports at the top of the file:

import { MatDialog } from '@angular/material/dialog';
import { SetApproversDialogComponent } from 'src/app/shared/components/set-approvers-dialog/set-approvers-dialog.component';

In onClickSearch(query), inside the if(this.keyUrl == 1){...} block, right after the existing .subscribe() call for that block, add:

      if (this.keyUrl == 1) {
        this.ensureType4Envelope()
      }
  • Step 2: Surface the "ระบุผู้อนุมัติ"/"ส่งอนุมัติ" buttons + waiting-for-whom message on keyUrl==1, and the review dashboard + status on keyUrl==3

In request-budget.container.html, add near the top (before <app-request-budget-search>, mirroring how request-budget-plan/.../request-budget.container.html places its dashboard):

<app-request-budget-review-dashboard
  *ngIf="keyUrl == 3"
  (onselectitem)="onSelectDashboardItem($event)"
></app-request-budget-review-dashboard>

Add onSelectDashboardItem to request-budget.container.ts (mirrors the existing income-report dashboard-click pattern):

  onSelectDashboardItem(item: any) {
    const query = {
      budget_year_uid: item.budget_year_uid,
      faculty_uid: item.faculty_uid,
      budget_location_uid: item.budget_location_uid,
    }
    this.onClickSearch(query)
  }

After <app-request-budget-search ...> and before <app-request-budget-list ...>, add:

<div *ngIf="keyUrl == 1 && request_expense_4" style="margin: 10px 0;">
  <button mat-raised-button color="primary" *ngIf="!request_expense_4.is_sent"
    (click)="onSetApprovers4()">ระบุผู้อนุมัติ</button>
  <button mat-raised-button color="accent" style="margin-left: 8px;"
    *ngIf="request_expense_4.approve_states?.length && !request_expense_4.is_sent"
    (click)="onSendApprove4()">ส่งอนุมัติ</button>
  <div *ngIf="request_expense_4.is_sent && request_expense_4.is_approve !== true" class="no-data-detail">
    <span class="material-icons" style="font-size:15px;">info</span>
    <span>รอ {{request_expense_4.current_approve_name}} อนุมัติ</span>
  </div>
  <div *ngIf="request_expense_4.is_approve === true" class="no-data-detail">
    <span class="material-icons" style="font-size:15px;">check_circle</span>
    <span>อนุมัติครบทุกลำดับแล้ว</span>
  </div>
</div>

<app-request-budget-review-status
  *ngIf="keyUrl == 3 && approve_button?.request_expense_uid"
  [requestExpenseUid]="approve_button?.request_expense_uid"
  [showActions]="true"
  (onchanged)="onClickSearch(searchInput)"
></app-request-budget-review-status>
  • Step 3: Fix the keyUrl==3 "only fully-approved" filter

In request-budget.container.ts, replace the entire if(this.keyUrl == 3){...} block (the one using state.display_name_th === 'พนักงานทั่วไป') with:

      if(this.keyUrl == 3){
        this.RequestExpenseSV.queryString(`?budget_year_uid=${this.searchInput.budget_year_uid}&faculty_uid=${this.searchInput.faculty_uid}&budget_location_uid=${this.searchInput.budget_location_uid}`).pipe(
          tap((x:any)=>{
            const fullyApproved = (x || []).find((item: any) => item.is_approve === true)
            if (!fullyApproved) {
              this.is_general_staff_approved = false
              this.approve_button = null
              this.requestBudget = []
              this.requestBudgetType2 = []
              this.InvestAssetRequest = []
              this.InvestConstructRequest = []
              this.BudgetProgress = []
              this.BudgetProgressType3 = []
              Swal.fire({
                icon: 'info',
                title: 'ยังไม่พร้อมตรวจสอบ',
                text: 'คำขอนี้ต้องผ่านการอนุมัติครบทุกลำดับก่อนจึงจะแสดงในหน้านี้ได้',
                confirmButtonText: 'ตกลง'
              });
              this.cdRef.detectChanges()
              return;
            }
            this.is_general_staff_approved = true
            this.getApprove([fullyApproved])
          }),
        ).subscribe()
      }

This directly satisfies "ที่เมนูนี้จะต้องดึงข้อมูลที่ผู้อนุมัติลำดับสุดท้ายอนุมัติมาแล้วเท่านั้น ไม่มีสถานะอื่นปะปน" — the only condition checked is the entity's own is_approve === true (set exactly once, by ApproveGetEntity, only when there is no next row_order to advance to), replacing the old fragile approver-name string match.

  • Step 4: Type-check

Run: cd rmutr-web && npx tsc -p tsconfig.app.json --noEmit Expected: no errors. If SetApproversDialogComponent's constructor data shape doesn't match what's passed here, read src/app/shared/components/set-approvers-dialog/set-approvers-dialog.component.ts and adjust the data: { existing } payload to match its actual @Inject(MAT_DIALOG_DATA) shape exactly (confirmed during research to seed 2 fixed rows from existing[0]/existing[1] — the payload key might be data.existing or something else; verify against the live file, don't guess).

  • Step 5: Manual browser verification

Run the app locally (npm start), then:

  1. Navigate to /app/request-budget, pick a budget year + faculty + location that has no prior type_id=4 envelope.
  2. Confirm a "ระบุผู้อนุมัติ" button appears; click it, pick 1-2 approvers, save — confirm success toast and that "ส่งอนุมัติ" now appears.
  3. Click "ส่งอนุมัติ" — confirm the "รอ [ชื่อ] อนุมัติ" message appears.
  4. Log in as that approver (or use their token), call approve() on the item — confirm the item appears at /app/request-budget-plan-01 with the "ตรวจสอบแล้ว"/"ส่งแก้ไข" buttons visible and the dashboard chip count incrementing.
  5. Click "ส่งแก้ไข", try submitting with an empty reason — confirm it's blocked; fill in a reason — confirm success, and that the item disappears from request-budget-plan-01's "only fully-approved" view (since is_approve flips back to false).
  6. Click "ตรวจสอบแล้ว" on a pending item — confirm it moves to the "ตรวจสอบแล้ว" dashboard bucket.
  • Step 6: Commit
git add src/app/feature/budget-request/request-budget/container/request-budget/request-budget.container.ts \
        src/app/feature/budget-request/request-budget/container/request-budget/request-budget.container.html \
        src/app/feature/budget-request/request-budget/presenter/list/request-budget-list/request-budget-list.component.ts \
        src/app/feature/budget-request/request-budget/presenter/list/request-budget-list/request-budget-list.component.html
git commit -m "feat: wire self-service approvers (key=1) + review stage (key=3) into request-budget pages"

Explicitly out of scope (YAGNI, matching the income_budget_review precedent's own scope cuts)

  • Real-time/email/LINE notifications — dashboard loads on page entry, same as income_budget_review.
  • Fine-grained reviewer permissions beyond "can access this menu" — matches the existing income_budget_review scope cut exactly.
  • Editing the underlying budget numbers from request-budget-plan-01 — this plan only adds status display + status-changing buttons, not a data-editing UI.
  • Migrating or touching the existing type_id=1/code "03" admin-config approval path — left completely untouched; historical records using it are unaffected.