ปรับ 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

@ -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>