From 22c3c84f553b83978e7561504f7d239affe279d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nut=2E=E0=B9=84=E0=B8=9B=E0=B9=80=E0=B8=A3=E0=B8=B7?= =?UTF-8?q?=E0=B9=88=E0=B8=AD=E0=B8=A2?= Date: Wed, 5 Aug 2026 11:57:59 +0700 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_011R9j5gaw2qsoh9ypWQeXxi --- ...-05-income-budget-report-native-group-a.md | 1511 +++++++++++++++++ 1 file changed, 1511 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-05-income-budget-report-native-group-a.md diff --git a/docs/superpowers/plans/2026-08-05-income-budget-report-native-group-a.md b/docs/superpowers/plans/2026-08-05-income-budget-report-native-group-a.md new file mode 100644 index 0000000..af7f05c --- /dev/null +++ b/docs/superpowers/plans/2026-08-05-income-budget-report-native-group-a.md @@ -0,0 +1,1511 @@ +# 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` usage follow the exact pattern already used for `announcement`/`announcement_faculty` (added this session — see `rmutr-api/Modules/Setting/Databases/Models/announcement.cs`). +- DB migrations are **manual SQL files** in `rmutr-api/Migrations/*.sql`, applied directly to the production Postgres instance (`rmutr_budget_050625` at `203.158.221.28:31620`) via `psql` — `dotnet ef migrations add` is **not** used in this codebase; the EF migration history has pre-existing unrelated drift that makes it unsafe (confirmed this session — do not attempt `dotnet ef migrations add` for this feature). +- When adding an EF relationship between two entities that both have `t_`/`v_` variants, configure `HasOne/WithMany/HasForeignKey` on **both** the `t_` and `v_` sides in `Db.cs` — omitting the `v_` side produces a broken shadow FK property and a 500 at runtime (root-caused and fixed for `announcement` this session; do not repeat the mistake). +- Frontend service files follow `rmutr-web/src/app/core/service/**` + extend `BaseService` (`rmutr-web/src/app/core/base/base-service.ts`) — full URL is always `environment.rmutrApi + '/api' + endpoint`. +- No automated test framework exists in either repo. Every task's verification step is a concrete manual check (`dotnet build`, `psql` query, `curl`, or browser check) — run it and read the actual output before moving on, per this session's established practice. +- All monetary fields end in `_amount` or `_price` so the project-wide `OnModelCreating` convention (`rmutr-api/Databases/Features/Db.cs:91-99`) automatically applies `numeric(18,4)` — do not set precision manually. + +--- + +## File Structure + +**Backend — new module `Modules/IncomeBudgetReport/`:** +``` +Databases/Models/income_budget_report/income_budget_report.cs entity: income_budget_report, t_/v_ +Databases/Models/income_budget_report/income_budget_report_line.cs entity: income_budget_report_line, t_/v_ +Databases/Db.cs DbSets + OnIncomeBudgetReportModelCreating +Config/IncomeReportTreeSchema.cs static tree definition for type_id 1/2/3 +Services/IncomeBudgetReportService.cs CreateDraft, CloneNextRound, BuildTree, UpdateLineAmounts +Controllers/IncomeBudgetReportController.cs BaseUidController + custom endpoints +``` + +**Backend — modified files:** +``` +Databases/Db.cs (central registry) add OnIncomeBudgetReportModelCreating(builder, schema) call +Migrations/add_income_budget_report_tables.sql new manual migration +``` + +**Frontend — new files:** +``` +src/app/shared/components/budget-report-tree/budget-report-tree-node.component.ts +src/app/shared/components/budget-report-tree/budget-report-tree-node.component.html +src/app/shared/components/budget-report-tree/budget-report-tree-node.component.scss +src/app/shared/components/budget-report-tree/budget-report-tree.module.ts +src/app/core/service/setting/income-budget-report.service.ts +``` + +**Frontend — modified files:** +``` +src/app/feature/income/draft/income-budget-report1/income-budget-report1/income-budget-report.component.ts rewrite +src/app/feature/income/draft/income-budget-report1/income-budget-report1/income-budget-report.component.html rewrite +src/app/feature/income/draft/income-budget-report1/income-budget-report.module.ts import BudgetReportTreeModule +``` + +--- + +## Interfaces (the contract every task below must match exactly) + +**Tree JSON returned by `GET /api/setting/income_budget_report/{uid}/tree`:** + +```ts +interface ReportTreeNode { + nodeKey: string; // stable id, e.g. "pb-temp-old" + label: string; + depth: number; // 0-based, drives indentation + kind: 'group' | 'leaf-amount' | 'leaf-table'; + amount: number; // computed sum for 'group', the value itself for 'leaf-amount'/'leaf-table' total + locked: boolean; // true when sourced from ร.2 (source_uid != null) — label/qty read-only + lineUid: string | null; // income_budget_report_line_uid, set for 'leaf-amount' nodes only + children: ReportTreeNode[]; // populated for kind='group' + rows: ReportTreeTableRow[]; // populated for kind='leaf-table' +} +interface ReportTreeTableRow { + lineUid: string; + label: string; + qualification: string | null; // ร.2 "วุฒิ" — null for non-personnel rows + qty: number | null; // "อัตรา" — null when not applicable + amount: number; + locked: boolean; +} +``` + +**C# equivalent (`IncomeBudgetReportRollupService.BuildTree` return type) uses the same shape** — see Task 4/6. + +--- + +### Task 1: Backend entity models + +**Files:** +- Create: `rmutr-api/Modules/IncomeBudgetReport/Databases/Models/income_budget_report/income_budget_report.cs` +- Create: `rmutr-api/Modules/IncomeBudgetReport/Databases/Models/income_budget_report/income_budget_report_line.cs` + +**Interfaces:** +- Produces: `income_budget_report`, `t_income_budget_report`, `v_income_budget_report`, `income_budget_report_line`, `t_income_budget_report_line`, `v_income_budget_report_line` classes used by every later backend task. + +- [ ] **Step 1: Write `income_budget_report.cs`** + +```csharp +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using SeventyOneDev.Utilities; +using SeventyOneDev.Utilities.Attributes; + +namespace rmutr_budget_api.Modules.IncomeBudgetReport.Databases.Models +{ + public class income_budget_report : base_table + { + [Key] public Guid? income_budget_report_uid { get; set; } + public int? type_id { get; set; } + public Guid? budget_year_uid { get; set; } + public Guid? faculty_uid { get; set; } + public Guid? budget_location_uid { get; set; } + [Search] + public string sector_name_th { get; set; } + public Guid? parent_uid { get; set; } + } + + public class t_income_budget_report : income_budget_report + { + [Include] + public List income_budget_report_lines { get; set; } + } + + public class v_income_budget_report : income_budget_report + { + [Include] + public List income_budget_report_lines { get; set; } + } +} +``` + +- [ ] **Step 2: Write `income_budget_report_line.cs`** + +```csharp +using System; +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using SeventyOneDev.Utilities; +using SeventyOneDev.Utilities.Attributes; + +namespace rmutr_budget_api.Modules.IncomeBudgetReport.Databases.Models +{ + public class income_budget_report_line : base_table + { + [Key] public Guid? income_budget_report_line_uid { get; set; } + public Guid? income_budget_report_uid { get; set; } + [MaxLength(100)] + public string node_key { get; set; } + [MaxLength(50)] + public string source_type { get; set; } // "personnel_detail" | "personnel_detail_2" | "manual" + public Guid? source_uid { get; set; } + public string label_th { get; set; } + public string qualification_th { get; set; } + public decimal? qty { get; set; } + public decimal? amount { get; set; } + public decimal? original_amount { get; set; } + public int? sequence_no { get; set; } + } + + public class t_income_budget_report_line : income_budget_report_line + { + [JsonIgnore] + public t_income_budget_report income_budget_report { get; set; } + } + + public class v_income_budget_report_line : income_budget_report_line + { + [JsonIgnore] + public v_income_budget_report income_budget_report { get; set; } + } +} +``` + +- [ ] **Step 3: Verify — no build target yet, just confirm files exist and namespaces match the folder they're in** + +```bash +ls rmutr-api/Modules/IncomeBudgetReport/Databases/Models/income_budget_report/ +``` +Expected: both files listed. (The project won't compile until Task 2 wires up `Db.cs` — that's expected, don't try to build yet.) + +- [ ] **Step 4: Commit** + +```bash +cd rmutr-api +git add Modules/IncomeBudgetReport/Databases/Models/income_budget_report/ +git commit -m "feat: add income_budget_report and income_budget_report_line entity models" +``` + +--- + +### Task 2: Backend Db.cs registration + +**Files:** +- Create: `rmutr-api/Modules/IncomeBudgetReport/Databases/Db.cs` +- Modify: `rmutr-api/Databases/Db.cs` (central registry, currently ends at line 33 `}`) + +**Interfaces:** +- Consumes: entity classes from Task 1. +- Produces: `_context.t_income_budget_report`, `_context.v_income_budget_report`, `_context.t_income_budget_report_line`, `_context.v_income_budget_report_line` DbSets usable by every later backend task. + +- [ ] **Step 1: Write `Modules/IncomeBudgetReport/Databases/Db.cs`** + +```csharp +using Microsoft.EntityFrameworkCore; +using rmutr_budget_api.Modules.IncomeBudgetReport.Databases.Models; + +namespace SeventyOneDev.Utilities +{ + public partial class Db : DbContext + { + public DbSet t_income_budget_report { get; set; } + public DbSet v_income_budget_report { get; set; } + public DbSet t_income_budget_report_line { get; set; } + public DbSet v_income_budget_report_line { get; set; } + + private void OnIncomeBudgetReportModelCreating(ModelBuilder builder, string schema) + { + builder.Entity().ToTable("income_budget_report", schema); + builder.Entity().ToView("v_income_budget_report", schema); + + builder.Entity().ToTable("income_budget_report_line", schema); + builder.Entity().ToView("v_income_budget_report_line", schema); + + builder.Entity().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().HasOne(p => p.income_budget_report) + .WithMany(p => p.income_budget_report_lines) + .HasForeignKey(p => p.income_budget_report_uid) + .OnDelete(DeleteBehavior.Cascade); + } + } +} +``` + +- [ ] **Step 2: Register the call in the central registry** + +Read `rmutr-api/Databases/Db.cs` first (it's short, ~34 lines). Add the new call as the last line inside `OnApplicationModelCreating`, right after `OnPlanResultModelCreating(builder,schema);`: + +```csharp + OnPlanResultModelCreating(builder,schema); + OnIncomeBudgetReportModelCreating(builder,schema); +``` + +- [ ] **Step 3: Build and verify it compiles** + +```bash +cd rmutr-api +dotnet build rmutr-budget-api.csproj -c Debug 2>&1 | tail -20 +``` +Expected: `0 Error(s)`. (Run in background if it exceeds ~2 minutes — it's a large solution, this is normal.) + +- [ ] **Step 4: Commit** + +```bash +git add Modules/IncomeBudgetReport/Databases/Db.cs Databases/Db.cs +git commit -m "feat: register income_budget_report entities with EF Db context" +``` + +--- + +### Task 3: Manual SQL migration + +**Files:** +- Create: `rmutr-api/Migrations/add_income_budget_report_tables.sql` + +**Interfaces:** +- Consumes: table/column names from Task 1. +- Produces: physical `"BUDGET".income_budget_report`, `"BUDGET".income_budget_report_line` tables and matching views, required by every later task that touches the database. + +- [ ] **Step 1: Write the migration file** + +```sql +-- Migration: Create income_budget_report + income_budget_report_line tables (Group A, type 1-3). +-- Date: 2026-08-05 +-- Description: Backs the native replacement for the SyncFusion income-budget-report page. +-- Replaces the .xlsx-blob storage (revenue_draft_committee) for type_id 1-3 with +-- structured rows. See docs/superpowers/specs/2026-08-05-income-budget-report-native-design.md +-- Idempotent: safe to run again. + +CREATE TABLE IF NOT EXISTS "BUDGET".income_budget_report ( + income_budget_report_uid uuid PRIMARY KEY, + status_id smallint, + created_by character varying(200), + created_datetime timestamptz, + updated_by character varying(200), + updated_datetime timestamptz, + owner_agency_uid uuid, + type_id integer, + budget_year_uid uuid, + faculty_uid uuid, + budget_location_uid uuid, + sector_name_th text, + parent_uid uuid REFERENCES "BUDGET".income_budget_report (income_budget_report_uid) +); + +CREATE TABLE IF NOT EXISTS "BUDGET".income_budget_report_line ( + income_budget_report_line_uid uuid PRIMARY KEY, + status_id smallint, + created_by character varying(200), + created_datetime timestamptz, + updated_by character varying(200), + updated_datetime timestamptz, + owner_agency_uid uuid, + income_budget_report_uid uuid REFERENCES "BUDGET".income_budget_report (income_budget_report_uid) ON DELETE CASCADE, + node_key character varying(100), + source_type character varying(50), + source_uid uuid, + label_th text, + qualification_th text, + qty numeric(18,4), + amount numeric(18,4), + original_amount numeric(18,4), + sequence_no integer +); + +CREATE INDEX IF NOT EXISTS "IX_income_budget_report_line_income_budget_report_uid" + ON "BUDGET".income_budget_report_line (income_budget_report_uid); + +CREATE INDEX IF NOT EXISTS "IX_income_budget_report_parent_uid" + ON "BUDGET".income_budget_report (parent_uid); + +CREATE OR REPLACE VIEW "BUDGET".v_income_budget_report AS +SELECT * FROM "BUDGET".income_budget_report; + +CREATE OR REPLACE VIEW "BUDGET".v_income_budget_report_line AS +SELECT * FROM "BUDGET".income_budget_report_line; +``` + +- [ ] **Step 2: Apply to the production database and verify** + +Confirm VPN/DB connectivity first (this session repeatedly needed to re-check this): +```bash +nc -zv -w 6 203.158.221.28 31620 +``` +Expected: `succeeded`. If it times out, ask the user to check their VPN before continuing. + +Apply: +```bash +export PGPASSWORD='vtwi,yo0tpkd-okfouh' +/opt/homebrew/Cellar/postgresql@16/16.14/bin/psql -h 203.158.221.28 -p 31620 -U devadmin -d rmutr_budget_050625 \ + -v ON_ERROR_STOP=1 -f rmutr-api/Migrations/add_income_budget_report_tables.sql +``` +Expected output: `CREATE TABLE` ×2, `CREATE INDEX` ×2, `CREATE VIEW` ×2, no errors. + +Verify columns exist: +```bash +/opt/homebrew/Cellar/postgresql@16/16.14/bin/psql -h 203.158.221.28 -p 31620 -U devadmin -d rmutr_budget_050625 \ + -c "SELECT table_name, column_name FROM information_schema.columns WHERE table_schema='BUDGET' AND table_name IN ('income_budget_report','income_budget_report_line') ORDER BY table_name, ordinal_position;" +``` +Expected: every field from Task 1's entities listed. + +- [ ] **Step 3: Commit** + +```bash +cd rmutr-api +git add Migrations/add_income_budget_report_tables.sql +git commit -m "feat: add income_budget_report tables migration" +``` + +--- + +### Task 4: Tree schema config (backend) + +**Files:** +- Create: `rmutr-api/Modules/IncomeBudgetReport/Config/IncomeReportTreeSchema.cs` + +**Interfaces:** +- Produces: `ReportTreeNodeDef` class and `IncomeReportTreeSchema.ForType1()` static method returning the type-1/2/3 tree definition, consumed by Task 5 (CreateDraft, to assign `node_key` per source row) and Task 6 (BuildTree rollup). + +This schema is transcribed directly from the real production file inspected this session (`type1.xlsx`, sheet "Page1", 153 rows) — see the spec's Data Model section for the source verification. + +- [ ] **Step 1: Write the schema definition** + +```csharp +using System.Collections.Generic; + +namespace rmutr_budget_api.Modules.IncomeBudgetReport.Config +{ + public class ReportTreeNodeDef + { + public string NodeKey { get; set; } + public string Label { get; set; } + public string Kind { get; set; } // "group" | "leaf-amount" | "leaf-table" + public string SourceType { get; set; } // null for group/manual nodes + public List 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(children) }; + + public static ReportTreeNodeDef LeafAmount(string key, string label) + => new ReportTreeNodeDef { NodeKey = key, Label = label, Kind = "leaf-amount" }; + + public static ReportTreeNodeDef LeafTable(string key, string label, string sourceType) + => new ReportTreeNodeDef { NodeKey = key, Label = label, Kind = "leaf-table", SourceType = sourceType }; + } + + public static class IncomeReportTreeSchema + { + // Verified against the real type-1 production file (Page1, 153 rows) downloaded and + // inspected 2026-08-05. Plan/output labels are the exact wording found in that file. + public static ReportTreeNodeDef ForType1() + { + return ReportTreeNodeDef.Group("root", "รายการบุคลากร", + ReportTreeNodeDef.Group("pb", "งบบุคลากร", + ReportTreeNodeDef.Group("pb-temp", "ค่าจ้างชั่วคราว", + ReportTreeNodeDef.LeafTable("pb-temp-old", "อัตราเดิม", "personnel_detail"), + ReportTreeNodeDef.LeafTable("pb-temp-new", "อัตราใหม่", "personnel_detail_2") + ), + ReportTreeNodeDef.Group("pb-cola", "เงินเพิ่มค่าครองชีพชั่วคราว", + ReportTreeNodeDef.LeafAmount("pb-cola-old", "อัตราเดิม"), + ReportTreeNodeDef.LeafAmount("pb-cola-new", "อัตราใหม่") + ) + ), + ReportTreeNodeDef.Group("op", "งบดำเนินงาน", + ReportTreeNodeDef.LeafAmount("op-comp", "ค่าตอบแทน ค่าใช้สอยและค่าวัสดุ"), + ReportTreeNodeDef.Group("plan1", "แผนงานยุทธศาสตร์พัฒนาศักยภาพคนตลอดช่วงชีวิต", + BuildOutput("out1", "ผลผลิต ผู้สำเร็จการศึกษาด้านวิทยาศาสตร์และเทคโนโลยี"), + BuildOutput("out2", "ผลผลิต ผู้สำเร็จการศึกษาด้านสังคมศาสตร์") + ), + ReportTreeNodeDef.Group("plan2", "แผนงานพื้นฐานด้านการพัฒนาและเสริมสร้างศักยภาพทรัพยากรมนุษย์", + BuildOutput("out3", "ผลผลิต ผลงานการให้บริการวิชาการ"), + BuildOutput("out4", "ผลผลิต ผลงานทำนุบำรุงศิลปวัฒนธรรม") + ), + ReportTreeNodeDef.Group("plan3", "แผนงานยุทธศาสตร์การวิจัยและพัฒนานวัตกรรม", + BuildOutput("out5", "ผลผลิต ผลงานวิจัยเพื่อสร้างองค์ความรู้") + ) + ) + ); + } + + private static ReportTreeNodeDef BuildOutput(string prefix, string label) + { + return ReportTreeNodeDef.Group(prefix, label, + ReportTreeNodeDef.Group($"{prefix}-grant", "งบเงินอุดหนุน", + ReportTreeNodeDef.LeafAmount($"{prefix}-grant-proj", "เงินอุดหนุนค่าใช้จ่ายโครงการ") + ), + ReportTreeNodeDef.Group($"{prefix}-invest", "งบลงทุน", + ReportTreeNodeDef.LeafTable($"{prefix}-invest-equip", "ค่าครุภัณฑ์", "invest_asset"), + ReportTreeNodeDef.LeafTable($"{prefix}-invest-land", "ค่าที่ดินและสิ่งก่อสร้าง", "invest_construct") + ), + ReportTreeNodeDef.LeafAmount($"{prefix}-other", "งบรายจ่ายอื่นๆ") + ); + } + } +} +``` + +- [ ] **Step 2: Build and verify it compiles** + +```bash +cd rmutr-api +dotnet build rmutr-budget-api.csproj -c Debug 2>&1 | tail -20 +``` +Expected: `0 Error(s)`. + +- [ ] **Step 3: Commit** + +```bash +git add Modules/IncomeBudgetReport/Config/ +git commit -m "feat: add type-1 income budget report tree schema" +``` + +--- + +### Task 5: IncomeBudgetReportService — CreateDraft + +**Files:** +- Create: `rmutr-api/Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs` + +**Interfaces:** +- Consumes: `ReportTreeNodeDef`/`IncomeReportTreeSchema.ForType1()` (Task 4), `t_personnel_statement`/`personnel_statement_detail`/`personnel_statement_detail_2` (existing, `rmutr-api/Modules/ReportSalary/Databases/Models/`). +- Produces: `IncomeBudgetReportService.CreateDraft(Guid facultyUid, Guid budgetYearUid, ClaimsIdentity identity) : Task`, consumed by Task 8 (controller). + +- [ ] **Step 1: Write the service (CreateDraft only — BuildTree and CloneNextRound come in Tasks 6/7)** + +```csharp +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using SeventyOneDev.Utilities; +using rmutr_budget_api.Modules.IncomeBudgetReport.Config; +using rmutr_budget_api.Modules.IncomeBudgetReport.Databases.Models; +using rmutr_budget_api.Modules.ReportSalary.Models; + +namespace rmutr_budget_api.Modules.IncomeBudgetReport.Services +{ + public class IncomeBudgetReportService : EntityUidService + { + private readonly Db _context; + + public IncomeBudgetReportService(Db context) : base(context) + { + _context = context; + } + + public override async Task GetEntity(Guid uid, List 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 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(); + int seq = 0; + if (statement != null) + { + foreach (var d in statement.personnel_statement_details ?? new List()) + { + 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()) + { + var amount = (d.salary_rate ?? 0) * 12; + lines.Add(new t_income_budget_report_line + { + income_budget_report_line_uid = Guid.NewGuid(), + income_budget_report_uid = reportUid, + node_key = "pb-temp-new", + source_type = "personnel_detail_2", + source_uid = d.personnel_statement_detail_2_uid, + label_th = d.full_name, + qualification_th = d.qualification, + qty = 1, + amount = amount, + original_amount = amount, + sequence_no = seq++, + }); + } + } + + await _context.t_income_budget_report.AddAsync(report); + await _context.t_income_budget_report_line.AddRangeAsync(lines); + await _context.SaveChangesAsync(identity); + + return await GetEntity(reportUid); + } + } +} +``` + +**Note for the implementer:** confirm the exact property names on `personnel_statement_detail`/`personnel_statement_detail_2` (`full_name`, `qualification`, `salary_rate`, `personnel_statement_detail_uid`) by reading `rmutr-api/Modules/ReportSalary/Databases/Models/personnel_statement_detail.cs` and `personnel_statement_detail_2.cs` before writing this file — they were confirmed present during this session's investigation but re-read them directly rather than trusting this plan's transcription, since field lists that long are easy to mistype. + +- [ ] **Step 2: Build and verify it compiles** + +```bash +cd rmutr-api +dotnet build rmutr-budget-api.csproj -c Debug 2>&1 | tail -30 +``` +Expected: `0 Error(s)`. If `personnel_statement_detail`/`_2` field names don't match, fix them here based on the actual model file — don't guess. + +- [ ] **Step 3: Commit** + +```bash +git add Modules/IncomeBudgetReport/Services/ +git commit -m "feat: add IncomeBudgetReportService.CreateDraft (pulls from ร.2)" +``` + +--- + +### Task 6: IncomeBudgetReportService — BuildTree (rollup) + +**Files:** +- Modify: `rmutr-api/Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs` +- Create: `rmutr-api/Modules/IncomeBudgetReport/Config/ReportTreeNode.cs` (the response DTOs matching the Interfaces section above) + +**Interfaces:** +- Consumes: `v_income_budget_report` with `income_budget_report_lines` loaded (Task 5's `GetEntity`). +- Produces: `IncomeBudgetReportService.BuildTree(v_income_budget_report report) : ReportTreeNode`, consumed by Task 8 (GET tree endpoint) and Task 9 (export). + +- [ ] **Step 1: Write the response DTOs** + +```csharp +using System.Collections.Generic; + +namespace rmutr_budget_api.Modules.IncomeBudgetReport.Config +{ + public class ReportTreeNode + { + public string NodeKey { get; set; } + public string Label { get; set; } + public int Depth { get; set; } + public string Kind { get; set; } + public decimal Amount { get; set; } + public bool Locked { get; set; } + public string LineUid { get; set; } + public List Children { get; set; } = new(); + public List Rows { get; set; } = new(); + } + + public class ReportTreeTableRow + { + public string LineUid { get; set; } + public string Label { get; set; } + public string Qualification { get; set; } + public decimal? Qty { get; set; } + public decimal Amount { get; set; } + public bool Locked { get; set; } + } +} +``` + +- [ ] **Step 2: Add `BuildTree` to `IncomeBudgetReportService`** + +Append to the class body from Task 5: + +```csharp + public ReportTreeNode BuildTree(v_income_budget_report report) + { + var linesByNode = (report.income_budget_report_lines ?? new List()) + .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> 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(); + + if (def.Kind == "leaf-amount") + { + var line = lines.FirstOrDefault(); + node.Amount = line?.amount ?? 0; + node.Locked = line?.source_uid != null; + node.LineUid = line?.income_budget_report_line_uid?.ToString(); + return node; + } + + // leaf-table + foreach (var line in lines) + { + node.Rows.Add(new ReportTreeTableRow + { + LineUid = line.income_budget_report_line_uid.ToString(), + Label = line.label_th, + Qualification = line.qualification_th, + Qty = line.qty, + Amount = line.amount ?? 0, + Locked = line.source_uid != null, + }); + } + node.Amount = node.Rows.Sum(r => r.Amount); + node.Locked = false; + return node; + } +``` + +Add `using System.Linq;` and `using rmutr_budget_api.Modules.IncomeBudgetReport.Config;` to the top of `IncomeBudgetReportService.cs` if not already present (the `Config` using is already there from Task 5). + +- [ ] **Step 3: Build and verify it compiles** + +```bash +cd rmutr-api +dotnet build rmutr-budget-api.csproj -c Debug 2>&1 | tail -20 +``` +Expected: `0 Error(s)`. + +- [ ] **Step 4: Commit** + +```bash +git add Modules/IncomeBudgetReport/Config/ReportTreeNode.cs Modules/IncomeBudgetReport/Services/ +git commit -m "feat: add tree rollup calculation (BuildTree)" +``` + +--- + +### Task 7: IncomeBudgetReportService — CloneNextRound + +**Files:** +- Modify: `rmutr-api/Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs` + +**Interfaces:** +- Produces: `IncomeBudgetReportService.CloneNextRound(Guid sourceUid, ClaimsIdentity identity) : Task`, consumed by Task 8. + +- [ ] **Step 1: Add `CloneNextRound` to the service** + +```csharp + public async Task 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()) + .Select(l => new t_income_budget_report_line + { + income_budget_report_line_uid = Guid.NewGuid(), + income_budget_report_uid = newUid, + node_key = l.node_key, + source_type = l.source_type, + source_uid = l.source_uid, + label_th = l.label_th, + qualification_th = l.qualification_th, + qty = l.qty, + amount = l.amount, + original_amount = l.amount, + sequence_no = l.sequence_no, + }).ToList(); + + await _context.t_income_budget_report.AddAsync(clone); + await _context.t_income_budget_report_line.AddRangeAsync(clonedLines); + await _context.SaveChangesAsync(identity); + + return await GetEntity(newUid); + } +``` + +Add `using System;` at the top if not already present (needed for `InvalidOperationException`). + +- [ ] **Step 2: Build and verify it compiles** + +```bash +cd rmutr-api +dotnet build rmutr-budget-api.csproj -c Debug 2>&1 | tail -20 +``` +Expected: `0 Error(s)`. + +- [ ] **Step 3: Commit** + +```bash +git add Modules/IncomeBudgetReport/Services/ +git commit -m "feat: add CloneNextRound for round-to-round continuation (type 1->2->3)" +``` + +--- + +### Task 8: IncomeBudgetReportController + +**Files:** +- Create: `rmutr-api/Modules/IncomeBudgetReport/Controllers/IncomeBudgetReportController.cs` + +**Interfaces:** +- Consumes: `IncomeBudgetReportService` (Tasks 5-7). +- Produces: the 5 HTTP endpoints listed below, consumed by Task 11 (frontend service). + +- [ ] **Step 1: Write the controller** + +```csharp +using System; +using System.Collections.Generic; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using rmutr_budget_api.Modules.IncomeBudgetReport.Databases.Models; +using rmutr_budget_api.Modules.IncomeBudgetReport.Services; +using SeventyOneDev.Utilities; + +namespace rmutr_budget_api.Modules.IncomeBudgetReport.Controllers +{ + public class LineAmountUpdate + { + public Guid income_budget_report_line_uid { get; set; } + public decimal amount { get; set; } + } + + [Route("api/setting/income_budget_report", Name = "income_budget_report")] + public class IncomeBudgetReportController : BaseUidController + { + 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> 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> 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 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 UpdateLineAmounts([FromBody] List updates) + { + foreach (var u in updates) + { + var line = await _db.t_income_budget_report_line.FindAsync(u.income_budget_report_line_uid); + if (line != null) line.amount = u.amount; + } + await _db.SaveChangesAsync(User.Identity as ClaimsIdentity); + return Ok(); + } + } +} +``` + +- [ ] **Step 2: Build and verify it compiles** + +```bash +cd rmutr-api +dotnet build rmutr-budget-api.csproj -c Debug 2>&1 | tail -20 +``` +Expected: `0 Error(s)`. + +- [ ] **Step 3: Verify endpoints exist via Swagger (dev server)** + +```bash +cd rmutr-api +dotnet run --urls http://localhost:5050 & +sleep 15 +curl -s http://localhost:5050/swagger/v1/swagger.json | python3 -c "import json,sys; d=json.load(sys.stdin); print([k for k in d['paths'] if 'income_budget_report' in k])" +kill %1 +``` +Expected: a list containing `/api/setting/income_budget_report/create_draft`, `/api/setting/income_budget_report/{uid}/clone_next_round`, `/api/setting/income_budget_report/{uid}/tree`, `/api/setting/income_budget_report/lines`, and the base CRUD paths from `BaseUidController`. + +- [ ] **Step 4: Commit** + +```bash +git add Modules/IncomeBudgetReport/Controllers/ +git commit -m "feat: add IncomeBudgetReportController with create_draft/clone_next_round/tree/lines endpoints" +``` + +--- + +### Task 9: Export to Excel + +**Files:** +- Modify: `rmutr-api/Modules/IncomeBudgetReport/Controllers/IncomeBudgetReportController.cs` + +**Interfaces:** +- Consumes: `BuildTree` (Task 6). +- Produces: `GET /api/setting/income_budget_report/{uid}/export/xlsx`, consumed by the frontend "ส่งออก Excel" button (Task 12). + +- [ ] **Step 1: Add the export endpoint** + +```csharp + [Authorize] + [HttpGet("{uid:guid}/export/xlsx")] + public async Task ExportXlsx([FromRoute] Guid uid) + { + var report = await _service.GetEntity(uid); + if (report == null) return NotFound(); + var tree = _service.BuildTree(report); + + using var package = new OfficeOpenXml.ExcelPackage(); + var ws = package.Workbook.Worksheets.Add("Page1"); + ws.Cells[1, 1].Value = "รายการ"; + ws.Cells[1, 2].Value = "จำนวนเงิน (บาท)"; + int row = 2; + WriteNode(tree, ws, ref row); + ws.Column(1).Width = 60; + ws.Column(2).Width = 20; + + var bytes = package.GetAsByteArray(); + var fileName = $"income_budget_report_{uid}.xlsx"; + return File(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", fileName); + } + + private static void WriteNode(Config.ReportTreeNode node, OfficeOpenXml.ExcelWorksheet ws, ref int row) + { + var indent = new string(' ', node.Depth * 4); + ws.Cells[row, 1].Value = indent + node.Label; + ws.Cells[row, 2].Value = node.Amount; + row++; + if (node.Kind == "leaf-table") + { + foreach (var r in node.Rows) + { + ws.Cells[row, 1].Value = indent + " - " + r.Label; + ws.Cells[row, 2].Value = r.Amount; + row++; + } + } + foreach (var child in node.Children) + { + WriteNode(child, ws, ref row); + } + } +``` + +Add `using OfficeOpenXml;` to the top of the file if preferred over the fully-qualified names used above (either works — the fully-qualified form avoids a namespace collision check). + +- [ ] **Step 2: Build and verify it compiles** + +```bash +cd rmutr-api +dotnet build rmutr-budget-api.csproj -c Debug 2>&1 | tail -20 +``` +Expected: `0 Error(s)`. + +- [ ] **Step 3: Commit** + +```bash +git add Modules/IncomeBudgetReport/Controllers/ +git commit -m "feat: add xlsx export endpoint for income budget report" +``` + +--- + +### Task 10: Frontend generic tree component + +**Files:** +- Create: `rmutr-web/src/app/shared/components/budget-report-tree/budget-report-tree-node.component.ts` +- Create: `rmutr-web/src/app/shared/components/budget-report-tree/budget-report-tree-node.component.html` +- Create: `rmutr-web/src/app/shared/components/budget-report-tree/budget-report-tree-node.component.scss` +- Create: `rmutr-web/src/app/shared/components/budget-report-tree/budget-report-tree.module.ts` + +**Interfaces:** +- Consumes: `ReportTreeNode`/`ReportTreeTableRow` JSON shape from the Interfaces section (Task 8's `/tree` endpoint). +- Produces: `` component, consumed by Task 12. + +This ports the mockup's interaction design (recursive `
`, indentation via depth, live recalculation) into a real Angular component. Unlike the mockup, amounts come pre-computed from the backend on load; the component only needs to recompute a node's own displayed total when one of ITS OWN rows/leaf inputs changes (bubbling the recalculation up through `@Output` events, since Angular components can't share mutable DOM state across siblings the way the static mockup's global `recalc()` did). + +- [ ] **Step 1: Write the component class** + +```typescript +import { Component, EventEmitter, Input, Output } from '@angular/core'; + +export interface ReportTreeTableRow { + lineUid: string; + label: string; + qualification: string | null; + qty: number | null; + amount: number; + locked: boolean; +} + +export interface ReportTreeNode { + nodeKey: string; + label: string; + depth: number; + kind: 'group' | 'leaf-amount' | 'leaf-table'; + amount: number; + locked: boolean; + lineUid: string | null; + children: ReportTreeNode[]; + rows: ReportTreeTableRow[]; +} + +export interface LineAmountChange { + lineUid: string; + amount: number; +} + +@Component({ + selector: 'app-budget-report-tree-node', + templateUrl: './budget-report-tree-node.component.html', + styleUrls: ['./budget-report-tree-node.component.scss'], +}) +export class BudgetReportTreeNodeComponent { + @Input() node: ReportTreeNode; + @Output() amountChange = new EventEmitter(); + + onLeafAmountInput(value: string): void { + const amount = parseFloat(value) || 0; + this.node.amount = amount; + this.amountChange.emit({ lineUid: this.node.lineUid, amount }); + } + + onRowAmountInput(row: ReportTreeTableRow, value: string): void { + const amount = parseFloat(value) || 0; + row.amount = amount; + this.node.amount = this.node.rows.reduce((s, r) => s + (r.amount || 0), 0); + this.amountChange.emit({ lineUid: row.lineUid, amount }); + } + + onChildAmountChange(event: LineAmountChange): void { + this.node.amount = this.node.children.reduce((s, c) => s + (c.amount || 0), 0); + this.amountChange.emit(event); + } + + trackByNodeKey(_i: number, n: ReportTreeNode): string { return n.nodeKey; } + trackByLineUid(_i: number, r: ReportTreeTableRow): string { return r.lineUid; } +} +``` + +- [ ] **Step 2: Write the template** + +```html +
+
+ {{ node.label }} + + + + {{ node.amount | number:'1.2-2' }} + + + + {{ node.amount | number:'1.2-2' }} + +
+ + + + + + + + + + + + + + + + + + +
รายการวุฒิอัตราจำนวนเงิน (บาท)
{{ row.label }}{{ row.qualification }}{{ row.qty }} + + + {{ row.amount | number:'1.2-2' }} + +
+

