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

63 KiB
Raw Blame History

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 psqldotnet 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:

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

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_line DbSets 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_line tables 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: 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
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_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

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:

        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 CloneNextRound to 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/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
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 (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

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 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:

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_statement records exist for faculty_uid matching "บพิตรพิมุข จักรวรรดิ / คณะศิลปศาสตร์" for budget_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_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)