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.
370 lines
17 KiB
JavaScript
370 lines
17 KiB
JavaScript
/* 아리 — '오늘의 여정' (클린 화이트 테마)
|
|
하루를 4단계 흐름으로 보여주고, 작업↔사람↔미팅을 실시간 베지어 커넥터로
|
|
연결한다. 카드에 마우스를 올리면 연결된 흐름이 강조된다.
|
|
상단바·페이지헤더는 dash.css의 클린 화이트 언어를 그대로 사용. */
|
|
const { useState, useEffect, useRef, useLayoutEffect, useCallback, useMemo } = React;
|
|
const J = window.AriJourney;
|
|
|
|
/* ---------- 아이콘 ---------- */
|
|
const PATHS = {
|
|
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",
|
|
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",
|
|
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",
|
|
target: "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20z|M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z|M12 12h.01",
|
|
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",
|
|
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",
|
|
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",
|
|
search: "M21 21l-4.34-4.34|M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16z",
|
|
send: "M22 2 11 13|M22 2 15 22l-4-9-9-4z",
|
|
zap: "M13 2 3 14h9l-1 8 10-12h-9z",
|
|
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",
|
|
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",
|
|
plus: "M12 5v14|M5 12h14",
|
|
share: "M4 12v7a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7|M16 6l-4-4-4 4|M12 2v13",
|
|
star: "M12 2l2.9 6.3 6.9.8-5.1 4.7 1.4 6.8L12 18l-6 3.4 1.4-6.8L2.3 9.9l6.9-.8z",
|
|
drop: "M12 2.7S5 10 5 14a7 7 0 0 0 14 0c0-4-7-11.3-7-11.3z",
|
|
link: "M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1|M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1",
|
|
};
|
|
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">
|
|
{PATHS[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 LEN = Math.PI * 64;
|
|
function Gauge({ pct, color, val, unit, label, sub }) {
|
|
return (
|
|
<div className="gauge">
|
|
<svg viewBox="0 0 160 104">
|
|
<path d="M16 88 A64 64 0 0 1 144 88" fill="none" stroke="var(--card-2)" strokeWidth="13" strokeLinecap="round" />
|
|
<path d="M16 88 A64 64 0 0 1 144 88" fill="none" stroke={color} strokeWidth="13" strokeLinecap="round"
|
|
strokeDasharray={`${(LEN * pct) / 100} ${LEN}`} />
|
|
</svg>
|
|
<div style={{ marginTop: "-50px", display: "flex", flexDirection: "column", alignItems: "center" }}>
|
|
<div className="gval">{val}</div>
|
|
<div className="gunit">{unit}</div>
|
|
</div>
|
|
<div className="glabel">{label}</div>
|
|
<div className="gsub">{sub}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ---------- 컬럼 ---------- */
|
|
function Col({ stage, count, children }) {
|
|
return (
|
|
<div className="jx-col">
|
|
<div className="jx-chead">
|
|
<span className="dot" style={{ background: stage.c }} />
|
|
<span className="t">{stage.title}</span>
|
|
<span className="n">{count || stage.n}</span>
|
|
</div>
|
|
<div className="jx-cbody">{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ====================================================================
|
|
흐름 보드 — 동적 커넥터
|
|
==================================================================== */
|
|
function Board({ tasks, toggle, hover, setHover, neighbors }) {
|
|
const boardRef = useRef(null);
|
|
const nodes = useRef({});
|
|
const [paths, setPaths] = useState([]);
|
|
const [size, setSize] = useState({ w: 0, h: 0 });
|
|
const setNode = (id) => (el) => { if (el) nodes.current[id] = el; };
|
|
|
|
const compute = useCallback(() => {
|
|
const board = boardRef.current;
|
|
if (!board) return;
|
|
const br = board.getBoundingClientRect();
|
|
const out = [];
|
|
for (const lk of J.links) {
|
|
const a = nodes.current[lk.from], b = nodes.current[lk.to];
|
|
if (!a || !b) continue;
|
|
const ar = a.getBoundingClientRect(), bb = b.getBoundingClientRect();
|
|
const x1 = ar.right - br.left, y1 = ar.top - br.top + ar.height / 2;
|
|
const x2 = bb.left - br.left, y2 = bb.top - br.top + bb.height / 2;
|
|
const dx = Math.max(36, (x2 - x1) * 0.5);
|
|
out.push({
|
|
key: lk.from + ">" + lk.to, from: lk.from, to: lk.to, c: lk.c, dash: lk.dash,
|
|
d: `M${x1},${y1} C${x1 + dx},${y1} ${x2 - dx},${y2} ${x2},${y2}`,
|
|
x1, y1, x2, y2,
|
|
});
|
|
}
|
|
setPaths(out);
|
|
setSize({ w: br.width, h: br.height });
|
|
}, []);
|
|
|
|
useLayoutEffect(() => {
|
|
compute();
|
|
const raf = requestAnimationFrame(compute);
|
|
const onR = () => compute();
|
|
window.addEventListener("resize", onR);
|
|
const ro = new ResizeObserver(compute);
|
|
if (boardRef.current) ro.observe(boardRef.current);
|
|
if (document.fonts && document.fonts.ready) document.fonts.ready.then(compute);
|
|
return () => { cancelAnimationFrame(raf); window.removeEventListener("resize", onR); ro.disconnect(); };
|
|
}, [compute, tasks]);
|
|
|
|
const isLit = (node) => hover && (node === hover || (neighbors[hover] && neighbors[hover].has(node)));
|
|
const cls = (node, base) => base + (isLit(node) ? " lit" : "") + (hover && !isLit(node) ? " mute" : "");
|
|
const pathState = (p) => (!hover ? "" : (p.from === hover || p.to === hover) ? " on" : " off");
|
|
const on = (node) => ({ onMouseEnter: () => setHover(node), onMouseLeave: () => setHover(null) });
|
|
|
|
return (
|
|
<div className="jx-board" ref={boardRef}>
|
|
<svg className="jx-svg" width={size.w} height={size.h} viewBox={`0 0 ${size.w} ${size.h}`}>
|
|
{paths.map((p) => (
|
|
<g key={p.key} className={"jx-link" + pathState(p)}>
|
|
<path className="jx-line" d={p.d} stroke={p.c} fill="none" strokeDasharray={p.dash ? "1 7" : "none"} />
|
|
<circle cx={p.x1} cy={p.y1} r="3.4" fill={p.c} className="jx-dot" />
|
|
<circle cx={p.x2} cy={p.y2} r="3.4" fill={p.c} className="jx-dot" />
|
|
</g>
|
|
))}
|
|
</svg>
|
|
|
|
{/* 1 — 아침 준비 */}
|
|
<Col stage={J.stages[0]}>
|
|
{J.cards.s1.map((c) => (
|
|
<div className={cls(c.node, "fcard")} key={c.node} ref={setNode(c.node)} {...on(c.node)}>
|
|
<div className="fcard-row">
|
|
<div className="fc-ic"><I d={c.ic} /></div>
|
|
<div className="fc-main"><div className="fc-title">{c.title}</div><div className="fc-meta">{c.meta}</div></div>
|
|
<div className={"mini " + (c.toolC || "")}><I d={c.tool} w="2.4" /></div>
|
|
</div>
|
|
<div className="fcard-foot"><span className="tag">{c.tag}</span></div>
|
|
</div>
|
|
))}
|
|
</Col>
|
|
|
|
{/* 2 — 오전 집중 (할 일) */}
|
|
<Col stage={J.stages[1]} count={`${tasks.filter((t) => !t.done).length}/4`}>
|
|
{tasks.map((t) => {
|
|
const pe = J.P[t.who];
|
|
return (
|
|
<div className={cls(t.node, "fcard task" + (t.done ? " done" : ""))} key={t.node} ref={setNode(t.node)} {...on(t.node)}>
|
|
<div className="fcard-row">
|
|
<div className="fcheck" onClick={(e) => { e.stopPropagation(); toggle(t.node); }}><I d="tick" w="3" /></div>
|
|
<div className="fc-main"><div className="fc-title">{t.title}</div><div className="fc-meta">{t.meta}</div></div>
|
|
<div className="fc-who" style={{ background: pe.c }} title={pe.name}>{pe.ini}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</Col>
|
|
|
|
{/* 3 — 오후 협업 (미팅) */}
|
|
<Col stage={J.stages[2]}>
|
|
{J.cards.s3.map((e) => {
|
|
const pe = J.P[e.who];
|
|
return (
|
|
<div className={cls(e.node, "fcard")} key={e.node} ref={setNode(e.node)} {...on(e.node)}>
|
|
<div className="fcard-row">
|
|
<div className="fc-av" style={{ background: pe.c }}>{pe.ini}</div>
|
|
<div className="fc-main"><div className="fc-title">{e.title}</div><div className="fc-meta">{e.meta}</div></div>
|
|
{e.soon ? <span className="mini soon">곧</span> : <div className="fc-time">{e.time}</div>}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</Col>
|
|
|
|
{/* 4 — 마무리 & 내일 */}
|
|
<Col stage={J.stages[3]}>
|
|
<div className="tiles">
|
|
{J.cards.s4.map((t) => (
|
|
<div className={cls(t.node, "tile" + (t.hi ? " hi" : ""))} key={t.node} ref={setNode(t.node)} {...on(t.node)}>
|
|
<div className="tic"><I d={t.ic} /></div>
|
|
<div className="tt">{t.tt}</div>
|
|
<div className="ts">{t.ts}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Col>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ====================================================================
|
|
앱
|
|
==================================================================== */
|
|
function App() {
|
|
const [theme, setTheme] = useState("light");
|
|
const [tasks, setTasks] = useState(J.cards.s2.map((c) => ({ ...c })));
|
|
const [hover, setHover] = useState(null);
|
|
const ref = useRef(null);
|
|
|
|
useEffect(() => { document.documentElement.setAttribute("data-theme", theme); }, [theme]);
|
|
useEffect(() => {
|
|
const el = ref.current;
|
|
const id = requestAnimationFrame(() => el && el.classList.add("entered"));
|
|
return () => cancelAnimationFrame(id);
|
|
}, []);
|
|
|
|
const toggle = (node) => setTasks((t) => t.map((x) => (x.node === node ? { ...x, done: !x.done } : x)));
|
|
const openCount = tasks.filter((t) => !t.done).length;
|
|
|
|
const neighbors = useMemo(() => {
|
|
const m = {};
|
|
for (const l of J.links) {
|
|
(m[l.from] = m[l.from] || new Set()).add(l.to);
|
|
(m[l.to] = m[l.to] || new Set()).add(l.from);
|
|
}
|
|
return m;
|
|
}, []);
|
|
|
|
return (
|
|
<div className="jx" ref={ref}>
|
|
{/* ===== 상단바 ===== */}
|
|
<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 === "journey" ? "active" : ""}>
|
|
<I d={m.icon} />{m.label}
|
|
{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="mail" /></button>
|
|
<button className="t-btn" aria-label="테마" onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
|
|
<I d={theme === "light" ? "moon" : "sun"} />
|
|
</button>
|
|
<div className="t-ava">지</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* ===== 페이지 헤더 ===== */}
|
|
<div className="pagehead">
|
|
<div>
|
|
<div className="ph-eyebrow">
|
|
<span>6월 10일 화요일</span>
|
|
<span className="sep" />
|
|
<span className="wx"><I d="sun" />맑음 · 한낮 28°</span>
|
|
</div>
|
|
<h1 className="ph-title">오늘의 여정</h1>
|
|
</div>
|
|
<div className="ph-search">
|
|
<I d="search" />
|
|
<input placeholder="흐름에서 작업·사람 찾기…" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* ===== 흐름 패널 ===== */}
|
|
<section className="jx-panel">
|
|
<div className="jx-top">
|
|
<div className="jx-label">오늘의 흐름 <span>· 4단계 · 12개 항목 · 5명 협업</span></div>
|
|
<div className="jx-stack">
|
|
{J.people.map((p) => (
|
|
<button key={p.id} className={"av" + (hover === p.id ? " on" : "")} style={{ background: p.c }} title={`${p.name} · ${p.role}`}>
|
|
{p.ini}
|
|
<span className={"cnt " + (p.me ? "me" : "tm")}>{p.tasks}</span>
|
|
</button>
|
|
))}
|
|
<div className="more">+3</div>
|
|
</div>
|
|
<div className="jx-actions">
|
|
<button className="jx-act"><I d="plus" /></button>
|
|
<button className="jx-act"><I d="share" /></button>
|
|
<button className="jx-act fill"><I d="cal" /></button>
|
|
</div>
|
|
</div>
|
|
|
|
<Board tasks={tasks} toggle={toggle} hover={hover} setHover={setHover} neighbors={neighbors} />
|
|
|
|
<div className="jx-labels">
|
|
{J.stages.map((s) => (
|
|
<div className="cl" key={s.id} style={{ color: s.c === J.C.ink ? "var(--ink)" : s.c }}>
|
|
{s.title}<span>{s.time}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="jx-hint"><I d="link" /> 카드에 마우스를 올리면 연결된 작업 · 사람 · 미팅 흐름이 강조돼요</div>
|
|
</section>
|
|
|
|
{/* ===== 하단 2열 ===== */}
|
|
<section className="jx-bottom">
|
|
<div className="card">
|
|
<div className="jx-phead">
|
|
<div><div className="pt">오늘의 작업</div><div className="ps">5건 · 진행중 2 · 검토 1 · 완료 1 · 예정 1</div></div>
|
|
<div className="pa">
|
|
<button className="jx-act"><I d="plus" /></button>
|
|
<button className="jx-act"><I d="share" /></button>
|
|
</div>
|
|
</div>
|
|
<table className="jtable">
|
|
<thead><tr><th>작업</th><th>상태</th><th>시작</th><th>담당</th></tr></thead>
|
|
<tbody>
|
|
{J.rows.map((r, i) => {
|
|
const pe = J.P[r.who];
|
|
return (
|
|
<tr key={i} className={hover === r.node ? "lit" : ""}
|
|
onMouseEnter={() => setHover(r.node)} onMouseLeave={() => setHover(null)}>
|
|
<td>
|
|
<div className="t-subj">
|
|
<svg className={"ic star" + (r.star ? " on" : "")} viewBox="0 0 24 24" fill={r.star ? "currentColor" : "none"} stroke="currentColor" strokeWidth="1.7"><path d={PATHS.star} /></svg>
|
|
<span className="nm">{r.nm}</span>
|
|
</div>
|
|
</td>
|
|
<td><span className={"chip " + r.st}>{r.stt}</span></td>
|
|
<td><span className="t-date">{r.date}</span></td>
|
|
<td><div className="t-user"><div className="ua" style={{ background: pe.c }}>{pe.ini}</div><span className="un">{pe.name}</span></div></td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div className="card">
|
|
<div className="jx-phead">
|
|
<div><div className="pt">오늘의 지표</div><div className="ps">집중 · 활동</div></div>
|
|
<div className="pa"><button className="jx-act"><I d="more" /></button></div>
|
|
</div>
|
|
<div className="gauges">
|
|
<Gauge pct={87} color={J.C.blue} val="5.2" unit="시간" label="딥 워크" sub="목표 6시간" />
|
|
<Gauge pct={72} color={J.C.coral} val="72" unit="%" label="활동 링" sub="7.2천 걸음" />
|
|
</div>
|
|
<div className="gauge-foot"><I d="drop" />오후 3시예요 — 물 한 잔, 5분 스트레칭 어때요?</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
|