Replace the token/owner pass-through with the ticket flow from
docs/requirement/addin-workflow.md (full plan: docs/PLAN-ADDIN-AUTH.md):
- utils/docver-ticket.ts: shared pure ticket parse + hex digest helpers,
client/server inject their own crypto (Web Crypto / node:crypto)
- lib/addin-ticket.ts + POST /api/documents/token second entry: body
{ticket_data, HmacSha256} verified server-side (HMAC timing-safe + exp,
secret ADDIN_TICKET_SECRET -> SECRET -> "super_secret"); x-api-key path
unchanged
- wordaddin: ticketService (Web Crypto HMAC, localStorage session
wordaddin-auth keyed by fid, same-origin token request), authService
rewrite (7-step flow, silent renew), apiClient 401 renew-retry once,
metadataService reads ticket_data/HmacSha256, no-access messages per
cause
- scripts/make-ticket-docx.mjs: dev tool to stamp tickets onto any .docx
for testing in real Word
- app/wordaddin/error.tsx: error boundary showing the real error + stack
in the pane (webviews have no devtools)
- tests: docver-ticket + addin-ticket unit suites (21 cases) + temporary
crash repro suite for the ongoing client-crash debug session
- docs sync: PLAN-DOCVER, WORDADDIN-DEV, addin-ui-flow, openapi (two
request bodies for /token), CLAUDE.md, .env.example
Co-Authored-By: Claude Code <noreply@anthropic.com>
616 lines
34 KiB
TypeScript
616 lines
34 KiB
TypeScript
/* global Office */
|
|
|
|
"use client";
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { FileUp, GitCommitHorizontal } 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 TemplateToolsPanel from "./TemplateToolsPanel";
|
|
import VersionHistoryPanel from "./VersionHistoryPanel";
|
|
|
|
/**
|
|
* Task pane หลัก — state machine:
|
|
* checking → (ไม่มี/ตรวจไม่ผ่าน ticket ในไฟล์) no-access
|
|
* → (ticket ผ่าน + ไม่ใช่ tracked document) untracked
|
|
* → (ticket ผ่าน + tracked document) tracking
|
|
* ไม่มี login ตั้งแต่ยกเลิก Keycloak (2026-10-27) — auth เข้าทางเดียวคือ ticket
|
|
* ของ e-Court: `detect()` อ่าน ticket_data + HmacSha256 จาก custom properties
|
|
* แล้วเรียก `authService.signInWithTicket` (ตรวจ HMAC → exp → session → ขอ token
|
|
* ตาม fid — PLAN-ADDIN-AUTH.md)
|
|
* พอร์ตจาก App.tsx ต้นฉบับ (D:\word-addin-poc\addin) — เปลี่ยน: import path,
|
|
* JSX → Genesis/Tailwind, ถอด FluentProvider/makeStyles, ถอดหน้า login,
|
|
* เพิ่ม prop demo (โหมด ?addindemo ของ WordAddinGate — ตั้ง phase ตรง ไม่ detect จริง)
|
|
*
|
|
* กติกา: ทุก async ต้อง catch — AuthRequiredError → หน้า no-access ให้ขอไฟล์ใหม่จาก e-Court
|
|
* error อื่นแสดง MessageBar ไม่ปล่อย unhandled rejection
|
|
*
|
|
* FR-17 — auto-save (local เท่านั้น) เริ่มตอน mount ไม่ผูกกับ phase; FR-3 reminder
|
|
* กลายเป็น flag `uncommittedSinceVersion` ที่ periodic tick ของ autoSaveService ตั้งค่า
|
|
*/
|
|
|
|
type Phase = "checking" | "no-access" | "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";
|
|
|
|
/** 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 [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);
|
|
|
|
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);
|
|
|
|
/** ข้อความบนหน้า no-access — ต่างกันตามสาเหตุ (ไม่มี token ในไฟล์ / token หมดอายุ) */
|
|
const [noAccessNotice, setNoAccessNotice] = 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);
|
|
|
|
/** AuthRequiredError (ticket หมดอายุ/token ขอไม่ได้ หลัง renew-retry ของ apiClient)
|
|
* → หน้า no-access — ticket ต่ออายุไม่ได้ ผู้ใช้ต้องขอไฟล์ใหม่จาก e-Court
|
|
* จึงรีเซ็ต state ทั้งชุดกันใช้ของเก่าต่อ */
|
|
const handleAuthRequired = useCallback((error: unknown): boolean => {
|
|
if (error instanceof AuthRequiredError) {
|
|
setPhase("no-access");
|
|
setActiveTab("versioning");
|
|
setDocumentId(null);
|
|
setDocumentTemplateId(null);
|
|
setVersions([]);
|
|
setNoAccessNotice("ticket ของ e-Court หมดอายุหรือไม่ถูกต้อง — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง");
|
|
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): ticket ในไฟล์ → SavedByAddIn → history
|
|
* FR-16 — อ่าน properties ครั้งเดียวทั้งชุดแล้ว derive tracked เอง เพราะไฟล์ร่าง (untracked)
|
|
* ต้องได้อ่าน TemplateId ไปใช้ตอน Start Versioning ด้วย (ข้ามทั้งหมดในโหมด demo)
|
|
* auth ทางเข้าเดียว: ticket_data + HmacSha256 ที่ e-Court stamp มา — signInWithTicket
|
|
* ตรวจ HMAC → exp → session/localStorage แล้วขอ token ตาม fid ให้ครบก่อนไหลต่อ
|
|
* (นอก Word เช่น browser ธรรมดา → อ่านไม่ได้ = ไป no-access) */
|
|
const detect = useCallback(async () => {
|
|
setPhase("checking");
|
|
setBootError(null);
|
|
try {
|
|
let properties: metadataService.TrackedDocumentProperties | null = null;
|
|
try {
|
|
properties = await metadataService.readTrackedProperties();
|
|
} catch {
|
|
properties = null;
|
|
}
|
|
if (!properties) {
|
|
setNoAccessNotice("อ่านข้อมูลเอกสารไม่สำเร็จ — ลองปิดแล้วเปิด task pane อีกครั้ง");
|
|
setPhase("no-access");
|
|
return;
|
|
}
|
|
const signIn = await authService.signInWithTicket({
|
|
ticketData: properties.ticketData,
|
|
ticketHmac: properties.ticketHmac,
|
|
});
|
|
if (!signIn.ok) {
|
|
setNoAccessNotice(signIn.message);
|
|
setPhase("no-access");
|
|
return;
|
|
}
|
|
setUsername(authService.getUsername() ?? null);
|
|
|
|
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 ไม่ผูกกับ phase (ข้ามใน 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]);
|
|
|
|
/** 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 !== "no-access" && phase !== "checking" && username ? (
|
|
/* ไม่มีปุ่มออกจากระบบ (ไม่มี login ให้ออก) — แสดงชื่อเจ้าของไฟล์จาก pass-through เฉย ๆ
|
|
จอแคบ: ตัดด้วย truncate + เต็ม ๆ ใน title ไม่กินพื้นที่แถบหัวจนตัวชื่อแอป wrap */
|
|
<span
|
|
className="shrink-0 max-w-40 truncate rounded-md bg-canvas px-2 py-1.5 text-xs text-ink-soft"
|
|
title={username}
|
|
>
|
|
{username}
|
|
</span>
|
|
) : 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/no-access ยังไม่มีอะไรให้จัดการ)
|
|
ปิดทั้งสองปุ่มระหว่าง 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="กำลังตรวจสอบเอกสาร…" />
|
|
{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 === "no-access" ? (
|
|
/* ไม่มี login ให้กด — บอกสาเหตุและจบที่นั้น (ขั้นตอนสำหรับเอกสารทั่วไปจะมีในอนาคต) */
|
|
<div className="flex flex-col gap-2 pt-4">
|
|
<p className="text-base text-ink">เอกสารนี้ยังใช้งาน add-in ไม่ได้</p>
|
|
<p className="text-sm text-ink-soft">
|
|
{noAccessNotice ?? "ไม่พบ ticket ของ e-Court ในไฟล์ — ขณะนี้ add-in ใช้ได้กับเอกสารที่ส่งมาจาก e-Court เท่านั้น"}
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
|
|
{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">
|
|
กดปุ่มด้านล่างเพื่อเริ่ม 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}
|
|
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" && activeTab === "versioning" ? (
|
|
/* ห้ามใส่ 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}
|
|
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}
|
|
|
|
{/* แท็บเครื่องมือ Template — state ทั้งหมดอยู่ใน panel เอง (snapshot ชั่วคราวของเอกสาร
|
|
สลับแท็บแล้ว unmount = ทิ้งค่าทิ้งโดยตั้งใจ) */}
|
|
{(phase === "untracked" || phase === "tracking") && activeTab === "template" ? (
|
|
<TemplateToolsPanel onBusyChange={handleTemplateBusyChange} />
|
|
) : null}
|
|
|
|
{/* 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>
|
|
);
|
|
};
|
|
|
|
export default TaskpaneApp;
|