Files
rmutr/docs/superpowers/plans/2026-08-05-income-budget-report-native-group-a.md
T
Nut.ไปเรื่อย 22c3c84f55 docs: add implementation plan for income budget report Group A (type 1-3)
Bite-sized task breakdown implementing the design in
docs/superpowers/specs/2026-08-05-income-budget-report-native-design.md,
scoped to Group A only (income-budget-report-income1, type_id 1) per the
writing-plans skill's guidance to split independently-testable subsystems
into separate plans. Group B (type 4-6) and Group C (type 10) are explicitly
out of scope, tracked as follow-up plans.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011R9j5gaw2qsoh9ypWQeXxi
2026-08-05 11:57:59 +07:00

1512 lines
63 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Income Budget Report — Group A (type 1-3) 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:** Replace the SyncFusion spreadsheet on `/app/income-budget-report-income1/2/3` with a native Angular tree component backed by a real data model, sourcing personnel line items read-only from ร.2 (`personnel_statement`) instead of free-edit Excel cells.
**Architecture:** Backend owns the report tree schema (a static C# structure per `type_id`) and computes the fully-merged tree (labels + amounts + lock flags) server-side on every GET; the frontend is a single generic recursive component that renders whatever tree JSON it receives, with no per-type knowledge of its own. New tables `income_budget_report` / `income_budget_report_line` replace the `.xlsx`-blob storage (`revenue_draft_committee`) for this feature; `.xlsx` is generated on demand (EPPlus) only when the user clicks "export".
**Tech Stack:** .NET 8 / EF Core 8 / Npgsql (backend), Angular (frontend), PostgreSQL, EPPlus 6.2.4 for export.
## Global Constraints
- Backend module lives at `rmutr-api/Modules/IncomeBudgetReport/` (new module, mirrors `Modules/Setting/` structure).
- Schema/table naming, `base_table` inheritance, `t_`/`v_` entity split, and `BaseUidController<T,TV>` usage follow the exact pattern already used for `announcement`/`announcement_faculty` (added this session — see `rmutr-api/Modules/Setting/Databases/Models/announcement.cs`).
- DB migrations are **manual SQL files** in `rmutr-api/Migrations/*.sql`, applied directly to the production Postgres instance (`rmutr_budget_050625` at `203.158.221.28:31620`) via `psql``dotnet ef migrations add` is **not** used in this codebase; the EF migration history has pre-existing unrelated drift that makes it unsafe (confirmed this session — do not attempt `dotnet ef migrations add` for this feature).
- When adding an EF relationship between two entities that both have `t_`/`v_` variants, configure `HasOne/WithMany/HasForeignKey` on **both** the `t_` and `v_` sides in `Db.cs` — omitting the `v_` side produces a broken shadow FK property and a 500 at runtime (root-caused and fixed for `announcement` this session; do not repeat the mistake).
- Frontend service files follow `rmutr-web/src/app/core/service/**` + extend `BaseService` (`rmutr-web/src/app/core/base/base-service.ts`) — full URL is always `environment.rmutrApi + '/api' + endpoint`.
- No automated test framework exists in either repo. Every task's verification step is a concrete manual check (`dotnet build`, `psql` query, `curl`, or browser check) — run it and read the actual output before moving on, per this session's established practice.
- All monetary fields end in `_amount` or `_price` so the project-wide `OnModelCreating` convention (`rmutr-api/Databases/Features/Db.cs:91-99`) automatically applies `numeric(18,4)` — do not set precision manually.
---
## File Structure
**Backend — new module `Modules/IncomeBudgetReport/`:**
```
Databases/Models/income_budget_report/income_budget_report.cs entity: income_budget_report, t_/v_
Databases/Models/income_budget_report/income_budget_report_line.cs entity: income_budget_report_line, t_/v_
Databases/Db.cs DbSets + OnIncomeBudgetReportModelCreating
Config/IncomeReportTreeSchema.cs static tree definition for type_id 1/2/3
Services/IncomeBudgetReportService.cs CreateDraft, CloneNextRound, BuildTree, UpdateLineAmounts
Controllers/IncomeBudgetReportController.cs BaseUidController + custom endpoints
```
**Backend — modified files:**
```
Databases/Db.cs (central registry) add OnIncomeBudgetReportModelCreating(builder, schema) call
Migrations/add_income_budget_report_tables.sql new manual migration
```
**Frontend — new files:**
```
src/app/shared/components/budget-report-tree/budget-report-tree-node.component.ts
src/app/shared/components/budget-report-tree/budget-report-tree-node.component.html
src/app/shared/components/budget-report-tree/budget-report-tree-node.component.scss
src/app/shared/components/budget-report-tree/budget-report-tree.module.ts
src/app/core/service/setting/income-budget-report.service.ts
```
**Frontend — modified files:**
```
src/app/feature/income/draft/income-budget-report1/income-budget-report1/income-budget-report.component.ts rewrite
src/app/feature/income/draft/income-budget-report1/income-budget-report1/income-budget-report.component.html rewrite
src/app/feature/income/draft/income-budget-report1/income-budget-report.module.ts import BudgetReportTreeModule
```
---
## Interfaces (the contract every task below must match exactly)
**Tree JSON returned by `GET /api/setting/income_budget_report/{uid}/tree`:**
```ts
interface ReportTreeNode {
nodeKey: string; // stable id, e.g. "pb-temp-old"
label: string;
depth: number; // 0-based, drives indentation
kind: 'group' | 'leaf-amount' | 'leaf-table';
amount: number; // computed sum for 'group', the value itself for 'leaf-amount'/'leaf-table' total
locked: boolean; // true when sourced from ร.2 (source_uid != null) — label/qty read-only
lineUid: string | null; // income_budget_report_line_uid, set for 'leaf-amount' nodes only
children: ReportTreeNode[]; // populated for kind='group'
rows: ReportTreeTableRow[]; // populated for kind='leaf-table'
}
interface ReportTreeTableRow {
lineUid: string;
label: string;
qualification: string | null; // ร.2 "วุฒิ" — null for non-personnel rows
qty: number | null; // "อัตรา" — null when not applicable
amount: number;
locked: boolean;
}
```
**C# equivalent (`IncomeBudgetReportRollupService.BuildTree` return type) uses the same shape** — see Task 4/6.
---
### Task 1: Backend entity models
**Files:**
- Create: `rmutr-api/Modules/IncomeBudgetReport/Databases/Models/income_budget_report/income_budget_report.cs`
- Create: `rmutr-api/Modules/IncomeBudgetReport/Databases/Models/income_budget_report/income_budget_report_line.cs`
**Interfaces:**
- Produces: `income_budget_report`, `t_income_budget_report`, `v_income_budget_report`, `income_budget_report_line`, `t_income_budget_report_line`, `v_income_budget_report_line` classes used by every later backend task.
- [ ] **Step 1: Write `income_budget_report.cs`**
```csharp
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using SeventyOneDev.Utilities;
using SeventyOneDev.Utilities.Attributes;
namespace rmutr_budget_api.Modules.IncomeBudgetReport.Databases.Models
{
public class income_budget_report : base_table
{
[Key] public Guid? income_budget_report_uid { get; set; }
public int? type_id { get; set; }
public Guid? budget_year_uid { get; set; }
public Guid? faculty_uid { get; set; }
public Guid? budget_location_uid { get; set; }
[Search]
public string sector_name_th { get; set; }
public Guid? parent_uid { get; set; }
}
public class t_income_budget_report : income_budget_report
{
[Include]
public List<t_income_budget_report_line> income_budget_report_lines { get; set; }
}
public class v_income_budget_report : income_budget_report
{
[Include]
public List<v_income_budget_report_line> income_budget_report_lines { get; set; }
}
}
```
- [ ] **Step 2: Write `income_budget_report_line.cs`**
```csharp
using System;
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using SeventyOneDev.Utilities;
using SeventyOneDev.Utilities.Attributes;
namespace rmutr_budget_api.Modules.IncomeBudgetReport.Databases.Models
{
public class income_budget_report_line : base_table
{
[Key] public Guid? income_budget_report_line_uid { get; set; }
public Guid? income_budget_report_uid { get; set; }
[MaxLength(100)]
public string node_key { get; set; }
[MaxLength(50)]
public string source_type { get; set; } // "personnel_detail" | "personnel_detail_2" | "manual"
public Guid? source_uid { get; set; }
public string label_th { get; set; }
public string qualification_th { get; set; }
public decimal? qty { get; set; }
public decimal? amount { get; set; }
public decimal? original_amount { get; set; }
public int? sequence_no { get; set; }
}
public class t_income_budget_report_line : income_budget_report_line
{
[JsonIgnore]
public t_income_budget_report income_budget_report { get; set; }
}
public class v_income_budget_report_line : income_budget_report_line
{
[JsonIgnore]
public v_income_budget_report income_budget_report { get; set; }
}
}
```
- [ ] **Step 3: Verify — no build target yet, just confirm files exist and namespaces match the folder they're in**
```bash
ls rmutr-api/Modules/IncomeBudgetReport/Databases/Models/income_budget_report/
```
Expected: both files listed. (The project won't compile until Task 2 wires up `Db.cs` — that's expected, don't try to build yet.)
- [ ] **Step 4: Commit**
```bash
cd rmutr-api
git add Modules/IncomeBudgetReport/Databases/Models/income_budget_report/
git commit -m "feat: add income_budget_report and income_budget_report_line entity models"
```
---
### Task 2: Backend Db.cs registration
**Files:**
- Create: `rmutr-api/Modules/IncomeBudgetReport/Databases/Db.cs`
- Modify: `rmutr-api/Databases/Db.cs` (central registry, currently ends at line 33 `}`)
**Interfaces:**
- Consumes: entity classes from Task 1.
- Produces: `_context.t_income_budget_report`, `_context.v_income_budget_report`, `_context.t_income_budget_report_line`, `_context.v_income_budget_report_line` DbSets usable by every later backend task.
- [ ] **Step 1: Write `Modules/IncomeBudgetReport/Databases/Db.cs`**
```csharp
using Microsoft.EntityFrameworkCore;
using rmutr_budget_api.Modules.IncomeBudgetReport.Databases.Models;
namespace SeventyOneDev.Utilities
{
public partial class Db : DbContext
{
public DbSet<t_income_budget_report> t_income_budget_report { get; set; }
public DbSet<v_income_budget_report> v_income_budget_report { get; set; }
public DbSet<t_income_budget_report_line> t_income_budget_report_line { get; set; }
public DbSet<v_income_budget_report_line> v_income_budget_report_line { get; set; }
private void OnIncomeBudgetReportModelCreating(ModelBuilder builder, string schema)
{
builder.Entity<t_income_budget_report>().ToTable("income_budget_report", schema);
builder.Entity<v_income_budget_report>().ToView("v_income_budget_report", schema);
builder.Entity<t_income_budget_report_line>().ToTable("income_budget_report_line", schema);
builder.Entity<v_income_budget_report_line>().ToView("v_income_budget_report_line", schema);
builder.Entity<t_income_budget_report_line>().HasOne(p => p.income_budget_report)
.WithMany(p => p.income_budget_report_lines)
.HasForeignKey(p => p.income_budget_report_uid)
.OnDelete(DeleteBehavior.Cascade);
builder.Entity<v_income_budget_report_line>().HasOne(p => p.income_budget_report)
.WithMany(p => p.income_budget_report_lines)
.HasForeignKey(p => p.income_budget_report_uid)
.OnDelete(DeleteBehavior.Cascade);
}
}
}
```
- [ ] **Step 2: Register the call in the central registry**
Read `rmutr-api/Databases/Db.cs` first (it's short, ~34 lines). Add the new call as the last line inside `OnApplicationModelCreating`, right after `OnPlanResultModelCreating(builder,schema);`:
```csharp
OnPlanResultModelCreating(builder,schema);
OnIncomeBudgetReportModelCreating(builder,schema);
```
- [ ] **Step 3: Build and verify it compiles**
```bash
cd rmutr-api
dotnet build rmutr-budget-api.csproj -c Debug 2>&1 | tail -20
```
Expected: `0 Error(s)`. (Run in background if it exceeds ~2 minutes — it's a large solution, this is normal.)
- [ ] **Step 4: Commit**
```bash
git add Modules/IncomeBudgetReport/Databases/Db.cs Databases/Db.cs
git commit -m "feat: register income_budget_report entities with EF Db context"
```
---
### Task 3: Manual SQL migration
**Files:**
- Create: `rmutr-api/Migrations/add_income_budget_report_tables.sql`
**Interfaces:**
- Consumes: table/column names from Task 1.
- Produces: physical `"BUDGET".income_budget_report`, `"BUDGET".income_budget_report_line` tables and matching views, required by every later task that touches the database.
- [ ] **Step 1: Write the migration file**
```sql
-- Migration: Create income_budget_report + income_budget_report_line tables (Group A, type 1-3).
-- Date: 2026-08-05
-- Description: Backs the native replacement for the SyncFusion income-budget-report page.
-- Replaces the .xlsx-blob storage (revenue_draft_committee) for type_id 1-3 with
-- structured rows. See docs/superpowers/specs/2026-08-05-income-budget-report-native-design.md
-- Idempotent: safe to run again.
CREATE TABLE IF NOT EXISTS "BUDGET".income_budget_report (
income_budget_report_uid uuid PRIMARY KEY,
status_id smallint,
created_by character varying(200),
created_datetime timestamptz,
updated_by character varying(200),
updated_datetime timestamptz,
owner_agency_uid uuid,
type_id integer,
budget_year_uid uuid,
faculty_uid uuid,
budget_location_uid uuid,
sector_name_th text,
parent_uid uuid REFERENCES "BUDGET".income_budget_report (income_budget_report_uid)
);
CREATE TABLE IF NOT EXISTS "BUDGET".income_budget_report_line (
income_budget_report_line_uid uuid PRIMARY KEY,
status_id smallint,
created_by character varying(200),
created_datetime timestamptz,
updated_by character varying(200),
updated_datetime timestamptz,
owner_agency_uid uuid,
income_budget_report_uid uuid REFERENCES "BUDGET".income_budget_report (income_budget_report_uid) ON DELETE CASCADE,
node_key character varying(100),
source_type character varying(50),
source_uid uuid,
label_th text,
qualification_th text,
qty numeric(18,4),
amount numeric(18,4),
original_amount numeric(18,4),
sequence_no integer
);
CREATE INDEX IF NOT EXISTS "IX_income_budget_report_line_income_budget_report_uid"
ON "BUDGET".income_budget_report_line (income_budget_report_uid);
CREATE INDEX IF NOT EXISTS "IX_income_budget_report_parent_uid"
ON "BUDGET".income_budget_report (parent_uid);
CREATE OR REPLACE VIEW "BUDGET".v_income_budget_report AS
SELECT * FROM "BUDGET".income_budget_report;
CREATE OR REPLACE VIEW "BUDGET".v_income_budget_report_line AS
SELECT * FROM "BUDGET".income_budget_report_line;
```
- [ ] **Step 2: Apply to the production database and verify**
Confirm VPN/DB connectivity first (this session repeatedly needed to re-check this):
```bash
nc -zv -w 6 203.158.221.28 31620
```
Expected: `succeeded`. If it times out, ask the user to check their VPN before continuing.
Apply:
```bash
export PGPASSWORD='vtwi,yo0tpkd-okfouh'
/opt/homebrew/Cellar/postgresql@16/16.14/bin/psql -h 203.158.221.28 -p 31620 -U devadmin -d rmutr_budget_050625 \
-v ON_ERROR_STOP=1 -f rmutr-api/Migrations/add_income_budget_report_tables.sql
```
Expected output: `CREATE TABLE` ×2, `CREATE INDEX` ×2, `CREATE VIEW` ×2, no errors.
Verify columns exist:
```bash
/opt/homebrew/Cellar/postgresql@16/16.14/bin/psql -h 203.158.221.28 -p 31620 -U devadmin -d rmutr_budget_050625 \
-c "SELECT table_name, column_name FROM information_schema.columns WHERE table_schema='BUDGET' AND table_name IN ('income_budget_report','income_budget_report_line') ORDER BY table_name, ordinal_position;"
```
Expected: every field from Task 1's entities listed.
- [ ] **Step 3: Commit**
```bash
cd rmutr-api
git add Migrations/add_income_budget_report_tables.sql
git commit -m "feat: add income_budget_report tables migration"
```
---
### Task 4: Tree schema config (backend)
**Files:**
- Create: `rmutr-api/Modules/IncomeBudgetReport/Config/IncomeReportTreeSchema.cs`
**Interfaces:**
- Produces: `ReportTreeNodeDef` class and `IncomeReportTreeSchema.ForType1()` static method returning the type-1/2/3 tree definition, consumed by Task 5 (CreateDraft, to assign `node_key` per source row) and Task 6 (BuildTree rollup).
This schema is transcribed directly from the real production file inspected this session (`type1.xlsx`, sheet "Page1", 153 rows) — see the spec's Data Model section for the source verification.
- [ ] **Step 1: Write the schema definition**
```csharp
using System.Collections.Generic;
namespace rmutr_budget_api.Modules.IncomeBudgetReport.Config
{
public class ReportTreeNodeDef
{
public string NodeKey { get; set; }
public string Label { get; set; }
public string Kind { get; set; } // "group" | "leaf-amount" | "leaf-table"
public string SourceType { get; set; } // null for group/manual nodes
public List<ReportTreeNodeDef> Children { get; set; } = new();
public static ReportTreeNodeDef Group(string key, string label, params ReportTreeNodeDef[] children)
=> new ReportTreeNodeDef { NodeKey = key, Label = label, Kind = "group", Children = new List<ReportTreeNodeDef>(children) };
public static ReportTreeNodeDef LeafAmount(string key, string label)
=> new ReportTreeNodeDef { NodeKey = key, Label = label, Kind = "leaf-amount" };
public static ReportTreeNodeDef LeafTable(string key, string label, string sourceType)
=> new ReportTreeNodeDef { NodeKey = key, Label = label, Kind = "leaf-table", SourceType = sourceType };
}
public static class IncomeReportTreeSchema
{
// Verified against the real type-1 production file (Page1, 153 rows) downloaded and
// inspected 2026-08-05. Plan/output labels are the exact wording found in that file.
public static ReportTreeNodeDef ForType1()
{
return ReportTreeNodeDef.Group("root", "รายการบุคลากร",
ReportTreeNodeDef.Group("pb", "งบบุคลากร",
ReportTreeNodeDef.Group("pb-temp", "ค่าจ้างชั่วคราว",
ReportTreeNodeDef.LeafTable("pb-temp-old", "อัตราเดิม", "personnel_detail"),
ReportTreeNodeDef.LeafTable("pb-temp-new", "อัตราใหม่", "personnel_detail_2")
),
ReportTreeNodeDef.Group("pb-cola", "เงินเพิ่มค่าครองชีพชั่วคราว",
ReportTreeNodeDef.LeafAmount("pb-cola-old", "อัตราเดิม"),
ReportTreeNodeDef.LeafAmount("pb-cola-new", "อัตราใหม่")
)
),
ReportTreeNodeDef.Group("op", "งบดำเนินงาน",
ReportTreeNodeDef.LeafAmount("op-comp", "ค่าตอบแทน ค่าใช้สอยและค่าวัสดุ"),
ReportTreeNodeDef.Group("plan1", "แผนงานยุทธศาสตร์พัฒนาศักยภาพคนตลอดช่วงชีวิต",
BuildOutput("out1", "ผลผลิต ผู้สำเร็จการศึกษาด้านวิทยาศาสตร์และเทคโนโลยี"),
BuildOutput("out2", "ผลผลิต ผู้สำเร็จการศึกษาด้านสังคมศาสตร์")
),
ReportTreeNodeDef.Group("plan2", "แผนงานพื้นฐานด้านการพัฒนาและเสริมสร้างศักยภาพทรัพยากรมนุษย์",
BuildOutput("out3", "ผลผลิต ผลงานการให้บริการวิชาการ"),
BuildOutput("out4", "ผลผลิต ผลงานทำนุบำรุงศิลปวัฒนธรรม")
),
ReportTreeNodeDef.Group("plan3", "แผนงานยุทธศาสตร์การวิจัยและพัฒนานวัตกรรม",
BuildOutput("out5", "ผลผลิต ผลงานวิจัยเพื่อสร้างองค์ความรู้")
)
)
);
}
private static ReportTreeNodeDef BuildOutput(string prefix, string label)
{
return ReportTreeNodeDef.Group(prefix, label,
ReportTreeNodeDef.Group($"{prefix}-grant", "งบเงินอุดหนุน",
ReportTreeNodeDef.LeafAmount($"{prefix}-grant-proj", "เงินอุดหนุนค่าใช้จ่ายโครงการ")
),
ReportTreeNodeDef.Group($"{prefix}-invest", "งบลงทุน",
ReportTreeNodeDef.LeafTable($"{prefix}-invest-equip", "ค่าครุภัณฑ์", "invest_asset"),
ReportTreeNodeDef.LeafTable($"{prefix}-invest-land", "ค่าที่ดินและสิ่งก่อสร้าง", "invest_construct")
),
ReportTreeNodeDef.LeafAmount($"{prefix}-other", "งบรายจ่ายอื่นๆ")
);
}
}
}
```
- [ ] **Step 2: Build and verify it compiles**
```bash
cd rmutr-api
dotnet build rmutr-budget-api.csproj -c Debug 2>&1 | tail -20
```
Expected: `0 Error(s)`.
- [ ] **Step 3: Commit**
```bash
git add Modules/IncomeBudgetReport/Config/
git commit -m "feat: add type-1 income budget report tree schema"
```
---
### Task 5: IncomeBudgetReportService — CreateDraft
**Files:**
- Create: `rmutr-api/Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs`
**Interfaces:**
- Consumes: `ReportTreeNodeDef`/`IncomeReportTreeSchema.ForType1()` (Task 4), `t_personnel_statement`/`personnel_statement_detail`/`personnel_statement_detail_2` (existing, `rmutr-api/Modules/ReportSalary/Databases/Models/`).
- Produces: `IncomeBudgetReportService.CreateDraft(Guid facultyUid, Guid budgetYearUid, ClaimsIdentity identity) : Task<v_income_budget_report>`, consumed by Task 8 (controller).
- [ ] **Step 1: Write the service (CreateDraft only — BuildTree and CloneNextRound come in Tasks 6/7)**
```csharp
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using SeventyOneDev.Utilities;
using rmutr_budget_api.Modules.IncomeBudgetReport.Config;
using rmutr_budget_api.Modules.IncomeBudgetReport.Databases.Models;
using rmutr_budget_api.Modules.ReportSalary.Models;
namespace rmutr_budget_api.Modules.IncomeBudgetReport.Services
{
public class IncomeBudgetReportService : EntityUidService<t_income_budget_report, v_income_budget_report>
{
private readonly Db _context;
public IncomeBudgetReportService(Db context) : base(context)
{
_context = context;
}
public override async Task<v_income_budget_report> GetEntity(Guid uid, List<Guid?> agencies = null, string userName = null)
{
return await _context.v_income_budget_report
.Include(c => c.income_budget_report_lines)
.AsNoTracking()
.FirstOrDefaultAsync(c => c.income_budget_report_uid == uid);
}
public async Task<v_income_budget_report> CreateDraft(Guid facultyUid, Guid budgetYearUid, ClaimsIdentity identity)
{
var statement = await _context.t_personnel_statement
.Include(c => c.personnel_statement_details)
.Include(c => c.personnel_statement_details_2)
.FirstOrDefaultAsync(c => c.faculty_uid == facultyUid && c.budget_year_uid == budgetYearUid);
var reportUid = Guid.NewGuid();
var report = new t_income_budget_report
{
income_budget_report_uid = reportUid,
type_id = 1,
budget_year_uid = budgetYearUid,
faculty_uid = facultyUid,
status_id = 0,
};
var lines = new List<t_income_budget_report_line>();
int seq = 0;
if (statement != null)
{
foreach (var d in statement.personnel_statement_details ?? new List<personnel_statement_detail>())
{
var amount = (d.salary_rate ?? 0) * 12;
lines.Add(new t_income_budget_report_line
{
income_budget_report_line_uid = Guid.NewGuid(),
income_budget_report_uid = reportUid,
node_key = "pb-temp-old",
source_type = "personnel_detail",
source_uid = d.personnel_statement_detail_uid,
label_th = d.full_name,
qualification_th = d.qualification,
qty = 1,
amount = amount,
original_amount = amount,
sequence_no = seq++,
});
}
foreach (var d in statement.personnel_statement_details_2 ?? new List<personnel_statement_detail_2>())
{
var amount = (d.salary_rate ?? 0) * 12;
lines.Add(new t_income_budget_report_line
{
income_budget_report_line_uid = Guid.NewGuid(),
income_budget_report_uid = reportUid,
node_key = "pb-temp-new",
source_type = "personnel_detail_2",
source_uid = d.personnel_statement_detail_2_uid,
label_th = d.full_name,
qualification_th = d.qualification,
qty = 1,
amount = amount,
original_amount = amount,
sequence_no = seq++,
});
}
}
await _context.t_income_budget_report.AddAsync(report);
await _context.t_income_budget_report_line.AddRangeAsync(lines);
await _context.SaveChangesAsync(identity);
return await GetEntity(reportUid);
}
}
}
```
**Note for the implementer:** confirm the exact property names on `personnel_statement_detail`/`personnel_statement_detail_2` (`full_name`, `qualification`, `salary_rate`, `personnel_statement_detail_uid`) by reading `rmutr-api/Modules/ReportSalary/Databases/Models/personnel_statement_detail.cs` and `personnel_statement_detail_2.cs` before writing this file — they were confirmed present during this session's investigation but re-read them directly rather than trusting this plan's transcription, since field lists that long are easy to mistype.
- [ ] **Step 2: Build and verify it compiles**
```bash
cd rmutr-api
dotnet build rmutr-budget-api.csproj -c Debug 2>&1 | tail -30
```
Expected: `0 Error(s)`. If `personnel_statement_detail`/`_2` field names don't match, fix them here based on the actual model file — don't guess.
- [ ] **Step 3: Commit**
```bash
git add Modules/IncomeBudgetReport/Services/
git commit -m "feat: add IncomeBudgetReportService.CreateDraft (pulls from ร.2)"
```
---
### Task 6: IncomeBudgetReportService — BuildTree (rollup)
**Files:**
- Modify: `rmutr-api/Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs`
- Create: `rmutr-api/Modules/IncomeBudgetReport/Config/ReportTreeNode.cs` (the response DTOs matching the Interfaces section above)
**Interfaces:**
- Consumes: `v_income_budget_report` with `income_budget_report_lines` loaded (Task 5's `GetEntity`).
- Produces: `IncomeBudgetReportService.BuildTree(v_income_budget_report report) : ReportTreeNode`, consumed by Task 8 (GET tree endpoint) and Task 9 (export).
- [ ] **Step 1: Write the response DTOs**
```csharp
using System.Collections.Generic;
namespace rmutr_budget_api.Modules.IncomeBudgetReport.Config
{
public class ReportTreeNode
{
public string NodeKey { get; set; }
public string Label { get; set; }
public int Depth { get; set; }
public string Kind { get; set; }
public decimal Amount { get; set; }
public bool Locked { get; set; }
public string LineUid { get; set; }
public List<ReportTreeNode> Children { get; set; } = new();
public List<ReportTreeTableRow> Rows { get; set; } = new();
}
public class ReportTreeTableRow
{
public string LineUid { get; set; }
public string Label { get; set; }
public string Qualification { get; set; }
public decimal? Qty { get; set; }
public decimal Amount { get; set; }
public bool Locked { get; set; }
}
}
```
- [ ] **Step 2: Add `BuildTree` to `IncomeBudgetReportService`**
Append to the class body from Task 5:
```csharp
public ReportTreeNode BuildTree(v_income_budget_report report)
{
var linesByNode = (report.income_budget_report_lines ?? new List<v_income_budget_report_line>())
.GroupBy(l => l.node_key)
.ToDictionary(g => g.Key, g => g.OrderBy(l => l.sequence_no).ToList());
var schema = IncomeReportTreeSchema.ForType1();
return BuildNode(schema, 0, linesByNode);
}
private ReportTreeNode BuildNode(ReportTreeNodeDef def, int depth, Dictionary<string, List<v_income_budget_report_line>> linesByNode)
{
var node = new ReportTreeNode
{
NodeKey = def.NodeKey,
Label = def.Label,
Depth = depth,
Kind = def.Kind,
};
if (def.Kind == "group")
{
foreach (var child in def.Children)
{
var childNode = BuildNode(child, depth + 1, linesByNode);
node.Children.Add(childNode);
}
node.Amount = node.Children.Sum(c => c.Amount);
node.Locked = false;
return node;
}
var lines = linesByNode.TryGetValue(def.NodeKey, out var l) ? l : new List<v_income_budget_report_line>();
if (def.Kind == "leaf-amount")
{
var line = lines.FirstOrDefault();
node.Amount = line?.amount ?? 0;
node.Locked = line?.source_uid != null;
node.LineUid = line?.income_budget_report_line_uid?.ToString();
return node;
}
// leaf-table
foreach (var line in lines)
{
node.Rows.Add(new ReportTreeTableRow
{
LineUid = line.income_budget_report_line_uid.ToString(),
Label = line.label_th,
Qualification = line.qualification_th,
Qty = line.qty,
Amount = line.amount ?? 0,
Locked = line.source_uid != null,
});
}
node.Amount = node.Rows.Sum(r => r.Amount);
node.Locked = false;
return node;
}
```
Add `using System.Linq;` and `using rmutr_budget_api.Modules.IncomeBudgetReport.Config;` to the top of `IncomeBudgetReportService.cs` if not already present (the `Config` using is already there from Task 5).
- [ ] **Step 3: Build and verify it compiles**
```bash
cd rmutr-api
dotnet build rmutr-budget-api.csproj -c Debug 2>&1 | tail -20
```
Expected: `0 Error(s)`.
- [ ] **Step 4: Commit**
```bash
git add Modules/IncomeBudgetReport/Config/ReportTreeNode.cs Modules/IncomeBudgetReport/Services/
git commit -m "feat: add tree rollup calculation (BuildTree)"
```
---
### Task 7: IncomeBudgetReportService — CloneNextRound
**Files:**
- Modify: `rmutr-api/Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs`
**Interfaces:**
- Produces: `IncomeBudgetReportService.CloneNextRound(Guid sourceUid, ClaimsIdentity identity) : Task<v_income_budget_report>`, consumed by Task 8.
- [ ] **Step 1: Add `CloneNextRound` to the service**
```csharp
public async Task<v_income_budget_report> CloneNextRound(Guid sourceUid, ClaimsIdentity identity)
{
var alreadyCloned = await _context.t_income_budget_report.AnyAsync(r => r.parent_uid == sourceUid);
if (alreadyCloned)
{
throw new InvalidOperationException("มีรายงานรอบถัดไปที่สร้างจากรอบนี้อยู่แล้ว");
}
var source = await _context.t_income_budget_report
.Include(c => c.income_budget_report_lines)
.AsNoTracking()
.FirstOrDefaultAsync(c => c.income_budget_report_uid == sourceUid);
if (source == null) throw new InvalidOperationException("ไม่พบรายงานต้นทาง");
if (source.type_id >= 3) throw new InvalidOperationException("รอบนี้เป็นรอบสุดท้ายแล้ว (type 3)");
var newUid = Guid.NewGuid();
var clone = new t_income_budget_report
{
income_budget_report_uid = newUid,
type_id = source.type_id + 1,
budget_year_uid = source.budget_year_uid,
faculty_uid = source.faculty_uid,
budget_location_uid = source.budget_location_uid,
sector_name_th = source.sector_name_th,
parent_uid = sourceUid,
status_id = 0,
};
var clonedLines = (source.income_budget_report_lines ?? new List<t_income_budget_report_line>())
.Select(l => new t_income_budget_report_line
{
income_budget_report_line_uid = Guid.NewGuid(),
income_budget_report_uid = newUid,
node_key = l.node_key,
source_type = l.source_type,
source_uid = l.source_uid,
label_th = l.label_th,
qualification_th = l.qualification_th,
qty = l.qty,
amount = l.amount,
original_amount = l.amount,
sequence_no = l.sequence_no,
}).ToList();
await _context.t_income_budget_report.AddAsync(clone);
await _context.t_income_budget_report_line.AddRangeAsync(clonedLines);
await _context.SaveChangesAsync(identity);
return await GetEntity(newUid);
}
```
Add `using System;` at the top if not already present (needed for `InvalidOperationException`).
- [ ] **Step 2: Build and verify it compiles**
```bash
cd rmutr-api
dotnet build rmutr-budget-api.csproj -c Debug 2>&1 | tail -20
```
Expected: `0 Error(s)`.
- [ ] **Step 3: Commit**
```bash
git add Modules/IncomeBudgetReport/Services/
git commit -m "feat: add CloneNextRound for round-to-round continuation (type 1->2->3)"
```
---
### Task 8: IncomeBudgetReportController
**Files:**
- Create: `rmutr-api/Modules/IncomeBudgetReport/Controllers/IncomeBudgetReportController.cs`
**Interfaces:**
- Consumes: `IncomeBudgetReportService` (Tasks 5-7).
- Produces: the 5 HTTP endpoints listed below, consumed by Task 11 (frontend service).
- [ ] **Step 1: Write the controller**
```csharp
using System;
using System.Collections.Generic;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using rmutr_budget_api.Modules.IncomeBudgetReport.Databases.Models;
using rmutr_budget_api.Modules.IncomeBudgetReport.Services;
using SeventyOneDev.Utilities;
namespace rmutr_budget_api.Modules.IncomeBudgetReport.Controllers
{
public class LineAmountUpdate
{
public Guid income_budget_report_line_uid { get; set; }
public decimal amount { get; set; }
}
[Route("api/setting/income_budget_report", Name = "income_budget_report")]
public class IncomeBudgetReportController : BaseUidController<t_income_budget_report, v_income_budget_report>
{
private readonly Db _db;
private readonly IncomeBudgetReportService _service;
public IncomeBudgetReportController(IncomeBudgetReportService entityUidService, Db db) : base(entityUidService)
{
_db = db;
_service = entityUidService;
}
[Authorize]
[HttpPost("create_draft")]
public async Task<ActionResult<v_income_budget_report>> CreateDraft(
[FromQuery] Guid faculty_uid, [FromQuery] Guid budget_year_uid)
{
var result = await _service.CreateDraft(faculty_uid, budget_year_uid, User.Identity as ClaimsIdentity);
return Ok(result);
}
[Authorize]
[HttpPost("{uid:guid}/clone_next_round")]
public async Task<ActionResult<v_income_budget_report>> CloneNextRound([FromRoute] Guid uid)
{
try
{
var result = await _service.CloneNextRound(uid, User.Identity as ClaimsIdentity);
return Ok(result);
}
catch (InvalidOperationException ex)
{
return BadRequest(new { description = ex.Message });
}
}
[Authorize]
[HttpGet("{uid:guid}/tree")]
public async Task<ActionResult> GetTree([FromRoute] Guid uid)
{
var report = await _service.GetEntity(uid);
if (report == null) return NotFound();
return Ok(_service.BuildTree(report));
}
[Authorize]
[HttpPut("lines")]
public async Task<ActionResult> UpdateLineAmounts([FromBody] List<LineAmountUpdate> updates)
{
foreach (var u in updates)
{
var line = await _db.t_income_budget_report_line.FindAsync(u.income_budget_report_line_uid);
if (line != null) line.amount = u.amount;
}
await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);
return Ok();
}
}
}
```
- [ ] **Step 2: Build and verify it compiles**
```bash
cd rmutr-api
dotnet build rmutr-budget-api.csproj -c Debug 2>&1 | tail -20
```
Expected: `0 Error(s)`.
- [ ] **Step 3: Verify endpoints exist via Swagger (dev server)**
```bash
cd rmutr-api
dotnet run --urls http://localhost:5050 &
sleep 15
curl -s http://localhost:5050/swagger/v1/swagger.json | python3 -c "import json,sys; d=json.load(sys.stdin); print([k for k in d['paths'] if 'income_budget_report' in k])"
kill %1
```
Expected: a list containing `/api/setting/income_budget_report/create_draft`, `/api/setting/income_budget_report/{uid}/clone_next_round`, `/api/setting/income_budget_report/{uid}/tree`, `/api/setting/income_budget_report/lines`, and the base CRUD paths from `BaseUidController`.
- [ ] **Step 4: Commit**
```bash
git add Modules/IncomeBudgetReport/Controllers/
git commit -m "feat: add IncomeBudgetReportController with create_draft/clone_next_round/tree/lines endpoints"
```
---
### Task 9: Export to Excel
**Files:**
- Modify: `rmutr-api/Modules/IncomeBudgetReport/Controllers/IncomeBudgetReportController.cs`
**Interfaces:**
- Consumes: `BuildTree` (Task 6).
- Produces: `GET /api/setting/income_budget_report/{uid}/export/xlsx`, consumed by the frontend "ส่งออก Excel" button (Task 12).
- [ ] **Step 1: Add the export endpoint**
```csharp
[Authorize]
[HttpGet("{uid:guid}/export/xlsx")]
public async Task<ActionResult> ExportXlsx([FromRoute] Guid uid)
{
var report = await _service.GetEntity(uid);
if (report == null) return NotFound();
var tree = _service.BuildTree(report);
using var package = new OfficeOpenXml.ExcelPackage();
var ws = package.Workbook.Worksheets.Add("Page1");
ws.Cells[1, 1].Value = "รายการ";
ws.Cells[1, 2].Value = "จำนวนเงิน (บาท)";
int row = 2;
WriteNode(tree, ws, ref row);
ws.Column(1).Width = 60;
ws.Column(2).Width = 20;
var bytes = package.GetAsByteArray();
var fileName = $"income_budget_report_{uid}.xlsx";
return File(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", fileName);
}
private static void WriteNode(Config.ReportTreeNode node, OfficeOpenXml.ExcelWorksheet ws, ref int row)
{
var indent = new string(' ', node.Depth * 4);
ws.Cells[row, 1].Value = indent + node.Label;
ws.Cells[row, 2].Value = node.Amount;
row++;
if (node.Kind == "leaf-table")
{
foreach (var r in node.Rows)
{
ws.Cells[row, 1].Value = indent + " - " + r.Label;
ws.Cells[row, 2].Value = r.Amount;
row++;
}
}
foreach (var child in node.Children)
{
WriteNode(child, ws, ref row);
}
}
```
Add `using OfficeOpenXml;` to the top of the file if preferred over the fully-qualified names used above (either works — the fully-qualified form avoids a namespace collision check).
- [ ] **Step 2: Build and verify it compiles**
```bash
cd rmutr-api
dotnet build rmutr-budget-api.csproj -c Debug 2>&1 | tail -20
```
Expected: `0 Error(s)`.
- [ ] **Step 3: Commit**
```bash
git add Modules/IncomeBudgetReport/Controllers/
git commit -m "feat: add xlsx export endpoint for income budget report"
```
---
### Task 10: Frontend generic tree component
**Files:**
- Create: `rmutr-web/src/app/shared/components/budget-report-tree/budget-report-tree-node.component.ts`
- Create: `rmutr-web/src/app/shared/components/budget-report-tree/budget-report-tree-node.component.html`
- Create: `rmutr-web/src/app/shared/components/budget-report-tree/budget-report-tree-node.component.scss`
- Create: `rmutr-web/src/app/shared/components/budget-report-tree/budget-report-tree.module.ts`
**Interfaces:**
- Consumes: `ReportTreeNode`/`ReportTreeTableRow` JSON shape from the Interfaces section (Task 8's `/tree` endpoint).
- Produces: `<app-budget-report-tree-node [node]="node">` component, consumed by Task 12.
This ports the mockup's interaction design (recursive `<details>`, indentation via depth, live recalculation) into a real Angular component. Unlike the mockup, amounts come pre-computed from the backend on load; the component only needs to recompute a node's own displayed total when one of ITS OWN rows/leaf inputs changes (bubbling the recalculation up through `@Output` events, since Angular components can't share mutable DOM state across siblings the way the static mockup's global `recalc()` did).
- [ ] **Step 1: Write the component class**
```typescript
import { Component, EventEmitter, Input, Output } from '@angular/core';
export interface ReportTreeTableRow {
lineUid: string;
label: string;
qualification: string | null;
qty: number | null;
amount: number;
locked: boolean;
}
export interface ReportTreeNode {
nodeKey: string;
label: string;
depth: number;
kind: 'group' | 'leaf-amount' | 'leaf-table';
amount: number;
locked: boolean;
lineUid: string | null;
children: ReportTreeNode[];
rows: ReportTreeTableRow[];
}
export interface LineAmountChange {
lineUid: string;
amount: number;
}
@Component({
selector: 'app-budget-report-tree-node',
templateUrl: './budget-report-tree-node.component.html',
styleUrls: ['./budget-report-tree-node.component.scss'],
})
export class BudgetReportTreeNodeComponent {
@Input() node: ReportTreeNode;
@Output() amountChange = new EventEmitter<LineAmountChange>();
onLeafAmountInput(value: string): void {
const amount = parseFloat(value) || 0;
this.node.amount = amount;
this.amountChange.emit({ lineUid: this.node.lineUid, amount });
}
onRowAmountInput(row: ReportTreeTableRow, value: string): void {
const amount = parseFloat(value) || 0;
row.amount = amount;
this.node.amount = this.node.rows.reduce((s, r) => s + (r.amount || 0), 0);
this.amountChange.emit({ lineUid: row.lineUid, amount });
}
onChildAmountChange(event: LineAmountChange): void {
this.node.amount = this.node.children.reduce((s, c) => s + (c.amount || 0), 0);
this.amountChange.emit(event);
}
trackByNodeKey(_i: number, n: ReportTreeNode): string { return n.nodeKey; }
trackByLineUid(_i: number, r: ReportTreeTableRow): string { return r.lineUid; }
}
```
- [ ] **Step 2: Write the template**
```html
<div class="tree-node lvl-{{ node.depth }}" [style.--depth]="node.depth">
<div class="row">
<span class="row-label">{{ node.label }}</span>
<ng-container *ngIf="node.kind === 'leaf-amount'; else groupAmount">
<input
*ngIf="!node.locked; else lockedAmount"
type="number"
class="amount-input"
[value]="node.amount"
(input)="onLeafAmountInput($any($event.target).value)"
/>
<ng-template #lockedAmount>
<span class="amount-readonly">{{ node.amount | number:'1.2-2' }}</span>
</ng-template>
</ng-container>
<ng-template #groupAmount>
<span class="amount-computed">{{ node.amount | number:'1.2-2' }}</span>
</ng-template>
</div>
<table class="leaf-table" *ngIf="node.kind === 'leaf-table' && node.rows.length">
<thead>
<tr>
<th>รายการ</th>
<th>วุฒิ</th>
<th class="num">อัตรา</th>
<th class="num">จำนวนเงิน (บาท)</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let row of node.rows; trackBy: trackByLineUid">
<td>{{ row.label }}</td>
<td>{{ row.qualification }}</td>
<td class="num">{{ row.qty }}</td>
<td class="num">
<input
*ngIf="!row.locked; else lockedRowAmount"
type="number"
class="amount-input"
[value]="row.amount"
(input)="onRowAmountInput(row, $any($event.target).value)"
/>
<ng-template #lockedRowAmount>
<span class="amount-readonly">{{ row.amount | number:'1.2-2' }}</span>
</ng-template>
</td>
</tr>
</tbody>
</table>
<p class="empty-hint" *ngIf="node.kind === 'leaf-table' && !node.rows.length">ยังไม่มีรายการ</p>
<div class="children" *ngIf="node.kind === 'group'">
<app-budget-report-tree-node
*ngFor="let child of node.children; trackBy: trackByNodeKey"
[node]="child"
(amountChange)="onChildAmountChange($event)"
></app-budget-report-tree-node>
</div>
</div>
```
- [ ] **Step 3: Write minimal styles**
```scss
.tree-node {
padding-left: calc(var(--depth, 0) * 20px);
}
.row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 8px;
gap: 12px;
}
.row-label { font-weight: 500; }
.amount-input {
width: 120px;
text-align: right;
font-variant-numeric: tabular-nums;
}
.amount-readonly, .amount-computed {
font-variant-numeric: tabular-nums;
min-width: 120px;
text-align: right;
display: inline-block;
}
.leaf-table {
width: 100%;
border-collapse: collapse;
margin: 4px 0 8px;
th, td { padding: 4px 8px; border-bottom: 1px solid #e2e6ed; text-align: left; }
th.num, td.num { text-align: right; }
}
.empty-hint { color: #8993a8; font-size: 12.5px; padding-left: 8px; }
```
- [ ] **Step 4: Write the module**
```typescript
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { BudgetReportTreeNodeComponent } from './budget-report-tree-node.component';
@NgModule({
declarations: [BudgetReportTreeNodeComponent],
imports: [CommonModule, FormsModule],
exports: [BudgetReportTreeNodeComponent],
})
export class BudgetReportTreeModule {}
```
- [ ] **Step 5: Verify — type-check the whole frontend**
```bash
cd rmutr-web
npx tsc --noEmit -p tsconfig.app.json 2>&1 | grep -i "budget-report-tree" | head -30
```
Expected: no output (no errors referencing the new files). Note: this only catches type errors, not template errors — Task 12's `ng build` will catch template binding mistakes.
- [ ] **Step 6: Commit**
```bash
git add src/app/shared/components/budget-report-tree/
git commit -m "feat: add generic recursive budget report tree component"
```
---
### Task 11: Frontend API service
**Files:**
- Create: `rmutr-web/src/app/core/service/setting/income-budget-report.service.ts`
**Interfaces:**
- Consumes: endpoints from Tasks 8-9.
- Produces: `IncomeBudgetReportService.createDraft/cloneNextRound/getTree/exportXlsxUrl/updateLineAmounts`, used by Task 12.
- [ ] **Step 1: Write the service**
```typescript
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { BaseService } from 'src/app/core/base/base-service';
import { environment } from 'src/environments/environment';
import { ReportTreeNode } from 'src/app/shared/components/budget-report-tree/budget-report-tree-node.component';
@Injectable({ providedIn: 'root' })
export class IncomeBudgetReportService extends BaseService<any> {
constructor(http: HttpClient, private httpClient: HttpClient) {
super('/setting/income_budget_report', http);
}
createDraft(facultyUid: string, budgetYearUid: string): Observable<any> {
const url = `${environment.rmutrApi}/api/setting/income_budget_report/create_draft?faculty_uid=${facultyUid}&budget_year_uid=${budgetYearUid}`;
return this.httpClient.post(url, {});
}
cloneNextRound(uid: string): Observable<any> {
const url = `${environment.rmutrApi}/api/setting/income_budget_report/${uid}/clone_next_round`;
return this.httpClient.post(url, {});
}
getTree(uid: string): Observable<ReportTreeNode> {
const url = `${environment.rmutrApi}/api/setting/income_budget_report/${uid}/tree`;
return this.httpClient.get<ReportTreeNode>(url);
}
exportXlsxUrl(uid: string): string {
return `${environment.rmutrApi}/api/setting/income_budget_report/${uid}/export/xlsx`;
}
updateLineAmounts(updates: { income_budget_report_line_uid: string; amount: number }[]): Observable<any> {
const url = `${environment.rmutrApi}/api/setting/income_budget_report/lines`;
return this.httpClient.put(url, updates);
}
}
```
Check `rmutr-web/src/app/core/base/base-service.ts`'s constructor signature before writing this file — confirm it takes `(endpoint: string, http: HttpClient)` in that order (matches every other service seen this session, e.g. `announcement.service.ts`, but verify directly).
- [ ] **Step 2: Verify — type-check**
```bash
cd rmutr-web
npx tsc --noEmit -p tsconfig.app.json 2>&1 | grep -i "income-budget-report.service" | head -30
```
Expected: no output.
- [ ] **Step 3: Commit**
```bash
git add src/app/core/service/setting/income-budget-report.service.ts
git commit -m "feat: add IncomeBudgetReportService frontend client"
```
---
### Task 12: Rewrite the page component
**Files:**
- Modify: `rmutr-web/src/app/feature/income/draft/income-budget-report1/income-budget-report1/income-budget-report.component.ts` (full rewrite)
- Modify: `rmutr-web/src/app/feature/income/draft/income-budget-report1/income-budget-report1/income-budget-report.component.html` (full rewrite)
- Modify: `rmutr-web/src/app/feature/income/draft/income-budget-report1/income-budget-report.module.ts` (add `BudgetReportTreeModule` import)
**Interfaces:**
- Consumes: `IncomeBudgetReportService` (Task 11), `BudgetReportTreeModule`/`BudgetReportTreeNodeComponent` (Task 10).
- Scope: this task only rewires **`type_id == 1`** behavior (the `/income-budget-report-income1/add` route this whole plan targets). Leave the `type == 2..10` branches in the existing `ngOnInit` switch alone for now — they still reference the old SyncFusion members, which is fine because Task 13's verification only exercises type 1. Wiring type 2/3 into this rewritten component is 1-2 lines each (call `cloneNextRound` instead of `createDraft`) but is explicitly **out of scope** for this plan per the Group A/B split agreed during brainstorming — leave the `NOTE(group-b-followup)` comment shown in Step 2 at the relevant branch rather than implementing it now, so the next plan has an obvious anchor.
- [ ] **Step 1: Read the current component fully before rewriting**
```bash
cat rmutr-web/src/app/feature/income/draft/income-budget-report1/income-budget-report1/income-budget-report.component.ts
```
Note the `budgetYearSV`, `FacultySV`, and route param handling (`this.form.get('type_id')`) — the rewrite keeps the faculty/budget-year selector UI but drops everything SyncFusion-related.
- [ ] **Step 2: Write the new component (type_id==1 path only)**
```typescript
import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { FacultyService } from 'src/app/core/service/general/faculty.service';
import { BudgetYearService } from 'src/app/core/service/budget/budget-year.service';
import { IncomeBudgetReportService } from 'src/app/core/service/setting/income-budget-report.service';
import { ReportTreeNode, LineAmountChange } from 'src/app/shared/components/budget-report-tree/budget-report-tree-node.component';
@Component({
selector: 'app-income-budget-report',
templateUrl: './income-budget-report.component.html',
styleUrls: ['./income-budget-report.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class IncomeBudgetReportComponent implements OnInit {
typeId: number;
reportUid: string | null = null;
tree: ReportTreeNode | null = null;
facultyUid: string | null = null;
budgetYearUid: string | null = null;
pendingChanges = new Map<string, number>();
faculty$ = new Observable<any>();
budgetYear$ = new Observable<any>();
constructor(
private activatedRoute: ActivatedRoute,
private router: Router,
private cdRef: ChangeDetectorRef,
private facultySV: FacultyService,
private budgetYearSV: BudgetYearService,
private incomeBudgetReportSV: IncomeBudgetReportService,
) {}
ngOnInit(): void {
this.faculty$ = this.facultySV.getAll();
this.budgetYear$ = this.budgetYearSV.getAll();
this.activatedRoute.data.pipe(
tap((x: any) => this.typeId = x.type),
).subscribe();
}
onFacultyChange(facultyUid: string): void {
this.facultyUid = facultyUid;
this.tryCreateDraft();
}
onBudgetYearChange(budgetYearUid: string): void {
this.budgetYearUid = budgetYearUid;
this.tryCreateDraft();
}
private tryCreateDraft(): void {
if (!this.facultyUid || !this.budgetYearUid) return;
this.incomeBudgetReportSV.createDraft(this.facultyUid, this.budgetYearUid).pipe(
tap((report: any) => {
this.reportUid = report.income_budget_report_uid;
this.loadTree();
}),
).subscribe();
}
loadTree(): void {
if (!this.reportUid) return;
this.incomeBudgetReportSV.getTree(this.reportUid).pipe(
tap((tree) => { this.tree = tree; this.cdRef.detectChanges(); }),
).subscribe();
}
hasNoPersonnelData(): boolean {
if (!this.tree) return false;
const pb = this.tree.children.find(c => c.nodeKey === 'pb');
const temp = pb?.children.find(c => c.nodeKey === 'pb-temp');
const oldRates = temp?.children.find(c => c.nodeKey === 'pb-temp-old');
const newRates = temp?.children.find(c => c.nodeKey === 'pb-temp-new');
return !oldRates?.rows.length && !newRates?.rows.length;
}
onAmountChange(change: LineAmountChange): void {
this.pendingChanges.set(change.lineUid, change.amount);
}
save(): void {
if (!this.reportUid || this.pendingChanges.size === 0) return;
const lines = Array.from(this.pendingChanges.entries()).map(([lineUid, amount]) => ({
income_budget_report_line_uid: lineUid,
amount,
}));
this.incomeBudgetReportSV.updateLineAmounts(lines).pipe(
tap(() => { this.pendingChanges.clear(); this.loadTree(); }),
).subscribe();
}
exportXlsx(): void {
if (!this.reportUid) return;
window.open(this.incomeBudgetReportSV.exportXlsxUrl(this.reportUid), '_blank');
}
// NOTE(group-b-followup): wire cloneNextRound() here instead of createDraft() when this
// component is extended to handle income-budget-report-income2/3 — tracked as a follow-up
// plan, not implemented here. See docs/superpowers/plans/2026-08-05-income-budget-report-native-group-a.md Task 12.
}
```
`save()` calls `IncomeBudgetReportService.updateLineAmounts()` (Task 11), which PUTs to `/api/setting/income_budget_report/lines` — that endpoint was added in Task 8 Step 1 (`IncomeBudgetReportController.UpdateLineAmounts`). No backend change needed in this task.
- [ ] **Step 3: Write the new template**
```html
<div class="report-page">
<div class="header-fields">
<mat-form-field>
<mat-label>หน่วยงาน</mat-label>
<mat-select (selectionChange)="onFacultyChange($event.value)">
<mat-option *ngFor="let f of (faculty$ | async)" [value]="f.faculty_uid">{{ f.faculty_name_th }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-label>ปีงบประมาณ</mat-label>
<mat-select (selectionChange)="onBudgetYearChange($event.value)">
<mat-option *ngFor="let y of (budgetYear$ | async)" [value]="y.budget_year_uid">{{ y.budget_year_name_th }}</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="empty-source-banner" *ngIf="tree && hasNoPersonnelData()">
ยังไม่มีข้อมูลตำแหน่ง/อัตรากำลังจาก ร.2 สำหรับหน่วยงานและปีงบประมาณนี้
<a routerLink="/app/estimated-income">กรอกฟอร์ม ร.2 ก่อน</a>
</div>
<div class="toolbar" *ngIf="tree">
<span class="grand-total">รวมทั้งสิ้น: {{ tree.amount | number:'1.2-2' }} บาท</span>
<button mat-raised-button color="primary" (click)="save()">บันทึก</button>
<button mat-raised-button (click)="exportXlsx()">ส่งออก Excel</button>
</div>
<app-budget-report-tree-node
*ngIf="tree"
[node]="tree"
(amountChange)="onAmountChange($event)"
></app-budget-report-tree-node>
</div>
```
- [ ] **Step 4: Wire `BudgetReportTreeModule` into the feature module**
Read `rmutr-web/src/app/feature/income/draft/income-budget-report1/income-budget-report.module.ts` and add to its `imports` array:
```typescript
import { BudgetReportTreeModule } from 'src/app/shared/components/budget-report-tree/budget-report-tree.module';
// ...
imports: [
// ...existing imports...
BudgetReportTreeModule,
],
```
- [ ] **Step 5: Build and verify**
```bash
cd rmutr-web
node --max_old_space_size=8192 node_modules/@angular/cli/bin/ng build --configuration production 2>&1 | tail -60
```
Expected: build succeeds (warnings OK, 0 errors). This is the first point template binding errors would surface — read the output carefully.
- [ ] **Step 6: Commit**
```bash
git add src/app/feature/income/draft/income-budget-report1/
git commit -m "feat: replace SyncFusion spreadsheet with native tree component (type 1)"
```
---
### Task 13: End-to-end verification against real data
**Files:** none (verification only)
- [ ] **Step 1: Deploy backend and frontend to the dev/staging environment** (follow this session's established deploy steps: `dotnet publish -c Release -r linux-x64`, scp the DLL, restart the systemd service; `ng build --configuration production`, rsync to the web server)
- [ ] **Step 2: Pick a faculty+year that already has a real ร.2 record** (this session confirmed `personnel_statement` records exist for `faculty_uid` matching "บพิตรพิมุข จักรวรรดิ / คณะศิลปศาสตร์" for `budget_year_name_th = '2569'`) — verify via:
```bash
export PGPASSWORD='vtwi,yo0tpkd-okfouh'
/opt/homebrew/Cellar/postgresql@16/16.14/bin/psql -h 203.158.221.28 -p 31620 -U devadmin -d rmutr_budget_050625 \
-c "SELECT personnel_statement_uid, faculty_uid, agency_name_th, budget_year_uid FROM \"BUDGET\".personnel_statement LIMIT 5;"
```
- [ ] **Step 3: In the browser, open `/app/income-budget-report-income1/add`, select that faculty+year, and confirm:**
- The tree renders with "รายการบุคลากร" at the top and the same category labels seen in the real downloaded `type1.xlsx` (`งบบุคลากร`, `งบดำเนินงาน`, `แผนงานยุทธศาสตร์พัฒนาศักยภาพคนตลอดช่วงชีวิต`, ...)
- Position rows under "อัตราเดิม" are **read-only** (no input box, just text) and match names/amounts in `personnel_statement_detail` for that faculty+year
- Editing an unlocked amount (e.g. "ค่าตอบแทน ค่าใช้สอยและค่าวัสดุ") updates the parent totals live
- "บันทึก" persists the change — reload the page and confirm the edited value survived
- "ส่งออก Excel" downloads a `.xlsx` file that opens and shows the same numbers
- [ ] **Step 4: Compare the grand total** against the real downloaded file for the same faculty (`/tmp/mockup_fonts/type2.xlsx` from this session's investigation, or re-download the latest `revenue_draft_committee` record for that faculty) — the personnel-derived subtotal should match `personnel_statement_detail` records summed × 12 exactly, since Task 5's `CreateDraft` uses the same `salary_rate * 12` formula the old system used (`Personnel.cs:2345`)
- [ ] **Step 5: Document any mismatch found as a follow-up task** — do not silently patch numbers to make them match without understanding why they differed first.
---
## Explicitly out of scope for this plan (tracked for the Group B follow-up plan)
- `type_id` 2, 3 UI wiring (backend `CloneNextRound` supports it; frontend component's faculty/year picker → clone flow is not built)
- `type_id` 4, 5, 6 (Group B — multi-tab workbook, different tree schema, different source tables ร.4/ร.5/ร.6)
- `type_id` 10 (Group C — matrix report, entirely different data model)
- Deprecating/removing the old `revenue_draft_committee` table and SyncFusion dependency (leave both in place until Group B ships too, per the phased/pilot rollout agreed in the spec)