feat(wordaddin): port Word add-in taskpane from poc
พอร์ต taskpane ของ Word add-in "e-Court Documents Versioning" จาก D:\word-addin-poc มาเป็น sibling app ที่ route /docedit/wordaddin: - wordaddin/ (client-only zone): services พอร์ตดิบทั้งชุด (authService/versioningService เป็น mock รอ backend จริง) + models + config - components/wordaddin/: WordAddinGate ฉีด office.js self-host (singleton กัน StrictMode double-inject + fallback timer ครอบเคส onReady เงียบ) และ TaskpaneApp state machine แปลง JSX เป็น Genesis design system - scripts/fetch-office-js.mjs (postinstall ตัวที่สาม): ดาวน์โหลด Office.js runtime ล่าสุดจาก CDN Microsoft ไป public/wordaddin/office-js/ (gitignore สร้างใหม่ได้ — npm @microsoft/office-js ตกรุ่นไม่มี Document.close) - middleware/basePath/root page: เปิด /wordaddin เป็น public (auth แยกจาก แอป note), root /docedit redirect ไป taskpane ยกเว้นลิงก์ e-Court N8 (?fileUrl=&fileId=&typeId=) ที่ยังส่งต่อ /note ตามเดิม - docker/web.Dockerfile รัน fetch-office-js ตอน build + npm scripts dev:https สำหรับ sideload ตาม docs/WORDADDIN-DEV.md Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
7a146d48be
commit
c401f4deb7
37 changed files with 11216 additions and 17 deletions
566
components/wordaddin/TaskpaneApp.tsx
Normal file
566
components/wordaddin/TaskpaneApp.tsx
Normal file
|
|
@ -0,0 +1,566 @@
|
|||
/* 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<Phase>(demo === "untracked" ? "untracked" : demo ? "tracking" : "checking");
|
||||
const [bootError, setBootError] = useState<string | null>(null);
|
||||
const [username, setUsername] = useState<string | null>(demo ? (authService.getUsername() ?? null) : null);
|
||||
|
||||
const [documentId, setDocumentId] = useState<string | null>(demo ? "00000000-0000-0000-0000-00000000demo" : null);
|
||||
/** FR-16 — template ต้นทางของไฟล์ร่าง (มีเฉพาะไฟล์ที่เว็บสร้างแบบ draft และยังไม่ได้ track) */
|
||||
const [documentTemplateId, setDocumentTemplateId] = useState<string | null>(null);
|
||||
const [versions, setVersions] = useState<VersionRecord[]>(demo ? demoVersions(Date.now()) : []);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
const [historyError, setHistoryError] = useState<string | null>(null);
|
||||
|
||||
const [signingIn, setSigningIn] = useState(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [progressMessage, setProgressMessage] = useState<string | null>(null);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [startConfirmOpen, setStartConfirmOpen] = useState(false);
|
||||
const [flowError, setFlowError] = useState<string | null>(null);
|
||||
const [flowWarning, setFlowWarning] = useState<string | null>(null);
|
||||
const [downloadingPdfVersionId, setDownloadingPdfVersionId] = useState<string | null>(null);
|
||||
const [flowSuccess, setFlowSuccess] = useState<string | null>(null);
|
||||
|
||||
const [downloadingVersionId, setDownloadingVersionId] = useState<string | null>(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<string | null>(null);
|
||||
/** FR-17 — banner เตือนเมื่อ auto-save ล้ม (ไม่บล็อก แต่ไม่ปล่อยเงียบ) */
|
||||
const [autoSaveError, setAutoSaveError] = useState<string | null>(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 (
|
||||
<div className="flex min-h-screen flex-col gap-2 p-4">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-line pb-2">
|
||||
<span className="font-display text-sm font-semibold leading-tight text-ink">e-Court Documents Versioning</span>
|
||||
{phase !== "signed-out" && phase !== "checking" && username ? (
|
||||
/* จอแคบ: username ย้ายไป title/aria-label — ไม่กินพื้นที่แถบหัวจนตัวชื่อแอป wrap 3-4 บรรทัด */
|
||||
<button
|
||||
type="button"
|
||||
className="focus-ring inline-flex shrink-0 items-center gap-1 rounded-md px-2 py-1.5 text-xs text-ink-soft transition hover:text-primary"
|
||||
onClick={handleSignOut}
|
||||
aria-label={`ออกจากระบบ (${username})`}
|
||||
title={`${username} — ออกจากระบบ`}
|
||||
>
|
||||
<LogOut className="size-3.5" aria-hidden />
|
||||
ออกจากระบบ
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{phase === "checking" ? (
|
||||
<div className="flex flex-col items-center gap-4 pt-10">
|
||||
<Spinner label="กำลังตรวจสอบเอกสาร…" />
|
||||
{bootError ? (
|
||||
<MessageBar
|
||||
intent="error"
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
className="focus-ring rounded-md border border-error/40 bg-white px-2 py-1.5 text-xs text-error-ink"
|
||||
onClick={() => void detect()}
|
||||
>
|
||||
ลองใหม่
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{bootError}
|
||||
</MessageBar>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{phase === "signed-out" ? (
|
||||
<div className="flex flex-col gap-2 pt-4">
|
||||
<p className="text-base text-ink">เข้าสู่ระบบเพื่อเริ่มใช้งาน versioning</p>
|
||||
<p className="text-sm text-ink-soft">
|
||||
ใช้บัญชีของ realm <code>word-versioning</code> — หน้าต่าง login จะเปิดแยกต่างหาก
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN_PRIMARY} mt-2 w-full justify-center`}
|
||||
disabled={signingIn}
|
||||
onClick={() => void handleSignIn()}
|
||||
>
|
||||
{signingIn ? "กำลังเข้าสู่ระบบ…" : "เข้าสู่ระบบ"}
|
||||
</button>
|
||||
{loginError ? <MessageBar intent="warning">{loginError}</MessageBar> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{phase === "untracked" ? (
|
||||
<div className="flex flex-col gap-2 pt-4">
|
||||
<p className="text-base text-ink">เอกสารนี้ยังไม่ได้ติดตาม version</p>
|
||||
<p className="text-sm text-ink-soft">
|
||||
กดปุ่มด้านล่างเพื่อเริ่ม track — ระบบจะ stamp metadata ลงไฟล์และอัปโหลดเป็น version 1
|
||||
(ไฟล์ที่สร้างจากเว็บ portal จะถูกตรวจพบและข้ามขั้นตอนนี้อัตโนมัติ)
|
||||
</p>
|
||||
{documentTemplateId ? (
|
||||
<p className="text-sm text-ink-soft">
|
||||
ไฟล์นี้เป็นไฟล์ร่างจาก template — กดเริ่มติดตามแล้วระบบจะผูกเอกสารกับ template ต้นทาง
|
||||
และ sync ค่า fields ให้อัตโนมัติ
|
||||
</p>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN_PRIMARY} mt-2 w-full justify-center`}
|
||||
disabled={starting}
|
||||
onClick={() => setStartConfirmOpen(true)}
|
||||
>
|
||||
<FileUp className="size-4" aria-hidden />
|
||||
{starting ? "กำลังเริ่มติดตาม…" : "Start Versioning This Document"}
|
||||
</button>
|
||||
<SaveControls
|
||||
disabled={starting}
|
||||
busy={saveBusy}
|
||||
status={saveStatus}
|
||||
error={saveError}
|
||||
autoSaveError={autoSaveError}
|
||||
closeSupported={closeSupported}
|
||||
onSave={() => void handleSaveOnly()}
|
||||
onSaveAndClose={() => void handleSaveAndClose()}
|
||||
/>
|
||||
{starting && progressMessage ? <Spinner label={progressMessage} /> : null}
|
||||
{flowError ? <MessageBar intent="error">{flowError}</MessageBar> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{phase === "tracking" ? (
|
||||
/* ห้ามใส่ items-start — ลูกทุกตัวจะหดตามเนื้อหา (MessageBar/history list ไม่เต็มความกว้าง pane) */
|
||||
<div className="flex flex-col gap-2 pt-4">
|
||||
{/* กลุ่มข้อความสถานะ */}
|
||||
{hasUncommitted ? (
|
||||
<MessageBar intent="warning">
|
||||
มีการแก้ไขที่ยังไม่ถูกบันทึกเป็นเวอร์ชันใหม่ — กด Save & Version เพื่อบันทึกเป็น version ใหม่
|
||||
</MessageBar>
|
||||
) : null}
|
||||
|
||||
{flowWarning ? <MessageBar intent="warning">{flowWarning}</MessageBar> : null}
|
||||
{flowSuccess ? <MessageBar intent="success">{flowSuccess}</MessageBar> : null}
|
||||
{flowError ? <MessageBar intent="error">{flowError}</MessageBar> : null}
|
||||
|
||||
{/* กลุ่ม action บันทึก — ปุ่มหลักเต็มความกว้างแถบ (action เด่นที่สุดของทั้งแอป) + progress + local save
|
||||
icon ใช้ commit ไม่ใช่ floppy — แยกจากปุ่ม "บันทึก" (local save) ที่ความหมายต่างกัน */}
|
||||
<div className="flex flex-col gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN_PRIMARY} w-full justify-center px-5 py-3`}
|
||||
disabled={saving}
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
>
|
||||
<GitCommitHorizontal className="size-4" aria-hidden />
|
||||
Save & Version
|
||||
</button>
|
||||
{saving && progressMessage ? <Spinner label={progressMessage} /> : null}
|
||||
<SaveControls
|
||||
disabled={saving}
|
||||
busy={saveBusy}
|
||||
status={saveStatus}
|
||||
error={saveError}
|
||||
autoSaveError={autoSaveError}
|
||||
closeSupported={closeSupported}
|
||||
onSave={() => void handleSaveOnly()}
|
||||
onSaveAndClose={() => void handleSaveAndClose()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ink-soft (ไม่ใช่ ink-faint 2.6:1) — tracking id คือ identifier ที่ผู้ใช้อาจต้องอ่าน/คัดลอกตอนแจ้งปัญหา */}
|
||||
<p className="pt-2 text-xs text-ink-soft">tracking id: {documentId?.slice(0, 8)}…</p>
|
||||
|
||||
<VersionHistoryPanel
|
||||
versions={versions}
|
||||
loading={historyLoading}
|
||||
error={historyError}
|
||||
downloadingVersionId={downloadingVersionId}
|
||||
downloadingPdfVersionId={downloadingPdfVersionId}
|
||||
onDownloadPdf={(version) => void handleDownload(version, "pdf")}
|
||||
onDownload={(version) => void handleDownload(version)}
|
||||
onRetry={() => documentId && void refreshHistory(documentId)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* กดยืนยันแล้วปิด dialog ทันที — progress/ผลลัพธ์แสดงที่ pane (dialog ค้าง = บังผลลัพธ์
|
||||
+ เสี่ยงกดยืนยันซ้ำสร้าง version ซ้ำ; busy ใน dialog เหลือเป็น defense ชั่วขณะก่อน re-render) */}
|
||||
<ConfirmVersionDialog
|
||||
open={confirmOpen}
|
||||
busy={saving}
|
||||
onConfirm={(message) => {
|
||||
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 */}
|
||||
<ConfirmVersionDialog
|
||||
open={startConfirmOpen}
|
||||
busy={starting}
|
||||
title="เริ่มติดตามเอกสารนี้?"
|
||||
description={
|
||||
documentTemplateId
|
||||
? "ระบบจะ stamp metadata ลงไฟล์ บันทึกไฟล์ปัจจุบันเป็น version 1 และผูกเอกสารกับ template ต้นทาง (ไฟล์ร่าง)"
|
||||
: "ระบบจะ stamp metadata ลงไฟล์และบันทึกไฟล์ปัจจุบันเป็น version 1 ของเอกสารนี้"
|
||||
}
|
||||
confirmLabel="เริ่มติดตาม (version 1)"
|
||||
onConfirm={(message) => {
|
||||
setStartConfirmOpen(false);
|
||||
void handleStartVersioning(message);
|
||||
}}
|
||||
onDismiss={() => setStartConfirmOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskpaneApp;
|
||||
Loading…
Add table
Add a link
Reference in a new issue