ปรับปรุง phase 6 ให้ครบถ้วน
This commit is contained in:
parent
93570d38d8
commit
a196b8f9a8
14 changed files with 747 additions and 40 deletions
233
web/app/pages/documents/[documentId].vue
Normal file
233
web/app/pages/documents/[documentId].vue
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
<script setup lang="ts">
|
||||
/** FR-12 UI — detail ของเอกสาร: ค่าล่าสุดของทุก field + version history (ขยายแถวเห็นค่าตอนนั้น)
|
||||
* + ปุ่ม re-generate: พาไปฟอร์มสร้างเอกสารแบบ prefill ค่าปัจจุบัน → ได้เอกสารใหม่ v1 (ไม่แตะเอกสารเดิม) */
|
||||
import type { DocumentDetail, VersionListItem } from '~/types/api'
|
||||
|
||||
const route = useRoute()
|
||||
const { apiJson, downloadFile } = useApi()
|
||||
|
||||
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 downloadingVersionId = ref<string | null>(null)
|
||||
const downloadError = 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}`)
|
||||
}
|
||||
|
||||
async function download(v: VersionListItem) {
|
||||
downloadingVersionId.value = v.versionId
|
||||
downloadError.value = null
|
||||
try {
|
||||
const base = (detail.value?.fileName ?? 'document').replace(/\.docx$/i, '')
|
||||
await downloadFile(`/api/documents/${documentId.value}/versions/${v.versionId}/download`, `${base}_v${v.versionNumber}.docx`)
|
||||
} catch (err) {
|
||||
downloadError.value = err instanceof Error ? err.message : 'ดาวน์โหลดไม่สำเร็จ'
|
||||
} finally {
|
||||
downloadingVersionId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
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-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 }}
|
||||
<NuxtLink to="/" class="ml-1 font-medium underline">กลับหน้าหลัก</NuxtLink>
|
||||
</AlertBox>
|
||||
|
||||
<template v-else-if="detail">
|
||||
<!-- หัวเรื่อง + การกระทำ -->
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold break-all">{{ detail.fileName }}</h1>
|
||||
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||
<template v-if="detail.templateId">
|
||||
จาก template
|
||||
<NuxtLink
|
||||
:to="`/templates/${detail.templateId}/documents`"
|
||||
class="font-medium text-sky-700 hover:underline dark:text-sky-400"
|
||||
>
|
||||
{{ detail.templateName }}
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<template v-else>เอกสารที่ track จาก add-in (ไม่ได้สร้างจาก template)</template>
|
||||
· สร้างเมื่อ {{ formatDateTime(detail.createdAt) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-end gap-1">
|
||||
<button
|
||||
v-if="detail.templateId"
|
||||
type="button"
|
||||
:disabled="!detail.templateIsActive"
|
||||
:title="detail.templateIsActive ? '' : 'template นี้ถูก deactivate แล้ว — สร้างเอกสารใหม่ไม่ได้'"
|
||||
class="rounded-md bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
@click="regenerate"
|
||||
>
|
||||
⟳ สร้างเอกสารใหม่จากข้อมูลนี้
|
||||
</button>
|
||||
<p v-if="detail.templateId && !detail.templateIsActive" class="text-xs text-slate-500 dark:text-slate-400">
|
||||
template ถูก deactivate — เอกสารฉบับนี้ยังใช้ได้ แต่สร้างใหม่ไม่ได้
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ค่าล่าสุดของทุก field -->
|
||||
<div class="space-y-3">
|
||||
<h2 class="text-lg font-semibold">ค่า fields ปัจจุบัน</h2>
|
||||
|
||||
<div
|
||||
v-if="detail.fields.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"
|
||||
>
|
||||
เอกสารนี้ไม่มี field values (ไม่ได้สร้างจาก template หรือ template ไม่มี fields)
|
||||
</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">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-slate-100 dark:divide-slate-700">
|
||||
<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="ml-1 rounded bg-amber-100 px-1.5 py-0.5 text-xs text-amber-700 dark:bg-amber-900 dark:text-amber-300">
|
||||
ไม่มีในนิยามปัจจุบัน
|
||||
</span>
|
||||
<span class="block font-mono text-xs text-slate-400">{{ 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-slate-500 dark:text-slate-400">{{ f.type ?? '—' }}</td>
|
||||
<td class="px-4 py-3 text-slate-500 dark:text-slate-400">
|
||||
{{ f.updatedAt ? formatDateTime(f.updatedAt) : 'ยังไม่เคยมีค่า' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- version history -->
|
||||
<div class="space-y-3">
|
||||
<h2 class="text-lg font-semibold">Version history ({{ versions.length }})</h2>
|
||||
|
||||
<div
|
||||
v-if="versions.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"
|
||||
>
|
||||
ยังไม่มี version
|
||||
</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">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-slate-100 dark:divide-slate-700">
|
||||
<template v-for="v in versions" :key="v.versionId">
|
||||
<tr class="cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-700/40" @click="expandedVersionId = expandedVersionId === v.versionId ? null : v.versionId">
|
||||
<td class="px-4 py-3 font-medium">v{{ v.versionNumber }}</td>
|
||||
<td class="px-4 py-3">{{ formatDateTime(v.createdAt) }}</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-slate-500 dark:text-slate-400">{{ v.createdByUserId.slice(0, 8) }}…</td>
|
||||
<td class="px-4 py-3 text-right whitespace-nowrap">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="downloadingVersionId === v.versionId"
|
||||
class="rounded-md border border-slate-300 px-2.5 py-1 text-xs hover:bg-slate-100 disabled:opacity-50 dark:border-slate-600 dark:hover:bg-slate-700"
|
||||
@click.stop="download(v)"
|
||||
>
|
||||
{{ downloadingVersionId === v.versionId ? '…' : '⬇ ดาวน์โหลด' }}
|
||||
</button>
|
||||
<span class="ml-2 text-xs text-slate-400">{{ expandedVersionId === v.versionId ? '▲ ซ่อน fields' : '▼ ดู fields' }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="expandedVersionId === v.versionId">
|
||||
<td colspan="4" class="bg-slate-50 px-4 py-4 dark:bg-slate-900">
|
||||
<p class="mb-2 text-xs font-medium text-slate-500 dark:text-slate-400">ค่า 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-slate-500 dark:text-slate-400" :title="e.key">
|
||||
{{ fieldLabel(e.key) }}
|
||||
</dt>
|
||||
<dd class="break-words">
|
||||
{{ e.value || '—' }}
|
||||
<span v-if="e.missing" class="ml-1 rounded bg-amber-100 px-1.5 py-0.5 text-xs text-amber-700 dark:bg-amber-900 dark:text-amber-300">
|
||||
หายจากเอกสาร
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<p v-if="snapshotOf(v).length === 0" class="text-sm text-slate-500 dark:text-slate-400">
|
||||
version นี้ไม่มี snapshot ของ fields
|
||||
</p>
|
||||
</dl>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<AlertBox v-if="downloadError" type="error">{{ downloadError }}</AlertBox>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</AuthGate>
|
||||
</template>
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
/** FR-10 UI — dynamic form สร้างเอกสารจาก field definitions ของ template → generate เป็น v1 + ดาวน์โหลด */
|
||||
import type { GenerateDocumentResponse, TemplateDetail } from '~/types/api'
|
||||
/** FR-10 UI — dynamic form สร้างเอกสารจาก field definitions ของ template → generate เป็น v1 + ดาวน์โหลด
|
||||
* FR-12 — รองรับ re-generate: ?from={documentId} → prefill ค่าล่าสุดของเอกสารนั้นให้แก้ก่อนสร้าง */
|
||||
import type { DocumentDetail, GenerateDocumentResponse, TemplateDetail } from '~/types/api'
|
||||
|
||||
const route = useRoute()
|
||||
const { apiJson, downloadFile } = useApi()
|
||||
|
|
@ -10,6 +11,10 @@ const detail = ref<TemplateDetail | null>(null)
|
|||
const values = ref<Record<string, string>>({})
|
||||
const issues = ref<Record<string, string>>({})
|
||||
|
||||
/** เอกสารต้นทางของ 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)
|
||||
|
||||
|
|
@ -29,12 +34,33 @@ useHead({
|
|||
),
|
||||
})
|
||||
|
||||
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}`))!
|
||||
values.value = Object.fromEntries(detail.value.fields.map((f) => [f.key, '']))
|
||||
|
||||
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) => [f.key, src.fields.find((s) => s.key === f.key)?.value ?? '']),
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// ไม่พบเอกสารต้นทาง — ปล่อยให้เป็นฟอร์มว่างตามปกติ
|
||||
}
|
||||
}
|
||||
|
||||
values.value = { ...blankValues(), ...prefill.value }
|
||||
} catch (err) {
|
||||
loadError.value = err instanceof Error ? err.message : 'โหลด template ไม่สำเร็จ'
|
||||
} finally {
|
||||
|
|
@ -80,13 +106,13 @@ async function download() {
|
|||
}
|
||||
}
|
||||
|
||||
/** กลับไปกรอกฟอร์มใหม่เพื่อสร้างอีกฉบับจาก template เดิม */
|
||||
/** กลับไปกรอกฟอร์มใหม่เพื่อสร้างอีกฉบับจาก template เดิม (ถ้าเป็น re-generate → คืนค่า prefill ไม่ใช่ฟอร์มว่าง) */
|
||||
function resetForm() {
|
||||
result.value = null
|
||||
submitError.value = null
|
||||
downloadError.value = null
|
||||
issues.value = {}
|
||||
values.value = Object.fromEntries((detail.value?.fields ?? []).map((f) => [f.key, '']))
|
||||
values.value = { ...blankValues(), ...prefill.value }
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
|
@ -175,6 +201,15 @@ onMounted(load)
|
|||
</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"
|
||||
|
|
@ -210,7 +245,7 @@ onMounted(load)
|
|||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="submitting || sortedFields.length === 0"
|
||||
:disabled="submitting || sortedFields.length === 0 || !detail.isActive"
|
||||
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)' }}
|
||||
|
|
|
|||
|
|
@ -50,21 +50,35 @@ onMounted(async () => {
|
|||
</div>
|
||||
|
||||
<div v-else class="grid gap-4 sm:grid-cols-2">
|
||||
<NuxtLink
|
||||
<div
|
||||
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"
|
||||
class="flex flex-col 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>
|
||||
<NuxtLink :to="`/documents/create/${t.id}`" class="flex-1">
|
||||
<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>
|
||||
</NuxtLink>
|
||||
<div class="mt-3 flex gap-4 border-t border-slate-100 pt-3 text-xs dark:border-slate-700">
|
||||
<NuxtLink
|
||||
:to="`/documents/create/${t.id}`"
|
||||
class="font-medium text-sky-600 hover:underline dark:text-sky-400"
|
||||
>
|
||||
สร้างเอกสาร →
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
:to="`/templates/${t.id}/documents`"
|
||||
class="text-slate-500 hover:text-sky-600 hover:underline dark:text-slate-400 dark:hover:text-sky-400"
|
||||
>
|
||||
ดูเอกสารที่สร้างแล้ว
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</AuthGate>
|
||||
|
|
|
|||
140
web/app/pages/templates/[id]/documents.vue
Normal file
140
web/app/pages/templates/[id]/documents.vue
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
<script setup lang="ts">
|
||||
/** FR-12 UI — รายการเอกสารที่สร้างจาก template หนึ่งตัว (เข้าได้แม้ template ถูก deactivate) */
|
||||
import type { TemplateDetail, TemplateDocumentListItem } from '~/types/api'
|
||||
|
||||
const route = useRoute()
|
||||
const { apiJson } = useApi()
|
||||
|
||||
const templateId = computed(() => String(route.params.id))
|
||||
|
||||
const template = ref<TemplateDetail | null>(null)
|
||||
const documents = ref<TemplateDocumentListItem[]>([])
|
||||
const loading = ref(true)
|
||||
const loadError = ref<string | null>(null)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const [tpl, docs] = await Promise.all([
|
||||
apiJson<TemplateDetail>(`/api/templates/${templateId.value}`),
|
||||
apiJson<TemplateDocumentListItem[]>(`/api/templates/${templateId.value}/documents`),
|
||||
])
|
||||
template.value = tpl!
|
||||
documents.value = docs ?? []
|
||||
useHead({ title: `เอกสารของ ${tpl?.name} — e-Court Documents Versioning` })
|
||||
} catch (err) {
|
||||
loadError.value = err instanceof Error ? err.message : 'โหลดรายการเอกสารไม่สำเร็จ'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthGate>
|
||||
<section class="space-y-5">
|
||||
<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 }}
|
||||
<NuxtLink to="/templates" class="ml-1 font-medium underline">กลับรายการ template</NuxtLink>
|
||||
</AlertBox>
|
||||
|
||||
<template v-else-if="template">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400">เอกสารที่สร้างจาก template</p>
|
||||
<h1 class="text-2xl font-bold">{{ template.name }}</h1>
|
||||
<p v-if="template.description" class="mt-1 text-sm text-slate-600 dark:text-slate-300">
|
||||
{{ template.description }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<NuxtLink
|
||||
:to="`/templates/${template.id}/edit`"
|
||||
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>
|
||||
<NuxtLink
|
||||
v-if="template.isActive"
|
||||
:to="`/documents/create/${template.id}`"
|
||||
class="rounded-md bg-sky-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-sky-700"
|
||||
>
|
||||
+ สร้างเอกสารใหม่
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertBox v-if="!template.isActive">
|
||||
template นี้ถูก deactivate แล้ว — สร้างเอกสารใหม่ไม่ได้ แต่เอกสารที่สร้างไว้แล้วยังใช้งานได้ตามปกติ
|
||||
</AlertBox>
|
||||
|
||||
<div
|
||||
v-if="documents.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 นี้<template v-if="template.isActive">
|
||||
—
|
||||
<NuxtLink
|
||||
:to="`/documents/create/${template.id}`"
|
||||
class="font-medium text-sky-600 hover:underline dark:text-sky-400"
|
||||
>
|
||||
สร้างเอกสารแรก
|
||||
</NuxtLink></template
|
||||
>
|
||||
</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">versions</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="d in documents" :key="d.documentId">
|
||||
<td class="max-w-72 px-4 py-3">
|
||||
<NuxtLink
|
||||
:to="`/documents/${d.documentId}`"
|
||||
class="font-medium text-sky-700 hover:underline dark:text-sky-400"
|
||||
>
|
||||
{{ d.fileName }}
|
||||
</NuxtLink>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-500 dark:text-slate-400">{{ formatDateTime(d.createdAt) }}</td>
|
||||
<td class="px-4 py-3 text-center">{{ d.versionCount }}</td>
|
||||
<td class="px-4 py-3 text-slate-500 dark:text-slate-400">
|
||||
<template v-if="d.latestVersionAt">
|
||||
v{{ d.latestVersionNumber }} · {{ formatDateTime(d.latestVersionAt) }}
|
||||
</template>
|
||||
<template v-else>—</template>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<NuxtLink
|
||||
:to="`/documents/${d.documentId}`"
|
||||
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>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</AuthGate>
|
||||
</template>
|
||||
|
|
@ -135,8 +135,14 @@ onMounted(load)
|
|||
<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`"
|
||||
:to="`/templates/${t.id}/documents`"
|
||||
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>
|
||||
<NuxtLink
|
||||
:to="`/templates/${t.id}/edit`"
|
||||
class="ml-2 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>
|
||||
|
|
|
|||
|
|
@ -54,3 +54,48 @@ export interface GenerateDocumentResponse {
|
|||
/** path สัมพัทธ์ของ backend — เติม apiBaseUrl ตอนเรียก */
|
||||
downloadUrl: string
|
||||
}
|
||||
|
||||
/** item ของ GET /api/templates/{templateId}/documents (FR-12) */
|
||||
export interface TemplateDocumentListItem {
|
||||
documentId: string
|
||||
fileName: string
|
||||
createdAt: string
|
||||
versionCount: number
|
||||
latestVersionNumber: number
|
||||
latestVersionAt: string | null
|
||||
}
|
||||
|
||||
/** field หนึ่งตัวของ document (FR-12) — label/type null = มีค่าแต่ key ไม่มีในนิยาม template ปัจจุบัน
|
||||
* sourceVersionId/updatedByUserId/updatedAt null = field เพิ่มเข้า template หลังเอกสารนี้ถูกสร้าง */
|
||||
export interface DocumentFieldItem {
|
||||
key: string
|
||||
label: string | null
|
||||
type: string | null
|
||||
isRequired: boolean | null
|
||||
value: string
|
||||
sourceVersionId: string | null
|
||||
updatedByUserId: string | null
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
/** response ของ GET /api/documents/{documentId} (FR-12) */
|
||||
export interface DocumentDetail {
|
||||
id: string
|
||||
fileName: string
|
||||
createdAt: string
|
||||
templateId: string | null
|
||||
templateName: string | null
|
||||
templateIsActive: boolean
|
||||
fields: DocumentFieldItem[]
|
||||
}
|
||||
|
||||
/** item ของ GET /api/documents/{documentId}/versions — fields คือ snapshot ที่ parse แล้ว
|
||||
* (2 รูปแบบ — ผ่าน normalizeSnapshot ก่อนใช้) */
|
||||
export interface VersionListItem {
|
||||
versionId: string
|
||||
versionNumber: number
|
||||
createdAt: string
|
||||
createdByUserId: string
|
||||
contentHash: string
|
||||
fields: unknown
|
||||
}
|
||||
|
|
|
|||
43
web/app/utils/snapshot.ts
Normal file
43
web/app/utils/snapshot.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* เปลี่ยน snapshot fields ของ version (จาก GET .../versions) ให้เป็น list ที่ UI ใช้แสดงได้
|
||||
* snapshot มี 2 รูปตามที่ backend เก็บ (phase 3):
|
||||
* - เอกสารผูก template: { key: { value: string, missing: boolean } }
|
||||
* - เอกสารไม่ผูก template: map ดิบ { key: value }
|
||||
* pure function — แยกไว้ให้ unit test ได้ (Phase 7)
|
||||
*/
|
||||
|
||||
export interface SnapshotEntry {
|
||||
key: string
|
||||
value: string
|
||||
missing: boolean
|
||||
}
|
||||
|
||||
export function normalizeSnapshot(fields: unknown, orderKeys?: string[]): SnapshotEntry[] {
|
||||
if (fields === null || fields === undefined || typeof fields !== 'object') return []
|
||||
|
||||
const entries = new Map<string, SnapshotEntry>()
|
||||
for (const [key, raw] of Object.entries(fields as Record<string, unknown>)) {
|
||||
if (raw !== null && typeof raw === 'object' && 'value' in raw) {
|
||||
const obj = raw as { value?: unknown; missing?: unknown }
|
||||
entries.set(key, {
|
||||
key,
|
||||
value: obj.value === null || obj.value === undefined ? '' : String(obj.value),
|
||||
missing: obj.missing === true,
|
||||
})
|
||||
} else {
|
||||
entries.set(key, { key, value: raw === null || raw === undefined ? '' : String(raw), missing: false })
|
||||
}
|
||||
}
|
||||
|
||||
const list = [...entries.values()]
|
||||
if (orderKeys?.length) {
|
||||
// เรียงตามลำดับ field defs ของ template — key ที่ไม่มีใน defs ต่อท้ายตามตัวอักษร
|
||||
const rank = new Map(orderKeys.map((k, i) => [k, i]))
|
||||
list.sort((a, b) => {
|
||||
const ra = rank.get(a.key) ?? orderKeys.length
|
||||
const rb = rank.get(b.key) ?? orderKeys.length
|
||||
return ra === rb ? a.key.localeCompare(b.key) : ra - rb
|
||||
})
|
||||
}
|
||||
return list
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue