/* global Office */ "use client"; import { useCallback, useEffect, useState } from "react"; import { FileUp, GitCommitHorizontal, LogOut } from "lucide-react"; import { autoSaveService } from "@/wordaddin/services/autoSaveService"; import * as authService from "@/wordaddin/services/authService"; import { AuthRequiredError } from "@/wordaddin/services/apiClient"; import * as documentService from "@/wordaddin/services/documentService"; import { getErrorMessage } from "@/wordaddin/services/errors"; import * as metadataService from "@/wordaddin/services/metadataService"; import { getDownloadLink, getVersions } from "@/wordaddin/services/versioningService"; import { saveAndVersion, startVersioning, VersionSavedButCloseFailedError } from "@/wordaddin/services/versionFlow"; import { VersionRecord } from "@/wordaddin/models/versionRecord"; import ConfirmVersionDialog from "./ConfirmVersionDialog"; import { MessageBar } from "./MessageBar"; import SaveControls from "./SaveControls"; import { Spinner } from "./Spinner"; import VersionHistoryPanel from "./VersionHistoryPanel"; /** * Task pane หลัก — state machine ตาม Phase 5: * checking → (ไม่มี token) signed-out * → (มี token + ไม่ใช่ tracked document) untracked * → (มี token + tracked document) tracking * พอร์ตจาก App.tsx ต้นฉบับ (D:\word-addin-poc\addin) — state/handlers คงเดิมทุกตัว * เปลี่ยนเฉพาะ: import path, JSX → Genesis/Tailwind, ถอด FluentProvider/makeStyles, * เพิ่ม prop demo (โหมด ?addindemo ของ WordAddinGate — ตั้ง phase ตรง ไม่ detect จริง) * * กติกา: ทุก async ต้อง catch — AuthRequiredError → กลับ signed-out อย่างเนียน (AC-9) * error อื่นแสดง MessageBar ไม่ปล่อย unhandled rejection * * FR-17 — auto-save (local เท่านั้น) เริ่มตอน mount ไม่รอ sign-in; FR-3 reminder * กลายเป็น flag `uncommittedSinceVersion` ที่ periodic tick ของ autoSaveService ตั้งค่า */ type Phase = "checking" | "signed-out" | "untracked" | "tracking"; const BTN_PRIMARY = "focus-ring inline-flex items-center gap-1.5 rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow disabled:pointer-events-none disabled:opacity-50"; /** demo mode — version ตัวอย่าง 3 รายการ (ใหม่ → เก่า) ให้ history panel มีของโชว์ทันที */ function demoVersions(now: number): VersionRecord[] { return [ { versionId: "demo-v3", versionNumber: 3, createdAt: new Date(now - 3_600_000).toISOString(), createdByUserId: authService.getUsername() ?? "tester01", contentHash: "demo", message: "ปรับคำร้องตามคำแนะนำอัยการ", hasPdf: true, fields: null, }, { versionId: "demo-v2", versionNumber: 2, createdAt: new Date(now - 86_400_000).toISOString(), createdByUserId: authService.getUsername() ?? "tester01", contentHash: "demo", message: null, hasPdf: true, fields: null, }, { versionId: "demo-v1", versionNumber: 1, createdAt: new Date(now - 172_800_000).toISOString(), createdByUserId: authService.getUsername() ?? "tester01", contentHash: "demo", message: "สร้างเอกสารครั้งแรก", hasPdf: false, fields: null, }, ]; } const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) => { const [phase, setPhase] = useState(demo === "untracked" ? "untracked" : demo ? "tracking" : "checking"); const [bootError, setBootError] = useState(null); const [username, setUsername] = useState(demo ? (authService.getUsername() ?? null) : null); const [documentId, setDocumentId] = useState(demo ? "00000000-0000-0000-0000-00000000demo" : null); /** FR-16 — template ต้นทางของไฟล์ร่าง (มีเฉพาะไฟล์ที่เว็บสร้างแบบ draft และยังไม่ได้ track) */ const [documentTemplateId, setDocumentTemplateId] = useState(null); const [versions, setVersions] = useState(demo ? demoVersions(Date.now()) : []); const [historyLoading, setHistoryLoading] = useState(false); const [historyError, setHistoryError] = useState(null); const [signingIn, setSigningIn] = useState(false); const [loginError, setLoginError] = useState(null); const [starting, setStarting] = useState(false); const [saving, setSaving] = useState(false); const [progressMessage, setProgressMessage] = useState(null); const [confirmOpen, setConfirmOpen] = useState(false); const [startConfirmOpen, setStartConfirmOpen] = useState(false); const [flowError, setFlowError] = useState(null); const [flowWarning, setFlowWarning] = useState(null); const [downloadingPdfVersionId, setDownloadingPdfVersionId] = useState(null); const [flowSuccess, setFlowSuccess] = useState(null); const [downloadingVersionId, setDownloadingVersionId] = useState(null); /** FR-18 — platform นี้ปิดเอกสารจาก add-in ได้ไหม (Word on the web ใช้ close() ไม่ได้ → ซ่อนปุ่มทุกแบบ) * นอก Word (SSR/browser ธรรมดา) canCloseDocument catch คืน false เสมอ */ const [closeSupported] = useState(() => documentService.canCloseDocument()); /** FR-18 — สถานะของปุ่ม [บันทึก] / [บันทึกและปิดเอกสาร] (แยกจาก flow versioning) */ const [saveBusy, setSaveBusy] = useState(false); const [saveStatus, setSaveStatus] = useState<"saving" | "saved" | null>(null); const [saveError, setSaveError] = useState(null); /** FR-17 — banner เตือนเมื่อ auto-save ล้ม (ไม่บล็อก แต่ไม่ปล่อยเงียบ) */ const [autoSaveError, setAutoSaveError] = useState(null); /** FR-3 — periodic tick เห็นการแก้ไขหลัง version ล่าสุด (แสดงเฉพาะตอน tracking) */ const [hasUncommitted, setHasUncommitted] = useState(false); /** 401 ที่ refresh ไม่ช่วย → กลับหน้า sign-in อย่างเนียน (AC-9) */ const handleAuthRequired = useCallback((error: unknown): boolean => { if (error instanceof AuthRequiredError) { setPhase("signed-out"); setDocumentId(null); setDocumentTemplateId(null); setVersions([]); setLoginError("เซสชันหมดอายุ — กรุณา login ใหม่"); return true; } return false; }, []); const refreshHistory = useCallback( async (id: string) => { setHistoryLoading(true); setHistoryError(null); try { setVersions(await getVersions(id)); } catch (error) { if (!handleAuthRequired(error)) { setHistoryError(getErrorMessage(error, "โหลด version history ไม่สำเร็จ")); } } finally { setHistoryLoading(false); } }, [handleAuthRequired], ); /** ตรวจตอนเปิด task pane (FR-1): token → SavedByAddIn → history * FR-16 — อ่าน properties ครั้งเดียวทั้งชุดแล้ว derive tracked เอง เพราะไฟล์ร่าง (untracked) * ต้องได้อ่าน TemplateId ไปใช้ตอน Start Versioning ด้วย (ข้ามทั้งหมดในโหมด demo) */ const detect = useCallback(async () => { setPhase("checking"); setBootError(null); try { const token = await authService.getAccessToken(); if (!token) { setPhase("signed-out"); return; } authService.startAutoRefresh(); setUsername(authService.getUsername() ?? null); const properties = await metadataService.readTrackedProperties(); const tracked = properties.savedByAddIn === metadataService.ADDIN_SIGNATURE_GUID; const id = tracked ? properties.documentTrackingId : undefined; if (!id) { // ไม่เคย track / track แต่ไม่มี id (ไฟล์เสียหาย) — ไฟล์ร่างจาก template อยู่กลุ่มนี้ (FR-16) setDocumentTemplateId(properties.templateId ?? null); setPhase("untracked"); return; } setDocumentId(id); setDocumentTemplateId(null); setPhase("tracking"); await refreshHistory(id); } catch (error) { setBootError(getErrorMessage(error, "ตรวจสอบเอกสารไม่สำเร็จ")); } }, [refreshHistory]); useEffect(() => { if (demo) return; // ?addindemo — ตั้ง phase จาก prop แล้ว ไม่ตรวจไฟล์จริง void detect(); }, [demo, detect]); /** FR-17 — เริ่ม auto-save ตั้งแต่ mount ไม่รอ sign-in (ข้ามใน demo กัน error banner จาก Word ที่ไม่มี) * ผูก listener ให้ React state: error → banner, dirty→save → flag FR-3 */ useEffect(() => { autoSaveService.onError = (error) => setAutoSaveError(getErrorMessage(error, "บันทึกอัตโนมัติไม่สำเร็จ — ลองกด \"บันทึก\" ด้วยตนเอง")); autoSaveService.onSaved = () => setAutoSaveError(null); autoSaveService.onStateChange = () => setHasUncommitted(autoSaveService.hasUncommittedSinceVersion); if (!demo) { autoSaveService.start(); } return () => { autoSaveService.stop(); autoSaveService.onError = undefined; autoSaveService.onSaved = undefined; autoSaveService.onStateChange = undefined; }; }, [demo]); const handleSignIn = async () => { setSigningIn(true); setLoginError(null); try { const result = await authService.login(); if (result.ok) { await detect(); } else { setLoginError(result.error); } } catch (error) { setLoginError(getErrorMessage(error, "เข้าสู่ระบบไม่สำเร็จ")); } finally { setSigningIn(false); } }; const handleSignOut = () => { authService.signOut(); setUsername(null); setDocumentId(null); setDocumentTemplateId(null); setVersions([]); setLoginError(null); setPhase("signed-out"); }; /** FR-18 [บันทึก] — local save เดี่ยวผ่าน lock ของ autoSaveService (ไม่ stamp metadata อะไร) */ const handleSaveOnly = async () => { setSaveBusy(true); setSaveStatus("saving"); setSaveError(null); setAutoSaveError(null); try { await autoSaveService.save(); setSaveStatus("saved"); } catch (error) { setSaveStatus(null); setSaveError(getErrorMessage(error, "บันทึกไม่สำเร็จ กรุณาลองอีกครั้ง")); } finally { setSaveBusy(false); } }; /** FR-18 [บันทึกและปิดเอกสาร] — ลำดับตาม spec: suspend → forceSave → close(Save) → resume * save ล้ม = ไม่ปิด (AC-21) · ปิดสำเร็จแล้ว task pane จะปิดตามเอกสารเอง ไม่มี toast ท้าย */ const handleSaveAndClose = async () => { setSaveBusy(true); setSaveStatus("saving"); setSaveError(null); setAutoSaveError(null); try { await autoSaveService.suspend(); try { await autoSaveService.forceSave(); await documentService.closeDocument(documentService.DocumentCloseMode.Save); } finally { autoSaveService.resume(); } } catch (error) { setSaveStatus(null); setSaveError(getErrorMessage(error, "บันทึก/ปิดเอกสารไม่สำเร็จ กรุณาลองอีกครั้ง")); } finally { setSaveBusy(false); } }; /** Start Versioning — stamp รอบแรก + upload v1 (FR-1/AC-5) · message คือ commit message จาก dialog (FR-13) * FR-16 — ไฟล์ร่าง: แนบ templateId ไปให้ backend ผูก Document กับ template ต้นทาง */ const handleStartVersioning = async (message: string) => { setStarting(true); setFlowError(null); setFlowSuccess(null); setFlowWarning(null); try { const result = await startVersioning(message, documentTemplateId ?? undefined, setProgressMessage); setDocumentId(result.documentId); setDocumentTemplateId(null); 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)) { setFlowError(getErrorMessage(error, "เริ่มติดตามเอกสารไม่สำเร็จ")); } } finally { setProgressMessage(null); setStarting(false); } }; /** Save & Version (FR-2) — ตามลำดับ spec: confirm → stamp → XML → save → file+fields → upload * message คือ commit message จาก dialog (FR-13) * FR-18 — closeAfter=true มาจาก submit ที่สองของ dialog: version สำเร็จ → ปิดเอกสารตาม */ const handleSaveAndVersion = async (message: string, closeAfter = false) => { if (!documentId) { return; } setSaving(true); setFlowError(null); setFlowSuccess(null); setFlowWarning(null); try { const latestVersionNumber = versions[0]?.versionNumber ?? 0; const result = await saveAndVersion(documentId, latestVersionNumber, message, setProgressMessage, closeAfter); if (closeAfter) { // ปิดเอกสารสำเร็จแล้ว — task pane จะปิดตามเอกสาร ไม่ต้อง refresh อะไรต่อ return; } setFlowSuccess(`สร้าง version ${result.versionNumber} เรียบร้อย`); if (!result.hasPdf) setFlowWarning( "บันทึกแล้ว แต่สร้าง PDF ไม่สำเร็จ — version นี้ไม่มีสำเนา PDF (กด Save & Version อีกครั้งเพื่อสร้าง version ใหม่ที่มี PDF)", ); await refreshHistory(documentId); } catch (error) { if (error instanceof VersionSavedButCloseFailedError) { // version ปลอดภัยบน backend แล้ว — เอกสารยังเปิด แจ้งเป็น warning ไม่ใช่ error ของ version setFlowWarning(error.message); await refreshHistory(documentId); return; } if (!handleAuthRequired(error)) { setFlowError(getErrorMessage(error, "สร้าง version ไม่สำเร็จ")); } } finally { setProgressMessage(null); setSaving(false); } }; /** * FR-4 — ดาวน์โหลด version ที่เลือก: ขอ presigned URL อายุสั้นจาก backend แล้วเปิดใน * เบราว์เซอร์ของเครื่อง (webview ของ Word — โดยเฉพาะ WKWebView บน Mac — ทิ้ง blob download * แบบเงียบๆ ไฟล์ไม่เคยถูกเขียนที่ไหนเลย) */ const handleDownload = async (version: VersionRecord, format: "docx" | "pdf" = "docx") => { if (!documentId) { return; } const setDownloading = format === "pdf" ? setDownloadingPdfVersionId : setDownloadingVersionId; setDownloading(version.versionId); setFlowError(null); try { const link = await getDownloadLink(documentId, version.versionId, format); if (typeof Office.context.ui.openBrowserWindow === "function") { Office.context.ui.openBrowserWindow(link.url); setFlowSuccess( format === "pdf" ? `กำลังเปิด PDF ของ v${version.versionNumber} ในเบราว์เซอร์ของคุณ` : `กำลังดาวน์โหลด v${version.versionNumber} ผ่านเบราว์เซอร์ของคุณ — ดูไฟล์ที่โฟลเดอร์ Downloads`, ); } else { throw new Error("Word นี้ไม่รองรับการเปิดเบราว์เซอร์ กรุณาเปิดไฟล์จาก Web Portal"); } } catch (error) { if (!handleAuthRequired(error)) { setFlowError(getErrorMessage(error, "ดาวน์โหลด version ไม่สำเร็จ")); } } finally { setDownloading(null); } }; return (
e-Court Documents Versioning {phase !== "signed-out" && phase !== "checking" && username ? ( /* จอแคบ: username ย้ายไป title/aria-label — ไม่กินพื้นที่แถบหัวจนตัวชื่อแอป wrap 3-4 บรรทัด */ ) : null}
{phase === "checking" ? (
{bootError ? ( void detect()} > ลองใหม่ } > {bootError} ) : null}
) : null} {phase === "signed-out" ? (

เข้าสู่ระบบเพื่อเริ่มใช้งาน versioning

ใช้บัญชีของ realm word-versioning — หน้าต่าง login จะเปิดแยกต่างหาก

{loginError ? {loginError} : null}
) : null} {phase === "untracked" ? (

เอกสารนี้ยังไม่ได้ติดตาม version

กดปุ่มด้านล่างเพื่อเริ่ม track — ระบบจะ stamp metadata ลงไฟล์และอัปโหลดเป็น version 1 (ไฟล์ที่สร้างจากเว็บ portal จะถูกตรวจพบและข้ามขั้นตอนนี้อัตโนมัติ)

{documentTemplateId ? (

ไฟล์นี้เป็นไฟล์ร่างจาก template — กดเริ่มติดตามแล้วระบบจะผูกเอกสารกับ template ต้นทาง และ sync ค่า fields ให้อัตโนมัติ

) : null} void handleSaveOnly()} onSaveAndClose={() => void handleSaveAndClose()} /> {starting && progressMessage ? : null} {flowError ? {flowError} : null}
) : null} {phase === "tracking" ? ( /* ห้ามใส่ items-start — ลูกทุกตัวจะหดตามเนื้อหา (MessageBar/history list ไม่เต็มความกว้าง pane) */
{/* กลุ่มข้อความสถานะ */} {hasUncommitted ? ( มีการแก้ไขที่ยังไม่ถูกบันทึกเป็นเวอร์ชันใหม่ — กด Save & Version เพื่อบันทึกเป็น version ใหม่ ) : null} {flowWarning ? {flowWarning} : null} {flowSuccess ? {flowSuccess} : null} {flowError ? {flowError} : null} {/* กลุ่ม action บันทึก — ปุ่มหลักเต็มความกว้างแถบ (action เด่นที่สุดของทั้งแอป) + progress + local save icon ใช้ commit ไม่ใช่ floppy — แยกจากปุ่ม "บันทึก" (local save) ที่ความหมายต่างกัน */}
{saving && progressMessage ? : null} void handleSaveOnly()} onSaveAndClose={() => void handleSaveAndClose()} />
{/* ink-soft (ไม่ใช่ ink-faint 2.6:1) — tracking id คือ identifier ที่ผู้ใช้อาจต้องอ่าน/คัดลอกตอนแจ้งปัญหา */}

tracking id: {documentId?.slice(0, 8)}…

void handleDownload(version, "pdf")} onDownload={(version) => void handleDownload(version)} onRetry={() => documentId && void refreshHistory(documentId)} />
) : null} {/* กดยืนยันแล้วปิด dialog ทันที — progress/ผลลัพธ์แสดงที่ pane (dialog ค้าง = บังผลลัพธ์ + เสี่ยงกดยืนยันซ้ำสร้าง version ซ้ำ; busy ใน dialog เหลือเป็น defense ชั่วขณะก่อน re-render) */} { setConfirmOpen(false); void handleSaveAndVersion(message); }} {...(closeSupported ? { onConfirmClose: (message: string) => { setConfirmOpen(false); void handleSaveAndVersion(message, true); }, } : {})} onDismiss={() => setConfirmOpen(false)} /> {/* FR-13 — Start Versioning ใช้ dialog เดียวกัน: ยืนยัน + พิมพ์ commit message ของ v1 ก่อนเริ่ม track */} { setStartConfirmOpen(false); void handleStartVersioning(message); }} onDismiss={() => setStartConfirmOpen(false)} />
); }; export default TaskpaneApp;