ปรับ look and feel

This commit is contained in:
Suphonchai Phoonsawat 2026-08-20 21:34:45 +07:00
parent 0148f48204
commit b47667f918
15 changed files with 915 additions and 523 deletions

View file

@ -0,0 +1,188 @@
# 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

View file

@ -6,59 +6,44 @@ onMounted(() => initTheme())
</script>
<template>
<div class="min-h-screen bg-slate-50 text-slate-900 dark:bg-slate-900 dark:text-slate-100">
<header class="border-b border-slate-200 dark:border-slate-700">
<div class="mx-auto flex max-w-5xl flex-wrap items-center justify-between gap-3 px-6 py-4">
<div class="flex items-baseline gap-3">
<NuxtLink to="/" class="text-lg font-semibold">e-Court Documents Versioning</NuxtLink>
<span class="hidden text-xs text-slate-500 dark:text-slate-400 sm:inline">web portal</span>
<div class="min-h-screen">
<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>
<header class="sticky top-0 z-20 border-b border-line bg-elevated/95 backdrop-blur">
<div class="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-3 sm:px-6">
<div class="flex min-w-0 items-baseline gap-3">
<NuxtLink to="/" class="min-w-0 truncate text-lg font-semibold hover:text-primary">
e-Court Documents Versioning
</NuxtLink>
<span class="badge badge-accent hidden sm:inline-flex">web portal</span>
</div>
<nav class="flex items-center gap-2 text-sm">
<NuxtLink
to="/"
class="rounded-md px-2.5 py-1.5 text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
>
สร้างเอกสาร
</NuxtLink>
<NuxtLink
to="/templates"
class="rounded-md px-2.5 py-1.5 text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
>
จัดการ template
</NuxtLink>
<nav class="flex flex-wrap items-center gap-2 text-sm">
<NuxtLink to="/" class="nav-link">สร้างเอกสาร</NuxtLink>
<NuxtLink to="/templates" class="nav-link">จัดการ template</NuxtLink>
<button
type="button"
class="rounded-md border border-slate-300 px-3 py-1.5 hover:bg-slate-100 dark:border-slate-600 dark:hover:bg-slate-800"
@click="toggle"
>
<button type="button" class="btn btn-secondary btn-sm" @click="toggle">
{{ isDark ? '☀️ Light' : '🌙 Dark' }}
</button>
<!-- สถานะ login รู้ผลหลัง restore session (client) — จึงต้อง ClientOnly กัน hydration mismatch -->
<ClientOnly>
<template #fallback>
<span class="inline-block w-24 text-right text-xs text-slate-400">…</span>
<span class="inline-block w-24 text-right text-xs text-muted">…</span>
</template>
<template v-if="ready">
<span v-if="isAuthenticated" class="max-w-40 truncate text-xs text-slate-500 dark:text-slate-400">
<span v-if="isAuthenticated" class="max-w-40 truncate text-xs text-muted">
{{ username }}
</span>
<button
v-if="isAuthenticated"
type="button"
class="rounded-md border border-slate-300 px-3 py-1.5 hover:bg-slate-100 dark:border-slate-600 dark:hover:bg-slate-800"
@click="logout"
>
<button v-if="isAuthenticated" type="button" class="btn btn-secondary btn-sm" @click="logout">
ออกจากระบบ
</button>
<button
v-else
type="button"
class="rounded-md bg-sky-600 px-3 py-1.5 font-medium text-white hover:bg-sky-700"
@click="login()"
>
<button v-else type="button" class="btn btn-primary btn-sm" @click="login()">
เข้าสู่ระบบ
</button>
</template>
@ -67,7 +52,7 @@ onMounted(() => initTheme())
</div>
</header>
<main class="mx-auto max-w-5xl p-6">
<main id="main" class="mx-auto max-w-6xl px-4 py-6 sm:px-6 sm:py-8">
<NuxtPage />
</main>
</div>

View file

