2026-08-20 06:20:16 +07:00
|
|
|
<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">
|
2026-08-20 21:34:45 +07:00
|
|
|
<div v-if="loading" class="flex items-center gap-3 py-10 text-sm text-muted">
|
|
|
|
|
<span class="spinner" />
|
2026-08-20 06:20:16 +07:00
|
|
|
กำลังโหลด…
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<AlertBox v-else-if="loadError" type="error">{{ loadError }}</AlertBox>
|
|
|
|
|
|
|
|
|
|
<template v-else-if="detail">
|
2026-08-20 21:34:45 +07:00
|
|
|
<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">
|
2026-08-20 06:20:16 +07:00
|
|
|
สร้าง {{ formatDateTime(detail.createdAt) }} · อัปเดต {{ formatDateTime(detail.updatedAt) }}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
2026-08-20 21:34:45 +07:00
|
|
|
<div class="flex shrink-0 flex-wrap items-center gap-2">
|
|
|
|
|
<NuxtLink to="/templates" class="btn btn-secondary btn-sm">← รายการ template</NuxtLink>
|
|
|
|
|
</div>
|
2026-08-20 06:20:16 +07:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 1) metadata -->
|
2026-08-20 21:34:45 +07:00
|
|
|
<form class="max-w-xl space-y-4" @submit.prevent="save">
|
|
|
|
|
<h2 class="text-section-title">ข้อมูล template</h2>
|
2026-08-20 06:20:16 +07:00
|
|
|
|
|
|
|
|
<div class="grid gap-4 sm:grid-cols-2">
|
|
|
|
|
<div>
|
2026-08-20 21:34:45 +07:00
|
|
|
<label for="edit-name" class="label">ชื่อ</label>
|
|
|
|
|
<input id="edit-name" v-model="name" type="text" required class="input" />
|
2026-08-20 06:20:16 +07:00
|
|
|
</div>
|
|
|
|
|
<div class="flex items-end gap-2 pb-1">
|
2026-08-20 21:34:45 +07:00
|
|
|
<input id="edit-active" v-model="isActive" type="checkbox" class="h-4 w-4 accent-primary" />
|
2026-08-20 06:20:16 +07:00
|
|
|
<label for="edit-active" class="text-sm">
|
|
|
|
|
active
|
2026-08-20 21:34:45 +07:00
|
|
|
<span class="text-xs text-muted">(เอาออก = สร้างเอกสารใหม่ไม่ได้ แต่เอกสารเดิมยังใช้ได้)</span>
|
2026-08-20 06:20:16 +07:00
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
2026-08-20 21:34:45 +07:00
|
|
|
<label for="edit-desc" class="label">คำอธิบาย</label>
|
|
|
|
|
<textarea id="edit-desc" v-model="description" rows="2" class="input" />
|
2026-08-20 06:20:16 +07:00
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
|
|
|
|
|
<!-- 2) field definitions -->
|
|
|
|
|
<div class="space-y-4">
|
|
|
|
|
<div>
|
2026-08-20 21:34:45 +07:00
|
|
|
<h2 class="text-section-title">Field definitions</h2>
|
|
|
|
|
<p class="mt-1 text-xs text-muted">
|
2026-08-20 06:20:16 +07:00
|
|
|
<span class="font-mono">key</span> มาจาก content control tag ในไฟล์ — เพิ่ม/ลบ key ต้องแทนที่ไฟล์ด้านล่าง
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-08-20 21:34:45 +07:00
|
|
|
<!-- มือถือ: 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">
|
2026-08-20 06:20:16 +07:00
|
|
|
<table class="w-full text-sm">
|
2026-08-20 21:34:45 +07:00
|
|
|
<thead class="table-head">
|
2026-08-20 06:20:16 +07:00
|
|
|
<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>
|
2026-08-20 21:34:45 +07:00
|
|
|
<tbody class="divide-y divide-line">
|
2026-08-20 06:20:16 +07:00
|
|
|
<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}`"
|
2026-08-20 21:34:45 +07:00
|
|
|
class="input min-w-40 px-2 py-1"
|
2026-08-20 06:20:16 +07:00
|
|
|
/>
|
|
|
|
|
</td>
|
|
|
|
|
<td class="px-4 py-2">
|
2026-08-20 21:34:45 +07:00
|
|
|
<select v-model="f.type" :aria-label="`type ของ ${f.key}`" class="input px-2 py-1">
|
2026-08-20 06:20:16 +07:00
|
|
|
<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}`"
|
2026-08-20 21:34:45 +07:00
|
|
|
class="h-4 w-4 accent-primary"
|
2026-08-20 06:20:16 +07:00
|
|
|
/>
|
|
|
|
|
</td>
|
|
|
|
|
<td class="px-4 py-2">
|
|
|
|
|
<input
|
|
|
|
|
v-model.number="f.sortOrder"
|
|
|
|
|
type="number"
|
|
|
|
|
:aria-label="`ลำดับของ ${f.key}`"
|
2026-08-20 21:34:45 +07:00
|
|
|
class="input w-20 px-2 py-1"
|
2026-08-20 06:20:16 +07:00
|
|
|
/>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="flex flex-wrap items-center gap-3">
|
2026-08-20 21:34:45 +07:00
|
|
|
<button type="button" :disabled="saving" class="btn btn-primary" @click="save">
|
2026-08-20 06:20:16 +07:00
|
|
|
{{ saving ? 'กำลังบันทึก…' : 'บันทึกทั้งหมด' }}
|
|
|
|
|
</button>
|
2026-08-20 21:34:45 +07:00
|
|
|
<span v-if="savedAt && !saveError" class="text-xs text-success-strong">
|
2026-08-20 06:20:16 +07:00
|
|
|
บันทึกแล้วเมื่อ {{ savedAt }}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<AlertBox v-if="saveError" type="error">{{ saveError }}</AlertBox>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 3) replace file -->
|
2026-08-20 21:34:45 +07:00
|
|
|
<div class="space-y-3 border-t border-line pt-6">
|
2026-08-20 06:20:16 +07:00
|
|
|
<div>
|
2026-08-20 21:34:45 +07:00
|
|
|
<h2 class="text-section-title">แทนที่ไฟล์ template</h2>
|
|
|
|
|
<p class="mt-1 text-xs text-muted">
|
2026-08-20 06:20:16 +07:00
|
|
|
ระบบจะ detect tags ใหม่จากไฟล์ — field definition ของ tag ที่หายไปจะถูกลบ, tag ใหม่จะเพิ่มแบบ default
|
|
|
|
|
(เอกสารที่สร้างไปแล้วไม่กระทบ)
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-08-20 21:34:45 +07:00
|
|
|
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
2026-08-20 06:20:16 +07:00
|
|
|
<input
|
|
|
|
|
ref="replaceInput"
|
|
|
|
|
type="file"
|
|
|
|
|
accept=".docx"
|
2026-08-20 21:34:45 +07:00
|
|
|
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"
|
2026-08-20 06:20:16 +07:00
|
|
|
@change="onReplaceFileChange"
|
|
|
|
|
/>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
:disabled="replacing || !replaceFile"
|
2026-08-20 21:34:45 +07:00
|
|
|
class="btn btn-secondary w-full sm:w-auto"
|
2026-08-20 06:20:16 +07:00
|
|
|
@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>
|