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>
This commit is contained in:
Suphonchai Phoonsawat 2026-09-08 14:01:13 +07:00
parent 618328ffa7
commit 39d610a781
39 changed files with 1387 additions and 212 deletions

View file

@ -2,8 +2,9 @@
* Type ตรงกับ response ของ backend (`Models/VersionDtos.cs` — ASP.NET Core serialize camelCase)
*/
/** response ของ POST /api/documents/{documentId}/versions และ POST /api/documents/generate */
/** response ของ POST /api/documents/{documentId}/versions */
export interface UploadVersionResult {
hasPdf: boolean;
versionId: string;
versionNumber: number;
}
@ -24,6 +25,7 @@ export type FieldsSnapshot = Record<string, FieldSnapshotEntry> | Record<string,
/** item ของ GET /api/documents/{documentId}/versions */
export interface VersionRecord {
hasPdf?: boolean;
versionId: string;
versionNumber: number;
/** ISO-8601 */

View file

@ -1,7 +1,13 @@
import { getCurrentDocumentFile, DOCX_CONTENT_TYPE } from "./fileService";
import {
getCurrentDocumentFile,
getCurrentDocumentPdf,
DOCX_CONTENT_TYPE,
PDF_CONTENT_TYPE,
} from "./fileService";
interface FileMockState {
getFileCalls: number;
fileTypes: unknown[];
requestedSlices: number[];
closeCalls: number;
}
@ -12,11 +18,17 @@ function installOfficeFileMock(
options: {
failSliceIndex?: number;
failClose?: boolean;
unsupported?: boolean;
/** รูปร่างของ slice.data ที่ host จริงส่งกลับ — ต่างกันตามแพลตฟอร์ม (Win=ArrayBuffer, Mac=string/array) */
sliceDataForm?: "buffer" | "view" | "array" | "base64";
} = {}
): FileMockState {
const state: FileMockState = { getFileCalls: 0, requestedSlices: [], closeCalls: 0 };
const state: FileMockState = {
fileTypes: [],
getFileCalls: 0,
requestedSlices: [],
closeCalls: 0,
};
const toSliceData = (bytes: Uint8Array): unknown => {
switch (options.sliceDataForm) {
@ -51,9 +63,10 @@ function installOfficeFileMock(
};
(globalThis as unknown as Record<string, unknown>).Office = {
FileType: { Compressed: "compressed", Text: "text" },
FileType: { Compressed: "compressed", Pdf: "pdf", Text: "text" },
AsyncResultStatus: { Succeeded: "succeeded", Failed: "failed" },
context: {
requirements: { isSetSupported: () => !options.unsupported },
document: {
getFileAsync: (
_fileType: unknown,
@ -61,6 +74,7 @@ function installOfficeFileMock(
callback: (result: unknown) => void
) => {
state.getFileCalls++;
state.fileTypes.push(_fileType);
callback({ status: "succeeded", value: file });
},
},
@ -135,3 +149,26 @@ describe("getCurrentDocumentFile", () => {
expect(blob.size).toBe(3);
});
});
describe("getCurrentDocumentPdf", () => {
it("captures PDF slices and closes the file", async () => {
const state = installOfficeFileMock([bytes(37, 80), bytes(68, 70)], {
sliceDataForm: "base64",
});
const pdf = await getCurrentDocumentPdf();
expect(pdf.type).toBe(PDF_CONTENT_TYPE);
expect(pdf.size).toBe(4);
expect(state.fileTypes).toEqual(["pdf"]);
expect(state.closeCalls).toBe(1);
});
it("rejects unsupported hosts without opening a file", async () => {
const state = installOfficeFileMock([], { unsupported: true });
await expect(getCurrentDocumentPdf()).rejects.toThrow();
expect(state.getFileCalls).toBe(0);
});
it("closes PDF even when slice reading fails", async () => {
const state = installOfficeFileMock([bytes(1)], { failSliceIndex: 0 });
await expect(getCurrentDocumentPdf()).rejects.toThrow();
expect(state.closeCalls).toBe(1);
});
});

View file

@ -9,26 +9,24 @@ export const DOCX_CONTENT_TYPE =
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
/** ขนาด slice ต่อครั้ง — 1MB กันกิน memory ยักษ์ในเครื่องรายงานน้อย */
export const PDF_CONTENT_TYPE = "application/pdf";
const SLICE_SIZE = 1024 * 1024;
function officeError(result: Office.AsyncResult<unknown>, fallback: string): Error {
return new Error(result.error?.message || fallback);
}
function getFileAsync(): Promise<Office.File> {
function getFileAsync(fileType: Office.FileType): Promise<Office.File> {
return new Promise((resolve, reject) => {
try {
Office.context.document.getFileAsync(
Office.FileType.Compressed,
{ sliceSize: SLICE_SIZE },
(result) => {
if (result.status === Office.AsyncResultStatus.Succeeded) {
resolve(result.value);
} else {
reject(officeError(result, "เปิดไฟล์เอกสารไม่สำเร็จ"));
}
Office.context.document.getFileAsync(fileType, { sliceSize: SLICE_SIZE }, (result) => {
if (result.status === Office.AsyncResultStatus.Succeeded) {
resolve(result.value);
} else {
reject(officeError(result, "เปิดไฟล์เอกสารไม่สำเร็จ"));
}
);
});
} catch (error) {
reject(error instanceof Error ? error : new Error("เปิดไฟล์เอกสารไม่สำเร็จ"));
}
@ -104,15 +102,26 @@ function closeFileQuietly(file: Office.File): Promise<void> {
* ประกอบไฟล์ทั้งไฟล์เป็น Blob — อ่าน slice ทีละชิ้นตามลำดับ
* เรียก closeAsync เสมอ (สำเร็จหรือ fail ก็ตาม)
*/
export async function getCurrentDocumentFile(): Promise<Blob> {
const file = await getFileAsync();
async function readFileAsBlob(fileType: Office.FileType, contentType: string): Promise<Blob> {
const file = await getFileAsync(fileType);
try {
const chunks: BlobPart[] = [];
for (let index = 0; index < file.sliceCount; index++) {
chunks.push(await getSliceAsync(file, index));
}
return new Blob(chunks, { type: DOCX_CONTENT_TYPE });
return new Blob(chunks, { type: contentType });
} finally {
await closeFileQuietly(file);
}
}
export async function getCurrentDocumentFile(): Promise<Blob> {
return readFileAsBlob(Office.FileType.Compressed, DOCX_CONTENT_TYPE);
}
export async function getCurrentDocumentPdf(): Promise<Blob> {
if (!Office.context.requirements.isSetSupported("PdfFile", "1.1")) {
throw new Error("Word นี้ไม่รองรับการสร้าง PDF");
}
return readFileAsBlob(Office.FileType.Pdf, PDF_CONTENT_TYPE);
}

View file

@ -5,12 +5,17 @@ import { installWordMock } from "../test/wordMock";
import { installFetchMock, makeFakeJwt, mockResponse } from "../test/httpMock";
/** mock Office.context.document (url สำหรับชื่อไฟล์ + getFileAsync สำหรับ fileService) */
function installOfficeDocumentMock(url: string | undefined): void {
function installOfficeDocumentMock(
url: string | undefined,
options: { failPdf?: boolean; emptyPdf?: boolean } = {}
): string[] {
const requested: string[] = [];
const slice = new Uint8Array([1, 2, 3, 4]);
(globalThis as unknown as Record<string, unknown>).Office = {
FileType: { Compressed: "compressed", Text: "text" },
FileType: { Compressed: "compressed", Pdf: "pdf", Text: "text" },
AsyncResultStatus: { Succeeded: "succeeded", Failed: "failed" },
context: {
requirements: { isSetSupported: () => true },
document: {
url,
getFileAsync: (
@ -18,10 +23,15 @@ function installOfficeDocumentMock(url: string | undefined): void {
_options: unknown,
callback: (result: unknown) => void
) => {
requested.push(String(_fileType));
if (_fileType === "pdf" && options.failPdf) {
callback({ status: "failed", error: { message: "PDF unavailable" } });
return;
}
callback({
status: "succeeded",
value: {
sliceCount: 1,
sliceCount: _fileType === "pdf" && options.emptyPdf ? 0 : 1,
getSliceAsync: (index: number, cb: (result: unknown) => void) =>
cb({ status: "succeeded", value: { index, data: slice.buffer } }),
closeAsync: (cb?: (result: unknown) => void) => cb?.({ status: "succeeded" }),
@ -31,6 +41,7 @@ function installOfficeDocumentMock(url: string | undefined): void {
},
},
};
return requested;
}
function seedSession(): void {
@ -266,3 +277,35 @@ describe("saveAndVersion (FR-2 / AC-7, 13)", () => {
expect(form.get("message")).toBeNull();
});
});
describe.each(["start", "save"])("FR-15 %s", (flow) => {
it.each(["success", "failure", "empty", "storage-failure"])(
"PDF %s preserves docx upload",
async (mode) => {
installWordMock({ properties: { DocumentTrackingId: "doc" } });
const requested = installOfficeDocumentMock(undefined, {
failPdf: mode === "failure",
emptyPdf: mode === "empty",
});
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
const fetchMock = installFetchMock();
fetchMock.mockImplementationOnce(async () =>
mockResponse({
status: 201,
body: { versionId: "v", versionNumber: 1, hasPdf: mode === "success" },
})
);
const result = flow === "start" ? await startVersioning() : await saveAndVersion("doc", 0);
const { form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]);
expect(requested).toEqual(["compressed", "pdf"]);
expect(form.get("file")).toBeInstanceOf(Blob);
if (mode === "success" || mode === "storage-failure") {
expect((form.get("pdf") as File).name).toMatch(/\.pdf$/);
expect((form.get("pdf") as File).type).toBe("application/pdf");
} else expect(form.get("pdf")).toBeNull();
expect(result.hasPdf).toBe(mode === "success");
expect(fetchMock).toHaveBeenCalledTimes(1);
warn.mockRestore();
}
);
});

View file

@ -1,7 +1,7 @@
/* global Word, Office */
/* global Word, Office, console */
import * as authService from "./authService";
import { getCurrentDocumentFile } from "./fileService";
import { getCurrentDocumentFile, getCurrentDocumentPdf } from "./fileService";
import {
generateGuid,
readContentControlFields,
@ -27,12 +27,14 @@ import { uploadVersion } from "./versioningService";
*/
export interface StartVersioningResult {
hasPdf: boolean;
documentId: string;
versionId: string;
versionNumber: number;
}
export interface SaveAndVersionResult {
hasPdf: boolean;
versionId: string;
versionNumber: number;
}
@ -71,10 +73,29 @@ async function uploadCurrentFile(
documentId: string,
clientVersionId: string,
fields: Record<string, string>,
message?: string
): Promise<{ versionId: string; versionNumber: number }> {
message?: string,
onProgress?: (progress: string) => void
): Promise<SaveAndVersionResult> {
const file = await getCurrentDocumentFile();
return uploadVersion(documentId, file, clientVersionId, fields, guessDocumentFileName(), message);
let pdf: Blob | undefined;
onProgress?.("กำลังสร้างสำเนา PDF…");
try {
pdf = await getCurrentDocumentPdf();
if (pdf.size === 0) throw new Error("Word คืนไฟล์ PDF ว่าง");
} catch (error) {
console.warn("PDF capture failed:", error);
pdf = undefined;
}
onProgress?.("กำลังอัปโหลด version…");
return uploadVersion(
documentId,
file,
clientVersionId,
fields,
guessDocumentFileName(),
message,
pdf
);
}
/** FR-1 — เริ่ม track เอกสารปัจจุบัน: stamp รอบแรก + upload เป็น version 1
@ -109,7 +130,7 @@ export async function startVersioning(
// 4) upload version แรก
onProgress?.("กำลังอัปโหลด version 1…");
const result = await uploadCurrentFile(documentId, clientVersionId, fields, message);
const result = await uploadCurrentFile(documentId, clientVersionId, fields, message, onProgress);
return { documentId, ...result };
}
@ -147,7 +168,7 @@ export async function saveAndVersion(
// 4) upload
onProgress?.("กำลังอัปโหลด version ใหม่…");
const result = await uploadCurrentFile(documentId, clientVersionId, fields, message);
const result = await uploadCurrentFile(documentId, clientVersionId, fields, message, onProgress);
// 5) เลขจริงจาก backend ต่างจากที่เดา (มี client อื่นแทรก) → แก้ XML part ให้ตรง (best effort)
if (result.versionNumber !== predictedNumber) {

View file

@ -0,0 +1,33 @@
import { getDownloadLink, uploadVersion } from "./versioningService";
import { apiGetJson, apiSendExpectJson } from "./apiClient";
jest.mock("./apiClient", () => ({ apiGetJson: jest.fn(), apiSendExpectJson: jest.fn() }));
it("requests PDF download-link and keeps docx as default", async () => {
await getDownloadLink("d", "v", "pdf");
expect(apiGetJson).toHaveBeenLastCalledWith(
"/api/documents/d/versions/v/download-link?format=pdf",
expect.any(String)
);
await getDownloadLink("d", "v");
expect(apiGetJson).toHaveBeenLastCalledWith(
"/api/documents/d/versions/v/download-link",
expect.any(String)
);
});
it("uploads optional PDF alongside the docx in one request", async () => {
await uploadVersion(
"d",
new Blob(["docx"]),
"v",
undefined,
undefined,
undefined,
new Blob(["pdf"], { type: "application/pdf" })
);
const form = (apiSendExpectJson as jest.Mock).mock.calls[0][1].body as FormData;
expect((form.get("pdf") as File).name).toBe("d.pdf");
expect((form.get("pdf") as File).type).toBe("application/pdf");
expect((form.get("file") as File).name).toBe("d.docx");
});

View file

@ -16,11 +16,13 @@ export async function uploadVersion(
clientVersionId: string,
fields?: Record<string, string>,
fileName?: string,
message?: string
message?: string,
pdf?: Blob
): Promise<UploadVersionResult> {
const form = new FormData();
form.append("file", file, fileName || `${documentId}.docx`);
form.append("clientVersionId", clientVersionId);
if (pdf) form.append("pdf", pdf, `${documentId}.pdf`);
if (fields && Object.keys(fields).length > 0) {
form.append("fields", JSON.stringify(fields));
}
@ -44,8 +46,12 @@ export async function getVersions(documentId: string): Promise<VersionRecord[]>
}
/** FR-7 — URL ของ download endpoint (ผ่าน backend proxy เท่านั้น — MinIO ไม่ถูก expose) */
export function buildDownloadUrl(documentId: string, versionId: string): string {
return `${config.apiBaseUrl}/api/documents/${documentId}/versions/${versionId}/download`;
export function buildDownloadUrl(
documentId: string,
versionId: string,
format?: "docx" | "pdf"
): string {
return `${config.apiBaseUrl}/api/documents/${documentId}/versions/${versionId}/download${format === "pdf" ? "?format=pdf" : ""}`;
}
export interface VersionDownloadLink {
@ -60,17 +66,22 @@ export interface VersionDownloadLink {
*/
export async function getDownloadLink(
documentId: string,
versionId: string
versionId: string,
format?: "docx" | "pdf"
): Promise<VersionDownloadLink> {
return apiGetJson<VersionDownloadLink>(
`/api/documents/${documentId}/versions/${versionId}/download-link`,
`/api/documents/${documentId}/versions/${versionId}/download-link${format === "pdf" ? "?format=pdf" : ""}`,
"ขอลิงก์ดาวน์โหลดไม่สำเร็จ"
);
}
/** FR-7 — ดาวน์โหลดไฟล์ของ version หนึ่งเป็น Blob (ต้องแนบ Bearer จึงต้อง fetch แทน link ตรง) */
export async function downloadVersion(documentId: string, versionId: string): Promise<Blob> {
const response = await apiFetch(buildDownloadUrl(documentId, versionId));
export async function downloadVersion(
documentId: string,
versionId: string,
format?: "docx" | "pdf"
): Promise<Blob> {
const response = await apiFetch(buildDownloadUrl(documentId, versionId, format));
if (!response.ok) {
throw new ApiError(
response.status,

View file

@ -16,7 +16,7 @@ import * as authService from "../../services/authService";
import { AuthRequiredError } from "../../services/apiClient";
import { getErrorMessage } from "../../services/errors";
import * as metadataService from "../../services/metadataService";
import { downloadVersion, getDownloadLink, getVersions } from "../../services/versioningService";
import { getDownloadLink, getVersions } from "../../services/versioningService";
import { saveAndVersion, startVersioning } from "../../services/versionFlow";
import { VersionRecord } from "../../models/versionRecord";
import ConfirmVersionDialog from "./ConfirmVersionDialog";
@ -89,6 +89,8 @@ const App: React.FC = () => {
const [confirmOpen, setConfirmOpen] = React.useState(false);
const [startConfirmOpen, setStartConfirmOpen] = React.useState(false);
const [flowError, setFlowError] = React.useState<string | null>(null);
const [flowWarning, setFlowWarning] = React.useState<string | null>(null);
const [downloadingPdfVersionId, setDownloadingPdfVersionId] = React.useState<string | null>(null);
const [flowSuccess, setFlowSuccess] = React.useState<string | null>(null);
const [downloadingVersionId, setDownloadingVersionId] = React.useState<string | null>(null);
@ -215,11 +217,16 @@ const App: React.FC = () => {
setStarting(true);
setFlowError(null);
setFlowSuccess(null);
setFlowWarning(null);
try {
const result = await startVersioning(message, setProgressMessage);
setDocumentId(result.documentId);
setPhase("tracking");
setFlowSuccess(`เริ่มติดตามเอกสารนี้แล้ว — สร้างเป็น version 1`);
if (!result.hasPdf)
setFlowWarning(
"บันทึกแล้ว แต่สร้าง PDF ไม่สำเร็จ — version นี้ไม่มีสำเนา PDF (กด Save & Version อีกครั้งเพื่อสร้าง version ใหม่ที่มี PDF)"
);
await refreshHistory(result.documentId);
} catch (error) {
if (!handleAuthRequired(error)) {
@ -240,10 +247,20 @@ const App: React.FC = () => {
setSaving(true);
setFlowError(null);
setFlowSuccess(null);
setFlowWarning(null);
try {
const latestVersionNumber = versions[0]?.versionNumber ?? 0;
const result = await saveAndVersion(documentId, latestVersionNumber, message, setProgressMessage);
const result = await saveAndVersion(
documentId,
latestVersionNumber,
message,
setProgressMessage
);
setFlowSuccess(`สร้าง version ${result.versionNumber} เรียบร้อย`);
if (!result.hasPdf)
setFlowWarning(
"บันทึกแล้ว แต่สร้าง PDF ไม่สำเร็จ — version นี้ไม่มีสำเนา PDF (กด Save & Version อีกครั้งเพื่อสร้าง version ใหม่ที่มี PDF)"
);
await refreshHistory(documentId);
} catch (error) {
if (!handleAuthRequired(error)) {
@ -258,36 +275,33 @@ const App: React.FC = () => {
/**
* FR-4 — ดาวน์โหลด version ที่เลือก: ขอ presigned URL อายุสั้นจาก backend แล้วเปิดใน
* เบราว์เซอร์ของเครื่อง (webview ของ Word — โดยเฉพาะ WKWebView บน Mac — ทิ้ง blob download
* แบบเงียบๆ ไฟล์ไม่เคยถูกเขียนที่ไหนเลย) · host ไหนไม่มี openBrowserWindow จะย้อนกลับไปใช้ blob
* แบบเงียบๆ ไฟล์ไม่เคยถูกเขียนที่ไหนเลย)
*/
const handleDownload = async (version: VersionRecord) => {
const handleDownload = async (version: VersionRecord, format: "docx" | "pdf" = "docx") => {
if (!documentId) {
return;
}
setDownloadingVersionId(version.versionId);
const setDownloading = format === "pdf" ? setDownloadingPdfVersionId : setDownloadingVersionId;
setDownloading(version.versionId);
setFlowError(null);
try {
const link = await getDownloadLink(documentId, version.versionId);
const link = await getDownloadLink(documentId, version.versionId, format);
if (typeof Office.context.ui.openBrowserWindow === "function") {
Office.context.ui.openBrowserWindow(link.url);
setFlowSuccess(`กำลังดาวน์โหลด v${version.versionNumber} ผ่านเบราว์เซอร์ของคุณ — ดูไฟล์ที่โฟลเดอร์ Downloads`);
setFlowSuccess(
format === "pdf"
? `กำลังเปิด PDF ของ v${version.versionNumber} ในเบราว์เซอร์ของคุณ`
: `กำลังดาวน์โหลด v${version.versionNumber} ผ่านเบราว์เซอร์ของคุณ — ดูไฟล์ที่โฟลเดอร์ Downloads`
);
} else {
const blob = await downloadVersion(documentId, version.versionId);
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `document_v${version.versionNumber}.docx`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
throw new Error("Word นี้ไม่รองรับการเปิดเบราว์เซอร์ กรุณาเปิดไฟล์จาก Web Portal");
}
} catch (error) {
if (!handleAuthRequired(error)) {
setFlowError(getErrorMessage(error, "ดาวน์โหลด version ไม่สำเร็จ"));
}
} finally {
setDownloadingVersionId(null);
setDownloading(null);
}
};
@ -379,6 +393,11 @@ const App: React.FC = () => {
</MessageBar>
) : null}
{flowWarning ? (
<MessageBar intent="warning">
<MessageBarBody>{flowWarning}</MessageBarBody>
</MessageBar>
) : null}
{flowSuccess ? (
<MessageBar intent="success">
<MessageBarBody>{flowSuccess}</MessageBarBody>
@ -415,6 +434,8 @@ const App: React.FC = () => {
loading={historyLoading}
error={historyError}
downloadingVersionId={downloadingVersionId}
downloadingPdfVersionId={downloadingPdfVersionId}
onDownloadPdf={(version) => void handleDownload(version, "pdf")}
onDownload={(version) => void handleDownload(version)}
onRetry={() => documentId && void refreshHistory(documentId)}
/>

View file

@ -9,7 +9,11 @@ import {
makeStyles,
tokens,
} from "@fluentui/react-components";
import { ArrowDownload24Regular, History24Regular } from "@fluentui/react-icons";
import {
ArrowDownload24Regular,
DocumentPdf24Regular,
History24Regular,
} from "@fluentui/react-icons";
import { VersionRecord } from "../../models/versionRecord";
interface VersionHistoryPanelProps {
@ -18,6 +22,8 @@ interface VersionHistoryPanelProps {
error: string | null;
/** versionId ของ version ที่กำลังดาวน์โหลดอยู่ (แสดง spinner ที่ปุ่ม) */
downloadingVersionId: string | null;
downloadingPdfVersionId: string | null;
onDownloadPdf: (version: VersionRecord) => void;
onDownload: (version: VersionRecord) => void;
onRetry: () => void;
}
@ -46,6 +52,7 @@ const useStyles = makeStyles({
backgroundColor: tokens.colorNeutralBackground1Hover,
},
},
actions: { display: "flex", flexDirection: "column", flexShrink: 0 },
itemInfo: {
display: "flex",
flexDirection: "column",
@ -76,6 +83,8 @@ export const VersionHistoryPanel: React.FC<VersionHistoryPanelProps> = ({
loading,
error,
downloadingVersionId,
downloadingPdfVersionId,
onDownloadPdf,
onDownload,
onRetry,
}) => {
@ -115,19 +124,30 @@ export const VersionHistoryPanel: React.FC<VersionHistoryPanelProps> = ({
</Badge>
<span className={styles.metaRow}>{formatTimestamp(version.createdAt)}</span>
<span className={styles.metaRow}>โดย {version.createdByUserId}</span>
{version.message ? (
<span className={styles.messageRow}>{version.message}</span>
{version.message ? <span className={styles.messageRow}>{version.message}</span> : null}
</div>
<div className={styles.actions}>
<Button
icon={<ArrowDownload24Regular />}
size="small"
appearance="subtle"
disabled={downloadingVersionId !== null || downloadingPdfVersionId !== null}
onClick={() => onDownload(version)}
>
{downloadingVersionId === version.versionId ? "กำลังดาวน์โหลด…" : "ดาวน์โหลด"}
</Button>
{version.hasPdf ? (
<Button
icon={<DocumentPdf24Regular />}
size="small"
appearance="subtle"
disabled={downloadingVersionId !== null || downloadingPdfVersionId !== null}
onClick={() => onDownloadPdf(version)}
>
{downloadingPdfVersionId === version.versionId ? "กำลังเปิด…" : "PDF"}
</Button>
) : null}
</div>
<Button
icon={<ArrowDownload24Regular />}
size="small"
appearance="subtle"
disabled={downloadingVersionId !== null}
onClick={() => onDownload(version)}
>
{downloadingVersionId === version.versionId ? "กำลังดาวน์โหลด…" : "ดาวน์โหลด"}
</Button>
</div>
))}
</div>