@ -1,3 +1,160 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ── Design tokens (derive จาก scheme ของ admincourt.go.th) ─────────────
เป็น RGB triplet (เว้นวรรค) เท่านั้น — ห้าม hex เพราะ pattern
rgb(var(--x) / <alpha-value>) ใน tailwind.config.ts จะพังเงียบๆ
Light = ค่าจาก scheme · Dark = derive (ครีม → warm near-black, #0000EE → indigo สว่าง) */
:root {
color-scheme: light;
/* brand */
--c-primary: 0 0 238; /* #0000EE — CTA, link */
--c-primary-hover: 0 0 190; /* #0000BE */
--c-on-primary: 255 255 255; /* ข้อความบนพื้น primary */
--c-accent: 143 114 38; /* #8F7226 — ใช้น้อย: badge เตือน/decorative */
/* semantic */
--c-success: 83 163 54; /* #53A336 action-green */
--c-success-strong: 41 109 24; /* เขียวเข้มสำหรับ text บน tint (contrast AA) */
--c-error: 219 55 0; /* #DB3700 */
--c-error-strong: 184 43 0; /* แดงเข้มสำหรับ text บน tint */
--c-focus: 41 119 255; /* #2977FF — ring บนพื้นขาว (#6CB5F9 จางไป ~2:1) */
/* surfaces */
--c-surface: 255 255 255; /* พื้นหน้า */
--c-elevated: 251 247 236; /* #FBF7EC card/ตาราง/navbar */
--c-surface-muted: 243 237 220; /* #F3EDDC hover fill/แถวขยาย/file button */
/* content */
--c-ink: 0 0 0;
--c-muted: 78 78 78; /* #4E4E4E */
--c-line: 204 206 209; /* #CCCED1 เส้นขอบ/Divider */
}
.dark {
color-scheme: dark;
--c-primary: 143 143 255; /* #8F8FFF */
--c-primary-hover: 170 170 255; /* #AAAAFF */
--c-on-primary: 23 20 15; /* ข้อความเข้มบนพื้น primary สว่าง */
--c-accent: 210 179 106; /* #D2B36A */
--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; /* #6CB5F9 — ring บนพื้นมืด */
--c-surface: 23 20 15; /* #17140F */
--c-elevated: 36 31 22; /* #241F16 */
--c-surface-muted: 44 38 27; /* #2C261B */
--c-ink: 247 243 233; /* #F7F3E9 */
--c-muted: 174 166 148; /* #AEA694 */
--c-line: 62 56 43; /* #3E382B */
}
@layer base {
html {
@apply font-sans antialiased;
}
body {
@apply bg-surface text-ink;
}
}
/* ── Component classes ─────────────────────────────────────────────────
กติกา: variant compose ใน markup (class="btn btn-primary") — Tailwind v3
ห้าม @apply custom class ซ้อนในอีก custom class
.btn-xs ต้องอยู่หลัง .btn ในไฟล์เดียวกัน (source order ชนะ padding) */
@layer components {
/* buttons */
.btn {
@apply inline-flex items-center justify-center gap-2 rounded-pill px-4 py-2 text-sm font-medium
transition-colors focus-visible:ring-2 focus-visible:ring-focus focus-visible:ring-offset-2
focus-visible:ring-offset-surface disabled:cursor-not-allowed disabled:opacity-50;
}
.btn-primary {
@apply bg-primary text-on-primary hover:bg-primary-hover;
}
.btn-secondary {
@apply border border-line bg-transparent text-ink hover:bg-surface-muted;
}
.btn-outline-danger {
@apply border border-error/50 text-error-strong hover:bg-error/10;
}
.btn-outline-success {
@apply border border-success/50 text-success-strong hover:bg-success/10;
}
.btn-sm {
@apply px-3 py-1.5;
}
.btn-xs {
@apply px-2.5 py-1 text-xs;
}
/* forms */
.input {
@apply w-full rounded-card border border-line bg-surface px-3 py-2 text-sm text-ink
placeholder:text-muted focus:border-focus focus:ring-1 focus:ring-focus;
}
.label {
@apply mb-1 block text-sm font-medium text-ink;
}
/* surfaces */
.card {
@apply rounded-card border border-line bg-elevated shadow-soft;
}
.table-card {
@apply overflow-x-auto rounded-card border border-line bg-elevated shadow-soft;
}
.table-head {
@apply border-b border-line text-left text-xs uppercase tracking-wide text-muted;
}
.empty-state {
@apply rounded-card border border-dashed border-line p-8 text-center text-sm text-muted;
}
/* badges / spinner / links */
.badge {
@apply inline-block rounded-pill px-2.5 py-0.5 text-xs font-medium;
}
.badge-success {
@apply bg-success/15 text-success-strong;
}
.badge-neutral {
@apply bg-surface-muted text-muted;
}
.badge-accent {
@apply bg-accent/15 text-accent;
}
.spinner {
@apply h-4 w-4 animate-spin rounded-full border-2 border-line border-t-primary;
}
.link {
@apply font-medium text-primary hover:underline;
}
.nav-link {
@apply rounded-pill px-3 py-1.5 text-muted transition-colors hover:bg-surface-muted hover:text-ink;
}
/* alerts + info panel */
.alert {
@apply rounded-card border px-4 py-3 text-sm;
}
.alert-error {
@apply border-error/40 bg-error/10 text-error-strong;
}
.alert-success {
@apply border-success/40 bg-success/10 text-success-strong;
}
.alert-info {
@apply border-primary/30 bg-primary/5 text-ink;
}
.panel-info {
@apply rounded-card border border-primary/30 bg-primary/5 p-4 text-sm text-ink;
}
}

View file

@ -10,18 +10,18 @@ const props = withDefaults(
const styles = computed(() => {
switch (props.type) {
case 'error':
return 'border-red-300 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-300'
return 'alert-error'
case 'success':
return 'border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-300'
return 'alert-success'
default:
return 'border-sky-300 bg-sky-50 text-sky-700 dark:border-sky-800 dark:bg-sky-950 dark:text-sky-300'
return 'alert-info'
}
})
const icon = computed(() => (props.type === 'error' ? '⚠️' : props.type === 'success' ? '✅' : 'ℹ️'))
</script>
<template>
<div role="alert" class="rounded-md border px-4 py-3 text-sm" :class="styles">
<div role="alert" class="alert" :class="styles">
<span class="mr-2" aria-hidden="true">{{ icon }}</span>
<slot />
</div>

View file

@ -4,25 +4,18 @@ const { ready, isAuthenticated, login } = useAuth()
</script>
<template>
<div v-if="!ready" class="flex items-center justify-center gap-3 py-16 text-sm text-slate-500 dark:text-slate-400">
<span class="h-4 w-4 animate-spin rounded-full border-2 border-slate-300 border-t-slate-600 dark:border-slate-600 dark:border-t-slate-300" />
<div v-if="!ready" class="flex items-center justify-center gap-3 py-16 text-sm text-muted">
<span class="spinner" />
กำลังตรวจสอบสถานะการเข้าสู่ระบบ…
</div>
<div
v-else-if="!isAuthenticated"
class="rounded-lg border border-slate-200 bg-white p-8 text-center dark:border-slate-700 dark:bg-slate-800"
>
<h2 class="text-lg font-semibold">ต้องเข้าสู่ระบบก่อนใช้งานหน้านี้</h2>
<p class="mt-2 text-sm text-slate-600 dark:text-slate-300">
<div v-else-if="!isAuthenticated" class="card mx-auto max-w-md p-8 text-center">
<h2 class="text-section-title">ต้องเข้าสู่ระบบก่อนใช้งานหน้านี้</h2>
<p class="mt-2 text-body-lg text-muted">
ระบบยืนยันตัวตนผ่าน Keycloak (realm <span class="font-mono">word-versioning</span>)
— กดปุ่มด้านล่างเพื่อเปิดหน้าเข้าสู่ระบบ
</p>
<button
type="button"
class="mt-4 rounded-md bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700"
@click="login()"
>
<button type="button" class="btn btn-primary mt-6 w-full sm:w-auto" @click="login()">
เข้าสู่ระบบด้วย Keycloak
</button>
</div>

View file

@ -23,30 +23,19 @@ onMounted(async () => {
<template>
<section class="mx-auto max-w-xl space-y-4 py-10 text-center">
<template v-if="busy">
<div class="flex items-center justify-center gap-3 text-sm text-slate-500 dark:text-slate-400">
<span
class="h-4 w-4 animate-spin rounded-full border-2 border-slate-300 border-t-slate-600 dark:border-slate-600 dark:border-t-slate-300"
/>
<div class="flex items-center justify-center gap-3 text-sm text-muted">
<span class="spinner" />
{{ message }}
</div>
</template>
<template v-else>
<AlertBox type="error">{{ message }}</AlertBox>
<div class="flex justify-center gap-3">
<button
type="button"
class="rounded-md bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700"
@click="login()"
>
<div class="flex flex-col-reverse justify-center gap-2 sm:flex-row">
<NuxtLink to="/" class="btn btn-secondary w-full sm:w-auto">กลับหน้าหลัก</NuxtLink>
<button type="button" class="btn btn-primary w-full sm:w-auto" @click="login()">
เข้าสู่ระบบใหม่
</button>
<NuxtLink
to="/"
class="rounded-md border border-slate-300 px-4 py-2 text-sm hover:bg-slate-100 dark:border-slate-600 dark:hover:bg-slate-800"
>
กลับหน้าหลัก
</NuxtLink>
</div>
</template>
</section>

View file

@ -71,10 +71,8 @@ onMounted(load)
<template>
<AuthGate>
<section class="space-y-8">
<div v-if="loading" class="flex items-center gap-3 py-10 text-sm text-slate-500 dark:text-slate-400">
<span
class="h-4 w-4 animate-spin rounded-full border-2 border-slate-300 border-t-slate-600 dark:border-slate-600 dark:border-t-slate-300"
/>
<div v-if="loading" class="flex items-center gap-3 py-10 text-sm text-muted">
<span class="spinner" />
กำลังโหลด…
</div>
@ -85,16 +83,13 @@ onMounted(load)
<template v-else-if="detail">
<!-- หัวเรื่อง + การกระทำ -->
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 class="text-2xl font-bold break-all">{{ detail.fileName }}</h1>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
<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 break-all">{{ detail.fileName }}</h1>
<p class="mt-1 text-sm text-muted">
<template v-if="detail.templateId">
จาก template
<NuxtLink
:to="`/templates/${detail.templateId}/documents`"
class="font-medium text-sky-700 hover:underline dark:text-sky-400"
>
<NuxtLink :to="`/templates/${detail.templateId}/documents`" class="link">
{{ detail.templateName }}
</NuxtLink>
</template>
@ -103,18 +98,18 @@ onMounted(load)
</p>
</div>
<div class="flex flex-col items-end gap-1">
<div class="flex shrink-0 flex-col items-start gap-1 sm:items-end">
<button
v-if="detail.templateId"
type="button"
:disabled="!detail.templateIsActive"
:title="detail.templateIsActive ? '' : 'template นี้ถูก deactivate แล้ว — สร้างเอกสารใหม่ไม่ได้'"
class="rounded-md bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700 disabled:cursor-not-allowed disabled:opacity-50"
class="btn btn-primary w-full sm:w-auto"
@click="regenerate"
>
⟳ สร้างเอกสารใหม่จากข้อมูลนี้
</button>
<p v-if="detail.templateId && !detail.templateIsActive" class="text-xs text-slate-500 dark:text-slate-400">
<p v-if="detail.templateId && !detail.templateIsActive" class="text-xs text-muted">
template ถูก deactivate — เอกสารฉบับนี้ยังใช้ได้ แต่สร้างใหม่ไม่ได้
</p>
</div>
@ -122,109 +117,171 @@ onMounted(load)
<!-- ค่าล่าสุดของทุก field -->
<div class="space-y-3">
<h2 class="text-lg font-semibold">ค่า fields ปัจจุบัน</h2>
<h2 class="text-section-title">ค่า fields ปัจจุบัน</h2>
<div
v-if="detail.fields.length === 0"
class="rounded-md border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500 dark:border-slate-600 dark:text-slate-400"
>
<div v-if="detail.fields.length === 0" class="empty-state">
เอกสารนี้ไม่มี field values (ไม่ได้สร้างจาก template หรือ template ไม่มี fields)
</div>
<div v-else class="overflow-x-auto rounded-lg border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-800">
<table class="w-full text-sm">
<thead class="border-b border-slate-200 text-left text-xs uppercase text-slate-500 dark:border-slate-700 dark:text-slate-400">
<tr>
<th class="px-4 py-3">field</th>
<th class="px-4 py-3">ค่า</th>
<th class="px-4 py-3">type</th>
<th class="px-4 py-3">อัปเดตเมื่อ</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-700">
<tr v-for="f in detail.fields" :key="f.key">
<td class="px-4 py-3">
{{ f.label ?? f.key }}
<span v-if="!f.label" class="ml-1 rounded bg-amber-100 px-1.5 py-0.5 text-xs text-amber-700 dark:bg-amber-900 dark:text-amber-300">
ไม่มีในนิยามปัจจุบัน
</span>
<span class="block font-mono text-xs text-slate-400">{{ f.key }}</span>
</td>
<td class="max-w-72 px-4 py-3 break-words">{{ f.value || '—' }}</td>
<td class="px-4 py-3 text-slate-500 dark:text-slate-400">{{ f.type ?? '—' }}</td>
<td class="px-4 py-3 text-slate-500 dark:text-slate-400">
{{ f.updatedAt ? formatDateTime(f.updatedAt) : 'ยังไม่เคยมีค่า' }}
</td>
</tr>
</tbody>
</table>
</div>
<template v-else>
<!-- มือถือ: card list -->
<ul class="space-y-3 md:hidden">
<li v-for="f in detail.fields" :key="f.key" class="card space-y-1 p-4">
<p class="text-sm font-medium">
{{ f.label ?? f.key }}
<span v-if="!f.label" class="badge badge-accent ml-1">ไม่มีในนิยามปัจจุบัน</span>
</p>
<p class="font-mono text-xs text-muted">{{ f.key }}</p>
<p class="break-words text-sm">{{ f.value || '—' }}</p>
<p class="text-xs text-muted">
{{ f.type ?? '—' }} ·
{{ f.updatedAt ? formatDateTime(f.updatedAt) : 'ยังไม่เคยมีค่า' }}
</p>
</li>
</ul>
<!-- desktop: ตาราง -->
<div class="table-card hidden md:block">
<table class="w-full text-sm">
<thead class="table-head">
<tr>
<th class="px-4 py-3">field</th>
<th class="px-4 py-3">ค่า</th>
<th class="px-4 py-3">type</th>
<th class="px-4 py-3">อัปเดตเมื่อ</th>
</tr>
</thead>
<tbody class="divide-y divide-line">
<tr v-for="f in detail.fields" :key="f.key">
<td class="px-4 py-3">
{{ f.label ?? f.key }}
<span v-if="!f.label" class="badge badge-accent ml-1">ไม่มีในนิยามปัจจุบัน</span>
<span class="block font-mono text-xs text-muted">{{ f.key }}</span>
</td>
<td class="max-w-72 px-4 py-3 break-words">{{ f.value || '—' }}</td>
<td class="px-4 py-3 text-muted">{{ f.type ?? '—' }}</td>
<td class="px-4 py-3 text-muted">
{{ f.updatedAt ? formatDateTime(f.updatedAt) : 'ยังไม่เคยมีค่า' }}
</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<!-- version history -->
<div class="space-y-3">
<h2 class="text-lg font-semibold">Version history ({{ versions.length }})</h2>
<h2 class="text-section-title">Version history ({{ versions.length }})</h2>
<div
v-if="versions.length === 0"
class="rounded-md border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500 dark:border-slate-600 dark:text-slate-400"
>
ยังไม่มี version
</div>
<div v-if="versions.length === 0" class="empty-state">ยังไม่มี version</div>
<div v-else class="overflow-x-auto rounded-lg border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-800">
<table class="w-full text-sm">
<thead class="border-b border-slate-200 text-left text-xs uppercase text-slate-500 dark:border-slate-700 dark:text-slate-400">
<tr>
<th class="px-4 py-3">version</th>
<th class="px-4 py-3">เวลา</th>
<th class="px-4 py-3">ผู้สร้าง</th>
<th class="px-4 py-3 text-right">การกระทำ</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-700">
<template v-for="v in versions" :key="v.versionId">
<tr class="cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-700/40" @click="expandedVersionId = expandedVersionId === v.versionId ? null : v.versionId">
<td class="px-4 py-3 font-medium">v{{ v.versionNumber }}</td>
<td class="px-4 py-3">{{ formatDateTime(v.createdAt) }}</td>
<td class="px-4 py-3 font-mono text-xs text-slate-500 dark:text-slate-400">{{ v.createdByUserId.slice(0, 8) }}…</td>
<td class="px-4 py-3 text-right whitespace-nowrap">
<button
type="button"
:disabled="downloadingVersionId === v.versionId"
class="rounded-md border border-slate-300 px-2.5 py-1 text-xs hover:bg-slate-100 disabled:opacity-50 dark:border-slate-600 dark:hover:bg-slate-700"
@click.stop="download(v)"
>
{{ downloadingVersionId === v.versionId ? '…' : '⬇ ดาวน์โหลด' }}
</button>
<span class="ml-2 text-xs text-slate-400">{{ expandedVersionId === v.versionId ? '▲ ซ่อน fields' : '▼ ดู fields' }}</span>
</td>
<template v-else>
<!-- มือถือ: card ต่อ version (กดขยายดู fields ได้เหมือนแถวตาราง) -->
<ul class="space-y-3 md:hidden">
<li v-for="v in versions" :key="v.versionId" class="card p-4">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="font-medium">v{{ v.versionNumber }}</p>
<p class="mt-1 text-xs text-muted">{{ formatDateTime(v.createdAt) }}</p>
<p class="mt-0.5 font-mono text-xs text-muted">{{ v.createdByUserId.slice(0, 8) }}…</p>
</div>
<div class="flex shrink-0 flex-col items-end gap-2">
<button
type="button"
:disabled="downloadingVersionId === v.versionId"
class="btn btn-secondary btn-xs"
@click.stop="download(v)"
>
{{ downloadingVersionId === v.versionId ? '…' : '⬇ ดาวน์โหลด' }}
</button>
<button
type="button"
class="text-xs text-muted"
@click="expandedVersionId = expandedVersionId === v.versionId ? null : v.versionId"
>
{{ expandedVersionId === v.versionId ? '▲ ซ่อน fields' : '▼ ดู fields' }}
</button>
</div>
</div>
<div v-if="expandedVersionId === v.versionId" class="mt-3 rounded-card bg-surface-muted p-3">
<p class="mb-2 text-xs font-medium text-muted">ค่า fields ของ v{{ v.versionNumber }}</p>
<dl class="space-y-1.5 text-sm">
<div v-for="e in snapshotOf(v)" :key="e.key" class="flex gap-2">
<dt class="w-36 shrink-0 truncate text-muted" :title="e.key">
{{ fieldLabel(e.key) }}
</dt>
<dd class="min-w-0 break-words">
{{ e.value || '—' }}
<span v-if="e.missing" class="badge badge-accent ml-1">หายจากเอกสาร</span>
</dd>
</div>
<p v-if="snapshotOf(v).length === 0" class="text-sm text-muted">
version นี้ไม่มี snapshot ของ fields
</p>
</dl>
</div>
</li>
</ul>
<!-- desktop: ตาราง (กดแถวเพื่อขยาย) -->
<div class="table-card hidden md:block">
<table class="w-full text-sm">
<thead class="table-head">
<tr>
<th class="px-4 py-3">version</th>
<th class="px-4 py-3">เวลา</th>
<th class="px-4 py-3">ผู้สร้าง</th>
<th class="px-4 py-3 text-right">การกระทำ</th>
</tr>
<tr v-if="expandedVersionId === v.versionId">
<td colspan="4" class="bg-slate-50 px-4 py-4 dark:bg-slate-900">
<p class="mb-2 text-xs font-medium text-slate-500 dark:text-slate-400">ค่า fields ของ v{{ v.versionNumber }}</p>
<dl class="grid gap-x-6 gap-y-1.5 text-sm sm:grid-cols-2">
<div v-for="e in snapshotOf(v)" :key="e.key" class="flex gap-2">
<dt class="w-44 shrink-0 truncate text-slate-500 dark:text-slate-400" :title="e.key">
{{ fieldLabel(e.key) }}
</dt>
<dd class="break-words">
{{ e.value || '—' }}
<span v-if="e.missing" class="ml-1 rounded bg-amber-100 px-1.5 py-0.5 text-xs text-amber-700 dark:bg-amber-900 dark:text-amber-300">
หายจากเอกสาร
</span>
</dd>
</div>
<p v-if="snapshotOf(v).length === 0" class="text-sm text-slate-500 dark:text-slate-400">
version นี้ไม่มี snapshot ของ fields
</p>
</dl>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</thead>
<tbody class="divide-y divide-line">
<template v-for="v in versions" :key="v.versionId">
<tr
class="cursor-pointer hover:bg-surface-muted/60"
@click="expandedVersionId = expandedVersionId === v.versionId ? null : v.versionId"
>
<td class="px-4 py-3 font-medium">v{{ v.versionNumber }}</td>
<td class="px-4 py-3">{{ formatDateTime(v.createdAt) }}</td>
<td class="px-4 py-3 font-mono text-xs text-muted">{{ v.createdByUserId.slice(0, 8) }}…</td>
<td class="px-4 py-3 text-right whitespace-nowrap">
<button
type="button"
:disabled="downloadingVersionId === v.versionId"
class="btn btn-secondary btn-xs"
@click.stop="download(v)"
>
{{ downloadingVersionId === v.versionId ? '…' : '⬇ ดาวน์โหลด' }}
</button>
<span class="ml-2 text-xs text-muted">
{{ expandedVersionId === v.versionId ? '▲ ซ่อน fields' : '▼ ดู fields' }}
</span>
</td>
</tr>
<tr v-if="expandedVersionId === v.versionId">
<td colspan="4" class="bg-surface-muted px-4 py-4">
<p class="mb-2 text-xs font-medium text-muted">ค่า fields ของ v{{ v.versionNumber }}</p>
<dl class="grid gap-x-6 gap-y-1.5 text-sm sm:grid-cols-2">
<div v-for="e in snapshotOf(v)" :key="e.key" class="flex gap-2">
<dt class="w-44 shrink-0 truncate text-muted" :title="e.key">
{{ fieldLabel(e.key) }}
</dt>
<dd class="break-words">
{{ e.value || '—' }}
<span v-if="e.missing" class="badge badge-accent ml-1">หายจากเอกสาร</span>
</dd>
</div>
<p v-if="snapshotOf(v).length === 0" class="text-sm text-muted">
version นี้ไม่มี snapshot ของ fields
</p>
</dl>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
<AlertBox v-if="downloadError" type="error">{{ downloadError }}</AlertBox>
</div>
</template>

View file

@ -121,10 +121,8 @@ onMounted(load)
<template>
<AuthGate>
<section class="mx-auto max-w-2xl space-y-6">
<div v-if="loading" class="flex items-center gap-3 py-10 text-sm text-slate-500 dark:text-slate-400">
<span
class="h-4 w-4 animate-spin rounded-full border-2 border-slate-300 border-t-slate-600 dark:border-slate-600 dark:border-t-slate-300"
/>
<div v-if="loading" class="flex items-center gap-3 py-10 text-sm text-muted">
<span class="spinner" />
กำลังโหลด template…
</div>
@ -140,48 +138,39 @@ onMounted(load)
สร้างเอกสารสำเร็จ — บันทึกเป็น <strong>version {{ result.versionNumber }}</strong> แล้ว
</AlertBox>
<div class="rounded-lg border border-slate-200 bg-white p-6 dark:border-slate-700 dark:bg-slate-800">
<h1 class="text-xl font-bold">{{ detail.name }}</h1>
<div class="card p-6">
<h1 class="text-page-title">{{ detail.name }}</h1>
<dl class="mt-4 space-y-2 text-sm">
<div class="flex gap-2">
<dt class="w-32 shrink-0 text-slate-500 dark:text-slate-400">document id</dt>
<dt class="w-32 shrink-0 text-muted">document id</dt>
<dd class="break-all font-mono text-xs leading-6">{{ result.documentId }}</dd>
</div>
<div class="flex gap-2">
<dt class="w-32 shrink-0 text-slate-500 dark:text-slate-400">version</dt>
<dt class="w-32 shrink-0 text-muted">version</dt>
<dd>{{ result.versionNumber }} (ต้นทางของ version history)</dd>
</div>
</dl>
<div class="mt-5 flex flex-wrap items-center gap-3">
<div class="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:flex-wrap">
<NuxtLink to="/" class="btn btn-secondary w-full sm:w-auto">กลับหน้าหลัก</NuxtLink>
<button type="button" class="btn btn-secondary w-full sm:w-auto" @click="resetForm">
สร้างอีกฉบับ
</button>
<button
type="button"
:disabled="downloading"
class="rounded-md bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700 disabled:opacity-50"
class="btn btn-primary w-full sm:w-auto"
@click="download"
>
{{ downloading ? 'กำลังเตรียมไฟล์…' : '⬇ ดาวน์โหลด .docx' }}
</button>
<button
type="button"
class="rounded-md border border-slate-300 px-4 py-2 text-sm hover:bg-slate-100 dark:border-slate-600 dark:hover:bg-slate-800"
@click="resetForm"
>
สร้างอีกฉบับ
</button>
<NuxtLink
to="/"
class="rounded-md border border-slate-300 px-4 py-2 text-sm hover:bg-slate-100 dark:border-slate-600 dark:hover:bg-slate-800"
>
กลับหน้าหลัก
</NuxtLink>
</div>
<AlertBox v-if="downloadError" type="error" class="mt-4">{{ downloadError }}</AlertBox>
</div>
<div class="rounded-md border border-sky-200 bg-sky-50 p-4 text-sm dark:border-sky-800 dark:bg-sky-950">
<p class="font-medium text-sky-800 dark:text-sky-300">ขั้นต่อไป</p>
<ol class="mt-2 list-decimal space-y-1 pl-5 text-sky-800 dark:text-sky-200">
<div class="panel-info">
<p class="font-medium text-primary">ขั้นต่อไป</p>
<ol class="mt-2 list-decimal space-y-1 pl-5">
<li>เปิดไฟล์ที่ดาวน์โหลดใน Word (Desktop หรือ Online)</li>
<li>
เปิด task pane ของ add-in <strong>e-Court Documents Versioning</strong> — ระบบจะตรวจพบว่าเป็น
@ -195,8 +184,8 @@ onMounted(load)
<!-- ฟอร์มกรอกค่า -->
<form v-else class="space-y-5" @submit.prevent="submit" novalidate>
<div>
<h1 class="text-2xl font-bold">{{ detail.name }}</h1>
<p v-if="detail.description" class="mt-1 text-sm text-slate-600 dark:text-slate-300">
<h1 class="text-page-title">{{ detail.name }}</h1>
<p v-if="detail.description" class="mt-1 text-sm text-muted">
{{ detail.description }}
</p>
</div>
@ -214,10 +203,10 @@ onMounted(load)
v-for="f in sortedFields"
:key="f.key"
>
<label :for="`field-${f.key}`" class="mb-1 block text-sm font-medium">
<label :for="`field-${f.key}`" class="label">
{{ f.label }}
<span v-if="f.isRequired" class="text-red-500">*</span>
<span class="ml-1 font-mono text-xs font-normal text-slate-400">{{ f.key }}</span>
<span v-if="f.isRequired" class="text-error-strong">*</span>
<span class="ml-1 font-mono text-xs font-normal text-muted">{{ f.key }}</span>
</label>
<input
@ -225,31 +214,28 @@ onMounted(load)
v-model="values[f.key]"
:type="f.type === 'number' ? 'number' : f.type === 'date' ? 'date' : 'text'"
:step="f.type === 'number' ? 'any' : undefined"
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800"
:class="issues[f.key] ? 'border-red-400 dark:border-red-500' : ''"
class="input"
:class="issues[f.key] ? 'border-error' : ''"
/>
<p v-if="issues[f.key]" class="mt-1 text-xs text-red-600 dark:text-red-400">{{ issues[f.key] }}</p>
<p v-if="issues[f.key]" class="mt-1 text-xs text-error-strong">{{ issues[f.key] }}</p>
</div>
<div
v-if="sortedFields.length === 0"
class="rounded-md border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500 dark:border-slate-600 dark:text-slate-400"
>
<div v-if="sortedFields.length === 0" class="empty-state">
template นี้ไม่มี field definitions —
<NuxtLink :to="`/templates/${detail.id}/edit`" class="font-medium text-sky-600 hover:underline dark:text-sky-400">
ตรวจสอบที่หน้าจัดการ template
</NuxtLink>
<NuxtLink :to="`/templates/${detail.id}/edit`" class="link">ตรวจสอบที่หน้าจัดการ template</NuxtLink>
</div>
<AlertBox v-if="submitError" type="error">{{ submitError }}</AlertBox>
<button
type="submit"
:disabled="submitting || sortedFields.length === 0 || !detail.isActive"
class="rounded-md bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700 disabled:opacity-50"
>
{{ submitting ? 'กำลังสร้างเอกสาร…' : 'สร้างเอกสาร (version 1)' }}
</button>
<div class="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
<button
type="submit"
:disabled="submitting || sortedFields.length === 0 || !detail.isActive"
class="btn btn-primary w-full sm:w-auto"
>
{{ submitting ? 'กำลังสร้างเอกสาร…' : 'สร้างเอกสาร (version 1)' }}
</button>
</div>
</form>
</template>
</section>

View file

@ -21,60 +21,46 @@ onMounted(async () => {
<template>
<AuthGate>
<section class="space-y-5">
<section class="space-y-6 sm:space-y-8">
<div>
<h1 class="text-2xl font-bold">สร้างเอกสารจาก template</h1>
<p class="mt-1 text-sm text-slate-600 dark:text-slate-300">
<div class="h-1 w-10 rounded-pill bg-accent" aria-hidden="true" />
<h1 class="mt-3 text-display">สร้างเอกสารจาก template</h1>
<p class="mt-2 text-body-lg text-muted">
เลือก template กรอกค่า fields แล้วดาวน์โหลด .docx ที่เป็น <strong>version 1</strong> พร้อม metadata
ไว้เปิดต่อใน Word พร้อม add-in
</p>
</div>
<div v-if="loading" class="flex items-center gap-3 py-10 text-sm text-slate-500 dark:text-slate-400">
<span
class="h-4 w-4 animate-spin rounded-full border-2 border-slate-300 border-t-slate-600 dark:border-slate-600 dark:border-t-slate-300"
/>
<div v-if="loading" class="flex items-center gap-3 py-10 text-sm text-muted">
<span class="spinner" />
กำลังโหลดรายการ template…
</div>
<AlertBox v-else-if="error" type="error">{{ error }}</AlertBox>
<div
v-else-if="templates.length === 0"
class="rounded-lg border border-dashed border-slate-300 p-8 text-center text-sm text-slate-500 dark:border-slate-600 dark:text-slate-400"
>
<div v-else-if="templates.length === 0" class="empty-state">
ยังไม่มี template — ไปที่
<NuxtLink to="/templates/new" class="font-medium text-sky-600 hover:underline dark:text-sky-400">
สร้าง template ตัวแรก
</NuxtLink>
<NuxtLink to="/templates/new" class="link">สร้าง template ตัวแรก</NuxtLink>
</div>
<div v-else class="grid gap-4 sm:grid-cols-2">
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div
v-for="t in templates"
:key="t.id"
class="flex flex-col rounded-lg border border-slate-200 bg-white p-5 transition hover:border-sky-400 hover:shadow-sm dark:border-slate-700 dark:bg-slate-800 dark:hover:border-sky-500"
class="card flex flex-col p-5 transition hover:border-primary/50 hover:shadow-soft-lg"
>
<NuxtLink :to="`/documents/create/${t.id}`" class="flex-1">
<h2 class="font-semibold">{{ t.name }}</h2>
<p class="mt-1 line-clamp-2 min-h-10 text-sm text-slate-600 dark:text-slate-300">
<p class="mt-1 line-clamp-2 min-h-10 text-sm text-muted">
{{ t.description ?? '—' }}
</p>
<p class="mt-3 text-xs text-slate-500 dark:text-slate-400">
<p class="mt-3 text-xs text-muted">
{{ t.fieldCount }} fields · อัปเดต {{ formatDateTime(t.updatedAt) }}
</p>
</NuxtLink>
<div class="mt-3 flex gap-4 border-t border-slate-100 pt-3 text-xs dark:border-slate-700">
<NuxtLink
:to="`/documents/create/${t.id}`"
class="font-medium text-sky-600 hover:underline dark:text-sky-400"
>
สร้างเอกสาร →
</NuxtLink>
<NuxtLink
:to="`/templates/${t.id}/documents`"
class="text-slate-500 hover:text-sky-600 hover:underline dark:text-slate-400 dark:hover:text-sky-400"
>
<div class="mt-auto flex gap-4 border-t border-line pt-3 text-xs">
<NuxtLink :to="`/documents/create/${t.id}`" class="link">สร้างเอกสาร →</NuxtLink>
<NuxtLink :to="`/templates/${t.id}/documents`" class="text-muted hover:text-primary hover:underline">
ดูเอกสารที่สร้างแล้ว
</NuxtLink>
</div>

View file

@ -35,11 +35,9 @@ onMounted(load)
<template>
<AuthGate>
<section class="space-y-5">
<div v-if="loading" class="flex items-center gap-3 py-10 text-sm text-slate-500 dark:text-slate-400">
<span
class="h-4 w-4 animate-spin rounded-full border-2 border-slate-300 border-t-slate-600 dark:border-slate-600 dark:border-t-slate-300"
/>
<section class="space-y-6">
<div v-if="loading" class="flex items-center gap-3 py-10 text-sm text-muted">
<span class="spinner" />
กำลังโหลด…
</div>
@ -49,25 +47,22 @@ onMounted(load)
</AlertBox>
<template v-else-if="template">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<p class="text-xs text-slate-500 dark:text-slate-400">เอกสารที่สร้างจาก template</p>
<h1 class="text-2xl font-bold">{{ template.name }}</h1>
<p v-if="template.description" class="mt-1 text-sm text-slate-600 dark:text-slate-300">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0">
<p class="text-xs text-muted">เอกสารที่สร้างจาก template</p>
<h1 class="mt-1 text-page-title">{{ template.name }}</h1>
<p v-if="template.description" class="mt-1 text-sm text-muted">
{{ template.description }}
</p>
</div>
<div class="flex gap-2">
<NuxtLink
:to="`/templates/${template.id}/edit`"
class="rounded-md border border-slate-300 px-3 py-1.5 text-sm hover:bg-slate-100 dark:border-slate-600 dark:hover:bg-slate-800"
>
<div class="flex shrink-0 flex-wrap items-center gap-2">
<NuxtLink :to="`/templates/${template.id}/edit`" class="btn btn-secondary btn-sm">
แก้ไข template
</NuxtLink>
<NuxtLink
v-if="template.isActive"
:to="`/documents/create/${template.id}`"
class="rounded-md bg-sky-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-sky-700"
class="btn btn-primary btn-sm"
>
+ สร้างเอกสารใหม่
</NuxtLink>
@ -78,62 +73,66 @@ onMounted(load)
template นี้ถูก deactivate แล้ว — สร้างเอกสารใหม่ไม่ได้ แต่เอกสารที่สร้างไว้แล้วยังใช้งานได้ตามปกติ
</AlertBox>
<div
v-if="documents.length === 0"
class="rounded-lg border border-dashed border-slate-300 p-8 text-center text-sm text-slate-500 dark:border-slate-600 dark:text-slate-400"
>
<div v-if="documents.length === 0" class="empty-state">
ยังไม่มีเอกสารจาก template นี้<template v-if="template.isActive">
—
<NuxtLink
:to="`/documents/create/${template.id}`"
class="font-medium text-sky-600 hover:underline dark:text-sky-400"
>
สร้างเอกสารแรก
</NuxtLink></template
<NuxtLink :to="`/documents/create/${template.id}`" class="link">สร้างเอกสารแรก</NuxtLink></template
>
</div>
<div v-else class="overflow-x-auto rounded-lg border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-800">
<table class="w-full text-sm">
<thead class="border-b border-slate-200 text-left text-xs uppercase text-slate-500 dark:border-slate-700 dark:text-slate-400">
<tr>
<th class="px-4 py-3">ไฟล์เอกสาร</th>
<th class="px-4 py-3">สร้างเมื่อ</th>
<th class="px-4 py-3 text-center">versions</th>
<th class="px-4 py-3">กิจกรรมล่าสุด</th>
<th class="px-4 py-3 text-right">การกระทำ</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-700">
<tr v-for="d in documents" :key="d.documentId">
<td class="max-w-72 px-4 py-3">
<NuxtLink
:to="`/documents/${d.documentId}`"
class="font-medium text-sky-700 hover:underline dark:text-sky-400"
>
{{ d.fileName }}
</NuxtLink>
</td>
<td class="px-4 py-3 text-slate-500 dark:text-slate-400">{{ formatDateTime(d.createdAt) }}</td>
<td class="px-4 py-3 text-center">{{ d.versionCount }}</td>
<td class="px-4 py-3 text-slate-500 dark:text-slate-400">
<template v-if="d.latestVersionAt">
v{{ d.latestVersionNumber }} · {{ formatDateTime(d.latestVersionAt) }}
</template>
<template v-else>—</template>
</td>
<td class="px-4 py-3 text-right">
<NuxtLink
:to="`/documents/${d.documentId}`"
class="rounded-md border border-slate-300 px-2.5 py-1 text-xs hover:bg-slate-100 dark:border-slate-600 dark:hover:bg-slate-700"
>
ดูรายละเอียด
</NuxtLink>
</td>
</tr>
</tbody>
</table>
</div>
<template v-else>
<!-- มือถือ: card list -->
<ul class="space-y-3 md:hidden">
<li v-for="d in documents" :key="d.documentId" class="card space-y-2 p-4">
<NuxtLink :to="`/documents/${d.documentId}`" class="link break-words font-semibold">
{{ d.fileName }}
</NuxtLink>
<p class="text-xs text-muted">สร้าง {{ formatDateTime(d.createdAt) }} · {{ d.versionCount }} versions</p>
<p class="text-xs text-muted">
<template v-if="d.latestVersionAt">
ล่าสุด v{{ d.latestVersionNumber }} · {{ formatDateTime(d.latestVersionAt) }}
</template>
<template v-else>—</template>
</p>
<NuxtLink :to="`/documents/${d.documentId}`" class="btn btn-secondary btn-xs">ดูรายละเอียด</NuxtLink>
</li>
</ul>
<!-- desktop: ตาราง -->
<div class="table-card hidden md:block">
<table class="w-full text-sm">
<thead class="table-head">
<tr>
<th class="px-4 py-3">ไฟล์เอกสาร</th>
<th class="px-4 py-3">สร้างเมื่อ</th>
<th class="px-4 py-3 text-center">versions</th>
<th class="px-4 py-3">กิจกรรมล่าสุด</th>
<th class="px-4 py-3 text-right">การกระทำ</th>
</tr>
</thead>
<tbody class="divide-y divide-line">
<tr v-for="d in documents" :key="d.documentId">
<td class="max-w-72 px-4 py-3">
<NuxtLink :to="`/documents/${d.documentId}`" class="link">{{ d.fileName }}</NuxtLink>
</td>
<td class="px-4 py-3 text-muted">{{ formatDateTime(d.createdAt) }}</td>
<td class="px-4 py-3 text-center">{{ d.versionCount }}</td>
<td class="px-4 py-3 text-muted">
<template v-if="d.latestVersionAt">
v{{ d.latestVersionNumber }} · {{ formatDateTime(d.latestVersionAt) }}
</template>
<template v-else>—</template>
</td>
<td class="px-4 py-3 text-right">
<NuxtLink :to="`/documents/${d.documentId}`" class="btn btn-secondary btn-xs">
ดูรายละเอียด
</NuxtLink>
</td>
</tr>
</tbody>
</table>
</div>
</template>
</template>
</section>
</AuthGate>

View file

@ -134,83 +134,99 @@ onMounted(load)
<template>
<AuthGate>
<section class="mx-auto max-w-4xl space-y-8">
<div v-if="loading" class="flex items-center gap-3 py-10 text-sm text-slate-500 dark:text-slate-400">
<span
class="h-4 w-4 animate-spin rounded-full border-2 border-slate-300 border-t-slate-600 dark:border-slate-600 dark:border-t-slate-300"
/>
<div v-if="loading" class="flex items-center gap-3 py-10 text-sm text-muted">
<span class="spinner" />
กำลังโหลด…
</div>
<AlertBox v-else-if="loadError" type="error">{{ loadError }}</AlertBox>
<template v-else-if="detail">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-2xl font-bold">{{ detail.name }}</h1>
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400">
<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">{{ detail.name }}</h1>
<p class="mt-1 text-xs text-muted">
สร้าง {{ formatDateTime(detail.createdAt) }} · อัปเดต {{ formatDateTime(detail.updatedAt) }}
</p>
</div>
<NuxtLink
to="/templates"
class="rounded-md border border-slate-300 px-3 py-1.5 text-sm hover:bg-slate-100 dark:border-slate-600 dark:hover:bg-slate-800"
>
← รายการ template
</NuxtLink>
<div class="flex shrink-0 flex-wrap items-center gap-2">
<NuxtLink to="/templates" class="btn btn-secondary btn-sm">← รายการ template</NuxtLink>
</div>
</div>
<!-- 1) metadata -->
<form class="space-y-4" @submit.prevent="save">
<h2 class="text-lg font-semibold">ข้อมูล template</h2>
<form class="max-w-xl space-y-4" @submit.prevent="save">
<h2 class="text-section-title">ข้อมูล template</h2>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label for="edit-name" class="mb-1 block text-sm font-medium">ชื่อ</label>
<input
id="edit-name"
v-model="name"
type="text"
required
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800"
/>
<label for="edit-name" class="label">ชื่อ</label>
<input id="edit-name" v-model="name" type="text" required class="input" />
</div>
<div class="flex items-end gap-2 pb-1">
<input
id="edit-active"
v-model="isActive"
type="checkbox"
class="h-4 w-4 rounded border-slate-300 dark:border-slate-600 dark:bg-slate-800"
/>
<input id="edit-active" v-model="isActive" type="checkbox" class="h-4 w-4 accent-primary" />
<label for="edit-active" class="text-sm">
active
<span class="text-xs text-slate-500 dark:text-slate-400">(เอาออก = สร้างเอกสารใหม่ไม่ได้ แต่เอกสารเดิมยังใช้ได้)</span>
<span class="text-xs text-muted">(เอาออก = สร้างเอกสารใหม่ไม่ได้ แต่เอกสารเดิมยังใช้ได้)</span>
</label>
</div>
</div>
<div>
<label for="edit-desc" class="mb-1 block text-sm font-medium">คำอธิบาย</label>
<textarea
id="edit-desc"
v-model="description"
rows="2"
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800"
/>
<label for="edit-desc" class="label">คำอธิบาย</label>
<textarea id="edit-desc" v-model="description" rows="2" class="input" />
</div>
</form>
<!-- 2) field definitions -->
<div class="space-y-4">
<div>
<h2 class="text-lg font-semibold">Field definitions</h2>
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400">
<h2 class="text-section-title">Field definitions</h2>
<p class="mt-1 text-xs text-muted">
<span class="font-mono">key</span> มาจาก content control tag ในไฟล์ — เพิ่ม/ลบ key ต้องแทนที่ไฟล์ด้านล่าง
</p>
</div>
<div class="overflow-x-auto rounded-lg border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-800">
<!-- มือถือ: card ต่อ field (v-model ชุดเดียวกับตาราง) -->
<ul v-if="fields.length" class="space-y-3 md:hidden">
<li v-for="f in fields" :key="f.key" class="card space-y-3 p-4">
<p class="font-mono text-xs text-muted">{{ f.key }}</p>
<div>
<label :for="`m-label-${f.key}`" class="label">label</label>
<input :id="`m-label-${f.key}`" v-model="f.label" type="text" :aria-label="`label ของ ${f.key}`" class="input" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label :for="`m-type-${f.key}`" class="label">type</label>
<select :id="`m-type-${f.key}`" v-model="f.type" :aria-label="`type ของ ${f.key}`" class="input">
<option v-for="t in FIELD_TYPES" :key="t" :value="t">{{ t }}</option>
</select>
</div>
<div>
<label :for="`m-order-${f.key}`" class="label">ลำดับ</label>
<input
:id="`m-order-${f.key}`"
v-model.number="f.sortOrder"
type="number"
:aria-label="`ลำดับของ ${f.key}`"
class="input"
/>
</div>
</div>
<label class="flex items-center gap-2 text-sm">
<input v-model="f.isRequired" type="checkbox" :aria-label="`required ของ ${f.key}`" class="h-4 w-4 accent-primary" />
required
</label>
</li>
</ul>
<AlertBox v-else-if="fields.length === 0" type="info">
ไฟล์นี้ไม่มี content control เลย — แทนที่ไฟล์ด้วยไฟล์ที่มี content controls
</AlertBox>
<!-- desktop: ตารางแก้ค่า -->
<div v-if="fields.length" class="table-card hidden md:block">
<table class="w-full text-sm">
<thead class="border-b border-slate-200 text-left text-xs uppercase text-slate-500 dark:border-slate-700 dark:text-slate-400">
<thead class="table-head">
<tr>
<th class="px-4 py-3">key</th>
<th class="px-4 py-3">label</th>
@ -219,7 +235,7 @@ onMounted(load)
<th class="px-4 py-3">ลำดับ</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-700">
<tbody class="divide-y divide-line">
<tr v-for="(f, i) in fields" :key="f.key">
<td class="px-4 py-2 font-mono text-xs">{{ f.key }}</td>
<td class="px-4 py-2">
@ -227,15 +243,11 @@ onMounted(load)
v-model="f.label"
type="text"
:aria-label="`label ของ ${f.key}`"
class="w-full min-w-40 rounded-md border border-slate-300 bg-white px-2 py-1 text-sm dark:border-slate-600 dark:bg-slate-800"
class="input min-w-40 px-2 py-1"
/>
</td>
<td class="px-4 py-2">
<select
v-model="f.type"
:aria-label="`type ของ ${f.key}`"
class="rounded-md border border-slate-300 bg-white px-2 py-1 text-sm dark:border-slate-600 dark:bg-slate-800"
>
<select v-model="f.type" :aria-label="`type ของ ${f.key}`" class="input px-2 py-1">
<option v-for="t in FIELD_TYPES" :key="t" :value="t">{{ t }}</option>
</select>
</td>
@ -244,7 +256,7 @@ onMounted(load)
v-model="f.isRequired"
type="checkbox"
:aria-label="`required ของ ${f.key}`"
class="h-4 w-4 rounded border-slate-300 dark:border-slate-600 dark:bg-slate-800"
class="h-4 w-4 accent-primary"
/>
</td>
<td class="px-4 py-2">
@ -252,29 +264,19 @@ onMounted(load)
v-model.number="f.sortOrder"
type="number"
:aria-label="`ลำดับของ ${f.key}`"
class="w-20 rounded-md border border-slate-300 bg-white px-2 py-1 text-sm dark:border-slate-600 dark:bg-slate-800"
class="input w-20 px-2 py-1"
/>
</td>
</tr>
<tr v-if="fields.length === 0">
<td colspan="5" class="px-4 py-6 text-center text-sm text-slate-500 dark:text-slate-400">
ไฟล์นี้ไม่มี content control เลย — แทนที่ไฟล์ด้วยไฟล์ที่มี content controls
</td>
</tr>
</tbody>
</table>
</div>
<div class="flex flex-wrap items-center gap-3">
<button
type="button"
:disabled="saving"
class="rounded-md bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700 disabled:opacity-50"
@click="save"
>
<button type="button" :disabled="saving" class="btn btn-primary" @click="save">
{{ saving ? 'กำลังบันทึก…' : 'บันทึกทั้งหมด' }}
</button>
<span v-if="savedAt && !saveError" class="text-xs text-emerald-600 dark:text-emerald-400">
<span v-if="savedAt && !saveError" class="text-xs text-success-strong">
บันทึกแล้วเมื่อ {{ savedAt }}
</span>
</div>
@ -282,27 +284,27 @@ onMounted(load)
</div>
<!-- 3) replace file -->
<div class="space-y-3 border-t border-slate-200 pt-6 dark:border-slate-700">
<div class="space-y-3 border-t border-line pt-6">
<div>
<h2 class="text-lg font-semibold">แทนที่ไฟล์ template</h2>
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400">
<h2 class="text-section-title">แทนที่ไฟล์ template</h2>
<p class="mt-1 text-xs text-muted">
ระบบจะ detect tags ใหม่จากไฟล์ — field definition ของ tag ที่หายไปจะถูกลบ, tag ใหม่จะเพิ่มแบบ default
(เอกสารที่สร้างไปแล้วไม่กระทบ)
</p>
</div>
<div class="flex flex-wrap items-center gap-3">
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<input
ref="replaceInput"
type="file"
accept=".docx"
class="rounded-md border border-slate-300 bg-white px-3 py-2 text-sm file:mr-3 file:rounded file:border-0 file:bg-slate-100 file:px-3 file:py-1 file:text-sm dark:border-slate-600 dark:bg-slate-800 dark:file:bg-slate-700"
class="input sm:max-w-md file:mr-3 file:rounded-pill file:border-0 file:bg-surface-muted file:px-3 file:py-1 file:text-sm file:font-medium"
@change="onReplaceFileChange"
/>
<button
type="button"
:disabled="replacing || !replaceFile"
class="rounded-md border border-slate-300 px-4 py-2 text-sm hover:bg-slate-100 disabled:opacity-50 dark:border-slate-600 dark:hover:bg-slate-800"
class="btn btn-secondary w-full sm:w-auto"
@click="submitReplaceFile"
>
{{ replacing ? 'กำลังอัปโหลด…' : 'อัปโหลดไฟล์ใหม่' }}

View file

@ -52,120 +52,109 @@ onMounted(load)
<template>
<AuthGate>
<section class="space-y-5">
<div class="flex flex-wrap items-center justify-between gap-3">
<h1 class="text-2xl font-bold">จัดการ template</h1>
<NuxtLink
to="/templates/new"
class="rounded-md bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700"
>
+ สร้าง template ใหม่
</NuxtLink>
<section class="space-y-6">
<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">จัดการ template</h1>
</div>
<div class="flex shrink-0 flex-wrap items-center gap-2">
<NuxtLink to="/templates/new" class="btn btn-primary w-full sm:w-auto">+ สร้าง template ใหม่</NuxtLink>
</div>
</div>
<label class="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300">
<input
v-model="showInactive"
type="checkbox"
class="h-4 w-4 rounded border-slate-300 dark:border-slate-600 dark:bg-slate-800"
/>
<label class="flex items-center gap-2 text-sm text-muted">
<input v-model="showInactive" type="checkbox" class="h-4 w-4 accent-primary" />
แสดง template ที่ deactivate ไว้ด้วย
</label>
<div v-if="loading" class="flex items-center gap-3 py-10 text-sm text-slate-500 dark:text-slate-400">
<span
class="h-4 w-4 animate-spin rounded-full border-2 border-slate-300 border-t-slate-600 dark:border-slate-600 dark:border-t-slate-300"
/>
<div v-if="loading" class="flex items-center gap-3 py-10 text-sm text-muted">
<span class="spinner" />
กำลังโหลด…
</div>
<AlertBox v-else-if="error" type="error">{{ error }}</AlertBox>
<div
v-else-if="templates.length === 0"
class="rounded-lg border border-dashed border-slate-300 p-8 text-center text-sm text-slate-500 dark:border-slate-600 dark:text-slate-400"
>
<div v-else-if="templates.length === 0" class="empty-state">
ยังไม่มี template{{ showInactive ? ' เลย' : 'ที่ active' }} —
<NuxtLink to="/templates/new" class="font-medium text-sky-600 hover:underline dark:text-sky-400">
สร้าง template แรก
</NuxtLink>
<NuxtLink to="/templates/new" class="link">สร้าง template แรก</NuxtLink>
</div>
<div
v-else
class="overflow-x-auto rounded-lg border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-800"
>
<table class="w-full text-sm">
<thead class="border-b border-slate-200 text-left text-xs uppercase text-slate-500 dark:border-slate-700 dark:text-slate-400">
<tr>
<th class="px-4 py-3">ชื่อ</th>
<th class="px-4 py-3">คำอธิบาย</th>
<th class="px-4 py-3 text-center">fields</th>
<th class="px-4 py-3">สถานะ</th>
<th class="px-4 py-3">อัปเดตล่าสุด</th>
<th class="px-4 py-3 text-right">การกระทำ</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-700">
<tr v-for="t in templates" :key="t.id" class="align-top">
<td class="px-4 py-3 font-medium">
<NuxtLink
:to="`/templates/${t.id}/edit`"
class="text-sky-700 hover:underline dark:text-sky-400"
>
{{ t.name }}
</NuxtLink>
</td>
<td class="max-w-60 px-4 py-3 text-slate-600 dark:text-slate-300">{{ t.description ?? '—' }}</td>
<td class="px-4 py-3 text-center">{{ t.fieldCount }}</td>
<td class="px-4 py-3">
<span
v-if="t.isActive"
class="rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300"
>
active
</span>
<span
v-else
class="rounded-full bg-slate-200 px-2 py-0.5 text-xs font-medium text-slate-600 dark:bg-slate-700 dark:text-slate-300"
>
deactivated
</span>
</td>
<td class="px-4 py-3 text-slate-500 dark:text-slate-400">{{ formatDateTime(t.updatedAt) }}</td>
<td class="px-4 py-3 text-right whitespace-nowrap">
<NuxtLink
:to="`/templates/${t.id}/documents`"
class="rounded-md border border-slate-300 px-2.5 py-1 text-xs hover:bg-slate-100 dark:border-slate-600 dark:hover:bg-slate-700"
>
เอกสาร
</NuxtLink>
<NuxtLink
:to="`/templates/${t.id}/edit`"
class="ml-2 rounded-md border border-slate-300 px-2.5 py-1 text-xs hover:bg-slate-100 dark:border-slate-600 dark:hover:bg-slate-700"
>
แก้ไข
</NuxtLink>
<button
type="button"
:disabled="busyId === t.id"
class="ml-2 rounded-md border px-2.5 py-1 text-xs disabled:opacity-50"
:class="
t.isActive
? 'border-red-300 text-red-600 hover:bg-red-50 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-950'
: 'border-emerald-300 text-emerald-600 hover:bg-emerald-50 dark:border-emerald-800 dark:text-emerald-400 dark:hover:bg-emerald-950'
"
@click="toggleActive(t)"
>
{{ busyId === t.id ? '…' : t.isActive ? 'deactivate' : 'activate' }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<template v-else>
<!-- มือถือ: card list -->
<ul class="space-y-3 md:hidden">
<li v-for="t in templates" :key="t.id" class="card space-y-3 p-4">
<div class="flex items-start justify-between gap-3">
<NuxtLink :to="`/templates/${t.id}/edit`" class="link min-w-0 break-words font-semibold">
{{ t.name }}
</NuxtLink>
<span v-if="t.isActive" class="badge badge-success shrink-0">active</span>
<span v-else class="badge badge-neutral shrink-0">deactivated</span>
</div>
<p class="line-clamp-2 text-sm text-muted">{{ t.description ?? '—' }}</p>
<p class="text-xs text-muted">
{{ t.fieldCount }} fields · อัปเดต {{ formatDateTime(t.updatedAt) }}
</p>
<div class="flex flex-wrap gap-2">
<NuxtLink :to="`/templates/${t.id}/documents`" class="btn btn-secondary btn-xs">เอกสาร</NuxtLink>
<NuxtLink :to="`/templates/${t.id}/edit`" class="btn btn-secondary btn-xs">แก้ไข</NuxtLink>
<button
type="button"
:disabled="busyId === t.id"
class="btn btn-xs"
:class="t.isActive ? 'btn-outline-danger' : 'btn-outline-success'"
@click="toggleActive(t)"
>
{{ busyId === t.id ? '…' : t.isActive ? 'deactivate' : 'activate' }}
</button>
</div>
</li>
</ul>
<p class="text-xs text-slate-500 dark:text-slate-400">
<!-- desktop: ตาราง -->
<div class="table-card hidden md:block">
<table class="w-full text-sm">
<thead class="table-head">
<tr>
<th class="px-4 py-3">ชื่อ</th>
<th class="px-4 py-3">คำอธิบาย</th>
<th class="px-4 py-3 text-center">fields</th>
<th class="px-4 py-3">สถานะ</th>
<th class="px-4 py-3">อัปเดตล่าสุด</th>
<th class="px-4 py-3 text-right">การกระทำ</th>
</tr>
</thead>
<tbody class="divide-y divide-line">
<tr v-for="t in templates" :key="t.id" class="align-top">
<td class="px-4 py-3 font-medium">
<NuxtLink :to="`/templates/${t.id}/edit`" class="link">{{ t.name }}</NuxtLink>
</td>
<td class="max-w-60 px-4 py-3 text-muted">{{ t.description ?? '—' }}</td>
<td class="px-4 py-3 text-center">{{ t.fieldCount }}</td>
<td class="px-4 py-3">
<span v-if="t.isActive" class="badge badge-success">active</span>
<span v-else class="badge badge-neutral">deactivated</span>
</td>
<td class="px-4 py-3 text-muted">{{ formatDateTime(t.updatedAt) }}</td>
<td class="whitespace-nowrap px-4 py-3 text-right">
<NuxtLink :to="`/templates/${t.id}/documents`" class="btn btn-secondary btn-xs">เอกสาร</NuxtLink>
<NuxtLink :to="`/templates/${t.id}/edit`" class="btn btn-secondary btn-xs ml-2">แก้ไข</NuxtLink>
<button
type="button"
:disabled="busyId === t.id"
class="btn btn-xs ml-2"
:class="t.isActive ? 'btn-outline-danger' : 'btn-outline-success'"
@click="toggleActive(t)"
>
{{ busyId === t.id ? '…' : t.isActive ? 'deactivate' : 'activate' }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<p class="text-xs text-muted">
การ deactivate เป็น soft-delete — เอกสารที่สร้างจาก template นั้นไปแล้วยังใช้งานต่อได้ตามปกติ
</p>
</section>

View file

@ -61,8 +61,8 @@ function resetForm() {
<template>
<AuthGate>
<section class="mx-auto max-w-2xl space-y-5">
<h1 class="text-2xl font-bold">สร้าง template ใหม่</h1>
<section class="mx-auto max-w-xl space-y-6">
<h1 class="text-page-title">สร้าง template ใหม่</h1>
<!-- ผลลัพธ์หลังอัปโหลดสำเร็จ: field list ที่ detect ได้จากไฟล์ -->
<template v-if="created">
@ -70,16 +70,26 @@ function resetForm() {
อัปโหลดสำเร็จ — ตรวจพบ <strong>{{ created.fields.length }}</strong> fields จากไฟล์
</AlertBox>
<div class="overflow-x-auto rounded-lg border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-800">
<!-- มือถือ: card list -->
<ul class="space-y-3 md:hidden">
<li v-for="f in created.fields" :key="f.key" class="card space-y-1 p-4">
<p class="font-mono text-xs text-muted">{{ f.key }}</p>
<p class="text-sm font-medium">{{ f.label }}</p>
<p class="text-xs text-muted">type: {{ f.type }}</p>
</li>
</ul>
<!-- desktop: ตาราง -->
<div class="table-card hidden md:block">
<table class="w-full text-sm">
<thead class="border-b border-slate-200 text-left text-xs uppercase text-slate-500 dark:border-slate-700 dark:text-slate-400">
<thead class="table-head">
<tr>
<th class="px-4 py-3">key (SDT tag)</th>
<th class="px-4 py-3">label เริ่มต้น</th>
<th class="px-4 py-3">type เริ่มต้น</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-700">
<tbody class="divide-y divide-line">
<tr v-for="f in created.fields" :key="f.key">
<td class="px-4 py-2 font-mono text-xs">{{ f.key }}</td>
<td class="px-4 py-2">{{ f.label }}</td>
@ -89,20 +99,13 @@ function resetForm() {
</table>
</div>
<div class="flex gap-3">
<NuxtLink
:to="`/templates/${created.id}/edit`"
class="rounded-md bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700"
>
ตั้งค่า label/type/required ต่อ →
</NuxtLink>
<button
type="button"
class="rounded-md border border-slate-300 px-4 py-2 text-sm hover:bg-slate-100 dark:border-slate-600 dark:hover:bg-slate-800"
@click="resetForm"
>
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button type="button" class="btn btn-secondary w-full sm:w-auto" @click="resetForm">
อัปโหลดอีกไฟล์
</button>
<NuxtLink :to="`/templates/${created.id}/edit`" class="btn btn-primary w-full sm:w-auto">
ตั้งค่า label/type/required ต่อ →
</NuxtLink>
</div>
</template>
@ -110,40 +113,40 @@ function resetForm() {
<AlertBox v-if="error" type="error">{{ error }}</AlertBox>
<div>
<label for="tpl-name" class="mb-1 block text-sm font-medium">ชื่อ template <span class="text-red-500">*</span></label>
<label for="tpl-name" class="label">ชื่อ template <span class="text-error-strong">*</span></label>
<input
id="tpl-name"
v-model="name"
type="text"
required
placeholder="เช่น สัญญาจ้างงาน"
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800"
class="input"
/>
</div>
<div>
<label for="tpl-desc" class="mb-1 block text-sm font-medium">คำอธิบาย</label>
<label for="tpl-desc" class="label">คำอธิบาย</label>
<textarea
id="tpl-desc"
v-model="description"
rows="2"
placeholder="คำอธิบายสั้นๆ ว่าเอกสารนี้ใช้ทำอะไร"
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800"
class="input"
/>
</div>
<div>
<label for="tpl-file" class="mb-1 block text-sm font-medium">ไฟล์ template (.docx) <span class="text-red-500">*</span></label>
<label for="tpl-file" class="label">ไฟล์ template (.docx) <span class="text-error-strong">*</span></label>
<input
id="tpl-file"
ref="fileInput"
type="file"
accept=".docx"
required
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm file:mr-3 file:rounded file:border-0 file:bg-slate-100 file:px-3 file:py-1 file:text-sm dark:border-slate-600 dark:bg-slate-800 dark:file:bg-slate-700"
class="input file:mr-3 file:rounded-pill file:border-0 file:bg-surface-muted file:px-3 file:py-1 file:text-sm file:font-medium"
@change="onFileChange"
/>
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400">
<p class="mt-1 text-xs text-muted">
สร้างไฟล์ด้วย Word แล้วใส่
<strong>Content Controls</strong> (Developer → Rich Text/Plain Text Control) โดยตั้งค่า
<span class="font-mono">Tag</span> ของแต่ละ control เป็น field key — ระบบจะ detect tags
@ -151,13 +154,11 @@ function resetForm() {
</p>
</div>
<button
type="submit"
:disabled="submitting"
class="rounded-md bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700 disabled:opacity-50"
>
{{ submitting ? 'กำลังอัปโหลด…' : 'อัปโหลด template' }}
</button>
<div class="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
<button type="submit" :disabled="submitting" class="btn btn-primary w-full sm:w-auto">
{{ submitting ? 'กำลังอัปโหลด…' : 'อัปโหลด template' }}
</button>
</div>
</form>
</section>
</AuthGate>

View file

@ -11,6 +11,13 @@ export default defineNuxtConfig({
modules: ['@nuxtjs/tailwindcss'],
// ต้องระบุ cssPath ชัด (alias ~/ ชี้ srcDir = app/) — default แบบ relative
// "assets/css/tailwind.css" จะ resolve ต่อ rootDir แล้ว fallback ไปไฟล์ default
// ของ tailwindcss package แบบเงียบๆ (token/component classes ทั้งหมดหายจาก build)
tailwindcss: {
cssPath: '~/assets/css/tailwind.css',
},
// config ทั้งหมดแยกเป็น env ได้ (กติกา: ห้าม hardcode) — override ด้วย env
// NUXT_PUBLIC_API_BASE_URL / NUXT_PUBLIC_KEYCLOAK_AUTHORITY / NUXT_PUBLIC_KEYCLOAK_CLIENT_ID
runtimeConfig: {
@ -28,6 +35,17 @@ export default defineNuxtConfig({
app: {
head: {
title: 'Word Versioning',
htmlAttrs: { lang: 'th' },
// ฟอนต์ไทย: Sarabun (ใกล้ Sukhumvit ที่สุดบน Google Fonts) — ต้องมี 500/600 ด้วย
// เพราะ UI ใช้ font-medium/font-semibold หนัก (โหลดแค่ 400/700 จะเกิด faux-bold)
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',
},
],
// กัน dark-mode flash (FOUC) ก่อน Vue hydrate — ต้องใช้คีย์/ค่าเดียวกับ useTheme.ts
script: [
{

View file

@ -3,4 +3,46 @@ import type { Config } from 'tailwindcss'
export default {
// dark mode คุมด้วย class บน <html> (toggle จาก useTheme)
darkMode: 'class',
theme: {
extend: {
// สีทั้งหมดอ่านจาก CSS vars ใน app/assets/css/tailwind.css (:root + .dark)
// → สลับโหมดที่ตัวแปร ไม่ต้องมีคู่ dark: class ตาม markup
colors: {
primary: 'rgb(var(--c-primary) / <alpha-value>)',
'primary-hover': 'rgb(var(--c-primary-hover) / <alpha-value>)',
'on-primary': 'rgb(var(--c-on-primary) / <alpha-value>)',
accent: 'rgb(var(--c-accent) / <alpha-value>)',
success: 'rgb(var(--c-success) / <alpha-value>)',
'success-strong': 'rgb(var(--c-success-strong) / <alpha-value>)',
error: 'rgb(var(--c-error) / <alpha-value>)',
'error-strong': 'rgb(var(--c-error-strong) / <alpha-value>)',
focus: 'rgb(var(--c-focus) / <alpha-value>)',
surface: 'rgb(var(--c-surface) / <alpha-value>)',
elevated: 'rgb(var(--c-elevated) / <alpha-value>)',
'surface-muted': 'rgb(var(--c-surface-muted) / <alpha-value>)',
ink: 'rgb(var(--c-ink) / <alpha-value>)',
muted: 'rgb(var(--c-muted) / <alpha-value>)',
line: 'rgb(var(--c-line) / <alpha-value>)',
},
fontFamily: {
// Mac ได้ Sukhumvit Set จริง — นอกนั้น fallback ไป Sarabun (Google Fonts)
sans: ['Sukhumvit Set', 'Sarabun', 'system-ui', '-apple-system', 'Segoe UI', 'sans-serif'],
},
fontSize: {
display: ['2.25rem', { lineHeight: '1.2', fontWeight: '700' }], // 36px — h1 หน้าหลัก
'page-title': ['1.75rem', { lineHeight: '1.25', fontWeight: '700' }], // 28px — h1 หน้าอื่น
'section-title': ['1.25rem', { lineHeight: '1.4', fontWeight: '700' }], // 20px — h2 section
'body-lg': ['1.125rem', { lineHeight: '1.5' }], // 18px — prose
},
borderRadius: {
DEFAULT: '0.5rem', // rounded เปล่า ๆ (รวม file:rounded) → 8px ตาม scheme
card: '0.5rem',
pill: '9999px',
},
boxShadow: {
soft: '0 4px 4px 0 rgba(0, 0, 0, 0.25)',
'soft-lg': '0 8px 20px 0 rgba(0, 0, 0, 0.18)',
},
},
},
} satisfies Config