แก้ตามผลใช้งานจริง 2 ประเด็น: 1. เปิดใน Word ทันที — generate-draft เปลี่ยนจากคืนไฟล์ตรงๆ เป็นเก็บ ชั่วคราวใต้ drafts/ (lifecycle ลบรายวัน, config Minio__DraftRetentionDays) + คืน presigned URL ให้เว็บเปิดผ่าน ms-word:ofe พร้อมลิงก์สำรอง (useVersionOpen.openDocxUrl แยกแกนเปิด docx URL ใช้ร่วมกับ flow v0) 2. ตัดคำไทย — T14 แตก placeholder เป็น [ / ข้อความ / ] โดย run ข้อความ ถือ w:hint="cs" + w:cs เท่านั้น; เดิมเขียนค่าลง run ปีกกาทำให้ Word จัดหน้าไทยผิด — แก้เป็นเขียนที่ run ข้อความยาวสุด + SDT เปล่าสืบทอด rPr จาก sdtPr (ใช้ร่วมทั้ง generate และ generate-draft) - ถอดของที่เลิกใช้: CORS expose Content-Disposition, downloadFile init, useApi.spec · Spec v9 + ADR 0002 Amendment + acceptance-review AC-19 - Tests: backend 84 / web 56 + build; QA reviewer PASS (2 rounds, 10 findings แก้ครบ — รวม bug จริงที่ test ใหม่จับได้: lifecycle Rules null ทำ startup ตั้ง rule ล้มแบบเงียบ) · ค้าง manual บน Word จริง Co-Authored-By: Claude Code <noreply@anthropic.com>
342 lines
17 KiB
Vue
342 lines
17 KiB
Vue
<script setup lang="ts">
|
|
/** FR-10 UI — dynamic form สร้างเอกสารจาก field definitions ของ template → generate เป็น v0 + ดาวน์โหลด
|
|
* FR-12 — รองรับ re-generate: ?from={documentId} → prefill ค่าล่าสุดของเอกสารนั้นให้แก้ก่อนสร้าง
|
|
* FR-16 — ปุ่ม "สร้างไฟล์ร่าง": generate docx แบบ stateless (ไม่เกิด Document/version) ดาวน์โหลดทันที */
|
|
import type { DocumentDetail, GenerateDocumentResponse, TemplateDetail } from '~/types/api'
|
|
|
|
const route = useRoute()
|
|
const { apiJson } = useApi()
|
|
const { openingVersionId, downloadError, fallbackUrl, openNotice, openVersion, openDocxUrl, resetOpen } = useVersionOpen()
|
|
|
|
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>>({})
|
|
/** คำอธิบาย version แบบ commit message (FR-13) — ทางเลือก · re-generate ไม่ prefill เพราะเป็นเอกสารใหม่ */
|
|
const message = ref('')
|
|
|
|
/** เอกสารต้นทางของ re-generate (จาก ?from=) — ใช้ทั้ง prefill และ banner บอกบริบท */
|
|
const fromDoc = ref<{ id: string; fileName: string } | null>(null)
|
|
const prefill = 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)
|
|
|
|
/** FR-16 — ไฟล์ร่าง: ฟอร์มอยู่ต่อหลังดาวน์โหลด (ไม่มี result card เพราะไม่มี documentId) */
|
|
const draftSubmitting = ref(false)
|
|
const draftSuccess = ref<string | null>(null)
|
|
const draftError = 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',
|
|
),
|
|
})
|
|
|
|
function blankValues(): Record<string, string> {
|
|
return Object.fromEntries((detail.value?.fields ?? []).map((f) => [f.key, '']))
|
|
}
|
|
|
|
async function load() {
|
|
loading.value = true
|
|
loadError.value = null
|
|
try {
|
|
detail.value = (await apiJson<TemplateDetail>(`/api/templates/${templateId.value}`))!
|
|
|
|
const from = typeof route.query.from === 'string' ? route.query.from : null
|
|
if (from) {
|
|
try {
|
|
const src = await apiJson<DocumentDetail>(`/api/documents/${from}`)
|
|
if (src?.templateId) {
|
|
fromDoc.value = { id: src.id, fileName: src.fileName }
|
|
// prefill เฉพาะ key ที่มีใน defs ปัจจุบัน — key ที่หายไปหลัง replace ไฟล์โชว์ไม่ได้ (generate ก็ ignore)
|
|
prefill.value = Object.fromEntries(
|
|
detail.value.fields.map((f) => {
|
|
const value = src.fields.find((s) => s.key === f.key)?.value ?? ''
|
|
// field วันที่: input[type=date] รับเฉพาะ yyyy-MM-dd — ค่าจาก sync ของ Word อาจเป็น ISO timestamp เต็ม (FR-14)
|
|
return [f.key, resolveEditor(f) === 'date' ? dateInputValue(value) : value]
|
|
}),
|
|
)
|
|
}
|
|
} catch {
|
|
// ไม่พบเอกสารต้นทาง — ปล่อยให้เป็นฟอร์มว่างตามปกติ
|
|
}
|
|
}
|
|
|
|
values.value = { ...blankValues(), ...prefill.value }
|
|
} 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
|
|
resetOpen() // เคลียร์ state ของการเปิดไฟล์ครั้งก่อน (รวม fallbackUrl/openNotice ของไฟล์ร่างที่อาจค้าง)
|
|
draftSuccess.value = null
|
|
draftError.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, message: message.value.trim() || null }),
|
|
})
|
|
window.scrollTo({ top: 0 })
|
|
} catch (err) {
|
|
submitError.value = err instanceof Error ? err.message : 'สร้างเอกสารไม่สำเร็จ'
|
|
} finally {
|
|
submitting.value = false
|
|
}
|
|
}
|
|
|
|
/** FR-16 — สร้างไฟล์ร่าง (draft): validate ชุดเดียวกันแล้ว POST ค่าไปขอ presigned URL
|
|
* แล้วเปิดใน Word ทันทีผ่าน ms-word:ofe พร้อมลิงก์ดาวน์โหลดสำรอง (เหมือน flow เปิด v0)
|
|
* backend ไม่สร้าง Document/version — เอกสารจะเกิดตอนกด Start Versioning ใน Word (เป็น v1 พร้อมผูก template นี้) */
|
|
async function submitDraft() {
|
|
draftSuccess.value = null
|
|
draftError.value = null
|
|
submitError.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
|
|
|
|
draftSubmitting.value = true
|
|
try {
|
|
const link = (await apiJson<{ url: string; expiresAt: string }>(
|
|
`/api/templates/${templateId.value}/generate-draft`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ values: values.value }),
|
|
},
|
|
))!
|
|
openDocxUrl(link.url)
|
|
draftSuccess.value =
|
|
'กำลังเปิดไฟล์ร่างใน Word — เปิด task pane ของ add-in แล้วกด Start Versioning This Document เพื่อเริ่ม track (เอกสารจะถูกผูกกับ template นี้และบันทึกเป็น version 1)'
|
|
} catch (err) {
|
|
draftError.value = err instanceof Error ? err.message : 'สร้างไฟล์ร่างไม่สำเร็จ'
|
|
} finally {
|
|
draftSubmitting.value = false
|
|
}
|
|
}
|
|
|
|
/** กลับไปกรอกฟอร์มใหม่เพื่อสร้างอีกฉบับจาก template เดิม (ถ้าเป็น re-generate → คืนค่า prefill ไม่ใช่ฟอร์มว่าง) */
|
|
function resetForm() {
|
|
resetOpen()
|
|
result.value = null
|
|
submitError.value = null
|
|
draftSuccess.value = null
|
|
draftError.value = null
|
|
downloadError.value = null
|
|
issues.value = {}
|
|
values.value = { ...blankValues(), ...prefill.value }
|
|
message.value = ''
|
|
}
|
|
|
|
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-muted">
|
|
<span class="spinner" />
|
|
กำลังโหลด 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">
|
|
<!-- ผลลัพธ์: สร้างสำเร็จเป็น v0 แล้ว -->
|
|
<template v-if="result">
|
|
<AlertBox type="success">
|
|
สร้างเอกสารสำเร็จ — บันทึกเป็น <strong>version {{ result.versionNumber }}</strong> แล้ว
|
|
</AlertBox>
|
|
|
|
<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-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-muted">version</dt>
|
|
<dd>{{ result.versionNumber }} (ต้นทางของ version history)</dd>
|
|
</div>
|
|
</dl>
|
|
|
|
<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="openingVersionId !== null"
|
|
class="btn btn-primary w-full sm:w-auto"
|
|
@click="openVersion(result.documentId, result.versionId)"
|
|
>
|
|
{{ openingVersionId !== null ? 'กำลังเตรียมไฟล์…' : 'เปิดใน Word' }}
|
|
</button>
|
|
</div>
|
|
<AlertBox v-if="fallbackUrl" class="mt-4">
|
|
<p v-if="openNotice">{{ openNotice }}</p>
|
|
<a :href="fallbackUrl" target="_blank" rel="noopener noreferrer" class="link">ดาวน์โหลด .docx สำรอง</a>
|
|
<p class="text-xs text-muted">ลิงก์มีอายุสั้น หากหมดอายุให้กดเปิดใน Word อีกครั้ง</p>
|
|
</AlertBox>
|
|
<AlertBox v-if="downloadError" type="error" class="mt-4">{{ downloadError }}</AlertBox>
|
|
</div>
|
|
|
|
<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> — ระบบจะตรวจพบว่าเป็น
|
|
tracked document และแสดง <strong>version 0</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-page-title">{{ detail.name }}</h1>
|
|
<p v-if="detail.description" class="mt-1 text-sm text-muted">
|
|
{{ detail.description }}
|
|
</p>
|
|
</div>
|
|
|
|
<AlertBox v-if="fromDoc">
|
|
กำลังสร้างเอกสารใหม่จากค่าปัจจุบันของ <strong class="break-all">{{ fromDoc.fileName }}</strong>
|
|
— แก้ค่าได้ก่อนกดสร้าง และเอกสารต้นทางจะไม่ถูกแตะ (ไม่เกิด version ใหม่)
|
|
</AlertBox>
|
|
|
|
<AlertBox v-if="!detail.isActive">
|
|
template นี้ถูก deactivate แล้ว — สร้างเอกสารใหม่ไม่ได้ (เอกสารที่สร้างไว้แล้วยังใช้ได้)
|
|
</AlertBox>
|
|
|
|
<div
|
|
v-for="f in sortedFields"
|
|
:key="f.key"
|
|
>
|
|
<label :for="`field-${f.key}`" class="label">
|
|
{{ f.label }}
|
|
<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>
|
|
|
|
<!-- render ตาม editor ของ field (FR-14): textarea สำหรับข้อความยาว · number/date ตาม type -->
|
|
<textarea
|
|
v-if="resolveEditor(f) === 'textarea'"
|
|
:id="`field-${f.key}`"
|
|
v-model="values[f.key]"
|
|
class="input"
|
|
:class="issues[f.key] ? 'border-error' : ''"
|
|
rows="4"
|
|
/>
|
|
<input
|
|
v-else-if="resolveEditor(f) === 'number'"
|
|
:id="`field-${f.key}`"
|
|
v-model="values[f.key]"
|
|
type="number"
|
|
step="any"
|
|
class="input"
|
|
:class="issues[f.key] ? 'border-error' : ''"
|
|
/>
|
|
<input
|
|
v-else-if="resolveEditor(f) === 'date'"
|
|
:id="`field-${f.key}`"
|
|
v-model="values[f.key]"
|
|
type="date"
|
|
class="input"
|
|
:class="issues[f.key] ? 'border-error' : ''"
|
|
/>
|
|
<input
|
|
v-else
|
|
:id="`field-${f.key}`"
|
|
v-model="values[f.key]"
|
|
type="text"
|
|
class="input"
|
|
:class="issues[f.key] ? 'border-error' : ''"
|
|
/>
|
|
<p v-if="issues[f.key]" class="mt-1 text-xs text-error-strong">{{ issues[f.key] }}</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label for="doc-message" class="label">คำอธิบาย version (ไม่บังคับ)</label>
|
|
<textarea
|
|
id="doc-message"
|
|
v-model="message"
|
|
class="input"
|
|
rows="2"
|
|
maxlength="2048"
|
|
placeholder="เช่น ฉบับเสนอลงนามครั้งแรก"
|
|
/>
|
|
<p class="mt-1 text-xs text-muted">ใช้กับการสร้างเอกสาร (version 0) เท่านั้น — ไฟล์ร่างยังไม่มี version จึงไม่ใช้คำอธิบายนี้</p>
|
|
</div>
|
|
|
|
<div v-if="sortedFields.length === 0" class="empty-state">
|
|
template นี้ไม่มี field definitions —
|
|
<NuxtLink :to="`/templates/${detail.id}/edit`" class="link">ตรวจสอบที่หน้าจัดการ template</NuxtLink>
|
|
</div>
|
|
|
|
<AlertBox v-if="submitError" type="error">{{ submitError }}</AlertBox>
|
|
|
|
<!-- FR-16 — ไฟล์ร่าง: เปิดใน Word แล้วฟอร์มอยู่ต่อ พร้อมลิงก์สำรอง (อายุสั้นตาม presigned URL) -->
|
|
<AlertBox v-if="draftSuccess" type="success">{{ draftSuccess }}</AlertBox>
|
|
<AlertBox v-if="fallbackUrl && (draftSuccess || openNotice)">
|
|
<p v-if="openNotice">{{ openNotice }}</p>
|
|
<a :href="fallbackUrl" target="_blank" rel="noopener noreferrer" class="link">ดาวน์โหลดไฟล์ร่างสำรอง (.docx)</a>
|
|
<p class="text-xs text-muted">ลิงก์มีอายุสั้น หากหมดอายุให้กดสร้างไฟล์ร่างอีกครั้ง</p>
|
|
</AlertBox>
|
|
<AlertBox v-if="draftError" type="error">{{ draftError }}</AlertBox>
|
|
|
|
<div class="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
|
|
<button
|
|
type="button"
|
|
:disabled="submitting || draftSubmitting || sortedFields.length === 0 || !detail.isActive"
|
|
class="btn btn-secondary w-full sm:w-auto"
|
|
@click="submitDraft"
|
|
>
|
|
{{ draftSubmitting ? 'กำลังสร้างไฟล์ร่าง…' : 'สร้างไฟล์ร่าง (ยังไม่ version)' }}
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
:disabled="submitting || draftSubmitting || sortedFields.length === 0 || !detail.isActive"
|
|
class="btn btn-primary w-full sm:w-auto"
|
|
>
|
|
{{ submitting ? 'กำลังสร้างเอกสาร…' : 'สร้างเอกสาร (version 0)' }}
|
|
</button>
|
|
</div>
|
|
<p class="text-xs text-muted">
|
|
ไฟล์ร่าง = เปิดใน Word ทันทีโดยยังไม่เกิดเอกสารในระบบ — เริ่ม versioning ตอนกด Start Versioning ใน Word (version 1)
|
|
</p>
|
|
</form>
|
|
</template>
|
|
</section>
|
|
</AuthGate>
|
|
</template>
|