[CLAUDE] App+Infra+Api+FE-Admin: PDF export (LibreOffice headless)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m33s

Pipeline: template.docx → FormRenderer fill placeholders → LibreOffice
soffice --headless --convert-to pdf → PDF byte[] → File() stream to
browser.

Clean-arch split:
- Application: IPdfConverter abstraction (swap to QuestPDF/Aspose later
  without touching caller).
- Infrastructure: LibreOfficePdfConverter — shells out to soffice.exe
  path from config (Pdf:SofficePath, default
  `C:\Program Files\LibreOffice\program\soffice.exe` on Windows).
  Per-request temp workDir để tránh filename collision + -env:
  UserInstallation isolate mỗi conversion (chống "soffice already
  running" khi concurrent). Timeout 60s (configurable). Best-effort
  cleanup. Kill entire process tree nếu timeout.
- Application: ExportTemplatePdfCommand — reuses existing FormRenderer
  + pipes bytes through IPdfConverter. Same data dict signature as
  Render để UI code share.
- Api: POST /api/forms/templates/{id}/export-pdf (same JSON body as
  /render, returns PDF stream).

FE:
- useExport hook chung cho 2 endpoints (DRY render + export-pdf mutations)
- Render dialog thêm nút "Tải PDF" (outline variant) cạnh "Tải file gốc".
  Disabled khi mutation khác đang chạy.
- Hướng dẫn dialog nâng cấp: "file gốc để edit Word/Excel, PDF để
  in/gửi không chỉnh sửa được".

Ops: scripts/install-libreoffice.ps1 — silent MSI install 25.8.6 cho
VPS (đã chạy trên prod).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-04-21 21:28:31 +07:00
parent acc134a2fd
commit 6bbd894d96
7 changed files with 207 additions and 29 deletions

View File

@ -56,26 +56,32 @@ export function FormsPage() {
queryFn: async () => (await api.get<ContractTemplate[]>('/forms/templates', { params: { onlyActive: false } })).data,
})
const render = useMutation({
mutationFn: async ({ id, data }: { id: string; data: Record<string, string | null> }) => {
const res = await api.post(`/forms/templates/${id}/render`, data, { responseType: 'blob' })
return {
blob: res.data as Blob,
filename: res.headers['content-disposition']?.match(/filename="?([^";]+)"?/)?.[1] ?? 'render.docx',
}
},
onSuccess: ({ blob, filename }) => {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
toast.success('Đã tải file render')
setRenderDialog(null)
},
onError: err => toast.error(getErrorMessage(err)),
})
function useExport(endpoint: 'render' | 'export-pdf', successMsg: string) {
return useMutation({
mutationFn: async ({ id, data }: { id: string; data: Record<string, string | null> }) => {
const res = await api.post(`/forms/templates/${id}/${endpoint}`, data, { responseType: 'blob' })
const fallback = endpoint === 'export-pdf' ? 'contract.pdf' : 'render.docx'
return {
blob: res.data as Blob,
filename: res.headers['content-disposition']?.match(/filename="?([^";]+)"?/)?.[1] ?? fallback,
}
},
onSuccess: ({ blob, filename }) => {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
toast.success(successMsg)
setRenderDialog(null)
},
onError: err => toast.error(getErrorMessage(err)),
})
}
const render = useExport('render', 'Đã tải file gốc')
const exportPdf = useExport('export-pdf', 'Đã tải PDF')
const upload = useMutation({
mutationFn: async (params: { file: File; meta: EditState }) => {
@ -123,18 +129,28 @@ export function FormsPage() {
onError: err => toast.error(getErrorMessage(err)),
})
function handleRender() {
if (!renderDialog) return
let data: Record<string, string | null>
function parseJsonOrToast(): Record<string, string | null> | null {
if (!renderDialog) return null
try {
data = JSON.parse(dataJson)
return JSON.parse(dataJson)
} catch {
toast.error('JSON không hợp lệ')
return
return null
}
}
function handleRender() {
const data = parseJsonOrToast()
if (!data || !renderDialog) return
render.mutate({ id: renderDialog.id, data })
}
function handleExportPdf() {
const data = parseJsonOrToast()
if (!data || !renderDialog) return
exportPdf.mutate({ id: renderDialog.id, data })
}
function handleSaveEdit(e: FormEvent) {
e.preventDefault()
if (!edit) return
@ -256,8 +272,15 @@ export function FormsPage() {
<Button variant="outline" onClick={() => setRenderDialog(null)}>
Hủy
</Button>
<Button onClick={handleRender} disabled={render.isPending}>
{render.isPending ? 'Đang render…' : 'Render & tải xuống'}
<Button
variant="outline"
onClick={handleExportPdf}
disabled={exportPdf.isPending || render.isPending}
>
{exportPdf.isPending ? 'Đang export PDF…' : 'Tải PDF'}
</Button>
<Button onClick={handleRender} disabled={render.isPending || exportPdf.isPending}>
{render.isPending ? 'Đang render…' : `Tải file gốc (.${renderDialog?.format ?? 'docx'})`}
</Button>
</>
}
@ -265,8 +288,8 @@ export function FormsPage() {
<div className="space-y-4">
<div className="rounded-md bg-amber-50 px-3 py-2 text-xs text-amber-800">
<strong>Hướng dẫn:</strong> Template chứa placeholder dạng{' '}
<code className="rounded bg-white px-1">{'{{fieldName}}'}</code>. Điền key-value JSON dưới đây, backend sẽ
replace placeholder trong file gốc.
<code className="rounded bg-white px-1">{'{{fieldName}}'}</code>. Điền JSON bên dưới. <strong>Tải file gốc</strong>{' '}
đ chỉnh sửa trong Word/Excel, <strong>Tải PDF</strong> đ in/gi không chnh sa đưc.
</div>
<div className="space-y-1.5">
<Label>Form Code</Label>