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.

127 lines
5.3 KiB
JavaScript

/* 아리 — 작업 리스크 레이더 (스마트 PMO)
지연 위험 · 업무 쏠림 · 의존성 영향을 현재 작업 트리에서 직접 계산해 경고한다.
window.TaskRisk = { RiskRadar } */
(function () {
const TD = window.TaskData;
const TODAY = 8; // 6월 8일
const RP = {
radar: "M19.07 4.93A10 10 0 0 0 6.99 3.34|M4 6h.01|M2.29 9.62a10 10 0 1 0 19.02-1.27|M16.24 7.76a6 6 0 1 0 1.07 7.04|M12 18h.01|M17.99 11.66a6 6 0 0 1-2.22 5.01|M12 12a2 2 0 1 0 4 0 2 2 0 0 0-4 0z|M13.41 10.59l5.66-5.66",
clock: "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20z|M12 7v5l3 2",
scale: "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2|M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z|M22 21v-2a4 4 0 0 0-3-3.87|M16 3.13a4 4 0 0 1 0 7.75",
link: "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 6z|M15 12H9a3 3 0 0 0-3 3",
chev: "M9 6l6 6-6 6",
};
function RI({ d, w }) {
return (
<svg className="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth={w || 1.9} strokeLinecap="round" strokeLinejoin="round">
{RP[d].split("|").map((s, i) => <path key={i} d={s} />)}
</svg>
);
}
function walkAll(nodes, fn) {
nodes.forEach((n) => { fn(n); if (n.children && n.children.length) walkAll(n.children, fn); });
}
function findByTitle(nodes, title) {
let hit = null;
walkAll(nodes, (n) => { if (!hit && n.title === title) hit = n; });
return hit;
}
const dueD = (due) => (due ? parseInt(due.split("-")[1], 10) : 99);
const dueTxt = (due) => (due ? `6/${dueD(due)}` : "");
/* 제목 기반 의존성 — 저장된 트리(로컬)에도 안전하게 매칭 */
const DEPS = [
{ blocker: "예산 섹션 작성", blocked: "경영진 검토 요청 메일" },
{ blocker: "데이터 전처리 파이프라인", blocked: "사용자 인터뷰 5건 정리" },
];
function computeRisks(tasks) {
const risks = [];
const statusLabel = { todo: "시작 전", doing: "진행 중", waiting: "대기 중", review: "검토 중" };
/* ① 지연 위험 — 오늘까지 마감인데 미완료 */
tasks
.filter((t) => t.status !== "done" && t.due && dueD(t.due) <= TODAY)
.slice(0, 1)
.forEach((t) => {
risks.push({
kind: "지연 위험", icon: "clock", tone: "coral", id: t.id,
text: <><b>{t.title}</b> — 오늘({dueTxt(t.due)}) 마감인데 아직 {statusLabel[t.status]}이에요</>,
cta: "작업 열기",
});
});
/* ② 업무 쏠림 — 담당자별 미완료 작업 수 편차 */
const counts = {};
walkAll(tasks, (n) => {
if (n.status !== "done" && n.assignee) counts[n.assignee] = (counts[n.assignee] || 0) + 1;
});
const entries = Object.entries(counts).filter(([w]) => TD.people[w]);
if (entries.length > 1) {
entries.sort((a, b) => b[1] - a[1]);
const [topWho, topN] = entries[0];
const avg = entries.reduce((s, [, n]) => s + n, 0) / entries.length;
if (topN >= avg * 1.5 && topN >= 4) {
const p = TD.people[topWho];
risks.push(
p.me
? {
kind: "업무 쏠림", icon: "scale", tone: "amber",
text: <>미완료 작업 <b>{topN}</b>이 내게 몰려 있어요 — 팀 평균의 {Math.round((topN / avg) * 10) / 10}. 벅찬 작업은 <b>위임 & 추적</b>으로 넘겨보세요</>,
}
: {
kind: "업무 쏠림", icon: "scale", tone: "amber",
text: <><b>{p.name}</b> <b>{topN}</b> {Math.round((topN / avg) * 10) / 10}, </>,
}
);
}
}
/* ③ 의존성 영향 — 선행 작업이 안 끝나면 밀리는 후속 작업 */
for (const d of DEPS) {
const a = findByTitle(tasks, d.blocker), b = findByTitle(tasks, d.blocked);
if (a && b && a.status !== "done" && b.status !== "done") {
risks.push({
kind: "의존성", icon: "link", tone: "violet", id: b.id,
text: <><b>{d.blocker}</b>() <b>{d.blocked}</b>{b.due ? `(${dueTxt(b.due)})` : ""} </>,
cta: "후속 작업 보기",
});
break; // 가장 임박한 1건만
}
}
return risks.slice(0, 3);
}
function RiskRadar({ tasks, onOpen }) {
const risks = React.useMemo(() => computeRisks(tasks), [tasks]);
if (!risks.length) return null;
return (
<div className="rradar">
<div className="rr-head">
<span className="rr-ic"><RI d="radar" /></span>
<div className="rr-ht"><b>리스크 레이더</b><span> · · </span></div>
</div>
<div className="rr-items">
{risks.map((r, i) => (
<div key={i} className={"rr-item t-" + r.tone}>
<span className="rr-item-ic"><RI d={r.icon} /></span>
<div className="rr-body">
<span className="rr-kind">{r.kind}</span>
<p className="rr-text">{r.text}</p>
</div>
{r.id && onOpen && (
<button className="rr-cta" onClick={() => onOpen(r.id)}>{r.cta}<RI d="chev" /></button>
)}
</div>
))}
</div>
</div>
);
}
window.TaskRisk = { RiskRadar };
})();