eCourt-Doc-Edit/docs/plans/web-portal-redesign.md
Suphonchai Phoonsawat 7b20f12ede Phase 9: version message (FR-13)
- ใส่คำอธิบายแบบ commit message ได้ทุก flow ที่สร้าง version (Save & Version / Start Versioning / generate จากเว็บ) + แสดงใน version history ทุกหน้า
- backend: DocumentVersion.Message (varchar 2048) + migration AddVersionMessage + BadRequest ตรวจความยาวก่อนแตะ MinIO (กัน 500 + orphan object)
- add-in: ConfirmVersionDialog เพิ่ม textarea (Start Versioning ใช้ dialog ด้วย) + แสดง message ใน VersionHistoryPanel
- web: ฟอร์มสร้างเอกสาร + หน้า detail แสดง message (desktop truncate + tooltip, mobile เต็มบรรทัด)
- sync เอกสารทั้งหมด: spec v5 (FR-13/AC-16) + plan Phase 9 + acceptance-review 1–16 + README + CLAUDE.md + web/README
- tests: backend 52 / addin 50 / web 36 — เขียวทั้งชุด

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 22:05:49 +07:00

194 lines
20 KiB
Markdown

# Re-skin + Layout Modernization ของ Web Portal (design system จาก admincourt.go.th)
## Context
Web portal (`/web` — Nuxt 4 + Tailwind v3) ปัจจุบันใช้ default palette ตรงๆ ใน markup (~400 จุด: slate/sky/red/emerald/amber) กระจายใน 12 ไฟล์ .vue โดยไม่มี design token layer (`tailwind.config.ts` มีแค่ `darkMode: 'class''`, ไม่มีฟอนต์ไทย — ใช้ system font) และ layout เป็นแบบเก่า: header ไม่ sticky, container แคบ, ตารางทุกหน้ากินพื้นที่ overflow บนมือถือ, ไม่มีรูปแบบ page header ที่ consistent
งานนี้ทำ 2 อย่างพร้อมกัน:
1. **Re-skin** ตาม scheme ของ admincourt.go.th: primary `#0000EE`, accent gold `#8F7226`, surface ขาว, elevated ครีม `#FBF7EC`, radius 8px, ปุ่ม pill, shadow นุ่ม, ฟอนต์ sukhumvit — ผ่านการ**สร้าง token layer ที่เหมาะสม** (ไม่ใช่ find-replace สี)
2. **Layout modernization + responsive**: sticky header, รูปแบบ page header มาตรฐาน, ตารางเปลี่ยนเป็น card list บนจอเล็ก (responsive table pattern สมัยใหม่), ฟอร์มจัด width และ action row ตามแนวปฏิบัติปัจจุบัน
**ข้อตกลงกับผู้ใช้ (fixed):**
1. **เก็บ dark mode ต่อ** — derive dark palette จาก scheme (ครีม → warm near-black, `#0000EE` → indigo สว่าง) ใช้ CSS variables + `.dark` ต่อจาก useTheme/FOUC guard เดิม, `useTheme.spec.ts` ต้องเขียว
2. **ฟอนต์ผ่าน Google Fonts CDN** — Sarabun (ใกล้ Sukhumvit ที่สุด) น้ำหนัก 400/500/600/700 + stack `'Sukhumvit Set', 'Sarabun', ...`
3. Scope = `/web` เท่านั้น ห้ามแตะ `/addin`, `/backend`
4. (เพิ่มจาก feedback) Layout ต้องเป็น modern standard + responsive เต็มรูปแบบ
## ขั้นตอนที่ 0 — บันทึกแผนลง repo
เขียนแผนฉบับนี้ไว้ที่ `docs/plans/web-portal-redesign.md` (ทำเป็น action แรกหลังอนุมัติแผน) เพื่อให้ session ถัดไปหยิบไป implement ต่อได้ — สอดคล้องกับธรรมเนียม `docs/plans/officejs-implementation-plan.md` ของ repo
## ส่วนที่ 1 — Token layer (re-skin)
### สถาปัตยกรรม
- **CSS custom properties** (`:root` + `.dark`) เป็น source of truth ของสี, expose ผ่าน `theme.extend.colors` ด้วย `rgb(var(--c-x) / <alpha-value>)` — `bg-primary/10` ฯลฯ ใช้ได้ และ **`dark:` variants ~209 จุดหายเกือบหมด** (สีพลิกผ่านตัวแปร)
- ⚠️ ตัวแปรต้องเป็น RGB triplet เว้นวรรค (`0 0 238`) — hex จะ break slash-opacity แบบเงียบ
- **`@layer components` + `@apply`** สำหรับ pattern ที่ repeat — ไม่สร้าง Vue component (ปุ่มเป็น mix ของ `<button>`/`<NuxtLink>`/ternary class, tests เป็น pure logic)
- ⚠️ Tailwind v3 ห้าม `@apply` custom class ในอีก custom class — compose ใน markup (`class="btn btn-primary"`)
- **ห้าม set root font-size 18px** — spacing เป็น rem จะเพี้ยนทั้ง scale; 18px เป็น typographic token (`text-body-lg`) เฉพาะ prose
### การ map scheme → ระบบจริง
| Scheme | การใช้ |
|---|---|
| Primary `#0000EE` | ปุ่ม primary, link, focus ring (light ใช้ `#2977FF` — `#6CB5F9` บนขาว contrast ~2:1, สลับตามโหมด) |
| Accent `#8F7226` | ใช้น้อย: badge "web portal", badge เตือน (แทน amber), accent bar หน้าหลัก |
| Elevated `#FBF7EC` | card, table, navbar (3 ชั้น: หน้าขาว → header/card ครีม → input ขาว inset) |
| Success/active | เขียว `#53A336` (action-green ของ scheme) — state ที่ user scan หา ต้องเป็นเขียว |
| Error | แดง `#DB3700` + `-strong` สำหรับ text บน tint (contrast AA) |
| Typography | home h1 = `text-display` 36px/700 · page h1 = `text-page-title` 28px/700 · section h2 = `text-section-title` 20px/700 · prose = `text-body-lg` 18px/1.5 · table/badge/ปุ่ม = 14/12px — 50px/155px เว้นตามกฎ "don't copy verbatim" |
| Radius | `rounded-card` 8px · `rounded-pill` 9999px · override `borderRadius.DEFAULT` → 8px |
| Shadow | `shadow-soft` = `0 4px 4px 0 rgba(0,0,0,.25)` + `shadow-soft-lg` (hover) |
| Spacing | Tailwind default scale = 4/8/12/16/20/24/32/40/60 อยู่แล้ว — ห้ามค่านอก scale |
### ขั้นตอน
**1. [tailwind.css](web/app/assets/css/tailwind.css)** — เพิ่ม vars หลัง 3 directives:
```css
:root {
color-scheme: light;
--c-primary: 0 0 238; --c-primary-hover: 0 0 190; --c-on-primary: 255 255 255;
--c-accent: 143 114 38;
--c-success: 83 163 54; --c-success-strong: 41 109 24;
--c-error: 219 55 0; --c-error-strong: 184 43 0;
--c-focus: 41 119 255;
--c-surface: 255 255 255; --c-elevated: 251 247 236; --c-surface-muted: 243 237 220;
--c-ink: 0 0 0; --c-muted: 78 78 78; --c-line: 204 206 209;
}
.dark {
color-scheme: dark;
--c-primary: 143 143 255; --c-primary-hover: 170 170 255; --c-on-primary: 23 20 15;
--c-accent: 210 179 106;
--c-success: 148 205 128; --c-success-strong: 154 214 134;
--c-error: 255 128 96; --c-error-strong: 255 157 126;
--c-focus: 108 181 249;
--c-surface: 23 20 15; --c-elevated: 36 31 22; --c-surface-muted: 44 38 27;
--c-ink: 247 243 233; --c-muted: 174 166 148; --c-line: 62 56 43;
}
```
**2. [tailwind.config.ts](web/tailwind.config.ts)** — `theme.extend`: colors ทั้ง 15 ตัวแบบ `rgb(var(--c-x) / <alpha-value>)` · `fontFamily.sans` = `['Sukhumvit Set', 'Sarabun', 'system-ui', '-apple-system', 'Segoe UI', 'sans-serif']` · `fontSize`: display/page-title/section-title/body-lg · `borderRadius`: DEFAULT 0.5rem, card 0.5rem, pill 9999px · `boxShadow`: soft, soft-lg — คง `darkMode: 'class'` + comment เดิม
**3. `@layer base` + `@layer components`** ใน tailwind.css:
- Base: `html { @apply font-sans antialiased }`, `body { @apply bg-surface text-ink }`
- Components: `.btn` (+`.btn-primary` `.btn-secondary` `.btn-outline-danger` `.btn-outline-success` `.btn-sm` `.btn-xs`) · `.input` `.label` · `.card` `.table-card` `.table-head` `.empty-state` · `.badge` (+success/neutral/accent) · `.spinner` `.link` `.nav-link` · `.alert` (+error/success/info) `.panel-info`
- `.btn-xs` define หลัง `.btn` ในไฟล์เดียวกัน → source order ชนะ padding
**4. [nuxt.config.ts](web/nuxt.config.ts)** — เพิ่มใน `app.head` (FOUC `script` **byte-identical**):
```ts
htmlAttrs: { lang: 'th' },
link: [
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
{ rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' },
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Sarabun:wght@400;500;600;700&display=swap' },
],
```
## ส่วนที่ 2 — Layout modernization + responsive
### Global shell — [app.vue](web/app/app.vue)
- **Sticky header** (modern standard): `sticky top-0 z-20 border-b border-line bg-elevated/95 backdrop-blur` (blur ผ่าน alpha var ได้อัตโนมัติ)
- **Container กว้างขึ้น**: header และ main ใช้ `mx-auto max-w-6xl` เดียวกัน (ตารางหายใจได้, ฟอร์มคุม width เองในระดับหน้า)
- **Responsive padding**: `px-4 py-3 sm:px-6` (header), `px-4 py-6 sm:px-6 sm:py-8` (main)
- **Navbar บนมือถือ**: คงกลไก flex-wrap (brand row + controls row ตอนจอแคบ) ปรับ `gap-x-4 gap-y-2`, brand `min-w-0 truncate`, badge "web portal" `hidden sm:inline-flex`
- **Skip link** (a11y มาตรฐานปัจจุบัน): `<a href="#main" class="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:rounded-pill focus:bg-primary focus:px-4 focus:py-2 focus:text-on-primary">ข้ามไปยังเนื้อหา</a>` + `<main id="main">`
### Page header pattern (ทุกหน้าใช้เหมือนกัน)
```html
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0">
<h1 class="text-page-title">…</h1>
<p class="mt-1 text-sm text-muted">…</p>
</div>
<div class="flex shrink-0 flex-wrap items-center gap-2">…actions…</div>
</div>
```
มือถือ: title กับ actions stack กัน, actions ไม่บีบเต็มความกว้าง · Desktop: actions ชิดขวา
### Responsive table pattern (Tailwind UI "stacked on mobile" — ทุกตาราง)
Desktop เป็นตารางปกติ (`hidden md:block table-card`) · ต่ำกว่า md เป็น card list (`md:hidden space-y-3`):
```html
<ul class="space-y-3 md:hidden">
<li v-for="t in templates" :key="t.id" class="card space-y-3 p-4">
<!-- ชื่อ/หัวข้อ (link font-semibold) + badge, description (line-clamp-2),
meta text-xs text-muted, action row: flex flex-wrap gap-2 (btn-xs) -->
</li>
</ul>
<div class="table-card hidden md:block"><table>…เดิม…</table></div>
```
ใช้กับ: templates index · template documents · document fields · version history (card มี download + expand toggle — ใช้ `expandedVersionId` state เดิม, expand แล้วโชว์ dl เดิมใน `bg-surface-muted rounded-card p-3`) · detected-fields ใน new · **field-definitions editor ใน edit** (card = key mono + inputs stack, v-model เดิม render สองที่ — Vue รองรับ)
### Form pattern
- ฟอร์มคุม width: `max-w-xl` (new/edit metadata) · `max-w-2xl` (create document)
- Action row มาตรฐานปัจจุบัน: `mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end` (primary ขวา, มือถือ primary อยู่ล่างเต็มความกว้าง `w-full sm:w-auto` บนปุ่ม)
- Login card ใน AuthGate: `mx-auto max-w-md` กึ่งกลาง
### Home
- Grid: `grid gap-4 sm:grid-cols-2 lg:grid-cols-3` (เพิ่ม lg จากเดิมที่มีแค่ sm:2)
- Card: footer ใช้ `mt-auto border-t border-line pt-3` ให้สูงเท่ากันทุกใบ
- หัวหน้า: `text-display` + intro `text-body-lg text-muted` + accent bar `h-1 w-10 rounded-pill bg-accent` (decorative accent จุดเดียวของหน้า)
### Breakpoints ที่ใช้ (Tailwind defaults)
`sm` 640 (grid 2 คอลัมน์, form row เป็นแนวนอน) · `md` 768 (table↔card, page header แนวนอน) · `lg` 1024 (grid 3 คอลัมน์)
## ส่วนที่ 3 — แก้ 12 ไฟล์ .vue (token + layout พร้อมกัน)
กฎร่วม: เปลี่ยน palette → token/component class, **ลบคู่ `dark:` ที่ตัวแปรรับแล้ว**, ห้ามแต่ structure/`v-if`/handler/`aria-*`/functional utility (`truncate`, `break-all`, `line-clamp-2`, `whitespace-nowrap`)
1. [app.vue](web/app/app.vue) — shell/page header ตามส่วนที่ 2; nav → `nav-link`; toggle/logout → `btn btn-secondary btn-sm`; login → `btn btn-primary btn-sm`; span "web portal" → `badge badge-accent`
2. [AlertBox.vue](web/app/components/AlertBox.vue) — computed คืน `'alert-error'|'alert-success'|'alert-info'`, template `class="alert"` + `:class`; props/emoji/`role` คงเดิม
3. [AuthGate.vue](web/app/components/AuthGate.vue) — `spinner`; login card → `card mx-auto max-w-md p-8 text-center` + `text-section-title` + prose `text-body-lg text-muted`; ปุ่ม `w-full sm:w-auto`
4. [index.vue](web/app/pages/index.vue) — หัวหน้า + grid + card ตามส่วนที่ 2; link ในการ์ด → `link`
5. [auth/callback.vue](web/app/pages/auth/callback.vue) — `spinner`; retry → `btn btn-primary`, back → `btn btn-secondary` (จัด `flex flex-col-reverse gap-2 sm:flex-row`)
6. [templates/index.vue](web/app/pages/templates/index.vue) — page header pattern (ปุ่มสร้าง → `btn btn-primary`); ตาราง dual-render ตาม pattern; badge → `badge badge-success`/`badge badge-neutral`; ternary activate/deactivate → `btn btn-xs btn-outline-danger`/`btn-outline-success`; checkbox เพิ่ม `accent-primary`
7. [templates/new.vue](web/app/pages/templates/new.vue) — ฟอร์ม `max-w-xl` + action row pattern; `label`/`input`; file input → `input` + `file:mr-3 file:rounded-pill file:border-0 file:bg-surface-muted file:px-3 file:py-1` (ลบ `dark:file:*`); detected-fields dual-render; ดาวแดง → `text-error-strong`
8. [templates/[id]/edit.vue](web/app/pages/templates/[id]/edit.vue) — page header + back `btn btn-secondary btn-sm`; section h2 → `text-section-title`; cell editor → `class="input px-2 py-1 …"`; field-definitions dual-render (มือถือ card ต่อ field, v-model เดิม); "บันทึกแล้วเมื่อ" → `text-success-strong`; metadata form `max-w-xl`
9. [templates/[id]/documents.vue](web/app/pages/templates/[id]/documents.vue) — page header + ตาราง dual-render ตาม pattern
10. [documents/create/[templateId].vue](web/app/pages/documents/create/[templateId].vue) — ฟอร์ม `max-w-2xl` + action row; result card → `card p-6` + download `btn btn-primary`; panel "ขั้นตอนต่อไป" → `panel-info`; invalid → `:class="issues[f.key] ? 'border-error' : ''"`; validation msg → `text-xs text-error-strong`
11. [documents/[documentId].vue](web/app/pages/documents/[documentId].vue) — page header pattern (ปุ่ม re-generate อยู่ actions ฝั่งขวา); ตาราง dual-render ทั้ง fields และ version history (expand ใน card ใช้ state เดิม); badge amber ทั้งสอง → `badge badge-accent`; hover แถว → `hover:bg-surface-muted/60`; expanded → `bg-surface-muted`
12. (ครบ 12: app.vue, AlertBox, AuthGate, index, callback, templates index/new/edit/documents, documents create/detail)
### ห้ามแตะ
- [useTheme.ts](web/app/composables/useTheme.ts) + FOUC script — byte-identical (STORAGE_KEY `'theme-preference'` sync)
- logic ทั้งหมด (useAuth/useApi, page scripts, window.confirm, navigation), `test/setup.ts`, test 3 ไฟล์ (verified: ไม่มี class assertion นอกจาก `classList.contains('dark')`, vitest ไม่ import tailwind.css)
- `devServer.port: 5173`, `runtimeConfig`, `/addin`, `/backend`
- ไม่ต้องแก้ spec/CLAUDE.md — re-skin + layout ล้วน, requirement ไม่เปลี่ยน
## Verification
1. Baseline `npm test` ใน `web/` ก่อนเริ่ม (3 files เขียว)
2. หลัง token + config + fonts: `npm run dev` → http://localhost:5173 — ยืนยัน Sarabun โหลด, ไม่มี unstyled flash (แก้ tailwind.config.ts แล้วต้อง restart dev server)
3. เดินทุก route ทั้ง 2 โหมด (toggle + hard reload ยืนยัน FOUC): `/`, `/templates`, `/templates/new`, `/templates/{id}/edit`, `/templates/{id}/documents`, `/documents/create/{id}` (รวม `?from=` + กรอกผิดดู invalid border), `/documents/{id}` (expand version), `/auth/callback` (error path)
4. **Responsive check**: DevTools device mode ที่ 375px และ 768px — header wrap ถูก, ตารางกลายเป็น card ต่ำกว่า md, ฟอร์ม/ปุ่ม stack ถูก, ไม่มี horizontal scroll ของหน้า (ยกเว้นใน table-card บน desktop)
5. `npm test` อีกครั้ง — เขียว
6. `npm run build` — fail ทันทีถ้า `@apply` class ที่ไม่มี
7. Audit grep (expect 0): `grep -rnE '(slate|sky|emerald|red|amber)-[0-9]' web/app` และ `grep -rn 'dark:' web/app`
8. เช็คตา: contrast ปุ่ม primary / text บน tint / dark mode ของ on-primary, sticky header ทับเนื้อหาถูกต้อง (z-20, anchor ไม่โดนบัง — `scroll-margin` ให้ section ถ้าจำเป็น)
## Risks / gotchas
- **RGB triplet เท่านั้น** ใน vars — hex break `/opacity` เงียบ (footgun อันดับ 1)
- **ห้าม `@apply` custom class ซ้อน** — compose ใน markup
- **Dual-render ตาราง**: markup ซ้ำสองชุดต่อตาราง — ต้อง sync v-for/v-model/state ให้ตรง (Vue รองรับ binding ซ้ำ, แต่ review ตอนแก้ต้องระวังลืมฝั่งใดฝั่งหนึ่ง)
- `file:` pseudo-button บน WebKit มี quirk — ตรวจตอน dev
- Dark ครีม→เทาอุ่น: saturation เกิน ~15% จะเลี่ยน — ค่าที่เลือก desaturate แล้ว; shadow มองไม่เห็นบนพื้นดำ (border token เป็นตัวแบ่ง elevation ใน dark)
- ตารางครีมที่ text-sm อาจแน่น — fallback บรรทัดเดียว: `table-card` → `bg-surface`
- ลำดับสำคัญ: tokens/config → component classes → .vue edits
## หมายเหตุจากการทำจริง (2026-08-20)
- ✅ **implement ครบทั้ง 12 ไฟล์ + verification ผ่านทั้งหมด** — web `npm test` 36/36 เขียวทั้งก่อน/หลัง · `npm run build` ผ่าน · smoke test production server ยืนยัน fonts link, `lang="th"`, sticky header + token classes, FOUC script ครบ · audit grep palette เดิม (`slate/sky/red/emerald/amber-*`) = **0 เหลือ** · `dark:` variants (เดิม 209 จุด) = **0 เหลือ**
- 🐛 **บั๊กแฝงที่เจอระหว่างทำ (สำคัญ):** `@nuxtjs/tailwindcss` resolve default cssPath แบบ relative ต่อ rootDir → บน Nuxt 4 (srcDir = `app/`) หา `app/assets/css/tailwind.css` ไม่เจอและ **fallback ไปไฟล์ default ของ tailwindcss package เงียบๆ** (build ผ่าน แต่ token/component classes หายทั้งหมด) — ก่อนหน้านี้ไม่มีใครเจอเพราะไฟล์เดิมมีเนื้อหา 3 directives เหมือน fallback เป๊ะ · แก้ด้วย `tailwindcss: { cssPath: '~/assets/css/tailwind.css' }` ใน nuxt.config.ts (บันทึกเป็นกติกา #14 ใน CLAUDE.md แล้ว)
- smoke test เครื่องมือ: `PORT=5174 node .output/server/index.mjs` + curl ตรวจ HTML — ใช้ซ้ำได้เมื่อแก้ shell/global