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
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_typecodes"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-datakey→this.keyUrlthroughoutrequest-budget.container.tsand its children. This is a different module fromfeature/budget-request/request-budget-plan/(which backsincome-budget-plan*and already hosts the shippedIncomeBudgetReviewUI — useful as a live reference, not a file to edit).- The real approval engine behind
/app/request-budgettoday ist_request_expense(type_id=1), nott_request_budget—t_request_budget's ownapprove_statewiring (code"01", viareference_uid) is dead/orphaned (created inRequestBudgetService.NewEntity(), never read back anywhere). - Today,
type_id=1request_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 thattype_id=1/code"03"path — it is left completely untouched. Instead this plan adds a new, separatetype_id=4self-service envelope that/app/request-budgetswitches to using going forward. Any historicaltype_id=1records are unaffected and simply won't appear in the new review dashboard (which filters ontype_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 checkingrequest_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/(mirrorsModules/IncomeBudgetReview/exactly). - New DB table
request_budget_review— create via a raw idempotent.sqlmigration file (this repo's established pattern for new tables added outside the EF Core scaffold flow — seeMigrations/add_income_budget_review.sql), notdotnet ef migrations add. - New request_approve_type code is the literal string
"08". New request_expense type_id is the literal int4. Do not reuse"01"-"07"ortype_id1/2/3. - Every new C# file must build: verify with
dotnet buildfromrmutr-api/after each backend task. - Every new/changed TS file must type-check: verify with
npx tsc -p tsconfig.app.json --noEmitfromrmutr-web/after each frontend task. - Reuse existing shared components/services wherever the codebase already has one for this exact shape (
SetApproversDialogComponent,SweetalertService,thaiDatepipe) — do not create new ones. - Do not modify any
invest_asset/invest_construct/existingrequest_expensetype_id 1/2/3 behavior — every change to shared files (ApproveState.cs,RequestExpenseController.cs,RequestExpenseService.cs) must be strictly additive (newifbranches, 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_reviewwith columnsrequest_budget_review_uid uuid PK,request_expense_uid uuid,review_status_id smallint,remark text, plusbase_tablecolumns (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
.sqlmigrations underMigrations/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_reviewclass (used by Task 3'sDb.csregistration and Task 4's service). -
Step 1: Write the model, copying
income_budget_review.csexactly 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 callsOnIncomeBudgetReviewModelCreating(builder,schema);)
Interfaces:
-
Consumes:
t_request_budget_reviewfrom Task 2. -
Produces:
_context.t_request_budget_review(aDbSet<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:
RequestBudgetReviewServicewith public methodsGetStatus(Guid),GetDashboard(),ReturnForEdit(Guid, string, ClaimsIdentity),MarkReviewed(Guid, ClaimsIdentity)— same signatures asIncomeBudgetReviewService, 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_idfield 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 legacytype_id == 1records 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).
- Namespace/class names
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 existingtype_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 afterNewEntityType3, i.e. right after line ~154 — confirm via grep below)
Interfaces:
-
Consumes: existing generic
SetCustomApprovers(Guid?, List<ApproverInput>, ClaimsIdentity, string approveTypeCode = "04")andSendApprove2(Guid?, ClaimsIdentity)onRequestExpenseService— 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
NewEntityType4toRequestExpenseService.cs, right afterNewEntityType3
/// <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 existingSendApprove3action
/// <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_reviewtrigger does (keepsApproveStateControllerfree of a new service dependency, matching the existing pattern precisely). -
Produces: code
"08"now gets the same row_order==1is_sent+identity gate as"04"/"05"; chain completion for codes"03"or"08"auto-inserts arequest_budget_reviewrow withreview_status_id=1. -
Step 1: Add
usingfor 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_reviewblock (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 thatqueryString(...)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==trueserver-side becomes possible without the frontend needing to guess. Actually the simplest, lowest-risk fix belongs on the frontend (Task 11) — the existingGET api/request/request_expense?budget_year_uid=...&faculty_uid=...&budget_location_uid=...endpoint already returns fullapprove_states/is_approveon eachv_request_expenserow (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 onRequestExpenseService(newType4,setApprovers4,sendApprove4—approve/rejectare already generic and reused as-is). -
Step 1: Create the Angular service, copying
income-budget-review.service.tsexactly 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)="...">emittingReviewDashboardItem { 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
.scssfiles 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), copyingincome-budget-review-dashboard.component.tswith 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), copyingincome-budget-review-dashboard.component.htmlverbatim (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), copyingincome-budget-review-status.component.tswith 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), copyingincome-budget-review-status.component.htmlverbatim
<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), sharedSetApproversDialogComponent(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 onkeyUrl==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:
- Navigate to
/app/request-budget, pick a budget year + faculty + location that has no priortype_id=4envelope. - Confirm a "ระบุผู้อนุมัติ" button appears; click it, pick 1-2 approvers, save — confirm success toast and that "ส่งอนุมัติ" now appears.
- Click "ส่งอนุมัติ" — confirm the "รอ [ชื่อ] อนุมัติ" message appears.
- Log in as that approver (or use their token), call
approve()on the item — confirm the item appears at/app/request-budget-plan-01with the "ตรวจสอบแล้ว"/"ส่งแก้ไข" buttons visible and the dashboard chip count incrementing. - 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 (sinceis_approveflips back tofalse). - 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_reviewscope 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.