พอร์ต 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>
166 lines
7.8 KiB
TypeScript
166 lines
7.8 KiB
TypeScript
"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;
|