feat(wordaddin): add template tools tab
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 1m12s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 14s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 0s

แท็บที่สองของ taskpane "เครื่องมือ Template" (TemplateToolsPanel +
templateService — Office.js ล้วน ไม่ยิง backend ใช้ได้ทั้ง phase
untracked/tracking) สำหรับคนออกแบบ template:

- สแกน Content Controls ทั้งเอกสาร + เตือน tag ว่าง/ซ้ำ + ปุ่มดูช่อง
  (select เลื่อน Word ไปให้เอง)
- กรอกค่าทดสอบเขียนกลับเอกสารจริงเพื่อดูผลจัดหน้า — ช่องว่างไม่ถูกแตะ,
  เขียนแยก Word.run ราย tag ให้ fail เฉพาะตัวพร้อมสาเหตุจาก debugInfo
- แทรกย่อหน้า/ตัวแบ่งบรรทัด/ตัวแบ่งหน้าที่ cursor — page break มี
  fallback OOXML block-level กัน E_FAIL ในบางบริบท (เช่น cursor ในตาราง)
- มองเห็น CC ทุกชนิดผ่าน feature-detect WordApi 1.5
  (Document.getContentControls) — Office 2019 เห็นเฉพาะ rich text พร้อม
  warning แยก state ไม่โดน banner อื่นล้าง, ห้ามยก MinVersion ของ manifest

จาก code review 2026-10-20:

- ปุ่ม toggle แท็บปิดระหว่าง flow ที่เขียนเอกสารครบทุกตัว
  (saving/starting/saveBusy/templateBusy — panel รายงาน busy ผ่าน
  onBusyChange พร้อม cleanup ตอน unmount) กัน flow versioning มา
  stamp/capture ซ้อน operation ของแท็บ template ที่ยังรันอยู่
- banner auto-save error ยกจาก SaveControls มาไว้เหนือ toggle
  ให้เห็นทั้งสองแท็บ
- สแกน merge หลังเขียนค่าตัด tag ที่หายไปจากเอกสารทิ้ง (ไม่ค้าง
  ไปโดน missingTags รอบถัดไป)
- รวม describeWordError เป็น getDetailedErrorMessage ที่ errors.ts

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-10-20 09:49:17 +07:00
parent a172b111a3
commit 6daee15851
7 changed files with 823 additions and 47 deletions

View file

