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.
583 lines
29 KiB
JavaScript
583 lines
29 KiB
JavaScript
/* 아리 — 작업(Tasks) · RON 디자인 언어
|
|
· 칸반 / 리스트 뷰 (Asana 스타일)
|
|
· 항목 클릭 → 우측 절반 드로어 · 자식 노드를 무한 중첩으로 추가 */
|
|
const { useState, useEffect, useRef } = React;
|
|
const TD = window.AriTasks;
|
|
const PPL = TD.people;
|
|
const PRJ = TD.projects;
|
|
|
|
/* ---------- 아이콘 ---------- */
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
zap: "M13 2 3 14h9l-1 8 10-12h-9z",
|
|
back: "M19 12H5|M12 19l-7-7 7-7",
|
|
user: "M20 21a8 8 0 0 0-16 0|M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z",
|
|
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",
|
|
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",
|
|
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",
|
|
hash: "M4 9h16M4 15h16M10 3L8 21M16 3l-2 18",
|
|
layers: "M12 2 2 7l10 5 10-5z|M2 17l10 5 10-5|M2 12l10 5 10-5",
|
|
};
|
|
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 NAV = [
|
|
{ id: "dash", label: "대시보드", icon: "grid", href: "아리 대시보드 (RON).html" },
|
|
{ id: "journey", label: "여정", icon: "route", href: "아리 대시보드 (RON v2).html" },
|
|
{ id: "cal", label: "일정", icon: "cal", href: "#" },
|
|
{ id: "task", label: "작업", icon: "check", href: "#" },
|
|
{ id: "mail", label: "메일", icon: "mail", href: "#" },
|
|
{ id: "life", label: "라이프", icon: "heart", href: "#" },
|
|
];
|
|
const VIEWS = [
|
|
{ id: "kanban", label: "칸반", icon: "grid" },
|
|
{ id: "list", label: "리스트", icon: "list" },
|
|
];
|
|
const FILTERS = [
|
|
{ id: "all", icon: "inbox", label: "전체 작업" },
|
|
{ id: "active", icon: "zap", label: "진행 중" },
|
|
{ id: "star", icon: "star", label: "즐겨찾기" },
|
|
{ id: "done", icon: "check", label: "완료" },
|
|
];
|
|
const STATUS = {
|
|
todo: { label: "할 일", c: "var(--faint)" },
|
|
doing: { label: "진행 중", c: "var(--blue-d)" },
|
|
review: { label: "검토", c: "var(--red)" },
|
|
done: { label: "완료", c: "var(--teal)" },
|
|
};
|
|
|
|
/* ---------- localStorage ---------- */
|
|
const load = (k, fb) => { try { const v = localStorage.getItem("ariTR." + k); return v == null ? fb : JSON.parse(v); } catch (e) { return fb; } };
|
|
const save = (k, v) => { try { localStorage.setItem("ariTR." + 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 = (d) => (d ? parseInt(d.split("-")[1], 10) : 99);
|
|
const dueLabel = (d) => { if (!d) return "—"; const [m, x] = d.split("-"); return `${+m}/${+x}`; };
|
|
const TODAY = 9; // 6월 9일
|
|
|
|
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 Ptag({ id }) { const p = PRJ[id]; if (!p) return null; return (<span className="ptag"><span className="pdot" style={{ background: p.color }} />{p.name}</span>); }
|
|
|
|
/* ===================== 칸반 ===================== */
|
|
function Card({ task, onOpen, onDrag, onDragEnd, dragging }) {
|
|
const s = stat(task);
|
|
const soon = task.status !== "done" && task.due && dueDay(task.due) <= TODAY + 1;
|
|
return (
|
|
<div className={"tcard" + (dragging ? " dragging" : "") + (task.status === "done" ? " done" : "")}
|
|
draggable onDragStart={onDrag} onDragEnd={onDragEnd} onClick={() => onOpen(task.id)}>
|
|
<div className="tcard-top">
|
|
<Ptag id={task.project} />
|
|
<button className="kmore" onClick={(e) => { e.stopPropagation(); onOpen(task.id); }} aria-label="열기"><I d="more" /></button>
|
|
</div>
|
|
<div className="tcard-title">{task.title}</div>
|
|
{s.total > 0 && (
|
|
<div className="miniprog">
|
|
<span className="bar"><span style={{ width: pct(task) + "%" }} /></span>
|
|
<span className="n"><I d="branch" /> {s.done}/{s.total}</span>
|
|
</div>
|
|
)}
|
|
<div className="tcard-foot">
|
|
<span className={"due" + (soon ? " soon" : "")}><I d="cal" /> {dueLabel(task.due)}</span>
|
|
<span className={"prio prio-" + task.prio}><I d="flag" /> {task.prio}</span>
|
|
<Av who={task.assignee} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
function Kanban({ tasks, onOpen, onMove, onAdd }) {
|
|
const [dragId, setDragId] = useState(null);
|
|
const [over, setOver] = useState(null);
|
|
const [addCol, setAddCol] = useState(null);
|
|
const [text, setText] = useState("");
|
|
const submit = (st) => { if (text.trim()) onAdd(st, 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" + (over === col.id ? " over" : "")}
|
|
onDragOver={(e) => { e.preventDefault(); if (over !== col.id) setOver(col.id); }}
|
|
onDragLeave={(e) => { if (e.currentTarget === e.target) setOver(null); }}
|
|
onDrop={(e) => { e.preventDefault(); if (dragId) onMove(dragId, col.id); setDragId(null); setOver(null); }}>
|
|
<div className="kcol-head">
|
|
<span className="kdot" style={{ background: col.accent }} />
|
|
<span className="kt">{col.label}</span>
|
|
<span className="kn">{items.length}</span>
|
|
<button className="kadd" onClick={() => { setAddCol(col.id); setText(""); }} aria-label="추가"><I d="plus" /></button>
|
|
</div>
|
|
<div className="kcol-body">
|
|
{addCol === col.id && (
|
|
<input className="sub-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)} />
|
|
)}
|
|
{items.map((t) => (
|
|
<Card key={t.id} task={t} onOpen={onOpen} dragging={dragId === t.id}
|
|
onDrag={(e) => { setDragId(t.id); e.dataTransfer.effectAllowed = "move"; }}
|
|
onDragEnd={() => { setDragId(null); setOver(null); }} />
|
|
))}
|
|
{items.length === 0 && addCol !== col.id && <div className="kcol-empty">여기로 옮겨보세요</div>}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ===================== 리스트 ===================== */
|
|
function ListView({ tasks, onOpen }) {
|
|
const order = { todo: 0, doing: 1, review: 2, done: 3 };
|
|
const sorted = [...tasks].sort((a, b) => order[a.status] - order[b.status] || dueDay(a.due) - dueDay(b.due));
|
|
return (
|
|
<div className="lst">
|
|
<div className="lst-head">
|
|
<div>작업</div>
|
|
<div className="hide-sm">프로젝트</div>
|
|
<div className="c">담당</div>
|
|
<div>마감</div>
|
|
<div className="hide-md">우선순위</div>
|
|
<div className="hide-sm">하위</div>
|
|
</div>
|
|
{sorted.map((t) => {
|
|
const s = stat(t), sm = STATUS[t.status];
|
|
const soon = t.status !== "done" && t.due && dueDay(t.due) <= TODAY + 1;
|
|
return (
|
|
<div key={t.id} className={"lrow" + (t.status === "done" ? " done" : "")} onClick={() => onOpen(t.id)}>
|
|
<div className="lt">
|
|
<span className="lstatus" style={{ background: sm.c }} />
|
|
<span className="lt-title">{t.title}{s.total > 0 && <span className="subcount">{s.done}/{s.total}</span>}</span>
|
|
</div>
|
|
<div className="hide-sm"><Ptag id={t.project} /></div>
|
|
<div className="c"><Av who={t.assignee} /></div>
|
|
<div><span className={"due" + (soon ? " soon" : "")}><I d="cal" /> {dueLabel(t.due)}</span></div>
|
|
<div className="hide-md"><span className={"prio prio-" + t.prio}><I d="flag" /> {t.prio}</span></div>
|
|
<div className="hide-sm">
|
|
{s.total > 0
|
|
? <span className="miniprog"><span className="bar"><span style={{ width: pct(t) + "%" }} /></span></span>
|
|
: <span style={{ color: "var(--faint)", fontFamily: "var(--font-mono)", fontSize: 12 }}>—</span>}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ===================== 하위 작업 행 (재귀) ===================== */
|
|
function SubRow({ node, 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" : "")} onClick={() => onFocus(node.id)}>
|
|
{has ? (
|
|
<button className={"twist" + (open ? " open" : "")} onClick={(e) => { e.stopPropagation(); onExpand(node.id); }} aria-label="펼치기"><I d="chev" /></button>
|
|
) : <span className="twist-spacer" />}
|
|
<div className={"scheck" + (done ? " done" : "")} onClick={(e) => { e.stopPropagation(); onCheck(node.id); }}><I d="tick" w="3" /></div>
|
|
<span className="stitle">{node.title}</span>
|
|
<div className="smeta">
|
|
{has && <span className="schips"><I d="branch" /> {s.done}/{s.total}</span>}
|
|
{node.due && <span className="schips"><I d="cal" /> {dueLabel(node.due)}</span>}
|
|
<Av who={node.assignee} />
|
|
<button className="sdrill" 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} expanded={expanded} onExpand={onExpand} onCheck={onCheck} onFocus={onFocus} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/* 자동 높이 제목 입력 */
|
|
function TitleInput({ value, done, onChange }) {
|
|
const ref = useRef(null);
|
|
useEffect(() => { const el = ref.current; if (el) { el.style.height = "auto"; el.style.height = el.scrollHeight + "px"; } }, [value]);
|
|
return <textarea ref={ref} className={"dtitle" + (done ? " done" : "")} rows={1} value={value}
|
|
onChange={(e) => onChange(e.target.value)} spellCheck={false} />;
|
|
}
|
|
|
|
/* ===================== 우측 드로어 ===================== */
|
|
function Drawer({ tasks, focusId, rootId, onFocus, onClose, onCheck, onAddChild, onField, onDelete }) {
|
|
const [expanded, setExpanded] = useState({});
|
|
const [adding, setAdding] = useState(false);
|
|
const [subText, setSubText] = useState("");
|
|
const [editDesc, setEditDesc] = useState(false);
|
|
const [descText, setDescText] = useState("");
|
|
const [menu, setMenu] = useState(false);
|
|
|
|
const path = findPath(tasks, focusId) || [];
|
|
const node = path[path.length - 1];
|
|
useEffect(() => { setAdding(false); setEditDesc(false); setMenu(false); }, [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 s = stat(node);
|
|
const person = PPL[node.assignee];
|
|
const sm = STATUS[node.status];
|
|
const done = node.status === "done";
|
|
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="drawer-scrim" onClick={onClose} />
|
|
<aside className="drawer" role="dialog" aria-label="작업 상세">
|
|
{/* 헤더 — 브레드크럼 */}
|
|
<div className="dh">
|
|
<button className="dh-up" onClick={() => crumbs.length > 1 ? onFocus(crumbs[crumbs.length - 2].id) : onClose()} aria-label="위로"><I d="back" /></button>
|
|
<div className="dh-crumbs">
|
|
{crumbs.map((c, i) => (
|
|
<React.Fragment key={c.id}>
|
|
{i > 0 && <span className="csep"><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>
|
|
<button className="dh-x" onClick={onClose} aria-label="닫기"><I d="x" /></button>
|
|
</div>
|
|
|
|
<div className="dbody">
|
|
{/* 제목 + 완료 체크 */}
|
|
<div className="dtitle-row">
|
|
<div className={"dcheck" + (done ? " done" : "")} onClick={() => onCheck(node.id)} role="checkbox" aria-checked={done}><I d="tick" w="3" /></div>
|
|
<TitleInput value={node.title} done={done} onChange={(v) => onField(node.id, { title: v })} />
|
|
</div>
|
|
|
|
{/* 메타 */}
|
|
<div className="dmeta">
|
|
<div className="dmi">
|
|
<div className="ml"><I d="user" /> 담당자</div>
|
|
<div className="dmv"><Av who={node.assignee} /> {person.name}{person.me ? " (나)" : ""}</div>
|
|
</div>
|
|
<div className="dmi">
|
|
<div className="ml"><I d="cal" /> 마감일</div>
|
|
<div className="dmv">{node.due ? `6월 ${dueDay(node.due)}일` : "미정"}</div>
|
|
</div>
|
|
<div className="dmi">
|
|
<div className="ml"><I d="flag" /> 우선순위</div>
|
|
<div className={"dmv prio-" + node.prio}><I d="flag" /> {node.prio}</div>
|
|
</div>
|
|
<div className="dmi">
|
|
<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[c.id].c }} /> {c.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 설명 */}
|
|
<div className="dlabel"><I d="file" /> 설명<span className="dline" /></div>
|
|
{editDesc ? (
|
|
<textarea className="ddesc-edit" autoFocus value={descText}
|
|
onChange={(e) => setDescText(e.target.value)}
|
|
onBlur={() => { onField(node.id, { desc: descText }); setEditDesc(false); }} />
|
|
) : (
|
|
<div className={"ddesc" + (node.desc ? "" : " empty")} onClick={() => { setDescText(node.desc || ""); setEditDesc(true); }}>
|
|
{node.desc || "설명이 아직 없어요. 클릭해 작성하세요."}
|
|
</div>
|
|
)}
|
|
|
|
{/* 하위 작업 (무한 중첩) */}
|
|
<div className="dlabel"><I d="branch" /> 하위 작업 <span className="cnt">{s.done}/{s.total}</span><span className="dline" /></div>
|
|
{s.total > 0 ? (
|
|
<div className="subtree">
|
|
{node.children.map((c) => (
|
|
<SubRow key={c.id} node={c} expanded={expanded} onExpand={onExpand} onCheck={onCheck} onFocus={onFocus} />
|
|
))}
|
|
</div>
|
|
) : !adding && (
|
|
<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="dfoot">
|
|
<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(() => load("view", "kanban"));
|
|
const [filter, setFilter] = useState(() => load("filter", "all"));
|
|
const [projOrder, setProjOrder] = useState(() => {
|
|
const stored = load("projects", null);
|
|
if (stored) Object.keys(stored).forEach((k) => { PRJ[k] = stored[k]; });
|
|
return load("projOrder", Object.keys(PRJ));
|
|
});
|
|
const [q, setQ] = useState("");
|
|
const [tasks, setTasks] = useState(() => load("tasks", TD.tree));
|
|
const [openRoot, setOpenRoot] = useState(null); // 드로어를 연 최상위 작업 id
|
|
const [focusId, setFocusId] = useState(null); // 현재 보고 있는 노드 id
|
|
const nextId = useRef(1000);
|
|
const ref = useRef(null);
|
|
|
|
useEffect(() => { document.documentElement.setAttribute("data-theme", theme); save("theme", theme); }, [theme]);
|
|
useEffect(() => save("view", view), [view]);
|
|
useEffect(() => save("filter", filter), [filter]);
|
|
useEffect(() => save("projOrder", projOrder), [projOrder]);
|
|
useEffect(() => save("tasks", tasks), [tasks]);
|
|
useEffect(() => { const el = ref.current; const id = requestAnimationFrame(() => el && el.classList.add("enter")); return () => cancelAnimationFrame(id); }, []);
|
|
|
|
const uid = () => "u" + nextId.current++;
|
|
const open = (id) => { setOpenRoot(id); setFocusId(id); };
|
|
const close = () => { setOpenRoot(null); setFocusId(null); };
|
|
|
|
const onCheck = (id) => setTasks((ts) => editTree(ts, id, (n) => ({ ...n, status: n.status === "done" ? "todo" : "done" })));
|
|
const onField = (id, patch) => setTasks((ts) => editTree(ts, id, (n) => ({ ...n, ...patch })));
|
|
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", desc: "", star: false, children: [] };
|
|
setTasks((ts) => editTree(ts, parentId, (n) => ({ ...n, children: [...(n.children || []), child] })));
|
|
};
|
|
const onMove = (id, status) => setTasks((ts) => ts.map((t) => (t.id === id ? { ...t, status } : t)));
|
|
const onDelete = (id) => {
|
|
if (id === openRoot) { setTasks((ts) => removeFromTree(ts, id)); close(); return; }
|
|
const path = findPath(tasks, id);
|
|
const parent = path && path.length > 1 ? path[path.length - 2] : null;
|
|
setTasks((ts) => removeFromTree(ts, id));
|
|
if (parent) setFocusId(parent.id);
|
|
};
|
|
const newTask = () => {
|
|
const id = uid();
|
|
const proj = filter.startsWith("prj:") ? filter.slice(4) : "me";
|
|
const t = { id, title: "새 작업", status: "todo", assignee: "jiwoo", due: "", prio: "보통", project: proj, desc: "", star: false, children: [] };
|
|
setTasks((ts) => [t, ...ts]);
|
|
setView("kanban");
|
|
setTimeout(() => open(id), 40);
|
|
};
|
|
const PRJ_COLORS = ["#ce6969", "#9c8bcb", "#5fa6a0", "#6286c9", "#c98a3e", "#5b8fb0", "#a86b9e"];
|
|
const addProject = () => {
|
|
const id = "p" + nextId.current++;
|
|
PRJ[id] = { name: "새 프로젝트", color: PRJ_COLORS[projOrder.length % PRJ_COLORS.length] };
|
|
save("projects", PRJ);
|
|
setProjOrder((o) => [...o, id]);
|
|
setFilter("prj:" + id);
|
|
};
|
|
const renameProject = (pid, name) => { if (PRJ[pid]) { PRJ[pid] = { ...PRJ[pid], name: name || "프로젝트" }; save("projects", PRJ); setProjOrder((o) => [...o]); } };
|
|
|
|
// 최상위 필터 + 검색
|
|
const match = (t) => {
|
|
if (filter.startsWith("prj:")) return t.project === filter.slice(4);
|
|
if (filter === "active") return t.status !== "done";
|
|
if (filter === "done") return t.status === "done";
|
|
if (filter === "star") return t.star;
|
|
return true;
|
|
};
|
|
const shown = tasks.filter((t) => match(t) && (!q.trim() || t.title.toLowerCase().includes(q.trim().toLowerCase())));
|
|
const activeCount = tasks.filter((t) => t.status !== "done").length;
|
|
const doneCount = tasks.filter((t) => t.status === "done").length;
|
|
const projCount = (pid) => tasks.filter((t) => t.project === pid && t.status !== "done").length;
|
|
const isPrj = filter.startsWith("prj:");
|
|
const curPrj = isPrj ? PRJ[filter.slice(4)] : null;
|
|
const curFilter = FILTERS.find((f) => f.id === filter);
|
|
const headTitle = isPrj ? (curPrj ? curPrj.name : "프로젝트") : (filter === "all" ? "작업" : curFilter.label);
|
|
|
|
return (
|
|
<div className="tpage app" ref={ref}>
|
|
{/* 상단바 */}
|
|
<header className="top">
|
|
<div className="brand">
|
|
<div className="brand-mark"><I d="spark" /></div>
|
|
<span className="brand-name">아리</span>
|
|
</div>
|
|
<nav className="nav">
|
|
{NAV.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">{activeCount}</span>}
|
|
</a>
|
|
))}
|
|
</nav>
|
|
<div className="top-actions">
|
|
<button className="circ" aria-label="검색"><I d="search" /></button>
|
|
<button className="circ dot" aria-label="알림"><I d="bell" /></button>
|
|
<button className="circ" aria-label="테마" onClick={() => setTheme(theme === "light" ? "dark" : "light")}><I d={theme === "light" ? "moon" : "sun"} /></button>
|
|
<div className="avatar">{PPL.jiwoo.initial}</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* 무대 */}
|
|
<div className="stage">
|
|
<aside className="tsidebar">
|
|
<button className="sb-back" onClick={() => (window.location.href = "아리 대시보드 (RON v2).html")}>
|
|
<I d="back" /> 대시보드
|
|
</button>
|
|
|
|
<div className="sb-label">보기</div>
|
|
<div className="sb-sec">
|
|
{FILTERS.map((f) => (
|
|
<button key={f.id} className={"sb-item" + (filter === f.id ? " on" : "")} onClick={() => setFilter(f.id)}>
|
|
<I d={f.icon} />
|
|
<span className="sb-name">{f.label}</span>
|
|
<span className="sb-count">
|
|
{f.id === "all" ? tasks.length : f.id === "active" ? activeCount : f.id === "done" ? doneCount : tasks.filter((t) => t.star).length}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="sb-divider" />
|
|
|
|
<div className="sb-prj-head">
|
|
<span className="sb-label">프로젝트</span>
|
|
<button className="sb-addprj" onClick={addProject} aria-label="프로젝트 추가"><I d="plus" /></button>
|
|
</div>
|
|
<div className="sb-sec">
|
|
{projOrder.map((pid) => {
|
|
const p = PRJ[pid]; if (!p) return null;
|
|
const key = "prj:" + pid;
|
|
return (
|
|
<button key={pid} className={"sb-item" + (filter === key ? " on" : "")} onClick={() => setFilter(key)}>
|
|
<span className="sb-dot" style={{ background: p.color }} />
|
|
<span className="sb-name">{p.name}</span>
|
|
<span className="sb-count">{projCount(pid)}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
<button className="sb-new" onClick={addProject}><I d="plus" /> 새 프로젝트</button>
|
|
</aside>
|
|
|
|
<main className="main">
|
|
{/* 헤더 */}
|
|
<div className="thead-row">
|
|
<div className="thead-ic" style={curPrj ? { background: curPrj.color, color: "#fff" } : null}>
|
|
<I d={isPrj ? "layers" : (curFilter ? curFilter.icon : "check")} />
|
|
</div>
|
|
<div className="thead-titles">
|
|
{isPrj ? (
|
|
<h1 key={filter} contentEditable suppressContentEditableWarning spellCheck={false}
|
|
onBlur={(e) => renameProject(filter.slice(4), e.target.textContent.trim())}
|
|
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); e.target.blur(); } }}
|
|
style={{ outline: "none" }}>{headTitle}</h1>
|
|
) : <h1>{headTitle}</h1>}
|
|
<p className="thead-sub">{shown.length}개 작업 · 진행 <b>{activeCount}</b> · 완료 <b>{doneCount}</b> · 항목을 열면 하위 작업을 무한히 만들 수 있어요</p>
|
|
</div>
|
|
</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>
|
|
|
|
{/* 본문 */}
|
|
{view === "kanban"
|
|
? <div className="panel" style={{ padding: "22px 20px" }}>
|
|
<Kanban tasks={shown} onOpen={open} onMove={onMove} onAdd={(st, title) => {
|
|
const id = uid();
|
|
const proj = filter.startsWith("prj:") ? filter.slice(4) : "me";
|
|
setTasks((ts) => [{ id, title, status: st, assignee: "jiwoo", due: "", prio: "보통", project: proj, desc: "", star: false, children: [] }, ...ts]);
|
|
}} />
|
|
</div>
|
|
: <div className="panel" style={{ padding: "20px" }}><ListView tasks={shown} onOpen={open} /></div>}
|
|
</main>
|
|
</div>
|
|
|
|
{focusId && (
|
|
<Drawer tasks={tasks} focusId={focusId} rootId={openRoot} onFocus={setFocusId} onClose={close}
|
|
onCheck={onCheck} onAddChild={onAddChild} onField={onField} onDelete={onDelete} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
|