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
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 };
|
|
})();
|