import { useState, useRef, useEffect } from "react";
// ── Palette & helpers ──────────────────────────────────────────────────────
const STATUS_META = {
DRAFT: { label: "Draft", bg: "#1E293B", text: "#94A3B8", border: "#334155" },
SUBOWN: { label: "Submitted", bg: "#0C1A2E", text: "#38BDF8", border: "#0369A1" },
PENDING: { label: "Pending Reply", bg: "#1C0A00", text: "#FB923C", border: "#C2410C" },
APPROVED: { label: "Approved", bg: "#052E16", text: "#4ADE80", border: "#15803D" },
REJECTED: { label: "Rejected", bg: "#2D0A0A", text: "#F87171", border: "#B91C1C" },
CLOSED: { label: "Closed", bg: "#1A1A2E", text: "#A78BFA", border: "#7C3AED" },
};
const PROJECTS = [
{ code: "LCBP3", name: "Laem Chabang Basin Phase 3" },
{ code: "LCBP2", name: "Laem Chabang Basin Phase 2" },
{ code: "PILOT", name: "Pilot Project" },
];
const TYPES = ["Letter", "RFI", "Notice", "Instruction", "Report", "Memo"];
const DISCIPLINES = ["GEN – General", "STR – Structural", "MEP – Mechanical", "ARC – Architecture", "CIV – Civil"];
const ORGS = ["กทท. (PAT)", "TEAM Consulting", "CHINA HARBOUR", "Third Party Inspector"];
const MOCK_DATA = [
{
id: 1, uuid: "c1a2b3c4", number: "LCBP3-LTR-2026-0042",
type: "Letter", discipline: "GEN", isInternal: false,
originator: "CHINA HARBOUR", project: "LCBP3",
recipients: [{ org: "กทท. (PAT)", type: "TO" }, { org: "TEAM Consulting", type: "CC" }],
tags: ["Urgent", "Schedule"],
revisions: [
{
id: 10, revNo: 0, label: "0", isCurrent: false, status: "APPROVED",
subject: "Request for Extension of Time – Phase 2 Piling Works",
description: "Initial submission requesting EOT due to unforeseen ground conditions.",
body: "In accordance with Clause 44 of the Contract, we hereby formally request an extension of time for the Phase 2 piling works...",
remarks: "Supporting geotechnical report attached.",
docDate: "2026-01-10", issuedDate: "2026-01-12", receivedDate: "2026-01-13", dueDate: "2026-02-12",
createdBy: "John Smith", updatedBy: null,
attachments: [
{ id: 1, name: "EOT-Request-Rev0.pdf", size: 2457600, mime: "application/pdf", isMain: true },
{ id: 2, name: "Geotech-Report.pdf", size: 8912000, mime: "application/pdf", isMain: false },
],
},
{
id: 11, revNo: 1, label: "1", isCurrent: true, status: "PENDING",
subject: "Request for Extension of Time – Phase 2 Piling Works (Rev.1)",
description: "Revised submission incorporating PAT's comments dated 2026-02-01.",
body: "Following PAT's comments dated 2026-02-01, we have revised our EOT request as follows...",
remarks: "Updated programme attached. Previous geotechnical report remains unchanged.",
docDate: "2026-02-08", issuedDate: "2026-02-10", receivedDate: null, dueDate: "2026-03-10",
createdBy: "John Smith", updatedBy: "Jane Doe",
attachments: [
{ id: 3, name: "EOT-Request-Rev1.pdf", size: 3012000, mime: "application/pdf", isMain: true },
{ id: 4, name: "Updated-Programme.xlsx", size: 512000, mime: "application/vnd.ms-excel", isMain: false },
{ id: 2, name: "Geotech-Report.pdf", size: 8912000, mime: "application/pdf", isMain: false },
],
},
],
references: ["LCBP3-LTR-2026-0018", "LCBP3-RFI-2025-0091"],
createdAt: "2026-01-10",
},
{
id: 2, uuid: "d2e3f4a5", number: "LCBP3-RFI-2026-0011",
type: "RFI", discipline: "STR", isInternal: false,
originator: "CHINA HARBOUR", project: "LCBP3",
recipients: [{ org: "TEAM Consulting", type: "TO" }],
tags: ["Technical"],
revisions: [
{
id: 20, revNo: 0, label: "0", isCurrent: true, status: "SUBOWN",
subject: "Clarification on Pile Cap Reinforcement Detail – Drawing STR-PC-105",
description: "Request for information regarding conflicting dimensions in pile cap drawing.",
body: "Please clarify the discrepancy between the plan dimension (4500mm) and section detail (4200mm) on Drawing STR-PC-105...",
remarks: "",
docDate: "2026-03-05", issuedDate: "2026-03-06", receivedDate: null, dueDate: "2026-03-20",
createdBy: "Alice Chen", updatedBy: null,
attachments: [
{ id: 5, name: "RFI-011-Query.pdf", size: 1245000, mime: "application/pdf", isMain: true },
{ id: 6, name: "STR-PC-105-marked.pdf", size: 3100000, mime: "application/pdf", isMain: false },
],
},
],
references: [],
createdAt: "2026-03-05",
},
{
id: 3, uuid: "e5f6a7b8", number: "LCBP3-NTC-2026-0007",
type: "Notice", discipline: "GEN", isInternal: true,
originator: "กทท. (PAT)", project: "LCBP3",
recipients: [{ org: "CHINA HARBOUR", type: "TO" }, { org: "TEAM Consulting", type: "CC" }],
tags: ["HSE", "Urgent"],
revisions: [
{
id: 30, revNo: 0, label: "0", isCurrent: true, status: "CLOSED",
subject: "Non-Conformance Notice – Safety Barrier Installation at Gate 3",
description: "Formal notice of non-conformance regarding inadequate safety barriers.",
body: "This is to formally notify that the safety barrier installation at Gate 3 does not comply with approved method statement MS-HSE-012...",
remarks: "Immediate remedial action required within 48 hours.",
docDate: "2026-02-20", issuedDate: "2026-02-20", receivedDate: "2026-02-21", dueDate: "2026-02-22",
createdBy: "PAT Safety", updatedBy: "PAT Safety",
attachments: [
{ id: 7, name: "NCN-007-Notice.pdf", size: 980000, mime: "application/pdf", isMain: true },
{ id: 8, name: "Photo-Evidence.zip", size: 15000000, mime: "application/zip", isMain: false },
],
},
],
references: ["LCBP3-LTR-2026-0031"],
createdAt: "2026-02-20",
},
];
// ── Utility components ─────────────────────────────────────────────────────
function StatusBadge({ code }) {
const m = STATUS_META[code] || { label: code, bg: "#1E293B", text: "#94A3B8", border: "#334155" };
return (
{m.label}
);
}
function TypeBadge({ type }) {
const colors = { Letter: "#F59E0B", RFI: "#38BDF8", Notice: "#F87171", Instruction: "#A78BFA", Report: "#4ADE80", Memo: "#94A3B8" };
return (
{type.toUpperCase()}
);
}
function TagPill({ label }) {
return (
{label}
);
}
function FileIcon({ mime }) {
if (mime?.includes("pdf")) return 📄;
if (mime?.includes("excel") || mime?.includes("sheet")) return 📊;
if (mime?.includes("zip") || mime?.includes("compressed")) return 📦;
return 📎;
}
function formatBytes(b) {
if (b < 1024) return b + " B";
if (b < 1048576) return (b / 1024).toFixed(0) + " KB";
return (b / 1048576).toFixed(1) + " MB";
}
// ── Flow Diagram Component ─────────────────────────────────────────────────
function FlowDiagram({ onClose }) {
const flows = [
{ step: "1", icon: "📝", title: "สร้าง Master Record", sub: "correspondences table", desc: "กำหนด Type, Number, Originator, Project, Recipients (TO/CC), Tags", color: "#F59E0B" },
{ step: "2", icon: "📋", title: "สร้าง Revision 0", sub: "correspondence_revisions (is_current=true)", desc: "ใส่เนื้อหา: Subject, Body, Dates, Status=DRAFT, Details JSON", color: "#38BDF8" },
{ step: "3", icon: "⬆️", title: "Upload ไฟล์แนบ (Phase 1)", sub: "attachments (is_temporary=true)", desc: "อัปโหลดล่วงหน้า ได้ temp_id กลับมา ไฟล์ยังไม่ Commit", color: "#A78BFA" },
{ step: "4", icon: "🔗", title: "Commit ไฟล์ (Phase 2)", sub: "correspondence_revision_attachments", desc: "ผูก temp attachment → revision ด้วย is_main_document flag, set is_temporary=false", color: "#4ADE80" },
{ step: "5", icon: "✉️", title: "Submit / ส่งเอกสาร", sub: "status: DRAFT → SUBOWN", desc: "เปลี่ยน Status, บันทึก issued_date, แจ้งเตือน Recipient", color: "#FB923C" },
{ step: "6", icon: "🔄", title: "เพิ่ม Revision ใหม่", sub: "correspondence_revisions (revision_number++)", desc: "set is_current=false บน Rev เก่า, สร้าง Rev ใหม่ is_current=true", color: "#F87171" },
];
return (
UX FLOW DIAGRAM
Correspondence Lifecycle
{/* DB Relationship */}
TABLE RELATIONSHIP (Master-Revision Pattern)
{[
{ label: "correspondences", role: "MASTER", color: "#F59E0B" },
{ arrow: "1:N" },
{ label: "correspondence_revisions", role: "REVISIONS", color: "#38BDF8" },
{ arrow: "M:N" },
{ label: "attachments", role: "FILES", color: "#A78BFA" },
].map((item, i) => item.arrow ? (
) : (
))}
{["correspondence_recipients (M:N)", "correspondence_tags (M:N)", "correspondence_references (M:N)", "correspondence_revision_attachments (Junction)"].map(t => (
{t}
))}
{/* Flow Steps */}
{flows.map((f, i) => (
{f.step}
{f.icon} {f.title}
{f.sub}
{f.desc}
))}
{/* Key UX Rules */}
KEY UX RULES
{[
"correspondence_number สร้างอัตโนมัติ (DocumentNumberingModule) — ผู้ใช้เลือก Type เท่านั้น",
"ไฟล์แนบ upload ก่อน → ได้ temp_id → commit หลัง save form (Two-Phase)",
"is_current เปลี่ยนได้เพียง 1 Revision ต่อ Correspondence เท่านั้น",
"แต่ละ Revision มีชุดไฟล์แนบเป็นของตัวเอง ผ่าน correspondence_revision_attachments",
"Revision ใหม่ copy เนื้อหาจาก Rev ล่าสุด เพื่อลดการพิมพ์ซ้ำ",
"Recipients (TO/CC) ผูกกับ Master ไม่ใช่ Revision — เปลี่ยนได้ตลอด",
].map((r, i) => (
→
{r}
))}
);
}
// ── Create Correspondence Wizard ───────────────────────────────────────────
function CreateWizard({ onClose, onCreated }) {
const [step, setStep] = useState(1);
const [form, setForm] = useState({
type: "", discipline: "", isInternal: false, originator: "", project: "LCBP3",
toOrgs: [], ccOrgs: [],
subject: "", body: "", remarks: "", docDate: "", dueDate: "", status: "DRAFT",
files: [], mainFileIdx: null,
});
const fileRef = useRef();
const STEPS = ["Basic Info", "Content", "Attachments", "Review"];
const total = 4;
const update = (k, v) => setForm(f => ({ ...f, [k]: v }));
const addFile = (e) => {
const newFiles = Array.from(e.target.files).map(file => ({
name: file.name, size: file.size, mime: file.type,
tempId: "tmp_" + Math.random().toString(36).slice(2), isMain: false,
}));
setForm(f => ({ ...f, files: [...f.files, ...newFiles] }));
};
const toggleRecipient = (org, type) => {
const key = type === "TO" ? "toOrgs" : "ccOrgs";
const arr = form[key];
setForm(f => ({ ...f, [key]: arr.includes(org) ? arr.filter(x => x !== org) : [...arr, org] }));
};
const inputStyle = {
width: "100%", background: "#0A1525", border: "1px solid #1E293B",
color: "#F1F5F9", padding: "8px 12px", borderRadius: 6, fontSize: 13,
outline: "none", boxSizing: "border-box",
};
const labelStyle = { color: "#64748B", fontSize: 11, fontWeight: 600, letterSpacing: "0.05em", marginBottom: 4, display: "block" };
return (
{/* Header */}
NEW DOCUMENT
Create Correspondence
{/* Step indicators */}
{STEPS.map((s, i) => (
))}
{/* Body */}
{step === 1 && (
⚙
{form.type ? `LCBP3-${form.type.slice(0,3).toUpperCase()}-2026-XXXX` : "เลือก Type เพื่อ Preview เลขที่"}
Auto-generated
{ORGS.filter(o => o !== form.originator).map(org => (
{org}
{["TO", "CC"].map(type => (
))}
))}
update("isInternal", e.target.checked)}
style={{ accentColor: "#F59E0B", width: 16, height: 16 }} />
)}
{step === 2 && (
REVISION
Rev. 0
• First submission — Revision 0 จะสร้างอัตโนมัติ (is_current=true)
update("subject", e.target.value)} />
update("remarks", e.target.value)} />
{["DRAFT", "SUBOWN"].map(s => (
))}
)}
{step === 3 && (
⬆️
Phase 1: Upload ไฟล์แนบ
ไฟล์จะถูกเก็บชั่วคราว (is_temporary=true) จนกว่าจะ Save
{form.files.length > 0 && (
UPLOADED FILES — Phase 2: กำหนด Main Document
{form.files.map((file, i) => (
{file.name}
{formatBytes(file.size)} · temp_id: {file.tempId}
))}
)}
📌 Note: ไฟล์จะถูก commit เข้า correspondence_revision_attachments เมื่อกด Save เท่านั้น
)}
{step === 4 && (
✓ Ready to create — ตรวจสอบข้อมูลก่อน Submit
{[
{ label: "Type", value: form.type || "—" },
{ label: "Number", value: form.type ? `LCBP3-${form.type.slice(0,3).toUpperCase()}-2026-XXXX (auto)` : "—", mono: true },
{ label: "Originator", value: form.originator || "—" },
{ label: "TO", value: form.toOrgs.join(", ") || "—" },
{ label: "CC", value: form.ccOrgs.join(", ") || "—" },
{ label: "Subject (Rev.0)", value: form.subject || "—" },
{ label: "Status", value: form.status },
{ label: "Doc Date", value: form.docDate || "—" },
{ label: "Due Date", value: form.dueDate || "—" },
{ label: "Attachments", value: form.files.length + " files" },
].map(({ label, value, mono }) => (
{label}
{value}
))}
)}
{/* Footer */}
);
}
// ── Correspondence Detail ──────────────────────────────────────────────────
function CorrespondenceDetail({ doc, onBack }) {
const [activeRevId, setActiveRevId] = useState(doc.revisions.find(r => r.isCurrent)?.id);
const [showAddRevModal, setShowAddRevModal] = useState(false);
const activeRev = doc.revisions.find(r => r.id === activeRevId) || doc.revisions[doc.revisions.length - 1];
return (
{/* Breadcrumb header */}
{doc.isInternal && INTERNAL}
{doc.number}
{/* Left: Revision Timeline */}
{[...doc.revisions].reverse().map((rev) => (
))}
{/* Main: Revision Detail */}
{activeRev && (
<>
{activeRev.subject}
📅 Doc: {activeRev.docDate || "—"}
📤 Issued: {activeRev.issuedDate || "—"}
📥 Received: {activeRev.receivedDate || "—"}
⏰ Due: {activeRev.dueDate || "—"}
{/* Description */}
{activeRev.description && (
DESCRIPTION (Revision Notes)
{activeRev.description}
)}
{/* Body */}
{activeRev.body && (
BODY (เนื้อความ)
{activeRev.body}
)}
{/* Remarks */}
{activeRev.remarks && (
REMARKS
{activeRev.remarks}
)}
{/* Meta */}
Created by: {activeRev.createdBy}
{activeRev.updatedBy && · Updated by: {activeRev.updatedBy}}
{/* Attachments */}
ATTACHMENTS ({activeRev.attachments.length}) — correspondence_revision_attachments
{activeRev.attachments.map(att => (
{att.name}
{formatBytes(att.size)} · {att.mime}
{att.isMain &&
MAIN DOC}
))}
>
)}
{/* Right panel: Master info */}
{[
{ label: "Project", value: doc.project },
{ label: "Originator", value: doc.originator },
{ label: "Discipline", value: doc.discipline },
{ label: "Created", value: doc.createdAt },
].map(({ label, value }) => (
))}
RECIPIENTS
{doc.recipients.map((r, i) => (
{r.type}
{r.org}
))}
{doc.tags.length > 0 && (
TAGS
{doc.tags.map(t => )}
)}
{doc.references.length > 0 && (
REFERENCES
{doc.references.map(r => (
{r}
))}
)}
{/* Add Revision Modal (simplified) */}
{showAddRevModal && (
NEW REVISION
Add Revision {doc.revisions.length}
⚡ Auto-action: Rev.{doc.revisions.length - 1} (current) จะถูก set is_current=false อัตโนมัติ
SUBJECT (copied from Rev.{doc.revisions.length - 1})
STATUS
{["DRAFT", "SUBOWN", "PENDING"].map(s => (
))}
)}
);
}
// ── FilterDropdown ─────────────────────────────────────────────────────────
function FilterDropdown({ label, value, options, onChange }) {
const [open, setOpen] = useState(false);
const ref = useRef();
useEffect(() => {
const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
const selected = options.find(o => o.value === value);
const isFiltered = value !== "ALL";
return (
{/* Trigger button */}
{/* Dropdown panel */}
{open && (
{options.map(opt => {
const active = value === opt.value;
return (
);
})}
)}
);
}
export default function CorrespondenceApp() {
const [view, setView] = useState("list"); // list | detail
const [selectedDoc, setSelectedDoc] = useState(null);
const [showCreate, setShowCreate] = useState(false);
const [showFlow, setShowFlow] = useState(false);
const [search, setSearch] = useState("");
const [filterType, setFilterType] = useState("ALL");
const [filterStatus, setFilterStatus] = useState("ALL");
const [filterProject, setFilterProject] = useState("ALL");
const [docs, setDocs] = useState(MOCK_DATA);
const filtered = docs.filter(d => {
const matchSearch = !search || d.number.toLowerCase().includes(search.toLowerCase()) ||
d.revisions.some(r => r.subject.toLowerCase().includes(search.toLowerCase()));
const matchType = filterType === "ALL" || d.type === filterType;
const matchProject = filterProject === "ALL" || d.project === filterProject;
const currentRev = d.revisions.find(r => r.isCurrent);
const matchStatus = filterStatus === "ALL" || currentRev?.status === filterStatus;
return matchSearch && matchType && matchProject && matchStatus;
});
if (view === "detail" && selectedDoc) {
return (
setView("list")} />
);
}
return (
{/* Top bar */}
setSearch(e.target.value)} />
{/* Filter bar */}
({ value: p.code, label: p.code, sublabel: p.name })),
]}
/>
{
const colors = { Letter: "#F59E0B", RFI: "#38BDF8", Notice: "#F87171", Instruction: "#A78BFA", Report: "#4ADE80", Memo: "#94A3B8" };
return { value: t, label: t, color: colors[t], dot: colors[t] };
}),
]}
/>
({
value: code, label: m.label, color: m.text, dot: m.text,
})),
]}
/>
{filtered.length} documents
{/* List */}
{/* Column headers */}
DOCUMENT NO.SUBJECT (CURRENT REV.)ORIGINATORTYPESTATUSREV.
{filtered.map(doc => {
const currentRev = doc.revisions.find(r => r.isCurrent) || doc.revisions[doc.revisions.length - 1];
return (
{ setSelectedDoc(doc); setView("detail"); }} style={{
display: "grid", gridTemplateColumns: "200px 1fr 140px 100px 90px 90px",
gap: 12, padding: "12px 14px", marginBottom: 4,
background: "#0A1525", border: "1px solid #1E293B", borderRadius: 8,
cursor: "pointer", alignItems: "center",
transition: "border-color 0.15s",
}}
onMouseEnter={e => e.currentTarget.style.borderColor = "#334155"}
onMouseLeave={e => e.currentTarget.style.borderColor = "#1E293B"}
>
{doc.number}
{doc.createdAt}
{currentRev?.subject}
{doc.tags.map(t => )}
{doc.references.length > 0 && 🔗 {doc.references.length} ref}
{doc.originator}
Rev.{currentRev?.revNo}
{doc.revisions.length > 1 && {doc.revisions.length}}
);
})}
{filtered.length === 0 && (
)}
{showCreate &&
setShowCreate(false)} onCreated={() => {}} />}
{showFlow && setShowFlow(false)} />}
);
}