You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

849 lines
45 KiB
JavaScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/* 아리 — 작업(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 (
<svg className="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth={w || 1.9} strokeLinecap="round" strokeLinejoin="round">
{P[d].split("|").map((s, i) => <path key={i} d={s} />)}
</svg>
);
}
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 <div className="av" style={{ background: p.c }} title={p.name}>{p.initial}</div>; }
function Tag({ projId }) { const p = PROJ[projId]; if (!p) return null; return (<span className="ktag"><span className="pdot" style={{ background: `var(--${p.tone})` }} /><span>{p.name}</span></span>); }
/* ---------------- 트리 노드 ---------------- */
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 (
<>
<div className={"tree-row" + (sel === node.id ? " on" : "") + (isFolder ? " folder" : "")} style={{ paddingLeft: 8 + depth * 15 }} onClick={() => onSelect(node.id)}>
{hasCh ? (
<button className={"tree-chev" + (open ? " open" : "")} onClick={(e) => { e.stopPropagation(); onToggle(node.id); }}><I d="chev" /></button>
) : <span className="tree-chev-spacer" />}
{isFolder
? <span className="tree-fold"><I d="folder" /></span>
: <span className="tree-dot" style={{ background: `var(--${node.tone})` }} />}
<span className="tree-name">{node.name}</span>
{counts[node.id] > 0 && <span className="tree-count">{counts[node.id]}</span>}
<button className={"tree-pin" + (isPinned ? " on" : "")} onClick={(e) => { e.stopPropagation(); onPin(node.id); }} aria-label="즐겨찾기"><I d="star" w={isPinned ? 0 : 1.9} /></button>
</div>
{hasCh && open && (node.children || []).map((c) => (
<TreeNode key={c.id} node={c} depth={depth + 1} sel={sel} onSelect={onSelect} expanded={expanded} onToggle={onToggle} pinned={pinned} onPin={onPin} counts={counts} onAddProj={onAddProj} />
))}
{isFolder && open && (
<TreeAdd depth={depth + 1} placeholder="새 프로젝트" onAdd={(name) => 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 (
<button className="tree-add" style={{ paddingLeft: 8 + depth * 15 }} onClick={() => setEditing(true)}>
<I d="plus" /> {placeholder}
</button>
);
}
return (
<div className="tree-addrow" style={{ paddingLeft: 8 + depth * 15 }}>
<input
autoFocus value={val} placeholder={placeholder}
onChange={(e) => setVal(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") commit(); if (e.key === "Escape") { setVal(""); setEditing(false); } }}
onBlur={commit}
/>
</div>
);
}
/* ---------------- 칸반 ---------------- */
function KCard({ task, onOpen, onDrag, onDragEnd, dragging }) {
const s = stat(task);
const soon = task.status !== "done" && dueDay(task.due) <= 8;
return (
<div className={"kcard" + (dragging ? " dragging" : "") + (task.status === "done" ? " done-card" : "")}
draggable onDragStart={onDrag} onDragEnd={onDragEnd} onClick={() => onOpen(task.id)}>
<div className="kcard-top">
<Tag projId={task.project} />
<button className="kmore" onClick={(e) => { e.stopPropagation(); onOpen(task.id); }}><I d="more" /></button>
</div>
<div className="kcard-title">{task.title}</div>
{s.total > 0 && (
<div className="ksub">
<div className="bar"><span style={{ width: pct(task) + "%" }} /></div>
<span className="ksub-num"><I d="branch" /> {s.done}/{s.total}</span>
</div>
)}
<div className="kfoot">
<span className={"due" + (soon ? " soon" : "")}><I d="cal" /> {dueLabel(task.due)}</span>
<span className={"prio-flag prio-" + task.prio}><I d="flag" /> {task.prio}</span>
{task.delegated && <span className="deleg-chip"><I d="users" />위임</span>}
<Av who={task.assignee} />
</div>
</div>
);
}
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 (
<div className="kboard">
{TD.columns.map((col) => {
const items = tasks.filter((t) => t.status === col.id);
return (
<div key={col.id} className={"kcol" + (overCol === col.id ? " over" : "")}
onDragOver={(e) => { 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); }}>
<div className="kcol-head">
<span className="kcol-dot" style={{ background: col.accent }} />
<span className="kcol-label">{col.label}</span>
<span className="kcol-count">{items.length}</span>
<button className="kcol-add" onClick={() => { setAddCol(col.id); setText(""); }}><I d="plus" /></button>
</div>
<div className="kcol-body">
{addCol === col.id && (
<div className="quickadd">
<input autoFocus value={text} placeholder="작업 입력 후 Enter…"
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") submit(col.id); if (e.key === "Escape") setAddCol(null); }}
onBlur={() => submit(col.id)} />
</div>
)}
{items.map((t) => (
<KCard key={t.id} task={t} onOpen={onOpen} dragging={dragId === t.id}
onDrag={(e) => { setDragId(t.id); e.dataTransfer.effectAllowed = "move"; }}
onDragEnd={() => { setDragId(null); setOverCol(null); }} />
))}
{items.length === 0 && addCol !== col.id && <div className="kcol-empty">여기로 작업을 옮겨보세요</div>}
</div>
</div>
);
})}
</div>
);
}
/* ---------------- 리스트 (상태별 그룹) ---------------- */
function LRow({ t, onOpen }) {
const s = stat(t), sm = STATUS_META[t.status];
const soon = t.status !== "done" && dueDay(t.due) <= 8;
return (
<div className={"lrow" + (t.status === "done" ? " done-row" : "")} onClick={() => onOpen(t.id)}>
<div className="lt">
<span className="lt-status" style={{ background: sm.c }} />
<span className="lt-title">{t.title}</span>
</div>
<div className="lcell hide-sm"><Tag projId={t.project} /></div>
<div className="lcell center hide-sm"><Av who={t.assignee} /></div>
<div className="lcell"><span className={"due" + (soon ? " soon" : "")}><I d="cal" /> {dueLabel(t.due)}</span></div>
<div className="lcell hide-sm"><span className={"prio-flag prio-" + t.prio}><I d="flag" /> {t.prio}</span></div>
<div className="lcell">
{s.total > 0 ? (
<span className="lprog"><span className="bar"><span style={{ width: pct(t) + "%" }} /></span><span className="n">{s.done}/{s.total}</span></span>
) : <span className="n" style={{ color: "var(--faint)" }}></span>}
</div>
</div>
);
}
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 (
<div className="lgroups">
{groups.map((g) => (
<div className="lgroup" key={g.id}>
<div className="lgroup-head">
<span className="lgroup-dot" style={{ background: g.accent }} />
<span className="lgroup-label">{g.label}</span>
<span className="lgroup-count">{g.items.length}</span>
</div>
<div className="ltable">
<div className="lhead">
<div>작업</div>
<div className="hide-sm">프로젝트</div>
<div className="center hide-sm">담당</div>
<div>마감</div>
<div className="hide-sm">우선순위</div>
<div>진행</div>
</div>
{g.items.map((t) => <LRow key={t.id} t={t} onOpen={onOpen} />)}
</div>
</div>
))}
{groups.length === 0 && <div className="lempty">표시할 작업이 없어요</div>}
</div>
);
}
/* ---------------- 리치 노트 에디터 ---------------- */
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 (
<div className="rich">
<div className="rich-tools">
{NOTE_TOOLS.map((t, i) => (
<button key={i} className="rt-btn" title={t.title || t.txt}
onMouseDown={(e) => { e.preventDefault(); exec(t.cmd, t.val); }}>
{t.icon ? <I d={t.icon} /> : <span className="rt-txt">{t.txt}</span>}
</button>
))}
</div>
<div className="rich-edit" ref={ref} contentEditable suppressContentEditableWarning
data-ph="자유롭게 메모하세요 — 제목, 목록, 인용을 섞어 쓸 수 있어요."
onInput={() => onChange(ref.current.innerHTML)} />
</div>
);
}
/* ---------------- 코멘트 ---------------- */
function Comments({ list, onAdd }) {
const [txt, setTxt] = useState("");
const me = PPL.jiwoo;
const submit = () => { if (txt.trim()) { onAdd(txt.trim()); setTxt(""); } };
return (
<div className="cmt">
<div className="cmt-list">
{list.length === 0 && <div className="cmt-empty">아직 코멘트가 없어요. 의견을 남겨보세요.</div>}
{list.map((c) => {
const p = PPL[c.who] || me;
return (
<div className="cmt-row" key={c.id}>
<div className="av" style={{ background: p.c }}>{p.initial}</div>
<div className="cmt-bub">
<div className="cmt-top"><b>{p.name}{p.me ? " (나)" : ""}</b><span className="t">{c.time}</span></div>
<div className="cmt-text">{c.text}</div>
</div>
</div>
);
})}
</div>
<div className="cmt-compose">
<div className="av" style={{ background: me.c }}>{me.initial}</div>
<input value={txt} placeholder="코멘트 추가…" onChange={(e) => setTxt(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") submit(); }} />
<button className="cmt-send" onClick={submit} disabled={!txt.trim()} aria-label="코멘트 등록"><I d="send" /></button>
</div>
</div>
);
}
/* ---------------- 하위 작업 행 (재귀 · 클릭하면 그 작업으로 드릴다운) ---------------- */
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 (
<>
<div className={"subrow" + (done ? " done" : "")} style={{ paddingLeft: 6 + depth * 22 }} onClick={() => onFocus(node.id)}>
{has
? <button className={"sub-twist" + (open ? " open" : "")} onClick={(e) => { e.stopPropagation(); onExpand(node.id); }} aria-label="펼치기"><I d="chev" /></button>
: <span className="sub-twist-sp" />}
<div className="sub-check" onClick={(e) => { e.stopPropagation(); onCheck(node.id); }}><I d="tick" w="3" /></div>
<span className="sub-title">{node.title}</span>
<div className="sub-meta">
{has && <span className="sub-chip"><I d="branch" /> {s.done}/{s.total}</span>}
{node.est && <span className="sub-chip est"><I d="clock" /> {node.est}</span>}
{node.due && <span className="sub-chip"><I d="cal" /> {dueLabel(node.due)}</span>}
<Av who={node.assignee} />
<button className="sub-drill" onClick={(e) => { e.stopPropagation(); onFocus(node.id); }} aria-label="작업 열기"><I d="chev" /></button>
</div>
</div>
{has && open && (
<div className="subkids">
{node.children.map((c) => (
<SubRow key={c.id} node={c} depth={depth + 1} expanded={expanded} onExpand={onExpand} onCheck={onCheck} onFocus={onFocus} />
))}
</div>
)}
</>
);
}
/* ---------------- 상세 패널 (포커스 기반 · 브레드크럼 드릴다운) ---------------- */
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 (
<>
<div className="dp-backdrop" onClick={onClose} />
<aside className="dpanel" role="dialog" aria-label="작업 상세">
<div className="dp-head">
<button className="dp-up" onClick={() => (crumbs.length > 1 ? onFocus(crumbs[crumbs.length - 2].id) : onClose())} aria-label="위로"><I d="back" /></button>
<div className="dp-crumbs">
{crumbs.map((c, i) => (
<React.Fragment key={c.id}>
{i > 0 && <span className="cz-sep"><I d="chev" /></span>}
{i === crumbs.length - 1
? <span className="cz cur">{c.title}</span>
: <button className="cz" onClick={() => onFocus(c.id)}>{c.title}</button>}
</React.Fragment>
))}
</div>
<Tag projId={node.project} />
<button className="dp-close" onClick={onClose} aria-label="닫기"><I d="x" /></button>
</div>
<div className="dp-grid">
{/* 좌측 — 본문 + 하위 작업 + 코멘트 */}
<div className="dp-main">
<div className="dp-title-row">
<button className={"dp-check" + (node.status === "done" ? " done" : "")} onClick={() => onCheck(node.id)} aria-label="완료 토글"><I d="tick" w="3" /></button>
<h2 className={"dp-title" + (node.status === "done" ? " done" : "")}>{node.title}</h2>
</div>
<div className="dp-label"><I d="file" /> 노트</div>
<RichNotes taskId={node.id} value={node.notes} onChange={(html) => onNotes(node.id, html)} />
<div className="dp-label"><I d="branch" /> 하위 작업 <span className="cnt">{s.done}/{s.total}</span></div>
<div className="subs">
{/* Auto-Scaffolding — 아리에게 업무 쪼개기 맡기기 */}
{!scaffold && node.status !== "done" && (
<button className="scaffold-trigger" onClick={() => setScaffold(TD.pickScaffold(node.title))}>
<span className="st-ic"><I d="wand" /></span>
<span className="st-tx">
<b>아리에게 업무 쪼개기 맡기기</b>
<span> 작업을 단계별 하위 작업으로 자동 분해하고 예상 시간을 배분해요</span>
</span>
<I d="arrow" />
</button>
)}
{scaffold && (
<div className="scaffold-panel">
<div className="sp-head">
<span className="sp-ic"><I d="spark2" /></span>
<div className="sp-h"><b>{scaffold.kind} 워크플로우 추천</b><span>{scaffold.items.length} · </span></div>
<button className="sp-x" onClick={() => setScaffold(null)} aria-label="닫기"><I d="x" /></button>
</div>
<div className="sp-items">
{scaffold.items.map((it, i) => (
<div className="sp-item" key={i}>
<span className="sp-num">{i + 1}</span>
<span className="sp-title">{it.title}</span>
<span className="sp-est"><I d="clock" />{it.est}</span>
</div>
))}
</div>
<div className="sp-foot">
<button className="sp-ghost" onClick={() => setScaffold(null)}>닫기</button>
<button className="sp-apply" onClick={() => { onScaffold(node.id, scaffold.items); setScaffold(null); }}>
<I d="plus" />{scaffold.items.length} 하위 작업 모두 추가
</button>
</div>
</div>
)}
{s.total > 0
? node.children.map((c) => (
<SubRow key={c.id} node={c} depth={0} expanded={expanded} onExpand={onExpand} onCheck={onCheck} onFocus={onFocus} />
))
: !adding && !scaffold && <div className="subempty"><I d="branch" /> 아직 하위 작업이 없어요. 버튼으로 아리에게 맡기거나 직접 추가하세요.</div>}
{adding ? (
<input className="sub-input" autoFocus value={subText} placeholder="하위 작업 입력 후 Enter…"
onChange={(e) => setSubText(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") submitSub(); if (e.key === "Escape") setAdding(false); }}
onBlur={submitSub} />
) : (
<button className="sub-add" onClick={() => setAdding(true)}><I d="plus" /> 하위 작업 추가</button>
)}
</div>
<div className="dp-label"><I d="msg" /> 코멘트 <span className="cnt">{comments.length}</span></div>
<Comments list={comments} onAdd={(text) => onAddComment(node.id, text)} />
</div>
{/* 우측 — 메타 */}
<div className="dp-rail">
<div className="dp-meta">
<div className="dp-mi">
<div className="ml"><I d="user" /> 담당자</div>
<div className="dp-mv"><Av who={node.assignee} /> {person.name}{person.me ? " (나)" : ""}</div>
</div>
<div className="dp-mi">
<div className="ml"><I d="cal" /> 마감일</div>
<div className="dp-mv">{node.due ? `6월 ${dueDay(node.due)}` : "미정"}</div>
</div>
<div className="dp-mi">
<div className="ml"><I d="flag" /> 우선순위</div>
<div className={"dp-mv prio-" + node.prio}><I d="flag" /> {node.prio}</div>
</div>
<div className="dp-mi">
<div className="ml"><I d="list" /> 상태</div>
<div className="status-wrap">
<button className="status-pill" onClick={() => setMenu((o) => !o)}>
<span className="pdot" style={{ background: sm.c }} /> {sm.label} <I d="chev" />
</button>
{menu && (
<div className="status-menu">
{TD.columns.map((c) => (
<button key={c.id} onClick={() => { onField(node.id, { status: c.id }); setMenu(false); }}>
<span className="pdot" style={{ background: STATUS_META[c.id].c }} /> {c.label}
</button>
))}
</div>
)}
</div>
</div>
</div>
{/* 위임 — 매니지먼트 & 위임 지원 */}
{node.delegated ? (
<div className="delegated-note">
<Av who={node.assignee} />
<div className="dn-tx"><b>{person.name}님께 위임됨</b><span> · </span></div>
</div>
) : (() => {
const collab = node.assignee === "jiwoo" && node.status !== "done" ? suggestCollaborator(tasks, node.project) : null;
return collab && (
<div className="delegate-sug">
<div className="ds-head"><I d="spark2" />위임 & 추적</div>
<p>직접 하기 벅차다면 {collab.sameProject ? "이 프로젝트를 함께 하는" : "함께 일해온"} <b>{PPL[collab.who].name}</b> . .</p>
<button className="ds-btn" onClick={() => onDelegate(node.id, collab.who)}>
<I d="users" />{PPL[collab.who].name}님께 위임하기
</button>
</div>
);
})()}
</div>
</div>
<div className="dp-foot">
<button className="btn danger" onClick={() => onDelete(node.id)} aria-label="삭제"><I d="trash" /></button>
{crumbs.length > 1 && (
<button className="btn ghost" onClick={() => onFocus(crumbs[crumbs.length - 2].id)}><I d="back" /> 상위로</button>
)}
<button className="btn primary" onClick={onClose}><I d="check" /> 완료</button>
</div>
</aside>
</>
);
}
/* ---------------- 앱 ---------------- */
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 (
<div className="tpage" ref={shellRef}>
{/* 상단바 — 메인 메뉴 */}
<header className="topbar">
<div className="brand">
<div className="brand-mark"><I d="spark" /></div>
<div className="brand-tag"><b>아리</b><span>AI LIFE OS</span></div>
</div>
<nav className="mainnav">
{MAIN.map((m) => (
<a key={m.id} href={m.href} className={m.id === "task" ? "active" : ""}>
<I d={m.icon} />{m.label}{m.id === "task" ? <span className="badge">{totalActive}</span> : (m.badge && <span className="badge">{m.badge}</span>)}
</a>
))}
</nav>
<div className="top-actions">
<button className="t-btn" aria-label="검색"><I d="search" /></button>
<button className="t-btn dot" aria-label="알림"><I d="bell" /></button>
<button className="t-btn" aria-label="테마" onClick={() => setTheme(theme === "light" ? "dark" : "light")}><I d={theme === "light" ? "moon" : "sun"} /></button>
<div className="t-ava">{PPL.jiwoo.initial}</div>
</div>
</header>
{/* 본문 */}
<div className="twork">
{/* 서브 메뉴 사이드바 */}
<aside className="subnav">
<div className="sn-title">작업</div>
<div className="sn-scroll">
{/* 업무 / 개인 — 펼치면 프로젝트 목록 */}
{ptree.map((f) => {
const kids = f.children || [];
const open = expanded[f.id];
return (
<React.Fragment key={f.id}>
<div
className={"tree-row folder" + (sel === f.id ? " on" : "")}
style={{ paddingLeft: 8 }}
onClick={() => { setSel(f.id); setExpanded((e) => ({ ...e, [f.id]: true })); }}
>
<button className={"tree-chev" + (open ? " open" : "")} onClick={(e) => { e.stopPropagation(); onToggle(f.id); }}><I d="chev" /></button>
<span className="tree-fold"><I d={f.icon || "folder"} /></span>
<span className="tree-name">{f.name}</span>
<span className="tree-badge">{counts[f.id] || 0}</span>
</div>
{open && kids.map((n) => (
<TreeNode key={n.id} node={n} depth={1} sel={sel} onSelect={setSel} expanded={expanded} onToggle={onToggle} pinned={pinned} onPin={onPin} counts={counts} onAddProj={onAddProject} />
))}
{open && <TreeAdd depth={1} placeholder="새 프로젝트" onAdd={(name) => onAddProject(f.id, name)} />}
</React.Fragment>
);
})}
{/* 즐겨찾기 — 펼치면 별표한 프로젝트 */}
<div
className={"tree-row folder" + (sel === "fav" ? " on" : "")}
style={{ paddingLeft: 8 }}
onClick={() => { setSel("fav"); setExpanded((e) => ({ ...e, fav: true })); }}
>
<button className={"tree-chev" + (expanded.fav ? " open" : "")} onClick={(e) => { e.stopPropagation(); onToggle("fav"); }}><I d="chev" /></button>
<span className="tree-fold"><I d="star" /></span>
<span className="tree-name">즐겨찾기</span>
<span className="tree-badge">{pinned.length}</span>
</div>
{expanded.fav && (
pinned.length > 0 ? pinned.map((pid) => {
const p = IX.byId[pid]; if (!p) return null;
return (
<div key={pid} className={"tree-row" + (sel === pid ? " on" : "")} style={{ paddingLeft: 8 + 15 }} onClick={() => setSel(pid)}>
<span className="tree-chev-spacer" />
<span className="tree-dot" style={{ background: `var(--${p.tone})` }} />
<span className="tree-name">{p.name}</span>
<button className="tree-pin on" onClick={(e) => { e.stopPropagation(); onPin(pid); }} aria-label="즐겨찾기 해제"><I d="star" w="0" /></button>
</div>
);
}) : (
<div className="sn-empty" style={{ paddingLeft: 8 + 15 }}><I d="star" />제목 별표를 눌러 모아두세요</div>
)
)}
</div>
<div className="sn-foot">
<div className="av">{PPL.jiwoo.initial}</div>
<div className="txt"><b>지우님</b><span>Pro </span></div>
</div>
</aside>
{/* 메인 */}
<main className="tmain">
<div className="crumbs">
<button onClick={() => setSel(activeSphere || "work")}>워크스페이스</button>
{isScope ? (
<><span className="sep"><I d="chev" /></span><span className="cur">{SCOPES[sel].label}</span></>
) : path.map((p, i) => (
<React.Fragment key={p.id}>
<span className="sep"><I d="chev" /></span>
{i === path.length - 1
? <span className="cur"><span className="c-dot" style={{ background: `var(--${p.tone})` }} /> {p.name}</span>
: <button onClick={() => setSel(p.id)}>{p.name}</button>}
</React.Fragment>
))}
</div>
<div className="thead-slim">
<span className="ts-ic" style={rec ? { color: `var(--${rec.tone})` } : { color: "var(--muted)" }}>
<I d={rec ? (rec.hasChildren ? "folder" : "hash") : (isScope ? SCOPES[sel].icon : "inbox")} />
</span>
<span className="ts-meta">{scopeAll.length} 작업 · {doneCount} 완료{rec && rec.hasChildren ? ` · 하위 프로젝트 ${IX.projects.filter((p) => p.parentId === rec.id).length}` : ""}</span>
{rec && (
<button className={"th-pin" + (pinned.includes(rec.id) ? " on" : "")} onClick={() => onPin(rec.id)}>
<I d="star" w={pinned.includes(rec.id) ? 0 : 1.9} /> {pinned.includes(rec.id) ? "즐겨찾기됨" : "즐겨찾기"}
</button>
)}
</div>
<div className="ttoolbar">
<div className="view-seg">
{VIEWS.map((v) => (
<button key={v.id} className={view === v.id ? "on" : ""} onClick={() => setView(v.id)}><I d={v.icon} /> {v.label}</button>
))}
</div>
<div className="t-search"><I d="search" /><input value={q} onChange={(e) => setQ(e.target.value)} placeholder="작업 검색…" /></div>
<button className="t-new" onClick={newTask}><I d="plus" /> 작업</button>
</div>
{/* 리스크 레이더 — 마감 지연 · 업무 쏠림 · 의존성 (업무 스코프에서만) */}
{activeSphere === "work" && window.TaskRisk && (
<window.TaskRisk.RiskRadar tasks={tasks} onOpen={open} />
)}
{view === "kanban" && <KanbanView tasks={filtered} onOpen={open} onMove={onMove} onAdd={onAdd} />}
{view === "list" && <ListView tasks={filtered} onOpen={open} />}
</main>
</div>
{focusId && (
<DetailPanel tasks={tasks} focusId={focusId} rootId={openRoot} onFocus={setFocusId} onClose={close}
onCheck={onCheck} onField={onField} onAddChild={onAddChild} onScaffold={onScaffold} onDelegate={onDelegate} onDelete={onDelete} onNotes={onNotes} onAddComment={onAddComment} />
)}
</div>
);
}
ReactDOM.createRoot(document.getElementById("root")).render(<App />);