phase 6 เบื้องต้น
This commit is contained in:
parent
1bb850c6b8
commit
93570d38d8
20 changed files with 1715 additions and 34 deletions
53
web/app/pages/auth/callback.vue
Normal file
53
web/app/pages/auth/callback.vue
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<script setup lang="ts">
|
||||
/** ปลายทางของ OIDC redirect — รับ code+state แลก token แล้วพากลับหน้าที่ login จาก (ทำงานฝั่ง client ทั้งหมด) */
|
||||
const route = useRoute()
|
||||
const { handleCallback, login } = useAuth()
|
||||
|
||||
const message = ref('กำลังตรวจสอบผลการเข้าสู่ระบบ…')
|
||||
const failed = ref(false)
|
||||
const busy = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
const result = await handleCallback(route.query)
|
||||
if (result.ok) {
|
||||
// replace เพื่อเช็ด ?code=...&state=... ออกจาก history
|
||||
await navigateTo(result.returnTo ?? '/', { replace: true })
|
||||
return
|
||||
}
|
||||
failed.value = true
|
||||
busy.value = false
|
||||
message.value = result.error ?? 'เข้าสู่ระบบไม่สำเร็จ'
|
||||
})
|
||||
</script>
|
||||
|
||||
<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"
|
||||
/>
|
||||
{{ 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()"
|
||||
>
|
||||
เข้าสู่ระบบใหม่
|
||||
</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>
|
||||
</template>
|
||||
222
web/app/pages/documents/create/[templateId].vue
Normal file
222
web/app/pages/documents/create/[templateId].vue
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
<script setup lang="ts">
|
||||
/** FR-10 UI — dynamic form สร้างเอกสารจาก field definitions ของ template → generate เป็น v1 + ดาวน์โหลด */
|
||||
import type { GenerateDocumentResponse, TemplateDetail } from '~/types/api'
|
||||
|
||||
const route = useRoute()
|
||||
const { apiJson, downloadFile } = useApi()
|
||||
|
||||
const templateId = computed(() => String(route.params.templateId))
|
||||
const detail = ref<TemplateDetail | null>(null)
|
||||
const values = ref<Record<string, string>>({})
|
||||
const issues = ref<Record<string, string>>({})
|
||||
|
||||
const loading = ref(true)
|
||||
const loadError = ref<string | null>(null)
|
||||
|
||||
const submitting = ref(false)
|
||||
const submitError = ref<string | null>(null)
|
||||
const result = ref<GenerateDocumentResponse | null>(null)
|
||||
const downloading = ref(false)
|
||||
const downloadError = ref<string | null>(null)
|
||||
|
||||
const sortedFields = computed(() => [...(detail.value?.fields ?? [])].sort((a, b) => a.sortOrder - b.sortOrder))
|
||||
|
||||
useHead({
|
||||
title: computed(() =>
|
||||
detail.value
|
||||
? `สร้างเอกสาร: ${detail.value.name} — e-Court Documents Versioning`
|
||||
: 'สร้างเอกสาร — e-Court Documents Versioning',
|
||||
),
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
detail.value = (await apiJson<TemplateDetail>(`/api/templates/${templateId.value}`))!
|
||||
values.value = Object.fromEntries(detail.value.fields.map((f) => [f.key, '']))
|
||||
} catch (err) {
|
||||
loadError.value = err instanceof Error ? err.message : 'โหลด template ไม่สำเร็จ'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** validate ฝั่ง client (required/type) ก่อนยิง backend — backend ตรวจซ้ำอีกชั้นเสมอ */
|
||||
async function submit() {
|
||||
submitError.value = null
|
||||
downloadError.value = null
|
||||
issues.value = Object.fromEntries(
|
||||
validateFieldValues(sortedFields.value, values.value).map((i) => [i.key, i.message]),
|
||||
)
|
||||
if (Object.keys(issues.value).length > 0) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
result.value = await apiJson<GenerateDocumentResponse>('/api/documents/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ templateId: templateId.value, values: values.value }),
|
||||
})
|
||||
window.scrollTo({ top: 0 })
|
||||
} catch (err) {
|
||||
submitError.value = err instanceof Error ? err.message : 'สร้างเอกสารไม่สำเร็จ'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function download() {
|
||||
if (!result.value) return
|
||||
downloading.value = true
|
||||
downloadError.value = null
|
||||
try {
|
||||
const name = detail.value ? `${detail.value.name}_v${result.value.versionNumber}.docx` : 'document_v1.docx'
|
||||
await downloadFile(result.value.downloadUrl, name)
|
||||
} catch (err) {
|
||||
downloadError.value = err instanceof Error ? err.message : 'ดาวน์โหลดไม่สำเร็จ'
|
||||
} finally {
|
||||
downloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** กลับไปกรอกฟอร์มใหม่เพื่อสร้างอีกฉบับจาก template เดิม */
|
||||
function resetForm() {
|
||||
result.value = null
|
||||
submitError.value = null
|
||||
downloadError.value = null
|
||||
issues.value = {}
|
||||
values.value = Object.fromEntries((detail.value?.fields ?? []).map((f) => [f.key, '']))
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<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"
|
||||
/>
|
||||
กำลังโหลด template…
|
||||
</div>
|
||||
|
||||
<AlertBox v-else-if="loadError" type="error">
|
||||
{{ loadError }}
|
||||
<NuxtLink to="/" class="ml-1 font-medium underline">กลับหน้าหลัก</NuxtLink>
|
||||
</AlertBox>
|
||||
|
||||
<template v-else-if="detail">
|
||||
<!-- ผลลัพธ์: สร้างสำเร็จเป็น v1 แล้ว -->
|
||||
<template v-if="result">
|
||||
<AlertBox type="success">
|
||||
สร้างเอกสารสำเร็จ — บันทึกเป็น <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>
|
||||
<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>
|
||||
<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>
|
||||
<dd>{{ result.versionNumber }} (ต้นทางของ version history)</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="mt-5 flex flex-wrap items-center gap-3">
|
||||
<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"
|
||||
@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">
|
||||
<li>เปิดไฟล์ที่ดาวน์โหลดใน Word (Desktop หรือ Online)</li>
|
||||
<li>
|
||||
เปิด task pane ของ add-in <strong>e-Court Documents Versioning</strong> — ระบบจะตรวจพบว่าเป็น
|
||||
tracked document และแสดง <strong>version 1</strong> ทันที โดยไม่ต้องกด Start Versioning
|
||||
</li>
|
||||
<li>แก้ไขเอกสารต่อแล้วกด <strong>Save & Version</strong> เพื่อบันทึก version ถัดไป</li>
|
||||
</ol>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ฟอร์มกรอกค่า -->
|
||||
<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">
|
||||
{{ detail.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="f in sortedFields"
|
||||
:key="f.key"
|
||||
>
|
||||
<label :for="`field-${f.key}`" class="mb-1 block text-sm font-medium">
|
||||
{{ 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>
|
||||
</label>
|
||||
|
||||
<input
|
||||
:id="`field-${f.key}`"
|
||||
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' : ''"
|
||||
/>
|
||||
<p v-if="issues[f.key]" class="mt-1 text-xs text-red-600 dark:text-red-400">{{ 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"
|
||||
>
|
||||
template นี้ไม่มี field definitions —
|
||||
<NuxtLink :to="`/templates/${detail.id}/edit`" class="font-medium text-sky-600 hover:underline dark:text-sky-400">
|
||||
ตรวจสอบที่หน้าจัดการ template
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<AlertBox v-if="submitError" type="error">{{ submitError }}</AlertBox>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="submitting || sortedFields.length === 0"
|
||||
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>
|
||||
</form>
|
||||
</template>
|
||||
</section>
|
||||
</AuthGate>
|
||||
</template>
|
||||
|
|
@ -1,15 +1,71 @@
|
|||
<script setup lang="ts">
|
||||
/** FR-10 UI — หน้าหลัก: card รายการ template ที่ active ให้เลือกสร้างเอกสาร */
|
||||
import type { TemplateListItem } from '~/types/api'
|
||||
|
||||
const { apiJson } = useApi()
|
||||
|
||||
const templates = ref<TemplateListItem[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
templates.value = (await apiJson<TemplateListItem[]>('/api/templates')) ?? []
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'โหลดรายการ template ไม่สำเร็จ'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-4">
|
||||
<h1 class="text-2xl font-bold">สร้างเอกสารจาก template</h1>
|
||||
<p class="text-slate-600 dark:text-slate-300">
|
||||
เลือก template กรอกค่า fields แล้วดาวน์โหลด .docx ที่เป็น version 1 พร้อม metadata
|
||||
ไปเปิดต่อใน Word พร้อม add-in
|
||||
</p>
|
||||
<!-- Phase 6: รายการ template ที่ active + ลิงก์เข้าหน้า documents/create/[templateId] -->
|
||||
<div
|
||||
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"
|
||||
>
|
||||
รายการ template จะแสดงที่นี่ (Phase 6)
|
||||
</div>
|
||||
</section>
|
||||
<AuthGate>
|
||||
<section class="space-y-5">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">สร้างเอกสารจาก template</h1>
|
||||
<p class="mt-1 text-sm text-slate-600 dark:text-slate-300">
|
||||
เลือก 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"
|
||||
/>
|
||||
กำลังโหลดรายการ 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"
|
||||
>
|
||||
ยังไม่มี template — ไปที่
|
||||
<NuxtLink to="/templates/new" class="font-medium text-sky-600 hover:underline dark:text-sky-400">
|
||||
สร้าง template ตัวแรก
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid gap-4 sm:grid-cols-2">
|
||||
<NuxtLink
|
||||
v-for="t in templates"
|
||||
:key="t.id"
|
||||
:to="`/documents/create/${t.id}`"
|
||||
class="block 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"
|
||||
>
|
||||
<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">
|
||||
{{ t.description ?? '—' }}
|
||||
</p>
|
||||
<p class="mt-3 text-xs text-slate-500 dark:text-slate-400">
|
||||
{{ t.fieldCount }} fields · อัปเดต {{ formatDateTime(t.updatedAt) }}
|
||||
</p>
|
||||
<p class="mt-3 text-sm font-medium text-sky-600 dark:text-sky-400">สร้างเอกสาร →</p>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</section>
|
||||
</AuthGate>
|
||||
</template>
|
||||
|
|
|
|||
325
web/app/pages/templates/[id]/edit.vue
Normal file
325
web/app/pages/templates/[id]/edit.vue
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
<script setup lang="ts">
|
||||
/** FR-9 UI — แก้ไข template: metadata + field definitions (label/type/required/ลำดับ) + แทนที่ไฟล์ */
|
||||
import type { FieldType, ReplaceTemplateFileResponse, TemplateDetail } from '~/types/api'
|
||||
|
||||
const route = useRoute()
|
||||
const { apiJson, apiUpload } = useApi()
|
||||
useHead({ title: 'แก้ไข template — e-Court Documents Versioning' })
|
||||
|
||||
const templateId = computed(() => String(route.params.id))
|
||||
const FIELD_TYPES: FieldType[] = ['text', 'number', 'date']
|
||||
|
||||
const detail = ref<TemplateDetail | null>(null)
|
||||
const loading = ref(true)
|
||||
const loadError = ref<string | null>(null)
|
||||
|
||||
// ฟอร์ม metadata
|
||||
const name = ref('')
|
||||
const description = ref('')
|
||||
const isActive = ref(true)
|
||||
|
||||
// ฟอร์ม field defs (แก้ใน local copy แล้วส่งทั้งชุดตอนบันทึก)
|
||||
const fields = ref<(TemplateDetail['fields'][number] & { label: string })[]>([])
|
||||
|
||||
const saving = ref(false)
|
||||
const saveError = ref<string | null>(null)
|
||||
const savedAt = ref<string | null>(null)
|
||||
|
||||
const replaceFile = ref<File | null>(null)
|
||||
const replaceInput = ref<HTMLInputElement | null>(null)
|
||||
const replacing = ref(false)
|
||||
const replaceError = ref<string | null>(null)
|
||||
const replaceResult = ref<ReplaceTemplateFileResponse | null>(null)
|
||||
|
||||
function applyDetail(d: TemplateDetail) {
|
||||
detail.value = d
|
||||
name.value = d.name
|
||||
description.value = d.description ?? ''
|
||||
isActive.value = d.isActive
|
||||
fields.value = d.fields.map((f) => ({ ...f }))
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
applyDetail((await apiJson<TemplateDetail>(`/api/templates/${templateId.value}`))!)
|
||||
} catch (err) {
|
||||
loadError.value = err instanceof Error ? err.message : 'โหลดข้อมูล template ไม่สำเร็จ'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saveError.value = null
|
||||
savedAt.value = null
|
||||
if (!name.value.trim()) {
|
||||
saveError.value = 'ชื่อ template ห้ามว่าง'
|
||||
return
|
||||
}
|
||||
if (fields.value.some((f) => !f.label.trim())) {
|
||||
saveError.value = 'ทุก field ต้องมี label'
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const updated = await apiJson<TemplateDetail>(`/api/templates/${templateId.value}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name.value.trim(),
|
||||
description: description.value.trim() || null,
|
||||
isActive: isActive.value,
|
||||
fields: fields.value.map((f) => ({
|
||||
key: f.key,
|
||||
label: f.label.trim(),
|
||||
type: f.type,
|
||||
isRequired: f.isRequired,
|
||||
sortOrder: f.sortOrder,
|
||||
})),
|
||||
}),
|
||||
})
|
||||
applyDetail(updated!)
|
||||
savedAt.value = new Date().toLocaleTimeString('th-TH')
|
||||
} catch (err) {
|
||||
saveError.value = err instanceof Error ? err.message : 'บันทึกไม่สำเร็จ'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onReplaceFileChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
replaceFile.value = input.files?.[0] ?? null
|
||||
}
|
||||
|
||||
async function submitReplaceFile() {
|
||||
replaceError.value = null
|
||||
replaceResult.value = null
|
||||
if (!replaceFile.value) {
|
||||
replaceError.value = 'เลือกไฟล์ .docx ใหม่ก่อน'
|
||||
return
|
||||
}
|
||||
if (!replaceFile.value.name.toLowerCase().endsWith('.docx')) {
|
||||
replaceError.value = 'รองรับเฉพาะไฟล์ .docx เท่านั้น'
|
||||
return
|
||||
}
|
||||
|
||||
const form = new FormData()
|
||||
form.append('file', replaceFile.value)
|
||||
|
||||
replacing.value = true
|
||||
try {
|
||||
const result = await apiUpload<ReplaceTemplateFileResponse>(
|
||||
`/api/templates/${templateId.value}/file`,
|
||||
form,
|
||||
'PUT',
|
||||
)
|
||||
replaceResult.value = result
|
||||
await load()
|
||||
replaceFile.value = null
|
||||
if (replaceInput.value) replaceInput.value.value = ''
|
||||
} catch (err) {
|
||||
replaceError.value = err instanceof Error ? err.message : 'แทนที่ไฟล์ไม่สำเร็จ'
|
||||
} finally {
|
||||
replacing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<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>
|
||||
|
||||
<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">
|
||||
สร้าง {{ 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>
|
||||
|
||||
<!-- 1) metadata -->
|
||||
<form class="space-y-4" @submit.prevent="save">
|
||||
<h2 class="text-lg font-semibold">ข้อมูล 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"
|
||||
/>
|
||||
</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"
|
||||
/>
|
||||
<label for="edit-active" class="text-sm">
|
||||
active
|
||||
<span class="text-xs text-slate-500 dark:text-slate-400">(เอาออก = สร้างเอกสารใหม่ไม่ได้ แต่เอกสารเดิมยังใช้ได้)</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"
|
||||
/>
|
||||
</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">
|
||||
<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">
|
||||
<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">key</th>
|
||||
<th class="px-4 py-3">label</th>
|
||||
<th class="px-4 py-3">type</th>
|
||||
<th class="px-4 py-3 text-center">required</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, 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">
|
||||
<input
|
||||
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"
|
||||
/>
|
||||
</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"
|
||||
>
|
||||
<option v-for="t in FIELD_TYPES" :key="t" :value="t">{{ t }}</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="px-4 py-2 text-center">
|
||||
<input
|
||||
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"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<input
|
||||
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"
|
||||
/>
|
||||
</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"
|
||||
>
|
||||
{{ saving ? 'กำลังบันทึก…' : 'บันทึกทั้งหมด' }}
|
||||
</button>
|
||||
<span v-if="savedAt && !saveError" class="text-xs text-emerald-600 dark:text-emerald-400">
|
||||
บันทึกแล้วเมื่อ {{ savedAt }}
|
||||
</span>
|
||||
</div>
|
||||
<AlertBox v-if="saveError" type="error">{{ saveError }}</AlertBox>
|
||||
</div>
|
||||
|
||||
<!-- 3) replace file -->
|
||||
<div class="space-y-3 border-t border-slate-200 pt-6 dark:border-slate-700">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">แทนที่ไฟล์ template</h2>
|
||||
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
ระบบจะ detect tags ใหม่จากไฟล์ — field definition ของ tag ที่หายไปจะถูกลบ, tag ใหม่จะเพิ่มแบบ default
|
||||
(เอกสารที่สร้างไปแล้วไม่กระทบ)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<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"
|
||||
@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"
|
||||
@click="submitReplaceFile"
|
||||
>
|
||||
{{ replacing ? 'กำลังอัปโหลด…' : 'อัปโหลดไฟล์ใหม่' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AlertBox v-if="replaceError" type="error">{{ replaceError }}</AlertBox>
|
||||
<AlertBox v-if="replaceResult" type="success">
|
||||
แทนที่ไฟล์สำเร็จ —
|
||||
เพิ่ม {{ replaceResult.addedTags.length }} tags
|
||||
<span v-if="replaceResult.addedTags.length" class="font-mono">({{ replaceResult.addedTags.join(', ') }})</span>,
|
||||
ลบ {{ replaceResult.removedTags.length }} tags
|
||||
<span v-if="replaceResult.removedTags.length" class="font-mono">({{ replaceResult.removedTags.join(', ') }})</span>
|
||||
— ตรวจและบันทึก field definitions ด้านบนอีกครั้ง
|
||||
</AlertBox>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</AuthGate>
|
||||
</template>
|
||||
167
web/app/pages/templates/index.vue
Normal file
167
web/app/pages/templates/index.vue
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
<script setup lang="ts">
|
||||
/** FR-9 UI — รายการ template ทั้งหมด (admin): ดู/แก้ไข/activate-deactivate */
|
||||
import type { TemplateListItem } from '~/types/api'
|
||||
|
||||
const { apiJson } = useApi()
|
||||
useHead({ title: 'จัดการ template — e-Court Documents Versioning' })
|
||||
|
||||
const templates = ref<TemplateListItem[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const showInactive = ref(false)
|
||||
const busyId = ref<string | null>(null)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const qs = showInactive.value ? '?includeInactive=true' : ''
|
||||
templates.value = (await apiJson<TemplateListItem[]>(`/api/templates${qs}`)) ?? []
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'โหลดรายการ template ไม่สำเร็จ'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** deactivate = DELETE (soft-delete), activate กลับ = PUT { isActive: true } */
|
||||
async function toggleActive(t: TemplateListItem) {
|
||||
const verb = t.isActive ? 'deactivate' : 'activate'
|
||||
if (!window.confirm(`ยืนยัน${verb === 'deactivate' ? 'การ deactivate' : 'การ activate'} template “${t.name}”?`)) return
|
||||
|
||||
busyId.value = t.id
|
||||
error.value = null
|
||||
try {
|
||||
if (t.isActive) await apiJson(`/api/templates/${t.id}`, { method: 'DELETE' })
|
||||
else await apiJson(`/api/templates/${t.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ isActive: true }),
|
||||
})
|
||||
await load()
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : `${verb} ไม่สำเร็จ`
|
||||
} finally {
|
||||
busyId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(showInactive, load)
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<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>
|
||||
</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"
|
||||
/>
|
||||
แสดง 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>
|
||||
|
||||
<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"
|
||||
>
|
||||
ยังไม่มี template{{ showInactive ? ' เลย' : 'ที่ active' }} —
|
||||
<NuxtLink to="/templates/new" class="font-medium text-sky-600 hover:underline dark:text-sky-400">
|
||||
สร้าง 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}/edit`"
|
||||
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>
|
||||
<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>
|
||||
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400">
|
||||
การ deactivate เป็น soft-delete — เอกสารที่สร้างจาก template นั้นไปแล้วยังใช้งานต่อได้ตามปกติ
|
||||
</p>
|
||||
</section>
|
||||
</AuthGate>
|
||||
</template>
|
||||
164
web/app/pages/templates/new.vue
Normal file
164
web/app/pages/templates/new.vue
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
<script setup lang="ts">
|
||||
/** FR-9 UI — upload template ใหม่: ระบบ detect fields (SDT tags) จากไฟล์ให้อัตโนมัติ */
|
||||
import type { CreateTemplateResponse } from '~/types/api'
|
||||
|
||||
const { apiUpload } = useApi()
|
||||
useHead({ title: 'สร้าง template ใหม่ — e-Court Documents Versioning' })
|
||||
|
||||
const name = ref('')
|
||||
const description = ref('')
|
||||
const file = ref<File | null>(null)
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const submitting = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const created = ref<CreateTemplateResponse | null>(null)
|
||||
|
||||
function onFileChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
file.value = input.files?.[0] ?? null
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
error.value = null
|
||||
if (!name.value.trim()) {
|
||||
error.value = 'กรอกชื่อ template ด้วย'
|
||||
return
|
||||
}
|
||||
if (!file.value) {
|
||||
error.value = 'เลือกไฟล์ .docx ของ template ด้วย'
|
||||
return
|
||||
}
|
||||
if (!file.value.name.toLowerCase().endsWith('.docx')) {
|
||||
error.value = 'รองรับเฉพาะไฟล์ .docx เท่านั้น'
|
||||
return
|
||||
}
|
||||
|
||||
const form = new FormData()
|
||||
form.append('name', name.value.trim())
|
||||
if (description.value.trim()) form.append('description', description.value.trim())
|
||||
form.append('file', file.value)
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
created.value = await apiUpload<CreateTemplateResponse>('/api/templates', form)
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'อัปโหลด template ไม่สำเร็จ'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** เคลียร์ฟอร์มเพื่ออัปโหลดไฟล์ถัดไป */
|
||||
function resetForm() {
|
||||
created.value = null
|
||||
name.value = ''
|
||||
description.value = ''
|
||||
file.value = null
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthGate>
|
||||
<section class="mx-auto max-w-2xl space-y-5">
|
||||
<h1 class="text-2xl font-bold">สร้าง template ใหม่</h1>
|
||||
|
||||
<!-- ผลลัพธ์หลังอัปโหลดสำเร็จ: field list ที่ detect ได้จากไฟล์ -->
|
||||
<template v-if="created">
|
||||
<AlertBox type="success">
|
||||
อัปโหลดสำเร็จ — ตรวจพบ <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">
|
||||
<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">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">
|
||||
<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>
|
||||
<td class="px-4 py-2">{{ f.type }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</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"
|
||||
>
|
||||
อัปโหลดอีกไฟล์
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<form v-else class="space-y-4" @submit.prevent="submit">
|
||||
<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>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="tpl-desc" class="mb-1 block text-sm font-medium">คำอธิบาย</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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="tpl-file" class="mb-1 block text-sm font-medium">ไฟล์ template (.docx) <span class="text-red-500">*</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"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
สร้างไฟล์ด้วย Word แล้วใส่
|
||||
<strong>Content Controls</strong> (Developer → Rich Text/Plain Text Control) โดยตั้งค่า
|
||||
<span class="font-mono">Tag</span> ของแต่ละ control เป็น field key — ระบบจะ detect tags
|
||||
ออกมาเป็นรายการ fields ให้อัตโนมัติ
|
||||
</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>
|
||||
</form>
|
||||
</section>
|
||||
</AuthGate>
|
||||
</template>
|
||||
Loading…
Add table
Add a link
Reference in a new issue