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
166
components/wordaddin/ConfirmVersionDialog.tsx
Normal file
166
components/wordaddin/ConfirmVersionDialog.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { Spinner } from "./Spinner";
|
||||
|
||||
/**
|
||||
* FR-2 ขั้นตอนที่ 1 — dialog ยืนยัน "สร้าง version ใหม่ของเอกสารนี้หรือไม่?" พร้อมชื่อ commit
|
||||
* message ทางเลือก (FR-13) — ใช้ร่วมกับทั้ง Save & Version และ Start Versioning
|
||||
* FR-18 — มี onConfirmClose = มีปุ่มที่สอง [บันทึกเวอร์ชันและปิดเอกสาร] เป็น primary ตัวเดียว
|
||||
* (ปุ่มยืนยันหลักกลายเป็น secondary) ส่งเฉพาะ platform ที่ปิดเอกสารได้
|
||||
* พอร์ตจาก ConfirmVersionDialog.tsx ต้นฉบับ — แทน Dialog ของ Fluent ด้วย portal + Genesis
|
||||
* (pattern overlay เดียวกับ AnnotationSidebar)
|
||||
*/
|
||||
|
||||
const DEFAULT_TITLE = "สร้าง version ใหม่?";
|
||||
const DEFAULT_DESCRIPTION = "ระบบจะบันทึกไฟล์ปัจจุบันและอัปโหลดเป็น version ถัดไปของเอกสารนี้";
|
||||
const DEFAULT_CONFIRM_LABEL = "สร้าง version";
|
||||
|
||||
const BTN_SECONDARY =
|
||||
"focus-ring rounded-md border border-line bg-white px-3 py-1.5 text-sm text-ink transition hover:border-primary hover:text-primary disabled:pointer-events-none disabled:opacity-50";
|
||||
const BTN_PRIMARY =
|
||||
"focus-ring rounded-md bg-primary px-3 py-1.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";
|
||||
|
||||
export function ConfirmVersionDialog({
|
||||
open,
|
||||
busy,
|
||||
title,
|
||||
description,
|
||||
confirmLabel,
|
||||
onConfirm,
|
||||
onConfirmClose,
|
||||
onDismiss,
|
||||
}: {
|
||||
open: boolean;
|
||||
/** กำลังอยู่ระหว่าง flow หลังกดยืนยันแล้ว — ปุ่มทั้งหมด disable + แสดง spinner + ปิดไม่ได้ */
|
||||
busy: boolean;
|
||||
/** ปรับข้อความตอน reuse กับ flow อื่น (FR-13 — Start Versioning ใช้ dialog ตัวเดียวกัน) */
|
||||
title?: string;
|
||||
description?: ReactNode;
|
||||
confirmLabel?: string;
|
||||
/** ได้รับ commit message ที่ผู้ใช้พิมพ์ (FR-13) — ว่างได้ */
|
||||
onConfirm: (message: string) => void;
|
||||
/** FR-18 — submit ที่สอง "บันทึกเวอร์ชันและปิดเอกสาร" */
|
||||
onConfirmClose?: (message: string) => void;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const [message, setMessage] = useState("");
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// component ถูก mount ค้างไว้ตลอด (TaskpaneApp render ไว้นอก phase branch) — ต้องเคลียร์ค่าตอนปิด
|
||||
// ไม่ใช่ตอน unmount ไม่งั้นข้อความรอบก่อนหลุดมารอบถัดไป
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setMessage("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// ESC = ยกเลิก (เฉพาะไม่กำลัง busy — ตอน busy ห้ามปิดได้ทุกทางตาม spec)
|
||||
// Tab กักไว้ใน dialog — สิ่งที่ Fluent Dialog เคยทำให้อัตโนมัติ (WCAG 2.1.2/2.4.3)
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && !busy) onDismiss();
|
||||
if (event.key === "Tab") {
|
||||
const focusables = dialogRef.current?.querySelectorAll<HTMLElement>(
|
||||
"button:not([disabled]), textarea:not([disabled]), [href], input, select, [tabindex]:not([tabindex='-1'])",
|
||||
);
|
||||
const first = focusables?.item(0);
|
||||
const last = focusables?.item((focusables?.length ?? 0) - 1);
|
||||
if (!first || !last) return;
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, busy, onDismiss]);
|
||||
|
||||
// โฟกัสเข้า dialog ตอนเปิด + คืน focus ให้ trigger เดิมตอนปิด (อีกส่วนที่ Fluent เคยทำให้)
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const previous = document.activeElement as HTMLElement | null;
|
||||
dialogRef.current?.focus();
|
||||
return () => {
|
||||
previous?.focus();
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4"
|
||||
onClick={() => !busy && onDismiss()}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title ?? DEFAULT_TITLE}
|
||||
tabIndex={-1}
|
||||
className="w-full max-w-sm rounded-xl border border-line bg-white p-4 shadow-lg focus:outline-none"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<h2 className="font-display text-base font-semibold text-ink">{title ?? DEFAULT_TITLE}</h2>
|
||||
<p className="mt-1 text-sm text-ink-soft">{description ?? DEFAULT_DESCRIPTION}</p>
|
||||
<label className="mt-3 block text-sm font-medium text-ink" htmlFor="wordaddin-version-message">
|
||||
คำอธิบาย version (ไม่บังคับ)
|
||||
</label>
|
||||
<textarea
|
||||
id="wordaddin-version-message"
|
||||
className="genesis-input mt-1 block w-full resize-y"
|
||||
value={message}
|
||||
onChange={(event) => setMessage(event.target.value)}
|
||||
placeholder="เช่น แก้ไขข้อ 3 สัญญาจ้าง"
|
||||
maxLength={2048}
|
||||
rows={3}
|
||||
disabled={busy}
|
||||
aria-label="คำอธิบาย version"
|
||||
/>
|
||||
{busy ? (
|
||||
<div className="mt-3">
|
||||
<Spinner label="กำลังดำเนินการ…" />
|
||||
</div>
|
||||
) : null}
|
||||
{/* จอแคบ: 3 ปุ่มในบรรทัดเดียว wrap ไม่มีแบบแผน (ปุ่ม primary ตกบรรทัดเดี่ยวเป็นเศษ)
|
||||
→ จัดเป็น 2 แถวโดยตั้งใจ — คู่ ยกเลิก/ยืนยัน บน + ปุ่ม primary เต็มความกว้างล่าง */}
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" className={BTN_SECONDARY} disabled={busy} onClick={onDismiss}>
|
||||
ยกเลิก
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={onConfirmClose ? BTN_SECONDARY : BTN_PRIMARY}
|
||||
disabled={busy}
|
||||
onClick={() => onConfirm(message)}
|
||||
>
|
||||
{confirmLabel ?? DEFAULT_CONFIRM_LABEL}
|
||||
</button>
|
||||
</div>
|
||||
{onConfirmClose ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN_PRIMARY} w-full`}
|
||||
disabled={busy}
|
||||
onClick={() => onConfirmClose(message)}
|
||||
>
|
||||
บันทึกเวอร์ชันและปิดเอกสาร
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfirmVersionDialog;
|
||||
45
components/wordaddin/MessageBar.tsx
Normal file
45
components/wordaddin/MessageBar.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { CircleAlert, CircleCheck, TriangleAlert } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Genesis MessageBar ของ wordaddin — แทน MessageBar ของ Fluent UI v9 ต้นฉบับ
|
||||
* intent error = ข้อความ/ไอคอนใช้เฉด -ink (contrast ≥5:1 ตาม DESIGN.md), พื้น tint จาง
|
||||
* มี icon นำตามระดับ — แยกด้วยรูปทรง ไม่ใช่สีอย่างเดียว (WCAG 1.4.1) + role ให้ screen
|
||||
* reader ประกาศ (error = alert, ที่เหลือ = status polite — WCAG 4.1.3) + w-full ให้ทุก
|
||||
* แถบกว้างเท่ากันทั้งคอลัมน์
|
||||
* prop action = ปุ่มเสริมในแถบ (เช่น "ลองใหม่" ของ bootError/history error)
|
||||
*/
|
||||
|
||||
const ICONS = {
|
||||
error: CircleAlert,
|
||||
warning: TriangleAlert,
|
||||
success: CircleCheck,
|
||||
} as const;
|
||||
|
||||
export function MessageBar({
|
||||
intent,
|
||||
children,
|
||||
action,
|
||||
}: {
|
||||
intent: "error" | "warning" | "success";
|
||||
children: ReactNode;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
const Icon = ICONS[intent];
|
||||
const tone =
|
||||
intent === "error"
|
||||
? "border-error/30 bg-error/10 text-error-ink"
|
||||
: intent === "warning"
|
||||
? "border-warning/30 bg-warning/15 text-warning-ink"
|
||||
: "border-success/30 bg-success/10 text-success-ink";
|
||||
return (
|
||||
<div
|
||||
role={intent === "error" ? "alert" : "status"}
|
||||
className={`flex w-full items-start gap-2 rounded-lg border px-3 py-2 text-sm ${tone}`}
|
||||
>
|
||||
<Icon className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||||
<div className="min-w-0 flex-1 break-words">{children}</div>
|
||||
{action ? <div className="shrink-0">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
components/wordaddin/SaveControls.tsx
Normal file
68
components/wordaddin/SaveControls.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { CircleCheck, Save, X } from "lucide-react";
|
||||
|
||||
import { MessageBar } from "./MessageBar";
|
||||
import { Spinner } from "./Spinner";
|
||||
|
||||
/**
|
||||
* FR-17/FR-18 — ปุ่ม [บันทึก] / [บันทึกและปิดเอกสาร] + สถานะการบันทึก (ใช้ทุก phase ตาม spec v10)
|
||||
* banner autoSaveError อยู่ที่นี่เพื่อให้เห็นทุก phase ไม่เฉพาะตอน tracking
|
||||
* พอร์ตจาก SaveControls ใน App.tsx ต้นฉบับ (D:\word-addin-poc\addin) — logic เดิม เปลี่ยนแค่ชั้นภาพ
|
||||
*/
|
||||
export function SaveControls({
|
||||
disabled,
|
||||
busy,
|
||||
status,
|
||||
error,
|
||||
autoSaveError,
|
||||
closeSupported,
|
||||
onSave,
|
||||
onSaveAndClose,
|
||||
}: {
|
||||
/** กำลังอยู่ใน flow อื่น (versioning) — ปิดปุ่มกันกดซ้อน */
|
||||
disabled: boolean;
|
||||
busy: boolean;
|
||||
status: "saving" | "saved" | null;
|
||||
error: string | null;
|
||||
autoSaveError: string | null;
|
||||
closeSupported: boolean;
|
||||
onSave: () => void;
|
||||
onSaveAndClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="focus-ring inline-flex items-center gap-1.5 rounded-md border border-line bg-white px-3 py-1.5 text-sm text-ink transition hover:border-primary hover:text-primary disabled:pointer-events-none disabled:opacity-50"
|
||||
disabled={disabled || busy}
|
||||
onClick={onSave}
|
||||
>
|
||||
<Save className="size-4" aria-hidden />
|
||||
บันทึก
|
||||
</button>
|
||||
{closeSupported ? (
|
||||
<button
|
||||
type="button"
|
||||
className="focus-ring inline-flex items-center gap-1.5 rounded-md border border-line bg-white px-3 py-1.5 text-sm text-ink transition hover:border-primary hover:text-primary disabled:pointer-events-none disabled:opacity-50"
|
||||
disabled={disabled || busy}
|
||||
onClick={onSaveAndClose}
|
||||
>
|
||||
<X className="size-4" aria-hidden />
|
||||
บันทึกและปิดเอกสาร
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{busy && status === "saving" ? <Spinner label="กำลังบันทึก…" /> : null}
|
||||
{!busy && status === "saved" ? (
|
||||
<p className="inline-flex items-center gap-1.5 text-sm text-ink-soft">
|
||||
<CircleCheck className="size-4 text-success-ink" aria-hidden />
|
||||
บันทึกแล้ว
|
||||
</p>
|
||||
) : null}
|
||||
{error ? <MessageBar intent="error">{error}</MessageBar> : null}
|
||||
{autoSaveError ? <MessageBar intent="warning">{autoSaveError}</MessageBar> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SaveControls;
|
||||
13
components/wordaddin/Spinner.tsx
Normal file
13
components/wordaddin/Spinner.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { LoaderCircle } from "lucide-react";
|
||||
|
||||
/** Genesis Spinner — แทน Spinner ของ Fluent UI v9 ต้นฉบับ (ใช้ไอคอนหมุนของ lucide)
|
||||
* role="status" ให้ progressMessage ที่เปลี่ยนตลอด flow ถูกประกาศแบบ polite (WCAG 4.1.3) */
|
||||
export function Spinner({ label, className = "" }: { label?: string; className?: string }) {
|
||||
return (
|
||||
<span role="status" className={`inline-flex items-center gap-2 text-sm text-ink-soft ${className}`}>
|
||||
<LoaderCircle className="size-4 animate-spin" aria-hidden />
|
||||
{label ? <span>{label}</span> : null}
|
||||
{!label ? <span className="sr-only">กำลังโหลด…</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
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;
|
||||
149
components/wordaddin/VersionHistoryPanel.tsx
Normal file
149
components/wordaddin/VersionHistoryPanel.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { Download, FileText, LoaderCircle } from "lucide-react";
|
||||
|
||||
import { VersionRecord } from "@/wordaddin/models/versionRecord";
|
||||
|
||||
import { MessageBar } from "./MessageBar";
|
||||
import { Spinner } from "./Spinner";
|
||||
|
||||
/**
|
||||
* FR-4 — แสดง version history (เลข version / เวลา / ผู้สร้าง) + ปุ่มดาวน์โหลด version นั้น
|
||||
* ชั้นภาพ: timeline แนวตั้ง — จุดโหนดเท่าทึบคือเวอร์ชันล่าสุด จุดกลวงคือเก่ากว่า
|
||||
* (เลข version เป็น static → ใช้ ink ตามกฎ "blue เฉพาะ interactive" ของ DESIGN.md)
|
||||
*/
|
||||
|
||||
function formatTimestamp(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
return Number.isNaN(date.getTime())
|
||||
? iso
|
||||
: date.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
|
||||
}
|
||||
|
||||
/** px-2 py-1.5 + text-xs ≈ สูง 30px — พ้นเกณฑ์ touch target 24px ของ WCAG 2.5.8 โดยยัง dense พอสำหรับ list */
|
||||
const BTN_ACTION =
|
||||
"focus-ring inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-xs text-ink-soft transition hover:bg-primary/[0.06] hover:text-primary disabled:pointer-events-none disabled:opacity-50";
|
||||
|
||||
export function VersionHistoryPanel({
|
||||
versions,
|
||||
loading,
|
||||
error,
|
||||
downloadingVersionId,
|
||||
downloadingPdfVersionId,
|
||||
onDownloadPdf,
|
||||
onDownload,
|
||||
onRetry,
|
||||
}: {
|
||||
versions: VersionRecord[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
/** versionId ของ version ที่กำลังดาวน์โหลดอยู่ (แสดง spinner ที่ปุ่ม) */
|
||||
downloadingVersionId: string | null;
|
||||
downloadingPdfVersionId: string | null;
|
||||
onDownloadPdf: (version: VersionRecord) => void;
|
||||
onDownload: (version: VersionRecord) => void;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const anyDownloading = downloadingVersionId !== null || downloadingPdfVersionId !== null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="mt-4 mb-2 flex items-baseline justify-between">
|
||||
<h3 className="font-display text-sm font-semibold text-ink">Version history</h3>
|
||||
{versions.length > 0 ? <span className="text-xs text-ink-soft">{versions.length} เวอร์ชัน</span> : null}
|
||||
</div>
|
||||
|
||||
{loading ? <Spinner label="กำลังโหลด…" /> : null}
|
||||
|
||||
{error ? (
|
||||
<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={onRetry}
|
||||
>
|
||||
ลองใหม่
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{error}
|
||||
</MessageBar>
|
||||
) : null}
|
||||
|
||||
{!loading && !error && versions.length === 0 ? (
|
||||
<p className="text-sm text-ink-soft">ยังไม่มี version ในระบบ</p>
|
||||
) : null}
|
||||
|
||||
{versions.length > 0 ? (
|
||||
<ol className="relative">
|
||||
{/* ราง timeline — วาดครั้งเดียวระดับรายการ (เวอร์ชันเดียวไม่ต้องมีเส้น) */}
|
||||
{versions.length > 1 ? (
|
||||
<span className="absolute bottom-2 left-[3.5px] top-2 w-px bg-line" aria-hidden />
|
||||
) : null}
|
||||
{versions.map((version, index) => {
|
||||
const isLatest = index === 0;
|
||||
return (
|
||||
<li className="relative pb-3 pl-4 last:pb-0" key={version.versionId}>
|
||||
<span
|
||||
className={`absolute left-0 top-[5px] size-2 rounded-full ${
|
||||
isLatest ? "bg-ink" : "border border-ink-faint bg-canvas"
|
||||
}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="font-display text-sm font-semibold text-ink">v{version.versionNumber}</span>
|
||||
{isLatest ? (
|
||||
<span className="rounded-full bg-ink/[0.06] px-1.5 py-px text-[11px] text-ink-soft">ล่าสุด</span>
|
||||
) : null}
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className={BTN_ACTION}
|
||||
disabled={anyDownloading}
|
||||
aria-busy={downloadingVersionId === version.versionId}
|
||||
onClick={() => onDownload(version)}
|
||||
>
|
||||
{/* คง label เดิม + สลับเป็น spinner — label ยาว "กำลังดาวน์โหลด…" ทำแถว wrap กระโดด */}
|
||||
{downloadingVersionId === version.versionId ? (
|
||||
<LoaderCircle className="size-3.5 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<Download className="size-3.5" aria-hidden />
|
||||
)}
|
||||
ดาวน์โหลด
|
||||
</button>
|
||||
{version.hasPdf ? (
|
||||
<button
|
||||
type="button"
|
||||
className={BTN_ACTION}
|
||||
disabled={anyDownloading}
|
||||
aria-busy={downloadingPdfVersionId === version.versionId}
|
||||
onClick={() => onDownloadPdf(version)}
|
||||
>
|
||||
{downloadingPdfVersionId === version.versionId ? (
|
||||
<LoaderCircle className="size-3.5 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<FileText className="size-3.5" aria-hidden />
|
||||
)}
|
||||
PDF
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
{version.message ? (
|
||||
<p className="mt-0.5 whitespace-pre-wrap [overflow-wrap:anywhere] text-xs text-ink">
|
||||
{version.message}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-0.5 text-xs text-ink-soft">
|
||||
{formatTimestamp(version.createdAt)} · โดย {version.createdByUserId}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VersionHistoryPanel;
|
||||
131
components/wordaddin/WordAddinGate.tsx
Normal file
131
components/wordaddin/WordAddinGate.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { withBasePath } from "@/utils/base-path";
|
||||
import { signInForDemo } from "@/wordaddin/services/authService";
|
||||
|
||||
import { MessageBar } from "./MessageBar";
|
||||
import { Spinner } from "./Spinner";
|
||||
import TaskpaneApp from "./TaskpaneApp";
|
||||
|
||||
/**
|
||||
* Gate ของ taskpane ใน App Router — ทำหน้าที่แทน Office.onReady ก่อน render ของ webpack
|
||||
* ต้นฉบับ: Office.js โหลดใน <head> ของ taskpane.html แล้ว Office.onReady ก่อน createRoot
|
||||
* (บริบท Next: ฉีด <script> self-host หลัง hydration แทน — ไฟล์ services ทั้งหมดไม่สัมผัส
|
||||
* Office ที่ module top-level จึงปลอดภัย)
|
||||
*
|
||||
* โหมด ?addindemo=untracked|tracking (เฉพาะ dev) — ข้าม office.js ทั้งหมด + sign in mock
|
||||
* ทันที เพื่อดู UI ทุก phase ใน browser ธรรมดา (mock versioningService ทำงานได้เอง
|
||||
* แต่ flow ที่แตะ Word จริงจะจบที่ error ตาม expected — ดู docs/WORDADDIN-DEV.md)
|
||||
*/
|
||||
|
||||
/** office.js self-host จาก postinstall scripts/fetch-office-js.mjs (intranet ไม่ใช้ CDN) */
|
||||
const OFFICE_JS_SRC = withBasePath("/wordaddin/office-js/office.js");
|
||||
/** Office.onReady callback ไม่ยิงภายใน 5 วิหลังฉีด script ก็ proceed (กัน webview แปลก ๆ — โหลดสำเร็จแต่ onReady เงียบ) */
|
||||
const ONREADY_FALLBACK_MS = 5_000;
|
||||
|
||||
/**
|
||||
* โหลด office.js ครั้งเดียวต่อ page เป็น singleton promise ระดับ module —
|
||||
* ห้ามฉีด <script> ซ้ำ: React StrictMode รัน effect สองรอบใน dev และ guard
|
||||
* `typeof Office !== "undefined"` ไม่ครอบช่วงที่ตัวแรกยังโหลดไม่เสร็จ
|
||||
* (โหลดซ้ำ = evaluate ซ้ำ = "Cannot redefine property: context" เพราะ office.js
|
||||
* define `Microsoft.Office.WebExtension.context` แบบ configurable:false)
|
||||
* error → คืน promise ใหม่ครั้งหน้า (ลบ script เก่าทิ้งให้ inject ใหม่ได้)
|
||||
*/
|
||||
let officeReadyPromise: Promise<boolean> | null = null;
|
||||
|
||||
function ensureOfficeJs(): Promise<boolean> {
|
||||
if (!officeReadyPromise) {
|
||||
officeReadyPromise = new Promise<boolean>((resolve) => {
|
||||
if (typeof Office !== "undefined") {
|
||||
void Office.onReady(() => resolve(true));
|
||||
return;
|
||||
}
|
||||
const script = document.createElement("script");
|
||||
const fallbackTimer = setTimeout(() => resolve(true), ONREADY_FALLBACK_MS);
|
||||
const onReady = () => {
|
||||
// เคลียร์ timer เฉพาะเมื่อ callback ของ Office.onReady ยิงจริง — ถ้าเคลียร์ตั้งแต่ onload
|
||||
// แล้ว onReady เงียบ fallback จะตายไปก่อนทำหน้าที่ (ค้าง loading ตลอดไป)
|
||||
void Office.onReady(() => {
|
||||
clearTimeout(fallbackTimer);
|
||||
resolve(true);
|
||||
});
|
||||
};
|
||||
script.onload = onReady;
|
||||
script.onerror = () => {
|
||||
clearTimeout(fallbackTimer);
|
||||
officeReadyPromise = null;
|
||||
script.remove();
|
||||
resolve(false);
|
||||
};
|
||||
script.src = OFFICE_JS_SRC;
|
||||
script.async = false; // ให้ script นี้ได้ทำงานตามลำดับก่อนโค้ดที่แตะ Office
|
||||
script.id = "office-js";
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
return officeReadyPromise;
|
||||
}
|
||||
|
||||
type GateStatus = "loading" | "ready" | "error";
|
||||
|
||||
export function WordAddinGate() {
|
||||
const [status, setStatus] = useState<GateStatus>("loading");
|
||||
const [demo, setDemo] = useState<"untracked" | "tracking" | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const demoParam = new URLSearchParams(window.location.search).get("addindemo");
|
||||
if (process.env.NODE_ENV !== "production" && (demoParam === "untracked" || demoParam === "tracking")) {
|
||||
signInForDemo();
|
||||
setDemo(demoParam);
|
||||
setStatus("ready");
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
void ensureOfficeJs()
|
||||
.then((ok) => {
|
||||
if (!cancelled) setStatus(ok ? "ready" : "error");
|
||||
})
|
||||
.catch(() => {
|
||||
// Office.onReady throw sync ใน executor กลายเป็น rejection — ไป error screen แทนค้าง loading
|
||||
if (!cancelled) setStatus("error");
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Spinner label="กำลังเริ่มต้น…" className="text-ink-soft" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (status === "error") {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<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={() => window.location.reload()}
|
||||
>
|
||||
ลองใหม่
|
||||
</button>
|
||||
}
|
||||
>
|
||||
โหลด Office.js ไม่สำเร็จ — เช็คว่า dev server รันอยู่และไฟล์ public/wordaddin/office-js/office.js
|
||||
ถูกสร้างแล้ว (npm install รัน postinstall scripts/fetch-office-js.mjs)
|
||||
</MessageBar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <TaskpaneApp demo={demo ?? undefined} />;
|
||||
}
|
||||
|
||||
export default WordAddinGate;
|
||||
Loading…
Add table
Add a link
Reference in a new issue