// app.jsx — Team Task Hub · real app wired to API const { useState, useEffect, useCallback, useRef } = React; // ---- API layer ---- // API base ตั้งจาก window.API_BASE ใน index.html (config เดียว) → ชี้ข้ามเครื่องไป backend คุณเอก // ทุก request ต้องใช้ credentials:"include" (auth = cookie ข้าม subdomain .newverse.cloud) const API = (typeof window!=="undefined" && window.API_BASE) || ""; // "" = same-origin (dev fallback) function token(){ return localStorage.getItem("th_token") || ""; } function setToken(t){ if(t) localStorage.setItem("th_token",t); else localStorage.removeItem("th_token"); } function getUser(){ try{ return JSON.parse(localStorage.getItem("th_user")||"null"); }catch(e){ return null; } } function setUser(u){ u ? localStorage.setItem("th_user",JSON.stringify(u)) : localStorage.removeItem("th_user"); } // ---- impersonate (superadmin ดูในมุมมองคนอื่น) ---- function isImp(){ return !!localStorage.getItem("th_real_token"); } function realUser(){ try{ return JSON.parse(localStorage.getItem("th_real_user")||"null"); }catch(e){ return null; } } function stopImp(){ const rt=localStorage.getItem("th_real_token"), ru=localStorage.getItem("th_real_user"); if(rt) localStorage.setItem("th_token",rt); if(ru) localStorage.setItem("th_user",ru); localStorage.removeItem("th_real_token"); localStorage.removeItem("th_real_user"); } async function api(method, path, body){ const h = { "Content-Type":"application/json" }; if(token()) h["Authorization"] = "Bearer "+token(); const res = await fetch(API+path, { method, headers:h, credentials:"include", body: body?JSON.stringify(body):undefined }); if(res.status===401){ setToken(null); setUser(null); location.reload(); return; } if(!res.ok) throw new Error((await res.text()).slice(0,200)); return res.status===204 ? null : res.json(); } // ---- pipeline mapping (API key → design token key) ---- const PIPE_MAP = { bot_dev:"bot", long_video:"long", short_video:"short", graphic:"graphic", page_content:"page", head_review:"review", ops:"ops", strategy:"strategy" }; function pipeTok(apiKey){ return PIPELINES[PIPE_MAP[apiKey]] || { color:"#9B93B5", soft:"rgba(155,147,181,0.14)", label:apiKey, short:apiKey }; } function statusTok(s){ return STATUS[s] || { bg:"rgba(155,147,181,0.14)", fg:"#9B93B5", dot:"#9B93B5", label:s }; } const STATUS_OPTIONS = ["idea","received","progress","editing","submitted","review","revision","approved","to_publish","done","published","blocked"]; // ---- small UI ---- function Badge({children, bg, fg}){ return {children}; } function StatusPill({s}){ const t=statusTok(s); return {t.label}; } // ---- Toast (non-blocking · แทน alert · รองรับ undo) ---- let _toastSeq=0; const _toastSubs=new Set(); function toast(msg,opts={}){ const t={id:++_toastSeq,msg:(""+msg).slice(0,200),kind:opts.kind||"info",action:opts.action,actionLabel:opts.actionLabel,ttl:opts.ttl||(opts.action?6000:3200)}; _toastSubs.forEach(fn=>fn(t)); return t.id; } toast.error=(m)=>toast(m,{kind:"error"}); toast.ok=(m,o={})=>toast(m,{...o,kind:"ok"}); function ToastHost(){ const [items,setItems]=useState([]); useEffect(()=>{ const sub=(t)=>{ setItems(xs=>[...xs,t]); setTimeout(()=>setItems(xs=>xs.filter(x=>x.id!==t.id)),t.ttl); }; _toastSubs.add(sub); return ()=>_toastSubs.delete(sub); },[]); const close=(id)=>setItems(xs=>xs.filter(x=>x.id!==id)); return
{items.map(t=>{ const c=t.kind==="error"?STATUS.blocked.fg:t.kind==="ok"?STATUS.done.fg:COLORS.accentBright; return
{t.msg} {t.action && }
; })}
; } // ปิด modal ด้วย Esc (desktop) function useEscClose(onClose){ useEffect(()=>{ const h=e=>{ if(e.key==="Escape"){ e.stopPropagation(); onClose&&onClose(); } }; window.addEventListener("keydown",h); return ()=>window.removeEventListener("keydown",h); },[onClose]); } // ---- Confirm dialog (promise-based · กันลบ/อนุมัติ/เผยแพร่พลาด + กันสวมบทแล้วแก้พลาด) ---- let _confirmSub=null; function confirmAsk(opts){ return new Promise(resolve=>{ if(!_confirmSub){ resolve(window.confirm(((opts.title||"ยืนยัน")+"\n"+(opts.message||"")).trim())); return; } _confirmSub({...opts,_resolve:resolve}); }); } // gate: คืน true ถ้าผ่าน (กดยืนยัน หรือไม่ต้องถาม) · false ถ้ายกเลิก // always=ถามเสมอ (อนุมัติ/เผยแพร่/ตีกลับ) · danger=อันตราย+ถามเสมอ (ลบ/ยกเลิก) · นอกนั้นถามเฉพาะตอนสวมบท (isImp) async function gate(o){ o=o||{}; const imp=isImp(); if(!o.always && !o.danger && !imp) return true; let impLine=""; if(imp){ const nm=(getUser()||{}).name||"คนอื่น"; impLine=`⚠️ คุณกำลังสวมบทเป็น ${nm} — การกระทำนี้จะถูกบันทึกในนามนั้น\n\n`; } return await confirmAsk({ title:o.title||"ยืนยัน", message:impLine+(o.body||"ดำเนินการต่อหรือไม่?"), danger:!!o.danger, confirmLabel:o.confirmLabel||(o.danger?"ยืนยันลบ":"ยืนยัน"), requireText:o.requireText }); } function ConfirmHost(){ const [dlg,setDlg]=useState(null); const [typed,setTyped]=useState(""); useEffect(()=>{ _confirmSub=(d)=>{ setTyped(""); setDlg(d); }; return ()=>{ _confirmSub=null; }; },[]); if(!dlg) return null; const fin=(v)=>{ const r=dlg._resolve; setDlg(null); setTyped(""); r&&r(v); }; const danger=!!dlg.danger, c=danger?"var(--th-st-red-fg)":COLORS.accentBright; const need=dlg.requireText, okOff=need && typed.trim()!==(""+need).trim(); return
fin(false)} style={{position:"fixed",inset:0,background:"var(--th-overlay-strong)",display:"flex",alignItems:"center",justifyContent:"center",zIndex:9500,padding:20}}>
e.stopPropagation()} style={{width:"100%",maxWidth:400,background:COLORS.bgElev,borderRadius:18,padding:22,border:`1px solid ${danger?"color-mix(in srgb, var(--th-st-red-fg) 35%, transparent)":COLORS.hairline}`,boxShadow:"var(--th-shadow-lg)"}}>
{danger&&🗑}{dlg.title||"ยืนยัน"}
{dlg.message &&
{dlg.message}
} {need && setTyped(e.target.value)} onKeyDown={e=>{if(e.key==="Enter"&&!okOff)fin(true);}} placeholder={`พิมพ์ "${need}" เพื่อยืนยัน`} style={{width:"100%",padding:"11px 13px",borderRadius:11,background:COLORS.card,border:`1px solid ${COLORS.hairline}`,color:COLORS.ink,fontSize:14,marginBottom:16}}/>}
; } // ---- Login ---- function Login({onLogin}){ const [email,setEmail]=useState(""); const [pw,setPw]=useState(""); const [err,setErr]=useState(""); const [busy,setBusy]=useState(false); const [pwMode,setPwMode]=useState(false); const gbtn=useRef(null); async function submit(e){ e.preventDefault(); setBusy(true); setErr(""); try{ const r=await api("POST","/api/auth/login",{email,password:pw}); setToken(r.token); setUser(r.user); onLogin(r.user); } catch(e){ setErr("อีเมลหรือรหัสผ่านไม่ถูกต้อง"); } finally{ setBusy(false); } } async function googleLogin(credential){ setBusy(true); setErr(""); try{ const r=await api("POST","/api/auth/google",{credential}); setToken(r.token); setUser(r.user); onLogin(r.user); } catch(e){ setErr("ล็อกอิน Google ไม่สำเร็จ · อีเมลนี้อาจยังไม่มีในระบบ Team Hub"); setBusy(false); } } useEffect(()=>{ let n=0; const CID=(typeof window!=="undefined"&&window.GOOGLE_CLIENT_ID)||""; const init=()=>{ const g=window.google&&window.google.accounts&&window.google.accounts.id; if(g&&gbtn.current&&CID){ try{ window.google.accounts.id.initialize({client_id:CID,callback:(resp)=>googleLogin(resp.credential)}); window.google.accounts.id.renderButton(gbtn.current,{theme:"filled_black",size:"large",shape:"pill",text:"signin_with",width:300,logo_alignment:"center"}); }catch(e){} } else if(n++<50){ setTimeout(init,150); } }; init(); },[]); const inp={width:"100%",padding:"14px 16px",borderRadius:14,background:COLORS.card,border:`1px solid ${COLORS.hairline}`,color:COLORS.ink,fontSize:15,marginBottom:12}; return (
NEWVERSE
Team Task Hub
เข้าสู่ระบบด้วยบัญชี Google ของทีม
{err &&
{err}
} {busy &&
กำลังเข้าสู่ระบบ…
} {!pwMode ?
setPwMode(true)} style={{textAlign:"center",color:COLORS.inkMuted,fontSize:12,marginTop:18,cursor:"pointer"}}>เข้าด้วยอีเมล/รหัสผ่าน
:
setEmail(e.target.value)} autoCapitalize="off"/> setPw(e.target.value)}/>
}
); } // ---- Task card ---- const THMON=["ม.ค.","ก.พ.","มี.ค.","เม.ย.","พ.ค.","มิ.ย.","ก.ค.","ส.ค.","ก.ย.","ต.ค.","พ.ย.","ธ.ค."]; const DONE_STATES=["done","published","approved","deployed"]; const TERMINAL_ST=["done","published","approved","deployed","cancelled"]; // ปิดงานแล้ว (เสร็จ/ยกเลิก) // due → {text:"5 มิ.ย.", color} · แดงเมื่อเลยกำหนด · เหลืองเมื่อเหลือ ≤2 วัน (ยกเว้นงานปิดแล้ว) function dueInfo(due,status){ if(!due) return null; const m=(""+due).match(/^(\d{4})-(\d{2})-(\d{2})/); if(!m) return {text:due,color:COLORS.inkMuted}; const d=new Date(+m[1],+m[2]-1,+m[3]); const today=new Date(); today.setHours(0,0,0,0); const days=Math.round((d-today)/86400000), terminal=["done","published","approved","deployed"].includes(status); let color=COLORS.inkMuted; if(!terminal){ if(days<0) color="var(--th-st-red-fg)"; else if(days<=2) color="var(--th-st-orange-fg)"; } return {text:(days<0&&!terminal?"เลย ":"")+`${+m[3]} ${THMON[+m[2]-1]}`, color, late:days<0&&!terminal}; } function TaskCard({t, onClick, onToggleDone, focused, picked, onPick, selMode, onTagClick, onPatch}){ const [editing,setEditing]=useState(false); const [ev,setEv]=useState(""); const cycle=()=>{ const o=["P0","P1","P2","P3"]; onPatch&&onPatch(t,{priority:o[(o.indexOf(t.priority)+1)%4]}); }; const saveTitle=()=>{ const v=ev.trim(); if(v&&v!==t.title) onPatch&&onPatch(t,{title:v}); setEditing(false); }; const p=pipeTok(t.pipeline_key); const pr=PRIORITY[t.priority]||PRIORITY.P2; const di=dueInfo(t.due,t.status); const done=DONE_STATES.includes(t.status); const terminal=TERMINAL_ST.includes(t.status); const tags=(t.tags||"").split(",").map(s=>s.trim()).filter(Boolean); let clT=0,clD=0; try{const c=JSON.parse(t.checklist||"[]"); clT=c.length; clD=c.filter(x=>x.done).length;}catch(e){} const docN=(t.attach_count||0)+(t.link_count||0); // เอกสาร/ไฟล์/ลิงก์แนบ → badge เฉพาะตอนมี return (
onPick&&onPick(t)):onClick} data-tid={t.id} style={{background:picked?COLORS.accentSoft:COLORS.card,border:`1px solid ${picked||focused?COLORS.accentEdge:COLORS.hairline}`,borderRadius:16,padding:14,marginBottom:10,cursor:"pointer",opacity:terminal?0.6:1,transition:"opacity .2s,border .15s,background .15s",boxShadow:focused?`0 0 0 1px ${COLORS.accentEdge}`:"none"}}>
{p.short}{t.recurrence&&🔁}
{e.stopPropagation();cycle();}):undefined} title={onPatch?"คลิกเปลี่ยนความสำคัญ":""} style={{cursor:onPatch?"pointer":"default"}}>{pr.label}
{selMode ? {picked?"✓":""} : onToggleDone && } {editing ? e.stopPropagation()} onChange={e=>setEv(e.target.value)} onKeyDown={e=>{ if(e.key==="Enter"){e.preventDefault();saveTitle();} else if(e.key==="Escape"){setEditing(false);} }} onBlur={saveTitle} style={{flex:1,fontSize:15,fontWeight:600,padding:"3px 6px",borderRadius:7,border:`1px solid ${COLORS.accentEdge}`,background:COLORS.bgElev,color:COLORS.ink,boxSizing:"border-box"}}/> :
{e.stopPropagation();setEv(t.title);setEditing(true);}):undefined} title={onPatch?"ดับเบิลคลิกแก้ชื่อ":""} style={{flex:1,fontSize:15,fontWeight:600,lineHeight:1.4,textDecoration:terminal?"line-through":"none",color:terminal?COLORS.inkMuted:COLORS.ink}}>{t.title}
}
{(clT>0||docN>0) &&
{clT>0 && ☑ {clD}/{clT}} {docN>0 && 📎 {docN}}
}
{t.owner_name||"—"}{di&&· 📅 {di.text}}
); } // quick-add บรรทัดเดียว · พิมพ์ Enter สร้างทันที · รองรับ !P1 / วันนี้ / พรุ่งนี้ / YYYY-MM-DD // แยก token จากชื่องาน → {title สะอาด, priority, due} · ใช้ทั้ง quick-add และฟอร์มเต็ม (พิมพ์ที่ไหนก็ parse) function parseQuick(raw){ let title=(raw||"").trim(), priority=null, due=null, recurrence=null; const fmt=d=>`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`; const pm=title.match(/!\s*(p[0-3])\b/i); if(pm){ priority=pm[1].toUpperCase(); title=title.replace(pm[0],""); } if(/ทุกวัน|รายวัน|daily/i.test(title)){ recurrence="daily"; title=title.replace(/ทุกวัน|รายวัน|daily/i,""); } else if(/ทุกสัปดาห์|รายสัปดาห์|weekly/i.test(title)){ recurrence="weekly"; title=title.replace(/ทุกสัปดาห์|รายสัปดาห์|weekly/i,""); } if(/มะรืนนี้|มะรืน/i.test(title)){ const d=new Date();d.setDate(d.getDate()+2);due=fmt(d);title=title.replace(/มะรืนนี้|มะรืน/i,""); } else if(/พรุ่งนี้|tomorrow/i.test(title)){ const d=new Date();d.setDate(d.getDate()+1);due=fmt(d);title=title.replace(/พรุ่งนี้|tomorrow/i,""); } else if(/วันนี้|today/i.test(title)){ due=fmt(new Date());title=title.replace(/วันนี้|today/i,""); } else if(/สัปดาห์หน้า|อาทิตย์หน้า|next week/i.test(title)){ const d=new Date();d.setDate(d.getDate()+7);due=fmt(d);title=title.replace(/สัปดาห์หน้า|อาทิตย์หน้า|next week/i,""); } const dm=title.match(/\b(\d{4}-\d{2}-\d{2})\b/); if(dm){ due=dm[1]; title=title.replace(dm[0],""); } title=title.replace(/\s{2,}/g," ").trim(); return {title, priority, due, recurrence}; } const RECUR_LABEL={daily:"🔁 ทุกวัน",weekly:"🔁 ทุกสัปดาห์"}; function QuickAdd({onAdd}){ const [v,setV]=useState(""); const [busy,setBusy]=useState(false); const submit=async()=>{ const t=v.trim(); if(!t||busy)return; setBusy(true); setV(""); try{ await onAdd(t); }finally{ setBusy(false); } }; return
+ setV(e.target.value)} onKeyDown={e=>{if(e.key==="Enter")submit();}} placeholder="เพิ่มงานเร็ว… เช่น ตัดต่อ EP1 !P1 พรุ่งนี้" style={{flex:1,minWidth:0,background:"transparent",border:"none",outline:"none",color:COLORS.ink,fontSize:14}}/> {v.trim() && }
; } // ---- Detail sheet ---- // render `extra` JSON (รายละเอียดงาน: done/todo/roles/domain ฯลฯ) ใน Detail modal function ExtraView({raw}){ if(!raw) return null; let d; try{ d=typeof raw==="string"?JSON.parse(raw):raw; }catch(e){ return null; } if(!d||typeof d!=="object"||Array.isArray(d)) return null; const keys=Object.keys(d); if(!keys.length) return null; const LBL={domain:"🌐 โดเมน",dev:"👨‍💻 ผู้พัฒนา",done:"✅ เสร็จแล้ว",todo:"⬜ เหลือทำ",progress:"✅ คืบหน้าแล้ว",platform:"📺 แพลตฟอร์ม",roles:"👥 หน้าที่ทีม",flow:"🔀 ขั้นตอน",note:"📝 โน้ต",stack:"⚙️ Stack",hosting:"🖥 Hosting",brokers:"🏦 โบรกเกอร์",goal:"🎯 เป้าหมาย",plan:"🗺 Action Plan",action_plan:"🗺 Action Plan",why:"💡 ทำไม impact สูง",tech:"⚙️ เทคนิค",effort:"⚖️ Effort",owner_detail:"👥 ใครทำ",expected:"📈 ผลคาดหวัง",steps:"🗺 ขั้นตอน",impact_tier:"⭐ ระดับ"}; const card={marginBottom:14}; // เลิกกรอบกล่องนอก (กล่องในกล่อง) · ปล่อยเนื้อหาไหลใต้ stage bar const lab={color:COLORS.inkMuted,fontSize:11,fontWeight:700,marginBottom:3,fontFamily:MONO_FONT,letterSpacing:0.3}; const itm={fontSize:14,color:COLORS.ink,padding:"2px 0"}; const phc={background:COLORS.bg,border:`1px solid ${COLORS.hairline}`,borderRadius:10,padding:"10px 12px",marginBottom:8}; function renderVal(k,v){ if(Array.isArray(v)){ if(v.length && typeof v[0]==="object") // array ของ phase object → การ์ดแต่ละ phase return v.map((ph,i)=>
{ph.phase||ph.title}
{(ph.owner||ph.eta)&&
{ph.owner?"👤 "+ph.owner:""}{ph.eta?" · ⏱ "+ph.eta:""}
} {(ph.steps||[]).map((s,j)=>
• {s}
)} {ph.done&&
✓ เสร็จเมื่อ: {ph.done}
}
); const pre = k==="todo"?"⬜ ":(k==="done"||k==="progress")?"✅ ":"• "; return v.map((x,i)=>
{pre}{x}
); } if(v&&typeof v==="object") return Object.entries(v).map(([kk,vv])=>
{vv} — {kk}
); return
{String(v)}
; } return
{keys.map(k=>
{LBL[k]||k}
{renderVal(k,d[k])}
)}
; } // ---- Desktop split-view pieces (embedded only) ---- // (StageBar ยุบทิ้ง · stage+status รวมเป็น field เดียวแล้ว #33 → CTA state-driven แทน) // link card (desktop) — icon + kind + label const KIND_ICON={brief:"📋",raw:"🎬",edited:"✂️",final:"✅",reference:"🔖",repo:"💻",doc:"📄"}; function LinkCard({link, color, label, canEdit, onDel}){ return
{KIND_ICON[link.kind]||"🔗"}
{link.kind}
{canEdit && <>
}
{label}
; } // audit timeline column (desktop) function fmtAudit(a){ let d={}; try{ d=JSON.parse(a.detail||"{}"); }catch(e){} if(a.action==="created") return "สร้างงาน"; if(a.action==="add_link") return "เพิ่มลิงก์ "+(d.kind||""); if(a.action==="attach") return "อัปไฟล์ "+(d.name||""); if(a.action==="review") return d.decision==="approved"?"อนุมัติงาน":"ตีกลับงาน"; if(a.action==="handoff") return "ส่งต่อ → ขั้น "+(d.stage||""); if(a.action==="assigned") return "มอบหมายงาน"; if(a.action==="update"){ const keys=Object.keys(d); if(d.status) return "เปลี่ยนสถานะ → "+(statusTok(d.status.to).label||d.status.to); if(d.current_stage_id) return "ขยับ stage"; if(d.owner_id) return "เปลี่ยนเจ้าของงาน"; if(d.due) return "ปรับกำหนดส่ง"; if(d.priority) return "ปรับ priority → "+d.priority.to; return "แก้ไข "+keys.join(", "); } return a.action; } function AuditTimeline({events}){ const evs=events||[]; if(!evs.length) return null; return
{evs.map((e,i)=>{ const last=i===evs.length-1; return
{!last &&
}
{fmtAudit(e)}
{e.imp_by_name ? {e.imp_by_name} 👁สวมบท {e.user_name||"?"} : (e.user_name||"—")} · {(e.created_at||"").slice(5,16).replace("T"," ")}
; })}
; } // ---- รายละเอียดงาน + แทรกสื่อ 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
{gd.icon}{gd.label}เปิด ↗