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>
646 lines
34 KiB
TypeScript
646 lines
34 KiB
TypeScript
/* 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 TemplateToolsPanel from "./TemplateToolsPanel";
|
|
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";
|
|
|
|
/** แท็บของ 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);
|
|
|
|
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");
|
|
setActiveTab("versioning");
|
|
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)
|
|
* e-Court pass-through — ยังไม่มี session แต่ไฟล์ stamp token/owner มา = ผ่านเข้าเลยไม่ต้องกด login */
|
|
const detect = useCallback(async () => {
|
|
setPhase("checking");
|
|
setBootError(null);
|
|
try {
|
|
let properties: metadataService.TrackedDocumentProperties | null = null;
|
|
if (!(await authService.getAccessToken())) {
|
|
// e-Court pass-through — อ่านไฟล์ก่อนตัดสิน: ไฟล์ที่ stamp token/owner มา = ผ่านเลยไม่ต้องกด login
|
|
// (นอก Word เช่น browser ธรรมดา → อ่านไม่ได้ = ไป signed-out ตามเดิม)
|
|
try {
|
|
properties = await metadataService.readTrackedProperties();
|
|
} catch {
|
|
properties = null;
|
|
}
|
|
if (
|
|
!properties ||
|
|
!authService.signInFromDocument({ token: properties.ecourtToken, owner: properties.ecourtOwner })
|
|
) {
|
|
setPhase("signed-out");
|
|
return;
|
|
}
|
|
}
|
|
authService.startAutoRefresh();
|
|
setUsername(authService.getUsername() ?? null);
|
|
|
|
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");
|
|
setActiveTab("versioning");
|
|
};
|
|
|
|
/** 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>
|
|
|
|
{/* 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="กำลังตรวจสอบเอกสาร…" />
|
|
{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" && 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;
|