Phase 10: field editor (FR-14)

เพิ่ม editor ต่อ template field — ผู้ดูแลเลือกรูปแบบช่องกรอกในฟอร์มสร้างเอกสารได้:
text → textbox/textarea, number/date → control ตาม type (presentation metadata
ล้วน — validation ยังยึด Type, ค่า field เป็น string ทั้ง flow, ไม่แตะ generate/sync/add-in)

- Backend: TemplateField.Editor (nullable, null = default ของ type) +
  migration AddTemplateFieldEditor + validate ตาม type ใน PUT templates
  (ค่าไม่รู้จัก/ไม่เข้ากับ type → 400, normalize-on-write, API คืน resolved เสมอ)
- Web: util fieldEditor.ts + editor select หน้าแก้ template (desktop+mobile,
  ซ่อนเมื่อ type มีทางเดียว) + ฟอร์มสร้างเอกสาร render ตาม editor +
  แก้ prefill re-generate ของ field date จาก ISO timestamp เต็ม (input[type=date]
  รับเฉพาะ yyyy-MM-dd เดิมแสดงว่าง)
- Docs: spec v6 (FR-14/AC-17) + plan Phase 10 + sub-plan field-editor.md +
  acceptance-review AC-17 + README/CLAUDE.md
- Tests: backend 60/60 (FieldEditorTests ใหม่ 8 กรณี) · web 46/46 + build ผ่าน

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Suphonchai Phoonsawat 2026-08-22 09:17:15 +07:00
parent 7b20f12ede
commit 9bb4a48c00
23 changed files with 865 additions and 45 deletions

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
/** FR-9 UI — แก้ไข template: metadata + field definitions (label/type/required/ลำดับ) + แทนที่ไฟล์ */
/** FR-9 UI — แก้ไข template: metadata + field definitions (label/type/editor/required/ลำดับ) + แทนที่ไฟล์ */
import type { FieldType, ReplaceTemplateFileResponse, TemplateDetail } from '~/types/api'
const route = useRoute()
@ -36,7 +36,12 @@ function applyDetail(d: TemplateDetail) {
name.value = d.name
description.value = d.description ?? ''
isActive.value = d.isActive
fields.value = d.fields.map((f) => ({ ...f }))
fields.value = d.fields.map((f) => ({ ...f, editor: resolveEditor(f) }))
}
/** เปลี่ยน type → reset editor เป็น default ของ type ใหม่ (FR-14) — อ่านค่าจาก select ตรงๆ ไม่พึ่งลำดับ v-model) */
function onTypeChange(f: (typeof fields.value)[number], e: Event) {
f.editor = defaultEditor((e.target as HTMLSelectElement).value)
}
async function load() {
@ -76,6 +81,7 @@ async function save() {
key: f.key,
label: f.label.trim(),
type: f.type,
editor: f.editor,
isRequired: f.isRequired,
sortOrder: f.sortOrder,
})),
@ -185,6 +191,9 @@ onMounted(load)
<p class="mt-1 text-xs text-muted">
<span class="font-mono">key</span> มาจาก content control tag ในไฟล์ — เพิ่ม/ลบ key ต้องแทนที่ไฟล์ด้านล่าง
</p>
<p class="mt-1 text-xs text-muted">
<span class="font-mono">editor</span> = รูปแบบช่องกรอกในฟอร์มสร้างเอกสาร (เลือกได้เฉพาะ type text)
</p>
</div>
<!-- มือถือ: card ต่อ field (v-model ชุดเดียวกับตาราง) -->
@ -198,7 +207,13 @@ onMounted(load)
<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">
<select
:id="`m-type-${f.key}`"
v-model="f.type"
:aria-label="`type ของ ${f.key}`"
class="input"
@change="onTypeChange(f, $event)"
>
<option v-for="t in FIELD_TYPES" :key="t" :value="t">{{ t }}</option>
</select>
</div>
@ -213,6 +228,12 @@ onMounted(load)
/>
</div>
</div>
<div v-if="editorOptions(f.type).length > 1">
<label :for="`m-editor-${f.key}`" class="label">editor</label>
<select :id="`m-editor-${f.key}`" v-model="f.editor" :aria-label="`editor ของ ${f.key}`" class="input">
<option v-for="e in editorOptions(f.type)" :key="e" :value="e">{{ e }}</option>
</select>
</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
@ -231,6 +252,7 @@ onMounted(load)
<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">editor</th>
<th class="px-4 py-3 text-center">required</th>
<th class="px-4 py-3">ลำดับ</th>
</tr>
@ -247,10 +269,26 @@ onMounted(load)
/>
</td>
<td class="px-4 py-2">
<select v-model="f.type" :aria-label="`type ของ ${f.key}`" class="input px-2 py-1">
<select
v-model="f.type"
:aria-label="`type ของ ${f.key}`"
class="input px-2 py-1"
@change="onTypeChange(f, $event)"
>
<option v-for="t in FIELD_TYPES" :key="t" :value="t">{{ t }}</option>
</select>
</td>
<td class="px-4 py-2">
<select
v-if="editorOptions(f.type).length > 1"
v-model="f.editor"
:aria-label="`editor ของ ${f.key}`"
class="input px-2 py-1"
>
<option v-for="e in editorOptions(f.type)" :key="e" :value="e">{{ e }}</option>
</select>
<span v-else class="text-muted">—</span>
</td>
<td class="px-4 py-2 text-center">
<input
v-model="f.isRequired"