Compare commits
15 Commits
2fbb0b3d51
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 75e1f3706e | |||
| d1297a134c | |||
| 22c3c84f55 | |||
| da58e1aac8 | |||
| b46096473b | |||
| a5de73a39c | |||
| b4a4994694 | |||
| f536274376 | |||
| 8e7a0cba4c | |||
| d24f73d119 | |||
| 2a581b1731 | |||
| d627a121d8 | |||
| 5bc4f8333f | |||
| 8034afed6a | |||
| 976710f2da |
@@ -0,0 +1,978 @@
|
||||
# Visual Reskin 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:** Reskin the app's shared visual layer (font, table/card colors, primary buttons, sidebar/toolbar, status badges, tool icons) to match the provided reference design, landing on the flagship page `request-qualification-adjustments` first.
|
||||
|
||||
**Architecture:** This app already has centralized theming: a large `!important`-based CSS block in `src/styles.scss` (~line 1360–1520) themes nearly every Material table/card app-wide, and `src/app/layout/components/**` is the single shared sidebar/toolbar used everywhere. Editing these few central files cascades the new look across most of the app without touching each menu individually. Only the flagship page has page-specific old-style markup (a status dot-icon and plain colored tool icons) that needs direct edits.
|
||||
|
||||
**Tech Stack:** Angular 17, SCSS, Angular Material, `@fontsource` (self-hosted Google Fonts npm packages).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Every color/class change in `src/styles.scss` or the layout components is a GLOBAL change — verify it doesn't break contrast/readability anywhere it cascades to (verified in Task 6 by spot-checking a second, unrelated menu).
|
||||
- No unit-test culture exists for these files (SCSS/HTML, no Karma specs) — verification is `ng build --configuration=production` succeeding plus live visual check at `http://localhost:4200` (user already has a dev server running there with `ng serve --hmr`, so SCSS edits hot-reload automatically — no rebuild/restart needed to see them).
|
||||
- Colors decided in the design spec (`docs/superpowers/specs/2026-07-06-visual-reskin-design.md`), adjustable live during Task 6 if the user wants a tweak after seeing it rendered:
|
||||
- Table/card header background: `#3d8b7a`, text `#ffffff`
|
||||
- Primary brand color (buttons + sidebar/toolbar, replaces `#bd413a`/`#5f9adc`): `#d9652d`
|
||||
- Font: IBM Plex Sans Thai (weights 100, 200, 400, 500, 700 — same set Sarabun used), self-hosted via `@fontsource/ibm-plex-sans-thai`, replacing Sarabun everywhere.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Embed IBM Plex Sans Thai font
|
||||
|
||||
**Files:**
|
||||
- Modify: `rmutr-web/package.json` (add dependency)
|
||||
- Modify: `rmutr-web/angular.json:41-45`
|
||||
- Modify: `rmutr-web/src/index.html:10`
|
||||
- Modify: `rmutr-web/src/styles.scss:133-139`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing from other tasks
|
||||
- Produces: global font `'IBM Plex Sans Thai'` applied via the same universal selector Sarabun used — later tasks don't depend on this directly, but it's part of the same visual pass.
|
||||
|
||||
- [ ] **Step 1: Install the font package**
|
||||
|
||||
Run from `/Users/nut.looknut/Project/rmutr/rmutr-web`:
|
||||
```bash
|
||||
npm install @fontsource/ibm-plex-sans-thai
|
||||
```
|
||||
Expected: `package.json` gains a new dependency entry `"@fontsource/ibm-plex-sans-thai": "^5.x.x"` (exact version npm resolves to).
|
||||
|
||||
- [ ] **Step 2: Register the weight-specific CSS files in Angular's build**
|
||||
|
||||
Find, in `angular.json` (inside `projects.rmutr-web.architect.build.options`):
|
||||
```json
|
||||
"styles": [
|
||||
"./node_modules/@angular/material/prebuilt-themes/indigo-pink.css",
|
||||
"src/styles.scss",
|
||||
"./node_modules/@syncfusion/ej2-material-theme/styles/material.css"
|
||||
],
|
||||
```
|
||||
Change to:
|
||||
```json
|
||||
"styles": [
|
||||
"./node_modules/@angular/material/prebuilt-themes/indigo-pink.css",
|
||||
"./node_modules/@fontsource/ibm-plex-sans-thai/100.css",
|
||||
"./node_modules/@fontsource/ibm-plex-sans-thai/200.css",
|
||||
"./node_modules/@fontsource/ibm-plex-sans-thai/400.css",
|
||||
"./node_modules/@fontsource/ibm-plex-sans-thai/500.css",
|
||||
"./node_modules/@fontsource/ibm-plex-sans-thai/700.css",
|
||||
"src/styles.scss",
|
||||
"./node_modules/@syncfusion/ej2-material-theme/styles/material.css"
|
||||
],
|
||||
```
|
||||
(Each of these `NNN.css` files ships full-Unicode-range `@font-face` rules — covering Thai and Latin glyphs in one font — for that one weight, and Angular CLI bundles the referenced `.woff2` files as build assets automatically.)
|
||||
|
||||
- [ ] **Step 3: Remove the external Google Fonts link for Sarabun**
|
||||
|
||||
Find, in `src/index.html`:
|
||||
```html
|
||||
<link href="https://fonts.googleapis.com/css2?family=Sarabun:wght@100;200;400;500;700&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||
```
|
||||
Change to (remove only the Sarabun line, keep Material Icons — that's a separate icon font, unrelated to this task):
|
||||
```html
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Switch the global font-family**
|
||||
|
||||
Find, in `src/styles.scss`:
|
||||
```scss
|
||||
*:not(
|
||||
.material-icons,
|
||||
.e-btn-icon,
|
||||
.e-icons
|
||||
) {
|
||||
font-family: 'Sarabun', sans-serif !important;
|
||||
}
|
||||
```
|
||||
Change to:
|
||||
```scss
|
||||
*:not(
|
||||
.material-icons,
|
||||
.e-btn-icon,
|
||||
.e-icons
|
||||
) {
|
||||
font-family: 'IBM Plex Sans Thai', sans-serif !important;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Build to confirm no errors**
|
||||
|
||||
Run: `cd /Users/nut.looknut/Project/rmutr/rmutr-web && ng build --configuration=production`
|
||||
Expected: build succeeds with no new errors (font files resolve as build assets).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add package.json package-lock.json angular.json src/index.html src/styles.scss
|
||||
git commit -m "feat: embed IBM Plex Sans Thai font, replacing Sarabun CDN link"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Global color tokens — table/card headers + primary button
|
||||
|
||||
**Files:**
|
||||
- Modify: `rmutr-web/src/styles.scss:438-442` (`.bg-bpi-primary-color`)
|
||||
- Modify: `rmutr-web/src/styles.scss:1379-1384` (card title bar)
|
||||
- Modify: `rmutr-web/src/styles.scss:1414-1417` (`tr.mat-header-row`)
|
||||
- Modify: `rmutr-web/src/styles.scss:1419-1429` (`th.mat-header-cell`)
|
||||
- Modify: `rmutr-web/src/styles.scss:1484-1498` (`.tables th`)
|
||||
- Modify: `rmutr-web/src/styles.scss:1530-1540` (`.table-excel th`, same pattern as `.tables`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing from other tasks
|
||||
- Produces: nothing later tasks call directly — this is a pure visual/color change other tasks don't depend on programmatically.
|
||||
|
||||
- [ ] **Step 1: Change the primary action button color**
|
||||
|
||||
Find, in `src/styles.scss`:
|
||||
```scss
|
||||
.bg-bpi-primary-color {
|
||||
background-color: #5f9adc !important;
|
||||
color: #ffffff !important;
|
||||
border: 1px solid #4a87cb !important;
|
||||
}
|
||||
```
|
||||
Change to:
|
||||
```scss
|
||||
.bg-bpi-primary-color {
|
||||
background-color: #d9652d !important;
|
||||
color: #ffffff !important;
|
||||
border: 1px solid #c2551f !important;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Change the card title bar gradient**
|
||||
|
||||
Find, in `src/styles.scss`:
|
||||
```scss
|
||||
// ----- Card title bar (.mat-header-cell as wrapper div) -----
|
||||
.rmutr_card > .mat-header-cell,
|
||||
.rmutr_card .mat-header-cell:first-child {
|
||||
background: linear-gradient(135deg, #639bd2 0%, #e6e6df 100%) !important;
|
||||
padding: 10px 16px !important;
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
.rmutr_title {
|
||||
color: #374151 !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 600 !important;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
```
|
||||
Change to:
|
||||
```scss
|
||||
// ----- Card title bar (.mat-header-cell as wrapper div) -----
|
||||
.rmutr_card > .mat-header-cell,
|
||||
.rmutr_card .mat-header-cell:first-child {
|
||||
background: #3d8b7a !important;
|
||||
padding: 10px 16px !important;
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
.rmutr_title {
|
||||
color: #ffffff !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 600 !important;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Change the Material table header background**
|
||||
|
||||
Find, in `src/styles.scss`:
|
||||
```scss
|
||||
// ----- Angular Material table header -----
|
||||
tr.mat-header-row {
|
||||
height: 40px !important;
|
||||
background: #e6e6df !important;
|
||||
}
|
||||
|
||||
th.mat-header-cell {
|
||||
color: #374151 !important;
|
||||
background: transparent !important;
|
||||
font-size: 11.5px !important;
|
||||
font-weight: 600 !important;
|
||||
letter-spacing: 0.3px !important;
|
||||
white-space: nowrap;
|
||||
border-color: #e5e7eb !important;
|
||||
text-transform: uppercase;
|
||||
padding: 0 10px !important;
|
||||
}
|
||||
```
|
||||
Change to:
|
||||
```scss
|
||||
// ----- Angular Material table header -----
|
||||
tr.mat-header-row {
|
||||
height: 40px !important;
|
||||
background: #3d8b7a !important;
|
||||
}
|
||||
|
||||
th.mat-header-cell {
|
||||
color: #ffffff !important;
|
||||
background: transparent !important;
|
||||
font-size: 11.5px !important;
|
||||
font-weight: 600 !important;
|
||||
letter-spacing: 0.3px !important;
|
||||
white-space: nowrap;
|
||||
border-color: #2f6d5f !important;
|
||||
text-transform: uppercase;
|
||||
padding: 0 10px !important;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Change the plain `.tables` header background**
|
||||
|
||||
Find, in `src/styles.scss` (inside the `.tables { ... }` block):
|
||||
```scss
|
||||
th:not(.th_class) {
|
||||
background: #e6e6df !important;
|
||||
color: #374151 !important;
|
||||
border-bottom: 1px solid #d1d5db !important;
|
||||
border-right: 1px solid #e5e7eb !important;
|
||||
font-size: 11.5px !important;
|
||||
font-weight: 600 !important;
|
||||
letter-spacing: 0.3px !important;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
padding: 7px 10px !important;
|
||||
|
||||
&:first-child { border-radius: 10px 0 0 0 !important; }
|
||||
&:last-child { border-radius: 0 10px 0 0 !important; border-right: none !important; }
|
||||
}
|
||||
```
|
||||
Change the `background`/`color`/`border-bottom` lines to:
|
||||
```scss
|
||||
th:not(.th_class) {
|
||||
background: #3d8b7a !important;
|
||||
color: #ffffff !important;
|
||||
border-bottom: 1px solid #2f6d5f !important;
|
||||
border-right: 1px solid #2f6d5f !important;
|
||||
font-size: 11.5px !important;
|
||||
font-weight: 600 !important;
|
||||
letter-spacing: 0.3px !important;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
padding: 7px 10px !important;
|
||||
|
||||
&:first-child { border-radius: 10px 0 0 0 !important; }
|
||||
&:last-child { border-radius: 0 10px 0 0 !important; border-right: none !important; }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Apply the identical change to `.table-excel`**
|
||||
|
||||
Find, in `src/styles.scss` (inside the `.table-excel { ... }` block, directly below `.tables`):
|
||||
```scss
|
||||
th:not(.th_class) {
|
||||
background: #e6e6df !important;
|
||||
color: #374151 !important;
|
||||
border-bottom: 1px solid #d1d5db !important;
|
||||
border-right: 1px solid #e5e7eb !important;
|
||||
font-size: 11.5px !important;
|
||||
font-weight: 600 !important;
|
||||
letter-spacing: 0.3px !important;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
padding: 7px 10px !important;
|
||||
|
||||
&:first-child { border-radius: 10px 0 0 0 !important; }
|
||||
&:last-child { border-radius: 0 10px 0 0 !important; border-right: none !important; }
|
||||
}
|
||||
```
|
||||
Change to:
|
||||
```scss
|
||||
th:not(.th_class) {
|
||||
background: #3d8b7a !important;
|
||||
color: #ffffff !important;
|
||||
border-bottom: 1px solid #2f6d5f !important;
|
||||
border-right: 1px solid #2f6d5f !important;
|
||||
font-size: 11.5px !important;
|
||||
font-weight: 600 !important;
|
||||
letter-spacing: 0.3px !important;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
padding: 7px 10px !important;
|
||||
|
||||
&:first-child { border-radius: 10px 0 0 0 !important; }
|
||||
&:last-child { border-radius: 0 10px 0 0 !important; border-right: none !important; }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Build to confirm no errors**
|
||||
|
||||
Run: `cd /Users/nut.looknut/Project/rmutr/rmutr-web && ng build --configuration=production`
|
||||
Expected: build succeeds with no new errors.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/styles.scss
|
||||
git commit -m "feat: recolor global table headers to teal and primary button to orange"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Global badge + icon-btn utility classes
|
||||
|
||||
**Files:**
|
||||
- Modify: `rmutr-web/src/styles.scss` (add new global classes)
|
||||
- Modify: `rmutr-web/src/app/feature/budget-request/request/request-budget-statistics/presenter/list/request-budget-statistics-list/list-between-year/list-between-year.component.scss:183-215`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing from other tasks
|
||||
- Produces: global classes `.badge`, `.badge-success`, `.badge-warning`, `.badge-pending`, `.badge-draft`, `.icon-btn`, `.icon-btn--view`, `.icon-btn--edit`, `.icon-btn--delete` — Task 5 uses these exact class names on the flagship page.
|
||||
|
||||
- [ ] **Step 1: Add the global badge classes to styles.scss**
|
||||
|
||||
Add this new block at the end of `src/styles.scss` (append, don't replace anything):
|
||||
```scss
|
||||
|
||||
// ----------------------------------------
|
||||
// Shared status badges (pill-shaped)
|
||||
// ----------------------------------------
|
||||
.badge {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 20px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.badge-pending {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.badge-draft {
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
// ----------------------------------------
|
||||
// Shared table tool icon buttons (soft circular background)
|
||||
// ----------------------------------------
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
margin: 0 2px;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.material-icons {
|
||||
font-size: 16px !important;
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-btn--view {
|
||||
background: #dbeafe;
|
||||
color: #007aff;
|
||||
}
|
||||
|
||||
.icon-btn--edit {
|
||||
background: #fef3c7;
|
||||
color: #f8a300;
|
||||
}
|
||||
|
||||
.icon-btn--delete {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Remove the now-duplicated badge definitions from list-between-year.component.scss**
|
||||
|
||||
Find, in `list-between-year.component.scss`:
|
||||
```scss
|
||||
.badge {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 20px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-between {
|
||||
background: #ede9fe;
|
||||
color: #5b21b6;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.badge-pending {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.badge-draft {
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
}
|
||||
```
|
||||
Change to (keep only `.badge-between`, which is specific to this component's "request type" pill and has no global equivalent — `.badge` and the 4 status-color variants now come from the global stylesheet added in Step 1):
|
||||
```scss
|
||||
.badge-between {
|
||||
background: #ede9fe;
|
||||
color: #5b21b6;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build to confirm no errors and no visual regression on the between-year list**
|
||||
|
||||
Run: `cd /Users/nut.looknut/Project/rmutr/rmutr-web && ng build --configuration=production`
|
||||
Expected: build succeeds with no new errors. The between-year list's badges (`ร่าง`/`ส่งงานแผน`/`ส่งแก้ไข`/`ตรวจสอบแล้ว`) must still render identically since the global classes are byte-identical to what was removed.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/styles.scss "src/app/feature/budget-request/request/request-budget-statistics/presenter/list/request-budget-statistics-list/list-between-year/list-between-year.component.scss"
|
||||
git commit -m "feat: promote badge pill styles to global, add shared icon-btn utility"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Sidebar/toolbar color swap
|
||||
|
||||
**Files:**
|
||||
- Modify: `rmutr-web/src/app/layout/components/menu-bar/menu-bar.component.scss`
|
||||
- Modify: `rmutr-web/src/app/layout/components/menu/collapsable/collapsable.component.scss`
|
||||
- Modify: `rmutr-web/src/app/layout/components/menu/basic-menu/basic-menu.component.scss`
|
||||
- Modify: `rmutr-web/src/app/layout/components/tool-bar/tool-bar.component.scss`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing from other tasks
|
||||
- Produces: nothing later tasks depend on — sidebar/toolbar are always-rendered shell components, no wiring needed.
|
||||
|
||||
- [ ] **Step 1: Recolor `menu-bar.component.scss`**
|
||||
|
||||
Find:
|
||||
```scss
|
||||
.sidebar-icon-wrap {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 11px;
|
||||
background: #bd413a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2px 8px rgba(189, 65, 58, 0.3);
|
||||
}
|
||||
```
|
||||
Change to:
|
||||
```scss
|
||||
.sidebar-icon-wrap {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 11px;
|
||||
background: #d9652d;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2px 8px rgba(217, 101, 45, 0.3);
|
||||
}
|
||||
```
|
||||
Find:
|
||||
```scss
|
||||
.sidebar-subtitle {
|
||||
font-size: 11px;
|
||||
color: #bd413a;
|
||||
line-height: 1.5;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
```
|
||||
Change to:
|
||||
```scss
|
||||
.sidebar-subtitle {
|
||||
font-size: 11px;
|
||||
color: #d9652d;
|
||||
line-height: 1.5;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Recolor `collapsable.component.scss`**
|
||||
|
||||
Find:
|
||||
```scss
|
||||
.bgMenu {
|
||||
border-radius: 8px;
|
||||
transition: background-color 0.15s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: #fef2f2;
|
||||
|
||||
.se_item {
|
||||
color: #bd413a;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
color: #bd413a;
|
||||
}
|
||||
|
||||
.se_icon {
|
||||
color: #bd413a !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bgActive {
|
||||
background-color: #fef2f2;
|
||||
border-left: 3px solid #bd413a;
|
||||
border-radius: 8px;
|
||||
|
||||
.se_item {
|
||||
color: #bd413a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
color: #bd413a;
|
||||
}
|
||||
|
||||
.se_icon {
|
||||
color: #bd413a !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
Change to:
|
||||
```scss
|
||||
.bgMenu {
|
||||
border-radius: 8px;
|
||||
transition: background-color 0.15s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: #fdf0e8;
|
||||
|
||||
.se_item {
|
||||
color: #d9652d;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
color: #d9652d;
|
||||
}
|
||||
|
||||
.se_icon {
|
||||
color: #d9652d !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bgActive {
|
||||
background-color: #fdf0e8;
|
||||
border-left: 3px solid #d9652d;
|
||||
border-radius: 8px;
|
||||
|
||||
.se_item {
|
||||
color: #d9652d;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
color: #d9652d;
|
||||
}
|
||||
|
||||
.se_icon {
|
||||
color: #d9652d !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
Find, in the `.se_item_children` block near the end of the same file:
|
||||
```scss
|
||||
::ng-deep .se_item_active {
|
||||
border-left: 2px solid #bd413a !important;
|
||||
border-radius: 6px !important;
|
||||
|
||||
.se_item {
|
||||
color: #bd413a !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
color: #bd413a !important;
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep .bgMenu:hover {
|
||||
.se_item { color: #bd413a !important; }
|
||||
.menu-icon { color: #bd413a !important; }
|
||||
}
|
||||
```
|
||||
Change to:
|
||||
```scss
|
||||
::ng-deep .se_item_active {
|
||||
border-left: 2px solid #d9652d !important;
|
||||
border-radius: 6px !important;
|
||||
|
||||
.se_item {
|
||||
color: #d9652d !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
color: #d9652d !important;
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep .bgMenu:hover {
|
||||
.se_item { color: #d9652d !important; }
|
||||
.menu-icon { color: #d9652d !important; }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Recolor `basic-menu.component.scss`**
|
||||
|
||||
Find:
|
||||
```scss
|
||||
.se_item_warpper {
|
||||
margin: 1px 10px;
|
||||
border-radius: 8px;
|
||||
transition: background-color 0.15s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: #fef2f2;
|
||||
|
||||
.se_item {
|
||||
color: #bd413a !important;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
color: #bd413a !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
Change to:
|
||||
```scss
|
||||
.se_item_warpper {
|
||||
margin: 1px 10px;
|
||||
border-radius: 8px;
|
||||
transition: background-color 0.15s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: #fdf0e8;
|
||||
|
||||
.se_item {
|
||||
color: #d9652d !important;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
color: #d9652d !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
Find:
|
||||
```scss
|
||||
.menu-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
background: #bd413a;
|
||||
color: #ffffff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 0 6px;
|
||||
line-height: 1;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.se_item_active {
|
||||
background-color: #fef2f2 !important;
|
||||
border-left: 3px solid #bd413a;
|
||||
border-radius: 8px;
|
||||
|
||||
.se_item {
|
||||
color: #bd413a !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
color: #bd413a !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
Change to:
|
||||
```scss
|
||||
.menu-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
background: #d9652d;
|
||||
color: #ffffff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 0 6px;
|
||||
line-height: 1;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.se_item_active {
|
||||
background-color: #fdf0e8 !important;
|
||||
border-left: 3px solid #d9652d;
|
||||
border-radius: 8px;
|
||||
|
||||
.se_item {
|
||||
color: #d9652d !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
color: #d9652d !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Recolor `tool-bar.component.scss`**
|
||||
|
||||
Find:
|
||||
```scss
|
||||
.user-avatar-circle {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
background: #bd413a;
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
```
|
||||
Change to:
|
||||
```scss
|
||||
.user-avatar-circle {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
background: #d9652d;
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
```
|
||||
Find:
|
||||
```scss
|
||||
.logout-btn {
|
||||
color: #bd413a;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: #fef2f2;
|
||||
}
|
||||
}
|
||||
```
|
||||
Change to:
|
||||
```scss
|
||||
.logout-btn {
|
||||
color: #d9652d;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: #fdf0e8;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Build to confirm no errors**
|
||||
|
||||
Run: `cd /Users/nut.looknut/Project/rmutr/rmutr-web && ng build --configuration=production`
|
||||
Expected: build succeeds with no new errors.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/layout/components/menu-bar/menu-bar.component.scss src/app/layout/components/menu/collapsable/collapsable.component.scss src/app/layout/components/menu/basic-menu/basic-menu.component.scss src/app/layout/components/tool-bar/tool-bar.component.scss
|
||||
git commit -m "feat: recolor sidebar and toolbar brand color from red to orange"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Apply badge + icon-btn to the flagship page
|
||||
|
||||
**Files:**
|
||||
- Modify: `rmutr-web/src/app/feature/budget-request/request-qualification-adjustments/presenter/list/request-qualification-adjustments-list/request-qualification-adjustments-list.component.html`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `.badge`/`.badge-success` and `.icon-btn`/`.icon-btn--view`/`.icon-btn--edit`/`.icon-btn--delete` from Task 3
|
||||
- Produces: nothing later tasks depend on — this is the last page-specific edit in this plan.
|
||||
|
||||
- [ ] **Step 1: Replace the status dot-icon with a badge pill**
|
||||
|
||||
Find, in `request-qualification-adjustments-list.component.html`:
|
||||
```html
|
||||
<ng-container matColumnDef="status_id">
|
||||
<th mat-header-cell *matHeaderCellDef > สถานะ </th>
|
||||
<td mat-cell *matCellDef="let x" style="text-align: center;">
|
||||
<span class="material-icons" [ngStyle]="{ 'color' : (x.status_id == 1) ? '#06B958' : 'rgba(0, 0, 0, 0.54)' }">
|
||||
fiber_manual_record
|
||||
</span>
|
||||
|
||||
</td>
|
||||
</ng-container>
|
||||
```
|
||||
Change to:
|
||||
```html
|
||||
<ng-container matColumnDef="status_id">
|
||||
<th mat-header-cell *matHeaderCellDef > สถานะ </th>
|
||||
<td mat-cell *matCellDef="let x" style="text-align: center;">
|
||||
<span class="badge" [ngClass]="(x.status_id == 1) ? 'badge-success' : 'badge-draft'">
|
||||
{{ (x.status_id == 1) ? 'ปกติ' : 'ไม่ใช้งาน' }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace the plain tool icons with icon-btn circles**
|
||||
|
||||
Find, in the same file:
|
||||
```html
|
||||
<ng-container matColumnDef="action">
|
||||
<th mat-header-cell *matHeaderCellDef > เครื่องมือ </th>
|
||||
<td mat-cell *matCellDef="let x" style="text-align: center;">
|
||||
<span class="material-icons" style="cursor: pointer;color: #007AFF;" (click)="word(x.budget_income_qualification_uid,'pdf')">description</span>
|
||||
|
||||
<span class="material-icons" style="cursor: pointer;color: #F8A300;" (click)="edit(x.budget_income_qualification_uid)">create</span>
|
||||
|
||||
<span class="material-icons" style="cursor: pointer;color: #BD413A;" (click)="onDelete(x)">
|
||||
delete_forever
|
||||
</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
```
|
||||
Change to:
|
||||
```html
|
||||
<ng-container matColumnDef="action">
|
||||
<th mat-header-cell *matHeaderCellDef > เครื่องมือ </th>
|
||||
<td mat-cell *matCellDef="let x" style="text-align: center;">
|
||||
<span class="icon-btn icon-btn--view" (click)="word(x.budget_income_qualification_uid,'pdf')">
|
||||
<span class="material-icons">description</span>
|
||||
</span>
|
||||
<span class="icon-btn icon-btn--edit" (click)="edit(x.budget_income_qualification_uid)">
|
||||
<span class="material-icons">create</span>
|
||||
</span>
|
||||
<span class="icon-btn icon-btn--delete" (click)="onDelete(x)">
|
||||
<span class="material-icons">delete_forever</span>
|
||||
</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build to confirm no errors**
|
||||
|
||||
Run: `cd /Users/nut.looknut/Project/rmutr/rmutr-web && ng build --configuration=production`
|
||||
Expected: build succeeds with no new errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add "src/app/feature/budget-request/request-qualification-adjustments/presenter/list/request-qualification-adjustments-list/request-qualification-adjustments-list.component.html"
|
||||
git commit -m "feat: apply badge pill and icon-btn styles to qualification-adjustments list"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: End-to-end manual verification
|
||||
|
||||
**Files:** none (verification only)
|
||||
|
||||
**Interfaces:** none
|
||||
|
||||
- [ ] **Step 1: Confirm the dev server is live**
|
||||
|
||||
The user already has `ng serve --hmr` running at `http://localhost:4200` — all 5 prior tasks' SCSS/HTML changes hot-reload into that session automatically. No restart needed.
|
||||
|
||||
- [ ] **Step 2: Check the flagship page**
|
||||
|
||||
Open `http://localhost:4200/app/request-qualification-adjustments`.
|
||||
Expected: search card header and table header are teal (`#3d8b7a`) with white text; "ยื่นคำขอ" button is orange (`#d9652d`); status column shows a green "ปกติ" pill (or gray "ไม่ใช้งาน") instead of a dot; the 3 tool icons (view/edit/delete) render as soft-colored circles instead of bare icons; font renders as IBM Plex Sans Thai (check via browser devtools computed `font-family` on any text element — should read `"IBM Plex Sans Thai", sans-serif`).
|
||||
|
||||
- [ ] **Step 3: Check the sidebar and toolbar**
|
||||
|
||||
On any page, confirm: sidebar logo block, active-menu-item highlight/left-border, and the top-right user avatar circle + logout icon are all orange (`#d9652d`) instead of red (`#bd413a`).
|
||||
|
||||
- [ ] **Step 4: Regression check — between-year list badges**
|
||||
|
||||
Open the "คำขอระหว่างปี" menu (built in an earlier feature). Confirm the "ร่าง"/"ส่งงานแผน"/"ส่งแก้ไข"/"ตรวจสอบแล้ว" badges still render with their original colors (gray/blue/amber/green) — this confirms Task 3's dedup didn't break the existing page.
|
||||
|
||||
- [ ] **Step 5: Regression check — one unrelated menu**
|
||||
|
||||
Open any other menu with a data table (e.g. "ต้นฉบับเสนอโครงการ ง.5"). Confirm its table header is now teal (cascaded automatically from Task 2's global change) and nothing else looks visually broken (no missing borders, no unreadable text contrast).
|
||||
|
||||
- [ ] **Step 6: Report back to the user**
|
||||
|
||||
Summarize what was changed and ask if any color/spacing needs adjusting before considering this phase done. Remaining menus' badge/icon-btn adoption (beyond what already cascades from the global table/button/sidebar colors) is explicitly out of scope for this plan — a follow-up phase per the design spec.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,598 @@
|
||||
# Expense-Project-Research Edit-History View 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 "ดูประวัติการแก้ไข" (view edit history) affordance — icon, edit-count badge, expandable session table — to the `expense-project-research` list (`List14ResearchComponent`), showing project detail-field diffs (ชื่อโครงการ, แผนงาน, ผลผลิต, ประเด็นยุทธศาสตร์, ลักษณะโครงการ, หน่วยงานที่รับผิดชอบ, แหล่งที่มาของเงิน) with the pre-edit original always pinned last.
|
||||
|
||||
**Architecture:** No backend changes — `GET /request_budget/change_project_research_detail/{uid}/history` already returns field-level diff rows including a synthetic "ต้นฉบับ" (original) snapshot. Two pure functions (`buildHistorySessions`, `countEditSessions`) that already implement the "group into sessions, original last" logic are extracted out of the large shared `make-year-plant-form.component.ts` into a new util module, then reused by `List14ResearchComponent`, which gets its own history-fetch/expand-row state (independent of the shared form component — different page, different data shape, no P/A metrics table). The visible history table shows a different field set than the existing (typeUrl 12) implementation: one row per edit session with the 7 project-detail columns instead of per-P/A-row numeric columns.
|
||||
|
||||
**Tech Stack:** Angular 17, Angular Material (`mat-icon`, `mat-icon-button`, `mat-spinner`), RxJS (`forkJoin`, `catchError`).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- No `rmutr-api` changes — the history endpoint and diff data already exist and cover every field this plan displays.
|
||||
- This repo has no unit-test culture for these Angular components. Per-task verification uses `ng build --configuration=production` (from `/Users/nut.looknut/Project/rmutr/rmutr-web`) to catch compile/template errors; the final task is a manual browser check via `npm start`.
|
||||
- Extracting `buildHistorySessions`/`countEditSessions` out of `make-year-plant-form.component.ts` must not change behavior for the existing `typeUrl 12` (`change-project-research-form/list-all`) history view — same output for the same input, verified by a regression check in the final task.
|
||||
- `List14ResearchComponent` uses `ChangeDetectionStrategy.OnPush` — every async state update must be followed by `this.cdRef.detectChanges()`, matching the pattern already used in `make-year-plant-form.component.ts`.
|
||||
- New history table shows only these 7 field_keys (plus datetime): `budget_plan_name_th`, `project_name_th`, `budget_project_name_th`, `budget_strategy_name_th`, `budget_topic_name_th`, `responsible_faculty_name_th`, `budget_come_from`. Do not add `a_1`-`a_13`/`p_1`-`p_13` or `budget_location_name_th` — out of scope per the approved design spec.
|
||||
- Failed `getHistory()` calls degrade silently (`catchError(() => of([]))`) — no `Swal` error popups on this read-only report page.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Extract `buildHistorySessions`/`countEditSessions` into a shared util
|
||||
|
||||
**Files:**
|
||||
- Create: `/Users/nut.looknut/Project/rmutr/rmutr-web/src/app/core/utils/change-history-session.util.ts`
|
||||
- Modify: `/Users/nut.looknut/Project/rmutr/rmutr-web/src/app/feature/budget-request/request/request-budget-statistics/presenter/form/make-year-plan-form/make-year-plant-form.component.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing new
|
||||
- Produces: `buildHistorySessions(records: any[]): HistorySession[]` and `countEditSessions(history: any[]): number`, plus the `HistorySession` interface (`{datetime: string, isOriginal: boolean, vals: Record<string,string>}`) — Task 2 imports all three.
|
||||
|
||||
- [ ] **Step 1: Create the util file**
|
||||
|
||||
```ts
|
||||
export interface HistorySession {
|
||||
datetime: string
|
||||
isOriginal: boolean
|
||||
vals: Record<string, string>
|
||||
}
|
||||
|
||||
export function buildHistorySessions(records: any[]): HistorySession[] {
|
||||
const sessionMap = new Map<string, HistorySession>()
|
||||
for (const r of records) {
|
||||
const isOriginal = r.change_remark === 'ต้นฉบับ'
|
||||
const key = isOriginal ? '__original__' : (r.created_datetime ?? '').substring(0, 19)
|
||||
if (!sessionMap.has(key)) {
|
||||
sessionMap.set(key, { datetime: r.created_datetime, isOriginal, vals: {} })
|
||||
}
|
||||
sessionMap.get(key)!.vals[r.field_key] = r.new_value
|
||||
}
|
||||
const entries = Array.from(sessionMap.values())
|
||||
const originals = entries.filter(e => e.isOriginal)
|
||||
const edits = entries.filter(e => !e.isOriginal)
|
||||
return [...edits, ...originals]
|
||||
}
|
||||
|
||||
export function countEditSessions(history: any[]): number {
|
||||
if (!history?.length) return 0
|
||||
const keys = new Set(
|
||||
history
|
||||
.filter(h => h.change_remark !== 'ต้นฉบับ')
|
||||
.map(h => (h.created_datetime ?? '').substring(0, 19))
|
||||
)
|
||||
return keys.size
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the import to `make-year-plant-form.component.ts`**
|
||||
|
||||
Find, at the top of the file:
|
||||
```ts
|
||||
import { ChangeProjectService } from 'src/app/core/service/request-budget/change-project.service';
|
||||
```
|
||||
Change to:
|
||||
```ts
|
||||
import { ChangeProjectService } from 'src/app/core/service/request-budget/change-project.service';
|
||||
import { buildHistorySessions, countEditSessions } from 'src/app/core/utils/change-history-session.util';
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Remove the private `countEditSessions` method**
|
||||
|
||||
Find:
|
||||
```ts
|
||||
private countEditSessions(history: any[]): number {
|
||||
if (!history?.length) return 0
|
||||
const keys = new Set(
|
||||
history
|
||||
.filter(h => h.change_remark !== 'ต้นฉบับ')
|
||||
.map(h => (h.created_datetime ?? '').substring(0, 19))
|
||||
)
|
||||
return keys.size
|
||||
}
|
||||
|
||||
getEditCount(uid: string): number {
|
||||
```
|
||||
Change to:
|
||||
```ts
|
||||
getEditCount(uid: string): number {
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Remove the private `buildHistorySessions` method**
|
||||
|
||||
Find:
|
||||
```ts
|
||||
private buildHistorySessions(records: any[]): Array<{datetime: string, isOriginal: boolean, vals: Record<string, string>}> {
|
||||
const sessionMap = new Map<string, {datetime: string, isOriginal: boolean, vals: Record<string, string>}>()
|
||||
for (const r of records) {
|
||||
const isOriginal = r.change_remark === 'ต้นฉบับ'
|
||||
const key = isOriginal ? '__original__' : (r.created_datetime ?? '').substring(0, 19)
|
||||
if (!sessionMap.has(key)) {
|
||||
sessionMap.set(key, { datetime: r.created_datetime, isOriginal, vals: {} })
|
||||
}
|
||||
sessionMap.get(key)!.vals[r.field_key] = r.new_value
|
||||
}
|
||||
const entries = Array.from(sessionMap.values())
|
||||
const originals = entries.filter(e => e.isOriginal)
|
||||
const edits = entries.filter(e => !e.isOriginal)
|
||||
return [...edits, ...originals]
|
||||
}
|
||||
|
||||
toggleHistory(d: any) {
|
||||
```
|
||||
Change to:
|
||||
```ts
|
||||
toggleHistory(d: any) {
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Repoint every call site to the imported functions**
|
||||
|
||||
Using the Edit tool with `replace_all: true` on `make-year-plant-form.component.ts`:
|
||||
- Replace `this.buildHistorySessions(` with `buildHistorySessions(` (4 occurrences: inside `saveProject()`, `toggleHistory()`, `toggleResearchHistory()`, `saveResearchProject()`)
|
||||
- Replace `this.countEditSessions(` with `countEditSessions(` (4 occurrences: inside the initial project-history batch load, `saveProject()`, the initial research-history batch load, `saveResearchProject()`)
|
||||
|
||||
- [ ] **Step 6: Verify no stray references remain**
|
||||
|
||||
Run: `cd /Users/nut.looknut/Project/rmutr/rmutr-web && grep -n "this.buildHistorySessions\|this.countEditSessions" src/app/feature/budget-request/request/request-budget-statistics/presenter/form/make-year-plan-form/make-year-plant-form.component.ts`
|
||||
Expected: no output (all call sites repointed).
|
||||
|
||||
- [ ] **Step 7: Build to confirm no errors**
|
||||
|
||||
Run: `cd /Users/nut.looknut/Project/rmutr/rmutr-web && ng build --configuration=production`
|
||||
Expected: build succeeds with no new errors.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add rmutr-web/src/app/core/utils/change-history-session.util.ts rmutr-web/src/app/feature/budget-request/request/request-budget-statistics/presenter/form/make-year-plan-form/make-year-plant-form.component.ts
|
||||
git commit -m "refactor: extract buildHistorySessions/countEditSessions into a shared util"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `List14ResearchComponent` — history fetch state
|
||||
|
||||
**Files:**
|
||||
- Modify: `/Users/nut.looknut/Project/rmutr/rmutr-web/src/app/feature/budget-request/request/request-budget-statistics/presenter/list/request-budget-statistics-list/list14-research/list14-research.component.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `buildHistorySessions`, `countEditSessions`, `HistorySession` (Task 1); `ChangeProjectResearchDetailService.getHistory(uid): Observable<any[]>` (existing, `change-project-research-detail.service.ts:14`)
|
||||
- Produces: `getEditCount(uid): number`, `toggleHistory(uid): void`, `isHistoryLoading(uid): boolean`, `getHistorySessions(uid): HistorySession[]`, `expandedHistoryUid: string | null` — Task 3's template binds to all of these.
|
||||
|
||||
- [ ] **Step 1: Replace the component with the history-aware version**
|
||||
|
||||
Find the full current file content:
|
||||
```ts
|
||||
import { Component, OnInit, ChangeDetectionStrategy, EventEmitter, Input, Output, OnChanges, SimpleChanges } from '@angular/core';
|
||||
import { PageEvent } from '@angular/material/paginator';
|
||||
import { BaseList } from 'src/app/core/base/base-list';
|
||||
|
||||
@Component({
|
||||
selector: 'app-list14-research',
|
||||
templateUrl: './list14-research.component.html',
|
||||
styleUrls: ['./list14-research.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class List14ResearchComponent extends BaseList implements OnInit, OnChanges {
|
||||
|
||||
@Input() dataSource: any = [];
|
||||
@Output() onchange = new EventEmitter();
|
||||
@Output() onedit = new EventEmitter();
|
||||
@Output() ondelete = new EventEmitter();
|
||||
@Output() onexcel = new EventEmitter();
|
||||
@Output() onReport = new EventEmitter();
|
||||
data;
|
||||
details: any[] = [];
|
||||
|
||||
constructor() { super(); }
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if ('dataSource' in changes && changes?.dataSource?.currentValue) {
|
||||
this.data = this.dataSource;
|
||||
this.details = this.dataSource ? [...this.dataSource] : [];
|
||||
}
|
||||
}
|
||||
|
||||
ngOnInit(): void {}
|
||||
|
||||
edit(val) { this.onedit.emit(val.change_project_research_uid); }
|
||||
delete(val) { this.ondelete.emit(val); }
|
||||
|
||||
change(event: PageEvent) {
|
||||
let page: number = event.pageIndex + 1;
|
||||
let table: any = { page: page, size: event.pageSize };
|
||||
this.onchange.emit(table);
|
||||
}
|
||||
|
||||
excel(val) { this.onexcel.emit(val); }
|
||||
report(el) { this.onReport.emit({ change_project_research_uid: el }); }
|
||||
}
|
||||
```
|
||||
Change to:
|
||||
```ts
|
||||
import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef, EventEmitter, Input, Output, OnChanges, SimpleChanges } from '@angular/core';
|
||||
import { PageEvent } from '@angular/material/paginator';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { BaseList } from 'src/app/core/base/base-list';
|
||||
import { ChangeProjectResearchDetailService } from 'src/app/core/service/request-budget/change-project-research-detail.service';
|
||||
import { buildHistorySessions, countEditSessions, HistorySession } from 'src/app/core/utils/change-history-session.util';
|
||||
|
||||
@Component({
|
||||
selector: 'app-list14-research',
|
||||
templateUrl: './list14-research.component.html',
|
||||
styleUrls: ['./list14-research.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class List14ResearchComponent extends BaseList implements OnInit, OnChanges {
|
||||
|
||||
@Input() dataSource: any = [];
|
||||
@Output() onchange = new EventEmitter();
|
||||
@Output() onedit = new EventEmitter();
|
||||
@Output() ondelete = new EventEmitter();
|
||||
@Output() onexcel = new EventEmitter();
|
||||
@Output() onReport = new EventEmitter();
|
||||
data;
|
||||
details: any[] = [];
|
||||
|
||||
editCountMap = new Map<string, number>();
|
||||
historySessionsMap = new Map<string, HistorySession[]>();
|
||||
historyLoadingUids = new Set<string>();
|
||||
expandedHistoryUid: string | null = null;
|
||||
|
||||
constructor(
|
||||
private cdRef: ChangeDetectorRef,
|
||||
private ChangeProjectResearchDetailSV: ChangeProjectResearchDetailService
|
||||
) { super(); }
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if ('dataSource' in changes && changes?.dataSource?.currentValue) {
|
||||
this.data = this.dataSource;
|
||||
this.details = this.dataSource ? [...this.dataSource] : [];
|
||||
this.loadHistoryCounts(this.details);
|
||||
}
|
||||
}
|
||||
|
||||
ngOnInit(): void {}
|
||||
|
||||
private loadHistoryCounts(details: any[]): void {
|
||||
const uids = (details || [])
|
||||
.map((d: any) => d.change_project_research_detail_uid)
|
||||
.filter((uid: string) => !!uid);
|
||||
if (uids.length === 0) return;
|
||||
forkJoin(uids.map((uid: string) =>
|
||||
this.ChangeProjectResearchDetailSV.getHistory(uid).pipe(catchError(() => of([] as any[])))
|
||||
)).subscribe((histories: any[][]) => {
|
||||
this.editCountMap = new Map<string, number>();
|
||||
uids.forEach((uid: string, i: number) => {
|
||||
const count = countEditSessions(histories[i] ?? []);
|
||||
if (count > 0) this.editCountMap.set(uid, count);
|
||||
});
|
||||
this.cdRef.detectChanges();
|
||||
});
|
||||
}
|
||||
|
||||
getEditCount(uid: string): number {
|
||||
return this.editCountMap.get(uid) ?? 0;
|
||||
}
|
||||
|
||||
toggleHistory(uid: string): void {
|
||||
if (!uid) return;
|
||||
if (this.expandedHistoryUid === uid) {
|
||||
this.expandedHistoryUid = null;
|
||||
this.cdRef.detectChanges();
|
||||
return;
|
||||
}
|
||||
this.expandedHistoryUid = uid;
|
||||
if (!this.historySessionsMap.has(uid)) {
|
||||
this.historyLoadingUids.add(uid);
|
||||
this.ChangeProjectResearchDetailSV.getHistory(uid).pipe(
|
||||
catchError(() => of([] as any[]))
|
||||
).subscribe((h: any[]) => {
|
||||
this.historySessionsMap.set(uid, buildHistorySessions(h));
|
||||
this.historyLoadingUids.delete(uid);
|
||||
this.cdRef.detectChanges();
|
||||
});
|
||||
}
|
||||
this.cdRef.detectChanges();
|
||||
}
|
||||
|
||||
isHistoryLoading(uid: string): boolean {
|
||||
return this.historyLoadingUids.has(uid);
|
||||
}
|
||||
|
||||
getHistorySessions(uid: string): HistorySession[] {
|
||||
return this.historySessionsMap.get(uid) ?? [];
|
||||
}
|
||||
|
||||
edit(val) { this.onedit.emit(val.change_project_research_uid); }
|
||||
delete(val) { this.ondelete.emit(val); }
|
||||
|
||||
change(event: PageEvent) {
|
||||
let page: number = event.pageIndex + 1;
|
||||
let table: any = { page: page, size: event.pageSize };
|
||||
this.onchange.emit(table);
|
||||
}
|
||||
|
||||
excel(val) { this.onexcel.emit(val); }
|
||||
report(el) { this.onReport.emit({ change_project_research_uid: el }); }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to confirm no errors**
|
||||
|
||||
Run: `cd /Users/nut.looknut/Project/rmutr/rmutr-web && ng build --configuration=production`
|
||||
Expected: build succeeds with no new errors.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add rmutr-web/src/app/feature/budget-request/request/request-budget-statistics/presenter/list/request-budget-statistics-list/list14-research/list14-research.component.ts
|
||||
git commit -m "feat: fetch and track edit-history counts/sessions in List14ResearchComponent"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Template — history icon/badge + expandable session table
|
||||
|
||||
**Files:**
|
||||
- Modify: `/Users/nut.looknut/Project/rmutr/rmutr-web/src/app/feature/budget-request/request/request-budget-statistics/presenter/list/request-budget-statistics-list/list14-research/list14-research.component.html`
|
||||
- Modify: `/Users/nut.looknut/Project/rmutr/rmutr-web/src/app/feature/budget-request/request/request-budget-statistics/presenter/list/request-budget-statistics-list/list14-research/list14-research.component.scss`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `getEditCount(uid)`, `toggleHistory(uid)`, `isHistoryLoading(uid)`, `getHistorySessions(uid)`, `expandedHistoryUid` (Task 2)
|
||||
|
||||
- [ ] **Step 1: Add the history icon + badge to the "ลำดับ" cell**
|
||||
|
||||
Find:
|
||||
```html
|
||||
<ng-container *ngFor="let pItem of (detail.change_project_research_p_details || []); let pIdx = index">
|
||||
<tr class="tr_p">
|
||||
<ng-container *ngIf="pIdx === 0">
|
||||
<td class="td_class text-center" [attr.rowspan]="(detail.change_project_research_p_details?.length || 1) + 1">{{i+1}}</td>
|
||||
<td class="td_class text-center" [attr.rowspan]="(detail.change_project_research_p_details?.length || 1) + 1">{{detail.budget_year_name_th}}</td>
|
||||
```
|
||||
Change to:
|
||||
```html
|
||||
<ng-container *ngFor="let pItem of (detail.change_project_research_p_details || []); let pIdx = index">
|
||||
<tr class="tr_p">
|
||||
<ng-container *ngIf="pIdx === 0">
|
||||
<td class="td_class text-center" [attr.rowspan]="(detail.change_project_research_p_details?.length || 1) + 1">
|
||||
<div class="seq-cell">
|
||||
<span class="seq-number">{{i+1}}</span>
|
||||
<button *ngIf="getEditCount(detail.change_project_research_detail_uid) > 0"
|
||||
mat-icon-button (click)="toggleHistory(detail.change_project_research_detail_uid)"
|
||||
class="seq-btn seq-btn--history"
|
||||
[style.color]="expandedHistoryUid === detail.change_project_research_detail_uid ? '#1565c0' : null"
|
||||
title="ดูประวัติการแก้ไข">
|
||||
<mat-icon>{{ expandedHistoryUid === detail.change_project_research_detail_uid ? 'expand_less' : 'history' }}</mat-icon>
|
||||
</button>
|
||||
<span *ngIf="getEditCount(detail.change_project_research_detail_uid) > 0"
|
||||
class="edit-count-pill edit-count-pill--active">
|
||||
เปลี่ยนแปลง #{{getEditCount(detail.change_project_research_detail_uid)}}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="td_class text-center" [attr.rowspan]="(detail.change_project_research_p_details?.length || 1) + 1">{{detail.budget_year_name_th}}</td>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the expand row after the "A" row**
|
||||
|
||||
Find:
|
||||
```html
|
||||
<tr class="tr_a">
|
||||
<td class="td_class text-center"><span class="badge-a">A</span></td>
|
||||
<td class="td_class text-right">{{detail.a_1}}</td>
|
||||
<td class="td_class text-right">{{detail.a_2}}</td>
|
||||
<td class="td_class text-right">{{detail.a_3}}</td>
|
||||
<td class="td_class text-right">{{detail.a_4}}</td>
|
||||
<td class="td_class text-right">{{detail.a_5}}</td>
|
||||
<td class="td_class text-right">{{detail.a_6}}</td>
|
||||
<td class="td_class text-right">{{detail.a_7}}</td>
|
||||
<td class="td_class text-right">{{detail.a_8}}</td>
|
||||
<td class="td_class text-center">{{detail.a_9 | date:'dd/MM/yyyy'}}</td>
|
||||
<td class="td_class text-center">{{detail.a_10 | date:'dd/MM/yyyy'}}</td>
|
||||
<td class="td_class text-right">{{detail.a_11}}</td>
|
||||
<td class="td_class text-right">{{detail.a_12}}</td>
|
||||
<td class="td_class">{{detail.a_13}}</td>
|
||||
</tr>
|
||||
</ng-container>
|
||||
</tbody>
|
||||
</table>
|
||||
```
|
||||
Change to:
|
||||
```html
|
||||
<tr class="tr_a">
|
||||
<td class="td_class text-center"><span class="badge-a">A</span></td>
|
||||
<td class="td_class text-right">{{detail.a_1}}</td>
|
||||
<td class="td_class text-right">{{detail.a_2}}</td>
|
||||
<td class="td_class text-right">{{detail.a_3}}</td>
|
||||
<td class="td_class text-right">{{detail.a_4}}</td>
|
||||
<td class="td_class text-right">{{detail.a_5}}</td>
|
||||
<td class="td_class text-right">{{detail.a_6}}</td>
|
||||
<td class="td_class text-right">{{detail.a_7}}</td>
|
||||
<td class="td_class text-right">{{detail.a_8}}</td>
|
||||
<td class="td_class text-center">{{detail.a_9 | date:'dd/MM/yyyy'}}</td>
|
||||
<td class="td_class text-center">{{detail.a_10 | date:'dd/MM/yyyy'}}</td>
|
||||
<td class="td_class text-right">{{detail.a_11}}</td>
|
||||
<td class="td_class text-right">{{detail.a_12}}</td>
|
||||
<td class="td_class">{{detail.a_13}}</td>
|
||||
</tr>
|
||||
<tr *ngIf="expandedHistoryUid === detail.change_project_research_detail_uid">
|
||||
<td colspan="24" style="padding:0; background:#f0f4ff; border:2px solid #c5cae9;">
|
||||
<div style="padding:10px 16px;">
|
||||
<div style="font-size:12px; font-weight:700; color:#1565c0; margin-bottom:8px;">ประวัติการแก้ไข</div>
|
||||
<div *ngIf="isHistoryLoading(detail.change_project_research_detail_uid)" style="text-align:center; padding:14px; color:#90a4ae;">
|
||||
<mat-spinner [diameter]="20" style="display:inline-block; vertical-align:middle;"></mat-spinner>
|
||||
<span style="margin-left:8px; vertical-align:middle;">กำลังโหลด...</span>
|
||||
</div>
|
||||
<ng-container *ngIf="!isHistoryLoading(detail.change_project_research_detail_uid)">
|
||||
<div style="overflow-x:auto;">
|
||||
<table style="border-collapse:collapse; width:100%; font-size:11px; min-width:900px;">
|
||||
<thead>
|
||||
<tr style="background:#e8eaf6;">
|
||||
<th style="padding:4px 8px; border:1px solid #c5cae9; width:140px;">วันเวลาที่แก้ไข</th>
|
||||
<th style="padding:4px 6px; border:1px solid #c5cae9;">แผนงาน</th>
|
||||
<th style="padding:4px 6px; border:1px solid #c5cae9;">ชื่อโครงการ</th>
|
||||
<th style="padding:4px 6px; border:1px solid #c5cae9;">ผลผลิต</th>
|
||||
<th style="padding:4px 6px; border:1px solid #c5cae9;">ประเด็นยุทธศาสตร์</th>
|
||||
<th style="padding:4px 6px; border:1px solid #c5cae9;">ลักษณะโครงการ</th>
|
||||
<th style="padding:4px 6px; border:1px solid #c5cae9;">หน่วยงานที่รับผิดชอบ</th>
|
||||
<th style="padding:4px 6px; border:1px solid #c5cae9;">แหล่งที่มาของเงิน</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let session of getHistorySessions(detail.change_project_research_detail_uid)">
|
||||
<td style="padding:5px 8px; border:1px solid #c5cae9; white-space:nowrap; font-size:10px; color:#3949ab; font-weight:600;">
|
||||
<span *ngIf="session.isOriginal" style="background:#ff8f00; color:#fff; padding:2px 7px; border-radius:4px; font-weight:700; font-size:10px; display:inline-block;">ต้นฉบับ</span>
|
||||
<span *ngIf="!session.isOriginal">{{session.datetime | date:'dd/MM/yyyy HH:mm'}}</span>
|
||||
</td>
|
||||
<td style="padding:4px 6px; border:1px solid #c5cae9;" [style.background]="!session.isOriginal && session.vals['budget_plan_name_th'] != null ? '#fff8e1' : '#fff'">{{ session.vals['budget_plan_name_th'] }}</td>
|
||||
<td style="padding:4px 6px; border:1px solid #c5cae9;" [style.background]="!session.isOriginal && session.vals['project_name_th'] != null ? '#fff8e1' : '#fff'">{{ session.vals['project_name_th'] }}</td>
|
||||
<td style="padding:4px 6px; border:1px solid #c5cae9;" [style.background]="!session.isOriginal && session.vals['budget_project_name_th'] != null ? '#fff8e1' : '#fff'">{{ session.vals['budget_project_name_th'] }}</td>
|
||||
<td style="padding:4px 6px; border:1px solid #c5cae9;" [style.background]="!session.isOriginal && session.vals['budget_strategy_name_th'] != null ? '#fff8e1' : '#fff'">{{ session.vals['budget_strategy_name_th'] }}</td>
|
||||
<td style="padding:4px 6px; border:1px solid #c5cae9;" [style.background]="!session.isOriginal && session.vals['budget_topic_name_th'] != null ? '#fff8e1' : '#fff'">{{ session.vals['budget_topic_name_th'] }}</td>
|
||||
<td style="padding:4px 6px; border:1px solid #c5cae9;" [style.background]="!session.isOriginal && session.vals['responsible_faculty_name_th'] != null ? '#fff8e1' : '#fff'">{{ session.vals['responsible_faculty_name_th'] }}</td>
|
||||
<td style="padding:4px 6px; border:1px solid #c5cae9;" [style.background]="!session.isOriginal && session.vals['budget_come_from'] != null ? '#fff8e1' : '#fff'">{{ session.vals['budget_come_from'] }}</td>
|
||||
</tr>
|
||||
<tr *ngIf="getHistorySessions(detail.change_project_research_detail_uid).length === 0">
|
||||
<td colspan="8" style="padding:14px; text-align:center; color:#90a4ae; font-style:italic; background:#fafafa;">ไม่มีประวัติการแก้ไข</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-container>
|
||||
</tbody>
|
||||
</table>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Append the seq-cell/badge styles to the component's scss**
|
||||
|
||||
Find, at the end of `list14-research.component.scss` (after the `.text-right { text-align: right; }` block):
|
||||
```scss
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
```
|
||||
Change to:
|
||||
```scss
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
// ===== Sequence number cell (history icon + edit-count badge) =====
|
||||
.seq-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 4px;
|
||||
}
|
||||
|
||||
.seq-number {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #2c3e50;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.seq-btn {
|
||||
width: 24px !important;
|
||||
height: 24px !important;
|
||||
line-height: 24px !important;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px !important;
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
line-height: 16px !important;
|
||||
}
|
||||
|
||||
&.seq-btn--history {
|
||||
color: #90a4ae !important;
|
||||
|
||||
&:hover {
|
||||
color: #0C7469 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.edit-count-pill {
|
||||
font-size: 9px;
|
||||
font-weight: 500;
|
||||
border-radius: 10px;
|
||||
padding: 2px 6px;
|
||||
white-space: nowrap;
|
||||
background: #f0f0f0;
|
||||
color: #bbb;
|
||||
letter-spacing: 0.2px;
|
||||
line-height: 1.4;
|
||||
|
||||
&.edit-count-pill--active {
|
||||
background: #e8f0fe;
|
||||
color: #1a73e8;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build to confirm no errors**
|
||||
|
||||
Run: `cd /Users/nut.looknut/Project/rmutr/rmutr-web && ng build --configuration=production`
|
||||
Expected: build succeeds with no new errors.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add rmutr-web/src/app/feature/budget-request/request/request-budget-statistics/presenter/list/request-budget-statistics-list/list14-research/list14-research.component.html rmutr-web/src/app/feature/budget-request/request/request-budget-statistics/presenter/list/request-budget-statistics-list/list14-research/list14-research.component.scss
|
||||
git commit -m "feat: render edit-history icon, badge, and session table on expense-project-research list"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: End-to-end manual verification
|
||||
|
||||
**Files:** none (verification only)
|
||||
|
||||
**Interfaces:** none
|
||||
|
||||
- [ ] **Step 1: Start the dev server**
|
||||
|
||||
Run: `cd /Users/nut.looknut/Project/rmutr/rmutr-web && npm start`
|
||||
|
||||
- [ ] **Step 2: Create a history entry**
|
||||
|
||||
Open "การบริหารและรายงานผล > เปลี่ยนแปลงโครงการ(วิจัย)" (`change-project-research-form/list-all`) → find a research project row with status allowing edits → change "ชื่อโครงการ" and "แผนงาน" → click "ส่งข้อมูล".
|
||||
|
||||
- [ ] **Step 3: Confirm the badge appears on expense-project-research**
|
||||
|
||||
Open the menu that routes to `expense-project-research` (typeUrl 26).
|
||||
Expected: the row for the project just edited shows a history icon and badge "เปลี่ยนแปลง #1" in the "ลำดับ" column; rows never edited show neither.
|
||||
|
||||
- [ ] **Step 4: Confirm the expand row content and ordering**
|
||||
|
||||
Click the history icon.
|
||||
Expected: row expands showing a table with 2 rows — the edit session (newest) first, then "ต้นฉบับ" last. In the edit-session row, "ชื่อโครงการ" and "แผนงาน" cells are highlighted pale yellow and show the new values; other columns (ผลผลิต, ประเด็นยุทธศาสตร์, ลักษณะโครงการ, หน่วยงานที่รับผิดชอบ, แหล่งที่มาของเงิน) are empty/not highlighted. The "ต้นฉบับ" row has no highlighted cells.
|
||||
|
||||
- [ ] **Step 5: Confirm a second edit appends correctly**
|
||||
|
||||
Back in `change-project-research-form/list-all`, edit the same project again, this time changing "ผลผลิต" only → "ส่งข้อมูล". Return to `expense-project-research`.
|
||||
Expected: badge now reads "เปลี่ยนแปลง #2"; expanding shows 3 rows (2 edit sessions newest-first, then "ต้นฉบับ" still last); only "ผลผลิต" is highlighted in the newest row, only "ชื่อโครงการ"/"แผนงาน" highlighted in the older one.
|
||||
|
||||
- [ ] **Step 6: Loading state**
|
||||
|
||||
With browser devtools network throttled (Slow 3G), collapse and re-click the history icon on a row whose sessions aren't cached yet (or hard-refresh the page first).
|
||||
Expected: brief "กำลังโหลด..." spinner shows before the session table renders.
|
||||
|
||||
- [ ] **Step 7: Regression check — typeUrl 12 history still works**
|
||||
|
||||
Open "เปลี่ยนแปลงโครงการ(วิจัย)" (`change-project-research-form/list-all`) again.
|
||||
Expected: the pre-existing history icon/badge/expand-row for research rows still work exactly as before (P/A numeric table, original pinned last) — unaffected by the Task 1 util extraction.
|
||||
|
||||
- [ ] **Step 8: No-history rows unaffected**
|
||||
|
||||
On `expense-project-research`, confirm rows for projects that were never edited via `change-project-research-form` show no icon, no badge, and the "ลำดับ" cell shows just the row number.
|
||||
@@ -0,0 +1,333 @@
|
||||
# In-Plan Report Submit/Review Bug-Fix 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:** Fix the dead "ส่งให้กองแผนตรวจสอบ" button on `agency-report-form/list-all` (typeUrl 20) so it actually submits selected reports to the planning division, add a status badge + checkbox guard to that list, and lock the report form for the agency once it's submitted — mirroring the pattern already shipped for out-of-plan reports.
|
||||
|
||||
**Architecture:** No new tables/endpoints/components. Three surgical edits to existing files: (1) one added boolean condition in `make-year-plant-form.component.ts`'s `save()` so typeUrl 20 stops being intercepted by the generic `isListAllMode()` branch and instead reaches its own already-correct `else if(this.typeUrl == 20)` block; (2) a `[disabled]` binding plus a new `reportStatusBadge()` helper and badge `<span>` in the list-all checkbox column; (3) one added `if` block in `agency-report-form.component.ts`'s existing `typeUrl == 202` branch, copied from the identical pattern already present in the `typeUrl == 282` branch two branches below it.
|
||||
|
||||
**Tech Stack:** Angular 17, Angular Material (`mat-checkbox`), RxJS (`tap`).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- No backend (`rmutr-api`) changes — `AgencyReportService.updateStatusAgency()` (POST `update_status/1`) and `AgencyReportService.get()` already exist and already work (used today by the out-of-plan flow and by `check-project-report`).
|
||||
- typeUrl 12 and typeUrl 14 behavior in `make-year-plant-form.component.ts` must be **completely unaffected** — every change in this plan is gated so it only fires for `typeUrl === 20`.
|
||||
- Do not touch `check-project-report` (typeUrl 16) or any out-of-plan code (typeUrl 28x/29x) — both already work correctly per prior investigation and must not regress.
|
||||
- This repo has no meaningful unit-test culture for these Angular components. Per-task verification uses `ng build --configuration=production` (from `/Users/nut.looknut/Project/rmutr/rmutr-web`) to catch compile/template errors, plus a manual browser check via `npm start` for the final task.
|
||||
- Follow existing code conventions exactly: raw `Swal`/`this.swSV.confirmSave()`/`this.swSV.errText()` patterns already used in these files — don't introduce a new dialog library.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Fix the `save()` dispatch bug for typeUrl 20
|
||||
|
||||
**Files:**
|
||||
- Modify: `/Users/nut.looknut/Project/rmutr/rmutr-web/src/app/feature/budget-request/request/request-budget-statistics/presenter/form/make-year-plan-form/make-year-plant-form.component.ts:2380`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing new — `this.typeUrl` (existing component field), `this.isListAllMode()` (existing method, ts:159-161)
|
||||
- Produces: nothing new — this task only changes which existing branch of `save()` executes for typeUrl 20. The already-existing `else if(this.typeUrl == 20)` block (ts:2439-2508) becomes reachable; its `case 'add':` sub-branch (ts:2457-2478) is what will now run when the button is clicked from `list-all` (route has no `:id`, so `this.state` is always `'add'` there).
|
||||
|
||||
- [ ] **Step 1: Change the guard condition**
|
||||
|
||||
Find, in `make-year-plant-form.component.ts`:
|
||||
```ts
|
||||
save(){
|
||||
console.log(this.typeUrl)
|
||||
|
||||
if(this.isListAllMode()){
|
||||
```
|
||||
Change to:
|
||||
```ts
|
||||
save(){
|
||||
console.log(this.typeUrl)
|
||||
|
||||
if(this.isListAllMode() && this.typeUrl !== 20){
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to confirm no errors**
|
||||
|
||||
Run: `cd /Users/nut.looknut/Project/rmutr/rmutr-web && ng build --configuration=production`
|
||||
Expected: build succeeds with no new errors.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add rmutr-web/src/app/feature/budget-request/request/request-budget-statistics/presenter/form/make-year-plan-form/make-year-plant-form.component.ts
|
||||
git commit -m "fix: route typeUrl 20 list-all submit through the correct save() branch"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Checkbox guard + status badge on the `list-all` report column
|
||||
|
||||
**Files:**
|
||||
- Modify: `/Users/nut.looknut/Project/rmutr/rmutr-web/src/app/feature/budget-request/request/request-budget-statistics/presenter/form/make-year-plan-form/make-year-plant-form.component.ts` (add helper method near `isListAllMode()`, ts:159-161)
|
||||
- Modify: `/Users/nut.looknut/Project/rmutr/rmutr-web/src/app/feature/budget-request/request/request-budget-statistics/presenter/form/make-year-plan-form/make-year-plan-form.component.html:472-493`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `item.value.agency_report.agency_report_uid` / `item.value.agency_report.status_id` (existing form data, already patched from backend — confirmed present at ts:1446, 1448 in the `agency_report` FormGroup builder)
|
||||
- Produces: `reportStatusBadge(status_id: number | null): {label: string, bg: string, color: string}` — a new public method on `MakeYearPlanFormComponent`/`MakeYearPlantFormComponent`, called only from the template in this task. No other task depends on it.
|
||||
|
||||
- [ ] **Step 1: Add the `reportStatusBadge()` helper method**
|
||||
|
||||
Find, in `make-year-plant-form.component.ts`:
|
||||
```ts
|
||||
isListAllMode(): boolean {
|
||||
return this.state === 'add' && (this.typeUrl === 12 || this.typeUrl === 14 || this.typeUrl === 20)
|
||||
}
|
||||
```
|
||||
Change to:
|
||||
```ts
|
||||
isListAllMode(): boolean {
|
||||
return this.state === 'add' && (this.typeUrl === 12 || this.typeUrl === 14 || this.typeUrl === 20)
|
||||
}
|
||||
|
||||
reportStatusBadge(status_id: number | null): { label: string, bg: string, color: string } {
|
||||
switch (status_id) {
|
||||
case 1: return { label: 'รอตรวจสอบ', bg: '#fef3c7', color: '#92400e' }
|
||||
case 2: return { label: 'ส่งแก้ไข', bg: '#fee2e2', color: '#991b1b' }
|
||||
case 3: return { label: 'ตรวจสอบแล้ว', bg: '#dcfce7', color: '#166534' }
|
||||
default: return { label: 'ร่าง', bg: '#e5e7eb', color: '#374151' }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `[disabled]` to the checkbox and the badge `<span>` in the template**
|
||||
|
||||
Find, in `make-year-plan-form.component.html`:
|
||||
```html
|
||||
<td class="td_class" [attr.rowspan]="cpPDetailsArray(i).length + cpADetailsArray(i).length" [hidden]="typeUrl != 20 && !(typeUrl == 12 && isListAllMode())">
|
||||
<div class="select-cell">
|
||||
<mat-checkbox
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="$event ? selection.toggle(item.value) : null"
|
||||
[checked]="selection.isSelected(item.value)"
|
||||
[aria-label]="checkboxLabel(item.value)">
|
||||
</mat-checkbox>
|
||||
<ng-container *ngIf="typeUrl == 12 && getSentCount(item.value) > 0">
|
||||
<span style="font-size:10px; background:#e65100; color:#fff; padding:2px 6px; border-radius:10px; display:block; text-align:center; margin-top:3px; white-space:nowrap;">แจ้งแล้ว {{getSentCount(item.value)}} ครั้ง</span>
|
||||
</ng-container>
|
||||
<button mat-icon-button *ngIf="typeUrl == 20 && item.value.agency_report?.agency_report_uid == null"
|
||||
(click)="addFormAgencyReport(item.value)"
|
||||
class="report-btn report-btn--add" title="เพิ่มรายงาน">
|
||||
<mat-icon>note_add</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button *ngIf="typeUrl == 20 && item.value.agency_report?.agency_report_uid != null"
|
||||
(click)="addFormAgencyReport(item.value)"
|
||||
class="report-btn report-btn--edit" title="แก้ไขรายงาน">
|
||||
<mat-icon>edit_document</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
```
|
||||
Change to:
|
||||
```html
|
||||
<td class="td_class" [attr.rowspan]="cpPDetailsArray(i).length + cpADetailsArray(i).length" [hidden]="typeUrl != 20 && !(typeUrl == 12 && isListAllMode())">
|
||||
<div class="select-cell">
|
||||
<mat-checkbox
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="$event ? selection.toggle(item.value) : null"
|
||||
[checked]="selection.isSelected(item.value)"
|
||||
[disabled]="typeUrl == 20 && (item.value.agency_report?.agency_report_uid == null || item.value.agency_report?.status_id === 1 || item.value.agency_report?.status_id === 3)"
|
||||
[aria-label]="checkboxLabel(item.value)">
|
||||
</mat-checkbox>
|
||||
<ng-container *ngIf="typeUrl == 12 && getSentCount(item.value) > 0">
|
||||
<span style="font-size:10px; background:#e65100; color:#fff; padding:2px 6px; border-radius:10px; display:block; text-align:center; margin-top:3px; white-space:nowrap;">แจ้งแล้ว {{getSentCount(item.value)}} ครั้ง</span>
|
||||
</ng-container>
|
||||
<button mat-icon-button *ngIf="typeUrl == 20 && item.value.agency_report?.agency_report_uid == null"
|
||||
(click)="addFormAgencyReport(item.value)"
|
||||
class="report-btn report-btn--add" title="เพิ่มรายงาน">
|
||||
<mat-icon>note_add</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button *ngIf="typeUrl == 20 && item.value.agency_report?.agency_report_uid != null"
|
||||
(click)="addFormAgencyReport(item.value)"
|
||||
class="report-btn report-btn--edit" title="แก้ไขรายงาน">
|
||||
<mat-icon>edit_document</mat-icon>
|
||||
</button>
|
||||
<span *ngIf="typeUrl == 20 && item.value.agency_report?.agency_report_uid != null"
|
||||
[style.background]="reportStatusBadge(item.value.agency_report?.status_id).bg"
|
||||
[style.color]="reportStatusBadge(item.value.agency_report?.status_id).color"
|
||||
style="display:inline-block;padding:2px 8px;border-radius:10px;font-size:10px;font-weight:600;margin-top:3px;white-space:nowrap;">
|
||||
{{reportStatusBadge(item.value.agency_report?.status_id).label}}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build to confirm no errors**
|
||||
|
||||
Run: `cd /Users/nut.looknut/Project/rmutr/rmutr-web && ng build --configuration=production`
|
||||
Expected: build succeeds with no new errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add rmutr-web/src/app/feature/budget-request/request/request-budget-statistics/presenter/form/make-year-plan-form/make-year-plant-form.component.ts rmutr-web/src/app/feature/budget-request/request/request-budget-statistics/presenter/form/make-year-plan-form/make-year-plan-form.component.html
|
||||
git commit -m "feat: add status badge and re-submit guard to list-all report checkboxes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Lock the in-plan report form once submitted
|
||||
|
||||
**Files:**
|
||||
- Modify: `/Users/nut.looknut/Project/rmutr/rmutr-web/src/app/feature/budget-request/request/request-budget-statistics/presenter/form/agency-report-form/agency-report-form.component.ts:469-508`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `this.isLocked` (existing field, ts:180, already declared — no new field needed), `x.status_id` (existing `agency_report` field, already loaded by `AgencyReportSV.get()`)
|
||||
- Produces: nothing new — `isLocked` being `true` is already read by the existing template bindings at `agency-report-form.component.html:428` (locked banner) and `:430` (hides the "บันทึก" button), both unconditional on `typeUrl`, so they activate automatically for typeUrl `202`/`202163` once this task sets the flag. The `save_send()`/`save_pass()` buttons (gated by `typeUrl == 202161`) are untouched by this task and keep working because both methods call `getRawValue()`, which reads values from disabled controls.
|
||||
|
||||
- [ ] **Step 1: Add the lock check to the `typeUrl == 202` branch**
|
||||
|
||||
Find, in `agency-report-form.component.ts`:
|
||||
```ts
|
||||
else if(this.typeUrl == 202 || this.typeUrl == 202161 || this.typeUrl == 202162 || this.typeUrl == 202163) {
|
||||
this.AgencyReportSV.get(this.uniquekey).pipe(
|
||||
tap((x:any)=>{
|
||||
console.log(x)
|
||||
this.form.patchValue(x)
|
||||
this.change_year(x.budget_year_name_th)
|
||||
if (x.agency_report_items) {
|
||||
const formArray = this.form.get('agency_report_items') as FormArray
|
||||
x.agency_report_items.forEach((agency_report_item) => {
|
||||
formArray.push(this.agency_report_item_form(agency_report_item))
|
||||
});
|
||||
}
|
||||
if (x.agency_report_details) {
|
||||
x.agency_report_details.forEach((agency_report_detail) => {
|
||||
let form_agency_report_details = this.form.get('agency_report_details') as FormArray
|
||||
form_agency_report_details.push(this.agency_report_details_form(agency_report_detail))
|
||||
});
|
||||
const firstIssue = x.agency_report_details.find(d => d.type == 1 && d.text_1)
|
||||
if (firstIssue) this.change_budget_strategy_faculty_strategic_edit(firstIssue.text_1)
|
||||
}
|
||||
if (x.agency_report_popularities) {
|
||||
x.agency_report_popularities.forEach((p) => {
|
||||
const arr = this.form.get('agency_report_popularities') as FormArray
|
||||
arr.push(this.fb.group(p))
|
||||
})
|
||||
this.cdRef.detectChanges()
|
||||
}
|
||||
}),
|
||||
concatMap((x: any) => x.change_project_detail_uid
|
||||
? this.ChangeProjectDetailSV.get(x.change_project_detail_uid)
|
||||
: of(null)
|
||||
),
|
||||
tap((changeDetail: any) => {
|
||||
if (changeDetail?.project_name_th) {
|
||||
this.form.get('project_name_th').setValue(changeDetail.project_name_th)
|
||||
}
|
||||
Swal.close()
|
||||
})
|
||||
).subscribe()
|
||||
}
|
||||
```
|
||||
Change to:
|
||||
```ts
|
||||
else if(this.typeUrl == 202 || this.typeUrl == 202161 || this.typeUrl == 202162 || this.typeUrl == 202163) {
|
||||
this.AgencyReportSV.get(this.uniquekey).pipe(
|
||||
tap((x:any)=>{
|
||||
console.log(x)
|
||||
this.form.patchValue(x)
|
||||
this.change_year(x.budget_year_name_th)
|
||||
if (x.agency_report_items) {
|
||||
const formArray = this.form.get('agency_report_items') as FormArray
|
||||
x.agency_report_items.forEach((agency_report_item) => {
|
||||
formArray.push(this.agency_report_item_form(agency_report_item))
|
||||
});
|
||||
}
|
||||
if (x.agency_report_details) {
|
||||
x.agency_report_details.forEach((agency_report_detail) => {
|
||||
let form_agency_report_details = this.form.get('agency_report_details') as FormArray
|
||||
form_agency_report_details.push(this.agency_report_details_form(agency_report_detail))
|
||||
});
|
||||
const firstIssue = x.agency_report_details.find(d => d.type == 1 && d.text_1)
|
||||
if (firstIssue) this.change_budget_strategy_faculty_strategic_edit(firstIssue.text_1)
|
||||
}
|
||||
if (x.agency_report_popularities) {
|
||||
x.agency_report_popularities.forEach((p) => {
|
||||
const arr = this.form.get('agency_report_popularities') as FormArray
|
||||
arr.push(this.fb.group(p))
|
||||
})
|
||||
this.cdRef.detectChanges()
|
||||
}
|
||||
if (x.status_id === 1 || x.status_id === 3) {
|
||||
this.isLocked = true
|
||||
this.form.disable()
|
||||
}
|
||||
}),
|
||||
concatMap((x: any) => x.change_project_detail_uid
|
||||
? this.ChangeProjectDetailSV.get(x.change_project_detail_uid)
|
||||
: of(null)
|
||||
),
|
||||
tap((changeDetail: any) => {
|
||||
if (changeDetail?.project_name_th) {
|
||||
this.form.get('project_name_th').setValue(changeDetail.project_name_th)
|
||||
}
|
||||
Swal.close()
|
||||
})
|
||||
).subscribe()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to confirm no errors**
|
||||
|
||||
Run: `cd /Users/nut.looknut/Project/rmutr/rmutr-web && ng build --configuration=production`
|
||||
Expected: build succeeds with no new errors.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add rmutr-web/src/app/feature/budget-request/request/request-budget-statistics/presenter/form/agency-report-form/agency-report-form.component.ts
|
||||
git commit -m "feat: lock in-plan report form once submitted or reviewed"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: End-to-end manual verification
|
||||
|
||||
**Files:** none (verification only)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: all of Tasks 1-3
|
||||
- Produces: nothing — this is the final confirmation task for this plan
|
||||
|
||||
- [ ] **Step 1: Start the dev server**
|
||||
|
||||
Run: `cd /Users/nut.looknut/Project/rmutr/rmutr-web && npm start`
|
||||
Expected: compiles, dev server serves on the configured local port.
|
||||
|
||||
- [ ] **Step 2: Verify the list-all badge and checkbox guard**
|
||||
|
||||
In the browser, log in and navigate to `app/agency-report-form/list-all` (typeUrl 20). Confirm:
|
||||
- Rows with a saved report but not yet submitted show a gray "ร่าง" badge and their checkbox is selectable.
|
||||
- Rows with no report at all show no badge and their checkbox is disabled.
|
||||
|
||||
- [ ] **Step 3: Verify submit actually reaches the backend**
|
||||
|
||||
Select 1-2 "ร่าง" rows → click "ส่งให้กองแผนตรวจสอบ" → confirm the dialog. Confirm:
|
||||
- No JS error in the browser console.
|
||||
- After the save completes and the list reloads, those rows' badges change to yellow "รอตรวจสอบ" and their checkboxes become disabled.
|
||||
|
||||
- [ ] **Step 4: Verify the empty-selection error still fires**
|
||||
|
||||
Deselect everything (or select only rows without a report) → click "ส่งให้กองแผนตรวจสอบ". Confirm the existing error message "กรุณาเลือกรายการที่ต้องการส่ง" appears and no request is sent.
|
||||
|
||||
- [ ] **Step 5: Verify the report form locks for the agency once submitted**
|
||||
|
||||
From `list-all`, open (แก้ไขรายงาน) one of the rows now in "รอตรวจสอบ" status. Confirm all fields are disabled and the "บันทึก" button is hidden, replaced by the "รายงานนี้ถูกล็อก ไม่สามารถแก้ไขได้" banner.
|
||||
|
||||
- [ ] **Step 6: Verify `check-project-report` regression-free (planning division side)**
|
||||
|
||||
Log in as (or switch to) a planning-division account and open `app/check-project-report`. Confirm the rows submitted in Step 3 appear under the "รอตรวจสอบ" tab. Click into one and click "ส่งแก้ไข". Confirm:
|
||||
- Status becomes 2 (ส่งแก้ไข).
|
||||
- Back on the agency's `list-all`, that row's badge is now red "ส่งแก้ไข" and its checkbox is selectable again.
|
||||
- Opening the report form for that row is now editable (not locked), since status_id is 2.
|
||||
|
||||
- [ ] **Step 7: Verify "ตรวจสอบผ่าน" end state**
|
||||
|
||||
Re-submit the same row from `list-all` (repeat Step 3). As the planning division, open it from `check-project-report`'s "รอตรวจสอบ" tab and click "ตรวจสอบผ่าน". Confirm status becomes 3, the row moves to the "ตรวจสอบแล้ว" tab, and on the agency's `list-all` the badge is now green "ตรวจสอบแล้ว" with the form permanently locked when reopened.
|
||||
|
||||
- [ ] **Step 8: Regression check typeUrl 12/14 unaffected**
|
||||
|
||||
If test access to a typeUrl 12 or 14 `list-all`-equivalent view is available, open it and confirm the existing save behavior (grouped `ChangeProjectSV.put()` per `change_project_uid`) is unchanged — no badge, no `[disabled]` guard, same button/label as before this plan.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
# คำขอระหว่างปี — ขั้นตอนบันทึกฉบับร่าง / ส่งกองนโยบายและแผน
|
||||
|
||||
## บริบท
|
||||
|
||||
เมนู "คำขอระหว่างปี" (`/budget-web/app/request-budget-between-year`, routing `type: 21`) ปัจจุบันเมื่อกด "บันทึก" (ครั้งแรก) ระบบจะสร้างระเบียน `request_budget` (`is_between_year=true`) ด้วย `status_id = 0` และ `is_examine = true` ทันที แล้วพากลับไปหน้ารายการ — ระเบียนนี้จะไปปรากฏในเมนู "ตรวจสอบคำเสนอขอโครงการ" (`check-project-proposal`, `type: 2`) แท็บ "จัดการคำเสนอขอ" โดยอัตโนมัติ เพราะแท็บนั้น query `request_budget_and_strategic?status_id=1`... แต่ทดสอบจริงพบว่าระเบียนที่ `status_id=0` จากฟอร์มก็ไปโผล่ที่นั่นแล้ว (มีสถานะ "รอตรวจสอบ") กล่าวคือ ปัจจุบัน**ไม่มีขั้นตอนกลาง** — บันทึกปุ๊บ = ส่งงานแผนปั๊บ ผู้ใช้ไม่มีโอกาสร่างทิ้งไว้ก่อน
|
||||
|
||||
ผู้ใช้ (เจ้าหน้าที่หน่วยงาน/คณะ ผู้กรอกคำขอ) ต้องการขั้นตอนกลาง: ร่างไว้ก่อน แล้วค่อยเลือกส่งเป็นชุดเมื่อพร้อม
|
||||
|
||||
โค้ดที่มีอยู่แล้วบางส่วนสะท้อนว่าเคยมีความตั้งใจทำ flow นี้มาก่อนแต่ทำไม่เสร็จ:
|
||||
- `list-between-year.component.html` มีคอลัมน์สถานะที่ comment ไว้ ตรงกับความหมาย `status_id==0` → "ร่าง", `status_id==1` (และยังไม่ `is_ok`/`is_modify`) → รอดำเนินการ
|
||||
- `request-budget-statistics.container.ts` → `getAll()` query เริ่มต้นของ `dataSourceBetweenYear$` มี filter `&status_id=1` ค้างอยู่ (ไม่ตรงกับ `onsearch()` ที่ไม่กรอง) — เป็น bug ที่ต้องแก้ไปพร้อมกัน มิฉะนั้นร่าง (`status_id=0`) จะไม่โผล่ในรายการเริ่มต้นเลย
|
||||
|
||||
`check-project-proposal` แท็บ "จัดการคำเสนอขอ" (query `status_id=1`) เป็นเมนูที่งานแผนใช้ตรวจสอบอยู่แล้ว **ไม่ต้องแก้ไขเมนูนั้น** — งานนี้ทำเฉพาะฝั่ง "คำขอระหว่างปี" ให้ควบคุมได้ว่าจะยังไม่ส่ง (ร่าง) หรือส่งแล้ว (`status_id: 1`) เท่านั้น การไปโผล่ที่ `check-project-proposal` เป็นผลพลอยได้อัตโนมัติจากการเปลี่ยน `status_id` ซึ่งมีอยู่แล้ว
|
||||
|
||||
## ขอบเขต
|
||||
|
||||
เฉพาะเมนู "คำขอระหว่างปี" (list + form ของ between-year เท่านั้น) ไม่แตะเมนู `check-project-proposal` หรือเมนูอื่นที่ใช้ container เดียวกัน (`request-budget-statistics.container.ts`) เกินความจำเป็น (จะเพิ่ม handler ใหม่ 1 ตัวในไฟล์นี้เท่านั้น)
|
||||
|
||||
## การไหลของสถานะ
|
||||
|
||||
| status_id | is_ok / is_modify | ป้ายในรายการ | แก้ไข/ลบได้ไหม |
|
||||
|---|---|---|---|
|
||||
| 0 | - | ร่าง | ได้ปกติ |
|
||||
| 1 | ทั้งคู่ false/null | ส่งงานแผน | ดูได้อย่างเดียว (form disable, ปุ่มลบปิด) |
|
||||
| 1 | is_modify = true | ส่งแก้ไข | (ปรับปรุงจากงานแผนผ่านเมนูอื่นอยู่แล้ว นอกขอบเขตนี้) |
|
||||
| 1 | is_ok = true | ตรวจสอบแล้ว | (นอกขอบเขตนี้) |
|
||||
|
||||
หมายเหตุ: แถวสถานะ `is_modify`/`is_ok` เกิดจากการทำงานของงานแผนในเมนู `check-project-proposal` ซึ่งมีอยู่แล้วในโค้ดปัจจุบัน งานนี้แค่แสดงป้ายให้ถูกต้องตามข้อมูลจริงในรายการเดียวกัน ไม่ต้องสร้าง logic ใหม่สำหรับสองแถวนี้
|
||||
|
||||
## การเปลี่ยนแปลงที่ต้องทำ
|
||||
|
||||
### 1. Bug fix: `request-budget-statistics.container.ts` → `getAll()`
|
||||
ลบ `&status_id=1` ออกจาก query เริ่มต้นของ `dataSourceBetweenYear$` (บรรทัด ~199) ให้เหลือ `?is_between_year=true` เท่านั้น เพื่อให้ทั้งร่างและที่ส่งแล้วแสดงในรายการเดียวกัน (ตรงกับพฤติกรรมของ `onsearch()` ที่มีอยู่แล้ว)
|
||||
|
||||
### 2. ฟอร์ม `request-budget-between-year-form.component.ts`
|
||||
- ปุ่ม "บันทึก" (label ในเทมเพลต) เปลี่ยนเป็น **"บันทึกฉบับร่าง"**
|
||||
- ตอน `save()` กรณี `state === 'add'`: คง `status_id = 0` ไว้ (ร่าง) แต่**เอา `is_examine = true` ออก** จาก path นี้ (ย้ายไปตั้งตอนส่งแทน เพราะ "กำลังตรวจสอบ" ควรเป็นจริงก็ต่อเมื่อส่งไปงานแผนแล้วเท่านั้น)
|
||||
- ตอนโหลดข้อมูลกรณี `state === 'edit'` (ใน `ngOnInit`, หลัง `this.form.patchValue(x)`): ถ้า `x.status_id !== 0` (ถูกส่งไปแล้ว) → เรียก `this.form.disable()` และตั้ง flag `isLocked = true` (component property ใหม่)
|
||||
- เทมเพลตฟอร์ม: ถ้า `isLocked` ให้ซ่อนปุ่ม "บันทึกฉบับร่าง" (เหลือแต่ "ยกเลิก"/กลับ) ฟิลด์ทั้งหมด disable อยู่แล้วจากการ `form.disable()`
|
||||
|
||||
### 3. List `list-between-year.component.ts` / `.html`
|
||||
- เพิ่ม `selection = new SelectionModel<any>(true, [])` (import จาก `@angular/cdk/collections`) ตามแพทเทิร์นเดียวกับ `list6`/`list24`
|
||||
- เพิ่มคอลัมน์ checkbox หน้าสุด: เช็คได้เฉพาะแถว `status_id === 0` (ร่าง) แถวอื่น disable ล่วงหน้า (ไม่ให้เลือกส่งซ้ำ)
|
||||
- Uncomment คอลัมน์สถานะเดิม เพิ่มเข้า columns array ของ header/row/footer และแก้ข้อความ:
|
||||
- `status_id == 0` → badge "ร่าง"
|
||||
- `is_modify == true` → badge "ส่งแก้ไข"
|
||||
- `is_ok == true` → badge "ตรวจสอบแล้ว"
|
||||
- `status_id == 1` (ไม่เข้าเงื่อนไขบนสองข้อ) → badge **"ส่งงานแผน"** (เดิม comment ไว้ว่า "รอตรวจสอบ" เปลี่ยนข้อความ)
|
||||
- ไอคอน "ลบ" (`delete`): disable เมื่อ `status_id !== 0`
|
||||
- ไอคอน "แก้ไข" (`edit`): ไม่ disable — กดได้เสมอ (เข้าไปดู แต่ฟอร์มจะ read-only เองถ้าไม่ใช่ร่าง ตามข้อ 2)
|
||||
- เพิ่มปุ่ม toolbar ใหม่ **"ส่งกองนโยบายและแผน"** ข้าง "เพิ่มคำขอระหว่างปี": `disabled` เมื่อ `selection.selected.length === 0`, คลิกแล้ว `emit` ผ่าน `@Output() onsend = new EventEmitter()` ส่ง `this.selection.selected` (array ของแถวที่เลือก)
|
||||
|
||||
### 4. เดินสาย output ผ่านชั้น presenter กลาง
|
||||
- `request-budget-statistics-list.component.html` ส่วน `*ngIf="typeUrl == 21"`: เพิ่ม `(onsend)="onsend.emit($event)"` (หรือชื่อ output ที่สอดคล้อง) ให้ bubble ต่อไปยัง container
|
||||
- `request-budget-statistics.container.html`: เพิ่ม `(onsend)="sendBetweenYearToPlan($event)"` บน `<app-request-budget-statistics-list>`
|
||||
|
||||
### 5. Handler ใหม่ใน `request-budget-statistics.container.ts`
|
||||
เมธอดใหม่ `sendBetweenYearToPlan(selected: any[])`:
|
||||
- ถ้า `selected.length === 0` return
|
||||
- confirm ด้วย `this.swSV.confirmSave()` (แพทเทิร์นเดียวกับ handler อื่นในไฟล์นี้)
|
||||
- เมื่อ confirm: map `selected` เป็น payload ที่ตั้ง `status_id: 1, is_examine: true` ทับค่าเดิมของแต่ละแถว แล้วเรียก `this.requestBudgetSV.updateMany(payload)`
|
||||
- สำเร็จ: `this.swSV.updateSuccess()` แล้ว `this.getAll()` (หรือเทียบเท่า) เพื่อ refresh `dataSourceBetweenYear$`, `this.cdRef.detectChanges()`
|
||||
- ผิดพลาด: `catchError` → `this.swSV.errText(err?.error?.description || 'ส่งไม่สำเร็จ')`
|
||||
|
||||
## Error handling
|
||||
- กดส่งโดยไม่เลือกอะไร: ปุ่มถูก disable ไว้แล้ว ไม่ต้องมี error state เพิ่ม
|
||||
- API update ล้มเหลว: แสดง error ผ่าน `swSV.errText` ตามแพทเทิร์นเดิมของไฟล์ ไม่ rollback selection (ผู้ใช้กดส่งซ้ำได้)
|
||||
- แถวที่ถูกล็อกแล้วไม่มีทางถูกส่งซ้ำ เพราะ checkbox ถูก disable ไว้ตั้งแต่ต้น (client-side guard เท่านั้น ไม่ได้เพิ่ม guard ฝั่ง backend เพราะนอกขอบเขตงานนี้)
|
||||
|
||||
## Testing
|
||||
- Manual/browser verification (ตาม superpowers:verify skill ของโปรเจกต์): เพิ่มคำขอระหว่างปีใหม่ → ยืนยันบันทึกเป็น "ร่าง", แก้ไข/ลบได้
|
||||
- ติ๊ก checkbox 1-2 แถวร่าง กด "ส่งกองนโยบายและแผน" → ยืนยันเปลี่ยนเป็น "ส่งงานแผน", แก้ไข/ลบไม่ได้ (เข้าไปดูฟอร์ม read-only ได้)
|
||||
- ยืนยันรายการที่ส่งแล้วไปปรากฏในเมนู "ตรวจสอบคำเสนอขอโครงการ" แท็บ "จัดการคำเสนอขอ" เหมือนเดิม (ไม่ต้องแก้โค้ดฝั่งนั้น)
|
||||
- ยืนยัน checkbox ของแถวที่ส่งแล้วไม่สามารถติ๊กเลือกซ้ำได้
|
||||
@@ -0,0 +1,58 @@
|
||||
# Visual reskin — sidebar, tables, buttons, badges, icons, font
|
||||
|
||||
## บริบท
|
||||
|
||||
ผู้ใช้ส่งภาพตัวอย่างดีไซน์ใหม่ (หน้า "คำของบประมาณเงินรายได้ > แบบฟอร์มเสนอขอปรับคุณวุฒิ") และต้องการปรับให้ทั้งแอป (`rmutr-web`) มีหน้าตาแบบนี้: sidebar, font, รูปแบบตาราง, สีตาราง, สีปุ่ม, รูปแบบไอคอน
|
||||
|
||||
## สิ่งที่พบจากการสำรวจโค้ด (สำคัญต่อขอบเขตงาน)
|
||||
|
||||
แอปนี้มีโครงสร้างธีมกลางที่ค่อนข้างแข็งแรงอยู่แล้ว ไม่ได้กระจัดกระจายทั้งหมดตามที่กังวลไว้ตอนแรก:
|
||||
|
||||
- **ธีม Angular Material กลาง**: `src/themes/default-theme.scss` — สี primary ปัจจุบันคือแดงอิฐ `#bd413a`
|
||||
- **CSS ตาราง/การ์ดกลางขนาดใหญ่**: `src/styles.scss` บรรทัด ~1360–1520 — ควบคุม header ตาราง (`th.mat-header-cell`, `tr.mat-header-row`), ขอบ, แถบสีสลับ, ท้ายตาราง, `.rmutr_card` แถบหัวข้อ, `.tables`, `.table-excel` ของ**เกือบทุกตารางทั้งแอป**ผ่าน selector แบบ `!important` — สีปัจจุบันคือเทาอมเบจ `#e6e6df`
|
||||
- **ปุ่มหลักกลาง**: `.bg-bpi-primary-color` ใน `styles.scss` — ปัจจุบันเป็นสีฟ้า `#5f9adc`
|
||||
- **Sidebar/แถบบน**: `src/app/layout/components/**` (menu-bar, basic-menu, collapsable, tool-bar, head-menu) — ใช้สีแบรนด์แดง `#bd413a` เดียวกันทั้งหมดอยู่แล้วอย่างสม่ำเสมอ (โลโก้, ไฮไลท์เมนู active, badge, ไอคอนออกจากระบบ)
|
||||
- **ฟอนต์กลาง**: `*:not(.material-icons,...) { font-family: 'Sarabun' !important; }` ใน `styles.scss` บรรทัด 138 — ครอบทั้งแอปอยู่แล้วที่จุดเดียว โหลดผ่าน Google Fonts CDN link ใน `src/index.html`
|
||||
|
||||
**ผลต่อขอบเขตงาน**: การแก้ไฟล์กลางเหล่านี้ (ไม่กี่ไฟล์) จะกระจายผลไปยังเกือบทุกเมนูทั้งแอปโดยอัตโนมัติ ไม่ต้องไล่แก้ทีละหน้า — หน้าเรือธง (request-qualification-adjustments) ใช้สไตล์กลางเหล่านี้อยู่แล้วโดยไม่มี override เฉพาะหน้า จึงเป็นตัวอย่างทดสอบที่สะอาด
|
||||
|
||||
**ขอบเขตที่เหลือนอกเหนือจากไฟล์กลาง**: หน้าเรือธงมี 2 จุดที่เขียนเฉพาะหน้า ไม่ได้ใช้ pattern กลาง ต้องแก้เพิ่ม:
|
||||
- คอลัมน์สถานะ: ปัจจุบันเป็นไอคอนจุดสี (`fiber_manual_record`) ไม่ใช่ badge ทรงเม็ดยา
|
||||
- คอลัมน์เครื่องมือ: ปัจจุบันเป็น `material-icons` สีเปล่าๆ ไม่มีพื้นหลังวงกลม
|
||||
|
||||
## ขอบเขตงาน (Phase 1)
|
||||
|
||||
1. หน้าเรือธง: `request-qualification-adjustments` (ใช้ตรวจสอบผลจริงที่ `localhost:4200`)
|
||||
2. Sidebar + top toolbar (ใช้ทั้งแอปอยู่แล้ว เปลี่ยนที่เดียว)
|
||||
3. Global CSS tokens (ตาราง, ปุ่ม, badge, icon-btn, font) — ไฟล์เดียวที่กระทบทั้งแอป
|
||||
|
||||
เมนูอื่นที่เหลือ (ที่ไม่มี override เฉพาะหน้าเหมือน request-qualification-adjustments) จะได้รับผลอัตโนมัติบางส่วนจาก global CSS แต่การไล่ปรับ badge/icon-btn เฉพาะหน้าให้ครบทุกเมนู เป็น**งานเฟสถัดไป** นอกขอบเขต spec นี้
|
||||
|
||||
## การเปลี่ยนแปลง
|
||||
|
||||
### 1. ฟอนต์ — IBM Plex Sans Thai (ฝังในแอป)
|
||||
- ติดตั้ง `@fontsource/ibm-plex-sans-thai` (npm package ที่รวมไฟล์ woff2 จริงไว้ในแพ็กเกจ ไม่ต้องพึ่ง Google Fonts CDN ตอนรันไทม์)
|
||||
- เพิ่มไฟล์ CSS น้ำหนัก 100, 200, 400, 500, 700 (ชุดเดียวกับที่ Sarabun ใช้อยู่เดิม) เข้า `angular.json` (`projects.rmutr-web.architect.build.options.styles`)
|
||||
- ลบ `<link href="https://fonts.googleapis.com/css2?family=Sarabun...">` ออกจาก `src/index.html` (คง Material Icons link ไว้ตามเดิม ไม่เกี่ยวกับฟอนต์ตัวอักษร)
|
||||
- เปลี่ยน `font-family: 'Sarabun', sans-serif !important;` → `font-family: 'IBM Plex Sans Thai', sans-serif !important;` ที่ `styles.scss:138` (จุดเดียว ครอบทั้งแอป)
|
||||
|
||||
### 2. สีตาราง/การ์ด (global, styles.scss ~1360–1520)
|
||||
เปลี่ยนกลุ่มสีเทาอมเบจ (`#e6e6df`, `$primary-mid`) ที่ใช้ใน header ตาราง/การ์ด → เขียวอมฟ้า (teal) ให้ตรงกับภาพตัวอย่าง ค่าเริ่มต้นที่จะใช้: `#3d8b7a` (header background) + ตัวอักษรสีขาว `#ffffff` — ปรับค่าจริงสดผ่าน `localhost:4200` ระหว่างทำ เพราะ `ng serve --hmr` reload อัตโนมัติ ไม่ต้องรอ build ใหม่ จุดที่ต้องแก้:
|
||||
- `.rmutr_card > .mat-header-cell` (แถบหัวข้อการ์ด/ค้นหา)
|
||||
- `tr.mat-header-row`, `th.mat-header-cell` (หัวตาราง Material ทั่วแอป)
|
||||
- `.tables th:not(.th_class)`, `.table-excel th:not(.th_class)` (ตาราง HTML ธรรมดา)
|
||||
|
||||
### 3. ปุ่มหลัก (global)
|
||||
เปลี่ยน `.bg-bpi-primary-color` (ปัจจุบันฟ้า `#5f9adc`) → สีส้ม/แดงอิฐ ค่าเริ่มต้น `#d9652d` (border เข้มกว่าเดิมสัดส่วนเดียวกับปัจจุบัน)
|
||||
|
||||
### 4. Sidebar/แถบบน
|
||||
สีแบรนด์แดงปัจจุบัน (`#bd413a`) ใกล้เคียงกับสีส้มในภาพตัวอย่างอยู่แล้ว — เปลี่ยนเป็นค่าเดียวกับปุ่มหลัก (`#d9652d`) ให้เป็นสีเดียวกันทั้งแอป (โลโก้, ไฮไลท์เมนู active, badge, ไอคอนออกจากระบบ) ไม่เปลี่ยนรูปทรง/ตำแหน่ง (ไฟล์: `menu-bar`, `basic-menu`, `collapsable`, `tool-bar` component scss)
|
||||
|
||||
### 5. Status badge (dot icon → เม็ดยา)
|
||||
ยกระดับ pattern badge ทรงเม็ดยา (`.badge-success` / `.badge-warning` / `.badge-pending` / `.badge-draft`) ที่เคยสร้างเฉพาะหน้าไว้ในงาน "คำขอระหว่างปี" (`list-between-year.component.scss`) ให้เป็น**คลาสกลางใน styles.scss** (ย้ายนิยามมาไว้ที่เดียว คอมโพเนนต์เดิมยังใช้ได้ปกติเพราะชื่อคลาสเหมือนเดิม) แล้วนำมาใช้แทนไอคอนจุดสีในคอลัมน์สถานะของหน้าเรือธง (`ปกติ` = เขียว ใช้ `.badge-success`)
|
||||
|
||||
### 6. ไอคอนเครื่องมือ (plain icon → ปุ่มวงกลมพื้นสีอ่อน)
|
||||
สร้างคลาสกลางใหม่ `.icon-btn` (+ variant `--view` สีฟ้าอ่อน / `--edit` สีส้มอ่อน / `--delete` สีแดงอ่อน — พื้นหลังโทนอ่อนของสีไอคอนเดิม) แทนที่ `material-icons` สีเปล่าในคอลัมน์เครื่องมือของหน้าเรือธง
|
||||
|
||||
## การทดสอบ
|
||||
ไม่มี unit test culture สำหรับไฟล์เหล่านี้ (เหมือนงานก่อนหน้าในโปรเจกต์นี้) — ตรวจสอบด้วย `ng build --configuration=production` ให้ผ่าน + ดูผลจริงสดที่ `localhost:4200/app/request-qualification-adjustments` (ผู้ใช้มี dev server รันอยู่แล้ว, HMR reload อัตโนมัติ) และสุ่มเช็คอีก 1-2 เมนูอื่นเพื่อยืนยันว่า global CSS ไม่พังอะไรที่มีอยู่เดิม
|
||||
@@ -0,0 +1,45 @@
|
||||
# Responsive Shell (Phase 1 of "make the site responsive") — Design Spec
|
||||
|
||||
## Context: this is a sub-project
|
||||
|
||||
The user's original ask was "ทำหน้าเว็บทั้งหมดให้ responsive" (make the whole site responsive). The app has no responsive/breakpoint infrastructure at all today, and spans hundreds of routes (`navigator.ts` is ~3,800 lines) built around wide, dense data tables (`table-excel`, horizontal drag-scroll) and multi-column filter forms. Doing "everything" in one pass isn't tractable, so this work is split into phases:
|
||||
|
||||
- **Phase 1 (this spec):** the global shell — sidebar, toolbar, main content container — since it's present on every page and is the highest-leverage, lowest-risk piece.
|
||||
- **Later phases (separate specs, not started):** per-page table and form responsiveness. Explicitly out of scope here.
|
||||
|
||||
**Goal for the whole initiative** (confirmed with the user): usable on tablet and smaller screens. Not a full mobile-first redesign of every table — the priority is that the most-used chrome (menu, toolbar, forms) doesn't break, over making every dense table phone-perfect.
|
||||
|
||||
## Problem (Phase 1 scope)
|
||||
|
||||
The sidebar built in the icon-rail feature (see `2026-07-07-sidebar-icon-rail-design.md`) only toggles between full (260px) and rail (72px) width via a manual hamburger click — there's no automatic response to viewport size. On a tablet-width screen, a user has to remember to collapse it themselves; the shell doesn't adapt on its own.
|
||||
|
||||
## Design
|
||||
|
||||
### Behavior
|
||||
|
||||
- Below **1024px** viewport width, the sidebar automatically switches to rail mode (72px), reusing the exact `isRailMode` state + `sidebar_rail_mode` localStorage key already built for the manual toggle — no new state, no new CSS classes.
|
||||
- The user can still manually re-expand to full width via the existing hamburger button at any point, including while the viewport is below 1024px. That manual choice is not immediately re-collapsed — the automatic behavior only fires again on an actual re-crossing of the 1024px boundary (shrinking past it again), not continuously while already narrow.
|
||||
- Widening back above 1024px does **not** auto-expand the sidebar back to full — only auto-collapse on shrinking is automatic; expanding is always a manual, explicit action. This avoids undoing a user's deliberate manual collapse made while at desktop width.
|
||||
- On initial page load, if the viewport is already narrower than 1024px, the sidebar starts in rail mode regardless of what was last stored in `localStorage` for that browser profile (e.g. a first-time tablet user gets the right starting state; a desktop user who previously stored `false` and then narrows their window gets correctly collapsed too).
|
||||
|
||||
### Mechanism
|
||||
|
||||
- Angular CDK's `BreakpointObserver` (`@angular/cdk/layout`) watches `(max-width: 1024px)`. This emits once immediately on subscribe with the current match state, then again only when the match state changes (not on every resize tick) — which is exactly the "fires on crossing, not continuously" behavior described above, with no manual debouncing needed.
|
||||
- `MainLayoutComponent` (which already owns `isRailMode` and its localStorage persistence from the icon-rail feature) subscribes to this observable in `ngOnInit`. Whenever it emits `matches: true`, `isRailMode` is set to `true` and persisted — mirroring exactly what `onToggleRailMode(true)` already does. Whenever it emits `matches: false`, nothing happens (no forced expand).
|
||||
- The subscription is torn down in `ngOnDestroy` (a new lifecycle hook on `MainLayoutComponent`, which doesn't implement `OnDestroy` today).
|
||||
- `LayoutModule` (`@angular/cdk/layout`) is registered in `app.module.ts`, the same way `OverlayModule` was added for the sidebar flyout.
|
||||
|
||||
### What was checked and needs no change
|
||||
|
||||
- `tool-bar.component.scss`'s `.user-display-name` already has `max-width: 200px; overflow: hidden; text-overflow: ellipsis` — a long display name already truncates gracefully rather than overflowing the toolbar at narrower widths.
|
||||
- `main-layout.component.html`'s content wrapper has no hardcoded `min-width`, so it doesn't itself force horizontal overflow at 1024px and below.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Any per-page table or form responsiveness (separate future phase/spec).
|
||||
- Any breakpoint other than the single 1024px tablet threshold.
|
||||
- Touch-specific interactions (this reuses the existing rail/flyout mouse-hover-and-click behavior as-is).
|
||||
|
||||
## Testing
|
||||
|
||||
- No automated test coverage exists for the layout shell (consistent with the icon-rail feature that preceded this). Verification is manual: resize the browser (or use devtools device emulation) across the 1024px boundary and confirm the sidebar collapses/expands as described, confirm a fresh load at a narrow width starts collapsed, and confirm manually re-expanding while narrow sticks until the boundary is crossed again.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Sidebar collapse-to-icon-rail — Design Spec
|
||||
|
||||
## Problem
|
||||
|
||||
The main sidebar (`app-menu-bar`) is a fixed 260px-wide column with icons + Thai text labels for every menu item. The only existing toggle (hamburger button in the toolbar) hides the entire sidebar off-screen via a negative `margin-left` — there is no "shrink to icon-only rail" mode like the reference screenshots the user provided (a narrow ~64-72px column showing just icons, with a small `>>` affordance to re-expand).
|
||||
|
||||
## Goal
|
||||
|
||||
Repurpose the existing hamburger toggle so it switches the sidebar between:
|
||||
- **Full mode** (current behavior): 260px wide, icons + text, current accordion sub-menus.
|
||||
- **Rail mode** (new): ~64-72px wide, icons only. Leaf items still navigate on click. Items with children (accordion groups) show a floating flyout popup (triggered by hover/click) listing the children as full-text links.
|
||||
|
||||
The hamburger no longer hides the sidebar entirely — it only toggles between these two width states. State persists across reloads via `localStorage`.
|
||||
|
||||
## Current architecture (relevant files)
|
||||
|
||||
- `src/app/layout/main-layout/main-layout.component.{ts,html}` — owns `isExpanedMenu: boolean`, applies `margin-left: -280px` to hide the sidebar, listens to `(expanedMenu)` from the toolbar.
|
||||
- `src/app/layout/components/tool-bar/tool-bar.component.ts` — hamburger `<button class="menu-toggle-btn" (click)="toggleMenu()">`, emits `expanedMenu` event.
|
||||
- `src/app/layout/components/menu-bar/menu-bar.component.{ts,html,scss}` — sidebar shell. `menu-bar.component.html:1` hardcodes `style="min-width: 260px; max-width: 260px; ..."`. Iterates `menus` (= `routes` from `navigator.ts`) and renders `app-head-menu` / `app-basic-menu` / `app-collapsable` / divider per `item.type`.
|
||||
- `src/app/layout/components/menu/head-menu/*` — flat top-level leaf link (icon + text).
|
||||
- `src/app/layout/components/menu/basic-menu/*` — leaf link, used at any nesting depth.
|
||||
- `src/app/layout/components/menu/collapsable/*` — expandable parent; recurses the same `*ngFor` type-switch over `item.children`. Existing accordion expand/collapse state (`isCollapsed`) is per-group and unrelated to the new rail-mode toggle.
|
||||
- `src/app/core/data/navigator.ts` — the `SeItem[]` data array driving the whole menu (`id, title, type, icon, children, link, ischildActive, ...`). Child items conventionally get `icon: 'fiber_manual_record'`, styled down to a 7px dot via `collapsable.component.scss` — i.e. children have no meaningful icon of their own, which is why rail mode must present them via a text flyout rather than shrinking them to an icon.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. State: `isRailMode`
|
||||
|
||||
- Add `isRailMode: boolean` to `MainLayoutComponent`, replacing the current use of `isExpanedMenu` for the hide/show margin trick. Initialize from `localStorage.getItem('sidebar_rail_mode') === 'true'` on construction; write back on every toggle.
|
||||
- `ToolBarComponent`'s existing hamburger button keeps emitting the same `expanedMenu` (or a renamed but equivalent) event; `MainLayoutComponent` flips `isRailMode` and persists it.
|
||||
- Pass `isRailMode` into `MenuBarComponent` as an `@Input()`. `MenuBarComponent` passes it straight through to `app-head-menu` / `app-basic-menu` / `app-collapsable` as an `@Input()` on each, since the same three components recurse at every nesting depth.
|
||||
|
||||
### 2. Sidebar shell width
|
||||
|
||||
- `menu-bar.component.html`: replace the hardcoded inline `min-width/max-width: 260px` with an `[ngClass]`/`[ngStyle]` binding driven by `isRailMode` (260px vs. ~68px), plus a CSS `transition: width 0.2s ease` (or reuse `SEAnimations` conventions already used for the accordion expand) so the resize animates smoothly.
|
||||
- Header block (logo + "มทร.รัตนโกสินทร์ / ระบบแผนงานและงบประมาณ"): wrap the text block in `*ngIf="!isRailMode"`; keep the logo icon visible and centered in both modes.
|
||||
- Divider items: hide entirely in rail mode (`*ngIf="!isRailMode"` on the divider `<div>`), since a horizontal rule with no label doesn't carry meaning at that width.
|
||||
|
||||
### 3. Leaf items (`head-menu`, `basic-menu`)
|
||||
|
||||
- Title `<span>` wrapped in `*ngIf="!isRailMode"`.
|
||||
- When `isRailMode` is true, add a native `title="{{item.title}}"` attribute on the icon button/row so hovering shows the label as a browser tooltip — cheap, no new UI component needed.
|
||||
- Click behavior unchanged (still navigates via existing `link`/`routerLink` handling).
|
||||
|
||||
### 4. Parent items with children (`collapsable`)
|
||||
|
||||
- When `isRailMode` is true, the component no longer renders its children inline/expanded in the document flow. Instead:
|
||||
- The parent icon becomes the trigger for a flyout popup.
|
||||
- Flyout opens on `mouseenter` of the icon (with a short close delay on `mouseleave` from both the icon and the popup, so the user can move the cursor into the popup) and also toggles on click, for touch/keyboard accessibility.
|
||||
- Flyout is a small absolutely-positioned panel anchored to the right of the icon (use Angular CDK Overlay, already a project dependency, with a `connectedPosition` anchored to the trigger element — avoids manual z-index/positioning math and handles viewport-edge flipping for free).
|
||||
- Flyout content: the same recursive item list (title text + click-through), reusing `app-basic-menu`/nested `app-collapsable` in their normal (non-rail) rendering, since inside the flyout there's room for full labels.
|
||||
- Closes on: selecting an item (navigation), clicking outside, or `Escape`.
|
||||
- Active-group indicator: if any descendant matches the existing `ischildActive` flag, render a small colored dot/border accent on the parent's icon in rail mode (reusing the existing active-state accent color already used for the expanded active row) so the user can tell which group the current page belongs to without opening the flyout.
|
||||
|
||||
### 5. Out of scope
|
||||
|
||||
- No new/second toggle button — the existing hamburger is the only control, per the approved design.
|
||||
- No responsive/mobile breakpoint behavior (none exists today; not part of this change).
|
||||
- No changes to `navigator.ts` data (icons, children, structure) — this is purely a rendering-mode change layered on the existing data-driven menu.
|
||||
|
||||
## Testing
|
||||
|
||||
- Manual verification via the `run`/browser flow: toggle rail mode, confirm width animates, confirm leaf items navigate, confirm a `collapsable` item's flyout opens/closes correctly (hover and click), confirm the active-group indicator shows on the right icon when a child route is active, confirm state survives a full page reload (localStorage).
|
||||
- No existing automated test suite covers the layout/menu components (none found during exploration) — this change ships without new automated tests, consistent with the rest of the layout code.
|
||||
@@ -0,0 +1,149 @@
|
||||
# รายงานผล(นอกแผน) — ส่งงานแผน / เมนูตรวจสอบ
|
||||
|
||||
## บริบท
|
||||
|
||||
หน้า "หน่วยงานทำรายงานผล(นอกแผน)" (`/app/agency-out-of-plan/out-of-plan`, `type: 28`) ให้หน่วยงานกรอกรายงานผลโครงการนอกแผนเป็นตารางรายการ (`out_of_plan_report_detail`) แต่ละแถวเชื่อมกับรายงาน (`agency_report`, ผ่าน `out_of_plan_report_detail_uid`) ที่กรอกในฟอร์มแยก (`agency-report-form.component.ts`, เปิดผ่าน `openReport()` ที่ `agency-out-of-plan-form.component.ts:107-117`)
|
||||
|
||||
ปัจจุบัน**ไม่มีขั้นตอนส่งงานแผน** สำหรับรายงานนอกแผน — ฟอร์มมีแต่ปุ่ม "บันทึก" (`save()` case `281`/`282` ที่ `agency-report-form.component.ts:4015-4045`) ซึ่งไม่แตะ `status_id` เลย ต่างจากรายงานในแผนปกติที่มี flow ผ่านเมนู "ตรวจสอบรายงานผลโครงการ" (`check-project-report`, `type: 16`) อยู่แล้ว:
|
||||
|
||||
- 3 แท็บ กรองด้วย `agency_report.status_id`: รอตรวจสอบ (1) / ส่งแก้ไข (2) / ตรวจสอบแล้ว (3) — `request-budget-statistics-list.component.html:297-330`, query ที่ `request-budget-statistics.container.ts:401-406`
|
||||
- ปุ่มตรวจสอบ "ส่งกลับแก้ไข" (`save_send()` → status 2) และ "ตรวจสอบแล้ว" (`save_pass()` → status 3) มีอยู่แล้วใน `agency-report-form.component.ts:4116-4179` (ใช้ร่วมกันได้ทุก typeUrl เพราะไม่ได้ gate ด้วย typeUrl ภายในเมธอด)
|
||||
- Backend endpoint `POST /request_budget/agency_report/update_status/{status_id}` (`rmutr-api/Modules/RequestBudgets/Controllers/AgencyReport.cs:72-87`) รับ list ของ `t_agency_report` แล้วตั้ง `status_id` ให้ทุกตัว — รองรับทั้ง 1/2/3 อยู่แล้วเพราะ `status_id` เป็น route parameter ทั่วไป ไม่ผูกกับค่าใดค่าหนึ่ง
|
||||
|
||||
เป้าหมายงานนี้คือต่อยอด pattern เดิมที่มีอยู่แล้ว มาสโคปเฉพาะรายงานที่มี `out_of_plan_report_detail_uid != null` โดย**ไม่ต้องแก้ backend**
|
||||
|
||||
ข้อจำกัดสำคัญ: filter mechanism ทั่วไปของ `BaseUidController`/`EntityUidService` (`rmutr-api/Databases/Features/EntityUidService.cs:125-128`) รองรับแค่ equality/substring ต่อ field เท่านั้น **ไม่รองรับ "field IS NOT NULL"** ผ่าน query string ดังนั้นการแยกรายงานนอกแผนออกจากรายงานในแผนต้องกรองฝั่ง client (Angular) หลัง fetch — ตรงกับ pattern ที่ `agency-out-of-plan-form.component.ts:54-94` ทำอยู่แล้วสำหรับ field เดียวกันนี้ และตรงกับ pattern การกรอง client-side ด้วย `map(data => data.filter(...))` ที่มีอยู่แล้วหลายจุดใน `request-budget-statistics.container.ts` (เช่น `typeUrl == 14/15/20/26/27`)
|
||||
|
||||
## ขอบเขต
|
||||
|
||||
- ฝั่งหน่วยงาน: หน้า `agency-out-of-plan/out-of-plan` (list) + ฟอร์มรายงาน (`agency-report-form.component.ts` เฉพาะ typeUrl ที่เกี่ยวกับนอกแผน)
|
||||
- เมนูใหม่: "ตรวจสอบรายงานผล(นอกแผน)" — list 3 แท็บ + routing + menu entry
|
||||
- ป้าย "โครงการ" / "นอกแผน" บนหัวฟอร์มรายงาน (ทุก typeUrl ที่ใช้ฟอร์มนี้ ไม่ใช่แค่นอกแผน) และบนเมนูใหม่
|
||||
- ไม่แตะ UI/เมนู `check-project-report` เดิม ไม่แตะ backend ไม่แตะ flow ของรายงานในแผนปกติ — **ยกเว้น** เพิ่ม client-side filter บรรทัดเดียวใน `typeUrl == 16` query (container เดียวกับที่แก้ในงานนี้อยู่แล้ว) เพื่อไม่ให้รายงานนอกแผนไปโผล่ซ้ำในเมนูเดิม (ดูเหตุผลข้อ 7)
|
||||
|
||||
## การไหลของสถานะ (บน `agency_report.status_id`)
|
||||
|
||||
| status_id | ป้าย | ใครทำ / เกิดอะไรขึ้น |
|
||||
|---|---|---|
|
||||
| 0 / null | ร่าง | หน่วยงานกรอก/แก้ไขได้ปกติ (ฟอร์ม enable) |
|
||||
| 1 | รอตรวจสอบ | หลังกด "ส่งงานแผน" — ฟอร์ม**ล็อก** (`form.disable()`) จนกว่าจะถูกส่งกลับ |
|
||||
| 2 | ส่งแก้ไข | งานแผนกด "ส่งกลับแก้ไข" (`save_send()`) — ฟอร์มปลดล็อกให้หน่วยงานแก้ไขอีกครั้ง |
|
||||
| 3 | ตรวจสอบแล้ว | งานแผนกด "ตรวจสอบแล้ว" (`save_pass()`) — จบ flow, ฟอร์มยังคงล็อก |
|
||||
|
||||
## การเปลี่ยนแปลงที่ต้องทำ
|
||||
|
||||
### 1. ฝั่งหน่วยงาน — list `agency-out-of-plan-form.component.ts` / `.html`
|
||||
|
||||
- เพิ่ม `selection = new SelectionModel<any>(true, [])` (import `@angular/cdk/collections`) ตาม pattern `list-between-year`
|
||||
- เพิ่มคอลัมน์ checkbox หน้าสุด: เช็คได้เฉพาะแถวที่ `_agency_report_uid` ไม่ null (กรอกรายงานแล้ว) **และ** `status_id` เป็น `0`/`null` เท่านั้น (ยังไม่เคยส่ง) แถวอื่น disable checkbox ไว้ล่วงหน้า — ต้องดึง `status_id` ของ `agency_report` มาเก็บใน `allDetails`/`details` ตอน `load()` ด้วย (ปัจจุบันเก็บแค่ `_agency_report_uid` ที่บรรทัด 89 — เพิ่ม `_status_id: agencyByDetail.get(uid)?.status_id` โดยเปลี่ยน map จาก uid string เป็น object `{uid, status_id}`)
|
||||
- เพิ่มคอลัมน์/badge สถานะแสดง "ร่าง"/"รอตรวจสอบ"/"ส่งแก้ไข"/"ตรวจสอบแล้ว" ตามตาราง state flow ด้านบน (ใช้ `_status_id` ที่เพิ่มมา)
|
||||
- เพิ่มปุ่ม toolbar "ส่งงานแผน": `disabled` เมื่อ `selection.selected.length === 0`, คลิกแล้ว confirm ผ่าน `swSV.confirmSave()` (pattern เดียวกับที่อื่นในไฟล์นี้) แล้วเรียก `agencyReportSV.updateStatusAgency(selection.selected.map(s => ({agency_report_uid: s._agency_report_uid})))` (service method ที่มีอยู่แล้ว `agency-report.service.ts` ยิง `POST update_status/1`) สำเร็จแล้ว `load()` ใหม่ + เคลียร์ selection
|
||||
|
||||
### 2. ฝั่งหน่วยงาน — ฟอร์ม `agency-report-form.component.ts` (typeUrl 281/282)
|
||||
|
||||
- ตอนโหลด (`ngOnInit`, ภายใน branch `typeUrl == 282` ที่บรรทัด 527-559 — branch `281` เป็นรายงานใหม่ ไม่มีปัญหานี้เพราะ `status_id` ถูก set เป็น `0` เสมอที่บรรทัด 512): หลัง `patchValue(x)` เช็คถ้า `x.status_id === 1 || x.status_id === 3` → `this.form.disable()` และตั้ง flag `isLocked = true` (component property ใหม่)
|
||||
- Template: ถ้า `isLocked` ซ่อนปุ่ม "บันทึก" ปกติ (ฟิลด์ทั้งหมด disable อยู่แล้วจาก `form.disable()`)
|
||||
|
||||
### 3. ป้าย "โครงการ" / "นอกแผน" บนฟอร์ม
|
||||
|
||||
- `agency-report-form.component.html` หัวฟอร์ม: เพิ่ม badge/chip อ่านจาก `form.value.out_of_plan_report_detail_uid` — มีค่า → "นอกแผน", ไม่มี (null) → "โครงการ" ใช้ได้กับทุก typeUrl เพราะ field นี้มีอยู่แล้วใน `agency_report` (จาก migration `20260624000002_AddOutOfPlanLinkToAgencyReport.cs`) และฟอร์มนี้ใช้ร่วมกันทั้งสองประเภทรายงาน
|
||||
|
||||
### 4. Routing ใหม่ (`app-routing.module.ts`)
|
||||
|
||||
เพิ่มต่อจากกลุ่ม route เดิมของ out-of-plan (ใกล้บรรทัด 2945-2971) และของ check-project-report review (ใกล้บรรทัด 2991-3016):
|
||||
|
||||
```ts
|
||||
{
|
||||
path: 'check-project-report-out-of-plan',
|
||||
loadChildren: () => import('.../request-budget-statistics.module').then(m => m.RequestBudgetStatisticsModule),
|
||||
data: { menuName: `การบริหารและรายงานผล${seperation}ตรวจสอบรายงานผล(นอกแผน)`, type: 29 }
|
||||
},
|
||||
{
|
||||
path: 'agency-report-out-of-plan-edit-wait', // เปิดจากแท็บ "รอตรวจสอบ"
|
||||
data: { ..., type: 29161 }
|
||||
},
|
||||
{
|
||||
path: 'agency-report-out-of-plan-edit-send', // เปิดจากแท็บ "ส่งแก้ไข"
|
||||
data: { ..., type: 29162 }
|
||||
},
|
||||
{
|
||||
path: 'agency-report-out-of-plan-edit-pass', // เปิดจากแท็บ "ตรวจสอบแล้ว"
|
||||
data: { ..., type: 29163 }
|
||||
},
|
||||
```
|
||||
|
||||
(เลข type 29/29161/29162/29163 ยืนยันแล้วว่าไม่ชนกับ type ที่มีอยู่เดิมทั้งหมดใน `app-routing.module.ts`)
|
||||
|
||||
### 5. Menu entry (`navigator.ts`)
|
||||
|
||||
เพิ่มต่อจาก entry `agency-report-out-of-plan` (บรรทัด 1111-1117):
|
||||
|
||||
```ts
|
||||
{
|
||||
id: 'check-project-report-out-of-plan',
|
||||
code: 'administer-011-007-3',
|
||||
title: 'ตรวจสอบรายงานผล(นอกแผน)',
|
||||
type: 'basic',
|
||||
icon: 'fiber_manual_record',
|
||||
link: '/app/check-project-report-out-of-plan',
|
||||
},
|
||||
```
|
||||
|
||||
### 6. List 3 แท็บใหม่ (มิเรอร์ `list16-1/2/3`)
|
||||
|
||||
- สร้าง component ใหม่ `list29-1`, `list29-2`, `list29-3` (copy โครงสร้างจาก `list16-1/2/3` รวม `SelectionModel`/`BaseList` pattern เดิม) — ต่างกันแค่ `edit(val)` navigate ไปที่ route ใหม่ตามข้อ 4 (`agency-report-out-of-plan-edit-wait/edit-report/:uid` เป็นต้น) และแสดง badge "นอกแผน" กำกับทุกแถว (แม้ในลิสต์นี้จะเป็นนอกแผนทั้งหมดอยู่แล้ว — ใส่เพื่อความสอดคล้องกับหัวฟอร์ม)
|
||||
- `request-budget-statistics-list.component.html`: เพิ่ม `<ng-container *ngIf="typeUrl == 29">` มิเรอร์ block ของ `typeUrl == 16` (บรรทัด 297-330) แต่ผูกกับ `dataSource29_1/2/3` และ component `app-list29-1/2/3`
|
||||
|
||||
### 7. Query data (`request-budget-statistics.container.ts`)
|
||||
|
||||
เพิ่ม branch ใหม่ต่อจาก `typeUrl == 16` (บรรทัด 401-406) — filter "not null" ทำฝั่ง client เพราะ backend ไม่รองรับ (ดูบริบทด้านบน):
|
||||
|
||||
```ts
|
||||
else if(this.typeUrl == 29){
|
||||
const filterOutOfPlan = (data: any[]) => (data || []).filter(d => !!d.out_of_plan_report_detail_uid)
|
||||
this.dataSource29_1$ = this.AgencyReportSV.queryString(`?status_id=1&${queryStr}`).pipe(map(filterOutOfPlan))
|
||||
this.dataSource29_2$ = this.AgencyReportSV.queryString(`?status_id=2&${queryStr}`).pipe(map(filterOutOfPlan))
|
||||
this.dataSource29_3$ = this.AgencyReportSV.queryString(`?status_id=3&${queryStr}`).pipe(map(filterOutOfPlan))
|
||||
}
|
||||
```
|
||||
|
||||
**ต้องแก้ควบคู่กัน**: query ของ `typeUrl == 16` (บรรทัด 401-406) ปัจจุบันไม่กรอง `out_of_plan_report_detail_uid` เลย — เพราะ `agency_report` เป็นตารางเดียวกันทั้งรายงานในแผนและนอกแผน (แยกกันแค่ field นี้) เมื่อรายงานนอกแผนถูก "ส่งงานแผน" (`status_id=1`) มันจะ**ตรงเงื่อนไข query เดิมของ `check-project-report` ด้วยเช่นกัน** ทำให้ไปโผล่ซ้ำสองเมนู ต้องเพิ่ม client-side filter แบบเดียวกัน (กรอง**ออก**) ในบล็อกเดิม:
|
||||
|
||||
```ts
|
||||
else if(this.typeUrl == 16){
|
||||
const excludeOutOfPlan = (data: any[]) => (data || []).filter(d => !d.out_of_plan_report_detail_uid)
|
||||
this.dataSource16_1$ = this.AgencyReportSV.queryString(`?status_id=1&${queryStr}`).pipe(map(excludeOutOfPlan))
|
||||
this.dataSource16_2$ = this.AgencyReportSV.queryString(`?status_id=2&${queryStr}`).pipe(map(excludeOutOfPlan))
|
||||
this.dataSource16_3$ = this.AgencyReportSV.queryString(`?status_id=3&${queryStr}`).pipe(map(excludeOutOfPlan))
|
||||
}
|
||||
```
|
||||
|
||||
ค้นหา (ปีงบประมาณ/ชื่อโครงการ/แผนงบประมาณ/หน่วยงาน) ใช้ `<app-request-budget-statistics-search>` เดิมที่ container ผูกไว้แล้วแบบไม่ขึ้นกับ typeUrl (`request-budget-statistics.container.html:1-12`) — **ไม่ต้องเพิ่ม UI ค้นหาใหม่** field ที่มีอยู่แล้ว (`request-budget-statistics.search.component.ts:26-32`) ครอบคลุมสิ่งที่ผู้ใช้ขอ (ปีงบประมาณ, ชื่อโครงการ) และเกินไปอีก (แผนงบประมาณ, หน่วยงานรับผิดชอบ)
|
||||
|
||||
ต้องเพิ่ม `dataSource29_1$/29_2$/29_3$ = new Observable<any>()` (properties, มิเรอร์บรรทัด 75-77) และค่าเริ่มต้นตอน `getAll()` (มิเรอร์บรรทัด 196-198)
|
||||
|
||||
### 8. ปุ่มตรวจสอบในฟอร์ม (`agency-report-form.component.html`)
|
||||
|
||||
ปุ่ม "ส่งกลับแก้ไข"/"ตรวจสอบแล้ว" มีอยู่แล้ว gate ด้วย `*ngIf="typeUrl == 202161"` (ตำแหน่งใกล้บรรทัด 435-442) — เพิ่ม `|| typeUrl == 29161` เข้าไปในเงื่อนไขเดิม (ใช้เมธอด `save_send()`/`save_pass()` เดิม ไม่ต้อง fork ใหม่)
|
||||
|
||||
### 9. `close()` (`agency-report-form.component.ts:3811-3825`)
|
||||
|
||||
เพิ่ม branch: `typeUrl == 29161 || typeUrl == 29162 || typeUrl == 29163` → `router.navigate(['app/check-project-report-out-of-plan'])`
|
||||
|
||||
## Error handling
|
||||
|
||||
- กดส่งงานแผนโดยไม่เลือกอะไร: ปุ่ม disabled ไว้แล้ว
|
||||
- API `update_status`/`save_send`/`save_pass` ล้มเหลว: error ผ่าน pattern `Swal.fire(err.error.description, '', 'error')` เดิมของไฟล์ ไม่ rollback selection
|
||||
- guard การเลือกแถวส่งซ้ำเป็น client-side เท่านั้น (checkbox disable ไว้ล่วงหน้า) — ไม่มี guard ฝั่ง backend เพิ่มเติม สอดคล้องกับ pattern เดิมของโปรเจกต์ (ดู `between-year-draft-send-design.md`)
|
||||
|
||||
## Testing (manual/browser)
|
||||
|
||||
1. หน่วยงานกรอกรายงานนอกแผนใหม่ → บันทึก → เห็นสถานะ "ร่าง" ในลิสต์ ยังแก้ไข/ลบได้
|
||||
2. ติ๊ก checkbox แถวร่างที่มีรายงานแล้ว 1-2 แถว → กด "ส่งงานแผน" → สถานะเปลี่ยนเป็น "รอตรวจสอบ" → เปิดฟอร์มดูอีกครั้ง ยืนยัน field ทั้งหมด disable
|
||||
3. ยืนยัน checkbox ของแถวที่ส่งแล้วไม่สามารถติ๊กเลือกซ้ำได้ และแถวที่ยังไม่มีรายงาน (`_agency_report_uid` null) ก็เลือกไม่ได้เช่นกัน
|
||||
4. เข้าเมนูใหม่ "ตรวจสอบรายงานผล(นอกแผน)" ในฐานะงานแผน → เห็นรายการที่ส่งมาในแท็บ "รอตรวจสอบ" พร้อม badge "นอกแผน" → ค้นหาด้วยปีงบประมาณ และค้นหาด้วยชื่อโครงการ ยืนยันกรองถูกต้อง
|
||||
5. เปิดรายงานจากแท็บ "รอตรวจสอบ" → กด "ส่งกลับแก้ไข" → ยืนยันสถานะเป็น "ส่งแก้ไข" และกลับไปเมนูตรวจสอบนอกแผน (ไม่ใช่เมนู check-project-report เดิม)
|
||||
6. หน่วยงานเปิดรายงานที่ถูกส่งกลับ (status 2) → ยืนยันฟอร์มแก้ไขได้อีกครั้ง → แก้ไข+ส่งงานแผนใหม่ (ทำซ้ำข้อ 2)
|
||||
7. งานแผนกด "ตรวจสอบแล้ว" ในแท็บ "รอตรวจสอบ" → ยืนยันสถานะเป็น "ตรวจสอบแล้ว" ย้ายไปแท็บ "ตรวจสอบแล้ว"
|
||||
8. ยืนยันรายการนอกแผน (ทุกสถานะ) **ไม่** ไปโผล่ในเมนู `check-project-report` เดิม หลังเพิ่ม client-side filter ตามข้อ 7 (ก่อนแก้จะโผล่ซ้ำแน่นอน เพราะ query เดิมกรองแค่ `status_id`) และยืนยันว่ารายงานในแผนปกติยังโผล่ใน `check-project-report` ตามเดิมไม่หายไป (regression check)
|
||||
9. เปิดรายงานในแผนปกติ (ไม่ใช่นอกแผน) ผ่านเมนูเดิม → ยืนยันหัวฟอร์มขึ้นป้าย "โครงการ" ถูกต้อง (regression check สำหรับข้อ 3 ที่แก้ template ที่ใช้ร่วมกัน)
|
||||
@@ -0,0 +1,89 @@
|
||||
# ประวัติการแก้ไขโครงการวิจัย — หน้า expense-project-research
|
||||
|
||||
## บริบท
|
||||
|
||||
เมนู "เปลี่ยนแปลงโครงการ(วิจัย)" (`change-project-research-form/list-all`, `typeUrl: 12`) ให้แก้ไขข้อมูลโครงการวิจัย กดปุ่ม "ส่งข้อมูล" (`sendResearchDataListAll()`) แล้วรายการจะไปโผล่ที่เมนู "หน่วยงานทำรายงานผล(วิจัย)" — route `expense-project-research` (`typeUrl: 26`, `status_id=2`) แสดงด้วย `List14ResearchComponent`
|
||||
|
||||
ฟีเจอร์ "ดูประวัติการแก้ไข" มีอยู่แล้วสมบูรณ์ทั้ง backend และ frontend — แต่ใช้งานได้เฉพาะที่หน้า `change-project-research-form/list-all` (typeUrl 12) เท่านั้น ผ่าน component ใหญ่ตัวเดียวที่ใช้ร่วมกันหลายเมนู `make-year-plant-form.component.ts`:
|
||||
|
||||
- Backend: `ChangeProjectResearchService.UpdateEntity()` (`rmutr-api/Modules/RequestBudgets/Services/ChangeProjectResearch.cs:25-89`) ทุกครั้งที่บันทึก จะ diff field ที่เปลี่ยนผ่าน `DiffDetail()` (บรรทัด 119-181) เขียนลงตาราง `t_change_project_research_history` ถ้ายังไม่เคยมี history ของ detail นี้มาก่อน จะสร้าง snapshot ต้นฉบับก่อนด้วย `BuildOriginalSnapshot()` (บรรทัด 186+, ใส่ `change_remark = "ต้นฉบับ"`) — endpoint อ่าน: `GET api/request_budget/change_project_research_detail/{uid}/history`
|
||||
- **`DiffDetail()`/`BuildOriginalSnapshot()` diff ทั้งฟิลด์รายละเอียดโครงการ** (`project_name_th`, `budget_project_name_th`, `budget_plan_name_th`, `budget_topic_name_th`, `budget_strategy_name_th`, `responsible_faculty_name_th`, `budget_come_from`, `budget_location_name_th`) **และ** ฟิลด์ตัวชี้วัด `a_1`-`a_13`/`p_1`-`p_13` — แต่หน้าจอเดิม (typeUrl 12) เอามาแสดงแค่กลุ่ม a/p เท่านั้น (ดูตารางในแถวขยายที่ `make-year-plan-form.component.html:1406-1487`) ฟิลด์รายละเอียดโครงการที่ diff ไว้ **ไม่เคยถูกแสดงที่ไหนเลย**
|
||||
- Frontend logic ที่มีอยู่แล้วและนำมาใช้ซ้ำได้ตรงๆ ใน `make-year-plant-form.component.ts`: `buildHistorySessions()` (บรรทัด 791-805, group ประวัติเป็น session ตาม timestamp แล้วเรียง `[...edits, ...originals]` — ต้นฉบับอยู่ท้ายเสมอ), `countEditSessions()` (บรรทัด 670-678), `toggleResearchHistory()`/`getResearchEditCount()`/`isResearchHistoryLoading()`/`getResearchHistorySessions()` (บรรทัด 2694-2726)
|
||||
|
||||
หน้า `expense-project-research` (typeUrl 26) แสดงด้วยคนละ component (`List14ResearchComponent`, `.../list14-research/list14-research.component.ts`) ซึ่ง**ไม่มี history เลย** — ไม่ fetch, ไม่มี state, ไม่มี UI (ยืนยันด้วย grep ไม่พบคำว่า "history"/"เปลี่ยนแปลง" ในไฟล์นี้)
|
||||
|
||||
เป้าหมายงานนี้: พอร์ตความสามารถ "ดูประวัติการแก้ไข" มาที่หน้า `expense-project-research` โดยแสดง**ฟิลด์รายละเอียดโครงการ**แทนกลุ่ม a/p (ตามภาพตัวอย่างที่ผู้ใช้ให้ไว้ — คอลัมน์ วันเวลาที่แก้ไข, แผนงาน, ชื่อโครงการ, ผลผลิต, ประเด็นยุทธศาสตร์, ลักษณะโครงการ ฯลฯ) ไม่ต้องแก้ backend เลยเพราะข้อมูลถูก diff ไว้ครบอยู่แล้ว
|
||||
|
||||
## ขอบเขต
|
||||
|
||||
- แก้เฉพาะฝั่ง frontend (`rmutr-web`), เฉพาะ `List14ResearchComponent` + extract util ใหม่จาก `make-year-plant-form.component.ts`
|
||||
- ไม่แตะ backend/endpoint ใดๆ (ข้อมูลพร้อมใช้อยู่แล้ว)
|
||||
- ไม่แตะพฤติกรรมเดิมของหน้า `change-project-research-form/list-all` (typeUrl 12) — ยกเว้น refactor ดึง `buildHistorySessions()`/`countEditSessions()` ออกเป็น shared util (พฤติกรรมต้องเหมือนเดิมทุกประการ ไม่ใช่ redesign)
|
||||
- ไม่รวมฟิลด์ตัวชี้วัด a/p ในตารางประวัติของหน้านี้ (ต่างจากหน้าเดิม) — ตามที่ผู้ใช้ยืนยันจากภาพตัวอย่าง
|
||||
|
||||
## การเปลี่ยนแปลงที่ต้องทำ
|
||||
|
||||
### 1. Extract shared util — ไฟล์ใหม่ `src/app/core/utils/change-history-session.util.ts`
|
||||
|
||||
- ย้าย `buildHistorySessions()` (ปัจจุบัน private method ที่ `make-year-plant-form.component.ts:791-805`) ออกมาเป็น exported pure function ชื่อเดิม รับ `records: any[]` คืน `Array<{datetime: string, isOriginal: boolean, vals: Record<string,string>}>` — คัดลอก logic มาทั้งหมดโดยไม่แก้ไข (group ด้วย `created_datetime.substring(0,19)`, แถว `change_remark === 'ต้นฉบับ'` แยกกลุ่มเป็น `__original__`, คืนค่า `[...edits, ...originals]`)
|
||||
- ย้าย `countEditSessions()` (บรรทัด 670-678) ออกมาเป็น exported pure function ชื่อเดิม เช่นกัน
|
||||
- แก้ `make-year-plant-form.component.ts`: import 2 ฟังก์ชันนี้จาก util ใหม่ ลบ private method เดิมทิ้ง แล้วแก้จุดเรียกทั้งหมด (`this.buildHistorySessions(...)` → `buildHistorySessions(...)`, `this.countEditSessions(...)` → `countEditSessions(...)`) — จุดเรียกใช้งานจริงรวม 8 จุด: `buildHistorySessions` ที่บรรทัด 749, 832, 2712, 2758 และ `countEditSessions` ที่บรรทัด 267, 746, 2660, 2755 พฤติกรรมต้องเหมือนเดิม 100%
|
||||
|
||||
### 2. `List14ResearchComponent` (`list14-research.component.ts`) — เพิ่ม history state + fetch
|
||||
|
||||
- inject `ChangeProjectResearchDetailService` (มีอยู่แล้ว `getHistory(uid)` ที่ `change-project-research-detail.service.ts:14`) และ `ChangeDetectorRef` (component ใช้ `ChangeDetectionStrategy.OnPush` อยู่แล้วที่บรรทัด 9 — ต้องเรียก `detectChanges()` เองทุกจุดที่ callback แบบ async อัปเดต state)
|
||||
- เพิ่ม properties: `editCountMap = new Map<string, number>()`, `historySessionsMap = new Map<string, Array<{datetime,isOriginal,vals}>>()`, `historyLoadingUids = new Set<string>()`, `expandedHistoryUid: string | null = null`
|
||||
- ใน `ngOnChanges` หลัง set `this.details = [...this.dataSource]` (บรรทัด 26-27): ถ้ามีข้อมูล, `forkJoin` เรียก `getHistory(d.change_project_research_detail_uid)` ทุกแถว (`catchError(() => of([]))` ต่อรายการ) แล้วคำนวณ `editCountMap` ด้วย `countEditSessions()` จาก util ใหม่ — ยังไม่ build session เต็ม ตอนนี้ (lazy โหลดตอน toggle เพื่อลด payload ตอนโหลดหน้าแรก เหมือน pattern เดิมที่ `toggleResearchHistory()` ทำ)
|
||||
- เพิ่มเมธอด มิเรอร์ `toggleResearchHistory`/`isResearchHistoryLoading`/`getResearchEditCount`/`getResearchHistorySessions` จาก `make-year-plant-form.component.ts:2694-2726` ทุกตัวอักษร (เปลี่ยนแค่ชื่อ map/property ให้ตรงกับของ component นี้, เรียก util ที่ extract แทน `this.` method เดิม):
|
||||
- `toggleHistory(uid: string)`
|
||||
- `isHistoryLoading(uid: string): boolean`
|
||||
- `getEditCount(uid: string): number`
|
||||
- `getHistorySessions(uid: string)`
|
||||
|
||||
### 3. Field set ของตารางประวัติ — ไม่ใช้ a/p
|
||||
|
||||
Mapping คงที่ (field_key ตรงกับที่ backend เขียนจริงใน `DiffDetail()`/`BuildOriginalSnapshot()`):
|
||||
|
||||
| field_key | หัวคอลัมน์ |
|
||||
|---|---|
|
||||
| `budget_plan_name_th` | แผนงาน |
|
||||
| `project_name_th` | ชื่อโครงการ |
|
||||
| `budget_project_name_th` | ผลผลิต |
|
||||
| `budget_strategy_name_th` | ประเด็นยุทธศาสตร์ |
|
||||
| `budget_topic_name_th` | ลักษณะโครงการ |
|
||||
| `responsible_faculty_name_th` | หน่วยงานที่รับผิดชอบ |
|
||||
| `budget_come_from` | แหล่งที่มาของเงิน |
|
||||
|
||||
ลำดับคอลัมน์อ้างอิงหัวตารางหลักของ `list14-research.component.html:29-37` ให้ตรงกัน (ต่อจากคอลัมน์ "วันเวลาที่แก้ไข" ที่นำหน้าเสมอ)
|
||||
|
||||
ไม่รวม `budget_location_name_th` แม้ backend diff ไว้ — เพราะไม่มีคอลัมน์นี้แสดงในตารางหลักของหน้านี้อยู่แล้ว (นอกสโคป ถ้าต้องการภายหลังแจ้งเพิ่มได้) และไม่รวม `a_1`-`a_13`/`p_1`-`p_13` ตามที่ตกลงกันไว้
|
||||
|
||||
### 4. Template `list14-research.component.html` — ไอคอน + badge + แถวขยาย
|
||||
|
||||
- คอลัมน์ "ลำดับ" (บรรทัด 81, ปัจจุบันแสดงแค่ `{{i+1}}` เฉยๆ): เปลี่ยนเป็นโครงสร้างเดียวกับ seq-cell ที่ `make-year-plan-form.component.html:1268-1288` แต่**ไม่มีปุ่มบันทึก** (หน้านี้ read-only ไม่มีฟอร์มแก้ไข) — แสดงเลขลำดับ + ปุ่มไอคอน history (`*ngIf="getEditCount(detail.change_project_research_detail_uid) > 0"`, สลับ `history`/`expand_less` ตามสถานะเปิด, `title="ดูประวัติการแก้ไข"`) + badge `เปลี่ยนแปลง #{{getEditCount(...)}}`
|
||||
- หลังแถว `tr_a` (บรรทัด 116-131) เพิ่มแถวขยาย `<tr *ngIf="expandedHistoryUid === detail.change_project_research_detail_uid"><td colspan="24" ...>` (24 = จำนวนคอลัมน์ทั้งหมดตาม `<colgroup>` บรรทัด 3-27) — โครงสร้างเดียวกับ `make-year-plan-form.component.html:1406-1487`:
|
||||
- หัวข้อ "ประวัติการแก้ไข", loading spinner ระหว่าง `isHistoryLoading(uid)`
|
||||
- ตาราง 1 session = 1 แถว (ต่างจากต้นฉบับที่แยก P/A เป็น 2 แถวย่อย เพราะ field ชุดใหม่นี้เป็นค่าเดียวต่อ detail ไม่ผูกกับ P-row) หัวตาราง: วันเวลาที่แก้ไข + 7 คอลัมน์ตามข้อ 3
|
||||
- แถว "ต้นฉบับ" ใช้ badge สีส้มเดิม (`background:#ff8f00`) แทนวันที่ — อยู่แถวสุดท้ายเสมอ (การันตีจาก `buildHistorySessions()`)
|
||||
- ไฮไลต์เซลล์ `background:#fff8e1` เมื่อ `!session.isOriginal && session.vals['<field_key>'] != null` (กติกาเดิมเป๊ะ — ถ้า field ไม่ได้ถูกแก้ใน session นั้น `field_key` จะไม่อยู่ใน `vals` เลย เพราะ `DiffDetail()` สร้างแถว history เฉพาะ field ที่ค่าเปลี่ยนจริงเท่านั้น)
|
||||
- แถว empty state `ไม่มีประวัติการแก้ไข` เมื่อ `getHistorySessions(uid).length === 0`
|
||||
|
||||
## Error handling
|
||||
|
||||
- `getHistory()` ล้มเหลว (ทั้งตอนโหลดหน้าแรกและตอน toggle): `catchError(() => of([]))` เหมือนทุกจุดในโปรเจกต์ — แถวนั้นจะไม่มี badge/ไอคอน (เหมือนไม่มีประวัติ) ไม่มี error popup รบกวน (หน้านี้เป็น read-only report ไม่ใช่ฟอร์มที่ต้อง block การทำงาน)
|
||||
- ไม่มีแถวไหนมีประวัติเลยทั้งหน้า: ไม่แสดงไอคอน/badge (ปกติ ไม่ต้องมี state พิเศษ)
|
||||
|
||||
## Known limitation (สืบทอดจากของเดิม ไม่ใช่สิ่งที่งานนี้สร้างขึ้น)
|
||||
|
||||
- `budget_location_name_th` ถูก diff ไว้ใน backend แต่ยังไม่มีหน้าไหนแสดงเลย (ทั้งของเดิมและงานนี้) — คงสถานะเดิม
|
||||
- field p_1-p_13 ของหลายแถว P ในรายละเอียดเดียวกัน backend ใช้ field_key ซ้ำกัน ("p_1" ทุกแถว) ทำให้ session ประวัติที่มี P มากกว่า 1 แถวจะเห็นค่าทับกันถ้าดูผ่านตาราง a/p เดิม — ไม่กระทบงานนี้เพราะไม่แสดง a/p ในตารางประวัติของหน้านี้
|
||||
|
||||
## Testing (manual/browser)
|
||||
|
||||
1. เข้าเมนู "เปลี่ยนแปลงโครงการ(วิจัย)" (`change-project-research-form/list-all`) แก้ไขโครงการวิจัยที่มีสถานะรอส่ง (เปลี่ยน "ชื่อโครงการ" และ "แผนงาน") กด "ส่งข้อมูล"
|
||||
2. เข้าเมนู `expense-project-research` — ยืนยันแถวโครงการที่เพิ่งแก้ไขมีไอคอน history + badge "เปลี่ยนแปลง #1" ในคอลัมน์ลำดับ
|
||||
3. กดไอคอน — ยืนยันแถวขยายออกมาแสดง 2 แถว (session ที่เพิ่งแก้ + ต้นฉบับ) ต้นฉบับอยู่ล่างสุดเสมอ คอลัมน์ "ชื่อโครงการ"/"แผนงาน" ไฮไลต์สีเหลืองเฉพาะแถว session ที่แก้จริง ไม่ไฮไลต์ในแถวต้นฉบับ คอลัมน์อื่นที่ไม่ได้แก้ (เช่น ผลผลิต) ว่างเปล่าไม่ไฮไลต์
|
||||
4. แก้ไขโครงการเดิมซ้ำอีกครั้งที่เมนู `change-project-research-form/list-all` (คนละ field เช่น "ผลผลิต") — กลับมาที่ `expense-project-research` ยืนยัน badge เปลี่ยนเป็น "#2" และแถวขยายมี 3 แถว (2 session + ต้นฉบับ) เรียงใหม่→เก่า ต้นฉบับยังคงอยู่ล่างสุด
|
||||
5. โครงการที่ไม่เคยถูกแก้ไขเลย — ยืนยันไม่มีไอคอน/badge ในคอลัมน์ลำดับ
|
||||
6. เปิดหน้า `change-project-research-form/list-all` (typeUrl 12) อีกครั้งหลัง refactor util — ยืนยัน badge/แถวขยาย/ไฮไลต์ของกลุ่ม a/p ยังทำงานเหมือนเดิมทุกจุด (regression check การ extract util)
|
||||
7. throttle network แล้วกดไอคอน history ที่หน้า `expense-project-research` — เห็น spinner "กำลังโหลด..." ก่อนแถวข้อมูลมา
|
||||
@@ -0,0 +1,91 @@
|
||||
# รายงานผล(ในแผน) — แก้บั๊กปุ่ม "ส่งให้กองแผนตรวจสอบ" + ล็อกฟอร์ม
|
||||
|
||||
## บริบท
|
||||
|
||||
หน้า "หน่วยงานทำรายงานผล" (`agency-report-form/list-all`, typeUrl `20`, component `MakeYearPlanFormComponent` ที่ `make-year-plant-form.component.ts`) ให้หน่วยงานติ๊กเลือกแถวที่กรอกรายงานผลแล้ว (`item.value.agency_report`) แล้วกดปุ่ม "ส่งให้กองแผนตรวจสอบ" (html:1036-1040, แสดงเมื่อ `typeUrl == 20 && isListAllMode()`)
|
||||
|
||||
**บั๊กปัจจุบัน**: เมธอด `save()` (ts:2377) เช็ค `if(this.isListAllMode())` เป็นเงื่อนไขแรกสุดและ `return` ทันที (ts:2380-2409) `isListAllMode()` (ts:159-161) คืนค่า `true` เมื่อ `state === 'add' && typeUrl ∈ {12,14,20}` — เนื่องจาก route `list-all` ไม่มี `:id` พารามิเตอร์ `state` จึงเป็น `'add'` เสมอ ทำให้ผู้ใช้ที่กด "ส่งให้กองแผนตรวจสอบ" (typeUrl 20) ตกเข้า branch `isListAllMode()` ซึ่งเป็น logic คนละเรื่อง (group ตาม `change_project_uid` แล้ว `ChangeProjectSV.put()`) — **ไม่อ่าน `this.selection.selected` เลย และไม่เรียก `AgencyReportSV.updateStatusAgency()`** จึงไม่มีการส่งสถานะ `agency_report.status_id` ไปกองแผนจริงๆ
|
||||
|
||||
Branch `else if(this.typeUrl == 20)` (ts:2439-2508) ที่ทำงานถูกต้องสมบูรณ์อยู่แล้ว (อ่าน `selection.selected` → กรองแถวที่มี `agency_report.agency_report_uid` → ตั้ง `status_id=1` → `AgencyReportSV.updateStatusAgency()`) จึงเป็น **dead code** ที่ไม่มีทางถูกเรียกถึงจากปุ่มนี้
|
||||
|
||||
ฝั่งกองแผน (`check-project-report`, typeUrl `16`) ทำงานสมบูรณ์อยู่แล้ว — 3 แท็บ (รอตรวจสอบ/ส่งแก้ไข/ตรวจสอบแล้ว ตาม `status_id` 1/2/3), ปุ่ม `save_send()`/`save_pass()` ใน `agency-report-form.component.ts` เรียก backend จริง (`PUT`) — **ไม่ต้องแก้ฝั่งนี้**
|
||||
|
||||
สิ่งที่ขาดเพิ่มเติมเมื่อเทียบกับ flow "รายงานนอกแผน" ที่เพิ่งทำ (`2026-07-21-out-of-plan-report-review-design.md`): list-all ไม่มี badge สถานะ/ไม่มีการกันติ๊กซ้ำ และฟอร์มรายงานฝั่งหน่วยงาน (typeUrl `202`) ไม่ถูกล็อกเมื่อสถานะเป็นรอตรวจสอบ/ตรวจสอบแล้ว (ทั้งที่ flow นอกแผน (typeUrl `282`) มี pattern นี้อยู่แล้ว)
|
||||
|
||||
## ขอบเขต
|
||||
|
||||
- แก้จุดเดียวใน `save()` ของ `make-year-plant-form.component.ts` ให้ typeUrl 20 ไม่ถูก `isListAllMode()` ดักก่อน — ไม่แก้ definition ของ `isListAllMode()` เอง (กระทบจุดอื่นในเทมเพลตน้อยที่สุด)
|
||||
- เพิ่ม `[disabled]` บน checkbox ของ list-all (เฉพาะ typeUrl 20) + เพิ่ม badge สถานะข้างปุ่ม "เพิ่ม/แก้ไขรายงาน"
|
||||
- เพิ่ม form-lock (`isLocked` + `form.disable()`) ให้ branch `typeUrl == 202` ใน `agency-report-form.component.ts` มิเรอร์ pattern เดียวกับ branch นอกแผน (`typeUrl == 282`) ที่มีอยู่แล้ว
|
||||
- ไม่แตะ `check-project-report` (typeUrl 16), ไม่แตะ backend, ไม่แตะ flow ของรายงานนอกแผน (typeUrl 28x/29x) ที่ทำไปแล้วเมื่อวาน
|
||||
- ไม่แก้ `isListAllMode()` สำหรับ typeUrl 12/14 — พฤติกรรมเดิมของสองตัวนี้ต้องเหมือนเดิมทุกประการ
|
||||
|
||||
## การเปลี่ยนแปลงที่ต้องทำ
|
||||
|
||||
### 1. แก้ `save()` dispatch bug (`make-year-plant-form.component.ts:2380`)
|
||||
|
||||
```ts
|
||||
if(this.isListAllMode() && this.typeUrl !== 20){
|
||||
```
|
||||
(เดิม: `if(this.isListAllMode()){`)
|
||||
|
||||
ผล: typeUrl 20 ตกไปที่ `else if(this.typeUrl == 20)` (ts:2439) ซึ่งมี `switch(this.state)` — เนื่องจาก `state` เป็น `'add'` เสมอบน route นี้ จะเข้า `case 'add':` (ts:2457-2478) ที่ทำงานถูกต้องอยู่แล้วโดยไม่ต้องแก้เพิ่ม (validate `updateForm.length`, `confirmSave()`, `updateStatusAgency()`, `saveSuccess()`, `loadAllDetails()`)
|
||||
|
||||
typeUrl 12/14 ยังคง `isListAllMode() === true` และเงื่อนไข `&& this.typeUrl !== 20` ไม่กระทบ จึงยังตกเข้า branch เดิม (ChangeProjectSV.put) เหมือนเดิมทุกประการ
|
||||
|
||||
### 2. Checkbox disable + status badge (`make-year-plan-form.component.html` รอบบรรทัด 474-493)
|
||||
|
||||
Checkbox (html:474-479) เพิ่ม:
|
||||
```html
|
||||
[disabled]="typeUrl == 20 && (item.value.agency_report?.agency_report_uid == null || item.value.agency_report?.status_id === 1 || item.value.agency_report?.status_id === 3)"
|
||||
```
|
||||
|
||||
ข้าง `report-btn` (หลังบรรทัด 492) เพิ่ม badge อ่านจาก `item.value.agency_report?.status_id` (แสดงเฉพาะเมื่อ `agency_report_uid != null`):
|
||||
|
||||
| status_id | ป้าย | สี |
|
||||
|---|---|---|
|
||||
| 0 / null | ร่าง | เทา |
|
||||
| 1 | รอตรวจสอบ | เหลือง (ใช้โทนเดียวกับ badge "นอกแผน" ที่มีอยู่แล้ว: `#fef3c7`/`#92400e`) |
|
||||
| 2 | ส่งแก้ไข | แดง/ส้ม |
|
||||
| 3 | ตรวจสอบแล้ว | เขียว |
|
||||
|
||||
```html
|
||||
<span *ngIf="typeUrl == 20 && item.value.agency_report?.agency_report_uid != null"
|
||||
[style.background]="reportStatusBadge(item.value.agency_report?.status_id).bg"
|
||||
[style.color]="reportStatusBadge(item.value.agency_report?.status_id).color"
|
||||
style="display:inline-block;padding:2px 8px;border-radius:10px;font-size:10px;font-weight:600;margin-top:3px;white-space:nowrap;">
|
||||
{{reportStatusBadge(item.value.agency_report?.status_id).label}}
|
||||
</span>
|
||||
```
|
||||
|
||||
เพิ่มเมธอด helper `reportStatusBadge(status_id)` ใน `make-year-plant-form.component.ts` คืนค่า `{label, bg, color}` ตามตารางด้านบน (ค่า default เมื่อ `status_id` เป็น `0`/`null`/`undefined` คือ "ร่าง")
|
||||
|
||||
### 3. ล็อกฟอร์มรายงานฝั่งหน่วยงาน (`agency-report-form.component.ts`, branch `typeUrl == 202`)
|
||||
|
||||
หา branch `typeUrl == 202` (ใน `case 'edit':`, cf. ts:469-508 ตามผลสำรวจ) เพิ่มก่อน `Swal.close()` เหมือนที่ branch `282` มี (ts:555-558):
|
||||
```ts
|
||||
if (x.status_id === 1 || x.status_id === 3) {
|
||||
this.isLocked = true
|
||||
this.form.disable()
|
||||
}
|
||||
```
|
||||
(ไม่ต้องเช็ค `out_of_plan_report_detail_uid` ในเงื่อนไขนี้ เพราะ branch `202` ใช้กับรายงานในแผนเท่านั้นอยู่แล้ว)
|
||||
|
||||
ปุ่ม "บันทึก"/badge locked banner ที่มีอยู่แล้วในเทมเพลต (gate ด้วย `isLocked`, ไม่ผูกกับ typeUrl เฉพาะ) ทำงานอัตโนมัติกับ branch นี้โดยไม่ต้องแก้เทมเพลตเพิ่ม — ปุ่ม `save_send()`/`save_pass()` (typeUrl 202161) ไม่ถูกกระทบเพราะไม่ได้ gate ด้วย `!isLocked` และใช้ `getRawValue()` ที่อ่านค่าจาก disabled control ได้ปกติ
|
||||
|
||||
## Error handling
|
||||
|
||||
- กดส่งโดยไม่ติ๊กแถวที่มีรายงาน: error text เดิม "กรุณาเลือกรายการที่ต้องการส่ง" (มีอยู่แล้ว ts:2459)
|
||||
- API `update_status` ล้มเหลว: `errText(err.error?.description ?? 'เกิดข้อผิดพลาด')` เดิม (ts:2467-2470) ไม่ rollback selection
|
||||
- guard การเลือกแถวซ้ำเป็น client-side เท่านั้น (checkbox disabled) ไม่มี guard เพิ่มฝั่ง backend — สอดคล้องกับ pattern เดิมของโปรเจกต์ (เหมือน flow นอกแผน)
|
||||
|
||||
## Testing (manual/browser)
|
||||
|
||||
1. เข้า `agency-report-form/list-all` (typeUrl 20) → เห็น badge "ร่าง" บนแถวที่กรอกรายงานแล้วแต่ยังไม่ส่ง, ไม่มี badge บนแถวที่ยังไม่มีรายงาน
|
||||
2. ติ๊กแถวที่มีรายงานสถานะ "ร่าง" 1-2 แถว → กด "ส่งให้กองแผนตรวจสอบ" → ยืนยัน error ไม่ขึ้นถ้ามีเลือกอยู่ → หลังส่งสำเร็จ badge เปลี่ยนเป็น "รอตรวจสอบ" และ checkbox แถวนั้น disabled ไปโดยอัตโนมัติ (reload)
|
||||
3. กด "ส่ง" โดยไม่ติ๊กอะไร (หรือติ๊กเฉพาะแถวที่ยังไม่มีรายงาน) → ยืนยัน error "กรุณาเลือกรายการที่ต้องการส่ง" ขึ้น
|
||||
4. เปิดฟอร์มรายงานของแถวที่ส่งไปแล้ว (status 1) จาก list-all → ยืนยันฟอร์ม disable ทั้งหมด ปุ่มบันทึกหาย
|
||||
5. เข้า `check-project-report` ในฐานะกองแผน → เห็นรายการที่ส่งมาในแท็บ "รอตรวจสอบ" (regression check — ต้องยังทำงานเหมือนเดิมทุกประการ เพราะไม่ได้แก้โค้ดฝั่งนี้)
|
||||
6. กด "ส่งกลับแก้ไข" → กลับไปหน่วยงาน → badge เปลี่ยนเป็น "ส่งแก้ไข" → checkbox กลับมาติ๊กได้ → ฟอร์มแก้ไขได้อีกครั้ง (ไม่ล็อก เพราะ status_id=2)
|
||||
7. แก้ไข+ส่งใหม่ (ทำซ้ำข้อ 2) → กองแผนกด "ตรวจสอบผ่าน" → badge เปลี่ยนเป็น "ตรวจสอบแล้ว" → เปิดฟอร์มยืนยัน disable ถาวร
|
||||
8. เข้า `agency-report-form/list-all` ด้วย typeUrl 12 หรือ 14 (ถ้ามีสิทธิ์ทดสอบ) → ยืนยันปุ่ม/พฤติกรรม save เดิมไม่เปลี่ยน (regression check สำหรับ Change 1)
|
||||
@@ -0,0 +1,147 @@
|
||||
# แทนที่ SyncFusion Spreadsheet ในรายงานงบประมาณเงินรายได้ ด้วยหน้าเว็บ native
|
||||
|
||||
## บริบท
|
||||
|
||||
หน้า `/app/income-budget-report-income1/add` (และเมนูพี่น้องอีก 6 เมนู) ใช้ SyncFusion Spreadsheet (`@syncfusion/ej2-angular-spreadsheet`) เปิดไฟล์ `.xlsx` ที่ backend generate มาให้ ผู้ใช้แก้ไขในสเปรดชีตที่มองเห็นได้ทุกเซลล์อิสระ แล้ว "บันทึก" กลับเป็นไฟล์ `.xlsx` ใหม่เก็บไว้เป็นหลักฐาน (`revenue_draft_committee_file`)
|
||||
|
||||
ปัญหาที่ต้องแก้:
|
||||
1. UX สเปรดชีตไม่เหมาะกับผู้ใช้ที่ไม่คุ้น Excel, formula/สูตรผูกกับ "สีเซลล์" ที่ซ่อนอยู่ในไฟล์ ตรวจสอบ/แก้ไขยาก
|
||||
2. **ไม่มีการล็อกข้อมูล** — ตำแหน่ง/จำนวนอัตรา/รายการครุภัณฑ์ที่ควรมาจากฟอร์มต้นทาง (ร.2/ร.4/ร.5/ร.6) กลับแก้ไขได้อิสระในหน้านี้เหมือนตัวเลขเงิน เพราะสเปรดชีตไม่แยกเซลล์ locked/unlocked
|
||||
|
||||
7 เมนูที่ใช้ component เดียวกัน (`income-budget-report.component.ts`, แยกพฤติกรรมด้วย route data `type`) แท้จริงแบ่งเป็น **3 กลุ่มเอกสารที่โครงสร้างต่างกัน** (ยืนยันด้วยการดาวน์โหลดไฟล์จริงจาก production มาตรวจ ไม่ใช่การเดา):
|
||||
|
||||
| กลุ่ม | type_id | ชื่อเมนู | โครงสร้างไฟล์จริง |
|
||||
|---|---|---|---|
|
||||
| **A — ร่างเงินรายได้** | 1→2→3 | ร่างเงินรายได้ เพื่อประชุม คกก.ร่างเงินรายได้ / คกก.การเงิน / สภา | 1 ชีท ("Page1"), tree เดียว รายการบุคลากร→งบบุคลากร/งบดำเนินงาน→แผนงาน→ผลผลิต→งบเงินอุดหนุน/งบลงทุน/งบรายจ่ายอื่นๆ ยืนยันไฟล์จริงทั้ง 3 type ตรงกัน |
|
||||
| **B — จัดสรร/ปรับแผนเงินรายได้** | 4→5→6 | จัดสรรงบประมาณเงินรายได้ / ปรับแผนเงินรายได้ (ประมาณการรายจ่าย) / ปรับแผนเงินรายได้ | workbook 5 ชีท แยกตาม "ผลผลิต" + สรุป มีช่องกรอกอิสระ ("ตำแหน่ง......(ระบุ)") แต่ **ใช้ logic คำนวณผลรวมแบบเดียวกับกลุ่ม A** (สีเซลล์ + สูตร SUM รูปแบบเดียวกัน) ยืนยันไฟล์จริง type 4,5 (type 6 ไม่มีข้อมูลจริงในระบบ อนุมานจาก chain ต่อจาก 5) |
|
||||
| **C — วิเคราะห์ค่าวัสดุการศึกษา** | 10 | ตารางและรายงานคำนวณค่าวัสดุการศึกษา | matrix แยกตามพื้นที่วิทยาเขต ไม่ใช่ tree เลย |
|
||||
|
||||
**Root cause ของปัญหาที่ 2:** ตำแหน่ง/รายการที่ควรมาจากฟอร์มต้นทางจริง ๆ **ก็ดึงมาจากต้นทางอยู่แล้วในทางเทคนิค** —
|
||||
- ร.2 (คำชี้แจงงบบุคลากร) เก็บใน `personnel_statement` + `personnel_statement_detail`/`_2` (`rmutr-api/Modules/ReportSalary/Databases/Models/`) ดึงผ่าน `GET /api/report/personnel/hr/budget_expenditure_report_from_revenue_v3/{view}` (`Personnel.cs:2195`)
|
||||
- ร.4 (งบครุภัณฑ์) = `invest_asset_request_information`, ร.5 (งบที่ดิน/สิ่งก่อสร้าง) = `invest_construct_request_information`, ร.6 (เงินอุดหนุน) = `request_budget_income` — ทั้งสามดึงรวมกันแล้วผ่าน `GET /api/budget_progress/budget_progress/summary_expense/{budget_year_uid}/{faculty_uid}` (`BudgetProgress.cs:409`) ซึ่งมี tree renderer ต้นแบบอยู่แล้วที่ `manage-budget-request-expense-list.component.html`
|
||||
|
||||
แต่เพราะ SyncFusion เปิดให้แก้ทุกเซลล์อิสระ ข้อมูลที่ดึงมาจึง **ไม่ถูกล็อก** อีกต่อไปหลังโหลดเข้าสเปรดชีต — นี่คือรากของปัญหา ไม่ใช่การขาดต้นทางข้อมูล
|
||||
|
||||
## ขอบเขต
|
||||
|
||||
- แทนที่ SyncFusion Spreadsheet ด้วย native Angular tree component สำหรับ **กลุ่ม A และ B** (ใช้ "เครื่องยนต์" เดียวกัน — ดูเหตุผลในหัวข้อสถาปัตยกรรม)
|
||||
- กลุ่ม C (type 10) แยก component matrix ต่างหาก ใช้ style/token ชุดเดียวกันเพื่อความสม่ำเสมอ แต่ไม่ใช้ tree schema/engine เดียวกับ A/B
|
||||
- เปลี่ยนการเก็บข้อมูลจาก "ไฟล์ .xlsx เป็นหลักฐาน" → "ข้อมูลโครงสร้างใน DB เป็นหลัก, .xlsx generate ตอนดาวน์โหลดเท่านั้น"
|
||||
- Routing เดิมทั้ง 7 เมนูไม่เปลี่ยน (`income-budget-report-income1/2/3`, `allocate-income-budget`, `income-expense-estimates(-02)`, `educational-materials`) — เปลี่ยนแค่เนื้อในของ component ที่ render
|
||||
- **ไม่แตะ** ฟอร์มต้นทาง ร.2/ร.4/ร.5/ร.6 เอง (`estimated-income-form`, `statement-invest-asset`, `statement-invest-construct`, `statement-request`) — หน้านี้อ่านข้อมูลจากตารางเหล่านั้นเท่านั้น ไม่เขียนกลับ
|
||||
|
||||
## สถาปัตยกรรม
|
||||
|
||||
```
|
||||
ต้นทางข้อมูล (อ่านอย่างเดียว) หน้ารายงานใหม่ (native web)
|
||||
───────────────────────── ──────────────────────────
|
||||
ร.2 personnel_statement ──┐
|
||||
ร.4 invest_asset_request_info ──┤ Backend: endpoint รวบรวมใหม่
|
||||
ร.5 invest_construct_request_info ──┼───▶ (ต่อยอด summary_expense เดิม
|
||||
ร.6 request_budget_income ──┘ + เพิ่ม ร.2)
|
||||
│
|
||||
▼
|
||||
income_budget_report (ใหม่)
|
||||
+ income_budget_report_line
|
||||
│
|
||||
▼
|
||||
Angular: tree component ใช้ร่วม
|
||||
กลุ่ม A (1 ต้นไม้ใหญ่) + กลุ่ม B (5 แท็บ)
|
||||
```
|
||||
|
||||
หน้ารายงานนี้เปลี่ยนบทบาทจาก "ฟอร์มกรอกข้อมูล" เป็น **"หน้ารวบรวม+ทบทวนตัวเลข"** — ตำแหน่ง/รายการครุภัณฑ์/โครงการ แสดง read-only จากฟอร์มต้นทาง ส่วนที่แก้ไขได้จริงคือ "จำนวนเงินที่คณะกรรมการปรับ" ต่อรายการ เก็บแยกเป็น snapshot ของตัวเอง ไม่เขียนทับข้อมูลต้นทาง
|
||||
|
||||
โครงสร้าง tree (รายการบุคลากร→งบบุคลากร→...) เป็น **config คงที่ต่อ `type_id`** (ไฟล์ TypeScript ฝั่ง frontend + ค่าคู่กันฝั่ง backend สำหรับคำนวณตอน export) ไม่ใช่ตารางใน DB — เพราะเป็นแบบฟอร์มราชการที่แทบไม่เปลี่ยนโครงสร้าง ถ้าในอนาคตต้องการให้แก้ schema ผ่าน UI ได้ ค่อยย้ายเป็น DB-driven ทีหลัง (YAGNI)
|
||||
|
||||
## Data Model
|
||||
|
||||
```
|
||||
income_budget_report income_budget_report_line
|
||||
────────────────────── ──────────────────────────
|
||||
income_budget_report_uid (PK) income_budget_report_line_uid (PK)
|
||||
type_id (1-6, 10) income_budget_report_uid (FK)
|
||||
budget_year_uid node_key เช่น "pb-temp-old", "out1-invest-equip"
|
||||
faculty_uid, budget_location_uid source_type: personnel_detail | personnel_detail_2 |
|
||||
sector_name_th invest_asset | invest_construct |
|
||||
parent_uid ← report รอบก่อนหน้า (1→2→3, 4→5→6) request_budget_income | manual
|
||||
status_id source_uid (uid ต้นทาง, null = รายการพิมพ์เอง)
|
||||
+ audit fields มาตรฐาน (base_table) label_th (snapshot ชื่อรายการตอนดึงมา)
|
||||
amount ← แก้ไขได้ที่นี่เท่านั้น
|
||||
original_amount (ค่าตอนดึงมา, ไว้เทียบ/audit)
|
||||
sequence_no
|
||||
```
|
||||
|
||||
**การสร้าง/ต่อรอบ:**
|
||||
- **รอบแรก (type 1, 4):** backend query ร.2/4/5/6 ตาม `faculty_uid` + `budget_year_uid` (ต่อยอด `summary_expense` + `budget_expenditure_report_from_revenue_v3`) → generate `income_budget_report_line` ให้อัตโนมัติ, `amount = original_amount` = ค่าจากต้นทาง, จับคู่ `node_key` ตาม config ของ type นั้น
|
||||
- **ต่อรอบ (type 2,3 / 5,6):** **clone** header+lines จากรอบก่อนหน้าตรงๆ (ตามที่ยืนยัน) — ไม่ query ต้นทางซ้ำ เพราะรอบถัดไปคือ "แก้ต่อจากมติที่ประชุมรอบก่อน" ไม่ใช่ดึงข้อมูลสดใหม่ ถ้าต้นทาง (ร.2/4/5/6) ถูกแก้ไขหลังจาก snapshot ไปแล้ว **จะไม่ไหลย้อนกลับมาอัตโนมัติ** — เป็นการตัดสินใจโดยตั้งใจเพื่อความนิ่งของมติที่ประชุมแต่ละรอบ ต้องมี UI แจ้งผู้ใช้ให้ชัดเจน
|
||||
- **รายการที่ `source_uid = null`** (เช่น "อัตราใหม่" ที่ยังไม่มีใน ร.2 จริง, หรือช่องกรอกอิสระ "ระบุ..." ในกลุ่ม B) → แก้ไขได้ทั้งชื่อและจำนวนเงิน
|
||||
- **รายการที่มี `source_uid`** → ชื่อ/ตำแหน่ง/จำนวนอัตรา **ล็อกเป็น read-only** แก้ได้แค่ `amount`
|
||||
|
||||
กลุ่ม C (type 10) โครงสร้างเป็น matrix ไม่ใช่ tree — ใช้ตารางแยก `material_cost_report` เรียบง่ายกว่า ไม่ผูกกับ schema ข้างต้น (รายละเอียดโครงสร้างกลุ่ม C จะออกแบบในรอบถัดไปเมื่อเริ่มลงมือ เพราะเป็น sub-project ที่ independent จาก A/B)
|
||||
|
||||
## Backend API (เพิ่มใหม่)
|
||||
|
||||
ต่อยอด pattern `BaseUidController<T,V>` มาตรฐานของระบบ:
|
||||
|
||||
```
|
||||
GET /api/setting/income_budget_report/{uid} → โหลด report + lines (BaseUidController มาตรฐาน)
|
||||
POST /api/setting/income_budget_report/create_draft → สร้างรอบแรก (type 1,4): query ร.2/4/5/6
|
||||
ตาม faculty+ปี → generate lines
|
||||
POST /api/setting/income_budget_report/{uid}/clone_next_round → สร้างรอบถัดไป (type 2,3,5,6): clone
|
||||
header+lines จาก uid เดิม, type_id+1
|
||||
PUT /api/setting/income_budget_report/lines → บันทึกจำนวนเงินที่แก้ (batch update amount)
|
||||
GET /api/setting/income_budget_report/{uid}/export/xlsx → generate .xlsx ตอนดาวน์โหลด (EPPlus/
|
||||
ClosedXML จาก tree config + ข้อมูลจริงใน DB)
|
||||
```
|
||||
|
||||
`IncomeBudgetReportRollupService` (C# ใหม่) เดินตาม tree config เดียวกับฝั่ง frontend สรุปยอดแต่ละ node จาก children — ใช้ทั้งตอน export Excel และตอนส่งข้อมูลกลับให้ Angular แสดง แทนที่ logic เดิมที่ไล่หาสีเซลล์ (`Personnel.cs:6089-6363`, endpoint `/report/personnel/calcurate`) ทั้งหมด
|
||||
|
||||
## Frontend Component Structure
|
||||
|
||||
```
|
||||
shared/components/budget-report-tree/ generic recursive tree (ต่อยอดจาก mockup ที่อนุมัติแล้ว)
|
||||
budget-report-tree-node.component.ts รับ @Input node config + data, render เอง + เรียกตัวเองซ้ำ
|
||||
คำนวณ sum ฝั่ง client แบบ real-time เหมือน mockup
|
||||
|
||||
feature/income/report-config/ config คงที่ต่อ type (TypeScript const)
|
||||
income-report-type1.config.ts tree schema กลุ่ม A
|
||||
income-report-type4.config.ts tree schema กลุ่ม B (5 sub-tree ตามผลผลิต)
|
||||
|
||||
feature/income/draft/income-budget-report1/ แทนที่เนื้อในของ component เดิมทั้งไฟล์
|
||||
income-budget-report.component.ts โหลด config ตาม type_id (route data), โหลด/สร้าง/clone
|
||||
report, bind เข้า budget-report-tree, ปุ่มบันทึก/ส่งออก
|
||||
(กลุ่ม B ครอบด้วย mat-tab-group 5 แท็บตามผลผลิต)
|
||||
|
||||
feature/income/material-cost-report/ กลุ่ม C แยกต่างหาก (matrix component ใหม่ทั้งหมด)
|
||||
```
|
||||
|
||||
Routing เดิมไม่เปลี่ยน — component เดิมยังถูกเรียกจากทั้ง 7 เมนูเหมือนเดิม แค่เนื้อในเปลี่ยนจาก SyncFusion เป็น tree component (กลุ่ม A/B) หรือ matrix component ใหม่ (กลุ่ม C, `educational-materials` type=10)
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. เปิดหน้า `/add` → เช็คว่ามี report ของ หน่วยงาน+ปี+type นี้อยู่แล้วหรือยัง ถ้ายัง → กด "สร้างรายงาน" → `create_draft`
|
||||
2. แก้จำนวนเงินที่ปรับได้ → คำนวณยอดรวมสดฝั่ง client (เหมือน mockup) → กด "บันทึก" → `PUT lines` แบบ batch
|
||||
3. ประชุมรอบนี้เสร็จ → กด "ส่งต่อรอบถัดไป" → `clone_next_round` → เปิดหน้า type ถัดไปพร้อมข้อมูล clone มาแล้ว
|
||||
4. กด "ส่งออก Excel" ได้ทุกจุด → generate ไฟล์ตามข้อมูล ณ ขณะนั้น
|
||||
|
||||
## Error Handling
|
||||
|
||||
- ยังไม่มีข้อมูลจาก ร.2/4/5/6 เลย → โชว์ tree เปล่าพร้อมข้อความ "ยังไม่มีข้อมูลจาก ร.2 กรุณากรอกฟอร์มก่อน" ลิงก์ไปหน้านั้น
|
||||
- กัน "ส่งต่อรอบถัดไป" ซ้ำซ้อน — เช็คว่ามี report ที่ `parent_uid` ชี้มาที่ตัวปัจจุบันอยู่แล้วหรือยังก่อนอนุญาตให้ clone อีกครั้ง
|
||||
- แก้ ร.2/4/5/6 ต้นทางหลัง snapshot ไปแล้ว ไม่ไหลย้อนกลับอัตโนมัติ (ดูหัวข้อ Data Model) — ต้องมี UI แจ้งชัดเจน
|
||||
|
||||
## Testing
|
||||
|
||||
ระบบนี้ไม่มี automated test (ตรวจสอบด้วยมือทั้งระบบตาม convention เดิม) — verify โดยเทียบยอดรวมกับไฟล์ Excel จริงที่ดาวน์โหลดมาตรวจสอบแล้วระหว่างขั้นตอนออกแบบ (มี type 1,2,3,4,5,10 อยู่ในมือ) ให้ตัวเลขตรงกันทุก node ก่อนถือว่าใช้ได้
|
||||
|
||||
## Rollout
|
||||
|
||||
ระบบ production ที่ใช้งานจริงกับการประชุมจริงของมหาวิทยาลัย — deploy เป็นฟีเจอร์คู่ขนาน ไม่ทับของเดิมทันที: ทดสอบกับ 1 หน่วยงานจริงก่อน 1 รอบประชุม เทียบผลกับของเดิมให้ตรงกัน ก่อนค่อยเปิดใช้แทนของเดิมทั้งระบบ
|
||||
|
||||
## จุดที่ยังไม่ชัด / ต้องตรวจเพิ่มตอน implementation
|
||||
|
||||
- **ร.7**: หาไม่เจอในระบบเลยทั้ง frontend/backend ไม่ทราบว่าเคยมีหรือถูกยุบรวมกับฟอร์มอื่น — ไม่กระทบ scope นี้เพราะไม่มีข้อมูลอ้างอิงในไฟล์ Excel เดิมที่ตรวจแล้ว
|
||||
- `budget_progress_id == 2` ใน `GetSummaryExpense` (`BudgetProgress.cs:419`) ความหมายจริงยังไม่ยืนยัน (draft/submitted?) กระทบว่าจะดึงข้อมูลสถานะไหนมาแสดง
|
||||
- `request_budget_income` ใน `GetSummaryExpense` ไม่ได้ filter `type_id` (ต่างจาก `Request.cs:651` ที่ filter `type_id == 1`) — อาจดึงมาทั้ง "งบเงินอุดหนุน" และ "งบรายจ่ายอื่นๆ" ปนกัน ต้อง filter เพิ่มถ้าต้องการเฉพาะ ร.6
|
||||
- โครงสร้าง tree schema ของ type 2,3,5,6 (chain ต่อจาก 1,4) ยังไม่ได้ตรวจไฟล์จริงเทียบเท่า type 1,4 — type 3 ยืนยันแล้วว่าตรงกับ type 1/2, type 5 ยืนยันแล้วว่าตรงกับ type 4, **type 6 ไม่มีข้อมูลจริงในระบบให้ตรวจ** อนุมานจาก chain เท่านั้น ต้องระวังตอน implement
|
||||
- กลุ่ม C (type 10) ยังไม่ได้ออกแบบ data model โดยละเอียด (matrix by campus) — เป็น sub-project แยกที่จะ spec เพิ่มเมื่อเริ่มลงมือ
|
||||
@@ -0,0 +1,87 @@
|
||||
# ส่งอนุมัติเพิ่มเติม (Additional Approval Round) — Design
|
||||
|
||||
## บริบท
|
||||
|
||||
หน้า "จัดทำคำของบประมาณแผ่นดิน" (`/app/request-budget`, `request-budget-list.component.ts/html` + `request-budget.container.ts` ใน `rmutr-web`) ให้ผู้ใช้บันทึกคำขอ (ง.3/ง.5/ง.อื่นๆ) ตามปีงบประมาณ+หน่วยงาน+พื้นที่ แล้วกด "ส่งอนุมัติ" เข้าสายอนุมัติ (`t_approve_state`, request_approve_type_code = "03") จนครบทุกขั้นถึงกองแผน
|
||||
|
||||
**ช่องว่างปัจจุบัน**: `t_request_expense` มี 1 record ต่อ (ปีงบ+หน่วยงาน+พื้นที่) เท่านั้นตลอดไป ระบบสร้างให้ครั้งแรกครั้งเดียว (`RequestExpenseService.NewEntity`, `Modules/Requests/Services/RequestExpense.cs:111-156`) แล้วดึง record เดิมมาใช้ซ้ำเสมอ เมื่ออนุมัติครบทุกขั้น (`request_expense.is_approve = true`) ปุ่ม "ส่งอนุมัติ" ฝั่ง frontend จะหายไปถาวร (`hasActiveStep(1)` เป็น false เพราะไม่มีแถวไหนใน `approve_states` ที่ `is_state==true` อีกแล้ว) — ถ้าผู้ใช้ต้องการบันทึกคำขอเพิ่มในปีงบ/หน่วยงาน/พื้นที่เดิมที่อนุมัติไปแล้ว **ไม่มีทางส่งรายการใหม่เข้าสายอนุมัติได้เลย** รายการใหม่จะถูกรวมเข้าไปในสรุปยอดเงียบๆ โดยไม่มีใครต้องอนุมัติมันเพิ่ม (ตรวจสอบโค้ดแล้วยืนยันว่าไม่มี hook เชื่อมระหว่าง flow "เพิ่มรายการคำขอ" กับ `request_expense`/`approve_state` เลย)
|
||||
|
||||
งานนี้ต่อเนื่องจากการแก้บั๊กหลายอย่างก่อนหน้าในเซสชันเดียวกัน (สีตัวหนังสือการ์ด, เงื่อนไขสิทธิ์ปุ่มส่งอนุมัติ, อ่านสถานะจาก `approve_states[]` แทน field top-level ที่เป็น null เสมอ, ข้อความบอกผู้อนุมัติคนถัดไป, ล่าสุดคือแก้ backend `ApproveState.cs` ให้เช็ค `area_uid` แทนสิทธิ์ `SEND-APPROVE` — deploy ไปแล้วก่อนเริ่มงานนี้) — ใช้ record จริงของ "คณะวิทยาศาสตร์และเทคโนโลยี / ปีงบ 2569 / ศาลายา" เป็นเคสอ้างอิงตลอดการออกแบบ
|
||||
|
||||
## ขอบเขต
|
||||
|
||||
- **อยู่ในขอบเขต**: เฉพาะ flow "จัดทำคำของบประมาณแผ่นดิน" (request_approve_type_code = "03") ที่หน้า request-budget เท่านั้น — คือ `t_request_expense` + `t_approve_state` (filtered by `request_expense_uid`) + `t_remark_history` (filtered by `request_expense_uid`)
|
||||
- **ไม่แตะ**: `invest_asset_request`/`invest_construct_request` ที่มี `approve_state` ของตัวเอง (`InvestAssetRequest.cs:884`, `InvestConstructRequest.cs:520`) — เป็นคนละหน้า คนละ flow ธุรกิจ ถึงจะใช้ตาราง `t_approve_state` ร่วมกัน (polymorphic) แต่ scope งานนี้จำกัดเฉพาะแถวที่ `request_expense_uid != null`
|
||||
- อนุญาตให้ส่ง "อนุมัติเพิ่มเติม" ได้ **ไม่จำกัดจำนวนรอบ** ต่อปีงบ/หน่วยงาน/พื้นที่เดียว (รอบ 2, 3, 4, ... ได้เรื่อยๆ)
|
||||
- Migration apply ขึ้น production อัตโนมัติตอน backend เริ่มทำงาน (ดูหัวข้อ "Migration" ด้านล่าง) — ไม่ต้องรันคำสั่งแยกเอง
|
||||
|
||||
> **แก้ไขจากดราฟต์แรก**: ตอนแรกคิดว่า approve/reject เดิม (`ApproveState.cs:132-169`, `298-343`) ไม่ต้องแก้เลย เพราะคิดว่า query `is_state==true` จะ match แค่แถวของรอบปัจจุบันเสมอ — **ข้อสรุปนี้ผิด** เมื่อตรวจโค้ดละเอียดพบว่าอีก query หนึ่งในทั้งสอง method (`c.row_order == getResult.row_order + 1` ตอน approve, และ `OrderBy(row_order)` + `FindIndex` ตอน reject) **ไม่ได้กรองด้วย `is_state` เลย** — พอมี 2 รอบที่มี `row_order` ซ้ำกัน (เช่นรอบ 1 กับรอบ 2 ต่างก็มี row_order 1,2) จะเจอแถวปนกันได้ (`FirstOrDefaultAsync`/`OrderBy` ไม่มี tie-breaker ระหว่างรอบ) เสี่ยงไปแก้แถวรอบเก่าที่อนุมัติไปแล้วซ้ำ ดังนั้น **approve/reject ต้องแก้ให้กรองด้วย `round_no` ปัจจุบันด้วย** (รายละเอียดในหัวข้อ Backend ด้านล่าง)
|
||||
|
||||
## Data Model
|
||||
|
||||
เพิ่มคอลัมน์เดียวกัน 2 ตาราง (EF Core migration, `dotnet ef migrations add`):
|
||||
|
||||
| ตาราง | คอลัมน์ | ชนิด | default |
|
||||
|---|---|---|---|
|
||||
| `t_approve_state` | `round_no` | `int not null` | `1` |
|
||||
| `t_remark_history` | `round_no` | `int not null` | `1` |
|
||||
|
||||
Record เดิมทั้งหมดได้ `round_no=1` อัตโนมัติจาก default value — ไม่กระทบข้อมูลเก่า ไม่ต้อง backfill เพิ่ม
|
||||
|
||||
**ไม่เพิ่มคอลัมน์ใหม่ที่ `t_request_expense`** — คำนวณ "รอบปัจจุบัน" จาก `MAX(round_no)` ของ `approve_states` ที่ query มาแทน เพื่อเลี่ยงปัญหาค่าไม่ sync กันระหว่าง denormalized field กับข้อมูลจริง
|
||||
|
||||
## Backend (`rmutr-api`)
|
||||
|
||||
### แก้ query ของ approve/reject เดิมให้ round-aware (`Modules/Setting/Controllers/ApproveState.cs`)
|
||||
|
||||
เฉพาะ branch `request_expense != null` ใน `ApproveGetEntity` (บรรทัด 132-169) และ `RejectGetEntity` (บรรทัด 298-343) — เพิ่มการหา `currentRound = MAX(round_no)` ของ `request_expense_uid` นั้นก่อน แล้วกรองทุก query ที่แตะ `t_approve_state` ในสอง branch นี้ด้วย `round_no == currentRound` เพิ่มจากเดิม (ทั้ง `FirstOrDefaultAsync(is_state==true)`, `Where(is_approve != true)`, `FirstOrDefaultAsync(row_order == getResult.row_order + 1)` ฝั่ง approve; และ `Where(...).OrderBy(row_order)` ที่ดึงมาทำ `FindIndex` ฝั่ง reject) branch `invest_asset`/`invest_construct` **ไม่ต้องแตะ** (อยู่นอกขอบเขต, `round_no` จะเป็น 1 เสมอสำหรับสองอันนี้)
|
||||
|
||||
### Endpoint ใหม่: resubmit
|
||||
|
||||
`GET /api/setting/approve_state/resubmit/{request_expense_uid}` เพิ่มใน `ApproveStateController` (ตั้งชื่อ/ตำแหน่งตามแบบ `approve`/`reject` เดิม)
|
||||
|
||||
Logic:
|
||||
1. โหลด `t_request_expense` ด้วย uid — ถ้าไม่พบ 404
|
||||
2. เช็ค `request_expense.is_approve == true` (อนุมัติครบทุกขั้นของรอบล่าสุดแล้วจริง) — ถ้าไม่ใช่ ตอบ 400 พร้อมข้อความ error ชัดเจน (เช่น "คำขอนี้ยังไม่ได้รับการอนุมัติครบทุกขั้น ไม่สามารถส่งอนุมัติเพิ่มเติมได้")
|
||||
3. หา `currentMaxRound = MAX(round_no)` จาก `t_approve_state where request_expense_uid == uid` (ถ้าไม่มีแถวเลยถือเป็น 0 กันพลาด แม้ในทางปฏิบัติต้องมีอยู่แล้วจาก NewEntity เดิม)
|
||||
4. reset `request_expense.is_approve = null` แล้ว save
|
||||
5. เรียก `ApproveStateService.getApproveState(...)` (`Modules/Setting/Services/ApproveState.cs:26-106`) — ต้องเพิ่มพารามิเตอร์ `int roundNo = 1` ให้ method นี้ (default 1 ไม่กระทบ caller เดิมคือ `RequestExpenseService.NewEntity`) โดย resubmit endpoint เรียกด้วย `roundNo: currentMaxRound + 1` เพื่อ clone `request_approve_type_states` (config ล่าสุดจากแอดมิน หน้า approve-menu) เป็น `t_approve_state` ชุดใหม่ติด round_no ใหม่ (แถวเก่าของรอบก่อนหน้าไม่ถูกแก้ ยังอยู่ครบเป็นประวัติ) แถวแรก (`row_order==1`) ของรอบใหม่ set `is_state=true`
|
||||
6. บันทึกและ return record ที่อัปเดตแล้ว (เหมือน approve/reject เดิม)
|
||||
|
||||
**เหตุผลที่เลือกใช้ config ล่าสุดจากแอดมินเสมอ** (ไม่ clone จากรอบก่อนหน้า): ถ้าแอดมินเคยแก้สายอนุมัติหลังจากรอบแรกอนุมัติไปแล้ว รอบใหม่ควรใช้สายอนุมัติที่ถูกต้องล่าสุด ไม่ใช่สายอนุมัติเก่าที่อาจไม่ถูกต้องแล้ว (เช่น คนย้ายตำแหน่ง)
|
||||
|
||||
### `remark_historys` ใหม่ — stamp round_no ฝั่ง frontend ไม่ต้องแก้ backend
|
||||
|
||||
ตรวจสอบแล้ว PUT `t_request_expense` (`BaseUidController<t_request_expense,v_request_expense>.UpdateEntityId` → `EntityUidService.UpdateEntity`, ไม่ได้ override ใน `RequestExpenseController`) cascade-insert แถวใหม่ใน `remark_historys` ให้อัตโนมัติอยู่แล้วจาก payload ที่ส่งมา (EF Core `Update()` เดา insert/update จาก key ที่เป็นค่า default หรือไม่) — **ไม่ต้องแก้ backend ส่วนนี้เลย** แค่ให้ frontend set `round_no` บน object ที่ push เข้า `remark_historys` array ก่อน (ดูหัวข้อ Frontend ข้อ 5)
|
||||
|
||||
### Migration
|
||||
|
||||
เพิ่ม `round_no int not null default 1` ที่ `approve_state` และ `remark_history` model classes (`Modules/Setting/Databases/Models/approve_state.cs`, `Modules/Requests/Databases/Models/remark_history.cs`) แล้วรัน `dotnet ef migrations add AddRoundNoToApproveStateAndRemarkHistory` — backend project นี้ apply migration **อัตโนมัติตอน startup** ผ่าน `dbContext.Database.Migrate()` (`Startup.cs:444-445`) ดังนั้นไม่ต้องรันคำสั่ง `dotnet ef database update` แยกเอง แค่ deploy binary ใหม่ (`script.sh`) แล้ว service restart จะ apply migration ให้เอง — **ต้อง backup database ก่อน deploy รอบนี้เสมอ** เพราะเป็นการ apply schema change ขึ้น production โดยอัตโนมัติทันทีที่ restart
|
||||
|
||||
## Frontend (`rmutr-web`)
|
||||
|
||||
ไฟล์หลัก: `request-budget-list.component.ts/html`, `request-budget.container.ts`, `request-expense.service.ts`
|
||||
|
||||
1. **`RequestExpenseService`**: เพิ่ม `resubmit(id)` เรียก `GET .../approve_state/resubmit/{id}` (มิเรอร์ `approve()`/`reject()` ที่มีอยู่แล้ว)
|
||||
2. **`currentRound()`**: helper ใหม่ใน `request-budget-list.component.ts` — `Math.max(1, ...(approve_states?.map(s => s.round_no ?? 1) ?? [1]))`
|
||||
3. **`hasActiveStep()`/`approveStatus()` ต้องกรองตามรอบปัจจุบันด้วย**: ทั้งสอง method ที่มีอยู่แล้วต้องกรอง `approve_states` ด้วย `(s.round_no ?? 1) === this.currentRound()` ก่อนค้นหา ไม่งั้นรอบเก่าจะมาปนกับรอบใหม่ (`approveStatus()` โดยเฉพาะใช้ `.find()` ซึ่งจะเจอ entry ของรอบเก่าก่อนเสมอถ้าไม่กรอง)
|
||||
4. **ปุ่มใหม่ "ส่งอนุมัติเพิ่มเติม"**: โชว์เมื่อ `approve_button?.is_approve === true` (คนละเงื่อนไขกับปุ่ม "ส่งอนุมัติ" เดิมที่ใช้ `hasActiveStep(1)`) ใช้ confirm dialog + โชว์ชื่อผู้อนุมัติคนถัดไปแบบเดียวกับปุ่ม "ส่งอนุมัติ" เดิมที่เพิ่งทำ (`nextApproverLabel()` ใน container ใช้ต่อได้เลย เพราะหาแถวที่ `is_state===true` เหมือนกัน)
|
||||
5. **Badge บอกรอบ**: ต่อท้ายข้อความ "สถานะ" ที่มีอยู่แล้ว (`request-budget-list.component.html:28-29`) เช่น *"สถานะ : อนุมัติแล้ว (รอบปกติ)"* / *"สถานะ : รออนุมัติ (รอบเพิ่มเติมที่ 2)"* — รอบ 1 = "รอบปกติ", รอบ ≥2 = "รอบเพิ่มเติมที่ N" (ใช้ `currentRound()` จากข้อ 2)
|
||||
6. **กรอง `remark_historys` ตามรอบปัจจุบัน**: การ์ด "รายละเอียดสำหรับการส่งแก้ไข" (แก้เงื่อนไข visibility ไปแล้วในรอบก่อนหน้าให้โชว์เมื่อมี remark) ต้องกรองเฉพาะ `remark_historys` ที่ `(round_no ?? 1) === currentRound()` ก่อนเช็ค `.length > 0` และก่อนแสดงในตาราง — กันคอมเมนต์เก่าจากรอบก่อนมาปนกับรอบใหม่ที่เพิ่งเริ่ม
|
||||
7. **`resole()` stamp round_no**: ตอน push remark ใหม่เข้า `approve_button.remark_historys` ให้ set `round_no: this.currentRound()` บน object ใหม่ด้วย ไม่งั้นค่าจะเป็น `undefined` จนกว่าจะโหลดข้อมูลใหม่จาก backend (ซึ่ง backend จะไม่ได้ stamp เพิ่มเพราะไม่แตะ endpoint นี้ — ต้องพึ่ง frontend ส่งค่ามาให้ถูกตั้งแต่แรก)
|
||||
|
||||
## Error Handling
|
||||
|
||||
- กด "ส่งอนุมัติเพิ่มเติม" ตอนยังไม่ครบรอบปัจจุบัน (เผื่อ race condition/refresh ช้า) → backend ตอบ 400 → frontend โชว์ error message เดิมที่มี pattern อยู่แล้ว (`catchError` + `Swal.fire(err.error.description, '', 'error')`)
|
||||
- Migration: backup DB (`pg_dump`/เทียบเท่าตาม engine จริง) ก่อน apply ทุกครั้ง โดยเฉพาะรอบนี้ที่ apply ขึ้น production ตรงๆ
|
||||
|
||||
## Testing Plan
|
||||
|
||||
- **Backend**: unit test endpoint `resubmit` ทั้ง 2 กรณี (ยังไม่ครบรอบ → 400, ครบแล้ว → สร้างรอบใหม่ถูกต้อง + `round_no` เพิ่มขึ้นถูกต้อง), integration test approve/reject เดิมว่ายังทำงานถูกต้องหลัง migration (regression)
|
||||
- **Frontend**: build ตรวจ compile ผ่านตามปกติ (ไม่มี test suite ของหน้านี้อยู่ก่อนแล้ว)
|
||||
- **Smoke test บน production**: ใช้ record จริงของคณะวิทยาศาสตร์และเทคโนโลยี/ปีงบ 2569/ศาลายา ที่ใช้อ้างอิงมาตลอดเซสชันนี้ — เดินสายอนุมัติให้ครบรอบ 1 (ถ้ายังไม่ครบ) แล้วลองกด "ส่งอนุมัติเพิ่มเติม" เช็ค badge/การ์ด/ปุ่มเปลี่ยนถูกต้อง
|
||||
|
||||
## ลำดับการ Deploy
|
||||
|
||||
1. Backend: migration + endpoint ใหม่ ขึ้น production ก่อน (ต้องมี endpoint พร้อมก่อน ไม่งั้นปุ่มใหม่ฝั่ง frontend จะเรียก endpoint ที่ยังไม่มีอยู่)
|
||||
2. Frontend: build + deploy ตามหลัง (สคริปต์ `script.sh` เดิมที่ใช้มาตลอดเซสชันนี้)
|
||||
Reference in New Issue
Block a user