@ -19,6 +19,7 @@ import ConfirmVersionDialog from "./ConfirmVersionDialog";
import { MessageBar } from "./MessageBar";
import SaveControls from "./SaveControls";
import { Spinner } from "./Spinner";
import TemplateToolsPanel from "./TemplateToolsPanel";
import VersionHistoryPanel from "./VersionHistoryPanel";
/**
@ -39,6 +40,18 @@ import VersionHistoryPanel from "./VersionHistoryPanel";
type Phase = "checking" | "signed-out" | "untracked" | "tracking";
/** แท็บของ taskpane — versioning คือทุกอย่างเดิม, template คือเครื่องมือจัดการ Content Controls
* (Office.js ล้วน ไม่ยิง backend จึงใช้ได้ทั้ง untracked และ tracking) */
type Tab = "versioning" | "template";
/** ปุ่ม toggle แท็บ — กลุ่มปุ่ม + aria-pressed ตามแบบแผน FacetFilterPanel (ไม่ใช่ role="tablist"
* เพราะไม่ทำ arrow-key navigation ของ tablist จริง — ทำครึ่ง ๆ ผิด spec กว่าไม่ทำ)
* active เป็น tint น้ำเงินอ่อน ไม่ใช่พื้นทึบ เพื่อไม่แย่งน้ำหนักกับ CTA primary ตัวจริง (Save & Version) */
const tabButtonClass = (active: boolean) =>
`focus-ring flex-1 rounded-md px-2 py-1.5 text-sm transition disabled:pointer-events-none disabled:opacity-50 ${
active ? "bg-primary/[0.08] font-medium text-primary" : "text-ink-soft hover:text-primary"
}`;
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";
@ -80,6 +93,11 @@ function demoVersions(now: number): VersionRecord[] {
const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) => {
const [phase, setPhase] = useState<Phase>(demo === "untracked" ? "untracked" : demo ? "tracking" : "checking");
const [activeTab, setActiveTab] = useState<Tab>("versioning");
/** operation ของแท็บ template กำลังเขียนเอกสารอยู่ (panel รายงานผ่าน onBusyChange) —
* ปิด toggle กันสลับแท็บกลาง operation แล้ว flow versioning มา stamp/capture ซ้อน */
const [templateBusy, setTemplateBusy] = useState(false);
const handleTemplateBusyChange = useCallback((busy: boolean) => setTemplateBusy(busy), []);
const [bootError, setBootError] = useState<string | null>(null);
const [username, setUsername] = useState<string | null>(demo ? (authService.getUsername() ?? null) : null);
@ -121,6 +139,7 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
const handleAuthRequired = useCallback((error: unknown): boolean => {
if (error instanceof AuthRequiredError) {
setPhase("signed-out");
setActiveTab("versioning");
setDocumentId(null);
setDocumentTemplateId(null);
setVersions([]);
@ -241,6 +260,7 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
setVersions([]);
setLoginError(null);
setPhase("signed-out");
setActiveTab("versioning");
};
/** FR-18 [บันทึก] — local save เดี่ยวผ่าน lock ของ autoSaveService (ไม่ stamp metadata อะไร) */
@ -403,6 +423,42 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
) : null}
</div>
{/* FR-17 — banner auto-save error ยกไว้เหนือ toggle (เดิมซ่อนอยู่ใน SaveControls ของแท็บ
versioning): auto-save รันทุกแท็บ การเขียนค่าทดสอบของแท็บ template ก็ทำให้ dirty ได้
จึงต้องเห็น error แม้อยู่อีกแท็บ */}
{(phase === "untracked" || phase === "tracking") && autoSaveError ? (
<MessageBar intent="warning">{autoSaveError}</MessageBar>
) : null}
{/* toggle แท็บ — แสดงเฉพาะ phase ที่เข้าถึงเอกสารแล้ว (checking/signed-out ยังไม่มีอะไรให้จัดการ)
ปิดทั้งสองปุ่มระหว่าง flow ที่เขียนเอกสาร: saving/starting ของ versioning (กัน progress หาย
กลาง flow — handler อยู่ที่ component นี้ flow ยังรันจนจบ แค่หน้าตา), saveBusy ของบันทึก
local/ปิดเอกสาร และ templateBusy ที่ panel รายงานผ่าน onBusyChange — กันเครื่องมือ template
แข่งแก้เอกสารกลาง stamp/capture ของ versionFlow หรือกลับกัน (panel unmount แล้ว promise
ของ service ก็ยังเขียนเอกสารต่อ ต้องปิดทางสลับตั้งแต่ต้น) */}
{(phase === "untracked" || phase === "tracking") && (
<div className="mt-2 flex gap-1 rounded-lg border border-line p-1" role="group" aria-label="ส่วนการทำงาน">
<button
type="button"
aria-pressed={activeTab === "versioning"}
className={tabButtonClass(activeTab === "versioning")}
disabled={saving || starting || saveBusy || templateBusy}
onClick={() => setActiveTab("versioning")}
>
Versioning
</button>
<button
type="button"
aria-pressed={activeTab === "template"}
className={tabButtonClass(activeTab === "template")}
disabled={saving || starting || saveBusy || templateBusy}
onClick={() => setActiveTab("template")}
>
เครื่องมือ Template
</button>
</div>
)}
{phase === "checking" ? (
<div className="flex flex-col items-center gap-4 pt-10">
<Spinner label="กำลังตรวจสอบเอกสาร…" />
@ -443,7 +499,7 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
</div>
) : null}
{phase === "untracked" ? (
{phase === "untracked" && activeTab === "versioning" ? (
<div className="flex flex-col gap-2 pt-4">
<p className="text-base text-ink">เอกสารนี้ยังไม่ได้ติดตาม version</p>
<p className="text-sm text-ink-soft">
@ -470,7 +526,6 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
busy={saveBusy}
status={saveStatus}
error={saveError}
autoSaveError={autoSaveError}
closeSupported={closeSupported}
onSave={() => void handleSaveOnly()}
onSaveAndClose={() => void handleSaveAndClose()}
@ -480,7 +535,7 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
</div>
) : null}
{phase === "tracking" ? (
{phase === "tracking" && activeTab === "versioning" ? (
/* ห้ามใส่ items-start — ลูกทุกตัวจะหดตามเนื้อหา (MessageBar/history list ไม่เต็มความกว้าง pane) */
<div className="flex flex-col gap-2 pt-4">
{/* กลุ่มข้อความสถานะ */}
@ -512,7 +567,6 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
busy={saveBusy}
status={saveStatus}
error={saveError}
autoSaveError={autoSaveError}
closeSupported={closeSupported}
onSave={() => void handleSaveOnly()}
onSaveAndClose={() => void handleSaveAndClose()}
@ -535,43 +589,56 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
</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)}
/>
{/* แท็บเครื่องมือ Template — state ทั้งหมดอยู่ใน panel เอง (snapshot ชั่วคราวของเอกสาร
สลับแท็บแล้ว unmount = ทิ้งค่าทิ้งโดยตั้งใจ) */}
{(phase === "untracked" || phase === "tracking") && activeTab === "template" ? (
<TemplateToolsPanel onBusyChange={handleTemplateBusyChange} />
) : null}
{/* 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)}
/>
{/* dialog ทั้งสองเป็นของ flow versioning — render เฉพาะแท็บ versioning (ตอน dialog เปิด
overlay fixed inset-0 + Tab trap อยู่แล้ว ปุ่ม toggle แตะไม่ได้ จึงไม่มี path ที่ dialog
เปิดค้างขณะสลับแท็บ) */}
{(phase === "untracked" || phase === "tracking") && activeTab === "versioning" ? (
<>
{/* กดยืนยันแล้วปิด 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)}
/>
</>
) : null}
</div>
);
};