ยังไม่มีรายการ

+ +
+ +
+
+``` + +- [ ] **Step 3: Write minimal styles** + +```scss +.tree-node { + padding-left: calc(var(--depth, 0) * 20px); +} +.row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 8px; + gap: 12px; +} +.row-label { font-weight: 500; } +.amount-input { + width: 120px; + text-align: right; + font-variant-numeric: tabular-nums; +} +.amount-readonly, .amount-computed { + font-variant-numeric: tabular-nums; + min-width: 120px; + text-align: right; + display: inline-block; +} +.leaf-table { + width: 100%; + border-collapse: collapse; + margin: 4px 0 8px; + th, td { padding: 4px 8px; border-bottom: 1px solid #e2e6ed; text-align: left; } + th.num, td.num { text-align: right; } +} +.empty-hint { color: #8993a8; font-size: 12.5px; padding-left: 8px; } +``` + +- [ ] **Step 4: Write the module** + +```typescript +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { BudgetReportTreeNodeComponent } from './budget-report-tree-node.component'; + +@NgModule({ + declarations: [BudgetReportTreeNodeComponent], + imports: [CommonModule, FormsModule], + exports: [BudgetReportTreeNodeComponent], +}) +export class BudgetReportTreeModule {} +``` + +- [ ] **Step 5: Verify — type-check the whole frontend** + +```bash +cd rmutr-web +npx tsc --noEmit -p tsconfig.app.json 2>&1 | grep -i "budget-report-tree" | head -30 +``` +Expected: no output (no errors referencing the new files). Note: this only catches type errors, not template errors — Task 12's `ng build` will catch template binding mistakes. + +- [ ] **Step 6: Commit** + +```bash +git add src/app/shared/components/budget-report-tree/ +git commit -m "feat: add generic recursive budget report tree component" +``` + +--- + +### Task 11: Frontend API service + +**Files:** +- Create: `rmutr-web/src/app/core/service/setting/income-budget-report.service.ts` + +**Interfaces:** +- Consumes: endpoints from Tasks 8-9. +- Produces: `IncomeBudgetReportService.createDraft/cloneNextRound/getTree/exportXlsxUrl/updateLineAmounts`, used by Task 12. + +- [ ] **Step 1: Write the service** + +```typescript +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { BaseService } from 'src/app/core/base/base-service'; +import { environment } from 'src/environments/environment'; +import { ReportTreeNode } from 'src/app/shared/components/budget-report-tree/budget-report-tree-node.component'; + +@Injectable({ providedIn: 'root' }) +export class IncomeBudgetReportService extends BaseService { + constructor(http: HttpClient, private httpClient: HttpClient) { + super('/setting/income_budget_report', http); + } + + createDraft(facultyUid: string, budgetYearUid: string): Observable { + 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 { + const url = `${environment.rmutrApi}/api/setting/income_budget_report/${uid}/clone_next_round`; + return this.httpClient.post(url, {}); + } + + getTree(uid: string): Observable { + const url = `${environment.rmutrApi}/api/setting/income_budget_report/${uid}/tree`; + return this.httpClient.get(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 { + const url = `${environment.rmutrApi}/api/setting/income_budget_report/lines`; + return this.httpClient.put(url, updates); + } +} +``` + +Check `rmutr-web/src/app/core/base/base-service.ts`'s constructor signature before writing this file — confirm it takes `(endpoint: string, http: HttpClient)` in that order (matches every other service seen this session, e.g. `announcement.service.ts`, but verify directly). + +- [ ] **Step 2: Verify — type-check** + +```bash +cd rmutr-web +npx tsc --noEmit -p tsconfig.app.json 2>&1 | grep -i "income-budget-report.service" | head -30 +``` +Expected: no output. + +- [ ] **Step 3: Commit** + +```bash +git add src/app/core/service/setting/income-budget-report.service.ts +git commit -m "feat: add IncomeBudgetReportService frontend client" +``` + +--- + +### Task 12: Rewrite the page component + +**Files:** +- Modify: `rmutr-web/src/app/feature/income/draft/income-budget-report1/income-budget-report1/income-budget-report.component.ts` (full rewrite) +- Modify: `rmutr-web/src/app/feature/income/draft/income-budget-report1/income-budget-report1/income-budget-report.component.html` (full rewrite) +- Modify: `rmutr-web/src/app/feature/income/draft/income-budget-report1/income-budget-report.module.ts` (add `BudgetReportTreeModule` import) + +**Interfaces:** +- Consumes: `IncomeBudgetReportService` (Task 11), `BudgetReportTreeModule`/`BudgetReportTreeNodeComponent` (Task 10). +- Scope: this task only rewires **`type_id == 1`** behavior (the `/income-budget-report-income1/add` route this whole plan targets). Leave the `type == 2..10` branches in the existing `ngOnInit` switch alone for now — they still reference the old SyncFusion members, which is fine because Task 13's verification only exercises type 1. Wiring type 2/3 into this rewritten component is 1-2 lines each (call `cloneNextRound` instead of `createDraft`) but is explicitly **out of scope** for this plan per the Group A/B split agreed during brainstorming — leave the `NOTE(group-b-followup)` comment shown in Step 2 at the relevant branch rather than implementing it now, so the next plan has an obvious anchor. + +- [ ] **Step 1: Read the current component fully before rewriting** + +```bash +cat rmutr-web/src/app/feature/income/draft/income-budget-report1/income-budget-report1/income-budget-report.component.ts +``` +Note the `budgetYearSV`, `FacultySV`, and route param handling (`this.form.get('type_id')`) — the rewrite keeps the faculty/budget-year selector UI but drops everything SyncFusion-related. + +- [ ] **Step 2: Write the new component (type_id==1 path only)** + +```typescript +import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { FacultyService } from 'src/app/core/service/general/faculty.service'; +import { BudgetYearService } from 'src/app/core/service/budget/budget-year.service'; +import { IncomeBudgetReportService } from 'src/app/core/service/setting/income-budget-report.service'; +import { ReportTreeNode, LineAmountChange } from 'src/app/shared/components/budget-report-tree/budget-report-tree-node.component'; + +@Component({ + selector: 'app-income-budget-report', + templateUrl: './income-budget-report.component.html', + styleUrls: ['./income-budget-report.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class IncomeBudgetReportComponent implements OnInit { + typeId: number; + reportUid: string | null = null; + tree: ReportTreeNode | null = null; + facultyUid: string | null = null; + budgetYearUid: string | null = null; + pendingChanges = new Map(); + + faculty$ = new Observable(); + budgetYear$ = new Observable(); + + constructor( + private activatedRoute: ActivatedRoute, + private router: Router, + private cdRef: ChangeDetectorRef, + private facultySV: FacultyService, + private budgetYearSV: BudgetYearService, + private incomeBudgetReportSV: IncomeBudgetReportService, + ) {} + + ngOnInit(): void { + this.faculty$ = this.facultySV.getAll(); + this.budgetYear$ = this.budgetYearSV.getAll(); + this.activatedRoute.data.pipe( + tap((x: any) => this.typeId = x.type), + ).subscribe(); + } + + onFacultyChange(facultyUid: string): void { + this.facultyUid = facultyUid; + this.tryCreateDraft(); + } + + onBudgetYearChange(budgetYearUid: string): void { + this.budgetYearUid = budgetYearUid; + this.tryCreateDraft(); + } + + private tryCreateDraft(): void { + if (!this.facultyUid || !this.budgetYearUid) return; + this.incomeBudgetReportSV.createDraft(this.facultyUid, this.budgetYearUid).pipe( + tap((report: any) => { + this.reportUid = report.income_budget_report_uid; + this.loadTree(); + }), + ).subscribe(); + } + + loadTree(): void { + if (!this.reportUid) return; + this.incomeBudgetReportSV.getTree(this.reportUid).pipe( + tap((tree) => { this.tree = tree; this.cdRef.detectChanges(); }), + ).subscribe(); + } + + hasNoPersonnelData(): boolean { + if (!this.tree) return false; + const pb = this.tree.children.find(c => c.nodeKey === 'pb'); + const temp = pb?.children.find(c => c.nodeKey === 'pb-temp'); + const oldRates = temp?.children.find(c => c.nodeKey === 'pb-temp-old'); + const newRates = temp?.children.find(c => c.nodeKey === 'pb-temp-new'); + return !oldRates?.rows.length && !newRates?.rows.length; + } + + onAmountChange(change: LineAmountChange): void { + this.pendingChanges.set(change.lineUid, change.amount); + } + + save(): void { + if (!this.reportUid || this.pendingChanges.size === 0) return; + const lines = Array.from(this.pendingChanges.entries()).map(([lineUid, amount]) => ({ + income_budget_report_line_uid: lineUid, + amount, + })); + this.incomeBudgetReportSV.updateLineAmounts(lines).pipe( + tap(() => { this.pendingChanges.clear(); this.loadTree(); }), + ).subscribe(); + } + + exportXlsx(): void { + if (!this.reportUid) return; + window.open(this.incomeBudgetReportSV.exportXlsxUrl(this.reportUid), '_blank'); + } + + // NOTE(group-b-followup): wire cloneNextRound() here instead of createDraft() when this + // component is extended to handle income-budget-report-income2/3 — tracked as a follow-up + // plan, not implemented here. See docs/superpowers/plans/2026-08-05-income-budget-report-native-group-a.md Task 12. +} +``` + +`save()` calls `IncomeBudgetReportService.updateLineAmounts()` (Task 11), which PUTs to `/api/setting/income_budget_report/lines` — that endpoint was added in Task 8 Step 1 (`IncomeBudgetReportController.UpdateLineAmounts`). No backend change needed in this task. + +- [ ] **Step 3: Write the new template** + +```html +
+
+ + หน่วยงาน + + {{ f.faculty_name_th }} + + + + ปีงบประมาณ + + {{ y.budget_year_name_th }} + + +
+ +
+ ยังไม่มีข้อมูลตำแหน่ง/อัตรากำลังจาก ร.2 สำหรับหน่วยงานและปีงบประมาณนี้ + กรอกฟอร์ม ร.2 ก่อน +
+ +
+ รวมทั้งสิ้น: {{ tree.amount | number:'1.2-2' }} บาท + + +
+ + +
+``` + +- [ ] **Step 4: Wire `BudgetReportTreeModule` into the feature module** + +Read `rmutr-web/src/app/feature/income/draft/income-budget-report1/income-budget-report.module.ts` and add to its `imports` array: +```typescript +import { BudgetReportTreeModule } from 'src/app/shared/components/budget-report-tree/budget-report-tree.module'; +// ... +imports: [ + // ...existing imports... + BudgetReportTreeModule, +], +``` + +- [ ] **Step 5: Build and verify** + +```bash +cd rmutr-web +node --max_old_space_size=8192 node_modules/@angular/cli/bin/ng build --configuration production 2>&1 | tail -60 +``` +Expected: build succeeds (warnings OK, 0 errors). This is the first point template binding errors would surface — read the output carefully. + +- [ ] **Step 6: Commit** + +```bash +git add src/app/feature/income/draft/income-budget-report1/ +git commit -m "feat: replace SyncFusion spreadsheet with native tree component (type 1)" +``` + +--- + +### Task 13: End-to-end verification against real data + +**Files:** none (verification only) + +- [ ] **Step 1: Deploy backend and frontend to the dev/staging environment** (follow this session's established deploy steps: `dotnet publish -c Release -r linux-x64`, scp the DLL, restart the systemd service; `ng build --configuration production`, rsync to the web server) + +- [ ] **Step 2: Pick a faculty+year that already has a real ร.2 record** (this session confirmed `personnel_statement` records exist for `faculty_uid` matching "บพิตรพิมุข จักรวรรดิ / คณะศิลปศาสตร์" for `budget_year_name_th = '2569'`) — verify via: + +```bash +export PGPASSWORD='vtwi,yo0tpkd-okfouh' +/opt/homebrew/Cellar/postgresql@16/16.14/bin/psql -h 203.158.221.28 -p 31620 -U devadmin -d rmutr_budget_050625 \ + -c "SELECT personnel_statement_uid, faculty_uid, agency_name_th, budget_year_uid FROM \"BUDGET\".personnel_statement LIMIT 5;" +``` + +- [ ] **Step 3: In the browser, open `/app/income-budget-report-income1/add`, select that faculty+year, and confirm:** + - The tree renders with "รายการบุคลากร" at the top and the same category labels seen in the real downloaded `type1.xlsx` (`งบบุคลากร`, `งบดำเนินงาน`, `แผนงานยุทธศาสตร์พัฒนาศักยภาพคนตลอดช่วงชีวิต`, ...) + - Position rows under "อัตราเดิม" are **read-only** (no input box, just text) and match names/amounts in `personnel_statement_detail` for that faculty+year + - Editing an unlocked amount (e.g. "ค่าตอบแทน ค่าใช้สอยและค่าวัสดุ") updates the parent totals live + - "บันทึก" persists the change — reload the page and confirm the edited value survived + - "ส่งออก Excel" downloads a `.xlsx` file that opens and shows the same numbers + +- [ ] **Step 4: Compare the grand total** against the real downloaded file for the same faculty (`/tmp/mockup_fonts/type2.xlsx` from this session's investigation, or re-download the latest `revenue_draft_committee` record for that faculty) — the personnel-derived subtotal should match `personnel_statement_detail` records summed × 12 exactly, since Task 5's `CreateDraft` uses the same `salary_rate * 12` formula the old system used (`Personnel.cs:2345`) + +- [ ] **Step 5: Document any mismatch found as a follow-up task** — do not silently patch numbers to make them match without understanding why they differed first. + +--- + +## Explicitly out of scope for this plan (tracked for the Group B follow-up plan) + +- `type_id` 2, 3 UI wiring (backend `CloneNextRound` supports it; frontend component's faculty/year picker → clone flow is not built) +- `type_id` 4, 5, 6 (Group B — multi-tab workbook, different tree schema, different source tables ร.4/ร.5/ร.6) +- `type_id` 10 (Group C — matrix report, entirely different data model) +- Deprecating/removing the old `revenue_draft_committee` table and SyncFusion dependency (leave both in place until Group B ships too, per the phased/pilot rollout agreed in the spec)