15 tasks covering backend (approve_state/remark_history FK, evaluate_form status fields, evaluate_form_review table, approve/reject endpoint branches, central-review controller) and frontend (approver picker, send-approve, approve/reject UI, review-status card, follow-list buckets). request_approve_type "09" confirmed free against production. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mf6qFtiQoRuSh9zQSPF1wS
2466 lines
112 KiB
Markdown
2466 lines
112 KiB
Markdown
# evaluate-collect → evaluate-follow Approve Workflow Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Add a 2-level self-service approval chain to `evaluate_form` (evaluate-collect) with a central-review stage on evaluate-follow, mirroring the existing request-budget type_4 approve_state/remark_history/request_budget_review pattern.
|
|
|
|
**Architecture:** Reuse the existing shared `approve_state`/`remark_history` tables (add an `evaluate_form_uid` FK column to each, new `request_approve_type` code `"09"`), add three status fields to `evaluate_form` (`is_sent`/`is_approve`/`current_approve_name`), extend the shared `ApproveStateController` approve/reject endpoints with a new branch, and add a new `evaluate_form_review` table + controller mirroring `request_budget_review` for the evaluate-follow central-review stage. Frontend ports the exact button/dialog/service pattern already used for คำขอแผ่นดิน (type_4) onto `evaluate-collect-send` and a new review-status component on `evaluate-follow-send`.
|
|
|
|
**Tech Stack:** ASP.NET Core 8 / EF Core (rmutr-api, branch `master`), Angular 17 standalone-ish module app with Angular Material (rmutr-web, branch `master`). No automated test framework in either repo — verification is manual `curl` + live SQL checks (`python3`+`psycopg2`, since `psql` is not installed locally) + `ng build` + browser walkthrough, per this project's established convention (see `docs/superpowers/plans/2026-09-08-request-budget-approve-workflow.md` line 27).
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-09-24-evaluate-collect-approve-workflow-design.md`
|
|
|
|
## Global Constraints
|
|
|
|
- New `request_approve_type` code for this feature is **`"09"`** — confirmed live against the production DB (`rmutr_budget_050625`, the DB the deployed API's `appsettings.json` `"postgres"` connection string actually points at) on 2026-09-24: `SELECT DISTINCT request_approve_type FROM "BUDGET".approve_state` returned `01,02,03,04,05,07,08`, and a full-repo grep of every `request_approve_type ==`/`= "..."` literal confirmed `"06"` is also already reserved (invest_construct self-service) even though no row uses it yet. Re-run Task 1's verification step before writing code, in case another branch claimed `"09"` since this plan was written.
|
|
- Approver labels are fixed 2-level, identical to request-budget: `['หัวหน้าหน่วยงาน', 'รองอธิการบดี']` — level 1 required, level 2 optional. Reuse `SetApproversDialogComponent` (`src/app/shared/components/set-approvers-dialog/`) unchanged; it is already generic.
|
|
- Identity check on approve/reject must be enforced at **every** `row_order`, not just `row_order == 1` (the bug already fixed for `request_expense` in commit `c12b93bd` — do not reintroduce the narrower check for the new branch).
|
|
- Reject during the approval chain steps back **one row_order at a time** (not straight back to the creator) — `row_order == 1` rejecting itself resets `is_sent = false` and returns to the data-entry user; `row_order > 1` rejecting steps back to `row_order - 1` without touching `is_sent`.
|
|
- `return_for_edit` on the central-review stage reopens the **last** `approve_state` row (highest `row_order`), not row 1.
|
|
- Production DB connection (already in `rmutr-api/appsettings.json`, not a new secret): `Host=203.158.221.28;Username=devadmin;Password=vtwi,yo0tpkd-okfouh;Database=rmutr_budget_050625;port=31620`. All SQL verification steps in this plan use this connection via `python3 -c "import psycopg2; ..."`.
|
|
- No `psql` binary is available in this environment — every "verify via SQL" step in this plan uses a small inline `python3` + `psycopg2` script instead.
|
|
- Test data must use `created_by = 'test.verify'` (matches the project's established synthetic-data convention, e.g. `docs/superpowers/plans/2026-09-07-invest-asset-self-service-approval.md` line 42) and must be deleted at the end of each backend task's verification.
|
|
- Backend `dotnet build` from `/Users/nut.looknut/Project/rmutr/rmutr-api` takes ~8-9 minutes when the worktree is otherwise idle (per this session's own measurement) — check for stray concurrent `dotnet build`/`dotnet publish` processes (`ps aux | grep dotnet`) before starting a build, per this project's known worktree-contention issue.
|
|
- Frontend `npm run build` (`ng build --configuration=production`) from `/Users/nut.looknut/Project/rmutr/rmutr-web` takes under a minute when idle; use plain `ng build` (dev config) for faster iteration during a task, and the production build only as the task's final verification step.
|
|
- Do all work in a fresh git worktree per repo (`superpowers:using-git-worktrees` convention already used elsewhere in this project, e.g. `.worktrees/feat-request-budget-approve-workflow`), not directly in the primary checkout — this project has had concurrent-session build collisions when that rule was skipped.
|
|
|
|
## Review Focus
|
|
|
|
- **Approve/reject called before "ส่งอนุมัติ" was ever clicked.** `SetCustomApprovers` seeds `row_order 1` with `is_state = true` immediately, before `is_sent` is ever set — calling `approve`/`reject` in that window must 400 with "ยังไม่ได้ส่งอนุมัติ กรุณากด \"ส่งอนุมัติ\" ก่อน", not silently approve. Covered in Task 7.
|
|
- **Only one approver configured (row 2 left empty).** The final approval on `row_order 1` alone (no `row_order 2` row exists) must still correctly flip `evaluate_form.is_approve = true`, trigger `final_active`, and insert the pending `evaluate_form_review` row — not get stuck waiting for a `row_order + 1` that will never exist. Covered in Task 7.
|
|
- **Wrong person clicks approve/reject.** A JWT whose `display_name` claim does not match `approve_state.display_name_th` for the currently pending row must get 403, at every `row_order` (not just row 1). Covered in Task 7.
|
|
- **Row 1 rejects itself (self-reject).** Must reset `is_sent = false` (so "ส่งอนุมัติ" can be clicked again after the data-entry user fixes something) and must leave `evaluate_form.is_active` untouched (the record was never finalized). Covered in Task 8.
|
|
- **Central-review actions called on a record that hasn't finished the 2-level chain.** `return_for_edit`/`mark_reviewed` on an `evaluate_form` where `is_approve != true` must 400, not silently log a status row for a record still mid-approval. Covered in Task 9.
|
|
|
|
---
|
|
|
|
## Task 1: Confirm the next free `request_approve_type` code
|
|
|
|
**Files:** none (verification-only task; its output, `"09"`, is a literal substituted into every later task's code — do not leave `<code>` placeholders anywhere below).
|
|
|
|
**Interfaces:**
|
|
- Produces: the literal string `"09"`, used as `request_approve_type` in Tasks 5-8.
|
|
|
|
- [ ] **Step 1: Re-run the live check**
|
|
|
|
```bash
|
|
python3 <<'EOF'
|
|
import psycopg2
|
|
conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin",
|
|
password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15)
|
|
cur = conn.cursor()
|
|
cur.execute('SELECT DISTINCT request_approve_type, count(*) FROM "BUDGET".approve_state GROUP BY 1 ORDER BY 1;')
|
|
for row in cur.fetchall():
|
|
print(row)
|
|
conn.close()
|
|
EOF
|
|
```
|
|
|
|
Expected: rows for `01,02,03,04,05,07,08` (no `06` row is expected — it's reserved in code for invest_construct self-service but has zero live rows — and no `09` row should exist yet). If a `09` row now exists, stop and pick `"10"` instead, updating every later task's literal accordingly.
|
|
|
|
- [ ] **Step 2: Confirm no code beyond what Step 1 shows is reserved in source**
|
|
|
|
```bash
|
|
cd /Users/nut.looknut/Project/rmutr/rmutr-api
|
|
grep -rn 'request_approve_type\s*==\s*"[0-9]*"\|request_approve_type\s*=\s*"[0-9]*"\|approveTypeCode\s*=\s*"[0-9]*"' --include="*.cs" . | grep -v .worktrees | sort -u
|
|
```
|
|
|
|
Expected: only codes `"03","04","05","06","07","08"` appear (plus the default-param `"04"`). If `"09"` appears anywhere already, someone else claimed it — pick the next free code instead and update this plan's Global Constraints line and every task below before proceeding.
|
|
|
|
- [ ] **Step 3: No commit for this task** (verification only — proceed to Task 2 using `"09"`)
|
|
|
|
---
|
|
|
|
## Task 2: `evaluate_form_uid` column on `approve_state`/`remark_history`
|
|
|
|
**Files:**
|
|
- Modify: `Modules/Setting/Databases/Models/approve_state.cs`
|
|
- Modify: `Modules/Requests/Databases/Models/remark_history.cs`
|
|
- Modify: `Modules/Evaluate/Databases/Models/evaluate_form/evaluate_form.cs`
|
|
- Modify: `Modules/Setting/Databases/Db.cs` (wire `v_approve_state.evaluate_form` ↔ `evaluate_form.approve_states`)
|
|
- Modify: `Modules/Requests/Databases/Db.cs` (wire `v_remark_history.evaluate_form` ↔ `evaluate_form.remark_historys`)
|
|
- Create: `Migrations/<timestamp>_AddEvaluateFormUidToApproveStateAndRemarkHistory.cs` (via `dotnet ef migrations add`)
|
|
- Create: `Migrations/add_evaluate_form_uid_to_approve_state_and_remark_history_views.sql`
|
|
- Modify: `Startup.cs` (register the new raw-SQL migration)
|
|
|
|
**Interfaces:**
|
|
- Produces: `approve_state.evaluate_form_uid` (nullable `Guid?`) and `remark_history.evaluate_form_uid` (nullable `Guid?`), both queryable through `v_approve_state`/`v_remark_history`; `evaluate_form.approve_states : List<v_approve_state>` and `evaluate_form.remark_historys : List<v_remark_history>` reverse-nav collections, mirroring exactly how `request_expense.approve_states`/`.remark_historys` are wired (confirmed by reading `Modules/Setting/Databases/Db.cs:316-326` and `Modules/Requests/Databases/Db.cs:91-95` — the `request_expense`/`invest_asset`/`invest_construct` precedent).
|
|
|
|
- [ ] **Step 1: Add the property to `approve_state`**
|
|
|
|
Edit `Modules/Setting/Databases/Models/approve_state.cs`, in the `approve_state` base class (after `request_expense_uid`):
|
|
|
|
```csharp
|
|
public Guid? request_expense_uid { get; set; }
|
|
public Guid? evaluate_form_uid { get; set; }
|
|
```
|
|
|
|
Also add the nav property to `v_approve_state`:
|
|
|
|
```csharp
|
|
public class v_approve_state : approve_state
|
|
{
|
|
[JsonIgnore]
|
|
public v_invest_asset invest_asset { get; set; }
|
|
[JsonIgnore]
|
|
public v_invest_construct invest_construct { get; set; }
|
|
[JsonIgnore]
|
|
public v_request_expense request_expense { get; set; }
|
|
[JsonIgnore]
|
|
public v_evaluate_form evaluate_form { get; set; }
|
|
}
|
|
```
|
|
|
|
(Add `using rmutr_budget_api.Modules.Evaluate.Databases.Models;` to this file's `using` block for `v_evaluate_form` to resolve.)
|
|
|
|
- [ ] **Step 2: Add the property to `remark_history`**
|
|
|
|
Edit `Modules/Requests/Databases/Models/remark_history.cs`, in the `remark_history` base class (after `request_expense_uid`):
|
|
|
|
```csharp
|
|
public Guid? request_expense_uid { get; set; }
|
|
public Guid? evaluate_form_uid { get; set; }
|
|
```
|
|
|
|
Also add the reverse nav to `v_remark_history` (mirroring `v_remark_history.request_expense` a few lines above it):
|
|
|
|
```csharp
|
|
[JsonIgnore] public v_request_expense request_expense { get; set; }
|
|
[JsonIgnore] public v_evaluate_form evaluate_form { get; set; }
|
|
```
|
|
|
|
(Add `using rmutr_budget_api.Modules.Evaluate.Databases.Models;` to this file's `using` block.)
|
|
|
|
- [ ] **Step 3: Add the reverse-nav collections to `evaluate_form` and wire them in the two `Db.cs` files**
|
|
|
|
Edit `Modules/Evaluate/Databases/Models/evaluate_form/evaluate_form.cs`, add to the `evaluate_form` base class:
|
|
|
|
```csharp
|
|
[Include]
|
|
public Guid? evaluate_form_uid { get; set; }
|
|
```
|
|
|
|
(that line already exists — do not duplicate it; instead add these two new lines near the other `[Include]` collections, e.g. right after `evaluate_time_period_uid`):
|
|
|
|
```csharp
|
|
[Include]
|
|
public List<v_approve_state> approve_states { get; set; }
|
|
[Include]
|
|
public List<v_remark_history> remark_historys { get; set; }
|
|
```
|
|
|
|
Add `using rmutr_budget_api.Modules.General.Databases.Models;` and `using rmutr_budget_api.Modules.Request.Databases.Models;` to this file's `using` block for `v_approve_state`/`v_remark_history` to resolve.
|
|
|
|
Edit `Modules/Setting/Databases/Db.cs`, add immediately after the existing `v_approve_state.request_expense` wiring (after line 326, `.OnDelete(DeleteBehavior.Cascade);`):
|
|
|
|
```csharp
|
|
builder.Entity<v_approve_state>().HasOne(p => p.evaluate_form).WithMany(p => p.approve_states)
|
|
.HasForeignKey(p => p.evaluate_form_uid)
|
|
.OnDelete(DeleteBehavior.Cascade);
|
|
```
|
|
|
|
(Add `using rmutr_budget_api.Modules.Evaluate.Databases.Models;` to this file's `using` block if not already present.)
|
|
|
|
Edit `Modules/Requests/Databases/Db.cs`, add immediately after the existing `v_remark_history.request_expense` wiring (after the block ending `.OnDelete(DeleteBehavior.Cascade);` around line 94):
|
|
|
|
```csharp
|
|
builder.Entity<t_remark_history>().HasOne(p => p.evaluate_form).WithMany(p => p.remark_historys)
|
|
.HasForeignKey(p => p.evaluate_form_uid)
|
|
.OnDelete(DeleteBehavior.Cascade);
|
|
builder.Entity<v_remark_history>().HasOne(p => p.evaluate_form).WithMany(p => p.remark_historys)
|
|
.HasForeignKey(p => p.evaluate_form_uid)
|
|
.OnDelete(DeleteBehavior.Cascade);
|
|
```
|
|
|
|
Note `t_remark_history` also needs an `evaluate_form` nav property for the first of these two calls to compile — add `[JsonIgnore] public t_evaluate_form evaluate_form { get; set; }` to the `t_remark_history` class in `remark_history.cs` (next to its other `[JsonIgnore] public t_request_expense request_expense { get; set; }` line), and add the matching `using` for `t_evaluate_form`.
|
|
|
|
- [ ] **Step 4: Generate the EF Core migration**
|
|
|
|
```bash
|
|
cd /Users/nut.looknut/Project/rmutr/rmutr-api
|
|
dotnet ef migrations add AddEvaluateFormUidToApproveStateAndRemarkHistory
|
|
```
|
|
|
|
Open the generated `Migrations/<timestamp>_AddEvaluateFormUidToApproveStateAndRemarkHistory.cs` and confirm its `Up()` contains exactly two `migrationBuilder.AddColumn<Guid>(name: "evaluate_form_uid", schema: "BUDGET", table: "approve_state"/"remark_history", type: "uuid", nullable: true)` calls (mirroring the shape of `20260820041930_AddRoundNoToApproveStateAndRemarkHistory.cs`) — if `dotnet ef` also picked up unrelated model drift, stop and investigate before proceeding (do not let an unrelated migration ship inside this task's commit).
|
|
|
|
- [ ] **Step 5: Write the view-refresh raw SQL migration**
|
|
|
|
Create `Migrations/add_evaluate_form_uid_to_approve_state_and_remark_history_views.sql`:
|
|
|
|
```sql
|
|
-- Migration: expose evaluate_form_uid (added to approve_state/remark_history base tables by
|
|
-- AddEvaluateFormUidToApproveStateAndRemarkHistory) through their matching views.
|
|
-- Schema placeholder {0} replaced at runtime by default_schema (e.g. BUDGET).
|
|
-- Idempotent: CREATE OR REPLACE VIEW is safe to run on every startup — appending a
|
|
-- column at the end of the SELECT list does not require dropping the view first.
|
|
|
|
CREATE OR REPLACE VIEW "{0}".v_approve_state AS
|
|
SELECT approve_state.approve_state_uid,
|
|
approve_state.status_id,
|
|
approve_state.created_by,
|
|
approve_state.created_datetime,
|
|
approve_state.updated_by,
|
|
approve_state.updated_datetime,
|
|
approve_state.owner_agency_uid,
|
|
approve_state.reference_uid,
|
|
approve_state.row_order,
|
|
approve_state.is_approve,
|
|
approve_state.is_state,
|
|
approve_state.approve_date,
|
|
approve_state.personnel_uid,
|
|
approve_state.display_name_th,
|
|
approve_state.request_approve_type,
|
|
approve_state.position_name_th,
|
|
approve_state.invest_asset_uid,
|
|
approve_state.invest_construct_uid,
|
|
approve_state.request_expense_uid,
|
|
approve_state.round_no,
|
|
approve_state.evaluate_form_uid
|
|
FROM "{0}".approve_state;
|
|
|
|
CREATE OR REPLACE VIEW "{0}".v_remark_history AS
|
|
SELECT remark_history.remark_history_uid,
|
|
remark_history.status_id,
|
|
remark_history.created_by,
|
|
remark_history.created_datetime,
|
|
remark_history.updated_by,
|
|
remark_history.updated_datetime,
|
|
remark_history.owner_agency_uid,
|
|
remark_history.request_budget_uid,
|
|
remark_history.remark_history_code,
|
|
remark_history.remark_history_short_name_en,
|
|
remark_history.remark_history_short_name_th,
|
|
remark_history.remark_history_name_th,
|
|
remark_history.remark_history_name_en,
|
|
remark_history.remark_history_image,
|
|
remark_history.updated_date,
|
|
remark_history.updated_name,
|
|
remark_history.request_budget_income_uid,
|
|
remark_history.budget_progress_uid,
|
|
remark_history.invest_asset_request_information_uid,
|
|
remark_history.invest_asset_request_uid,
|
|
remark_history.invest_asset_uid,
|
|
remark_history.invest_construct_request_information_uid,
|
|
remark_history.invest_construct_request_uid,
|
|
remark_history.invest_construct_uid,
|
|
remark_history.request_budget_strategic_uid,
|
|
remark_history.request_expense_uid,
|
|
remark_history.round_no,
|
|
remark_history.evaluate_form_uid
|
|
FROM "{0}".remark_history;
|
|
```
|
|
|
|
- [ ] **Step 6: Register the new raw-SQL migration in `Startup.cs`**
|
|
|
|
Find the line `ApplyRawSqlMigration(dbContext, setting.Value.default_schema, "add_round_no_to_views.sql");` and add immediately after it:
|
|
|
|
```csharp
|
|
ApplyRawSqlMigration(dbContext, setting.Value.default_schema, "add_evaluate_form_uid_to_approve_state_and_remark_history_views.sql");
|
|
```
|
|
|
|
- [ ] **Step 7: Build**
|
|
|
|
```bash
|
|
cd /Users/nut.looknut/Project/rmutr/rmutr-api
|
|
ps aux | grep -E "dotnet build|dotnet publish" | grep -v grep # confirm nothing else is building first
|
|
dotnet build rmutr-budget-api.csproj
|
|
```
|
|
|
|
Expected: `0 Error(s)`.
|
|
|
|
- [ ] **Step 8: Apply the migration and verify against the live DB**
|
|
|
|
```bash
|
|
cd /Users/nut.looknut/Project/rmutr/rmutr-api
|
|
dotnet run --urls http://localhost:5000 &
|
|
sleep 25 # give Database.Migrate()/ApplyRawSqlMigration a chance to run on startup
|
|
kill %1
|
|
```
|
|
|
|
Then verify:
|
|
|
|
```bash
|
|
python3 <<'EOF'
|
|
import psycopg2
|
|
conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin",
|
|
password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15)
|
|
cur = conn.cursor()
|
|
cur.execute("""SELECT column_name FROM information_schema.columns
|
|
WHERE table_schema='BUDGET' AND table_name IN ('approve_state','remark_history')
|
|
AND column_name='evaluate_form_uid';""")
|
|
print(cur.fetchall())
|
|
cur.execute("""SELECT column_name FROM information_schema.columns
|
|
WHERE table_schema='BUDGET' AND table_name IN ('v_approve_state','v_remark_history')
|
|
AND column_name='evaluate_form_uid';""")
|
|
print(cur.fetchall())
|
|
conn.close()
|
|
EOF
|
|
```
|
|
|
|
Expected: both queries return two rows each (one per table/view).
|
|
|
|
- [ ] **Step 9: Commit**
|
|
|
|
```bash
|
|
git add Modules/Setting/Databases/Models/approve_state.cs Modules/Requests/Databases/Models/remark_history.cs Modules/Evaluate/Databases/Models/evaluate_form/evaluate_form.cs Modules/Setting/Databases/Db.cs Modules/Requests/Databases/Db.cs Migrations/*AddEvaluateFormUidToApproveStateAndRemarkHistory* Migrations/add_evaluate_form_uid_to_approve_state_and_remark_history_views.sql Startup.cs
|
|
git commit -m "feat(evaluate_form): add evaluate_form_uid FK + approve_states/remark_historys nav to approve_state/remark_history"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: `is_sent`/`is_approve`/`current_approve_name` on `evaluate_form`
|
|
|
|
**Files:**
|
|
- Modify: `Modules/Evaluate/Databases/Models/evaluate_form/evaluate_form.cs`
|
|
- Create: `Migrations/<timestamp>_AddApprovalFieldsToEvaluateForm.cs` (via `dotnet ef migrations add`)
|
|
- Create: `Migrations/add_approval_fields_to_evaluate_form_view.sql`
|
|
- Modify: `Startup.cs`
|
|
|
|
**Interfaces:**
|
|
- Consumes: none new.
|
|
- Produces: `evaluate_form.is_sent : bool?`, `evaluate_form.is_approve : bool?`, `evaluate_form.current_approve_name : string`, all queryable through `v_evaluate_form`.
|
|
|
|
- [ ] **Step 1: Add the properties**
|
|
|
|
Edit `Modules/Evaluate/Databases/Models/evaluate_form/evaluate_form.cs`, in the `evaluate_form` base class:
|
|
|
|
```csharp
|
|
public DateTime? active_date { get; set; }
|
|
public bool? is_active { get; set; }
|
|
public bool? is_sent { get; set; }
|
|
public bool? is_approve { get; set; }
|
|
public string current_approve_name { get; set; }
|
|
```
|
|
|
|
- [ ] **Step 2: Generate the EF Core migration**
|
|
|
|
```bash
|
|
cd /Users/nut.looknut/Project/rmutr/rmutr-api
|
|
dotnet ef migrations add AddApprovalFieldsToEvaluateForm
|
|
```
|
|
|
|
Confirm the generated `Up()` adds `is_sent boolean` (nullable), `is_approve boolean` (nullable), `current_approve_name character varying`/`text` (nullable) to `evaluate_form` in schema `BUDGET` — and nothing else unrelated.
|
|
|
|
- [ ] **Step 3: Write the view-refresh raw SQL migration**
|
|
|
|
Create `Migrations/add_approval_fields_to_evaluate_form_view.sql` (base SELECT list captured live from production on 2026-09-24 via `pg_get_viewdef('"BUDGET".v_evaluate_form'::regclass, true)`, with the three new columns appended):
|
|
|
|
```sql
|
|
-- Migration: expose is_sent/is_approve/current_approve_name (added to evaluate_form by
|
|
-- AddApprovalFieldsToEvaluateForm) through v_evaluate_form.
|
|
-- Schema placeholder {0} replaced at runtime by default_schema (e.g. BUDGET).
|
|
-- Idempotent: CREATE OR REPLACE VIEW is safe to run on every startup.
|
|
|
|
CREATE OR REPLACE VIEW "{0}".v_evaluate_form AS
|
|
SELECT evaluate_form.evaluate_form_uid,
|
|
evaluate_form.status_id,
|
|
evaluate_form.created_by,
|
|
evaluate_form.created_datetime,
|
|
evaluate_form.updated_by,
|
|
evaluate_form.updated_datetime,
|
|
evaluate_form.owner_agency_uid,
|
|
evaluate_form.budget_year_uid,
|
|
evaluate_form.evaluate_time_period_uid,
|
|
evaluate_form.request_date,
|
|
evaluate_form.is_active,
|
|
evaluate_form.active_date,
|
|
evaluate_form.is_success,
|
|
budget_year.budget_year_name_th,
|
|
evaluate_time_period.evaluate_time_period_name_th,
|
|
evaluate_indicator.evaluate_indicator_name_th,
|
|
evaluate_time_period.end_date,
|
|
evaluate_form.is_sent,
|
|
evaluate_form.is_approve,
|
|
evaluate_form.current_approve_name
|
|
FROM "{0}".evaluate_form
|
|
LEFT JOIN "{0}".evaluate_time_period ON evaluate_time_period.evaluate_time_period_uid = evaluate_form.evaluate_time_period_uid
|
|
LEFT JOIN "{0}".evaluate_time ON evaluate_time.evaluate_time_uid = evaluate_time_period.evaluate_time_uid
|
|
LEFT JOIN "{0}".budget_year ON budget_year.budget_year_uid = evaluate_form.budget_year_uid
|
|
LEFT JOIN "{0}".evaluate_indicator ON evaluate_indicator.evaluate_indicator_uid = evaluate_time.evaluate_indicator_uid;
|
|
```
|
|
|
|
- [ ] **Step 4: Register in `Startup.cs`**
|
|
|
|
Add immediately after the line added in Task 2 Step 5:
|
|
|
|
```csharp
|
|
ApplyRawSqlMigration(dbContext, setting.Value.default_schema, "add_approval_fields_to_evaluate_form_view.sql");
|
|
```
|
|
|
|
- [ ] **Step 5: Build**
|
|
|
|
```bash
|
|
cd /Users/nut.looknut/Project/rmutr/rmutr-api
|
|
dotnet build rmutr-budget-api.csproj
|
|
```
|
|
|
|
Expected: `0 Error(s)`.
|
|
|
|
- [ ] **Step 6: Apply and verify**
|
|
|
|
```bash
|
|
cd /Users/nut.looknut/Project/rmutr/rmutr-api
|
|
dotnet run --urls http://localhost:5000 &
|
|
sleep 25
|
|
kill %1
|
|
```
|
|
|
|
```bash
|
|
python3 <<'EOF'
|
|
import psycopg2
|
|
conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin",
|
|
password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15)
|
|
cur = conn.cursor()
|
|
cur.execute("""SELECT column_name FROM information_schema.columns
|
|
WHERE table_schema='BUDGET' AND table_name='evaluate_form'
|
|
AND column_name IN ('is_sent','is_approve','current_approve_name') ORDER BY 1;""")
|
|
print(cur.fetchall())
|
|
cur.execute("""SELECT pg_get_viewdef('"BUDGET".v_evaluate_form'::regclass, true);""")
|
|
print(cur.fetchone()[0])
|
|
conn.close()
|
|
EOF
|
|
```
|
|
|
|
Expected: three rows in the first query; the view definition in the second includes `is_sent`, `is_approve`, `current_approve_name`.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add Modules/Evaluate/Databases/Models/evaluate_form/evaluate_form.cs Migrations/*AddApprovalFieldsToEvaluateForm* Migrations/add_approval_fields_to_evaluate_form_view.sql Startup.cs
|
|
git commit -m "feat(evaluate_form): add is_sent/is_approve/current_approve_name status fields"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: `evaluate_form_review` table (central-review log)
|
|
|
|
**Files:**
|
|
- Create: `Migrations/add_evaluate_form_review.sql`
|
|
- Create: `Modules/EvaluateFormReview/Databases/Models/evaluate_form_review.cs`
|
|
- Create: `Modules/EvaluateFormReview/Databases/Db.cs`
|
|
- Modify: `Databases/Db.cs`
|
|
- Modify: `Startup.cs`
|
|
|
|
**Interfaces:**
|
|
- Produces: `t_evaluate_form_review` DbSet, table `"BUDGET".evaluate_form_review` with columns `evaluate_form_review_uid, evaluate_form_uid, review_status_id (1=รอตรวจสอบ, 2=ส่งแก้ไข, 3=ตรวจสอบแล้ว), remark`.
|
|
|
|
- [ ] **Step 1: Raw SQL migration**
|
|
|
|
Create `Migrations/add_evaluate_form_review.sql` (mirrors `add_request_budget_review.sql` exactly, entity swapped):
|
|
|
|
```sql
|
|
-- Migration: สร้างตาราง evaluate_form_review — เก็บ log การตรวจสอบส่วนกลางของ evaluate_form
|
|
-- (evaluate-follow) หลังเชนอนุมัติ 2 ลำดับผ่านครบ (request_approve_type = "09")
|
|
-- Schema placeholder {0} replaced at runtime by default_schema (e.g. BUDGET).
|
|
-- Idempotent: safe to run on every startup.
|
|
|
|
CREATE TABLE IF NOT EXISTS "{0}".evaluate_form_review (
|
|
evaluate_form_review_uid uuid PRIMARY KEY,
|
|
evaluate_form_uid uuid,
|
|
review_status_id smallint NOT NULL,
|
|
remark text,
|
|
status_id smallint,
|
|
created_by text,
|
|
created_datetime timestamptz,
|
|
updated_by text,
|
|
updated_datetime timestamptz,
|
|
owner_agency_uid uuid
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS ix_evaluate_form_review_evaluate_form_uid
|
|
ON "{0}".evaluate_form_review (evaluate_form_uid);
|
|
```
|
|
|
|
- [ ] **Step 2: Model**
|
|
|
|
Create `Modules/EvaluateFormReview/Databases/Models/evaluate_form_review.cs`:
|
|
|
|
```csharp
|
|
using System;
|
|
using System.ComponentModel.DataAnnotations;
|
|
using SeventyOneDev.Utilities;
|
|
|
|
namespace rmutr_budget_api.Modules.EvaluateFormReview.Databases.Models
|
|
{
|
|
public class evaluate_form_review : base_table
|
|
{
|
|
[Key] public Guid? evaluate_form_review_uid { get; set; }
|
|
public Guid? evaluate_form_uid { get; set; }
|
|
/// <summary>1 = รอตรวจสอบ, 2 = ส่งแก้ไข, 3 = ตรวจสอบแล้ว — mirrors request_budget_review,
|
|
/// see docs/superpowers/specs/2026-09-24-evaluate-collect-approve-workflow-design.md §3.3</summary>
|
|
public short review_status_id { get; set; }
|
|
public string remark { get; set; }
|
|
}
|
|
|
|
public class t_evaluate_form_review : evaluate_form_review
|
|
{
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: DbContext registration**
|
|
|
|
Create `Modules/EvaluateFormReview/Databases/Db.cs`:
|
|
|
|
```csharp
|
|
using Microsoft.EntityFrameworkCore;
|
|
using rmutr_budget_api.Modules.EvaluateFormReview.Databases.Models;
|
|
|
|
namespace SeventyOneDev.Utilities
|
|
{
|
|
public partial class Db : DbContext
|
|
{
|
|
public DbSet<t_evaluate_form_review> t_evaluate_form_review { get; set; }
|
|
|
|
private void OnEvaluateFormReviewModelCreating(ModelBuilder builder, string schema)
|
|
{
|
|
builder.Entity<t_evaluate_form_review>().ToTable("evaluate_form_review", schema);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
Edit `Databases/Db.cs`, add after the `OnRequestBudgetReviewModelCreating(builder,schema);` line:
|
|
|
|
```csharp
|
|
OnRequestBudgetReviewModelCreating(builder,schema);
|
|
OnEvaluateFormReviewModelCreating(builder,schema);
|
|
```
|
|
|
|
- [ ] **Step 4: Register the raw SQL migration in `Startup.cs`**
|
|
|
|
Add after the line added in Task 3 Step 4:
|
|
|
|
```csharp
|
|
ApplyRawSqlMigration(dbContext, setting.Value.default_schema, "add_evaluate_form_review.sql");
|
|
```
|
|
|
|
- [ ] **Step 5: Build**
|
|
|
|
```bash
|
|
cd /Users/nut.looknut/Project/rmutr/rmutr-api
|
|
dotnet build rmutr-budget-api.csproj
|
|
```
|
|
|
|
Expected: `0 Error(s)`. (No `dotnet ef migrations add` needed here — this table is created by the raw SQL file directly, not EF code-first, matching `request_budget_review`'s precedent of not having a Designer.cs migration.)
|
|
|
|
- [ ] **Step 6: Apply and verify**
|
|
|
|
```bash
|
|
cd /Users/nut.looknut/Project/rmutr/rmutr-api
|
|
dotnet run --urls http://localhost:5000 &
|
|
sleep 25
|
|
kill %1
|
|
```
|
|
|
|
```bash
|
|
python3 <<'EOF'
|
|
import psycopg2
|
|
conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin",
|
|
password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15)
|
|
cur = conn.cursor()
|
|
cur.execute("""SELECT table_name FROM information_schema.tables
|
|
WHERE table_schema='BUDGET' AND table_name='evaluate_form_review';""")
|
|
print(cur.fetchall())
|
|
conn.close()
|
|
EOF
|
|
```
|
|
|
|
Expected: one row.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add Migrations/add_evaluate_form_review.sql Modules/EvaluateFormReview Databases/Db.cs Startup.cs
|
|
git commit -m "feat(evaluate_form_review): add central-review log table"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: `SetCustomApprovers` for `evaluate_form`
|
|
|
|
**Files:**
|
|
- Modify: `Modules/Evaluate/Services/EvaluateForm.cs`
|
|
- Modify: `Modules/Evaluate/Controllers/EvaluateForm.cs`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `t_approve_state`, `t_evaluate_form` (via `_context`, already injected in `EvaluateFormService`).
|
|
- Produces: `Task<(bool ok, string error)> SetCustomApprovers(Guid? evaluateFormUid, List<ApproverInput> approvers, ClaimsIdentity claimsIdentity)` on `EvaluateFormService`, and `POST /api/evaluate/evaluate_form/set_approvers/{uid}`.
|
|
- `ApproverInput` (`personnel_uid: Guid?`, `display_name_th: string`) already exists in `rmutr_budget_api.Modules.RequestExpense.Services` — reference it via `using rmutr_budget_api.Modules.RequestExpense.Services;` rather than redefining it.
|
|
|
|
- [ ] **Step 1: Add the method to `EvaluateFormService`**
|
|
|
|
Edit `Modules/Evaluate/Services/EvaluateForm.cs`, add `using rmutr_budget_api.Modules.RequestExpense.Services;` and `using rmutr_budget_api.Modules.General.Databases.Models;` to the `using` block, then add this method to the class body:
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// ให้ผู้กรอกข้อมูลกำหนดคิวผู้อนุมัติ (ลำดับ 1, 2) เองก่อนกด "ส่งอนุมัติ" — mirror
|
|
/// RequestExpenseService.SetCustomApprovers ทุกจุด (code "09")
|
|
/// </summary>
|
|
public async Task<(bool ok, string error)> SetCustomApprovers(Guid? evaluateFormUid,
|
|
List<ApproverInput> approvers, ClaimsIdentity claimsIdentity)
|
|
{
|
|
var entity = await _context.t_evaluate_form
|
|
.FirstOrDefaultAsync(c => c.evaluate_form_uid == evaluateFormUid);
|
|
if (entity == null) return (false, "ไม่พบรายการ");
|
|
if (entity.is_sent != null || entity.is_approve != null)
|
|
return (false, "รายการนี้เข้าสู่กระบวนการอนุมัติไปแล้ว ไม่สามารถกำหนดผู้อนุมัติใหม่ได้");
|
|
|
|
var existing = await _context.t_approve_state
|
|
.Where(c => c.evaluate_form_uid == evaluateFormUid).ToListAsync();
|
|
if (existing.Any(s => s.is_approve == true))
|
|
return (false, "รายการนี้เข้าสู่กระบวนการอนุมัติไปแล้ว ไม่สามารถกำหนดผู้อนุมัติใหม่ได้");
|
|
|
|
approvers = (approvers ?? new List<ApproverInput>())
|
|
.Where(a => !string.IsNullOrWhiteSpace(a?.display_name_th)).ToList();
|
|
if (!approvers.Any()) return (false, "กรุณาระบุผู้อนุมัติอย่างน้อย 1 คน");
|
|
|
|
if (existing.Any())
|
|
{
|
|
_context.t_approve_state.RemoveRange(existing);
|
|
await _context.SaveChangesAsync(claimsIdentity);
|
|
}
|
|
|
|
var rows = approvers.Select((a, idx) => new t_approve_state
|
|
{
|
|
approve_state_uid = Guid.NewGuid(),
|
|
evaluate_form_uid = evaluateFormUid,
|
|
personnel_uid = a.personnel_uid,
|
|
display_name_th = a.display_name_th,
|
|
row_order = idx + 1,
|
|
is_state = idx == 0,
|
|
request_approve_type = "09",
|
|
}).ToList();
|
|
await _context.t_approve_state.AddRangeAsync(rows);
|
|
entity.current_approve_name = rows.First().display_name_th;
|
|
_context.t_evaluate_form.Update(entity);
|
|
await _context.SaveChangesAsync(claimsIdentity);
|
|
return (true, null);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Add the endpoint**
|
|
|
|
Edit `Modules/Evaluate/Controllers/EvaluateForm.cs`, add after `FinalActiveAgencyEvaluate` (before `SendBackEvaluate`):
|
|
|
|
```csharp
|
|
[Authorize]
|
|
[HttpPost("set_approvers/{evaluate_form_uid:guid}")]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
public async Task<ActionResult> SetApprovers([FromRoute] Guid evaluate_form_uid,
|
|
[FromBody] List<rmutr_budget_api.Modules.RequestExpense.Services.ApproverInput> approvers)
|
|
{
|
|
var result = await _entityUidService.SetCustomApprovers(evaluate_form_uid, approvers, User.Identity as ClaimsIdentity);
|
|
if (!result.ok) return BadRequest(new { description = result.error });
|
|
return Ok();
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Build**
|
|
|
|
```bash
|
|
cd /Users/nut.looknut/Project/rmutr/rmutr-api
|
|
dotnet build rmutr-budget-api.csproj
|
|
```
|
|
|
|
Expected: `0 Error(s)`.
|
|
|
|
- [ ] **Step 4: Run locally and verify with synthetic data + curl**
|
|
|
|
Start the API: `cd /Users/nut.looknut/Project/rmutr/rmutr-api && dotnet run --urls http://localhost:5000` (leave running in this shell for Tasks 5-9's manual verification; obtain a bearer token by logging into the running rmutr-web app locally against this API and copying the `Authorization` header value from any XHR request, or from the OIDC token in browser storage — same convention as `docs/superpowers/plans/2026-09-08-request-budget-approve-workflow.md`).
|
|
|
|
Create a synthetic `evaluate_form` row via SQL (adjust `budget_year_uid`/`evaluate_time_period_uid` to real existing UIDs from your DB — query `SELECT budget_year_uid FROM "BUDGET".budget_year LIMIT 1;` and `SELECT evaluate_time_period_uid FROM "BUDGET".evaluate_time_period LIMIT 1;` first if needed):
|
|
|
|
```bash
|
|
python3 <<'EOF'
|
|
import psycopg2, uuid
|
|
conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin",
|
|
password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15)
|
|
cur = conn.cursor()
|
|
cur.execute('SELECT budget_year_uid FROM "BUDGET".budget_year LIMIT 1;')
|
|
byu = cur.fetchone()[0]
|
|
form_uid = uuid.uuid4()
|
|
cur.execute("""INSERT INTO "BUDGET".evaluate_form
|
|
(evaluate_form_uid, budget_year_uid, request_date, created_by, status_id)
|
|
VALUES (%s, %s, now(), 'test.verify', 1);""", (str(form_uid), str(byu)))
|
|
conn.commit()
|
|
print("evaluate_form_uid:", form_uid)
|
|
conn.close()
|
|
EOF
|
|
```
|
|
|
|
```bash
|
|
curl -s -X POST "http://localhost:5000/api/evaluate/evaluate_form/set_approvers/<form_uid-from-above>" \
|
|
-H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
|
|
-d '[{"display_name_th":"ทดสอบ หัวหน้าหน่วยงาน"},{"display_name_th":"ทดสอบ รองอธิการบดี"}]'
|
|
```
|
|
|
|
Expected: `200 OK`.
|
|
|
|
```bash
|
|
python3 <<'EOF'
|
|
import psycopg2
|
|
conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin",
|
|
password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15)
|
|
cur = conn.cursor()
|
|
cur.execute("""SELECT row_order, is_state, display_name_th, request_approve_type
|
|
FROM "BUDGET".approve_state WHERE evaluate_form_uid = %s ORDER BY row_order;""", ("<form_uid-from-above>",))
|
|
print(cur.fetchall())
|
|
conn.close()
|
|
EOF
|
|
```
|
|
|
|
Expected: two rows, `row_order 1` with `is_state=True`, `row_order 2` with `is_state=False`, both `request_approve_type='09'`. **Do not delete this test data yet** — Tasks 6-9 reuse it.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
cd /Users/nut.looknut/Project/rmutr/rmutr-api
|
|
git add Modules/Evaluate/Services/EvaluateForm.cs Modules/Evaluate/Controllers/EvaluateForm.cs
|
|
git commit -m "feat(evaluate_form): add SetCustomApprovers + set_approvers endpoint"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: `SendApprove` for `evaluate_form`
|
|
|
|
**Files:**
|
|
- Modify: `Modules/Evaluate/Services/EvaluateForm.cs`
|
|
- Modify: `Modules/Evaluate/Controllers/EvaluateForm.cs`
|
|
|
|
**Interfaces:**
|
|
- Produces: `Task<(bool ok, string error)> SendApprove(Guid? evaluateFormUid, ClaimsIdentity claimsIdentity)` on `EvaluateFormService`, and `POST /api/evaluate/evaluate_form/send_approve/{uid}`.
|
|
|
|
- [ ] **Step 1: Add the method**
|
|
|
|
Add to `Modules/Evaluate/Services/EvaluateForm.cs`:
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// "ส่งอนุมัติ" — ใครก็ได้กดได้ แค่ตั้ง is_sent=true ไม่แตะ approve_state — mirror
|
|
/// RequestExpenseService.SendApprove2
|
|
/// </summary>
|
|
public async Task<(bool ok, string error)> SendApprove(Guid? evaluateFormUid, ClaimsIdentity claimsIdentity)
|
|
{
|
|
var entity = await _context.t_evaluate_form
|
|
.FirstOrDefaultAsync(c => c.evaluate_form_uid == evaluateFormUid);
|
|
if (entity == null) return (false, "ไม่พบรายการ");
|
|
if (entity.is_approve == true) return (false, "รายการนี้เข้าสู่กระบวนการอนุมัติไปแล้ว");
|
|
|
|
var isPristine = entity.is_sent == null && entity.is_approve == null;
|
|
var isRejectedReadyToResend = entity.is_sent == false && entity.is_approve == false;
|
|
if (!isPristine && !isRejectedReadyToResend)
|
|
return (false, "รายการนี้เข้าสู่กระบวนการอนุมัติไปแล้ว");
|
|
|
|
var pending = await _context.t_approve_state.FirstOrDefaultAsync(c =>
|
|
c.evaluate_form_uid == evaluateFormUid && c.is_state == true);
|
|
if (pending == null || pending.row_order != 1)
|
|
return (false, "ยังไม่พร้อมส่งอนุมัติ กรุณากำหนดผู้อนุมัติก่อน");
|
|
|
|
entity.is_sent = true;
|
|
entity.is_approve = null;
|
|
_context.t_evaluate_form.Update(entity);
|
|
await _context.SaveChangesAsync(claimsIdentity);
|
|
return (true, null);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Add the endpoint**
|
|
|
|
Add to `Modules/Evaluate/Controllers/EvaluateForm.cs`, after `SetApprovers`:
|
|
|
|
```csharp
|
|
[Authorize]
|
|
[HttpPost("send_approve/{evaluate_form_uid:guid}")]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
public async Task<ActionResult> SendApprove([FromRoute] Guid evaluate_form_uid)
|
|
{
|
|
var result = await _entityUidService.SendApprove(evaluate_form_uid, User.Identity as ClaimsIdentity);
|
|
if (!result.ok) return BadRequest(new { description = result.error });
|
|
return Ok();
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Build**
|
|
|
|
```bash
|
|
cd /Users/nut.looknut/Project/rmutr/rmutr-api
|
|
dotnet build rmutr-budget-api.csproj
|
|
```
|
|
|
|
Expected: `0 Error(s)`.
|
|
|
|
- [ ] **Step 4: Verify (reuse Task 5's test `evaluate_form`)**
|
|
|
|
Restart `dotnet run` if it isn't still running, then:
|
|
|
|
```bash
|
|
curl -s -X POST "http://localhost:5000/api/evaluate/evaluate_form/send_approve/<form_uid>" \
|
|
-H "Authorization: Bearer <token>"
|
|
```
|
|
|
|
Expected: `200 OK`.
|
|
|
|
```bash
|
|
python3 <<'EOF'
|
|
import psycopg2
|
|
conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin",
|
|
password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15)
|
|
cur = conn.cursor()
|
|
cur.execute('SELECT is_sent, is_approve FROM "BUDGET".evaluate_form WHERE evaluate_form_uid = %s;', ("<form_uid>",))
|
|
print(cur.fetchone())
|
|
conn.close()
|
|
EOF
|
|
```
|
|
|
|
Expected: `(True, None)`.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
cd /Users/nut.looknut/Project/rmutr/rmutr-api
|
|
git add Modules/Evaluate/Services/EvaluateForm.cs Modules/Evaluate/Controllers/EvaluateForm.cs
|
|
git commit -m "feat(evaluate_form): add SendApprove + send_approve endpoint"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Extend `ApproveGetEntity` (approve) with the `evaluate_form` branch
|
|
|
|
**Files:**
|
|
- Modify: `Modules/Setting/Controllers/ApproveState.cs`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `_db.t_evaluate_form`, `_db.t_approve_state`, `_db.t_evaluate_form_review` (add `using rmutr_budget_api.Modules.EvaluateFormReview.Databases.Models;`).
|
|
|
|
- [ ] **Step 1: Add the branch**
|
|
|
|
Edit `ApproveGetEntity` in `Modules/Setting/Controllers/ApproveState.cs`. First, add `evaluate_form` to the initial lookup block:
|
|
|
|
```csharp
|
|
var invest_asset = await _db.t_invest_asset.FirstOrDefaultAsync(c => c.invest_asset_uid == uid);
|
|
var invest_construct = await _db.t_invest_construct.FirstOrDefaultAsync(c => c.invest_construct_uid == uid);
|
|
var request_expense = await _db.t_request_expense.FirstOrDefaultAsync(c => c.request_expense_uid == uid);
|
|
var evaluate_form = await _db.t_evaluate_form.FirstOrDefaultAsync(c => c.evaluate_form_uid == uid);
|
|
if (invest_asset == null && invest_construct == null && request_expense == null && evaluate_form == null)
|
|
{
|
|
return BadRequest("Error, Not Found Approve State.");
|
|
}
|
|
```
|
|
|
|
Then add a new `if (evaluate_form != null)` block, placed after the existing `if (request_expense != null) { ... }` block and before `return Ok(getResult);`:
|
|
|
|
```csharp
|
|
if (evaluate_form != null)
|
|
{
|
|
getResult = await _db.t_approve_state.FirstOrDefaultAsync(c =>
|
|
c.evaluate_form_uid == uid && c.is_state == true);
|
|
if (getResult != null)
|
|
{
|
|
// ประเมินผล (evaluate_form) แบบผู้กรอกข้อมูลเลือกผู้อนุมัติเอง (code "09") — เช็คสิทธิ์
|
|
// ทุก row_order ตั้งแต่วันแรก (ไม่ใช่แค่ row_order==1) ตาม fix ที่ทำกับ request_expense
|
|
// ไปแล้วเมื่อ 2026-09-08 — ดู docs/superpowers/specs/2026-09-24-evaluate-collect-approve-workflow-design.md §6
|
|
if (getResult.row_order == 1 && evaluate_form.is_sent != true)
|
|
{
|
|
return BadRequest(new { description = "ยังไม่ได้ส่งอนุมัติ กรุณากด \"ส่งอนุมัติ\" ก่อน" });
|
|
}
|
|
|
|
var currentDisplayName = (User.Identity as ClaimsIdentity)?.FindFirst("display_name")?.Value;
|
|
if (string.IsNullOrEmpty(currentDisplayName) || currentDisplayName != getResult.display_name_th)
|
|
{
|
|
return StatusCode(StatusCodes.Status403Forbidden,
|
|
new { description = "ไม่มีสิทธิ์อนุมัติ (ต้องเป็นผู้ที่ถูกกำหนดเป็นผู้อนุมัติในลำดับนี้เท่านั้น)" });
|
|
}
|
|
|
|
getResult.is_state = null;
|
|
getResult.is_approve = true;
|
|
evaluate_form.current_approve_name = null;
|
|
_db.t_approve_state.Update(getResult);
|
|
_db.t_evaluate_form.Update(evaluate_form);
|
|
await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);
|
|
|
|
var check_all_state = await _db.t_approve_state
|
|
.Where(c => c.evaluate_form_uid == uid && c.is_approve != true).ToListAsync();
|
|
if (!check_all_state.Any())
|
|
{
|
|
evaluate_form.is_approve = true;
|
|
evaluate_form.is_active = true;
|
|
evaluate_form.active_date = DateTime.UtcNow.Date;
|
|
_db.t_evaluate_form.Update(evaluate_form);
|
|
await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);
|
|
|
|
await _db.t_evaluate_form_review.AddAsync(new t_evaluate_form_review
|
|
{
|
|
evaluate_form_review_uid = Guid.NewGuid(),
|
|
evaluate_form_uid = uid,
|
|
review_status_id = 1,
|
|
});
|
|
await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);
|
|
}
|
|
|
|
var check_getResult = await _db.t_approve_state.FirstOrDefaultAsync(c =>
|
|
c.evaluate_form_uid == uid && c.row_order == getResult.row_order + 1);
|
|
if (check_getResult != null)
|
|
{
|
|
check_getResult.is_state = true;
|
|
evaluate_form.current_approve_name = check_getResult.display_name_th;
|
|
_db.t_evaluate_form.Update(evaluate_form);
|
|
_db.t_approve_state.Update(check_getResult);
|
|
await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Build**
|
|
|
|
```bash
|
|
cd /Users/nut.looknut/Project/rmutr/rmutr-api
|
|
dotnet build rmutr-budget-api.csproj
|
|
```
|
|
|
|
Expected: `0 Error(s)`.
|
|
|
|
- [ ] **Step 3: Verify the "approve before send" guard (Review Focus item 1)**
|
|
|
|
Using a **fresh** synthetic `evaluate_form` (repeat Task 5 Step 4's insert + `set_approvers` call, but skip `send_approve`):
|
|
|
|
```bash
|
|
curl -s -w "\n%{http_code}\n" "http://localhost:5000/api/setting/approve_state/approve/<new_form_uid>" \
|
|
-H "Authorization: Bearer <approver-1's-token>"
|
|
```
|
|
|
|
Expected: `400` with `description` containing `ยังไม่ได้ส่งอนุมัติ`.
|
|
|
|
- [ ] **Step 4: Verify the wrong-person guard (Review Focus item 3)**
|
|
|
|
Call `send_approve` on the same record, then call `approve` with a token whose `display_name` claim does **not** match `"ทดสอบ หัวหน้าหน่วยงาน"`:
|
|
|
|
```bash
|
|
curl -s -X POST "http://localhost:5000/api/evaluate/evaluate_form/send_approve/<new_form_uid>" -H "Authorization: Bearer <token>"
|
|
curl -s -w "\n%{http_code}\n" "http://localhost:5000/api/setting/approve_state/approve/<new_form_uid>" -H "Authorization: Bearer <someone-else's-token>"
|
|
```
|
|
|
|
Expected: `403`.
|
|
|
|
- [ ] **Step 5: Verify the single-approver happy path (Review Focus item 2)**
|
|
|
|
Create a **third** synthetic `evaluate_form`, call `set_approvers` with **only one** approver (`row_order 2` never created), `send_approve`, then `approve` with a token whose `display_name` matches that one approver's `display_name_th`:
|
|
|
|
```bash
|
|
python3 <<'EOF'
|
|
import psycopg2, uuid
|
|
conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin",
|
|
password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15)
|
|
cur = conn.cursor()
|
|
cur.execute('SELECT budget_year_uid FROM "BUDGET".budget_year LIMIT 1;')
|
|
byu = cur.fetchone()[0]
|
|
form_uid = uuid.uuid4()
|
|
cur.execute("""INSERT INTO "BUDGET".evaluate_form (evaluate_form_uid, budget_year_uid, request_date, created_by, status_id)
|
|
VALUES (%s, %s, now(), 'test.verify', 1);""", (str(form_uid), str(byu)))
|
|
conn.commit()
|
|
print("single_approver_form_uid:", form_uid)
|
|
conn.close()
|
|
EOF
|
|
```
|
|
|
|
```bash
|
|
curl -s -X POST "http://localhost:5000/api/evaluate/evaluate_form/set_approvers/<single_approver_form_uid>" \
|
|
-H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
|
|
-d '[{"display_name_th":"ทดสอบ หัวหน้าหน่วยงาน"}]'
|
|
curl -s -X POST "http://localhost:5000/api/evaluate/evaluate_form/send_approve/<single_approver_form_uid>" -H "Authorization: Bearer <token>"
|
|
curl -s "http://localhost:5000/api/setting/approve_state/approve/<single_approver_form_uid>" -H "Authorization: Bearer <token-matching-หัวหน้าหน่วยงาน>"
|
|
```
|
|
|
|
```bash
|
|
python3 <<'EOF'
|
|
import psycopg2
|
|
conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin",
|
|
password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15)
|
|
cur = conn.cursor()
|
|
cur.execute('SELECT is_approve, is_active FROM "BUDGET".evaluate_form WHERE evaluate_form_uid = %s;', ("<single_approver_form_uid>",))
|
|
print(cur.fetchone())
|
|
cur.execute('SELECT review_status_id FROM "BUDGET".evaluate_form_review WHERE evaluate_form_uid = %s;', ("<single_approver_form_uid>",))
|
|
print(cur.fetchall())
|
|
conn.close()
|
|
EOF
|
|
```
|
|
|
|
Expected: `(True, True)`; one `evaluate_form_review` row with `review_status_id=1`.
|
|
|
|
- [ ] **Step 6: Clean up all synthetic data from Tasks 5-7**
|
|
|
|
```bash
|
|
python3 <<'EOF'
|
|
import psycopg2
|
|
conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin",
|
|
password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15)
|
|
cur = conn.cursor()
|
|
cur.execute("""DELETE FROM "BUDGET".evaluate_form_review WHERE evaluate_form_uid IN
|
|
(SELECT evaluate_form_uid FROM "BUDGET".evaluate_form WHERE created_by='test.verify');""")
|
|
cur.execute("""DELETE FROM "BUDGET".approve_state WHERE evaluate_form_uid IN
|
|
(SELECT evaluate_form_uid FROM "BUDGET".evaluate_form WHERE created_by='test.verify');""")
|
|
cur.execute("""DELETE FROM "BUDGET".evaluate_form WHERE created_by='test.verify';""")
|
|
conn.commit()
|
|
conn.close()
|
|
EOF
|
|
```
|
|
|
|
(Leave the two-approver form from Task 5 alive if Task 8 still needs it — Task 8 creates its own fresh data instead, so it's safe to delete everything here.)
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
cd /Users/nut.looknut/Project/rmutr/rmutr-api
|
|
git add Modules/Setting/Controllers/ApproveState.cs
|
|
git commit -m "feat(approve_state): add evaluate_form branch to approve endpoint"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Extend `RejectGetEntity` (reject) with the `evaluate_form` branch
|
|
|
|
**Files:**
|
|
- Modify: `Modules/Setting/Controllers/ApproveState.cs`
|
|
|
|
- [ ] **Step 1: Add the branch**
|
|
|
|
In `RejectGetEntity`, add `evaluate_form` to the initial lookup block:
|
|
|
|
```csharp
|
|
var request_expense_approve =
|
|
await _db.t_approve_state.FirstOrDefaultAsync(c => c.request_expense_uid == uid && c.is_state == true);
|
|
var evaluate_form_approve =
|
|
await _db.t_approve_state.FirstOrDefaultAsync(c => c.evaluate_form_uid == uid && c.is_state == true);
|
|
if (invest_asset_approve == null && invest_construct_approve == null && request_expense_approve == null && evaluate_form_approve == null)
|
|
{
|
|
return BadRequest("Error, Not Found Approve State.");
|
|
}
|
|
```
|
|
|
|
Then add a new block after the existing `if (request_expense_approve != null) { ... }` block and before `return Ok(getResult);`:
|
|
|
|
```csharp
|
|
if (evaluate_form_approve != null)
|
|
{
|
|
getResult = evaluate_form_approve;
|
|
var result_data_lists = await _db.t_approve_state
|
|
.Where(c => c.evaluate_form_uid == uid)
|
|
.OrderBy(o => o.row_order).ToListAsync();
|
|
var index_current = result_data_lists.FindIndex(c => c.is_state == true);
|
|
if (index_current > 0)
|
|
{
|
|
result_data_lists[index_current].is_state = null;
|
|
result_data_lists[index_current].is_approve = null;
|
|
result_data_lists[index_current - 1].is_state = true;
|
|
result_data_lists[index_current - 1].is_approve = null;
|
|
}
|
|
|
|
_db.t_approve_state.UpdateRange(result_data_lists);
|
|
await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);
|
|
var evaluate_form = await _db.t_evaluate_form.FirstOrDefaultAsync(c => c.evaluate_form_uid == uid);
|
|
if (evaluate_form != null)
|
|
{
|
|
evaluate_form.current_approve_name = null;
|
|
var approve = await _db.t_approve_state
|
|
.Where(c => c.evaluate_form_uid == uid && c.is_state == true)
|
|
.OrderBy(c => c.row_order).ToListAsync();
|
|
if (approve.Count > 0)
|
|
{
|
|
evaluate_form.current_approve_name = approve.First().display_name_th;
|
|
}
|
|
|
|
evaluate_form.is_approve = false;
|
|
if (index_current == 0)
|
|
{
|
|
// ลำดับ 1 ส่งแก้ไขเอง: ไม่มีลำดับก่อนหน้าให้ตีกลับ ต้องรีเซ็ต is_sent เพื่อให้กลับไปเป็น
|
|
// สถานะ "ยังไม่ได้ส่งอนุมัติ" ให้ผู้กรอกข้อมูล (ใครก็ได้) กลับมาส่งใหม่ได้อีกครั้งหลังแก้ไข
|
|
evaluate_form.is_sent = false;
|
|
}
|
|
|
|
_db.t_evaluate_form.Update(evaluate_form);
|
|
await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Build**
|
|
|
|
```bash
|
|
cd /Users/nut.looknut/Project/rmutr/rmutr-api
|
|
dotnet build rmutr-budget-api.csproj
|
|
```
|
|
|
|
Expected: `0 Error(s)`.
|
|
|
|
- [ ] **Step 3: Verify self-reject at row 1 (Review Focus item 4)**
|
|
|
|
Create a fresh two-approver synthetic form (repeat Task 5 Step 4), `send_approve`, then reject as the row-1 approver:
|
|
|
|
```bash
|
|
curl -s -X POST "http://localhost:5000/api/evaluate/evaluate_form/send_approve/<form_uid>" -H "Authorization: Bearer <token>"
|
|
curl -s "http://localhost:5000/api/setting/approve_state/reject/<form_uid>" -H "Authorization: Bearer <token>"
|
|
```
|
|
|
|
```bash
|
|
python3 <<'EOF'
|
|
import psycopg2
|
|
conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin",
|
|
password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15)
|
|
cur = conn.cursor()
|
|
cur.execute('SELECT is_sent, is_approve, is_active FROM "BUDGET".evaluate_form WHERE evaluate_form_uid = %s;', ("<form_uid>",))
|
|
print(cur.fetchone())
|
|
conn.close()
|
|
EOF
|
|
```
|
|
|
|
Expected: `(False, False, None)` — `is_sent` reset to `False`, `is_active` untouched (still `None`, never finalized).
|
|
|
|
- [ ] **Step 4: Verify row-2 reject steps back to row 1 (not straight back to the creator)**
|
|
|
|
Create another fresh two-approver form, run it through `set_approvers` → `send_approve` → `approve` as the row-1 approver (advances the queue to row 2), then reject as the row-2 approver:
|
|
|
|
```bash
|
|
curl -s -X POST "http://localhost:5000/api/evaluate/evaluate_form/send_approve/<row2_reject_form_uid>" -H "Authorization: Bearer <token>"
|
|
curl -s "http://localhost:5000/api/setting/approve_state/approve/<row2_reject_form_uid>" -H "Authorization: Bearer <token-matching-หัวหน้าหน่วยงาน>"
|
|
curl -s "http://localhost:5000/api/setting/approve_state/reject/<row2_reject_form_uid>" -H "Authorization: Bearer <token-matching-รองอธิการบดี>"
|
|
```
|
|
|
|
```bash
|
|
python3 <<'EOF'
|
|
import psycopg2
|
|
conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin",
|
|
password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15)
|
|
cur = conn.cursor()
|
|
cur.execute("""SELECT row_order, is_state, is_approve FROM "BUDGET".approve_state
|
|
WHERE evaluate_form_uid = %s ORDER BY row_order;""", ("<row2_reject_form_uid>",))
|
|
print(cur.fetchall())
|
|
cur.execute('SELECT is_sent, is_approve FROM "BUDGET".evaluate_form WHERE evaluate_form_uid = %s;', ("<row2_reject_form_uid>",))
|
|
print(cur.fetchone())
|
|
conn.close()
|
|
EOF
|
|
```
|
|
|
|
Expected: `row_order 1` has `is_state=True, is_approve=None` (reopened), `row_order 2` has `is_state=None, is_approve=None` (not straight back to row 1's original never-approved state — it was `is_approve=True` before this reject and must now be cleared); `evaluate_form.is_sent` stays `True` (unlike the row-1 self-reject case in Step 3), `is_approve=False`.
|
|
|
|
- [ ] **Step 5: Clean up**
|
|
|
|
```bash
|
|
python3 <<'EOF'
|
|
import psycopg2
|
|
conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin",
|
|
password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15)
|
|
cur = conn.cursor()
|
|
cur.execute("""DELETE FROM "BUDGET".approve_state WHERE evaluate_form_uid IN
|
|
(SELECT evaluate_form_uid FROM "BUDGET".evaluate_form WHERE created_by='test.verify');""")
|
|
cur.execute("""DELETE FROM "BUDGET".evaluate_form WHERE created_by='test.verify';""")
|
|
conn.commit()
|
|
conn.close()
|
|
EOF
|
|
```
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
cd /Users/nut.looknut/Project/rmutr/rmutr-api
|
|
git add Modules/Setting/Controllers/ApproveState.cs
|
|
git commit -m "feat(approve_state): add evaluate_form branch to reject endpoint"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: `EvaluateFormReview` controller + service (central-review stage)
|
|
|
|
**Files:**
|
|
- Create: `Modules/EvaluateFormReview/Services/EvaluateFormReview.cs`
|
|
- Create: `Modules/EvaluateFormReview/Controllers/EvaluateFormReview.cs`
|
|
|
|
**Interfaces:**
|
|
- Produces: `GET /api/evaluate_form_review/status/{evaluate_form_uid}`, `GET /api/evaluate_form_review/dashboard`, `POST /api/evaluate_form_review/return_for_edit/{evaluate_form_uid}` (body `{remark}`), `POST /api/evaluate_form_review/mark_reviewed/{evaluate_form_uid}`.
|
|
|
|
- [ ] **Step 1: Service**
|
|
|
|
Create `Modules/EvaluateFormReview/Services/EvaluateFormReview.cs` (mirrors `RequestBudgetReviewService` exactly, entity swapped; `evaluate_form` has no `faculty_uid`/`budget_location_uid` denormalized fields to join for a dashboard item, so the dashboard item is intentionally smaller than `request_budget_review_dashboard_item`):
|
|
|
|
```csharp
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Security.Claims;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using rmutr_budget_api.Modules.EvaluateFormReview.Databases.Models;
|
|
using rmutr_budget_api.Modules.Evaluate.Databases.Models;
|
|
using SeventyOneDev.Utilities;
|
|
|
|
namespace rmutr_budget_api.Modules.EvaluateFormReview.Services
|
|
{
|
|
public class evaluate_form_review_history_item
|
|
{
|
|
public DateTime? datetime { get; set; }
|
|
public string by { get; set; }
|
|
public string detail { get; set; }
|
|
}
|
|
|
|
public class evaluate_form_review_status_response
|
|
{
|
|
public Guid? evaluate_form_uid { get; set; }
|
|
public string current_status { get; set; }
|
|
public List<evaluate_form_review_history_item> history { get; set; } = new();
|
|
}
|
|
|
|
public class evaluate_form_review_dashboard_item
|
|
{
|
|
public Guid? evaluate_form_uid { get; set; }
|
|
public Guid? budget_year_uid { get; set; }
|
|
public string budget_year_name_th { get; set; }
|
|
public DateTime? updated_datetime { get; set; }
|
|
}
|
|
|
|
public class evaluate_form_review_dashboard
|
|
{
|
|
public List<evaluate_form_review_dashboard_item> pending { get; set; } = new();
|
|
public List<evaluate_form_review_dashboard_item> returned { get; set; } = new();
|
|
public List<evaluate_form_review_dashboard_item> reviewed { get; set; } = new();
|
|
}
|
|
|
|
public class EvaluateFormReviewService
|
|
{
|
|
private readonly Db _context;
|
|
|
|
public EvaluateFormReviewService(Db context)
|
|
{
|
|
_context = context;
|
|
}
|
|
|
|
private string ComputeStatus(t_evaluate_form entity, t_evaluate_form_review latestLog)
|
|
{
|
|
if (entity.is_sent == false && entity.is_approve == false) return "returned";
|
|
if (entity.is_approve == true)
|
|
{
|
|
if (latestLog != null && latestLog.review_status_id == 3) return "reviewed";
|
|
return "pending";
|
|
}
|
|
return "not_ready";
|
|
}
|
|
|
|
public async Task<evaluate_form_review_status_response> GetStatus(Guid evaluateFormUid)
|
|
{
|
|
var entity = await _context.t_evaluate_form
|
|
.FirstOrDefaultAsync(c => c.evaluate_form_uid == evaluateFormUid);
|
|
if (entity == null) return null;
|
|
|
|
var logs = await _context.t_evaluate_form_review.AsNoTracking()
|
|
.Where(c => c.evaluate_form_uid == evaluateFormUid)
|
|
.OrderByDescending(c => c.created_datetime)
|
|
.ToListAsync();
|
|
var latestLog = logs.FirstOrDefault();
|
|
|
|
var remarks = await _context.t_remark_history.AsNoTracking()
|
|
.Where(c => c.evaluate_form_uid == evaluateFormUid)
|
|
.OrderByDescending(c => c.updated_date)
|
|
.ToListAsync();
|
|
|
|
var history = remarks
|
|
.Select(r => new evaluate_form_review_history_item
|
|
{
|
|
datetime = r.updated_date,
|
|
by = r.updated_name,
|
|
detail = r.remark_history_name_th,
|
|
})
|
|
.OrderByDescending(h => h.datetime)
|
|
.ToList();
|
|
|
|
return new evaluate_form_review_status_response
|
|
{
|
|
evaluate_form_uid = evaluateFormUid,
|
|
current_status = ComputeStatus(entity, latestLog),
|
|
history = history,
|
|
};
|
|
}
|
|
|
|
public async Task<evaluate_form_review_dashboard> GetDashboard()
|
|
{
|
|
var result = new evaluate_form_review_dashboard();
|
|
|
|
var candidates = await _context.v_evaluate_form.AsNoTracking()
|
|
.Where(e => e.is_approve == true || (e.is_sent == false && e.is_approve == false))
|
|
.ToListAsync();
|
|
var candidateIds = candidates.Select(c => c.evaluate_form_uid).ToList();
|
|
var allLogs = await _context.t_evaluate_form_review.AsNoTracking()
|
|
.Where(r => candidateIds.Contains(r.evaluate_form_uid))
|
|
.ToListAsync();
|
|
var latestLogByForm = allLogs
|
|
.GroupBy(r => r.evaluate_form_uid)
|
|
.ToDictionary(g => g.Key, g => g.OrderByDescending(r => r.created_datetime).First());
|
|
|
|
evaluate_form_review_dashboard_item ToItem(v_evaluate_form e) => new()
|
|
{
|
|
evaluate_form_uid = e.evaluate_form_uid,
|
|
budget_year_uid = e.budget_year_uid,
|
|
budget_year_name_th = e.budget_year_name_th,
|
|
updated_datetime = e.updated_datetime,
|
|
};
|
|
|
|
foreach (var e in candidates)
|
|
{
|
|
latestLogByForm.TryGetValue(e.evaluate_form_uid, out var latestLog);
|
|
var status = ComputeStatus(new t_evaluate_form
|
|
{
|
|
is_sent = e.is_sent,
|
|
is_approve = e.is_approve,
|
|
}, latestLog);
|
|
var item = ToItem(e);
|
|
if (status == "pending") result.pending.Add(item);
|
|
else if (status == "returned") result.returned.Add(item);
|
|
else if (status == "reviewed") result.reviewed.Add(item);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
public async Task<(bool ok, string error)> ReturnForEdit(Guid evaluateFormUid, string remark,
|
|
ClaimsIdentity claimsIdentity)
|
|
{
|
|
var entity = await _context.t_evaluate_form
|
|
.FirstOrDefaultAsync(c => c.evaluate_form_uid == evaluateFormUid);
|
|
if (entity == null) return (false, "ไม่พบรายการ");
|
|
if (entity.is_approve != true)
|
|
return (false, "รายการนี้ยังไม่ผ่านการอนุมัติครบทุกลำดับ ไม่สามารถส่งแก้ไขจากการตรวจสอบได้");
|
|
if (string.IsNullOrWhiteSpace(remark))
|
|
return (false, "กรุณาระบุรายละเอียดที่ส่งแก้ไข");
|
|
|
|
var lastRow = await _context.t_approve_state
|
|
.Where(c => c.evaluate_form_uid == evaluateFormUid)
|
|
.OrderByDescending(c => c.row_order)
|
|
.FirstOrDefaultAsync();
|
|
if (lastRow == null) return (false, "ไม่พบผู้อนุมัติของรายการนี้");
|
|
|
|
lastRow.is_state = true;
|
|
lastRow.is_approve = null;
|
|
_context.t_approve_state.Update(lastRow);
|
|
|
|
entity.is_approve = false;
|
|
entity.current_approve_name = lastRow.display_name_th;
|
|
_context.t_evaluate_form.Update(entity);
|
|
|
|
await _context.t_remark_history.AddAsync(new rmutr_budget_api.Modules.Request.Databases.Models.t_remark_history
|
|
{
|
|
remark_history_uid = Guid.NewGuid(),
|
|
evaluate_form_uid = evaluateFormUid,
|
|
remark_history_name_th = remark,
|
|
updated_date = DateTime.Now,
|
|
updated_name = claimsIdentity?.FindFirst("display_name")?.Value,
|
|
});
|
|
|
|
await _context.t_evaluate_form_review.AddAsync(new t_evaluate_form_review
|
|
{
|
|
evaluate_form_review_uid = Guid.NewGuid(),
|
|
evaluate_form_uid = evaluateFormUid,
|
|
review_status_id = 2,
|
|
remark = remark,
|
|
});
|
|
|
|
await _context.SaveChangesAsync(claimsIdentity);
|
|
return (true, null);
|
|
}
|
|
|
|
public async Task<(bool ok, string error)> MarkReviewed(Guid evaluateFormUid, ClaimsIdentity claimsIdentity)
|
|
{
|
|
var entity = await _context.t_evaluate_form
|
|
.FirstOrDefaultAsync(c => c.evaluate_form_uid == evaluateFormUid);
|
|
if (entity == null) return (false, "ไม่พบรายการ");
|
|
if (entity.is_approve != true)
|
|
return (false, "รายการนี้ยังไม่ผ่านการอนุมัติครบทุกลำดับ");
|
|
|
|
var latestLog = await _context.t_evaluate_form_review.AsNoTracking()
|
|
.Where(c => c.evaluate_form_uid == evaluateFormUid)
|
|
.OrderByDescending(c => c.created_datetime)
|
|
.FirstOrDefaultAsync();
|
|
var isPendingWithLog = latestLog != null && latestLog.review_status_id == 1;
|
|
var isPendingWithoutLog = latestLog == null && entity.is_approve == true;
|
|
if (!isPendingWithLog && !isPendingWithoutLog)
|
|
return (false, "รายการนี้ไม่ได้อยู่ในสถานะรอตรวจสอบ");
|
|
|
|
await _context.t_evaluate_form_review.AddAsync(new t_evaluate_form_review
|
|
{
|
|
evaluate_form_review_uid = Guid.NewGuid(),
|
|
evaluate_form_uid = evaluateFormUid,
|
|
review_status_id = 3,
|
|
});
|
|
await _context.SaveChangesAsync(claimsIdentity);
|
|
return (true, null);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Controller**
|
|
|
|
Create `Modules/EvaluateFormReview/Controllers/EvaluateFormReview.cs`:
|
|
|
|
```csharp
|
|
using System;
|
|
using System.Security.Claims;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using rmutr_budget_api.Modules.EvaluateFormReview.Services;
|
|
|
|
namespace rmutr_budget_api.Modules.EvaluateFormReview.Controllers
|
|
{
|
|
public class evaluate_form_return_for_edit_input
|
|
{
|
|
public string remark { get; set; }
|
|
}
|
|
|
|
[Route("api/evaluate_form_review", Name = "evaluate_form_review")]
|
|
public class EvaluateFormReviewController : Controller
|
|
{
|
|
private readonly EvaluateFormReviewService _service;
|
|
|
|
public EvaluateFormReviewController(EvaluateFormReviewService service)
|
|
{
|
|
_service = service;
|
|
}
|
|
|
|
[Authorize]
|
|
[HttpGet("status/{evaluate_form_uid:guid}")]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
public async Task<ActionResult<evaluate_form_review_status_response>> GetStatus([FromRoute] Guid evaluate_form_uid)
|
|
{
|
|
var result = await _service.GetStatus(evaluate_form_uid);
|
|
return result == null ? NotFound() : Ok(result);
|
|
}
|
|
|
|
[Authorize]
|
|
[HttpGet("dashboard")]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
public async Task<ActionResult<evaluate_form_review_dashboard>> GetDashboard()
|
|
{
|
|
return Ok(await _service.GetDashboard());
|
|
}
|
|
|
|
[Authorize]
|
|
[HttpPost("return_for_edit/{evaluate_form_uid:guid}")]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
public async Task<ActionResult> ReturnForEdit([FromRoute] Guid evaluate_form_uid,
|
|
[FromBody] evaluate_form_return_for_edit_input input)
|
|
{
|
|
var result = await _service.ReturnForEdit(evaluate_form_uid, input?.remark, User.Identity as ClaimsIdentity);
|
|
if (!result.ok) return BadRequest(new { description = result.error });
|
|
return Ok();
|
|
}
|
|
|
|
[Authorize]
|
|
[HttpPost("mark_reviewed/{evaluate_form_uid:guid}")]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
public async Task<ActionResult> MarkReviewed([FromRoute] Guid evaluate_form_uid)
|
|
{
|
|
var result = await _service.MarkReviewed(evaluate_form_uid, User.Identity as ClaimsIdentity);
|
|
if (!result.ok) return BadRequest(new { description = result.error });
|
|
return Ok();
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
`EvaluateFormReviewService` needs to be registered for DI — check `Startup.cs`'s `ConfigureServices` for how `RequestBudgetReviewService` is registered (likely `services.AddScoped<RequestBudgetReviewService>();` or an assembly-scan) and add the equivalent line for `EvaluateFormReviewService` if registration is explicit rather than scanned.
|
|
|
|
- [ ] **Step 3: Build**
|
|
|
|
```bash
|
|
cd /Users/nut.looknut/Project/rmutr/rmutr-api
|
|
dotnet build rmutr-budget-api.csproj
|
|
```
|
|
|
|
Expected: `0 Error(s)`.
|
|
|
|
- [ ] **Step 4: Verify the not-yet-approved guard (Review Focus item 5)**
|
|
|
|
Create a fresh `evaluate_form` with **no** approvers configured at all (`is_approve` still `null`):
|
|
|
|
```bash
|
|
curl -s -w "\n%{http_code}\n" -X POST "http://localhost:5000/api/evaluate_form_review/mark_reviewed/<fresh_form_uid>" -H "Authorization: Bearer <token>"
|
|
curl -s -w "\n%{http_code}\n" -X POST "http://localhost:5000/api/evaluate_form_review/return_for_edit/<fresh_form_uid>" \
|
|
-H "Authorization: Bearer <token>" -H "Content-Type: application/json" -d '{"remark":"test"}'
|
|
```
|
|
|
|
Expected: both `400`.
|
|
|
|
- [ ] **Step 5: Verify the full happy path end-to-end**
|
|
|
|
Reuse (or recreate) a two-approver form, run it through `set_approvers` → `send_approve` → `approve` (row 1) → `approve` (row 2) → confirm `is_approve=true` and an `evaluate_form_review` row with `review_status_id=1` exists (already covered by Task 7 Step 5's assertions) → then:
|
|
|
|
```bash
|
|
curl -s "http://localhost:5000/api/evaluate_form_review/status/<form_uid>" -H "Authorization: Bearer <token>"
|
|
```
|
|
|
|
Expected: `{"current_status":"pending", ...}`.
|
|
|
|
```bash
|
|
curl -s -X POST "http://localhost:5000/api/evaluate_form_review/mark_reviewed/<form_uid>" -H "Authorization: Bearer <token>"
|
|
curl -s "http://localhost:5000/api/evaluate_form_review/status/<form_uid>" -H "Authorization: Bearer <token>"
|
|
```
|
|
|
|
Expected: `{"current_status":"reviewed", ...}`.
|
|
|
|
- [ ] **Step 6: Verify the full `return_for_edit` → re-approve → `mark_reviewed` loop (spec §11 test path 4)**
|
|
|
|
Run a fresh two-approver form through the full chain to `pending` (repeat Task 7 Step 5's flow with two approvers instead of one), then:
|
|
|
|
```bash
|
|
curl -s -X POST "http://localhost:5000/api/evaluate_form_review/return_for_edit/<loop_form_uid>" \
|
|
-H "Authorization: Bearer <token>" -H "Content-Type: application/json" -d '{"remark":"ทดสอบส่งแก้ไขจากการตรวจสอบ"}'
|
|
```
|
|
|
|
```bash
|
|
python3 <<'EOF'
|
|
import psycopg2
|
|
conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin",
|
|
password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15)
|
|
cur = conn.cursor()
|
|
cur.execute("""SELECT row_order, is_state, is_approve FROM "BUDGET".approve_state
|
|
WHERE evaluate_form_uid = %s ORDER BY row_order;""", ("<loop_form_uid>",))
|
|
print(cur.fetchall())
|
|
cur.execute('SELECT is_approve, current_approve_name FROM "BUDGET".evaluate_form WHERE evaluate_form_uid = %s;', ("<loop_form_uid>",))
|
|
print(cur.fetchone())
|
|
conn.close()
|
|
EOF
|
|
```
|
|
|
|
Expected: `row_order 2` (the last row, "รองอธิการบดี") has `is_state=True, is_approve=None` — reopened, **not** `row_order 1` — and `evaluate_form.is_approve=False`, `current_approve_name` matches `row_order 2`'s name.
|
|
|
|
Re-approve as the row-2 approver and confirm the loop closes back to `pending`:
|
|
|
|
```bash
|
|
curl -s "http://localhost:5000/api/setting/approve_state/approve/<loop_form_uid>" -H "Authorization: Bearer <token-matching-รองอธิการบดี>"
|
|
curl -s "http://localhost:5000/api/evaluate_form_review/status/<loop_form_uid>" -H "Authorization: Bearer <token>"
|
|
```
|
|
|
|
Expected: `{"current_status":"pending", "history":[{"detail":"ทดสอบส่งแก้ไขจากการตรวจสอบ", ...}]}` (a second `evaluate_form_review` row with `review_status_id=1` was inserted; `evaluate_form.is_approve` is `true` again).
|
|
|
|
- [ ] **Step 7: Clean up all remaining synthetic data**
|
|
|
|
```bash
|
|
python3 <<'EOF'
|
|
import psycopg2
|
|
conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin",
|
|
password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15)
|
|
cur = conn.cursor()
|
|
cur.execute("""DELETE FROM "BUDGET".remark_history WHERE evaluate_form_uid IN
|
|
(SELECT evaluate_form_uid FROM "BUDGET".evaluate_form WHERE created_by='test.verify');""")
|
|
cur.execute("""DELETE FROM "BUDGET".evaluate_form_review WHERE evaluate_form_uid IN
|
|
(SELECT evaluate_form_uid FROM "BUDGET".evaluate_form WHERE created_by='test.verify');""")
|
|
cur.execute("""DELETE FROM "BUDGET".approve_state WHERE evaluate_form_uid IN
|
|
(SELECT evaluate_form_uid FROM "BUDGET".evaluate_form WHERE created_by='test.verify');""")
|
|
cur.execute("""DELETE FROM "BUDGET".evaluate_form WHERE created_by='test.verify';""")
|
|
conn.commit()
|
|
conn.close()
|
|
EOF
|
|
```
|
|
|
|
Stop the local `dotnet run` process.
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
cd /Users/nut.looknut/Project/rmutr/rmutr-api
|
|
git add Modules/EvaluateFormReview/Services/EvaluateFormReview.cs Modules/EvaluateFormReview/Controllers/EvaluateFormReview.cs Startup.cs
|
|
git commit -m "feat(evaluate_form_review): add central-review status/dashboard/return_for_edit/mark_reviewed endpoints"
|
|
```
|
|
|
|
Backend work is complete after this task. Push the branch and merge into `master` per this project's normal flow before starting frontend tasks, so the frontend tasks can build against the real deployed (or at least locally running) API.
|
|
|
|
---
|
|
|
|
## Task 10: "กำหนดผู้อนุมัติ" button on evaluate-collect-send
|
|
|
|
**Files:**
|
|
- Modify: `src/app/core/service/evaluate/evaluate-form.service.ts`
|
|
- Modify: `src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.ts`
|
|
- Modify: `src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.html`
|
|
|
|
**Interfaces:**
|
|
- Produces: `EvaluateFormService.setApprovers(id, approvers)`, `EvaluateCollectSendComponent.onSetApprovers()`.
|
|
- Consumes: `SetApproversDialogComponent` (`src/app/shared/components/set-approvers-dialog/set-approvers-dialog.component.ts`) — unchanged.
|
|
|
|
- [ ] **Step 1: Add the service method**
|
|
|
|
Edit `src/app/core/service/evaluate/evaluate-form.service.ts`:
|
|
|
|
```typescript
|
|
import {HttpClient} from '@angular/common/http';
|
|
import {Injectable} from '@angular/core';
|
|
import {BaseService} from '../../base/base-service';
|
|
|
|
@Injectable({
|
|
providedIn: 'root'
|
|
})
|
|
export class EvaluateFormService extends BaseService {
|
|
|
|
constructor(
|
|
public http: HttpClient
|
|
) {
|
|
super('/evaluate/evaluate_form', http);
|
|
}
|
|
|
|
setApprovers(id: string, approvers: { personnel_uid: string, display_name_th: string }[]) {
|
|
return this.post(`set_approvers/${id}`, approvers);
|
|
}
|
|
|
|
sendApprove(id: string) {
|
|
return this.post(`send_approve/${id}`, {});
|
|
}
|
|
}
|
|
```
|
|
|
|
(Check `BaseService.post()`'s signature in `src/app/core/base/base-service.ts` before writing this — if it doesn't build a URL the same way `RequestExpenseService`'s raw `this.http.post` calls do, use the same `this.http.post` pattern instead, pointed at `${this.fullUrl}/set_approvers/${id}` / `${this.fullUrl}/send_approve/${id}`.)
|
|
|
|
- [ ] **Step 2: Add the dialog wiring to the component**
|
|
|
|
Edit `src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.ts`, add imports:
|
|
|
|
```typescript
|
|
import {SetApproversDialogComponent} from '../../../../shared/components/set-approvers-dialog/set-approvers-dialog.component';
|
|
import Swal from 'sweetalert2';
|
|
```
|
|
|
|
Add the method (near `onSend`):
|
|
|
|
```typescript
|
|
onSetApprovers() {
|
|
const existing = (this.dataForm.approve_states || []).slice().sort((a, b) => a.row_order - b.row_order);
|
|
const ref = this.dialog.open(SetApproversDialogComponent, {
|
|
width: '600px',
|
|
disableClose: true,
|
|
data: {approvers: existing},
|
|
});
|
|
ref.afterClosed().subscribe((approvers) => {
|
|
if (!approvers) return;
|
|
this.evaluateFormService.setApprovers(this.uid, approvers).subscribe(
|
|
() => {
|
|
Swal.fire({title: 'บันทึกผู้อนุมัติสำเร็จ', icon: 'success', confirmButtonText: 'ตกลง'}).then(() => this.getData());
|
|
},
|
|
(err) => {
|
|
Swal.fire(err.error?.description || 'เกิดข้อผิดพลาด', '', 'error');
|
|
}
|
|
);
|
|
});
|
|
}
|
|
```
|
|
|
|
This relies on `dataForm.approve_states` and `dataForm.is_sent`/`is_approve` being present on the entity returned by `GET /evaluate/evaluate_form/{uid}` — Task 2 already wired `evaluate_form.approve_states : List<v_approve_state>` (via `[Include]` + the `HasOne`/`WithMany` in `Modules/Setting/Databases/Db.cs`), so no further backend change is needed here.
|
|
|
|
- [ ] **Step 3: Add the button to the template**
|
|
|
|
Edit `src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.html`, add before the existing `onSend()` button (around line 306):
|
|
|
|
```html
|
|
<button (click)="onSetApprovers()" *ngIf="isAction !== 'view' && !dataForm.is_sent" mat-raised-button type="button">กำหนดผู้อนุมัติ</button>
|
|
```
|
|
|
|
- [ ] **Step 4: Build**
|
|
|
|
```bash
|
|
cd /Users/nut.looknut/Project/rmutr/rmutr-web
|
|
ng build
|
|
```
|
|
|
|
Expected: compiles with no new errors.
|
|
|
|
- [ ] **Step 5: Manual browser walkthrough**
|
|
|
|
Start the app (`npm run start` or `ng serve`), navigate to `/app/research-and-evaluation/evaluate-collect`, open an existing (or newly created) form's detail page. Click "กำหนดผู้อนุมัติ" — the dialog opens with the two approver fields (level 1 required, level 2 optional), pick a level-1 approver from the autocomplete, click save. Expected: success toast, and re-loading the page shows the button state doesn't change yet (that's Task 11/12) but no console errors.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
cd /Users/nut.looknut/Project/rmutr/rmutr-web
|
|
git add src/app/core/service/evaluate/evaluate-form.service.ts src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.ts src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.html
|
|
git commit -m "feat(evaluate-collect): add กำหนดผู้อนุมัติ button"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11: Replace "ส่งผล" with "ส่งอนุมัติ" on evaluate-collect-send
|
|
|
|
**Files:**
|
|
- Modify: `src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.ts`
|
|
- Modify: `src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.html`
|
|
|
|
- [ ] **Step 1: Replace `onSend()`**
|
|
|
|
Edit `evaluate-collect-send.component.ts`, replace the existing `onSend()` method:
|
|
|
|
```typescript
|
|
async onSend() {
|
|
const confirmed = await this.sweetalertService.confirm('ต้องการส่งอนุมัติหรือไม่', 'ส่งอนุมัติ').toPromise();
|
|
if (!confirmed?.isConfirmed) return;
|
|
try {
|
|
await this.evaluateFormService.put(this.dataForm).toPromise();
|
|
await this.evaluateFormService.sendApprove(this.uid).toPromise();
|
|
this.sweetalertService.saveSuccess();
|
|
this.onCancel();
|
|
} catch (err) {
|
|
this.sweetalertService.errText(err.error?.description || 'เกิดข้อผิดพลาด');
|
|
}
|
|
}
|
|
```
|
|
|
|
(Check `SweetalertService.confirm()`'s exact signature used elsewhere in this component — it's already called with `(message, title)` in `onSendBackAgency()` above, so the call above matches that existing convention.)
|
|
|
|
- [ ] **Step 2: Update the button label and guard**
|
|
|
|
Edit `evaluate-collect-send.component.html` line ~306:
|
|
|
|
```html
|
|
<button (click)="onSend()" *ngIf="isAction !== 'view' && dataForm.approve_states?.length && !dataForm.is_sent" class="btn-green" mat-raised-button type="button">ส่งอนุมัติ</button>
|
|
```
|
|
|
|
- [ ] **Step 3: Build**
|
|
|
|
```bash
|
|
cd /Users/nut.looknut/Project/rmutr/rmutr-web
|
|
ng build
|
|
```
|
|
|
|
Expected: compiles with no new errors.
|
|
|
|
- [ ] **Step 4: Manual browser walkthrough**
|
|
|
|
On the same test form from Task 10 (approvers already set), confirm "ส่งอนุมัติ" now shows (and "กำหนดผู้อนุมัติ" is still visible since `is_sent` is still falsy), click it, confirm the dialog, confirm success. Reload the page — "กำหนดผู้อนุมัติ" should now be hidden (per Task 10 Step 3's `!dataForm.is_sent` guard) and "ส่งอนุมัติ" should also be hidden (per this task's guard).
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
cd /Users/nut.looknut/Project/rmutr/rmutr-web
|
|
git add src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.ts src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.html
|
|
git commit -m "feat(evaluate-collect): replace ส่งผล with ส่งอนุมัติ"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 12: Approve/reject-with-remark UI on evaluate-collect-send
|
|
|
|
**Files:**
|
|
- Create: `src/app/@common/utils/evaluate-form-approval.ts`
|
|
- Modify: `src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.ts`
|
|
- Modify: `src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.html`
|
|
|
|
**Interfaces:**
|
|
- Produces (in `evaluate-form-approval.ts`): `isPristineApproval`, `canDefineApprovers`, `canApproveNow`, `canReject`, `approveStatusText` — same shapes as `request-expense-approval.ts` but reading `is_sent`/`is_approve`/`approve_states` directly off the `evaluate_form` entity (no `is_draft` field exists on `evaluate_form`, so every `is_draft`-based branch in the original collapses to always-false, which matches this feature's simpler pristine model from spec §3.2).
|
|
|
|
- [ ] **Step 1: Write the util**
|
|
|
|
Create `src/app/@common/utils/evaluate-form-approval.ts`:
|
|
|
|
```typescript
|
|
/**
|
|
* ตรรกะสถานะ/สิทธิ์ของ "ชุดอนุมัติ" ที่เก็บใน evaluate_form (t_approve_state เป็นคิวผู้อนุมัติ) —
|
|
* เหมือน request-expense-approval.ts แต่ evaluate_form ไม่มี is_draft, ใช้แค่ is_sent/is_approve
|
|
* ดู docs/superpowers/specs/2026-09-24-evaluate-collect-approve-workflow-design.md §3.2
|
|
*/
|
|
|
|
export function isPristineApproval(form: any): boolean {
|
|
if (!form) return false
|
|
if (form.is_sent != null || form.is_approve != null) return false
|
|
return !(form.approve_states || []).some((s: any) => s.is_approve == true)
|
|
}
|
|
|
|
export function canDefineApprovers(form: any): boolean {
|
|
return isPristineApproval(form)
|
|
}
|
|
|
|
export function wasJustRejected(form: any): boolean {
|
|
return form?.is_sent === false && form?.is_approve === false
|
|
}
|
|
|
|
export function canAnyoneSend(form: any): boolean {
|
|
if (!form) return false
|
|
if (form.is_approve == true) return false
|
|
if (form.is_sent == true) return false
|
|
const pending = (form.approve_states || []).find((s: any) => s.is_state == true)
|
|
return !!pending && pending.row_order == 1
|
|
}
|
|
|
|
export function canApproveRow1(form: any, myName: string): boolean {
|
|
if (!form) return false
|
|
if (form.is_approve == true) return false
|
|
if (form.is_sent != true) return false
|
|
const pending = (form.approve_states || []).find((s: any) => s.is_state == true)
|
|
if (!pending || pending.row_order != 1) return false
|
|
return !!myName && myName === pending.display_name_th
|
|
}
|
|
|
|
export function canApproveNow(form: any, myName: string): boolean {
|
|
// ไม่ใช้ canApproveRow1 + OR แบบเดิม เพราะ is_state/row_order เป็นฟิลด์ของแถว approve_state
|
|
// แต่ละแถว ไม่ใช่ฟิลด์บน form เอง — เช็คจากแถวที่ is_state==true ตรงๆ ครอบคลุมทุก row_order
|
|
// (ดู docs/superpowers/specs/2026-09-24-evaluate-collect-approve-workflow-design.md §6)
|
|
if (!form) return false
|
|
if (form.is_approve == true) return false
|
|
if (form.is_sent != true) return false
|
|
const pending = (form.approve_states || []).find((s: any) => s.is_state == true)
|
|
return !!pending && !!myName && myName === pending.display_name_th
|
|
}
|
|
|
|
export function canReject(form: any, myName: string): boolean {
|
|
return canApproveNow(form, myName)
|
|
}
|
|
|
|
export function approveStatusText(form: any): string {
|
|
if (!form) return 'ยังไม่ได้กำหนดผู้อนุมัติ'
|
|
if (form.is_approve == true) return 'อนุมัติแล้ว'
|
|
const pending = (form.approve_states || []).find((s: any) => s.is_state == true)
|
|
if (!pending) return 'ยังไม่ได้กำหนดผู้อนุมัติ'
|
|
if (pending.row_order > 1) return `ส่งอนุมัติแล้ว รอ${pending.display_name_th}เป็นผู้อนุมัติ`
|
|
if (wasJustRejected(form)) {
|
|
return form.is_sent == true
|
|
? 'ถูกส่งแก้ไขจากลำดับถัดไป รอแก้ไขแล้วให้ผู้อนุมัติกด "อนุมัติ" อีกครั้ง'
|
|
: 'ถูกส่งแก้ไข กรุณาแก้ไขข้อมูลแล้วส่งอนุมัติใหม่'
|
|
}
|
|
if (form.is_sent == true) return `ส่งอนุมัติแล้ว รอ${pending.display_name_th}เป็นผู้อนุมัติ`
|
|
return 'ยังไม่ได้ส่งอนุมัติ'
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Wire approve/reject into the component**
|
|
|
|
Edit `evaluate-collect-send.component.ts`, add imports:
|
|
|
|
```typescript
|
|
import {canApproveNow, canReject} from '../../../../@common/utils/evaluate-form-approval';
|
|
import {RequestExpenseService} from '../../../../core/service/request/request-expense.service';
|
|
import {RemarkHistoryService} from '../../../../core/service/request/remark-history.service';
|
|
import {AppTokenService} from '../../../../core/service/security/app-token.service';
|
|
```
|
|
|
|
Inject `RemarkHistorySV: RemarkHistoryService` and `AppTokenSV: AppTokenService` in the constructor (add as new constructor params). Note: approve/reject themselves reuse the **generic** `/setting/approve_state/approve|reject/{uid}` endpoints — `RequestExpenseService.approve(id)`/`.reject(id)` already point at those generic paths regardless of entity type (they don't touch `/request/request_expense/...`), so it is safe and correct to reuse `RequestExpenseService` here purely for those two generic calls rather than adding duplicate methods to `EvaluateFormService`.
|
|
|
|
Add the methods:
|
|
|
|
```typescript
|
|
isCurrentApprover(): boolean {
|
|
const currentDisplayName = this.AppTokenSV.payload?.display;
|
|
return canApproveNow(this.dataForm, currentDisplayName);
|
|
}
|
|
|
|
canRejectNow(): boolean {
|
|
const currentDisplayName = this.AppTokenSV.payload?.display;
|
|
return canReject(this.dataForm, currentDisplayName);
|
|
}
|
|
|
|
onApprove() {
|
|
Swal.fire({
|
|
icon: 'warning', title: 'ต้องการอนุมัติหรือไม่',
|
|
showCancelButton: true, confirmButtonText: 'อนุมัติ', cancelButtonText: 'ยกเลิก',
|
|
}).then((result) => {
|
|
if (!result.isConfirmed) return;
|
|
this.requestExpenseService.approve(this.uid).subscribe(
|
|
() => Swal.fire({title: 'อนุมัติสำเร็จ', icon: 'success', confirmButtonText: 'ตกลง'}).then(() => this.getData()),
|
|
(err) => Swal.fire(err.error?.description || 'เกิดข้อผิดพลาด', '', 'error')
|
|
);
|
|
});
|
|
}
|
|
|
|
async onReject() {
|
|
const {value: remark} = await Swal.fire({
|
|
input: 'textarea', inputLabel: 'รายละเอียดที่ส่งแก้ไข',
|
|
inputPlaceholder: 'กรอกรายละเอียดที่ส่งแก้ไข...', width: 1200,
|
|
showCancelButton: true, confirmButtonText: 'ส่งแก้ไข', cancelButtonText: 'ยกเลิก',
|
|
confirmButtonColor: '#d33',
|
|
inputValidator: (v) => !v ? 'กรุณาระบุรายละเอียดที่ต้องแก้ไข' : null,
|
|
});
|
|
if (!remark) return;
|
|
this.remarkHistoryService.add({
|
|
evaluate_form_uid: this.uid,
|
|
remark_history_name_th: remark,
|
|
updated_name: this.AppTokenSV.payload?.display,
|
|
updated_date: new Date(),
|
|
}).pipe(
|
|
concatMap(() => this.requestExpenseService.reject(this.uid))
|
|
).subscribe(
|
|
() => Swal.fire({title: 'ส่งแก้ไขสำเร็จ', icon: 'success', confirmButtonText: 'ตกลง'}).then(() => this.getData()),
|
|
(err) => Swal.fire(err.error?.description || 'เกิดข้อผิดพลาด', '', 'error')
|
|
);
|
|
}
|
|
```
|
|
|
|
Add `import {concatMap} from 'rxjs/operators';` and rename the injected `RequestExpenseService`/`RemarkHistoryService` instances to match whatever casing convention this file already uses for its other injected services (the constructor list above uses `public evaluateFormService`, `public hrAgencyService` — lowercase-first; follow that, i.e. `private requestExpenseService: RequestExpenseService` and `private remarkHistoryService: RemarkHistoryService`, and update the method bodies above to match).
|
|
|
|
- [ ] **Step 3: Add buttons + remark history table to the template**
|
|
|
|
Edit `evaluate-collect-send.component.html`, add near the existing action buttons (before the "ส่งอนุมัติ" button from Task 11):
|
|
|
|
```html
|
|
<button (click)="onApprove()" *ngIf="isAction !== 'view' && isCurrentApprover()" class="btn-green" mat-raised-button type="button">อนุมัติ</button>
|
|
<button (click)="onReject()" *ngIf="isAction !== 'view' && canRejectNow()" mat-raised-button type="button" style="background-color:#E74C3C;color:#fff;">ส่งแก้ไข</button>
|
|
```
|
|
|
|
Add a remark history table near the bottom of the form, gated on `dataForm.remark_historys?.length` (mirrors `currentRound4RemarkHistorys()`'s table in `request-budget.container.html`, simplified since `evaluate_form` has no `round_no` concept):
|
|
|
|
```html
|
|
<table *ngIf="dataForm.remark_historys?.length" class="remark-history-table">
|
|
<thead>
|
|
<tr><th>วันที่</th><th>รายละเอียด</th><th>ผู้บันทึก</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr *ngFor="let r of dataForm.remark_historys">
|
|
<td>{{r.updated_date | thaiDate}}</td>
|
|
<td>{{r.remark_history_name_th}}</td>
|
|
<td>{{r.updated_name}}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
```
|
|
|
|
This relies on the `GET /evaluate/evaluate_form/{uid}` response including a `remark_historys` navigation collection — Task 2 already wired `evaluate_form.remark_historys : List<v_remark_history>` (via `[Include]` + the `HasOne`/`WithMany` in `Modules/Requests/Databases/Db.cs`), so no further backend change is needed here.
|
|
|
|
- [ ] **Step 4: Build**
|
|
|
|
```bash
|
|
cd /Users/nut.looknut/Project/rmutr/rmutr-web
|
|
ng build
|
|
```
|
|
|
|
Expected: compiles with no new errors.
|
|
|
|
- [ ] **Step 5: Manual browser walkthrough**
|
|
|
|
Using the test form from Tasks 10-11 (already sent for approval), log in as the level-1 approver (matching `display_name` claim) and confirm the "อนุมัติ"/"ส่งแก้ไข" buttons appear on evaluate-collect-send; log in as a different user and confirm neither button appears. Then, on a **second** test form with both approvers configured, log in as the level-1 approver and click "อนุมัติ" — reload as the level-2 approver ("รองอธิการบดี") and confirm "อนุมัติ"/"ส่งแก้ไข" now appear for them too (this is the case the row_order>1 branch of `canApproveNow`/`canReject` must get right — verify it explicitly, don't skip straight to level 1 only). Click "ส่งแก้ไข" as the level-2 approver, fill the remark, confirm it appears in the remark-history table on reload.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
cd /Users/nut.looknut/Project/rmutr/rmutr-web
|
|
git add src/app/@common/utils/evaluate-form-approval.ts src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.ts src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.html
|
|
git commit -m "feat(evaluate-collect): add approve/ส่งแก้ไข UI + remark history table"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 13: Disable "ส่งกลับหน่วยงาน" while pending approval
|
|
|
|
**Files:**
|
|
- Modify: `src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.html`
|
|
|
|
- [ ] **Step 1: Add the guard**
|
|
|
|
Edit the existing "ส่งกลับหน่วยงาน" button (line ~303):
|
|
|
|
```html
|
|
<button *ngIf="isAction !== 'view' && !(dataForm.is_sent && !dataForm.is_approve)" (click)="onSendBackAgency()" mat-raised-button type="button" style="background-color:#E74C3C;color:#fff;">
|
|
```
|
|
|
|
(Only the `*ngIf` condition changes; leave the rest of the button unchanged.)
|
|
|
|
- [ ] **Step 2: Build**
|
|
|
|
```bash
|
|
cd /Users/nut.looknut/Project/rmutr/rmutr-web
|
|
ng build
|
|
```
|
|
|
|
Expected: compiles with no new errors.
|
|
|
|
- [ ] **Step 3: Manual browser walkthrough**
|
|
|
|
On the test form that has been sent for approval (Task 11), confirm "ส่งกลับหน่วยงาน" is now hidden. On a fresh form that hasn't entered the approval flow, confirm it still shows.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
cd /Users/nut.looknut/Project/rmutr/rmutr-web
|
|
git add src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.html
|
|
git commit -m "fix(evaluate-collect): hide ส่งกลับหน่วยงาน while pending approval"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 14: Review-status component on evaluate-follow-send
|
|
|
|
**Files:**
|
|
- Create: `src/app/core/service/evaluate/evaluate-form-review.service.ts`
|
|
- Create: `src/app/feature/research-and-evaluation/evaluate-follow/presenter/evaluate-form-review-status/evaluate-form-review-status.component.ts`
|
|
- Create: `src/app/feature/research-and-evaluation/evaluate-follow/presenter/evaluate-form-review-status/evaluate-form-review-status.component.html`
|
|
- Create: `src/app/feature/research-and-evaluation/evaluate-follow/presenter/evaluate-form-review-status/evaluate-form-review-status.component.scss`
|
|
- Modify: `src/app/feature/research-and-evaluation/research-and-evaluation.module.ts` (declare the new component — this is the single shared module for the whole research-and-evaluation area; `RequestBudgetReviewStatusComponent`'s equivalent is declared in its own feature module, `request-budget.module.ts`, not a global shared module, so mirror that pattern here rather than using `src/app/shared/shared.module.ts`)
|
|
- Modify: `src/app/feature/research-and-evaluation/evaluate-follow/send/evaluate-follow-send.component.ts`
|
|
- Modify: `src/app/feature/research-and-evaluation/evaluate-follow/send/evaluate-follow-send.component.html`
|
|
|
|
**Interfaces:**
|
|
- Produces: `EvaluateFormReviewService` with `getStatus`/`getDashboard`/`returnForEdit`/`markReviewed`, and `<app-evaluate-form-review-status [evaluateFormUid]="..." [showActions]="...">`.
|
|
|
|
- [ ] **Step 1: Service**
|
|
|
|
Create `src/app/core/service/evaluate/evaluate-form-review.service.ts` (mirrors `request-budget-review.service.ts` exactly):
|
|
|
|
```typescript
|
|
import { HttpClient } from '@angular/common/http';
|
|
import { Injectable } from '@angular/core';
|
|
import { environment } from 'src/environments/environment';
|
|
|
|
@Injectable({
|
|
providedIn: 'root'
|
|
})
|
|
export class EvaluateFormReviewService {
|
|
|
|
private base = `${environment.rmutrApi}/api/evaluate_form_review`;
|
|
|
|
constructor(private http: HttpClient) { }
|
|
|
|
getStatus(evaluateFormUid: string) {
|
|
return this.http.get(`${this.base}/status/${evaluateFormUid}`);
|
|
}
|
|
|
|
getDashboard() {
|
|
return this.http.get(`${this.base}/dashboard`);
|
|
}
|
|
|
|
returnForEdit(evaluateFormUid: string, remark: string) {
|
|
return this.http.post(`${this.base}/return_for_edit/${evaluateFormUid}`, { remark });
|
|
}
|
|
|
|
markReviewed(evaluateFormUid: string) {
|
|
return this.http.post(`${this.base}/mark_reviewed/${evaluateFormUid}`, {});
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Component**
|
|
|
|
Create `src/app/shared/components/evaluate-form-review-status/evaluate-form-review-status.component.ts` (mirrors `RequestBudgetReviewStatusComponent` exactly, `requestExpenseUid` → `evaluateFormUid`, service swapped):
|
|
|
|
```typescript
|
|
import { Component, Input, OnChanges, SimpleChanges, ChangeDetectionStrategy, ChangeDetectorRef, EventEmitter, Output } from '@angular/core';
|
|
import { catchError, tap } from 'rxjs/operators';
|
|
import { throwError } from 'rxjs';
|
|
import { EvaluateFormReviewService } from 'src/app/core/service/evaluate/evaluate-form-review.service';
|
|
import Swal from 'sweetalert2';
|
|
|
|
@Component({
|
|
selector: 'app-evaluate-form-review-status',
|
|
templateUrl: './evaluate-form-review-status.component.html',
|
|
styleUrls: ['./evaluate-form-review-status.component.scss'],
|
|
changeDetection: ChangeDetectionStrategy.OnPush
|
|
})
|
|
export class EvaluateFormReviewStatusComponent implements OnChanges {
|
|
|
|
@Input() evaluateFormUid: string | null = null;
|
|
@Input() showActions = false;
|
|
@Output() onchanged = new EventEmitter<void>();
|
|
|
|
isLoading = false;
|
|
status: any = null;
|
|
isHistoryExpanded = false;
|
|
|
|
statusLabel: { [key: string]: string } = {
|
|
not_ready: 'ยังไม่ผ่านการอนุมัติครบทุกลำดับ',
|
|
pending: 'รอตรวจสอบ',
|
|
returned: 'ถูกส่งแก้ไข รอผู้รวบรวมข้อมูลแก้ไข',
|
|
reviewed: 'ตรวจสอบแล้ว',
|
|
};
|
|
|
|
constructor(
|
|
private reviewSV: EvaluateFormReviewService,
|
|
private cd: ChangeDetectorRef,
|
|
) { }
|
|
|
|
ngOnChanges(changes: SimpleChanges): void {
|
|
if (changes.evaluateFormUid) {
|
|
this.load();
|
|
}
|
|
}
|
|
|
|
load() {
|
|
this.status = null;
|
|
if (!this.evaluateFormUid) {
|
|
this.cd.markForCheck();
|
|
return;
|
|
}
|
|
this.isLoading = true;
|
|
this.reviewSV.getStatus(this.evaluateFormUid).pipe(
|
|
tap((res: any) => {
|
|
this.status = res;
|
|
this.isLoading = false;
|
|
this.cd.markForCheck();
|
|
}),
|
|
catchError(err => {
|
|
this.isLoading = false;
|
|
this.cd.markForCheck();
|
|
return throwError(err);
|
|
})
|
|
).subscribe();
|
|
}
|
|
|
|
toggleHistory() {
|
|
this.isHistoryExpanded = !this.isHistoryExpanded;
|
|
}
|
|
|
|
async onReturnForEdit() {
|
|
const { value: remark } = await Swal.fire({
|
|
input: 'textarea',
|
|
inputLabel: 'รายละเอียดที่ส่งแก้ไข',
|
|
inputPlaceholder: 'กรอกรายละเอียดที่ส่งแก้ไข...',
|
|
width: 1200,
|
|
showCancelButton: true,
|
|
confirmButtonText: 'ส่งแก้ไข',
|
|
cancelButtonText: 'ยกเลิก',
|
|
inputValidator: (v) => !v ? 'กรุณาระบุรายละเอียดที่ต้องแก้ไข' : null,
|
|
});
|
|
if (!remark) return;
|
|
|
|
this.reviewSV.returnForEdit(this.evaluateFormUid, remark).pipe(
|
|
catchError(err => {
|
|
Swal.fire(err.error?.description || 'เกิดข้อผิดพลาด', '', 'error');
|
|
return throwError(err);
|
|
})
|
|
).subscribe(() => {
|
|
Swal.fire({ title: 'ส่งแก้ไขสำเร็จ', icon: 'success', confirmButtonText: 'ตกลง' }).then(() => {
|
|
this.load();
|
|
this.onchanged.emit();
|
|
});
|
|
});
|
|
}
|
|
|
|
onMarkReviewed() {
|
|
Swal.fire({
|
|
icon: 'warning',
|
|
title: 'ยืนยันว่าตรวจสอบแล้วใช่หรือไม่',
|
|
showCancelButton: true,
|
|
confirmButtonText: 'ยืนยัน',
|
|
cancelButtonText: 'ยกเลิก',
|
|
}).then((result) => {
|
|
if (!result.isConfirmed) return;
|
|
this.reviewSV.markReviewed(this.evaluateFormUid).pipe(
|
|
catchError(err => {
|
|
Swal.fire(err.error?.description || 'เกิดข้อผิดพลาด', '', 'error');
|
|
return throwError(err);
|
|
})
|
|
).subscribe(() => {
|
|
Swal.fire({ title: 'บันทึกสำเร็จ', icon: 'success', confirmButtonText: 'ตกลง' }).then(() => {
|
|
this.load();
|
|
this.onchanged.emit();
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
}
|
|
```
|
|
|
|
Create `evaluate-form-review-status.component.html` (identical copy of `request-budget-review-status.component.html`, `status.request_expense_uid`-independent so no rename needed inside the template):
|
|
|
|
```html
|
|
<div class="review-status-card" *ngIf="status">
|
|
<div class="status-row">
|
|
<span class="status-label">สถานะการตรวจสอบ:</span>
|
|
<span class="status-badge" [ngClass]="'status-' + status.current_status">
|
|
{{statusLabel[status.current_status]}}
|
|
</span>
|
|
</div>
|
|
|
|
<div class="revision-table-wrapper" *ngIf="status.history?.length">
|
|
<div class="revision-table-header" (click)="toggleHistory()">
|
|
<span class="material-icons revision-table-header__chevron" [class.revision-table-header__chevron--open]="isHistoryExpanded">chevron_right</span>
|
|
<span>ประวัติการส่งแก้ไข ({{status.history.length}})</span>
|
|
</div>
|
|
<table class="revision-table" *ngIf="isHistoryExpanded">
|
|
<thead>
|
|
<tr>
|
|
<th class="col-date">วันที่</th>
|
|
<th class="col-name">รายละเอียด</th>
|
|
<th class="col-editor">ผู้บันทึก</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr *ngFor="let h of status.history">
|
|
<td class="col-date">{{h.datetime | thaiDate}}</td>
|
|
<td class="col-name">{{h.detail}}</td>
|
|
<td class="col-editor">{{h.by}}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="status-actions" *ngIf="showActions && status.current_status === 'pending'">
|
|
<button mat-raised-button color="warn" (click)="onReturnForEdit()">ส่งแก้ไข</button>
|
|
<button mat-raised-button class="btn-mark-reviewed" (click)="onMarkReviewed()">ตรวจสอบแล้ว</button>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
Create `evaluate-form-review-status.component.scss` with this exact content (copied verbatim from `request-budget-review-status.component.scss`):
|
|
|
|
```scss
|
|
.review-status-card {
|
|
background: #ffffff;
|
|
border-radius: 8px;
|
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
|
|
padding: 1rem 1.5rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.status-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
|
|
.status-label {
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
color: #37474f;
|
|
}
|
|
|
|
.status-badge {
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
padding: 4px 12px;
|
|
border-radius: 16px;
|
|
|
|
&.status-pending {
|
|
background: #e3f2fd;
|
|
color: #1565c0;
|
|
}
|
|
&.status-returned {
|
|
background: #fff3e0;
|
|
color: #e65100;
|
|
}
|
|
&.status-reviewed {
|
|
background: #e8f5e9;
|
|
color: #2e7d32;
|
|
}
|
|
&.status-not_ready {
|
|
background: #eceff1;
|
|
color: #607d8b;
|
|
}
|
|
}
|
|
|
|
.revision-table-wrapper {
|
|
overflow-x: auto;
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
|
|
.revision-table-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
padding: 6px 0;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
color: #37474f;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
|
|
&:hover {
|
|
color: #0C7469;
|
|
}
|
|
}
|
|
|
|
.revision-table-header__chevron {
|
|
font-size: 18px;
|
|
transition: transform 0.15s ease;
|
|
|
|
&--open {
|
|
transform: rotate(90deg);
|
|
}
|
|
}
|
|
|
|
.revision-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 13px;
|
|
|
|
th, td {
|
|
padding: 6px 8px;
|
|
border-bottom: 1px solid #eceff1;
|
|
text-align: left;
|
|
}
|
|
|
|
th {
|
|
background: linear-gradient(135deg, #118F82 0%, #0C7469 100%);
|
|
color: #ffffff;
|
|
font-weight: 600;
|
|
}
|
|
}
|
|
|
|
.status-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
|
|
.btn-mark-reviewed {
|
|
background-color: #2e7d32 !important;
|
|
color: #ffffff !important;
|
|
|
|
&:hover {
|
|
background-color: #27682b !important;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Declare in `research-and-evaluation.module.ts`**
|
|
|
|
Edit `src/app/feature/research-and-evaluation/research-and-evaluation.module.ts`: import `EvaluateFormReviewStatusComponent` from its new path and add it to the module's `declarations` array (follow the exact pattern already used there for `EvaluateCollectSendComponent`/`EvaluateFollowSendComponent` — same module, same array).
|
|
|
|
- [ ] **Step 4: Replace `onSendBack()` in evaluate-follow-send**
|
|
|
|
Edit `evaluate-follow-send.component.ts`: delete the `onSendBack()` method entirely (and the now-unused `loadComments()`/`loadHistory()`/`commentHistory` if nothing else in the component reads `follow_comment`/`commentHistory` — check the `.html` template for other usages of `follow_comment`/`commentHistory` before deleting; if the template still displays historical `follow_comment` values read-only elsewhere, keep `loadComments()`/`loadHistory()` and only delete `onSendBack()`).
|
|
|
|
- [ ] **Step 5: Add the component to the template**
|
|
|
|
Edit `evaluate-follow-send.component.html`, replace the "ส่งกลับแก้ไข" button block (line ~313-315) with:
|
|
|
|
```html
|
|
<app-evaluate-form-review-status [evaluateFormUid]="uid" [showActions]="isAction === 'send'"></app-evaluate-form-review-status>
|
|
```
|
|
|
|
- [ ] **Step 6: Build**
|
|
|
|
```bash
|
|
cd /Users/nut.looknut/Project/rmutr/rmutr-web
|
|
ng build
|
|
```
|
|
|
|
Expected: compiles with no new errors.
|
|
|
|
- [ ] **Step 7: Manual browser walkthrough**
|
|
|
|
Using the fully-approved test form (`is_approve=true` from Task 9's happy-path verification, or run a fresh one through the full chain via the UI), navigate to its evaluate-follow-send detail page. Confirm the status card shows "รอตรวจสอบ" with "ส่งแก้ไข"/"ตรวจสอบแล้ว" buttons. Click "ส่งแก้ไข", fill a remark, confirm status flips to "ถูกส่งแก้ไข รอผู้รวบรวมข้อมูลแก้ไข" and the old "ส่งกลับแก้ไข" button/behavior is gone. On a different form still `pending`, click "ตรวจสอบแล้ว" and confirm status flips to "ตรวจสอบแล้ว".
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
cd /Users/nut.looknut/Project/rmutr/rmutr-web
|
|
git add src/app/core/service/evaluate/evaluate-form-review.service.ts src/app/feature/research-and-evaluation/evaluate-follow/presenter/evaluate-form-review-status src/app/feature/research-and-evaluation/research-and-evaluation.module.ts src/app/feature/research-and-evaluation/evaluate-follow/send/evaluate-follow-send.component.ts src/app/feature/research-and-evaluation/evaluate-follow/send/evaluate-follow-send.component.html
|
|
git commit -m "feat(evaluate-follow): replace ส่งกลับแก้ไข with ส่งแก้ไข/ตรวจสอบแล้ว review-status card"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 15: Status buckets on evaluate-follow-index
|
|
|
|
**Files:**
|
|
- Modify: `src/app/feature/research-and-evaluation/evaluate-follow/index/evaluate-follow-index.component.ts`
|
|
- Modify: `src/app/feature/research-and-evaluation/evaluate-follow/index/evaluate-follow-index.component.html`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `EvaluateFormReviewService.getDashboard()` (from Task 14).
|
|
|
|
- [ ] **Step 1: Load the dashboard alongside the existing list**
|
|
|
|
Edit `evaluate-follow-index.component.ts`, add the service injection and a field:
|
|
|
|
```typescript
|
|
import {EvaluateFormReviewService} from '../../../../core/service/evaluate/evaluate-form-review.service';
|
|
```
|
|
|
|
```typescript
|
|
reviewDashboard: any = {pending: [], returned: [], reviewed: []};
|
|
```
|
|
|
|
Add `private evaluateFormReviewService: EvaluateFormReviewService,` to the constructor, and load it in `ngOnInit`:
|
|
|
|
```typescript
|
|
async ngOnInit() {
|
|
this.select.budgetYear = await this.budgetYearService.getAll().toPromise();
|
|
await this.getData();
|
|
this.reviewDashboard = await this.evaluateFormReviewService.getDashboard().toPromise();
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Add a status column to the table**
|
|
|
|
Edit `evaluate-follow-index.component.ts`'s `getData()` to attach each row's status from the dashboard (matched by `evaluate_form_uid`):
|
|
|
|
```typescript
|
|
async getData($event?) {
|
|
const url = `agency_evaluate/final_active`;
|
|
const dataSource = await this.evaluateFormService.query<IListResponse<any>>(this.setUrl(url, $event)).toPromise();
|
|
if (!this._markedSeen) {
|
|
this.menuBadgeService.markSeen('evaluate-follow', dataSource?.row_count ?? 0);
|
|
this._markedSeen = true;
|
|
}
|
|
this.dataSource = this.setDataSource<any>(dataSource);
|
|
const reviewDashboard: any = await this.evaluateFormReviewService.getDashboard().toPromise();
|
|
const statusByUid: {[key: string]: string} = {};
|
|
(reviewDashboard.pending || []).forEach((r: any) => statusByUid[r.evaluate_form_uid] = 'pending');
|
|
(reviewDashboard.returned || []).forEach((r: any) => statusByUid[r.evaluate_form_uid] = 'returned');
|
|
(reviewDashboard.reviewed || []).forEach((r: any) => statusByUid[r.evaluate_form_uid] = 'reviewed');
|
|
this.dataSource.map((item: IEvaluateFormResult) => {
|
|
item.budget_year_name_th = this.select.budgetYear.find((f) => f.budget_year_uid === item.budget_year_uid).budget_year_name_th;
|
|
(item as any).review_status = statusByUid[(item as any).evaluate_form_uid] || 'not_ready';
|
|
});
|
|
}
|
|
```
|
|
|
|
(This replaces the separate `this.reviewDashboard = ...` load added in Step 1 — keep only this version in `getData()`; drop the duplicate call from `ngOnInit`.)
|
|
|
|
- [ ] **Step 3: Show the status in the table**
|
|
|
|
Edit `evaluate-follow-index.component.ts`, add `'review_status'` to `displayedColumns`:
|
|
|
|
```typescript
|
|
displayedColumns: string[] = ['budget_year_uid', 'rounds', 'name', 'review_status', 'created_datetime', 'action'];
|
|
```
|
|
|
|
Edit `evaluate-follow-index.component.html`, add a new `matColumnDef` block (matching the existing pattern used by `'rounds'`/`'name'`) right before the `'created_datetime'` column:
|
|
|
|
```html
|
|
<ng-container matColumnDef="review_status">
|
|
<th *matHeaderCellDef class="tac" mat-header-cell width="140">สถานะตรวจสอบ</th>
|
|
<td *matCellDef="let item" class="tac" mat-cell>
|
|
<span [ngSwitch]="item.review_status">
|
|
<span *ngSwitchCase="'pending'">รอตรวจสอบ</span>
|
|
<span *ngSwitchCase="'returned'">ถูกส่งแก้ไข</span>
|
|
<span *ngSwitchCase="'reviewed'">ตรวจสอบแล้ว</span>
|
|
<span *ngSwitchDefault>ยังไม่ผ่านการอนุมัติ</span>
|
|
</span>
|
|
</td>
|
|
</ng-container>
|
|
```
|
|
|
|
- [ ] **Step 4: Build**
|
|
|
|
```bash
|
|
cd /Users/nut.looknut/Project/rmutr/rmutr-web
|
|
ng build
|
|
```
|
|
|
|
Expected: compiles with no new errors.
|
|
|
|
- [ ] **Step 5: Manual browser walkthrough**
|
|
|
|
Navigate to `/app/research-and-evaluation/evaluate-follow`, confirm the list now shows a status column, and that it matches each row's actual status (cross-check one row against its detail page from Task 14).
|
|
|
|
- [ ] **Step 6: Production build (final verification for the whole frontend slice)**
|
|
|
|
```bash
|
|
cd /Users/nut.looknut/Project/rmutr/rmutr-web
|
|
npm run build
|
|
```
|
|
|
|
Expected: `Application bundle generation complete`, no errors (pre-existing bundle-size/CommonJS warnings are fine, per this session's earlier baseline build).
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
cd /Users/nut.looknut/Project/rmutr/rmutr-web
|
|
git add src/app/feature/research-and-evaluation/evaluate-follow/index/evaluate-follow-index.component.ts src/app/feature/research-and-evaluation/evaluate-follow/index/evaluate-follow-index.component.html
|
|
git commit -m "feat(evaluate-follow): show review status bucket on the list page"
|
|
```
|
|
|
|
---
|
|
|
|
## Post-implementation
|
|
|
|
Push both branches, open PRs (or merge directly per this project's usual flow — check with the user), and run the full end-to-end happy path one more time through the real UI (not curl) before considering this feature done: กำหนดผู้อนุมัติ → ส่งอนุมัติ → อนุมัติ (ลำดับ 1) → อนุมัติ (ลำดับ 2) → record appears on evaluate-follow → ส่งแก้ไข → ผู้อนุมัติลำดับสุดท้ายเห็นคิวเปิดใหม่ → อนุมัติอีกครั้ง → ตรวจสอบแล้ว.
|