|
|
/* 아리 — 작업(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 />);
|