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

1401 lines
69 KiB
Markdown

# คำขอแผ่นดิน (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**
```sql
-- 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**
```bash
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**
```csharp
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**
```bash
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`**
```csharp
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:
```csharp
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**
```bash
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)`.
```csharp
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using rmutr_budget_api.Modules.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**
```bash
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**
```csharp
using System;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using rmutr_budget_api.Modules.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`**
```csharp
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):
```csharp
services.AddRequestBudgetReviewServices();
```
- [ ] **Step 4: Build and smoke-test the new endpoints**
Run: `cd rmutr-api && dotnet build`
Expected: succeeds.
Run the API locally, then:
```bash
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**
```bash
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`**
```csharp
/// <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**
```csharp
/// <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**
```bash
# 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**
```bash
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:
```csharp
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):
```csharp
if (getResult.row_order == 1
&& (getResult.request_approve_type == "04" || getResult.request_approve_type == "05"))
```
Replace with:
```csharp
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):
```csharp
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):
```csharp
// เชนอนุมัติของคำขอแผ่นดินผ่านครบ (ทั้งทาง 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**
```bash
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**
```bash
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**
```typescript
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:
```typescript
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**
```bash
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:
```bash
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**
```typescript
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)**
```html
<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**
```typescript
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**
```html
<div class="review-status-card" *ngIf="status">
<div class="status-row">
<span class="status-label">สถานะการตรวจสอบ:</span>
<span class="status-badge" [ngClass]="'status-' + status.current_status">
{{statusLabel[status.current_status]}}
</span>
</div>
<div class="revision-table-wrapper" *ngIf="status.history?.length">
<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**
```bash
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):
```typescript
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`):
```typescript
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:
```typescript
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:
```typescript
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):
```html
<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):
```typescript
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:
```html
<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:
```typescript
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**
```bash
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.