# คำขอแผ่นดิน (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; } /// 1 = รอตรวจสอบ, 2 = ส่งแก้ไข, 3 = ตรวจสอบแล้ว — ดูรายละเอียดที่ /// docs/superpowers/plans/2026-09-08-request-budget-approve-workflow.md 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`) 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 { get; set; } private void OnRequestBudgetReviewModelCreating(ModelBuilder builder, string schema) { builder.Entity().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 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 pending { get; set; } = new(); public List returned { get; set; } = new(); public List 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 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 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> 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> GetDashboard() { return Ok(await _service.GetDashboard()); } [Authorize] [HttpPost("return_for_edit/{request_expense_uid:guid}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task 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 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(); 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 " ``` 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, 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 /// /// สร้างชุดอนุมัติของคำขอแผ่นดิน (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) /// public async Task 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() { "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 /// /// สร้าง/ดึงชุดอนุมัติของคำขอแผ่นดิน (type_id = 4) 1 ใบต่อ ปีงบ+คณะ+พื้นที่ — idempotent /// [Authorize] [HttpPost("type_4")] [ProducesResponseType(StatusCodes.Status201Created)] public async Task> NewEntityUid4([FromBody] t_request_expense entity) { var result = await _entityUidService.NewEntityType4(entity, User.Identity as ClaimsIdentity); return Created("", result); } /// กำหนดผู้อนุมัติเองสำหรับคำขอแผ่นดิน — ตรรกะเดียวกับ type_2/type_3 ทุกอย่าง ต่างแค่ code เป็น "08" [Authorize] [HttpPost("type_4/set_approvers/{uid:guid}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task SetApprovers4([FromRoute] Guid uid, [FromBody] List approvers) { var result = await _entityUidService.SetCustomApprovers(uid, approvers, User.Identity as ClaimsIdentity, "08"); if (!result.ok) { return BadRequest(new { description = result.error }); } return Ok(); } /// ส่งอนุมัติลำดับ 1 ของคำขอแผ่นดิน — ใครก็ได้กดได้ แค่ตั้ง is_sent=true เหมือน type_2/type_3 [Authorize] [HttpPost("type_4/send_approve/{uid:guid}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task 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 " -H "Content-Type: application/json" \ -d '{"budget_year_uid":"","faculty_uid":"","budget_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/ \ -H "Authorization: Bearer " -H "Content-Type: application/json" \ -d '[{"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/ \ -H "Authorization: Bearer " # 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/ \ -H "Authorization: Bearer " # 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 " # 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: `` 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 }`; `` — 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(); 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
{{item.faculty_name_th}} {{item.budget_location_name_th}} ปีงบ {{item.budget_year_name_th}} {{item.updated_datetime | thaiDate}}
ไม่มีรายการ
``` - [ ] **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(); 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
สถานะการตรวจสอบ: {{statusLabel[status.current_status]}}
วันที่ รายละเอียด ผู้บันทึก
{{h.datetime | thaiDate}} {{h.detail}} {{h.by}}
``` - [ ] **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`), ``/`` (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 ``, mirroring how `request-budget-plan/.../request-budget.container.html` places its dashboard): ```html ``` 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 `` and before ``, add: ```html
info รอ {{request_expense_4.current_approve_name}} อนุมัติ
check_circle อนุมัติครบทุกลำดับแล้ว
``` - [ ] **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.