/* 아리 — 작업(Tasks) · 클린 화이트 글래스 상단바=메인메뉴 · 좌측 사이드바=서브메뉴(중첩 프로젝트) · 칸반/리스트/캘린더 */ const { useState, useEffect, useRef } = React; const TD = window.TaskData; const PROJ = Object.fromEntries(TD.projects.map((p) => [p.id, p])); const PPL = TD.people; /* ---------- 아이콘 ---------- */ const P = { spark: "M12 3l1.9 5.2L19 10l-5.1 1.8L12 17l-1.9-5.2L5 10l5.1-1.8z|M19 4v3|M5 17v3", grid: "M3 3h7v7H3z|M14 3h7v7h-7z|M14 14h7v7h-7z|M3 14h7v7H3z", cal: "M8 2v4M16 2v4M3 10h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z", route: "M6 19a3 3 0 1 0 0-6 3 3 0 0 0 0 6z|M18 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6z|M9 16h6a3 3 0 0 0 3-3V11", check: "M9 11l3 3L22 4|M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11", tick: "M20 6 9 17l-5-5", mail: "M22 7 13.03 12.7a1.94 1.94 0 0 1-2.06 0L2 7|M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z", brain: "M12 5a3 3 0 1 0-5.9.8A3 3 0 0 0 4 9.5 3 3 0 0 0 6 14a3 3 0 0 0 6 1zM12 5a3 3 0 1 1 5.9.8A3 3 0 0 1 20 9.5 3 3 0 0 1 18 14a3 3 0 0 1-6 1z|M12 5v14", heart: "M19 14c1.5-1.5 3-3.4 3-5.5A4.5 4.5 0 0 0 12 5.5 4.5 4.5 0 0 0 2 8.5c0 2.1 1.5 4 3 5.5l7 7z", list: "M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01", inbox: "M22 12h-6l-2 3h-4l-2-3H2|M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z", search: "M21 21l-4.34-4.34|M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16z", bell: "M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9|M10.3 21a1.94 1.94 0 0 0 3.4 0", sun: "M12 17a5 5 0 1 0 0-10 5 5 0 0 0 0 10z|M12 1v2M12 21v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M1 12h2M21 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4", moon: "M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8z", plus: "M12 5v14M5 12h14", x: "M18 6 6 18M6 6l12 12", more: "M5 12a1 1 0 1 0 2 0 1 1 0 0 0-2 0zM11 12a1 1 0 1 0 2 0 1 1 0 0 0-2 0zM17 12a1 1 0 1 0 2 0 1 1 0 0 0-2 0z", chev: "M9 6l6 6-6 6", star: "M12 2.5l2.9 5.9 6.5.9-4.7 4.6 1.1 6.5L12 18.8 6.2 21l1.1-6.5L2.6 9.8l6.5-.9z", flag: "M4 22V4M4 4s1.5-1 5-1 5 2 8 2 4-1 4-1v10s-1.5 1-4 1-5-2-8-2-5 1-5 1", folder: "M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V18a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z", hash: "M4 9h16M4 15h16M10 3L8 21M16 3l-2 18", arrow: "M5 12h14|M13 6l6 6-6 6", grip: "M9 5a1 1 0 1 0 2 0 1 1 0 0 0-2 0zM9 12a1 1 0 1 0 2 0 1 1 0 0 0-2 0zM9 19a1 1 0 1 0 2 0 1 1 0 0 0-2 0zM14 5a1 1 0 1 0 2 0 1 1 0 0 0-2 0zM14 12a1 1 0 1 0 2 0 1 1 0 0 0-2 0zM14 19a1 1 0 1 0 2 0 1 1 0 0 0-2 0z", file: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|M14 2v6h6M9 13h6M9 17h4", user: "M20 21a8 8 0 0 0-16 0|M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z", bold: "M6 4h7a4 4 0 0 1 0 8H6z|M6 12h8a4 4 0 0 1 0 8H6z", italic: "M19 4h-9M14 20H5M15 4 9 20", under: "M6 4v6a6 6 0 0 0 12 0V4|M5 21h14", ulist: "M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01", olist: "M10 6h11M10 12h11M10 18h11|M4 4v4M3 4h1M3 8h2|M3 14h2l-2 3h2", quote: "M6 11H3a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v8c0 2-1 3.5-3 4|M16 11h-3a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v8c0 2-1 3.5-3 4", msg: "M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z", send: "M22 2 11 13|M22 2 15 22l-4-9-9-4z", back: "M19 12H5|M12 19l-7-7 7-7", trash: "M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6", branch: "M6 3v12|M18 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6zM6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM6 6a3 3 0 1 0 0-6 3 3 0 0 0 0 6z|M15 12H9a3 3 0 0 0-3 3", users: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2|M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z|M23 21v-2a4 4 0 0 0-3-3.87|M16 3.13a4 4 0 0 1 0 7.75", clock: "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20z|M12 7v5l3 2", wand: "M15 4V2M15 16v-2M8 9h2M20 9h2M17.8 11.8 19 13M17.8 6.2 19 5M3 21l9-9M12.2 6.2 11 5", zap: "M13 2 3 14h9l-1 8 10-12h-9z", spark2: "M12 3l1.9 5.2L19 10l-5.1 1.8L12 17l-1.9-5.2L5 10l5.1-1.8z|M19 4v3|M5 17v3", }; function I({ d, w }) { return ( {P[d].split("|").map((s, i) => )} ); } const MAIN = [ { id: "dash", label: "대시보드", icon: "grid", href: "대시보드.html" }, { id: "sbox", label: "인박스", icon: "inbox", href: "인박스.html" }, { id: "appr", label: "결재함", icon: "spark", badge: "3", href: "결재함.html" }, { id: "auto", label: "자동화", icon: "zap", href: "자동화.html" }, { id: "journey", label: "여정", icon: "route", href: "여정.html" }, { id: "cal", label: "일정", icon: "cal", href: "일정.html" }, { id: "task", label: "작업", icon: "check", badge: "4", href: "작업.html" }, { id: "mail", label: "메일", icon: "mail", href: "메일.html" }, { id: "noti", label: "알림", icon: "bell", badge: "6", href: "알림.html" }, { id: "research", label: "리서치", icon: "brain", href: "리서치.html" }, { id: "trip", label: "여행", icon: "send", href: "여행.html" }, { id: "life", label: "라이프", icon: "heart", href: "라이프.html" }, { id: "wrap", label: "하루 마감", icon: "moon", href: "하루 마감.html" }, ]; const VIEWS = [ { id: "kanban", label: "칸반", icon: "grid" }, { id: "list", label: "리스트", icon: "list" }, ]; const STATUS_META = { todo: { label: "할 일", c: "var(--faint)" }, doing: { label: "진행 중", c: "var(--blue)" }, waiting: { label: "대기 중", c: "var(--violet)" }, review: { label: "검토", c: "var(--coral)" }, done: { label: "완료", c: "var(--green)" }, }; function load(k, fb) { try { const v = localStorage.getItem("ariT." + k); return v === null ? fb : JSON.parse(v); } catch (e) { return fb; } } function save(k, v) { try { localStorage.setItem("ariT." + k, JSON.stringify(v)); } catch (e) {} } /* 작업 = 무한 중첩 트리. 하위 작업은 그 자체가 완전한 작업 노드. */ function findPath(nodes, id, trail) { trail = trail || []; for (const n of nodes) { const t = trail.concat(n); if (n.id === id) return t; if (n.children && n.children.length) { const r = findPath(n.children, id, t); if (r) return r; } } return null; } const getNode = (nodes, id) => { const p = findPath(nodes, id); return p ? p[p.length - 1] : null; }; function editTree(nodes, id, fn) { return nodes.map((n) => { if (n.id === id) return fn(n); if (n.children && n.children.length) return { ...n, children: editTree(n.children, id, fn) }; return n; }); } function removeFromTree(nodes, id) { return nodes.filter((n) => n.id !== id).map((n) => (n.children && n.children.length ? { ...n, children: removeFromTree(n.children, id) } : n)); } const stat = (node) => { const c = node.children || []; return { total: c.length, done: c.filter((x) => x.status === "done").length }; }; const pct = (node) => { const s = stat(node); return s.total ? Math.round((s.done / s.total) * 100) : 0; }; const dueDay = (due) => (due ? parseInt(due.split("-")[1], 10) : 99); const dueLabel = (due) => { if (!due) return "—"; const [m, d] = due.split("-"); return `${+m}/${+d}`; }; /* 위임 추천 대상 — 아리는 동료의 업무량을 모른다. 대신 '이 작업이 속한 프로젝트를 함께 하는 사람'을 내 작업 데이터에서 찾는다. */ function suggestCollaborator(tasks, projectId) { if (!projectId || projectId === "me") return null; // 개인 프로젝트는 위임 제안 안 함 const counts = {}; (function walk(ns) { ns.forEach((n) => { if (n.assignee && n.assignee !== "jiwoo" && n.project === projectId) { counts[n.assignee] = (counts[n.assignee] || 0) + 1; } if (n.children && n.children.length) walk(n.children); }); })(tasks); const people = Object.keys(counts); if (!people.length) return null; people.sort((a, b) => counts[b] - counts[a]); return { who: people[0], sameProject: true }; } function Av({ who }) { const p = PPL[who]; if (!p) return null; return
{p.initial}
; } function Tag({ projId }) { const p = PROJ[projId]; if (!p) return null; return ({p.name}); } /* ---------------- 트리 노드 ---------------- */ function TreeNode({ node, depth, sel, onSelect, expanded, onToggle, pinned, onPin, counts, onAddProj }) { const isFolder = !!node.folder; const hasCh = isFolder || !!(node.children && node.children.length); const open = expanded[node.id]; const isPinned = pinned.includes(node.id); return ( <>
onSelect(node.id)}> {hasCh ? ( ) : } {isFolder ? : } {node.name} {counts[node.id] > 0 && {counts[node.id]}}
{hasCh && open && (node.children || []).map((c) => ( ))} {isFolder && open && ( onAddProj(node.id, name)} /> )} ); } /* 인라인 생성 (폴더/프로젝트 공용) */ function TreeAdd({ depth, placeholder, onAdd }) { const [editing, setEditing] = useState(false); const [val, setVal] = useState(""); const commit = () => { const v = val.trim(); if (v) onAdd(v); setVal(""); setEditing(false); }; if (!editing) { return ( ); } return (
setVal(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") commit(); if (e.key === "Escape") { setVal(""); setEditing(false); } }} onBlur={commit} />
); } /* ---------------- 칸반 ---------------- */ function KCard({ task, onOpen, onDrag, onDragEnd, dragging }) { const s = stat(task); const soon = task.status !== "done" && dueDay(task.due) <= 8; return (
onOpen(task.id)}>
{task.title}
{s.total > 0 && (
{s.done}/{s.total}
)}
{dueLabel(task.due)} {task.prio} {task.delegated && 위임}
); } function KanbanView({ tasks, onOpen, onMove, onAdd }) { const [dragId, setDragId] = useState(null); const [overCol, setOverCol] = useState(null); const [addCol, setAddCol] = useState(null); const [text, setText] = useState(""); const submit = (status) => { if (text.trim()) onAdd(status, text.trim()); setText(""); setAddCol(null); }; return (
{TD.columns.map((col) => { const items = tasks.filter((t) => t.status === col.id); return (
{ e.preventDefault(); if (overCol !== col.id) setOverCol(col.id); }} onDragLeave={(e) => { if (e.currentTarget === e.target) setOverCol(null); }} onDrop={(e) => { e.preventDefault(); if (dragId) onMove(dragId, col.id); setDragId(null); setOverCol(null); }}>
{col.label} {items.length}
{addCol === col.id && (
setText(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") submit(col.id); if (e.key === "Escape") setAddCol(null); }} onBlur={() => submit(col.id)} />
)} {items.map((t) => ( { setDragId(t.id); e.dataTransfer.effectAllowed = "move"; }} onDragEnd={() => { setDragId(null); setOverCol(null); }} /> ))} {items.length === 0 && addCol !== col.id &&
여기로 작업을 옮겨보세요
}
); })}
); } /* ---------------- 리스트 (상태별 그룹) ---------------- */ function LRow({ t, onOpen }) { const s = stat(t), sm = STATUS_META[t.status]; const soon = t.status !== "done" && dueDay(t.due) <= 8; return (
onOpen(t.id)}>
{t.title}
{dueLabel(t.due)}
{t.prio}
{s.total > 0 ? ( {s.done}/{s.total} ) : }
); } function ListView({ tasks, onOpen }) { const groups = TD.columns .map((col) => ({ ...col, items: tasks .filter((t) => t.status === col.id) .sort((a, b) => dueDay(a.due) - dueDay(b.due)), })) .filter((g) => g.items.length > 0); return (
{groups.map((g) => (
{g.label} {g.items.length}
작업
프로젝트
담당
마감
우선순위
진행
{g.items.map((t) => )}
))} {groups.length === 0 &&
표시할 작업이 없어요
}
); } /* ---------------- 리치 노트 에디터 ---------------- */ const NOTE_TOOLS = [ { cmd: "formatBlock", val: "h3", txt: "제목" }, { cmd: "bold", icon: "bold", title: "굵게" }, { cmd: "italic", icon: "italic", title: "기울임" }, { cmd: "underline", icon: "under", title: "밑줄" }, { cmd: "insertUnorderedList", icon: "ulist", title: "글머리 목록" }, { cmd: "insertOrderedList", icon: "olist", title: "번호 목록" }, { cmd: "formatBlock", val: "blockquote", icon: "quote", title: "인용" }, ]; function RichNotes({ taskId, value, onChange }) { const ref = useRef(null); useEffect(() => { if (ref.current) ref.current.innerHTML = value || ""; }, [taskId]); const exec = (cmd, val) => { ref.current.focus(); document.execCommand(cmd, false, val); onChange(ref.current.innerHTML); }; return (
{NOTE_TOOLS.map((t, i) => ( ))}
onChange(ref.current.innerHTML)} />
); } /* ---------------- 코멘트 ---------------- */ function Comments({ list, onAdd }) { const [txt, setTxt] = useState(""); const me = PPL.jiwoo; const submit = () => { if (txt.trim()) { onAdd(txt.trim()); setTxt(""); } }; return (
{list.length === 0 &&
아직 코멘트가 없어요. 첫 의견을 남겨보세요.
} {list.map((c) => { const p = PPL[c.who] || me; return (
{p.initial}
{p.name}{p.me ? " (나)" : ""}{c.time}
{c.text}
); })}
{me.initial}
setTxt(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") submit(); }} />
); } /* ---------------- 하위 작업 행 (재귀 · 클릭하면 그 작업으로 드릴다운) ---------------- */ function SubRow({ node, depth, expanded, onExpand, onCheck, onFocus }) { const has = !!(node.children && node.children.length); const open = !!expanded[node.id]; const s = stat(node); const done = node.status === "done"; return ( <>
onFocus(node.id)}> {has ? : }
{ e.stopPropagation(); onCheck(node.id); }}>
{node.title}
{has && {s.done}/{s.total}} {node.est && {node.est}} {node.due && {dueLabel(node.due)}}
{has && open && (
{node.children.map((c) => ( ))}
)} ); } /* ---------------- 상세 패널 (포커스 기반 · 브레드크럼 드릴다운) ---------------- */ function DetailPanel({ tasks, focusId, rootId, onFocus, onClose, onCheck, onField, onAddChild, onScaffold, onDelegate, onDelete, onNotes, onAddComment }) { const [menu, setMenu] = useState(false); const [expanded, setExpanded] = useState({}); const [adding, setAdding] = useState(false); const [subText, setSubText] = useState(""); const [scaffold, setScaffold] = useState(null); const path = findPath(tasks, focusId) || []; const node = path[path.length - 1]; useEffect(() => { setAdding(false); setMenu(false); setScaffold(null); }, [focusId]); useEffect(() => { const h = (e) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", h); return () => window.removeEventListener("keydown", h); }, [onClose]); if (!node) return null; const rootIdx = path.findIndex((n) => n.id === rootId); const crumbs = rootIdx >= 0 ? path.slice(rootIdx) : path; const person = PPL[node.assignee], s = stat(node), sm = STATUS_META[node.status]; const comments = node.comments || []; const onExpand = (id) => setExpanded((e) => ({ ...e, [id]: !e[id] })); const submitSub = () => { if (subText.trim()) onAddChild(node.id, subText.trim()); setSubText(""); setAdding(false); }; return ( <>
); } /* ---------------- 앱 ---------------- */ function App() { const [theme, setTheme] = useState(() => load("theme", "light")); const [view, setView] = useState(() => { const v = load("view", "kanban"); return v === "calendar" ? "kanban" : v; }); const [sel, setSel] = useState(() => { const s = load("sel", "work"); return s === "all" ? "work" : s; }); const [pinned, setPinned] = useState(() => load("pinned", ["onb-wire"])); const [expanded, setExpanded] = useState(() => ({ work: true, life: true, biz: true, onb: true, ...load("expanded", {}) })); const [q, setQ] = useState(""); const [ptree, setPtree] = useState(() => load("ptree", TD.tree)); const [tasks, setTasks] = useState(() => { const saved = load("tree", null); if (!saved) return TD.tasks; // 데모 보강: 새로 추가된 기본 작업이 저장본에 없으면 병합 const missing = TD.tasks.filter((t) => ["k20", "k21"].includes(t.id) && !saved.some((s) => s.id === t.id)); return [...saved, ...missing]; }); const [openRoot, setOpenRoot] = useState(null); const [focusId, setFocusId] = useState(null); const nextId = useRef(1000); const shellRef = useRef(null); useEffect(() => { document.documentElement.setAttribute("data-theme", theme); save("theme", theme); }, [theme]); useEffect(() => save("view", view), [view]); useEffect(() => save("sel", sel), [sel]); useEffect(() => save("pinned", pinned), [pinned]); useEffect(() => save("expanded", expanded), [expanded]); useEffect(() => save("tree", tasks), [tasks]); useEffect(() => save("ptree", ptree), [ptree]); /* 동적 트리 인덱스 — PROJ 룩업도 동기화 */ const IX = React.useMemo(() => { const ix = TD.buildIndex(ptree); Object.keys(PROJ).forEach((k) => delete PROJ[k]); ix.projects.forEach((p) => { PROJ[p.id] = p; }); return ix; }, [ptree]); useEffect(() => { const el = shellRef.current; const id = requestAnimationFrame(() => el && el.classList.add("entered")); return () => cancelAnimationFrame(id); }, []); const counts = {}; IX.projects.forEach((p) => { counts[p.id] = tasks.filter((t) => t.status !== "done" && IX.isDescendant(t.project, p.id)).length; }); const totalActive = tasks.filter((t) => t.status !== "done").length; const SCOPES = { work: { label: "업무", icon: "folder" }, life: { label: "개인", icon: "heart" }, fav: { label: "즐겨찾기", icon: "star" } }; const isScope = !!SCOPES[sel]; const inScope = (t) => { if (sel === "fav") return pinned.some((pid) => t.id === pid || IX.isDescendant(t.project, pid)); return IX.isDescendant(t.project, sel); // work·life·project 모두 isDescendant로 처리 }; const filtered = tasks.filter((t) => inScope(t) && (!q.trim() || t.title.toLowerCase().includes(q.trim().toLowerCase()))); const scopeAll = tasks.filter(inScope); const doneCount = scopeAll.filter((t) => t.status === "done").length; const uid = () => "n" + nextId.current++; const open = (id) => { setOpenRoot(id); setFocusId(id); }; const close = () => { setOpenRoot(null); setFocusId(null); }; const onMove = (id, status) => setTasks((ts) => editTree(ts, id, (n) => ({ ...n, status }))); const onField = (id, patch) => setTasks((ts) => editTree(ts, id, (n) => ({ ...n, ...patch }))); const onCheck = (id) => setTasks((ts) => editTree(ts, id, (n) => ({ ...n, status: n.status === "done" ? "todo" : "done" }))); const onAddChild = (parentId, title) => { const parent = getNode(tasks, parentId); const child = { id: uid(), title, status: "todo", assignee: parent ? parent.assignee : "jiwoo", due: "", prio: "보통", project: parent ? parent.project : "me", notes: "", est: "", delegated: false, comments: [], children: [] }; setTasks((ts) => editTree(ts, parentId, (n) => ({ ...n, children: [...(n.children || []), child] }))); }; const onScaffold = (parentId, items) => { const parent = getNode(tasks, parentId); const kids = items.map((it) => ({ id: uid(), title: it.title, status: "todo", assignee: parent ? parent.assignee : "jiwoo", due: "", prio: "보통", project: parent ? parent.project : "me", notes: "", est: it.est || "", delegated: false, comments: [], children: [] })); setTasks((ts) => editTree(ts, parentId, (n) => ({ ...n, children: [...(n.children || []), ...kids] }))); }; const onDelegate = (id, who) => setTasks((ts) => editTree(ts, id, (n) => ({ ...n, assignee: who, status: "waiting", delegated: true }))); const onAdd = (status, title) => { const id = uid(); const proj = !isScope ? sel : sel === "work" ? "biz" : "me"; // 스코프 선택 시 대표 프로젝트로 setTasks((ts) => [{ id, title, status, assignee: "jiwoo", due: "06-15", prio: "보통", project: proj, notes: "", est: "", delegated: false, comments: [], children: [] }, ...ts]); return id; }; const newTask = () => { const id = onAdd("todo", "새 작업"); setView("kanban"); setTimeout(() => open(id), 60); }; const onNotes = (id, notes) => setTasks((ts) => editTree(ts, id, (n) => ({ ...n, notes }))); const onAddComment = (id, text) => setTasks((ts) => editTree(ts, id, (n) => ({ ...n, comments: [...(n.comments || []), { id: uid(), who: "jiwoo", text, time: "방금" }] }))); const onDelete = (id) => { if (id === openRoot) { setTasks((ts) => removeFromTree(ts, id)); close(); return; } const p = findPath(tasks, id); const parent = p && p.length > 1 ? p[p.length - 2] : null; setTasks((ts) => removeFromTree(ts, id)); if (parent) setFocusId(parent.id); }; const onPin = (id) => setPinned((ps) => (ps.includes(id) ? ps.filter((x) => x !== id) : [...ps, id])); const onToggle = (id) => setExpanded((e) => ({ ...e, [id]: !e[id] })); const rec = isScope ? null : IX.byId[sel]; const path = isScope ? [] : IX.pathOf(sel); /* 현재 스코프(업무/개인) — 프로젝트 선택 시엔 그 프로젝트의 최상위 폴더 */ const sphereOf = (id) => { const p = IX.pathOf(id); return p.length ? p[0].id : null; }; const activeSphere = sel === "fav" ? null : (sel === "work" || sel === "life") ? sel : sphereOf(sel); const sphereNode = activeSphere ? ptree.find((n) => n.id === activeSphere) : null; const sphereKids = sphereNode ? (sphereNode.children || []) : []; /* 프로젝트 생성 (현재 스코프 폴더 하위에) */ const TONES = ["blue", "coral", "green", "violet", "amber"]; const addProjNode = (nodes, parentId, node) => nodes.map((n) => n.id === parentId ? { ...n, children: [...(n.children || []), node] } : (n.children && n.children.length ? { ...n, children: addProjNode(n.children, parentId, node) } : n)); const onAddProject = (parentId, name) => { const node = { id: "p" + Date.now(), name, tone: TONES[name.length % TONES.length], children: [] }; setPtree((tr) => addProjNode(tr, parentId, node)); setExpanded((e) => ({ ...e, [parentId]: true })); }; return (
{/* 상단바 — 메인 메뉴 */}
아리AI LIFE OS
{PPL.jiwoo.initial}
{/* 본문 */}
{/* 서브 메뉴 사이드바 */} {/* 메인 */}
{isScope ? ( <>{SCOPES[sel].label} ) : path.map((p, i) => ( {i === path.length - 1 ? {p.name} : } ))}
{scopeAll.length}개 작업 · {doneCount}개 완료{rec && rec.hasChildren ? ` · 하위 프로젝트 ${IX.projects.filter((p) => p.parentId === rec.id).length}개` : ""} {rec && ( )}
{VIEWS.map((v) => ( ))}
setQ(e.target.value)} placeholder="작업 검색…" />
{/* 리스크 레이더 — 마감 지연 · 업무 쏠림 · 의존성 (업무 스코프에서만) */} {activeSphere === "work" && window.TaskRisk && ( )} {view === "kanban" && } {view === "list" && }
{focusId && ( )}
); } ReactDOM.createRoot(document.getElementById("root")).render();