ปรับ look and feel
This commit is contained in:
parent
0148f48204
commit
b47667f918
15 changed files with 915 additions and 523 deletions
188
docs/plans/web-portal-redesign.md
Normal file
188
docs/plans/web-portal-redesign.md
Normal 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
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,18 +117,33 @@ 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">
|
||||
<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="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">field</th>
|
||||
<th class="px-4 py-3">ค่า</th>
|
||||
|
|
@ -141,40 +151,83 @@ 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 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>
|
||||
<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-slate-500 dark:text-slate-400">{{ f.type ?? '—' }}</td>
|
||||
<td class="px-4 py-3 text-slate-500 dark:text-slate-400">
|
||||
<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 v-if="versions.length === 0" class="empty-state">ยังไม่มี version</div>
|
||||
|
||||
<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>
|
||||
|
||||
<div v-else class="overflow-x-auto rounded-lg border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-800">
|
||||
<!-- 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">version</th>
|
||||
<th class="px-4 py-3">เวลา</th>
|
||||
|
|
@ -182,40 +235,43 @@ onMounted(load)
|
|||
<th class="px-4 py-3 text-right">การกระทำ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 dark:divide-slate-700">
|
||||
<tbody class="divide-y divide-line">
|
||||
<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">
|
||||
<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-slate-500 dark:text-slate-400">{{ v.createdByUserId.slice(0, 8) }}…</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="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"
|
||||
class="btn btn-secondary btn-xs"
|
||||
@click.stop="download(v)"
|
||||
>
|
||||
{{ downloadingVersionId === v.versionId ? '…' : '⬇ ดาวน์โหลด' }}
|
||||
</button>
|
||||
<span class="ml-2 text-xs text-slate-400">{{ expandedVersionId === v.versionId ? '▲ ซ่อน fields' : '▼ ดู fields' }}</span>
|
||||
<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-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>
|
||||
<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-slate-500 dark:text-slate-400" :title="e.key">
|
||||
<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="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 v-if="e.missing" class="badge badge-accent ml-1">หายจากเอกสาร</span>
|
||||
</dd>
|
||||
</div>
|
||||
<p v-if="snapshotOf(v).length === 0" class="text-sm text-slate-500 dark:text-slate-400">
|
||||
<p v-if="snapshotOf(v).length === 0" class="text-sm text-muted">
|
||||
version นี้ไม่มี snapshot ของ fields
|
||||
</p>
|
||||
</dl>
|
||||
|
|
@ -225,6 +281,7 @@ onMounted(load)
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
<AlertBox v-if="downloadError" type="error">{{ downloadError }}</AlertBox>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
<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="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"
|
||||
>
|
||||
{{ submitting ? 'กำลังสร้างเอกสาร…' : 'สร้างเอกสาร (version 1)' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,24 +73,35 @@ 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">
|
||||
<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="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">ไฟล์เอกสาร</th>
|
||||
<th class="px-4 py-3">สร้างเมื่อ</th>
|
||||
|
|
@ -104,29 +110,21 @@ onMounted(load)
|
|||
<th class="px-4 py-3 text-right">การกระทำ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 dark:divide-slate-700">
|
||||
<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="font-medium text-sky-700 hover:underline dark:text-sky-400"
|
||||
>
|
||||
{{ d.fileName }}
|
||||
</NuxtLink>
|
||||
<NuxtLink :to="`/documents/${d.documentId}`" class="link">{{ 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-muted">{{ 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">
|
||||
<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="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 :to="`/documents/${d.documentId}`" class="btn btn-secondary btn-xs">
|
||||
ดูรายละเอียด
|
||||
</NuxtLink>
|
||||
</td>
|
||||
|
|
@ -135,6 +133,7 @@ onMounted(load)
|
|||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</section>
|
||||
</AuthGate>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -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 ? 'กำลังอัปโหลด…' : 'อัปโหลดไฟล์ใหม่' }}
|
||||
|
|
|
|||
|
|
@ -52,51 +52,68 @@ 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"
|
||||
<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>
|
||||
|
||||
<!-- 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">ชื่อ</th>
|
||||
<th class="px-4 py-3">คำอธิบาย</th>
|
||||
|
|
@ -106,55 +123,26 @@ onMounted(load)
|
|||
<th class="px-4 py-3 text-right">การกระทำ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 dark:divide-slate-700">
|
||||
<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="text-sky-700 hover:underline dark:text-sky-400"
|
||||
>
|
||||
{{ t.name }}
|
||||
</NuxtLink>
|
||||
<NuxtLink :to="`/templates/${t.id}/edit`" class="link">{{ 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="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="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>
|
||||
<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-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>
|
||||
<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="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'
|
||||
"
|
||||
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' }}
|
||||
|
|
@ -164,8 +152,9 @@ onMounted(load)
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400">
|
||||
<p class="text-xs text-muted">
|
||||
การ deactivate เป็น soft-delete — เอกสารที่สร้างจาก template นั้นไปแล้วยังใช้งานต่อได้ตามปกติ
|
||||
</p>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue