eCourt-Doc-Edit/web/app/pages/documents/[documentId].vue
Suphonchai Phoonsawat 39d610a781 Phase 11: version PDF (FR-15)
- เลข version ใหม่: เว็บ-generate = v0 ฉบับตั้งต้น (ไม่มี PDF), add-in save (Start Versioning + Save & Version) = v1+ มี PDF เสมอ
- PDF ผลิตจาก Word จริงผ่าน task pane เท่านั้น (getFileAsync Pdf + runtime guard, manifest ไม่เพิ่ม requirement); capture/upload เป็น best-effort — docx ผ่านแม้ PDF ล้ม + MessageBar เตือนตาม hasPdf
- Backend: DocumentVersion.PdfObjectKey (nullable, migration AddVersionPdf) + sibling .pdf ใน bucket docx-versions · multipart part pdf (ผิด type → 400) · /download + /download-link รับ ?format=pdf (ไม่มี → 404) · presigned ฝังชื่อไฟล์ไทย + _vN (RFC 5987)
- Web: เปิดไฟล์แทน blob-download — PDF แท็บใหม่, docx ผ่าน ms-word:ofe|u| พร้อม heuristic fallback ลิงก์สำรอง (useVersionOpen, versionDownload) · ปุ่ม "เปิดใน Word" ที่หน้า success ของฟอร์มสร้างเอกสาร
- Spec v7 + CONTEXT.md + ADR 0001 + แผน docs/plans/version-pdf.md
- Automated QA: backend 68 / add-in 63 / web 55 ผ่าน · manual acceptance ผ่านบน Word for Mac (2026-09-08) รวม fidelity + track changes — ค้างเฉพาะ Windows / Word on the web

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-08 14:01:13 +07:00

295 lines
15 KiB
Vue

<script setup lang="ts">
/** FR-12 UI — detail ของเอกสาร: ค่าล่าสุดของทุก field + version history (ขยายแถวเห็นค่าตอนนั้น)
* + ปุ่ม re-generate: พาไปฟอร์มสร้างเอกสารแบบ prefill ค่าปัจจุบัน → ได้เอกสารใหม่ v0 (ไม่แตะเอกสารเดิม) */
import type { DocumentDetail, VersionListItem } from '~/types/api'
const route = useRoute()
const { apiJson } = useApi()
const { openingVersionId, downloadError, fallbackUrl, openNotice, openVersion } = useVersionOpen()
const documentId = computed(() => String(route.params.documentId))
const detail = ref<DocumentDetail | null>(null)
const versions = ref<VersionListItem[]>([])
const loading = ref(true)
const loadError = ref<string | null>(null)
const expandedVersionId = ref<string | null>(null)
const fieldOrderKeys = computed(() => detail.value?.fields.filter((f) => f.label !== null).map((f) => f.key) ?? [])
function snapshotOf(v: VersionListItem) {
return normalizeSnapshot(v.fields, fieldOrderKeys.value)
}
/** โชว์ label ของ field ถ้ามีนิยาม ไม่มีก็โชว์ key ตรงๆ */
function fieldLabel(key: string): string {
return detail.value?.fields.find((f) => f.key === key)?.label ?? key
}
async function load() {
loading.value = true
loadError.value = null
try {
const [d, vs] = await Promise.all([
apiJson<DocumentDetail>(`/api/documents/${documentId.value}`),
apiJson<VersionListItem[]>(`/api/documents/${documentId.value}/versions`),
])
detail.value = d!
versions.value = vs ?? []
useHead({ title: `${d?.fileName} — e-Court Documents Versioning` })
} catch (err) {
loadError.value = err instanceof Error ? err.message : 'โหลดข้อมูลเอกสารไม่สำเร็จ'
} finally {
loading.value = false
}
}
/** re-generate — เข้าฟอร์มสร้างเอกสารของ template เดิมโดย prefill ค่าปัจจุบัน (flow เดิม ไม่แตะเอกสารฉบับนี้) */
function regenerate() {
if (!detail.value?.templateId) return
navigateTo(`/documents/create/${detail.value.templateId}?from=${detail.value.id}`)
}
onMounted(load)
</script>
<template>
<AuthGate>
<section class="space-y-8">
<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 }}
<NuxtLink to="/" class="ml-1 font-medium underline">กลับหน้าหลัก</NuxtLink>
</AlertBox>
<template v-else-if="detail">
<!-- หัวเรื่อง + การกระทำ -->
<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="link">
{{ detail.templateName }}
</NuxtLink>
</template>
<template v-else>เอกสารที่ track จาก add-in (ไม่ได้สร้างจาก template)</template>
· สร้างเมื่อ {{ formatDateTime(detail.createdAt) }}
</p>
</div>
<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="btn btn-primary w-full sm:w-auto"
@click="regenerate"
>
⟳ สร้างเอกสารใหม่จากข้อมูลนี้
</button>
<p v-if="detail.templateId && !detail.templateIsActive" class="text-xs text-muted">
template ถูก deactivate — เอกสารฉบับนี้ยังใช้ได้ แต่สร้างใหม่ไม่ได้
</p>
</div>
</div>
<!-- ค่าล่าสุดของทุก field -->
<div class="space-y-3">
<h2 class="text-section-title">ค่า fields ปัจจุบัน</h2>
<div v-if="detail.fields.length === 0" class="empty-state">
เอกสารนี้ไม่มี field values (ไม่ได้สร้างจาก template หรือ template ไม่มี fields)
</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-section-title">Version history ({{ versions.length }})</h2>
<div v-if="versions.length === 0" class="empty-state">ยังไม่มี version</div>
<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>
<p v-if="v.message" class="mt-1 text-sm break-words">{{ v.message }}</p>
</div>
<div class="flex shrink-0 flex-col items-end gap-2">
<button
type="button"
:disabled="openingVersionId !== null"
class="btn btn-secondary btn-xs"
@click.stop="openVersion(documentId, v.versionId)"
>
{{ openingVersionId !== null ? '…' : 'เปิดใน Word' }}
</button>
<button v-if="v.hasPdf" type="button" class="btn btn-secondary btn-xs"
:disabled="openingVersionId !== null" @click.stop="openVersion(documentId, v.versionId, 'pdf')">PDF</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>
</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 }}
<p
v-if="v.message"
class="max-w-md truncate text-xs font-normal text-muted"
:title="v.message"
>
{{ v.message }}
</p>
</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="openingVersionId !== null"
class="btn btn-secondary btn-xs"
@click.stop="openVersion(documentId, v.versionId)"
>
{{ openingVersionId !== null ? '…' : 'เปิดใน Word' }}
</button>
<button v-if="v.hasPdf" type="button" class="btn btn-secondary btn-xs ml-2"
:disabled="openingVersionId !== null" @click.stop="openVersion(documentId, v.versionId, 'pdf')">PDF</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="fallbackUrl">
<p v-if="openNotice">{{ openNotice }}</p>
<a :href="fallbackUrl" target="_blank" rel="noopener noreferrer" class="link">เปิด / ดาวน์โหลดไฟล์สำรอง</a>
<p class="text-xs text-muted">ลิงก์มีอายุสั้น หากหมดอายุให้กดเปิดไฟล์อีกครั้ง</p>
</AlertBox>
<AlertBox v-if="downloadError" type="error">{{ downloadError }}</AlertBox>
</div>
</template>
</section>
</AuthGate>
</template>