;
}
// ---- รายละเอียดงาน + แทรกสื่อ Google Drive ----
// ดึง file id จากลิงก์ Google Drive ทุกแบบ (/file/d/ID, ?id=ID, /d/ID)
function driveId(url){ const m=(url||"").match(/\/file\/d\/([-\w]{20,})/)||(url||"").match(/[?&]id=([-\w]{20,})/)||(url||"").match(/\/d\/([-\w]{20,})/); return m?m[1]:null; }
// Google Docs/Sheets/Slides → preview URL ที่ถูก format (ไม่ใช่ /file/d/ ของ Drive ทั่วไป)
function gdocInfo(url){ const u=url||""; let m;
if(m=u.match(/docs\.google\.com\/document\/d\/([-\w]{20,})/)) return {kind:"doc", id:m[1],icon:"📄",label:"Google Docs", preview:`https://docs.google.com/document/d/${m[1]}/preview`};
if(m=u.match(/docs\.google\.com\/spreadsheets\/d\/([-\w]{20,})/)) return {kind:"sheet",id:m[1],icon:"📊",label:"Google Sheets",preview:`https://docs.google.com/spreadsheets/d/${m[1]}/preview`};
if(m=u.match(/docs\.google\.com\/presentation\/d\/([-\w]{20,})/)) return {kind:"slide",id:m[1],icon:"📽",label:"Google Slides",preview:`https://docs.google.com/presentation/d/${m[1]}/preview`};
return null; }
// จาก mime ของ Google native file → preview src (ใช้กับ attachment ที่เป็น Google Doc)
function gdocByMime(mime,fid){ const m=(mime||""); if(!fid) return null;
if(m.includes("google-apps.document")) return {icon:"📄",label:"Google Docs", preview:`https://docs.google.com/document/d/${fid}/preview`};
if(m.includes("google-apps.spreadsheet")) return {icon:"📊",label:"Google Sheets",preview:`https://docs.google.com/spreadsheets/d/${fid}/preview`};
if(m.includes("google-apps.presentation")) return {icon:"📽",label:"Google Slides",preview:`https://docs.google.com/presentation/d/${fid}/preview`};
return null; }
function isHttp(s){ return /^https?:\/\/\S+$/i.test((s||"").trim()); }
// render รายละเอียด: บรรทัดที่เป็นลิงก์ Drive → ฝัง preview · ลิงก์รูป/วิดีโอตรง → img/video · ลิงก์อื่น → a · ที่เหลือ → ข้อความ
function MediaRenderer({text}){
if(!text) return null;
return
{(""+text).split(/\n/).map((ln,i)=>{
const s=ln.trim(), gd=gdocInfo(s), did=gd?null:driveId(s);
if(gd) return
;
if(did) return
;
if(/^https?:\/\/\S+\.(jpg|jpeg|png|gif|webp|avif)(\?\S*)?$/i.test(s)) return

;
if(/^https?:\/\/\S+\.(mp4|webm|mov|m4v)(\?\S*)?$/i.test(s)) return
;
if(isHttp(s)) return
{s};
if(!s) return
;
}
// ไฟล์แนบ (อัปขึ้น Drive แล้ว) → embed รูป/วิดีโอ · เอกสารเป็นลิงก์
function AttachItem({a, canEdit, onDel}){
const m=(a.mime||"").toLowerCase(), fid=a.drive_file_id;
const isImg=m.startsWith("image/"), isVid=m.startsWith("video/");
const gd=gdocByMime(a.mime,fid); // Google Docs/Sheets/Slides → embed แบบถูก format
const sizeStr=a.size?(a.size>1048576?(a.size/1048576).toFixed(1)+" MB":Math.max(1,Math.round(a.size/1024))+" KB"):"";
return
{gd?gd.icon:isImg?"🖼":isVid?"🎬":"📄"}
{a.name}
{gd&&
แก้ไข ↗}
{sizeStr&&
{sizeStr}}
{canEdit&&
}
{isImg&&fid&&

}
{isVid&&fid&&
}
{gd&&
}
;
}
// ---- Focus Mode: เริ่มงาน → จอมืดเต็มจอ + timer + checklist + done/เลื่อน ----
const FOCUS_CHEERS=["เยี่ยมไปเลย ✨","ลุยต่อ 🔥","โฟกัสสุดยอด 🎯","งานเดินแล้ว 💪","เก่งมาก!","ปังอีกชิ้น 🚀","มืออาชีพชัด ✨","สำเร็จแล้ว 🏆","ทำได้ดีมาก","อีกนิดเดียว สู้ๆ"];
function pickCheer(){ return FOCUS_CHEERS[Math.floor(Math.random()*FOCUS_CHEERS.length)]; }
function nextRoundHour(){ const d=new Date(); d.setTime(d.getTime()+3600000); // now+1ชม. → ปัดขึ้นชั่วโมงเต็ม (12:45→14:00)
if(d.getMinutes()>0||d.getSeconds()>0){ d.setHours(d.getHours()+1); } d.setMinutes(0,0,0); return d; }
function fmtDur(sec){ sec=Math.max(0,Math.floor(sec)); const h=Math.floor(sec/3600),m=Math.floor((sec%3600)/60),s=sec%60;
const p=n=>String(n).padStart(2,"0"); return h>0?`${h}:${p(m)}:${p(s)}`:`${p(m)}:${p(s)}`; }
const FOCUS_BG={position:"fixed",inset:0,background:"var(--th-focus-bg)",zIndex:8000,display:"flex",alignItems:"center",justifyContent:"center",overflowY:"auto",padding:"32px 0"};
const FOCUS_INP={flex:1,padding:"11px 12px",borderRadius:10,background:"var(--th-focus-inp-bg)",border:"1px solid var(--th-focus-inp-border)",color:"var(--th-focus-ink)",fontSize:14,colorScheme:"var(--th-color-scheme)",minWidth:0};
function FocusOverlay({t, startedAt, clist, onToggle, onDone, onSnooze, onExit, done}){
const [elapsed,setElapsed]=useState(0); const [snz,setSnz]=useState(false);
const today=(()=>{ const d=new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`; })();
const [snDate,setSnDate]=useState(today);
const [snTime,setSnTime]=useState(()=>{ const d=nextRoundHour(); return `${String(d.getHours()).padStart(2,"0")}:${String(d.getMinutes()).padStart(2,"0")}`; });
useEffect(()=>{ if(done) return; const base=new Date(startedAt).getTime();
const tick=()=>setElapsed((Date.now()-base)/1000); tick(); const iv=setInterval(tick,1000); return ()=>clearInterval(iv); },[startedAt,done]);
const total=clist.length, dn=clist.filter(x=>x.done).length, pct=total?Math.round(dn/total*100):0;
const inner = done ? (
🔥
โฟกัส {done.minutes} นาที
{done.cheer}
) : (
กำลังโฟกัส
{t.title}
{fmtDur(elapsed)}
{total>0 && <>
ความคืบหน้า{dn}/{total} · {pct}%
>}
{t.description &&
{t.description}
}
{clist.map((it,i)=>
onToggle(i)} style={{display:"flex",alignItems:"center",gap:11,padding:"9px 0",cursor:"pointer"}}>
{it.done?"✓":""}
{it.text}
)}
{total===0 &&
ยังไม่มีเช็กลิสต์ · เพิ่มได้จากหน้ารายละเอียด (เร็วๆนี้ ✨ AI ช่วยสร้าง)
}
{snz ?
เลื่อนไปทำเมื่อไหร่?
setSnDate(e.target.value)} style={FOCUS_INP}/>setSnTime(e.target.value)} style={FOCUS_INP}/>
:
}
);
// portal ไป document.body เพื่อให้ overlay หลุดจาก stacking context ของ modal → คลุมเต็มจอจริง (รวม header)
return ReactDOM.createPortal(inner, document.body);
}
function Detail({task, user, onClose, onChanged, canEdit, embedded, users, canSeeAll}){
const [t,setT]=useState(task); const [busy,setBusy]=useState(false);
const [reviews,setReviews]=useState([]); const [note,setNote]=useState("");
const [links,setLinks]=useState([]); const [edit,setEdit]=useState(false); const [ef,setEf]=useState({});
const [nl,setNl]=useState({kind:"brief",url:""});
const [comments,setComments]=useState([]); const [cbody,setCbody]=useState("");
const [addingLink,setAddingLink]=useState(false); const [cFocus,setCFocus]=useState(false); const [audit,setAudit]=useState([]);
const [docTitles,setDocTitles]=useState({}); // kb/#id → ชื่อ doc จริง (label)
const [clist,setClist]=useState(()=>{try{return JSON.parse(task.checklist||"[]");}catch(e){return [];}});
const [newItem,setNewItem]=useState(""); const [mediaUrl,setMediaUrl]=useState("");
const [attachments,setAttachments]=useState([]); const [uploading,setUploading]=useState(false); const [upPct,setUpPct]=useState(0); const [dragOver,setDragOver]=useState(false);
const [focusOn,setFocusOn]=useState(false); const [focusStart,setFocusStart]=useState(null); const [focusDone,setFocusDone]=useState(null);
useEffect(()=>{ try{setClist(JSON.parse(t.checklist||"[]"));}catch(e){setClist([]);} },[t.id,t.checklist]);
useEffect(()=>{ setT(task); },[task.id, task.updated_at]); // resync เมื่อข้อมูลงานเปลี่ยน (poll/แก้จากที่อื่น)
useEscClose(embedded?null:onClose); // modal (mobile/kanban) ปิดด้วย Esc · embedded ใช้ปุ่มล้าง
function loadComments(){ api("GET","/api/tasks/"+t.id+"/comments").then(setComments).catch(()=>{}); }
function loadAttachments(){ api("GET","/api/tasks/"+t.id+"/attachments").then(setAttachments).catch(()=>{}); }
function uploadFiles(files){ const list=[...(files||[])].filter(Boolean); if(!list.length)return; let i=0;
const next=()=>{ if(i>=list.length){ setUploading(false); setUpPct(0); loadAttachments(); onChanged&&onChanged(); return; }
const f=list[i++], fd=new FormData(); fd.append("file",f);
const xhr=new XMLHttpRequest(); xhr.open("POST",API+"/api/tasks/"+t.id+"/attachments");
if(token()) xhr.setRequestHeader("Authorization","Bearer "+token()); xhr.withCredentials=true;
xhr.upload.onprogress=e=>{ if(e.lengthComputable) setUpPct(Math.round(e.loaded/e.total*100)); };
xhr.onload=()=>{ if(xhr.status>=200&&xhr.status<300){ toast.ok("อัปไฟล์แล้ว · "+f.name); } else { let m="อัปไฟล์ไม่สำเร็จ"; try{m=JSON.parse(xhr.responseText).detail||m;}catch(_){} toast.error(m); } setUpPct(0); next(); };
xhr.onerror=()=>{ toast.error("เครือข่ายขัดข้องระหว่างอัปไฟล์"); setUpPct(0); next(); };
xhr.send(fd); };
setUploading(true); next(); }
async function delAttachment(a){ if(!a)return;
if(!await gate({title:"ลบไฟล์นี้?",body:`"${a.name||"ไฟล์"}" จะถูกลบออกจาก Google Drive · กู้คืนไม่ได้`,danger:true})) return;
api("DELETE","/api/attachments/"+a.id).then(()=>{ loadAttachments();
toast("ลบไฟล์แล้ว",{actionLabel:"",}); }).catch(e=>toast.error(e.message)); }
async function addComment(){ const b=cbody.trim(); if(!b)return; setBusy(true);
try{ await api("POST","/api/tasks/"+t.id+"/comments",{body:b}); setCbody(""); loadComments(); }
catch(e){toast.error(e.message);} finally{setBusy(false);} }
function linkText(l){ const m=(l.url||"").match(/\/kb\/#(\d+)$/); return (m&&docTitles[m[1]]) ? ("📄 "+docTitles[m[1]]) : l.url; }
const isReviewer=["head_content","admin","superadmin"].includes(user.role);
function loadLinks(){ api("GET","/api/tasks/"+t.id+"/links").then(ls=>{ setLinks(ls);
ls.forEach(l=>{ const m=(l.url||"").match(/\/kb\/#(\d+)$/); if(m){ api("GET","/api/docs/"+m[1]).then(d=>setDocTitles(prev=>prev[m[1]]?prev:{...prev,[m[1]]:d.title})).catch(()=>{}); } });
}).catch(()=>{}); }
useEffect(()=>{ api("GET","/api/tasks/"+t.id+"/reviews").then(setReviews).catch(()=>{}); loadLinks(); loadComments(); loadAttachments();
api("GET","/api/tasks/"+t.id+"/audit").then(a=>setAudit(a||[])).catch(()=>{}); },[t.id]);
async function patch(body){ setBusy(true); try{ const r=await api("PATCH","/api/tasks/"+t.id,body); setT(r.task); onChanged&&onChanged(); }catch(e){toast.error(e.message);} finally{setBusy(false);} }
function startEdit(){ setEf({title:t.title,due:t.due||"",brand:t.brand||"",priority:t.priority,owner_id:t.owner_id||"",recurrence:t.recurrence||"",tags:t.tags||"",description:t.description||""}); setEdit(true); }
async function saveEdit(){
if(!await gate({title:"บันทึกการแก้ไข?",body:`บันทึกการเปลี่ยนแปลงของ "${t.title}"`})) return; // ถามเฉพาะตอนสวมบท
const body={...ef}; if("owner_id" in body) body.owner_id=body.owner_id?+body.owner_id:null; await patch(body); setEdit(false); }
async function addLink(){ if(!nl.url)return; setBusy(true); try{ await api("POST","/api/tasks/"+t.id+"/links",nl); setNl({kind:"brief",url:""}); setAddingLink(false); loadLinks(); }catch(e){toast.error(e.message);} finally{setBusy(false);} }
async function delLink(link){ setBusy(true); try{ await api("DELETE","/api/links/"+link.id);
toast("ลบลิงก์แล้ว",{actionLabel:"เลิกทำ",action:()=>{ api("POST","/api/tasks/"+t.id+"/links",{kind:link.kind,url:link.url}).then(loadLinks).catch(()=>{}); }});
loadLinks(); }catch(e){toast.error(e.message);} finally{setBusy(false);} }
async function review(decision){
const ret=decision==="returned";
if(!await gate({title:ret?"ตีกลับงาน?":"อนุมัติงาน?",body:ret?`ตีกลับ "${t.title}" ให้แก้ไข + แจ้งเจ้าของงาน`:`อนุมัติ "${t.title}" · แจ้งเจ้าของงาน`,always:true,danger:ret,confirmLabel:ret?"ยืนยันตีกลับ":"ยืนยันอนุมัติ"})) return;
setBusy(true); try{ const r=await api("POST","/api/tasks/"+t.id+"/review",{decision,notes:note||null}); setT(r.task); setNote(""); api("GET","/api/tasks/"+t.id+"/reviews").then(setReviews); onChanged&&onChanged(); }catch(e){toast.error(e.message);} finally{setBusy(false);} }
function rescheduleDays(n){ const d=new Date(); d.setHours(0,0,0,0); d.setDate(d.getDate()+n);
const ds=`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
patch({due:ds}); toast.ok("เลื่อนกำหนด → "+ds); }
function saveClist(next){ setClist(next); patch({checklist:next}); }
async function createGDoc(kind){ if(busy)return; setBusy(true);
try{ const a=await api("POST","/api/tasks/"+t.id+"/gdoc",{kind});
toast.ok((kind==="sheet"?"สร้าง Google Sheets":kind==="slide"?"สร้าง Google Slides":"สร้าง Google Docs")+" แล้ว · เปิดแท็บใหม่ให้แก้ไข");
if(a.web_link){ try{ window.open(a.web_link,"_blank","noopener"); }catch(_){} }
loadAttachments(); onChanged&&onChanged();
}catch(e){ toast.error(e.message); } finally{ setBusy(false); } }
async function startFocus(){ try{ const r=await api("POST","/api/tasks/"+t.id+"/focus/start"); setFocusStart(r.started_at); setFocusDone(null); setFocusOn(true); onChanged&&onChanged(); }catch(e){toast.error(e.message);} }
async function focusComplete(){ try{ const r=await api("POST","/api/tasks/"+t.id+"/focus/done"); setFocusDone({minutes:r.minutes,cheer:pickCheer()}); api("GET","/api/tasks/"+t.id).then(setT).catch(()=>{}); onChanged&&onChanged(); }catch(e){toast.error(e.message);} }
async function focusSnooze(due,snooze_to){ try{ await api("POST","/api/tasks/"+t.id+"/focus/snooze",{due,snooze_to}); toast.ok("เลื่อนไป "+(snooze_to||due).replace("T"," ")); setFocusOn(false); api("GET","/api/tasks/"+t.id).then(setT).catch(()=>{}); onChanged&&onChanged(); }catch(e){toast.error(e.message);} }
async function cancelTask(){ if(t.status==="cancelled")return;
if(!await gate({title:"ยกเลิกงาน?",body:`"${t.title}" จะถูกยกเลิก · ยังเก็บในคลังงาน (เปิดใหม่ได้)`,danger:true,confirmLabel:"ยืนยันยกเลิก"})) return;
await patch({status:"cancelled"}); toast.ok("ยกเลิกงานแล้ว · เก็บในคลังงาน"); }
async function deleteTask(){
if(!await gate({title:"ลบงานนี้?",body:`"${t.title}" จะย้ายลงถังขยะ · กู้คืนได้ 30 วัน`,danger:true})) return;
setBusy(true); try{ await api("DELETE","/api/tasks/"+t.id); toast.ok("ย้ายลงถังขยะแล้ว · กู้คืนได้ 30 วัน"); onChanged&&onChanged(); onClose&&onClose(); }catch(e){ toast.error(e.message); } finally{ setBusy(false); } }
const canDelete=["admin","service","superadmin"].includes(user.role);
function insertMedia(){ const u=mediaUrl.trim(); if(!u)return; if(!isHttp(u)){ toast.error("ต้องเป็นลิงก์ http(s)"); return; }
setEf(s=>({...s,description:((s.description||"").trim()+"\n"+u).trim()})); setMediaUrl(""); toast.ok(driveId(u)?"แทรกสื่อ Drive แล้ว":"แทรกลิงก์แล้ว"); }
// ── redesign: overflow / doc-menu / accordion / owner-pick UI state ──
const [ovfOpen,setOvfOpen]=useState(false); const [docOpen,setDocOpen]=useState(false);
// viewRole: map ระบบ → worker / leader / ceo (disclosure)
const viewRole = ["superadmin","exec","service"].includes(user.role) ? "ceo"
: ["head_content","admin","pm","head_review"].includes(user.role) ? "leader" : "worker";
const isLeader = viewRole!=="worker";
const [accOpen,setAccOpen]=useState(isLeader); // leader/ceo เปิดประวัติอัตโนมัติ · worker ปิด
const [pickOwner,setPickOwner]=useState(false); // เปิด select มอบหมาย (leader)
// ── state-driven CTA: map status จริง (หลายค่า) → 5 state ──
const CTA_STATE = (s=>{
if(["idea","not_started","pending","received","brief_received"].includes(s)) return "idea";
if(s==="blocked") return "blocked";
if(["submitted","review","to_publish"].includes(s)) return "review";
if(["approved","done","deployed","published"].includes(s)) return "done";
if(s==="cancelled") return "cancelled";
// progress / in_progress / editing / filming / shoot / testing / revision / returned → ลงมือ
return "progress";
})(t.status);
const ctaState=CTA_STATE;
const terminalSt=["done","cancelled","approved","published","deployed"].includes(t.status);
// CTA actions (เปลี่ยน status ผ่าน patch เดิม + log อัตโนมัติฝั่ง backend)
async function ctaStart(){ if(await gate({title:"เริ่มทำงานนี้?",body:`"${t.title}" → กำลังทำ`})) patch({status:"progress"}); }
async function ctaComplete(){ if(await gate({title:"ส่งงานนี้?",body:`"${t.title}" → ส่งให้รีวิว`,always:true,confirmLabel:"ยืนยันส่งงาน"})) patch({status:"review"}); }
async function ctaResume(){ patch({status:"progress"}); }
const p=pipeTok(t.pipeline_key);
const di=dueInfo(t.due,t.status);
const ownerName=t.owner_name||"—";
const usersArr=users||[];
// ── เลเบล mono เล็ก (subhead/zlabel) ──
const ZLBL={fontSize:11,color:COLORS.inkSubtle,fontFamily:MONO_FONT,fontWeight:700,letterSpacing:1.3,textTransform:"uppercase",display:"flex",alignItems:"center",gap:9,marginBottom:14};
const ZCNT={fontFamily:NUM_FONT,fontSize:11,fontWeight:700,color:COLORS.inkMuted,background:"var(--th-row-soft-2)",padding:"1px 9px",borderRadius:999,letterSpacing:0};
const SUBHEAD={fontSize:11.5,color:COLORS.inkSubtle,fontFamily:MONO_FONT,fontWeight:700,letterSpacing:1.1,textTransform:"uppercase",marginBottom:12,display:"flex",alignItems:"center",gap:8};
// ── โซน "ลงมือ + ส่งงาน" (ซ้าย) ──
const ctaBtnBase={width:"100%",padding:16,borderRadius:15,border:"none",fontWeight:800,fontSize:16,display:"flex",alignItems:"center",justifyContent:"center",gap:9,letterSpacing:0.2,cursor:"pointer"};
const ctaBlock=(()=>{
if(!canEdit) return null;
if(ctaState==="idea") return
;
if(ctaState==="progress") return
;
if(ctaState==="blocked") return
;
if(ctaState==="review") return
;
if(ctaState==="cancelled") return
;
return
✓ งานนี้เสร็จแล้ว
;
})();
// blocked banner (เฉพาะ state=blocked)
const blockBanner = ctaState==="blocked" &&
🚩
ติดปัญหา: งานนี้ถูกแจ้งว่าติดปัญหา — เคลียร์แล้วกด "กลับมาทำต่อ"
;
// sec-row: Focus + แจ้งปัญหา
const ghost={padding:"9px 15px",borderRadius:999,background:"transparent",cursor:"pointer",fontSize:12.5,fontWeight:600,display:"inline-flex",alignItems:"center",gap:6};
const secRow = canEdit && !terminalSt &&
{ctaState!=="blocked" && }
;
// ส่งงาน: dropzone + file list + +สร้างเอกสาร▾ + +เพิ่มลิงก์
const submitZone = (canEdit||attachments.length>0||links.length>0) && <>
📤 ส่งงาน
{canEdit &&
}
{attachments.map(a=>
)}
{links.length>0 && {links.map(l=>)}
}
{canEdit &&
{!addingLink && }
{/* doc dropdown ถูกเอาออก — สร้างได้แค่ Google Doc (คุณเอก 23 มิ.ย.) */}
}
{canEdit && addingLink &&
setNl({...nl,url:e.target.value})} onKeyDown={e=>{if(e.key==="Enter")addLink();}} placeholder="วาง URL" style={{flex:1,minWidth:120,padding:"9px 10px",borderRadius:10,background:COLORS.card,border:`1px solid ${COLORS.hairline}`,color:COLORS.ink,fontSize:13}}/>
}
>;
const zoneAction =
ลงมือ
{ctaBlock}
{blockBanner}
{secRow}
{submitZone}
;
// ── โซน "เช็กลิสต์" (ขวา) ──
const clDone=clist.filter(x=>x.done).length, clPct=clist.length?Math.round(clDone/clist.length*100):0;
const zoneChecklist = (canEdit||clist.length>0) &&
เช็กลิสต์ {clist.length>0 && {clDone}/{clist.length}}
{clist.length>0 &&
}
{clist.map((it,i)=>
{it.text}
{canEdit && }
)}
{canEdit &&
setNewItem(e.target.value)} onKeyDown={e=>{ if(e.key==="Enter"){ const v=newItem.trim(); if(v){ saveClist([...clist,{text:v,done:false}]); setNewItem(""); } } }} placeholder="+ เพิ่มขั้นตอนย่อย (Enter)" style={{width:"100%",marginTop:11,padding:"10px 13px",borderRadius:11,background:"var(--th-row-soft)",border:`1px solid ${COLORS.hairline}`,color:COLORS.ink,fontSize:13.5,outline:"none"}}/>}
;
// ── review card (leader/ceo + state=review) ──
const zoneReview = isLeader && isReviewer && ctaState==="review" &&
รอคุณรีวิว
{t.owner_name||"เจ้าของงาน"}ส่งงานเข้ามาแล้ว{attachments.length?` · มี ${attachments.length} ไฟล์แนบ`:""}{clist.length?` · เช็กลิสต์ ${clDone}/${clist.length}`:""} — ตรวจแล้วกดอนุมัติหรือตีกลับพร้อมโน้ต
;
// ── accordion พูดคุย & ประวัติ ──
const discCount=comments.length+audit.length+reviews.length;
const zoneDiscuss =
{accOpen &&
{comments.map(cm=>
{(cm.user_name||"?").slice(0,1)}
{cm.user_name||"—"}
{(cm.created_at||"").slice(5,16).replace("T"," ")}
{cm.body}
)}
{canEdit &&
}
{reviews.length>0 && <>
โน้ตรีวิว {reviews.length}
{reviews.map(rv=>
{rv.decision==="approved"?"✅ อนุมัติ":"↩️ ตีกลับ"}
{rv.reviewer_name} · {(rv.created_at||"").slice(5,16).replace("T"," ")}
{rv.notes &&
{rv.notes}
}
)}
>}
{audit.length>0 && <>
ความเคลื่อนไหว
>}
{discCount===0 && ยังไม่มีคอมเมนต์หรือความเคลื่อนไหว
}
}
;
// ── edit form (เดิม · กางเมื่อ edit) ──
const editForm =
{(()=>{ const inp={width:"100%",padding:"10px 12px",borderRadius:10,background:COLORS.card,border:`1px solid ${COLORS.hairline}`,color:COLORS.ink,fontSize:14,marginBottom:8}; return <>
setEf({...ef,title:e.target.value})} placeholder="ชื่องาน"/>
setEf({...ef,due:e.target.value})}/>
setEf({...ef,brand:e.target.value})} placeholder="แบรนด์"/>
{canSeeAll && usersArr.length>0 &&
}
setEf({...ef,tags:e.target.value})} placeholder="แท็ก คั่นด้วย , (เช่น ด่วน, รีวิว)"/>
;
// ── overflow ⋯ menu (destructive + leader rows) ──
const ovfMenu =
{ovfOpen &&
e.stopPropagation()} style={{position:"absolute",top:"calc(100% + 8px)",right:0,minWidth:210,zIndex:20,background:COLORS.cardElev,border:`1px solid ${COLORS.hairlineStr}`,borderRadius:14,padding:6,boxShadow:"var(--th-shadow-lg)"}}>
{(()=>{ const mItem={display:"flex",alignItems:"center",gap:10,width:"100%",textAlign:"left",background:"transparent",border:"none",cursor:"pointer",fontSize:13,fontWeight:600,color:COLORS.ink,padding:"9px 11px",borderRadius:9};
const mi={width:18,textAlign:"center",color:COLORS.inkMuted};
const dItem={...mItem,color:"var(--th-st-red-fg)"}; const di2={...mi,color:"var(--th-st-red-fg)"};
return <>
{canEdit &&
}
{canEdit && [["พรุ่งนี้",1],["+3 วัน",3],["+1 สัปดาห์",7]].map(([lb,dn])=>
)}
{isLeader && canSeeAll && usersArr.length>0 &&
}
{(canEdit||canDelete) &&
}
{canEdit && t.status!=="cancelled" &&
}
{canDelete &&
}
>; })()}
}
;
// ── header bar ──
const headerBar =
{p.label}
{canEdit && !edit && }
{(canEdit||canDelete) && ovfMenu}
;
// ── meta strip ──
const sep=·;
const metaStrip =
👤 {ownerName}
{isLeader && canSeeAll && usersArr.length>0 && }
{sep}
{t.brand && <>🏷 {t.brand}{sep}>}
📅 {di?{di.text}:"—"}{sep}
{t.priority||"—"}
{isLeader && t.recurrence && <>{sep}🔁 {RECUR_LABEL[t.recurrence]}>}
;
// owner reassign select (leader · เปิดจาก ▾ หรือ menu)
const ownerPicker = pickOwner && canSeeAll && usersArr.length>0 &&
มอบหมายให้
;
// ── ส่วนหัว (title + meta + desc) ที่ใช้ร่วมทั้ง embedded/modal ──
const headSection = <>
{t.title}
{metaStrip}
{ownerPicker}
{t.description &&
}
{/* tags display ถูกเอาออก — ไม่มีประโยชน์ (คุณเอก 23 มิ.ย.) · field tags ยังอยู่ใน edit/data */}
>;
const content = ({setOvfOpen(false);setDocOpen(false);}}>
{!embedded &&
}
{headerBar}
{edit ? editForm : <>
{headSection}
{/* 2 โซน desktop · 1 คอลัมน์ mobile (CSS class th-task-grid · breakpoint 760px ใน index.html) */}
{zoneAction}
{zoneChecklist}
{zoneReview}
{zoneDiscuss}
>}
{focusOn &&
saveClist(clist.map((x,j)=>j===i?{...x,done:!x.done}:x))}
onDone={focusComplete} onSnooze={focusSnooze} onExit={()=>{setFocusOn(false);setFocusDone(null);}}/>}
);
if(embedded) return {content}
;
return (
e.stopPropagation()} style={{width:"100%",maxWidth:520,background:COLORS.bgElev,borderRadius:"22px 22px 0 0",padding:22,maxHeight:"88vh",overflowY:"auto"}}>
{content}
);
}
// ---- Change password ----
function ChangePw({onClose}){
const [o,setO]=useState(""); const [n,setN]=useState(""); const [n2,setN2]=useState(""); const [msg,setMsg]=useState(""); const [busy,setBusy]=useState(false);
async function submit(){ setMsg(""); if(n!==n2){setMsg("รหัสใหม่ไม่ตรงกัน");return;} if(n.length<6){setMsg("อย่างน้อย 6 ตัว");return;}
setBusy(true); try{ await api("POST","/api/auth/change-password",{old_password:o,new_password:n}); setMsg("✓ เปลี่ยนรหัสแล้ว"); setTimeout(onClose,900); }
catch(e){ setMsg("รหัสเดิมไม่ถูกต้อง"); } finally{ setBusy(false); } }
const inp={width:"100%",padding:"12px 14px",borderRadius:12,background:COLORS.card,border:`1px solid ${COLORS.hairline}`,color:COLORS.ink,fontSize:15,marginBottom:10};
const isD=useIsDesktop(); useEscClose(onClose);
return (
e.stopPropagation()} style={{width:"100%",maxWidth:440,background:COLORS.bgElev,borderRadius:isD?18:"22px 22px 0 0",padding:22,border:isD?`1px solid ${COLORS.hairline}`:"none"}}>
{!isD &&
}
เปลี่ยนรหัสผ่าน
setO(e.target.value)}/>
setN(e.target.value)}/>
setN2(e.target.value)}/>
{msg &&
{msg}
}
);
}
// ---- Create ----
function Create({pipelines, users, onClose, onCreated, canSeeAll, defPipeline, defDue, defOwner}){
const [f,setF]=useState({title:"",pipeline_key:defPipeline||pipelines[0]?.key||"",priority:"P1",brand:"",due:defDue||"",owner_id:defOwner||"",recurrence:"",tags:"",description:""});
const [busy,setBusy]=useState(false); const [adv,setAdv]=useState(false);
const up=(k,v)=>setF(s=>({...s,[k]:v}));
async function submit(){ if(!f.title.trim()){return;} setBusy(true);
// parse token ในชื่อ (!P0 / พรุ่งนี้ / วันที่) ให้เหมือน quick-add · token ที่พิมพ์ชนะ dropdown · ชื่อถูกล้างให้สะอาด
const q=parseQuick(f.title);
try{ await api("POST","/api/tasks",{title:q.title||f.title.trim(),pipeline_key:f.pipeline_key,
priority:q.priority||f.priority, due:q.due||f.due||null,
owner_id:f.owner_id?+f.owner_id:null, brand:f.brand||null, recurrence:q.recurrence||f.recurrence||null, tags:f.tags||null, description:f.description||null}); onCreated(); onClose(); }
catch(e){alert(e.message);} finally{setBusy(false);} }
const inp={width:"100%",padding:"12px 14px",borderRadius:12,background:COLORS.card,border:`1px solid ${COLORS.hairline}`,color:COLORS.ink,fontSize:15,marginBottom:10};
const isD=useIsDesktop(); useEscClose(onClose);
return (
e.stopPropagation()} style={{width:"100%",maxWidth:480,background:COLORS.bgElev,borderRadius:isD?18:"22px 22px 0 0",padding:22,maxHeight:"88vh",overflowY:"auto",border:isD?`1px solid ${COLORS.hairline}`:"none"}}>
{!isD &&
}
เพิ่มงานใหม่
up("title",e.target.value)} autoFocus/>
up("due",e.target.value)}/>
{canSeeAll
?
:
👤 มอบให้: ตัวคุณเอง
}
setAdv(a=>!a)} style={{fontSize:13,color:COLORS.accentBright,cursor:"pointer",fontWeight:600,padding:"4px 2px",marginBottom:8,userSelect:"none"}}>{adv?"▾":"▸"} รายละเอียดเพิ่มเติม (แบรนด์ · ทำซ้ำ · แท็ก · คำอธิบาย)
{adv && <>
up("brand",e.target.value)}/>
up("tags",e.target.value)} placeholder="แท็ก คั่นด้วย , (ไม่บังคับ)"/>
);
}
// ---- Board (main) ----
// Admin Overview dashboard — match design (KPI · trend · workload · pipeline health · activity)
function Overview({onOpenTask, onPickOwner, onKpi, meId}){
const isDesktop=useIsDesktop();
const [d,setD]=React.useState(null);
React.useEffect(()=>{ api("GET","/api/overview").then(setD).catch(()=>setD({})); },[]);
if(!d) return กำลังโหลดภาพรวม...
;
const k=d.kpi||{}, pipes=d.by_pipeline||[], trend=d.trend||[], wl=d.workload||[], acts=d.activity||[];
const maxW=Math.max(1,...wl.map(w=>w.open||0));
const Card=({title,count,action,children,pad=true})=>(
{title&&
{title}
{count!=null&&
{count}}
{action&&{action}}
}
{children}
);
const KPI=({label,value,unit,tint,sub,accent,kind})=>(
kind&&onKpi&&onKpi(kind)} style={{flex:"1 1 130px",minWidth:118,padding:"15px 17px",background:accent?`linear-gradient(135deg, ${COLORS.accentSoft} 0%, ${COLORS.card} 70%)`:COLORS.card,borderRadius:14,border:`1px solid ${accent?COLORS.accentEdge:COLORS.hairline}`,cursor:kind?"pointer":"default",transition:"border .15s"}}>
{label}{kind&&›}
{value}
{unit&&{unit}}
{sub&&
{sub}
}
);
const Trend=()=>{ const W=600,H=170,mx=Math.max(1,...trend)*1.18;
const pts=trend.map((v,i)=>[(i/Math.max(1,trend.length-1))*(W-30)+15, H-16-(v/mx)*(H-32)]);
const path=pts.map(([x,y],i)=>(i?"L":"M")+x.toFixed(1)+" "+y.toFixed(1)).join(" ");
return
;
};
const ACT={created:["สร้าง","var(--th-st-gray-fg)"],status:["อัปเดต","#5BA0FF"],handoff:["ส่งต่อ","var(--th-st-orange-fg)"],assigned:["มอบงาน","#B47EFF"],add_link:["ลิงก์","#56C8D8"],review:["รีวิว","#3CD680"],update:["แก้ไข","#9B7EFF"]};
const left=<>
{trend.reduce((a,b)=>a+b,0)}
งานเสร็จ · 8 สัปดาห์
{wl.length===0&&—
}
{wl.map((w,i)=>{const pct=Math.round((w.open||0)/maxW*100),bar=w.open>=10?"var(--th-st-red-dot)":w.open>=6?"var(--th-st-orange-fg)":"var(--th-st-green-dot)";
return w.id&&onPickOwner&&onPickOwner(w.id,w.name)} style={{display:"flex",alignItems:"center",gap:12,padding:"8px 0",cursor:w.id?"pointer":"default",borderRadius:8}}>
{w.name}{w.id===meId?" (คุณ)":""}
{w.role}
{w.open}
;})}
>;
const right=<>
{pipes.map((p,i)=>{const tk=pipeTok(p.key); return
{tk.label}
{p.late>0&&LATE {p.late}}
{p.c}OPEN
;})}
{acts.length===0&&ยังไม่มีกิจกรรม
}
{acts.map((a,i)=>{const tk=ACT[a.action]||[a.action,"var(--th-st-gray-fg)"]; return a.task_id&&onOpenTask&&onOpenTask(a.task_id)} style={{display:"flex",gap:10,alignItems:"flex-start",padding:"10px 16px",borderBottom:i
{tk[0]} {fmtAudit(a)}{a.task_title?" · "+a.task_title:""}
{a.by_name||"—"} · {(a.created_at||"").slice(5,16).replace("T"," ")}
;})}
>;
return
;
}
// วันนี้ (เวลาไทย) สำหรับ default due
const bkkToday=()=>{ try{ return new Intl.DateTimeFormat('en-CA',{timeZone:'Asia/Bangkok'}).format(new Date()); }catch(_){ return new Date().toISOString().slice(0,10); } };
// Quick-add ในคอลัมน์ Kanban — พิมพ์ชื่อ → Enter เพิ่มทันที (default: วันนี้/P1/ฉัน/stage นี้) · ▸ กาง Advanced
function ColQuickAdd({stageId,pipelineKey,brand,user,users,canSeeAll,onAdded,onClose}){
const [title,setTitle]=React.useState(""); const [adv,setAdv]=React.useState(false); const [busy,setBusy]=React.useState(false);
const [priority,setPriority]=React.useState("P1"); const [due,setDue]=React.useState(bkkToday());
const [ownerId,setOwnerId]=React.useState(user&&user.id?user.id:""); const [tags,setTags]=React.useState("");
const inRef=React.useRef(null);
React.useEffect(()=>{ if(inRef.current) inRef.current.focus(); },[]);
async function submit(){ const q=parseQuick(title); const t=q.title; if(!t||busy) return; setBusy(true);
try{ await api("POST","/api/tasks",{title:t,pipeline_key:pipelineKey,current_stage_id:stageId,priority:q.priority||priority,due:q.due||due||undefined,owner_id:ownerId||undefined,tags:tags.trim()||undefined,brand:brand||undefined,recurrence:q.recurrence||undefined});
setTitle(""); onAdded&&onAdded(); if(inRef.current) inRef.current.focus(); // เปิดค้าง พิมพ์ต่อได้รัวๆ
}catch(e){ alert(e.message); } finally{ setBusy(false); } }
const inp={width:"100%",padding:"7px 9px",borderRadius:8,background:COLORS.card,border:`1px solid ${COLORS.hairline}`,color:COLORS.ink,fontSize:12.5,marginBottom:6,boxSizing:"border-box"};
const ownerName=canSeeAll?((((users||[]).find(u=>String(u.id)===String(ownerId))||{}).name)||"ไม่มีเจ้าของ"):"ฉัน";
return
setTitle(e.target.value)}
onKeyDown={e=>{ if(e.key==="Enter"){e.preventDefault();submit();} else if(e.key==="Escape"){onClose&&onClose();} }}
placeholder="ชื่องาน… (Enter)" style={inp}/>
setAdv(a=>!a)} style={{fontSize:10.5,color:COLORS.inkMuted,cursor:"pointer",marginBottom:adv?8:6,userSelect:"none"}}>
{adv?"▾":"▸"} {priority} · {due===bkkToday()?"วันนี้":(due||"ไม่กำหนด")} · {ownerName}
{adv &&
setDue(e.target.value)} style={inp}/>
{canSeeAll && }
setTags(e.target.value)} placeholder="แท็ก (คั่นด้วย ,)" style={{...inp,marginBottom:0}}/>
}
;
}
// Kanban board — columns = stages · cards grouped by current_stage_id (สำหรับ pipeline has_stages)
function Kanban({pipelineKey,onOpenTask,canEdit,user,users,canSeeAll}){
const [stages,setStages]=React.useState(null); const [tasks,setTasks]=React.useState([]); const [brand,setBrand]=React.useState("all");
const [dragId,setDragId]=React.useState(null); const [overId,setOverId]=React.useState(null); const [busy,setBusy]=React.useState(false);
const [addCol,setAddCol]=React.useState(null); // stage id ที่เปิด quick-add composer
const [editId,setEditId]=React.useState(null); const [editVal,setEditVal]=React.useState(""); const [dateId,setDateId]=React.useState(null); // #1 inline edit
const isDesktop=useIsDesktop(); // มือถือ: kanban ปัดทีละคอลัมน์ (snap)
const loadTasks=React.useCallback(()=>{ api("GET","/api/tasks?pipeline="+pipelineKey).then(t=>setTasks(t||[])).catch(()=>{}); },[pipelineKey]);
React.useEffect(()=>{
api("GET","/api/stages?pipeline="+pipelineKey).then(setStages).catch(()=>setStages([]));
loadTasks();
},[pipelineKey,loadTasks]);
async function move(taskId,stageId){ const t=tasks.find(x=>x.id===taskId); if(!t||t.current_stage_id===stageId)return;
setBusy(true);
setTasks(ts=>ts.map(x=>x.id===taskId?{...x,current_stage_id:stageId}:x)); // optimistic
try{ await api("PATCH","/api/tasks/"+taskId,{current_stage_id:stageId}); loadTasks(); }
catch(e){ alert(e.message); loadTasks(); } finally{ setBusy(false); } }
async function patchTask(id,body){ setTasks(ts=>ts.map(x=>x.id===id?{...x,...body}:x)); try{ await api("PATCH","/api/tasks/"+id,body); loadTasks(); }catch(e){ alert(e.message); loadTasks(); } }
const cyclePri=(t)=>{ const o=["P0","P1","P2","P3"]; patchTask(t.id,{priority:o[(o.indexOf(t.priority)+1)%4]}); };
if(!stages) return
กำลังโหลด board...
;
if(!stages.length) return
pipeline นี้ไม่มี stage
;
const byStage={}; stages.forEach(s=>byStage[s.id]=[]); const firstId=stages[0].id;
const brands=[...new Set(tasks.map(t=>t.brand).filter(Boolean))];
const ftasks=brand==="all"?tasks:tasks.filter(t=>t.brand===brand);
ftasks.forEach(t=>{ const sid=(t.current_stage_id&&byStage[t.current_stage_id])?t.current_stage_id:firstId; byStage[sid].push(t); });
const SC=["#9B7EFF","#5BA0FF","#56C8D8","var(--th-st-orange-fg)","#F4CC4E","#3CD680","#6EE5A0"];
return
{brands.length>1 &&
{["all",...brands].map(b=>{const on=brand===b; return ;})}
}
{canEdit &&
💡 ลากการ์ดข้ามคอลัมน์เพื่อเลื่อนขั้น
}
{stages.length>4 &&
}
{stages.map((s,i)=>{const items=byStage[s.id]||[],col=SC[i%SC.length],hot=overId===s.id&&canEdit;
return
{e.preventDefault();setOverId(s.id);}):undefined}
onDragLeave={canEdit?(()=>setOverId(o=>o===s.id?null:o)):undefined}
onDrop={canEdit?(()=>{const id=dragId;setOverId(null);setDragId(null);if(id)move(id,s.id);}):undefined}
style={{flex:isDesktop?"1 1 240px":"0 0 88%",minWidth:isDesktop?220:0,maxWidth:isDesktop?340:"none",scrollSnapAlign:isDesktop?undefined:"start",background:hot?"var(--th-hover-accent)":"var(--th-hover-2)",border:`1px solid ${hot?COLORS.accentEdge:COLORS.hairline}`,borderRadius:12,padding:9,display:"flex",flexDirection:"column",gap:8,transition:"background .12s"}}>
{s.name}
{items.length}
{!items.length&&
{canEdit?"ลากการ์ดมาที่นี่":"ว่าง"}
}
{items.map(t=>{const pr=PRIORITY[t.priority]||{},dragging=dragId===t.id,editing=editId===t.id;
let clT=0,clD=0; try{const c=JSON.parse(t.checklist||"[]");clT=c.length;clD=c.filter(x=>x.done).length;}catch(_){}
const saveTitle=()=>{ const v=editVal.trim(); if(v&&v!==t.title) patchTask(t.id,{title:v}); setEditId(null); };
return
{setDragId(t.id);try{e.dataTransfer.effectAllowed="move";}catch(_){}}):undefined}
onDragEnd={canEdit?(()=>{setDragId(null);setOverId(null);}):undefined}
onClick={()=>{ if(!editing) onOpenTask&&onOpenTask(t.id); }}
style={{background:COLORS.card,border:`1px solid ${COLORS.hairline}`,borderRadius:10,padding:"9px 11px",cursor:canEdit?"grab":"pointer",opacity:dragging?0.4:1}}>
{editing
?
e.stopPropagation()} onChange={e=>setEditVal(e.target.value)}
onKeyDown={e=>{ if(e.key==="Enter"){e.preventDefault();saveTitle();} else if(e.key==="Escape"){setEditId(null);} }} onBlur={saveTitle}
style={{width:"100%",fontSize:12.5,fontWeight:600,padding:"3px 5px",borderRadius:6,border:`1px solid ${COLORS.accentEdge}`,background:COLORS.bgElev,color:COLORS.ink,marginBottom:7,boxSizing:"border-box"}}/>
:
{e.stopPropagation();setEditId(t.id);setEditVal(t.title);}):undefined} title={canEdit?"ดับเบิลคลิกแก้ชื่อ":""}
style={{fontSize:12.5,color:COLORS.ink,fontWeight:600,lineHeight:1.35,marginBottom:7}}>{t.title}
}
{e.stopPropagation();cyclePri(t);}):undefined} title={canEdit?"คลิกเปลี่ยนความสำคัญ":""}
style={{fontSize:9,fontWeight:700,color:pr.color||COLORS.inkMuted,fontFamily:MONO_FONT,cursor:canEdit?"pointer":"default"}}>● {t.priority}
{clT>0 &&
☑{clD}/{clT}}
{dateId===t.id
?
e.stopPropagation()} onChange={e=>{ if(e.target.value) patchTask(t.id,{due:e.target.value}); setDateId(null); }} onBlur={()=>setDateId(null)}
style={{fontSize:9,padding:"1px 3px",borderRadius:5,border:`1px solid ${COLORS.accentEdge}`,background:COLORS.bgElev,color:COLORS.ink}}/>
:
{e.stopPropagation();setDateId(t.id);}):undefined} title={canEdit?"คลิกตั้งวันที่":""}
style={{fontSize:9.5,color:COLORS.inkSubtle,cursor:canEdit?"pointer":"default"}}>{t.due?("📅 "+(""+t.due).slice(5)):(canEdit?"+📅":"")}}
{t.owner_name&&{t.owner_name}}
;})}
{canEdit && (addCol===s.id
?
setAddCol(null)}/>
: )}
;})}
;
}
// Sidebar (desktop · admin/head) — Workspace nav + Pipelines + user card (match design)
function Sidebar({user,view,setView,filter,setFilter,pipelines,counts,onLogout,canSeeAll,mine,setMine,reviewRoles,reviewMode,setReviewMode,reviewCount,todayMode,setTodayMode,todayCount,reworkMode,setReworkMode,reworkCount,mineNew,onMineSeen,setOwnerFilter,usersList,onImpersonate,archiveMode,setArchiveMode,trashMode,setTrashMode,canDelete,search,setSearch,onNav}){
const clearRev=()=>{setReviewMode&&setReviewMode(false);setTodayMode&&setTodayMode(false);setReworkMode&&setReworkMode(false);setOwnerFilter&&setOwnerFilter(null);setArchiveMode&&setArchiveMode(false);setTrashMode&&setTrashMode(false);};
const navBadge=(n,c)=>n>0?
{n}:null;
const nav=(active,emoji,label,onClick,trailing)=>(
{onClick&&onClick();onNav&&onNav();}} style={{display:"flex",alignItems:"center",gap:10,padding:"8px 12px",borderRadius:8,margin:"0 10px",cursor:"pointer",fontSize:13,fontWeight:active?700:500,
background:active?COLORS.accentSoft:"transparent",color:active?COLORS.accentBright:COLORS.ink,border:`1px solid ${active?COLORS.accentEdge:"transparent"}`}}>
{emoji}{label}{trailing||null}
);
const sec=(label,children)=>(
);
return
{/* #5 · ค้นหาถาวร (Notion-style) */}
setSearch&&setSearch(e.target.value)} onFocus={()=>setView&&setView("board")}
placeholder="🔍 ค้นหางาน…" style={{width:"100%",padding:"8px 11px",borderRadius:9,background:COLORS.card,border:`1px solid ${search?COLORS.accentEdge:COLORS.hairline}`,color:COLORS.ink,fontSize:12.5,boxSizing:"border-box"}}/>
{sec("มุมมอง",<>
{canSeeAll && nav(view==="overview","📊","Dashboard",()=>{clearRev();setView("overview");})}
{nav(view==="board"&&!!todayMode,"📅",`วันนี้${todayCount?` (${todayCount})`:""}`,()=>{clearRev();setView("board");setFilter("all");setMine&&setMine(false);setTodayMode&&setTodayMode(true);})}
{nav(view==="board"&&filter==="all"&&!mine&&!reviewMode&&!todayMode&&!reworkMode&&!archiveMode&&!trashMode,"📋","งานทั้งหมด",()=>{clearRev();setView("board");setFilter("all");setMine&&setMine(false);})}
{nav(view==="calendar","📆","ปฏิทิน",()=>{clearRev();setView("calendar");setFilter("all");setMine&&setMine(false);})}
{nav(view==="week7","🗓","7 วันข้างหน้า",()=>{clearRev();setView("week7");setFilter("all");setMine&&setMine(false);})}
{nav(view==="availability","📅","ตารางทีม ลา/WFH",()=>{clearRev();setView("availability");})}
{nav(!!mine&&!reviewMode&&!todayMode&&!reworkMode,"🙋","งานของฉัน",()=>{clearRev();setView("board");setMine&&setMine(true);onMineSeen&&onMineSeen();},navBadge(mineNew,"#6EE5A0"))}
>)}
{sec("ต้องจัดการ",<>
{nav(!!reworkMode,"↩️","ตีกลับ ต้องแก้",()=>{clearRev();setView("board");setFilter("all");setMine&&setMine(false);setReworkMode&&setReworkMode(true);},navBadge(reworkCount,"var(--th-st-red-fg)"))}
{reviewRoles && nav(!!reviewMode,"🛡","รอรีวิว",()=>{clearRev();setView("board");setFilter("all");setMine&&setMine(false);setReviewMode&&setReviewMode(true);},navBadge(reviewCount))}
{nav(!!archiveMode,"🗄","คลังงาน",()=>{clearRev();setView("board");setFilter("all");setMine&&setMine(false);setArchiveMode&&setArchiveMode(true);})}
{canDelete && nav(!!trashMode,"🗑","ถังขยะ",()=>{clearRev();setView("board");setFilter("all");setMine&&setMine(false);setTrashMode&&setTrashMode(true);})}
>)}
{/* frontend-split: ตัดลิงก์ CRM (/crm) และ KB (/kb) ออก — โมดูลลับ อยู่คนละ frontend/route */}
{(["admin","exec","superadmin"].includes(user.role)) && sec("ทีม",<>
{["admin","exec","superadmin"].includes(user.role) && nav(view==="activity","🟢","ใครออนไลน์ · Activity",()=>{clearRev();setView("activity");})}
>)}
{sec(canSeeAll?"Pipelines":"สายงานของฉัน",(pipelines||[]).map(p=>{const tk=pipeTok(p.key),active=view==="board"&&filter===p.key;
return
{clearRev();setView("board");setFilter(p.key);onNav&&onNav();}} style={{display:"flex",alignItems:"center",gap:10,padding:"7px 12px",borderRadius:8,margin:"0 10px",cursor:"pointer",fontSize:12.5,fontWeight:active?700:500,color:active?COLORS.inkBright:COLORS.ink,background:active?"var(--th-hover-soft)":"transparent",border:`1px solid ${active?COLORS.hairline:"transparent"}`}}>
{p.name||tk.label}
{counts[p.key]||0}
;}))}
{["superadmin","admin"].includes(user.role) && (usersList||[]).length>0 && sec("👁 ดูในมุมมองของ",
เห็น+ใช้งานเสมือนเป็นเขา
)}
{(user.name||"?")[0]}
{user.name}
{(user.role||"").toUpperCase()}
⏻
;
}
// จอ ≥1024px (MacBook/desktop) → master-detail · เล็กกว่า → mobile modal
function useIsDesktop(){
const [d,setD]=useState(typeof window!=="undefined" && window.matchMedia("(min-width:1024px)").matches);
useEffect(()=>{ const m=window.matchMedia("(min-width:1024px)"); const h=e=>setD(e.matches);
m.addEventListener?.("change",h); return ()=>m.removeEventListener?.("change",h); },[]);
return d;
}
// ปฏิทินรายเดือน — งานเรียงตาม due · คลิกการ์ดเปิดงาน
function CalendarView({tasks,onOpenTask,onAddDay,onReschedule}){
const today=new Date(); today.setHours(0,0,0,0);
const [ym,setYm]=useState({y:today.getFullYear(),m:today.getMonth()});
const [dragId,setDragId]=useState(null); const [overK,setOverK]=useState(null);
const fmt=(y,m,d)=>`${y}-${String(m+1).padStart(2,"0")}-${String(d).padStart(2,"0")}`;
const startDow=new Date(ym.y,ym.m,1).getDay();
const daysInMonth=new Date(ym.y,ym.m+1,0).getDate();
const byDue={}; (tasks||[]).forEach(t=>{ if(t.due){ const k=(""+t.due).slice(0,10); (byDue[k]=byDue[k]||[]).push(t); } });
const cells=[]; for(let i=0;i
setYm(s=>{const m=s.m-1;return m<0?{y:s.y-1,m:11}:{y:s.y,m};});
const goNext=()=>setYm(s=>{const m=s.m+1;return m>11?{y:s.y+1,m:0}:{y:s.y,m};});
const goToday=()=>setYm({y:today.getFullYear(),m:today.getMonth()});
// มือถือ: month grid 7 คอลัมน์อ่านไม่ออก → Agenda รายวัน (คุณเอก 23 มิ.ย.)
if(!isDesktop){
const aRow=(t)=>{const p=pipeTok(t.pipeline_key);const done=DONE_STATES.includes(t.status);return onOpenTask&&onOpenTask(t)} style={{display:"flex",alignItems:"center",gap:9,padding:"10px 12px",borderRadius:10,background:COLORS.card,border:`1px solid ${COLORS.hairline}`,cursor:"pointer",marginBottom:7}}>{t.recurrence?"🔁 ":""}{t.title}{t.priority&&{t.priority}}
;};
const monthDays=[]; for(let dd=1;dd<=daysInMonth;dd++){const k=fmt(ym.y,ym.m,dd);if(byDue[k]&&byDue[k].length)monthDays.push({d:dd,k,items:byDue[k]});}
const overdue=(tasks||[]).filter(t=>t.due&&(""+t.due).slice(0,10)(""+a.due).localeCompare(""+b.due));
return
{THMON[ym.m]} {ym.y+543}
{overdue.length>0&&
🔴 เลยกำหนด ({overdue.length})
{overdue.map(aRow)}
}
{monthDays.length===0&&
ไม่มีงานในเดือนนี้
}
{monthDays.map(({d,k,items})=>{const dt=new Date(ym.y,ym.m,d);const isToday=k===todayStr;return
{DOW[dt.getDay()]} {d} {THMON[ym.m]}{isToday?" · วันนี้":""}
{onAddDay&&}
{items.map(aRow)}
;})}
;
}
return
{THMON[ym.m]} {ym.y+543}
{DOW.map(d=>
{d}
)}
{cells.map((d,i)=>{ if(d==null)return
; const k=fmt(ym.y,ym.m,d); const isToday=k===todayStr; const items=byDue[k]||[]; const hot=overK===k&&dragId;
return
{e.preventDefault();setOverK(k);}):undefined}
onDragLeave={onReschedule?(()=>setOverK(o=>o===k?null:o)):undefined}
onDrop={onReschedule?(()=>{const id=dragId;setOverK(null);setDragId(null);if(id)onReschedule(id,k);}):undefined}
style={{minHeight:94,background:hot?"var(--th-hover-accent-3)":(isToday?COLORS.accentSoft:"var(--th-hover-2)"),border:`1px solid ${hot?COLORS.accentBright:(isToday?COLORS.accentEdge:COLORS.hairline)}`,borderRadius:10,padding:6,display:"flex",flexDirection:"column",gap:3,overflow:"hidden",transition:"background .12s"}}>
{d}
{onAddDay&&
}
{items.slice(0,4).map(t=>{const p=pipeTok(t.pipeline_key);const done=DONE_STATES.includes(t.status);return
onOpenTask&&onOpenTask(t)} title={t.title}
draggable={!!onReschedule}
onDragStart={onReschedule?(e=>{setDragId(t.id);try{e.dataTransfer.effectAllowed="move";}catch(_){}}):undefined}
onDragEnd={onReschedule?(()=>{setDragId(null);setOverK(null);}):undefined}
style={{fontSize:10.5,padding:"2px 5px",borderRadius:5,background:p.soft,color:done?COLORS.inkSubtle:p.color,cursor:onReschedule?"grab":"pointer",whiteSpace:"nowrap",overflow:"hidden",textOverflow:"ellipsis",textDecoration:done?"line-through":"none",fontWeight:600,opacity:dragId===t.id?0.4:1}}>{t.recurrence?"🔁 ":""}{t.title}
;})}
{items.length>4&&
+{items.length-4} เพิ่มเติม
}
;})}
;
}
// #5 · 7 วันข้างหน้า — 7 คอลัมน์ (วันนี้ + อีก 6 วัน) · งานตาม due แต่ละวัน
function Week7View({tasks,onOpenTask,canEdit,onAddDay,onReschedule}){
const [dragId,setDragId]=React.useState(null); const [overIso,setOverIso]=React.useState(null);
const base=bkkToday().split('-').map(Number); const todayIso=bkkToday(); const DOW=["อา","จ","อ","พ","พฤ","ศ","ส"];
const days=[]; for(let i=0;i<7;i++){ const d=new Date(Date.UTC(base[0],base[1]-1,base[2])); d.setUTCDate(d.getUTCDate()+i);
days.push({iso:d.toISOString().slice(0,10),dow:DOW[d.getUTCDay()],dnum:d.getUTCDate(),mon:THMON[d.getUTCMonth()]}); }
const byDue={}; (tasks||[]).forEach(t=>{ if(t.due){ const k=(""+t.due).slice(0,10); (byDue[k]=byDue[k]||[]).push(t); } });
const PR={P0:0,P1:1,P2:2,P3:3};
const isDesktop=useIsDesktop(); // มือถือ: เรียงแนวตั้ง · เดสก์ท็อป: แนวนอนยืดเต็ม
return
🗓 7 วันข้างหน้า · งานตามวันกำหนด
{days.map(day=>{ const isToday=day.iso===todayIso; const items=(byDue[day.iso]||[]).slice().sort((a,b)=>(PR[a.priority]??9)-(PR[b.priority]??9)); const hot=overIso===day.iso&&dragId;
return
{e.preventDefault();setOverIso(day.iso);}):undefined}
onDragLeave={onReschedule?(()=>setOverIso(o=>o===day.iso?null:o)):undefined}
onDrop={onReschedule?(()=>{const id=dragId;setOverIso(null);setDragId(null);if(id)onReschedule(id,day.iso);}):undefined}
style={{flex:isDesktop?"1 1 180px":"1 1 auto",minWidth:isDesktop?160:0,maxWidth:isDesktop?300:"none",width:isDesktop?"auto":"100%",background:hot?"var(--th-hover-accent-2)":(isToday?COLORS.accentSoft:"var(--th-hover-2)"),border:`1px solid ${hot?COLORS.accentBright:(isToday?COLORS.accentEdge:COLORS.hairline)}`,borderRadius:12,padding:9,display:"flex",flexDirection:"column",gap:7,transition:"background .12s"}}>
{isToday?"วันนี้":day.dow} {day.dnum} {day.mon}
{items.length}
{items.length===0&&
ว่าง
}
{items.map(t=>{const p=pipeTok(t.pipeline_key),pr=PRIORITY[t.priority]||{},done=DONE_STATES.includes(t.status);
return
onOpenTask&&onOpenTask(t.id)}
draggable={!!onReschedule}
onDragStart={onReschedule?(e=>{setDragId(t.id);try{e.dataTransfer.effectAllowed="move";}catch(_){}}):undefined}
onDragEnd={onReschedule?(()=>{setDragId(null);setOverIso(null);}):undefined}
style={{background:COLORS.card,border:`1px solid ${COLORS.hairline}`,borderRadius:9,padding:"7px 9px",cursor:onReschedule?"grab":"pointer",opacity:done?0.55:(dragId===t.id?0.4:1)}}>
{t.recurrence?"🔁 ":""}{t.title}
● {t.priority}
{t.owner_name&&{t.owner_name}}
;})}
{canEdit&&onAddDay&&
}
;})}
;
}
// ถังขยะ — งานที่ลบ (soft) · กู้คืน / ลบถาวร · auto-purge 30 วัน
function TrashView({list, onRestore, onPurge}){
if(list===null) return
กำลังโหลดถังขยะ...
;
return
🗑 ถังขยะ · {list.length} งาน · ลบถาวรอัตโนมัติหลัง 30 วัน
{list.length===0 &&
ถังขยะว่าง
}
{list.map(t=>{ const p=pipeTok(t.pipeline_key);
return
{t.title}
{p.label} · ลบเมื่อ {(t.deleted_at||"").slice(5,16).replace("T"," ")} · เหลือ {t.days_left} วัน
; })}
;
}
// ---- theme toggle (🌙/☀️) · own state so it re-renders its icon · CSS vars handle the rest ----
function ThemeToggle({compact}){
const [theme,setTh]=useState(getTheme());
const flip=()=>{ const v=toggleTheme(); setTh(v); };
const light=theme==="light";
return
;
}
function Board({user, onLogout}){
const [tasks,setTasks]=useState([]); const [stats,setStats]=useState(null);
const [pipelines,setPipelines]=useState([]); const [users,setUsers]=useState([]);
const [filter,setFilter]=useState("all"); const [mine,setMine]=useState(false); const [view,setView]=useState(()=>{try{return new URLSearchParams(location.search).get("view")==="availability"?"availability":"board";}catch(_){return "board";}});
const [search,setSearch]=useState(""); const [reviewMode,setReviewMode]=useState(false);
const [todayMode,setTodayMode]=useState(false); const [sortBy,setSortBy]=useState("smart");
const [reworkMode,setReworkMode]=useState(false); const [mineNew,setMineNew]=useState(0);
const [archiveMode,setArchiveMode]=useState(false); const [trashMode,setTrashMode]=useState(false); const [trashList,setTrashList]=useState(null);
const [sel,setSel]=useState(null); const [creating,setCreating]=useState(false); const [loading,setLoading]=useState(true); const [pwOpen,setPwOpen]=useState(false);
const [createDue,setCreateDue]=useState(""); // ปฏิทินคลิกวันที่ → prefill due
const [drawerOpen,setDrawerOpen]=useState(false); // #1 mobile sidebar drawer
const [cursor,setCursor]=useState(-1); const searchRef=React.useRef(null);
const shownRef=React.useRef([]); const cursorRef=React.useRef(-1); const kbActions=React.useRef({});
const [selMode,setSelMode]=useState(false); const [picked,setPicked]=useState(()=>new Set());
const [ownerFilter,setOwnerFilter]=useState(null); const [tagFilter,setTagFilter]=useState("");
const canSeeAll=["head_content","pm","admin","service","exec","superadmin"].includes(user.role);
const canEdit=user.role!=="pm";
async function toggleDone(t){ const done=DONE_STATES.includes(t.status); const next=done?"progress":"done"; const prev=t.status;
setTasks(ts=>ts.map(x=>x.id===t.id?{...x,status:next}:x)); // optimistic
setSel(s=>(s&&s.id===t.id)?{...s,status:next}:s);
try{ await api("PATCH","/api/tasks/"+t.id,{status:next});
if(!done) toast.ok(`ทำเสร็จ · ${(""+t.title).slice(0,30)}`,{actionLabel:"เลิกทำ",action:()=>{
setTasks(ts=>ts.map(x=>x.id===t.id?{...x,status:prev}:x)); setSel(s=>(s&&s.id===t.id)?{...s,status:prev}:s);
api("PATCH","/api/tasks/"+t.id,{status:prev}).then(load).catch(()=>{}); }});
load();
}catch(e){ setTasks(ts=>ts.map(x=>x.id===t.id?{...x,status:prev}:x)); setSel(s=>(s&&s.id===t.id)?{...s,status:prev}:s); toast.error(e.message); } }
async function quickAdd(text){ const {title,priority,due,recurrence}=parseQuick(text);
let pk = (filter!=="all" && filter) || user.pipeline_key || (visiblePipes[0]&&visiblePipes[0].key);
if(!title||!pk) return;
try{ await api("POST","/api/tasks",{title,pipeline_key:pk,priority:priority||"P2",due,recurrence:recurrence||null,status:"idea"}); load(); }catch(e){ toast.error(e.message); } }
async function impersonate(uid){
const tgt=(users||[]).find(u=>u.id===uid)||{};
if(!await confirmAsk({title:"สวมบทเป็นคนนี้?",message:`คุณจะเข้าสู่มุมมองของ ${tgt.name||"ผู้ใช้ #"+uid}${tgt.role?` (${tgt.role})`:""}\n\nทุกการแก้/ลบ/ย้าย จะทำในนามนี้ และถูกบันทึก audit`,confirmLabel:"สวมบท"})) return;
try{ const r=await api("POST","/api/impersonate",{user_id:uid});
localStorage.setItem("th_real_token", token()); localStorage.setItem("th_real_user", JSON.stringify(user));
setToken(r.token); setUser(r.user); location.reload();
}catch(e){ toast.error(e.message); } }
const loadTrash=useCallback(()=>{ api("GET","/api/trash").then(setTrashList).catch(()=>setTrashList([])); },[]);
useEffect(()=>{ if(trashMode) loadTrash(); },[trashMode,loadTrash]);
async function restoreTask(id){ try{ await api("POST","/api/tasks/"+id+"/restore"); toast.ok("กู้คืนงานแล้ว"); loadTrash(); load(); }catch(e){ toast.error(e.message); } }
async function purgeTask(id){
if(!await gate({title:"ลบถาวร?",body:"งานจะถูกลบอย่างถาวร · กู้คืนไม่ได้อีก",danger:true,requireText:"ลบถาวร",confirmLabel:"ลบถาวร"})) return;
try{ await api("DELETE","/api/trash/"+id); toast.ok("ลบถาวรแล้ว"); loadTrash(); }catch(e){ toast.error(e.message); } }
const togglePick=(t)=>setPicked(s=>{const n=new Set(s); n.has(t.id)?n.delete(t.id):n.add(t.id); return n;});
const exitSel=()=>{ setSelMode(false); setPicked(new Set()); };
async function bulkPatch(body,label){ const ids=[...picked]; if(!ids.length){ exitSel(); return; }
if(isImp() && !await gate({title:`${label} ${ids.length} งาน?`,body:`ดำเนินการกับ ${ids.length} งานพร้อมกัน`})) return; // สวมบท → ยืนยันก่อน bulk
let ok=0,fail=0; for(const id of ids){ try{ await api("PATCH","/api/tasks/"+id,body); ok++; }catch(e){ fail++; } }
exitSel(); await load(); (fail?toast.error:toast.ok)(`${label} ${ok} งาน${fail?` · พลาด ${fail}`:""}`); }
function bulkReschedule(n){ const d=new Date(); d.setHours(0,0,0,0); d.setDate(d.getDate()+n);
bulkPatch({due:`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`},"เลื่อนกำหนด"); }
const isDesktop=useIsDesktop();
const load=useCallback(async(silent)=>{
if(!silent) setLoading(true);
const q=new URLSearchParams();
if(filter!=="all") q.set("pipeline",filter);
if(mine) q.set("mine","true");
if(ownerFilter) q.set("owner_id",ownerFilter);
try{
const [t,s,p]=await Promise.all([
api("GET","/api/tasks?"+q.toString()),
api("GET","/api/stats").catch(()=>null),
api("GET","/api/pipelines").catch(()=>[]),
]);
setTasks(t||[]); setStats(s); setPipelines(p||[]);
if(canSeeAll){ api("GET","/api/users").then(u=>setUsers(u||[])).catch(()=>{}); }
if(user.id){ api("GET","/api/tasks?mine=true").then(mt=>{ const seen=+(localStorage.getItem("th_seen_max")||0); setMineNew((mt||[]).filter(x=>!TERMINAL_ST.includes(x.status)&&x.id>seen).length); }).catch(()=>{}); }
}catch(e){
if(!silent) toast.error("โหลดงานไม่สำเร็จ · ลองรีเฟรชอีกครั้ง");
}finally{
setLoading(false);
}
},[filter,mine,canSeeAll,user.id,ownerFilter]);
useEffect(()=>{ load(); },[load]);
// live refresh เงียบ ๆ ทุก 30 วิ + ตอนกลับมาโฟกัสแท็บ (กันข้อมูลค้างเมื่อหลายคนแก้พร้อมกัน)
useEffect(()=>{ const iv=setInterval(()=>{ if(!document.hidden) load(true); },30000);
const onFocus=()=>{ if(!document.hidden) load(true); }; window.addEventListener("focus",onFocus);
return ()=>{ clearInterval(iv); window.removeEventListener("focus",onFocus); }; },[load]);
const markMineSeen=useCallback(()=>{ api("GET","/api/tasks?mine=true").then(mt=>{ const mx=Math.max(0,...((mt||[]).map(x=>x.id))); localStorage.setItem("th_seen_max",String(mx)); setMineNew(0); }).catch(()=>setMineNew(0)); },[]);
// deep-link: /app/#
→ เปิดงานนั้นตรง · sync 2 ทาง (hash ↔ sel)
useEffect(()=>{
function fromHash(){ const m=(location.hash||"").match(/#(\d+)/); if(m){ const t=(tasks||[]).find(x=>x.id===+m[1]); if(t) setSel(t); } }
fromHash(); window.addEventListener("hashchange",fromHash);
return ()=>window.removeEventListener("hashchange",fromHash);
},[tasks]);
// หลัง load/poll → resync งานที่เลือกอยู่ให้เป็นข้อมูลล่าสุด (กัน detail ค้าง)
useEffect(()=>{ if(sel){ const f=(tasks||[]).find(x=>x.id===sel.id); if(f&&f!==sel) setSel(f); } },[tasks]);
// keyboard shortcuts (desktop) · / ค้นหา · c/q เพิ่มงาน · j/k เลื่อน · Enter เปิด · x ติ๊กเสร็จ
useEffect(()=>{
function onKey(e){
const tag=(e.target.tagName||"").toLowerCase();
if(tag==="input"||tag==="textarea"||tag==="select"||e.metaKey||e.ctrlKey||e.altKey) return;
const list=shownRef.current||[], A=kbActions.current;
if(e.key==="/"){ e.preventDefault(); searchRef.current&&searchRef.current.focus(); }
else if(e.key==="c"||e.key==="q"){ if(A.canEdit){ e.preventDefault(); A.setCreating(true); } }
else if(e.key==="j"||e.key==="ArrowDown"){ e.preventDefault(); setCursor(c=>Math.min((c<0?-1:c)+1,list.length-1)); }
else if(e.key==="k"||e.key==="ArrowUp"){ e.preventDefault(); setCursor(c=>Math.max((c<=0?0:c)-1,0)); }
else if(e.key==="Enter"){ const t=list[cursorRef.current]; if(t){ e.preventDefault(); A.openTask(t); } }
else if(e.key==="x"){ const t=list[cursorRef.current]; if(t&&A.canEdit){ e.preventDefault(); A.toggleDone(t); } }
}
window.addEventListener("keydown",onKey); return ()=>window.removeEventListener("keydown",onKey);
},[]);
useEffect(()=>{ cursorRef.current=cursor; if(cursor>=0){ const t=shownRef.current[cursor]; if(t){ const el=document.querySelector(`[data-tid="${t.id}"]`); el&&el.scrollIntoView&&el.scrollIntoView({block:"nearest"}); } } },[cursor]);
const openTask=(t)=>{ setSel(t); try{ history.replaceState(null,"",location.pathname+"#"+t.id); }catch(_){} };
const closeTask=()=>{ setSel(null); try{ history.replaceState(null,"",location.pathname); }catch(_){} };
const openTaskById=(id)=>{ setView("board"); try{ location.hash="#"+id; }catch(_){} };
const visiblePipes = canSeeAll ? pipelines : pipelines.filter(p=>p.key===user.pipeline_key);
const showSidebar=isDesktop; // member ก็มี sidebar (เนื้อหาปรับตาม role)
const curPipe=pipelines.find(p=>p.key===filter); const showKanban=view==="board"&&curPipe&&curPipe.has_stages;
const reviewRoles=["head_content","admin","superadmin"].includes(user.role);
const reviewCount=(stats&&stats.by_status&&((stats.by_status.submitted||0)+(stats.by_status.review||0)))||0;
const reworkCount=(stats&&stats.by_status&&(stats.by_status.returned||0))||0;
const todayStr=(()=>{const d=new Date();return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;})();
const todayCount=tasks.filter(t=>!TERMINAL_ST.includes(t.status)&&t.due&&t.due<=todayStr).length;
let shown=tasks;
if(reviewMode) shown=shown.filter(t=>["submitted","review"].includes(t.status));
else if(reworkMode) shown=shown.filter(t=>t.status==="returned");
else if(archiveMode) shown=shown.filter(t=>TERMINAL_ST.includes(t.status)); // คลังงาน: เสร็จ+ยกเลิก
else if(todayMode) shown=shown.filter(t=>!TERMINAL_ST.includes(t.status)&&t.due&&t.due<=todayStr);
else shown=shown.filter(t=>!TERMINAL_ST.includes(t.status)); // บอร์ดปกติ: ซ่อนงานปิดแล้ว → ดูใน "คลังงาน"
if(search.trim()) shown=shown.filter(t=>(t.title||"").toLowerCase().includes(search.trim().toLowerCase()));
if(tagFilter) shown=shown.filter(t=>(t.tags||"").split(",").map(s=>s.trim()).includes(tagFilter));
// sort
const PRANK={P0:0,P1:1,P2:2,P3:3};
const byDue=(a,b)=>(a.due||"9999").localeCompare(b.due||"9999");
const byPrio=(a,b)=>(PRANK[a.priority]??9)-(PRANK[b.priority]??9);
shown=[...shown].sort(
sortBy==="due"?byDue:
sortBy==="priority"?((a,b)=>byPrio(a,b)||byDue(a,b)):
sortBy==="updated"?((a,b)=>(b.updated_at||"").localeCompare(a.updated_at||"")):
/*smart*/((a,b)=>{const ao=!TERMINAL_ST.includes(a.status),bo=!TERMINAL_ST.includes(b.status); if(ao!==bo)return ao?-1:1; const ad=a.due&&a.due<=todayStr,bd=b.due&&b.due<=todayStr; if(ad!==bd)return ad?-1:1; return byPrio(a,b)||byDue(a,b);})
);
shownRef.current=shown; kbActions.current={openTask,toggleDone,setCreating,canEdit};
const bbtn={padding:"6px 12px",borderRadius:999,fontSize:12.5,fontWeight:700,cursor:"pointer",background:"transparent",color:COLORS.inkMuted,border:`1px solid ${COLORS.hairline}`,whiteSpace:"nowrap"};
const bsel={padding:"6px 10px",borderRadius:999,fontSize:12.5,background:COLORS.card,color:COLORS.inkMuted,border:`1px solid ${COLORS.hairline}`,cursor:"pointer"};
return (
{showSidebar &&
}
{!showSidebar && drawerOpen && setDrawerOpen(false)} style={{position:"fixed",inset:0,background:"var(--th-overlay-soft)",zIndex:300}}>
e.stopPropagation()} style={{position:"fixed",left:0,top:0,bottom:0,zIndex:301,boxShadow:"var(--th-shadow-drawer)"}}>
setDrawerOpen(false)}/>
}
{/* header */}
{!showSidebar &&
}
NEWVERSE
สวัสดี {user.name}
{stats && !showSidebar &&
{Object.entries(stats.by_status).slice(0,3).map(([k,v])=>)}
}
{/* view toggle (admin/head · ซ่อนเมื่อมี sidebar) */}
{canSeeAll && !showSidebar &&
{[["board","📋 งาน"],["overview","📊 ภาพรวม"]].map(([v,l])=>)}
}
{canSeeAll && view==="overview" &&
{ setOwnerFilter(id); setView("board"); setFilter("all"); setMine(false); setReviewMode(false); setTodayMode(false); setReworkMode(false); }}
onKpi={(kind)=>{ setView("board"); setReviewMode(false); setReworkMode(false); setOwnerFilter(null); setTagFilter(""); setMine(false); setFilter("all"); setTodayMode(kind==="overdue"); }}/>}
{view==="calendar" && {setCreateDue(k);setCreating(true);}):undefined} onReschedule={canEdit?((id,iso)=>api("PATCH","/api/tasks/"+id,{due:iso}).then(load).catch(e=>alert(e.message))):undefined}/>}
{view==="week7" && {setCreateDue(k);setCreating(true);}):undefined} onReschedule={canEdit?((id,iso)=>api("PATCH","/api/tasks/"+id,{due:iso}).then(load).catch(e=>alert(e.message))):undefined}/>}
{view==="activity" && }
{view==="availability" && }
{view==="board" && <>
{/* filters */}
{/* nav chips เฉพาะตอนไม่มี sidebar (mobile) · desktop ใช้ sidebar แทน */}
{!showSidebar && <>
{canSeeAll &&
setFilter("all")}>ทั้งหมด}
{visiblePipes.map(p=>{ const tk=pipeTok(p.key); return
setFilter(p.key)}>{p.name||tk.label}; })}
{user.id &&
setMine(m=>!m)}>งานฉัน}
>}
{canEdit && !showKanban &&
}
{!showKanban &&
}
🔍
setSearch(e.target.value)} placeholder="ค้นหางาน... ( / )" style={{flex:1,minWidth:0,background:"transparent",border:"none",outline:"none",color:COLORS.ink,fontSize:13}}/>
{search && }
{/* board content: kanban (pipeline มี stages) หรือ list + master-detail */}
{showKanban ?
: trashMode ? : (
{canEdit && !reviewMode && !reworkMode && !archiveMode &&
}
{loading ? กำลังโหลด...
: shown.length===0 ? {reviewMode?"✅ ไม่มีงานรอรีวิว":reworkMode?"✅ ไม่มีงานตีกลับ":archiveMode?"🗄 ยังไม่มีงานในคลัง":todayMode?"🎉 วันนี้เคลียร์หมดแล้ว":search?`ไม่พบงานที่ตรงกับ "${search}"`:"ยังไม่มีงาน · พิมพ์เพิ่มด้านบนได้เลย"}
: <>{ownerFilter && 👤 งานของ {((users||[]).find(u=>String(u.id)===String(ownerFilter))||{}).name||"คนนี้"} · {shown.length}
}
{reviewMode &&
🛡 รอรีวิว · {shown.length} งาน
}
{reworkMode &&
↩️ ตีกลับ ต้องแก้ · {shown.length} งาน
}
{tagFilter &&
🏷 #{tagFilter} · {shown.length}
}
{todayMode &&
📅 วันนี้ + เลยกำหนด · {shown.length} งาน
}
{archiveMode &&
🗄 คลังงาน · เสร็จ+ยกเลิก · {shown.length} งาน
}
{shown.map(t=>
openTask(t)} onToggleDone={canEdit?toggleDone:undefined} focused={cursor>=0&&shown[cursor]&&shown[cursor].id===t.id} selMode={selMode} picked={picked.has(t.id)} onPick={togglePick} onTagClick={setTagFilter} onPatch={canEdit?((tk,body)=>api("PATCH","/api/tasks/"+tk.id,body).then(load).catch(e=>alert(e.message))):undefined}/>)}>}
{isDesktop &&
{sel ?
: ← เลือกงานทางซ้ายเพื่อดูรายละเอียด
}
}
)}
>}
{/* bulk action bar */}
{selMode &&
เลือก {picked.size}
{canSeeAll && }
}
{/* impersonate banner (สวมบทคนอื่น) · ค้างล่างจอ กันลืม */}
{isImp() &&
👁 กำลังดูในมุมมองของ {user.name} ({user.role}) — ทุกการกระทำทำในนามนี้
}
{/* FAB */}
{canEdit && !selMode && view!=="activity" && }
{sel && (!isDesktop || showKanban || view==="calendar") && }
{creating && {setCreating(false);setCreateDue("");}} onCreated={load}/>}
{pwOpen && setPwOpen(false)}/>}
);
}
function Stat({n,label,warn}){ return 0?"var(--th-st-red-fg)":COLORS.ink,fontFamily:NUM_FONT}}>{n}
{label}
; }
function Chip({children,on,onClick,color}){ return ; }
// ---- Activity & Presence (เห็นเฉพาะ superadmin/exec/admin · service เข้าทาง API) ----
function _utcDate(s){ if(!s) return null; const d=new Date(String(s).replace(" ","T")+"Z"); return isNaN(d.getTime())?null:d; }
function _agoTH(s){ const d=_utcDate(s); if(!d) return "—"; const sec=Math.max(0,(Date.now()-d.getTime())/1000);
if(sec<60) return "เมื่อกี้"; const m=Math.floor(sec/60); if(m<60) return m+" นาทีที่แล้ว";
const h=Math.floor(m/60); if(h<24) return h+" ชม.ก่อน"; return Math.floor(h/24)+" วันก่อน"; }
function _hhmmTH(s){ const d=_utcDate(s); if(!d) return ""; return d.toLocaleTimeString("th-TH",{hour:"2-digit",minute:"2-digit",hour12:false}); }
function _dmyhmTH(s){ const d=_utcDate(s); if(!d) return ""; return d.toLocaleString("th-TH",{day:"2-digit",month:"2-digit",hour:"2-digit",minute:"2-digit",hour12:false}); }
const _ACT_TH={login:"เข้าระบบ",logout:"ออกจากระบบ",created:"สร้างงาน",status:"เปลี่ยนสถานะ",update:"แก้ไขงาน",
handoff:"ส่งต่อลงเพจ",review:"รีวิว",approved:"อนุมัติ",returned:"ตีกลับ",published:"เผยแพร่",
focus_start:"เริ่มโฟกัส",focus_done:"โฟกัสเสร็จ",comment:"คอมเมนต์",assigned:"มอบหมาย",deleted:"ลบงาน",restored:"กู้คืน",
email_add:"เพิ่มอีเมล",email_remove:"ลบอีเมล",gdoc_created:"สร้าง Google Doc",snooze:"เลื่อน"};
const _SRC_TH={session:"เข้าระบบ",task:"งาน",member:"จัดการสมาชิก"};
const _ALLLOG_ACTIONS=[["login","เข้าระบบ"],["logout","ออกจากระบบ"],["created","สร้างงาน"],["update","แก้ไข"],["handoff","ส่งต่อลงเพจ"],["review","รีวิว"],["focus_start","เริ่มโฟกัส"],["focus_done","โฟกัสเสร็จ"],["comment","คอมเมนต์"],["add_link","เพิ่มลิงก์"],["attach","แนบไฟล์"],["email_add","เพิ่มอีเมล"],["email_remove","ลบอีเมล"]];
function _downloadCSV(filename, rows){
const esc=v=>{ const s=(v==null?"":String(v)); return /[",\n]/.test(s)?'"'+s.replace(/"/g,'""')+'"':s; };
const body=rows.map(r=>r.map(esc).join(",")).join("\r\n");
const blob=new Blob([""+body],{type:"text/csv;charset=utf-8;"});
const a=document.createElement("a"); a.href=URL.createObjectURL(blob); a.download=filename;
document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(a.href);
}
function PresenceDot({name,online,size=32}){
const ch=((name||"?").trim()[0])||"?";
return ;
}
// ===== Team Weekly Availability (ลา/WFH/เข้าออฟฟิศ · task #34) =====
const _AVL={office:{e:"🟢",l:"เข้าออฟฟิศ"},wfh:{e:"🔵",l:"WFH"},leave:{e:"⚪",l:"ลา"}};
const _AVLSC={office:["var(--th-st-green-fg)","var(--th-st-green-bg)","var(--th-st-green-dot)"],
wfh:["var(--th-st-blue-fg)","var(--th-st-blue-bg)","var(--th-st-blue-dot)"],
leave:["var(--th-st-gray-fg)","var(--th-st-gray-bg)","var(--th-st-gray-dot)"]};
const _THMON=["ม.ค.","ก.พ.","มี.ค.","เม.ย.","พ.ค.","มิ.ย.","ก.ค.","ส.ค.","ก.ย.","ต.ค.","พ.ย.","ธ.ค."];
const _avlYmd=x=>x.getFullYear()+"-"+String(x.getMonth()+1).padStart(2,"0")+"-"+String(x.getDate()).padStart(2,"0");
const _avlMonNow=()=>{const x=new Date();x.setDate(x.getDate()-((x.getDay()+6)%7));return _avlYmd(x);};
const _avlShift=(m,n)=>{const x=new Date(m+"T00:00:00");x.setDate(x.getDate()+n);return _avlYmd(x);};
const _avlDnum=d=>parseInt(d.slice(8),10);
const _avlMon=d=>_THMON[new Date(d+"T00:00:00").getMonth()];
function AvailStats(){
const [range,setRange]=React.useState({from:"",to:""});
const [data,setData]=React.useState(null); const [err,setErr]=React.useState("");
const load=React.useCallback(()=>{ setErr(""); const p=new URLSearchParams();
if(range.from)p.set("date_from",range.from); if(range.to)p.set("date_to",range.to);
api("GET","/api/availability/stats"+(p.toString()?"?"+p.toString():"")).then(setData).catch(e=>setErr((e&&e.message)||"โหลดไม่ได้"));
},[range]);
React.useEffect(()=>{ load(); },[load]);
const inp={padding:"7px 10px",borderRadius:9,background:COLORS.card,border:`1px solid ${COLORS.hairline}`,color:COLORS.ink,fontSize:12.5,fontFamily:"inherit"};
return
ช่วง
setRange(r=>({...r,from:e.target.value}))} style={inp}/>
→
setRange(r=>({...r,to:e.target.value}))} style={inp}/>
{(range.from||range.to)&&}
{data&&{data.from} – {data.to}}
{err?
{err}
:!data?
กำลังโหลด…
:
{["ทีม","🟢 เข้าออฟฟิศ","🔵 WFH","⚪ ลา"].map((h,i)=>
| {h} | )}
{data.users.map(u=>
| {u.name} |
{["office","wfh","leave"].map(k=>{u[k]} | )}
)}
นับเฉพาะวันที่ลงสถานะ · วันที่ไม่ได้ลง = ถือว่าเข้าออฟฟิศ (ไม่นับ)
}
;
}
function AvailabilityView({me, users}){
const DOW=["จ","อ","พ","พฤ","ศ","ส","อา"], DOWLONG=["จันทร์","อังคาร","พุธ","พฤหัสบดี","ศุกร์","เสาร์","อาทิตย์"];
const NEXT={office:"wfh",wfh:"leave",leave:"office"};
const [week,setWeek]=React.useState(_avlMonNow());
const [data,setData]=React.useState(null); const [err,setErr]=React.useState(""); const [busy,setBusy]=React.useState(false);
const [tab,setTab]=React.useState("grid");
const load=React.useCallback(()=>{ setErr(""); api("GET","/api/availability?week="+week).then(setData).catch(e=>setErr((e&&e.message)||"โหลดไม่ได้")); },[week]);
React.useEffect(()=>{ load(); },[load]);
async function setDay(day,status,user_id,note){ setBusy(true);
try{ const r=await api("POST","/api/availability",{day,status,user_id:user_id||undefined,note:note||undefined});
if(r&&r.warning)toast.error(r.warning); else toast.ok("บันทึกแล้ว"); load();
}catch(e){ toast.error((e&&e.message)||"บันทึกไม่ได้"); } finally{ setBusy(false); } }
const rangeT=m=>_avlDnum(m)+" "+_avlMon(m)+" – "+_avlDnum(_avlShift(m,6))+" "+_avlMon(_avlShift(m,6));
const C={maxWidth:1180,margin:"0 auto",padding:"0 18px 48px"};
const navBtn={padding:"7px 13px",borderRadius:10,background:COLORS.card,border:`1px solid ${COLORS.hairline}`,color:COLORS.ink,fontSize:14,cursor:"pointer",fontWeight:700};
const header=(extra)=>
{rangeT(week)}
{week!==_avlMonNow()&&
}
{data&&data.locked&&
🔒 ผ่านไปแล้ว · ดูได้อย่างเดียว}
{extra}
;
if(err)return
;
if(!data)return
;
if(!data.is_lead){
const m0=data.me, locked=data.locked;
return
ลงสถานะแต่ละวัน · ไม่ลง = เข้าออฟฟิศอัตโนมัติ · ลงล่วงหน้าได้หลายสัปดาห์
{header()}
{m0.over_cap&&
⚠️ WFH สัปดาห์นี้ {m0.wfh_count} วัน · เกินเพดาน {data.cap} วัน/สัปดาห์ (เตือนเฉยๆ ไม่ห้าม)
}
{data.days.map((d,i)=>{const cur=m0.status[d], isToday=d===data.today;
return
{DOWLONG[i]} {isToday&&· วันนี้}
{_avlDnum(d)} {_avlMon(d)}
{["office","wfh","leave"].map(s=>{const on=cur===s, sc=_AVLSC[s];
return ;})}
{cur==="leave"&&!locked&&
{const v=e.target.value.trim(); if(v!==(m0.note[d]||""))setDay(d,"leave",null,v);}} placeholder="เหตุผล (สั้นๆ)" style={{width:"100%",marginTop:7,padding:"6px 8px",borderRadius:8,background:COLORS.cardSoft,border:`1px solid ${COLORS.hairline}`,color:COLORS.ink,fontSize:12,boxSizing:"border-box"}}/>}
{cur==="leave"&&locked&&m0.note[d]&&
📝 {m0.note[d]}
}
;})}
;
}
const locked=data.locked;
const tabBtn=(v,l)=>
;
return
{tabBtn("grid","📅 ตารางทีม")}{tabBtn("stats","📊 สถิติ")}
{tab==="grid"?<>
{header(
⭐ คนเข้าเยอะสุด = {data.best_day?(_avlDnum(data.best_day)+" "+_avlMon(data.best_day)):"—"} · เหมาะนัดประชุม)}
| ทีม |
{data.days.map((d,i)=>{const s=data.summary[i], isToday=d===data.today;
return
{DOW[i]}{s.is_best?" ⭐":""}
{_avlDnum(d)}
เข้า {s.office} | ;})}
{data.grid.map(row=>
|
{row.over_cap&&⚠️ }{row.name} |
{data.days.map(d=>{const s=row.status[d], sc=_AVLSC[s];
return !locked&&!busy&&setDay(d,NEXT[s],row.user_id)} title={(row.note&&row.note[d])?(_AVL[s].l+" · "+row.note[d]):_AVL[s].l} style={{padding:"7px 4px",textAlign:"center",cursor:locked?"default":"pointer",background:sc[1],borderTop:`1px solid ${COLORS.hairlineSoft}`,borderLeft:`1px solid ${COLORS.hairlineSoft}`}}>
{_AVL[s].e} | ;})}
)}
🟢 เข้าออฟฟิศ · 🔵 WFH · ⚪ ลา · {locked?"สัปดาห์ผ่านแล้ว แก้ไม่ได้":"คลิกช่องเพื่อเปลี่ยนสถานะลูกทีม (วน 🟢→🔵→⚪)"} · ⚠️ = WFH เกิน {data.cap} วัน/สัปดาห์
>:
}
;
}
function ActivityView({me, users}){
const [tab,setTab]=useState("overview");
const [presence,setPresence]=useState(null), [today,setToday]=useState(null), [feed,setFeed]=useState(null);
const [scope,setScope]=useState("recent"), [err,setErr]=useState(""), [loading,setLoading]=useState(true);
const load=useCallback(()=>{
if(tab!=="overview"){ setLoading(false); return; } // poll เฉพาะแท็บภาพรวม
Promise.all([
api("GET","/api/activity/presence"),
api("GET","/api/activity/today"),
api("GET","/api/activity/feed?limit=80&scope="+scope),
]).then(([p,t,f])=>{ if(p)setPresence(p); if(t)setToday(t); if(f)setFeed(f); setErr(""); })
.catch(e=>setErr((e&&e.message)||"โหลดไม่ได้"))
.finally(()=>setLoading(false));
},[scope,tab]);
useEffect(()=>{ load(); const id=setInterval(load,30000); return ()=>clearInterval(id); },[load]);
const card={background:COLORS.card,border:`1px solid ${COLORS.hairline}`,borderRadius:16,padding:16};
const head={fontSize:13,fontWeight:800,color:COLORS.inkBright,marginBottom:12,display:"flex",alignItems:"center",gap:8};
const online=(presence&&presence.users.filter(u=>u.online))||[];
const tBtn=(v,l)=>
;
const tabBar=
{tBtn("overview","📊 ภาพรวม")}{tBtn("all","🗂 Log ทั้งหมด")}
;
const wrap=(inner)=>
{tabBar}{inner}
;
if(tab==="all") return wrap(
);
if(loading && !presence) return wrap(กำลังโหลด Activity…
);
if(err && !presence) return wrap(
ยังเปิด Activity ไม่ได้
{err}
ถ้าเพิ่งดีพลอย รอ backend สักครู่แล้วรีเฟรช
);
return wrap(
ออนไลน์ตอนนี้ · {online.length}
{online.length===0 ?
ยังไม่มีใครออนไลน์ใน 5 นาทีล่าสุด
:
{online.map(u=>
{u.name}{me&&u.id===me.id?" (คุณ)":""}
{u.role}
)}
}
📊 ใครมาทำงานวันนี้ · {today?today.count:0}
{(!today||today.users.length===0) ?
ยังไม่มีใครเริ่มงานวันนี้
:
{today.users.map(u=>
{u.name}{me&&u.id===me.id?" (คุณ)":""}
{u.role} · ใช้ล่าสุด {_agoTH(u.last_seen)}
{u.actions>0 && {u.actions} งาน}
{u.logins>0 && เข้า {u.logins}}
)}
}
📜 ความเคลื่อนไหวล่าสุด
{[["recent","ล่าสุด"],["today","วันนี้"]].map(([v,l])=>)}
{(!feed||feed.events.length===0) ?
ยังไม่มีความเคลื่อนไหว
:
{feed.events.map((e,i)=>{
const isSession=e.source==="session";
const label=_ACT_TH[e.action]||e.action;
const dot=isSession?(e.action==="logout"?COLORS.inkFaint:"var(--th-st-blue-dot)"):"var(--th-accent)";
return
{_hhmmTH(e.created_at)}
{e.imp_by_name||e.user_name||"ระบบ"}{e.imp_by_name && 👁สวมบท {e.user_name}}
{label}
{e.task_title && · {e.task_title}}
;
})}
}
อัปเดตอัตโนมัติทุก 30 วินาที · online = ใช้งานใน 5 นาที · เห็นเฉพาะหัวหน้า
);
}
function AllLogPanel({me, users}){
const [f,setF]=useState({user_id:"",source:"all",action:"",date_from:"",date_to:""});
const [data,setData]=useState(null), [loading,setLoading]=useState(false), [err,setErr]=useState(""), [busy,setBusy]=useState(false);
const LIMIT=50;
const qs=(extra)=>{ const p=new URLSearchParams();
if(f.user_id)p.set("user_id",f.user_id); if(f.source&&f.source!=="all")p.set("source",f.source);
if(f.action)p.set("action",f.action); if(f.date_from)p.set("date_from",f.date_from); if(f.date_to)p.set("date_to",f.date_to);
Object.entries(extra||{}).forEach(([k,v])=>p.set(k,v)); return p.toString(); };
const load=useCallback((off)=>{ setLoading(true);
api("GET","/api/activity/log?"+qs({limit:LIMIT,offset:off||0}))
.then(r=>{ if(!r)return; setData(prev=>(off&&prev)?{...r,events:[...prev.events,...r.events]}:r); setErr(""); })
.catch(e=>setErr((e&&e.message)||"โหลดไม่ได้")).finally(()=>setLoading(false));
},[f]);
useEffect(()=>{ load(0); },[load]);
async function exportCSV(){ setBusy(true);
try{ const r=await api("GET","/api/activity/log?"+qs({limit:5000,offset:0}));
const rows=[["เวลา (ไทย)","ผู้ใช้","role","แหล่ง","action","งาน/เป้าหมาย","ip"]];
(r.events||[]).forEach(e=>rows.push([
(_utcDate(e.created_at)?_utcDate(e.created_at).toLocaleString("th-TH",{hour12:false}):e.created_at),
e.imp_by_name?`${e.imp_by_name} (สวมบท ${e.user_name||""})`:(e.user_name||""), e.role||"", _SRC_TH[e.src]||e.src, (_ACT_TH[e.action]||e.action),
e.task_title||e.target_name||"", e.ip||"" ]));
_downloadCSV("activity-log-"+(data&&data.total?data.total+"rows-":"")+(f.date_from||"all")+".csv", rows);
toast.ok("ดาวน์โหลด "+(rows.length-1)+" รายการ");
}catch(e){ toast.error((e&&e.message)||"export ไม่ได้"); } finally{ setBusy(false); }
}
const set=(k,v)=>setF(p=>({...p,[k]:v}));
const inp={padding:"7px 10px",borderRadius:9,background:COLORS.card,border:`1px solid ${COLORS.hairline}`,color:COLORS.ink,fontSize:12.5,fontFamily:"inherit"};
const card={background:COLORS.card,border:`1px solid ${COLORS.hairline}`,borderRadius:16,padding:16};
const hasFilter=f.user_id||f.source!=="all"||f.action||f.date_from||f.date_to;
const usersSorted=[...(users||[])].sort((a,b)=>(a.name||"").localeCompare(b.name||""));
return
set("date_from",e.target.value)} title="ตั้งแต่" style={inp}/>
→
set("date_to",e.target.value)} title="ถึง" style={inp}/>
{hasFilter &&
}
{data &&
พบ {data.total} รายการ{hasFilter?" (กรองแล้ว)":""}
}
{err ?
{err}
: (!data||data.events.length===0) ?
{loading?"กำลังโหลด…":"ไม่พบรายการตามเงื่อนไข"}
:
{data.events.map((e,i)=>{
const lbl=_ACT_TH[e.action]||e.action;
const dot=e.src==="session"?(e.action==="logout"?COLORS.inkFaint:"var(--th-st-blue-dot)"):e.src==="member"?"var(--th-st-orange-dot)":"var(--th-accent)";
const subj=e.task_title||(e.target_name?("→ "+e.target_name):"");
return
{_dmyhmTH(e.created_at)}
{e.imp_by_name||e.user_name||"ระบบ"}{e.imp_by_name && 👁สวมบท {e.user_name}}
· {_SRC_TH[e.src]||e.src}
· {lbl}
{subj && · {subj}}
;
})}
}
{data && data.has_more &&
}
;
}
// ---- root ----
function App(){
const [user,setU]=useState(getUser());
const [hydrating,setH]=useState(true);
// ทุก mount: ยืนยันตัวตนผ่าน /api/me (รองรับ cookie .newverse.cloud ข้าม subdomain) ·
// มี session = auto-login (ไม่ต้องกรอกรหัส · เช่น login มาจาก newverse.cloud แล้ว) · ไม่มี = แสดงหน้า Login ในตัว
useEffect(()=>{
fetch(API+"/api/me",{credentials:"include",headers: token()?{Authorization:"Bearer "+token()}:{}})
.then(r=> r.ok ? r.json() : null)
.then(u=>{
if(u && u.id){ setUser(u); setU(u); }
else { setToken(null); setUser(null); setU(null); }
})
.catch(()=>{ setToken(null); setUser(null); setU(null); })
.finally(()=>setH(false));
},[]);
if(hydrating) return
กำลังโหลด…
;
if(!user) return
setU(u)}/>;
return <>{setToken(null);setUser(null);setU(null);}}/>>;
}
ReactDOM.createRoot(document.getElementById("root")).render();