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
63 KiB
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, mirrorsModules/Setting/structure). - Schema/table naming,
base_tableinheritance,t_/v_entity split, andBaseUidController<T,TV>usage follow the exact pattern already used forannouncement/announcement_faculty(added this session — seermutr-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_050625at203.158.221.28:31620) viapsql—dotnet ef migrations addis not used in this codebase; the EF migration history has pre-existing unrelated drift that makes it unsafe (confirmed this session — do not attemptdotnet ef migrations addfor this feature). - When adding an EF relationship between two entities that both have
t_/v_variants, configureHasOne/WithMany/HasForeignKeyon both thet_andv_sides inDb.cs— omitting thev_side produces a broken shadow FK property and a 500 at runtime (root-caused and fixed forannouncementthis session; do not repeat the mistake). - Frontend service files follow
rmutr-web/src/app/core/service/**+ extendBaseService(rmutr-web/src/app/core/base/base-service.ts) — full URL is alwaysenvironment.rmutrApi + '/api' + endpoint. - No automated test framework exists in either repo. Every task's verification step is a concrete manual check (
dotnet build,psqlquery,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
_amountor_priceso the project-wideOnModelCreatingconvention (rmutr-api/Databases/Features/Db.cs:91-99) automatically appliesnumeric(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:
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_lineclasses used by every later backend task. -
Step 1: Write
income_budget_report.cs
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
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
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
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_lineDbSets usable by every later backend task. -
Step 1: Write
Modules/IncomeBudgetReport/Databases/Db.cs
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);:
OnPlanResultModelCreating(builder,schema);
OnIncomeBudgetReportModelCreating(builder,schema);
- Step 3: Build and verify it compiles
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
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_linetables and matching views, required by every later task that touches the database. -
Step 1: Write the migration file
-- 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):
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:
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:
/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
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:
ReportTreeNodeDefclass andIncomeReportTreeSchema.ForType1()static method returning the type-1/2/3 tree definition, consumed by Task 5 (CreateDraft, to assignnode_keyper 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
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
cd rmutr-api
dotnet build rmutr-budget-api.csproj -c Debug 2>&1 | tail -20
Expected: 0 Error(s).
- Step 3: Commit
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)
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
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
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_reportwithincome_budget_report_linesloaded (Task 5'sGetEntity). -
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
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
BuildTreetoIncomeBudgetReportService
Append to the class body from Task 5:
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
cd rmutr-api
dotnet build rmutr-budget-api.csproj -c Debug 2>&1 | tail -20
Expected: 0 Error(s).
- Step 4: Commit
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
CloneNextRoundto the service
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
cd rmutr-api
dotnet build rmutr-budget-api.csproj -c Debug 2>&1 | tail -20
Expected: 0 Error(s).
- Step 3: Commit
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
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
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)
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
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
[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
cd rmutr-api
dotnet build rmutr-budget-api.csproj -c Debug 2>&1 | tail -20
Expected: 0 Error(s).
- Step 3: Commit
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/ReportTreeTableRowJSON shape from the Interfaces section (Task 8's/treeendpoint). - 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
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
<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
.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
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
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
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
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
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
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(addBudgetReportTreeModuleimport)
Interfaces:
-
Consumes:
IncomeBudgetReportService(Task 11),BudgetReportTreeModule/BudgetReportTreeNodeComponent(Task 10). -
Scope: this task only rewires
type_id == 1behavior (the/income-budget-report-income1/addroute this whole plan targets). Leave thetype == 2..10branches in the existingngOnInitswitch 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 (callcloneNextRoundinstead ofcreateDraft) but is explicitly out of scope for this plan per the Group A/B split agreed during brainstorming — leave theNOTE(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
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)
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
<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
BudgetReportTreeModuleinto 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:
import { BudgetReportTreeModule } from 'src/app/shared/components/budget-report-tree/budget-report-tree.module';
// ...
imports: [
// ...existing imports...
BudgetReportTreeModule,
],
- Step 5: Build and verify
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
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_statementrecords exist forfaculty_uidmatching "บพิตรพิมุข จักรวรรดิ / คณะศิลปศาสตร์" forbudget_year_name_th = '2569') — verify via:
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_detailfor 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
.xlsxfile that opens and shows the same numbers
- The tree renders with "รายการบุคลากร" at the top and the same category labels seen in the real downloaded
-
Step 4: Compare the grand total against the real downloaded file for the same faculty (
/tmp/mockup_fonts/type2.xlsxfrom this session's investigation, or re-download the latestrevenue_draft_committeerecord for that faculty) — the personnel-derived subtotal should matchpersonnel_statement_detailrecords summed × 12 exactly, since Task 5'sCreateDraftuses the samesalary_rate * 12formula 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_id2, 3 UI wiring (backendCloneNextRoundsupports it; frontend component's faculty/year picker → clone flow is not built)type_id4, 5, 6 (Group B — multi-tab workbook, different tree schema, different source tables ร.4/ร.5/ร.6)type_id10 (Group C — matrix report, entirely different data model)- Deprecating/removing the old
revenue_draft_committeetable and SyncFusion dependency (leave both in place until Group B ships too, per the phased/pilot rollout agreed in the spec)