-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathanalytics.js
More file actions
715 lines (661 loc) · 28.4 KB
/
Copy pathanalytics.js
File metadata and controls
715 lines (661 loc) · 28.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
// ScopeWeave EVM + S-curve analytics — the 공정관리 (schedule-control) moat.
// Pure math (node-testable via ESM export) + an optional DOM panel bridged onto
// window.ScopeWeaveAnalytics. Reuses app.js's date/ratio helpers by injection so
// there's a single source of truth for the planned/actual calculation.
//
// EVM mapping to the existing weighted-progress model:
// PV (Planned Value) = totalWeightedPlannedRatio (0..1)
// EV (Earned Value) = totalWeightedActualRatio (0..1)
// SPI = EV / PV SV = EV - PV (schedule performance)
// Cost axis (AC/CPI/CV) is intentionally omitted — the product tracks schedule,
// not cost. Add it when a cost/actual-hours field exists (named ceiling).
export function computeEvm({ pv, ev }) {
const p = Number(pv) || 0;
const e = Number(ev) || 0;
const spi = p > 0 ? e / p : null; // null = nothing planned yet → N/A
const sv = e - p; // weighted fraction; ×100 for %p
let status, label;
if (p === 0) {
status = 'before';
label = '계획 착수 전';
} else if (spi >= 1) {
status = 'active';
label = spi > 1.001 ? '일정 선행' : '일정 준수';
} else if (spi >= 0.9) {
status = 'delay';
label = '경미한 지연';
} else {
status = 'delay';
label = '지연 위험';
}
return { pv: p, ev: e, spi, sv, status, label };
}
// Time-phased PLANNED cumulative curve across the project's weekday timeline.
// Actual is only known as of baseDate (no historical snapshots yet — a future
// backend version-history feature would supply the time-phased actual curve).
export function buildScurve({ tasks, calcPlannedRatio, calcDuration, buildTimeline }) {
const dated = (tasks || []).filter((t) => t.plannedStartDate && t.plannedEndDate);
if (!dated.length) return { timeline: [], planned: [] };
const durations = new Map();
let totalDays = 0;
for (const t of dated) {
const d = calcDuration(t.plannedStartDate, t.plannedEndDate);
durations.set(t.id, d);
totalDays += d;
}
if (totalDays <= 0) return { timeline: [], planned: [] };
let minStart = null;
let maxEnd = null;
for (const t of dated) {
if (minStart === null || t.plannedStartDate < minStart) minStart = t.plannedStartDate;
if (maxEnd === null || t.plannedEndDate > maxEnd) maxEnd = t.plannedEndDate;
}
const timeline = buildTimeline(minStart, maxEnd).map((d) => d.date);
const planned = timeline.map((date) => {
let pv = 0;
for (const t of dated) {
const dur = durations.get(t.id);
pv += (dur / totalDays) * calcPlannedRatio(date, t.plannedStartDate, t.plannedEndDate, dur);
}
return pv; // 0..1
});
return { timeline, planned };
}
// Critical Path Method (CPM). Pure. Duration per task from task.duration (days)
// or from plannedStart/End day-count (mirrors app.js calculateDurationDays; an
// injected calcDuration is used when provided). Dependencies come from an
// optional task.predecessors (array or comma-separated ids referencing task.id).
// Robust to cycles (returns cycleDetected:true, never throws).
export function computeCpm(tasks, opts = {}) {
const list = Array.isArray(tasks) ? tasks : [];
const ids = list.map((t) => String(t.id));
const idset = new Set(ids);
const byId = new Map(list.map((t) => [String(t.id), t]));
const durOf = (t) => {
if (typeof t.duration === 'number' && t.duration >= 0) return t.duration;
if (t.plannedStartDate && t.plannedEndDate) {
if (opts.calcDuration) return opts.calcDuration(t.plannedStartDate, t.plannedEndDate);
const ms = Date.parse(t.plannedEndDate) - Date.parse(t.plannedStartDate);
if (!Number.isFinite(ms) || ms < 0) return 0;
return Math.max(1, Math.round(ms / 86400000));
}
return 0;
};
// Dependency token: "P100" (FS), "P100SS", "P100FF+2", "P100SF-1".
// If the whole token matches a task id, treat it as plain FS (ids may end
// in letters that look like a type).
const parseDep = (token) => {
const raw = String(token).trim();
if (!raw) return null;
if (idset.has(raw)) return { id: raw, type: 'FS', lag: 0 };
const m = raw.match(/^(.*?)(FS|SS|FF|SF)?([+-]\d+)?$/i);
const id = (m?.[1] || raw).trim();
return { id, type: (m?.[2] || 'FS').toUpperCase(), lag: Number(m?.[3]) || 0 };
};
const predsOf = (t) => {
let p = t.predecessors;
if (!p) return [];
if (typeof p === 'string') p = p.split(',').map((s) => s.trim()).filter(Boolean);
return (Array.isArray(p) ? p : []).map(parseDep).filter(Boolean);
};
const preds = new Map(ids.map((id) => [id, []]));
const succ = new Map(ids.map((id) => [id, []]));
for (const t of list) {
const id = String(t.id);
for (const link of predsOf(t)) {
if (idset.has(link.id) && link.id !== id) {
preds.get(id).push(link);
succ.get(link.id).push({ id, type: link.type, lag: link.lag });
}
}
}
// Kahn topological sort → cycle detection.
const indeg = new Map(ids.map((id) => [id, preds.get(id).length]));
const queue = ids.filter((id) => indeg.get(id) === 0);
const order = [];
let queueIndex = 0; // ⚡ Optimization: Use tracked index instead of O(K) queue.shift()
while (queueIndex < queue.length) {
const id = queue[queueIndex++];
order.push(id);
for (const s of succ.get(id)) {
indeg.set(s.id, indeg.get(s.id) - 1);
if (indeg.get(s.id) === 0) queue.push(s.id);
}
}
const cycleDetected = order.length !== ids.length;
const topo = cycleDetected ? ids : order; // best-effort order under a cycle
const dur = new Map(ids.map((id) => [id, durOf(byId.get(id))]));
const es = new Map();
const ef = new Map();
for (const id of topo) {
// per-link earliest-start constraint by dependency type
const start = preds.get(id).reduce((m, l) => {
const pes = es.get(l.id) ?? 0;
const pef = ef.get(l.id) ?? 0;
const d = dur.get(id);
let c;
if (l.type === 'SS') c = pes + l.lag;
else if (l.type === 'FF') c = pef + l.lag - d;
else if (l.type === 'SF') c = pes + l.lag - d;
else c = pef + l.lag; // FS
return Math.max(m, c);
}, 0);
es.set(id, Math.max(0, start));
ef.set(id, Math.max(0, start) + dur.get(id));
}
const projectDurationDays = ids.reduce((m, id) => Math.max(m, ef.get(id) ?? 0), 0);
const lf = new Map();
const ls = new Map();
for (const id of [...topo].reverse()) {
const succs = succ.get(id);
const d = dur.get(id);
// per-link latest-finish constraint (mirror of the forward pass)
const finish = succs.length
? succs.reduce((m, l) => {
const sls = ls.get(l.id) ?? projectDurationDays;
const slf = lf.get(l.id) ?? projectDurationDays;
let c;
if (l.type === 'SS') c = sls - l.lag + d;
else if (l.type === 'FF') c = slf - l.lag;
else if (l.type === 'SF') c = slf - l.lag + d;
else c = sls - l.lag; // FS
return Math.min(m, c);
}, Infinity)
: projectDurationDays;
lf.set(id, finish);
ls.set(id, finish - d);
}
const perTask = {};
for (const id of ids) {
const slack = (ls.get(id) ?? 0) - (es.get(id) ?? 0);
perTask[id] = {
duration: dur.get(id),
es: es.get(id) ?? 0,
ef: ef.get(id) ?? 0,
ls: ls.get(id) ?? 0,
lf: lf.get(id) ?? 0,
slack,
critical: !cycleDetected && Math.abs(slack) < 1e-9,
};
}
const criticalPath = ids
.filter((id) => perTask[id].critical)
.sort((a, b) => perTask[a].es - perTask[b].es);
return { perTask, projectDurationDays, criticalPath, cycleDetected };
}
// --------------------------------------------------------------------- DOM
const SVGNS = 'http://www.w3.org/2000/svg';
const pct = (n) => `${(n * 100).toFixed(1)}%`;
function el(tag, attrs, text) {
const node = document.createElement(tag);
if (attrs) for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v);
if (text != null) node.textContent = text;
return node;
}
function ensurePanel() {
let panel = document.getElementById('evm-panel');
if (panel) return panel;
const anchor = document.querySelector('.meta-grid-secondary') || document.querySelector('.top-panel');
if (!anchor) return null;
panel = el('section', { id: 'evm-panel', class: 'evm-panel', 'aria-label': '일정성과지표(EVM)' });
anchor.insertAdjacentElement('afterend', panel);
return panel;
}
// Cost EVM: the money axis (schedule EVM = computeEvm above). Tasks carry
// budget (예산) and actualCost (실투입비); progress fields are %.
// BAC=Σbudget · PV/EV in currency · AC=ΣactualCost · CPI=EV/AC ·
// EAC=BAC/CPI · VAC=BAC-EAC · ETC=EAC-AC.
export function computeCostEvm(tasks) {
let bac = 0, pv = 0, ev = 0, ac = 0;
for (const t of tasks || []) {
const b = Number(t.budget) || 0;
bac += b;
pv += b * ((Number(t.plannedProgress) || 0) / 100);
ev += b * ((Number(t.actualProgress) || 0) / 100);
ac += Number(t.actualCost) || 0;
}
if (bac <= 0) return null; // no budgets → cost EVM not applicable
const cpi = ac > 0 ? ev / ac : null;
const cv = ev - ac;
const eac = cpi ? bac / cpi : null;
return {
bac, pv, ev, ac, cpi, cv,
eac,
vac: eac === null ? null : bac - eac,
etc: eac === null ? null : eac - ac,
status: cpi === null ? 'before' : cpi >= 1 ? 'active' : 'delay',
label: cpi === null ? '실투입 전' : cpi >= 1 ? '예산 준수' : cpi >= 0.9 ? '경미한 초과' : '예산 초과 위험',
};
}
function renderCostEvm(panel, tasks) {
const c = computeCostEvm(tasks);
if (!c) return;
const krw = (v) => `₩${Math.round(v).toLocaleString('ko-KR')}`;
const metric = (title, value, cls) => {
const card = el('div', { class: `evm-metric ${cls || ''}` });
card.appendChild(el('span', { class: 'evm-label' }, title));
card.appendChild(el('strong', { class: 'evm-value' }, value));
return card;
};
const row = el('div', { class: 'evm-metrics' });
row.appendChild(metric('BAC 총예산', krw(c.bac)));
row.appendChild(metric('EV 획득가치', krw(c.ev)));
row.appendChild(metric('AC 실투입비', krw(c.ac)));
row.appendChild(metric('CPI 원가효율', c.cpi === null ? 'N/A' : c.cpi.toFixed(2), `evm-${c.status}`));
row.appendChild(metric('EAC 완료시추정', c.eac === null ? 'N/A' : krw(c.eac), `evm-${c.status}`));
row.appendChild(metric('VAC 예산편차', c.vac === null ? 'N/A' : krw(c.vac), `evm-${c.status}`));
row.appendChild(el('span', { class: `evm-badge evm-${c.status}` }, c.label));
panel.appendChild(row);
}
// Resource workload: aggregate leaf-level effort per 담당자 (owner).
// Pure — feeds the panel table and is unit-tested directly.
export function computeWorkload(tasks) {
const byOwner = new Map();
for (const t of tasks || []) {
const owner = String(t.owner || '').trim() || '미지정';
let o = byOwner.get(owner);
if (!o) { o = { owner, count: 0, plannedSum: 0, actualSum: 0, behind: 0 }; byOwner.set(owner, o); }
const planned = Number(t.plannedProgress) || 0;
const actual = Number(t.actualProgress) || 0;
o.count += 1;
o.plannedSum += planned;
o.actualSum += actual;
if (actual < planned) o.behind += 1;
}
return [...byOwner.values()]
.map((o) => ({
owner: o.owner,
count: o.count,
avgPlanned: o.count ? o.plannedSum / o.count : 0,
avgActual: o.count ? o.actualSum / o.count : 0,
behind: o.behind,
}))
.sort((a, b) => b.count - a.count || a.owner.localeCompare(b.owner));
}
const PM_SIGNAL_GROUPS = Object.freeze({
requirements: [
'requirement', 'requisite', 'stakeholder', '요구사항', '요건', '요구정의',
'요구 정의', '요구사항정의', '인터뷰', 'workshop', '워크숍'
],
procurement: [
'rfi', 'rfp', 'request for information', 'request for proposal',
'제안요청', '정보요청', '제안서', '입찰', '공급사', '벤더', 'vendor'
],
business: [
'business', '사업계획', '사업 계획', 'business case', 'roi', '효익',
'편익', '예산', 'budget', 'commercial', '비용'
],
evaluation: [
'evaluation', 'criteria', 'acceptance', '평가', '배점', '선정기준',
'평가기준', '검수', '수락기준', '인수기준'
],
questions: ['질의', '질문', 'q&a', 'qa', 'clarification', '답변', '응답'],
});
function taskSearchText(task) {
return [
task.phase,
task.activity,
task.task,
task.categoryLarge,
task.categoryMedium,
task.documentName,
task.owner,
task.supportTeam,
task.sprint,
].filter(Boolean).join(' ').toLowerCase();
}
function hasSignal(text, keywords) {
return keywords.some((keyword) => text.includes(keyword.toLowerCase()));
}
function taskDurationDays(task, calcDuration) {
if (typeof task.duration === 'number' && task.duration >= 0) return task.duration;
if (task.plannedStartDate && task.plannedEndDate) {
if (calcDuration) return calcDuration(task.plannedStartDate, task.plannedEndDate);
const ms = Date.parse(task.plannedEndDate) - Date.parse(task.plannedStartDate);
if (Number.isFinite(ms) && ms >= 0) return Math.max(1, Math.round(ms / 86400000));
}
return 0;
}
function predecessorTokens(value) {
if (!value) return [];
const values = Array.isArray(value) ? value : String(value).split(',');
return values.map((v) => String(v).trim()).filter(Boolean);
}
function predecessorId(token, idset) {
if (idset.has(token)) return token;
const m = token.match(/^(.*?)(FS|SS|FF|SF)?([+-]\d+)?$/i);
return (m?.[1] || token).trim();
}
function scoreRatio(numerator, denominator) {
return denominator > 0 ? numerator / denominator : 0;
}
function scoreClass(score) {
if (score >= 80) return 'active';
if (score >= 55) return 'delay';
return 'before';
}
// PM readiness analysis for requirements/RFI/RFP/WBS estimation. This is a
// deterministic first slice: it extracts auditable signals from existing WBS
// fields rather than introducing an LLM/runtime dependency.
export function computePmAnalysis(tasks, opts = {}) {
const list = Array.isArray(tasks) ? tasks : [];
if (!list.length) {
return {
tasks: { total: 0, leaf: 0 },
readinessScore: 0,
readinessClass: 'before',
signals: { requirements: 0, procurement: 0, business: 0, evaluation: 0, questions: 0 },
coverage: { requirements: 0, estimate: 0, owner: 0, dates: 0, deliverables: 0, procurement: 0 },
estimates: {
totalDurationDays: 0,
storyPoints: 0,
budget: 0,
estimateReady: 0,
ownerReady: 0,
dateReady: 0,
deliverableReady: 0,
},
dependencies: {
declaredLinks: 0,
density: 0,
risk: 'low',
danglingPredecessors: [],
cycleDetected: false,
criticalPath: [],
projectDurationDays: 0,
},
procurement: { ready: 0, total: 6, sections: [] },
recommendations: [],
};
}
const parentIds = new Set(list.map((t) => t.parentId).filter(Boolean).map(String));
const leaves = list.filter((t) => !parentIds.has(String(t.id)));
const workItems = leaves.length ? leaves : list;
const idset = new Set(list.map((t) => String(t.id)));
const signalCounts = {
requirements: 0,
procurement: 0,
business: 0,
evaluation: 0,
questions: 0,
};
let ownerReady = 0;
let dateReady = 0;
let deliverableReady = 0;
let estimateReady = 0;
let totalDurationDays = 0;
let storyPoints = 0;
let budget = 0;
let declaredLinks = 0;
const danglingPredecessors = [];
for (const task of workItems) {
const text = taskSearchText(task);
for (const key of Object.keys(signalCounts)) {
if (hasSignal(text, PM_SIGNAL_GROUPS[key])) signalCounts[key] += 1;
}
if (String(task.owner || '').trim()) ownerReady += 1;
if (task.plannedStartDate && task.plannedEndDate) dateReady += 1;
if (String(task.documentName || '').trim()) deliverableReady += 1;
const duration = taskDurationDays(task, opts.calcDuration);
const points = Number(task.storyPoints) || 0;
const taskBudget = Number(task.budget) || 0;
totalDurationDays += duration;
storyPoints += points;
budget += taskBudget;
if (duration > 0 || points > 0 || taskBudget > 0) estimateReady += 1;
for (const token of predecessorTokens(task.predecessors)) {
declaredLinks += 1;
const pid = predecessorId(token, idset);
if (!idset.has(pid)) danglingPredecessors.push({ taskId: task.id, predecessor: token });
}
}
const cpm = computeCpm(list, opts);
const procurementSections = [
{ id: 'requirements', label: '요구사항/요건 정의', present: signalCounts.requirements > 0 || deliverableReady > 0 },
{ id: 'scope', label: 'WBS 범위와 산출물', present: workItems.length > 0 && deliverableReady > 0 },
{ id: 'schedule', label: '일정/마일스톤', present: dateReady > 0 },
{ id: 'commercial', label: '예산/사업성', present: budget > 0 || signalCounts.business > 0 },
{ id: 'evaluation', label: '평가/검수 기준', present: signalCounts.evaluation > 0 },
{ id: 'questions', label: '질의응답/RFI 루프', present: signalCounts.questions > 0 || signalCounts.procurement > 0 },
];
const procurementReady = procurementSections.filter((s) => s.present).length;
const dependencyDensity = scoreRatio(declaredLinks, Math.max(workItems.length, 1));
const dependencyRisk = cpm.cycleDetected || danglingPredecessors.length
? 'high'
: workItems.length >= 4 && dependencyDensity < 0.2
? 'medium'
: 'low';
const requirementScore = Math.min(1, scoreRatio(signalCounts.requirements, Math.max(1, Math.ceil(workItems.length / 4))));
const estimateScore = scoreRatio(estimateReady, workItems.length);
const ownerScore = scoreRatio(ownerReady, workItems.length);
const dateScore = scoreRatio(dateReady, workItems.length);
const dependencyScore = dependencyRisk === 'high' ? 0 : dependencyRisk === 'medium' ? 0.5 : 1;
const procurementScore = scoreRatio(procurementReady, procurementSections.length);
const readinessScore = Math.round(
(requirementScore * 20)
+ (estimateScore * 25)
+ (ownerScore * 15)
+ (dateScore * 15)
+ (dependencyScore * 15)
+ (procurementScore * 10)
);
const recommendations = [];
if (signalCounts.requirements === 0) recommendations.push('요구사항/요건 정의 작업 또는 산출물을 WBS에 추가하십시오.');
if (estimateReady < workItems.length) recommendations.push('모든 leaf 작업에 기간, 스토리포인트, 또는 예산 중 하나 이상의 추정값을 채우십시오.');
if (ownerReady < workItems.length) recommendations.push('담당자 미지정 leaf 작업을 줄여 RFI/RFP 책임 추적성을 높이십시오.');
if (dateReady < workItems.length) recommendations.push('계획 시작/종료일을 채워 일정 기반 WBS 추정을 완성하십시오.');
if (dependencyRisk === 'high') recommendations.push('순환 또는 존재하지 않는 선행작업을 먼저 정리하십시오.');
else if (dependencyRisk === 'medium') recommendations.push('leaf 작업 간 선행작업을 더 명시해 inter-event dependency 추정의 근거를 보강하십시오.');
if (procurementReady < procurementSections.length) recommendations.push('RFI/RFP 패키지에 빠진 요구사항, 일정, 예산, 평가, 질의응답 섹션을 보강하십시오.');
return {
tasks: { total: list.length, leaf: workItems.length },
readinessScore,
readinessClass: scoreClass(readinessScore),
signals: signalCounts,
coverage: {
requirements: requirementScore,
estimate: estimateScore,
owner: ownerScore,
dates: dateScore,
deliverables: scoreRatio(deliverableReady, workItems.length),
procurement: procurementScore,
},
estimates: {
totalDurationDays,
storyPoints,
budget,
estimateReady,
ownerReady,
dateReady,
deliverableReady,
},
dependencies: {
declaredLinks,
density: dependencyDensity,
risk: dependencyRisk,
danglingPredecessors,
cycleDetected: cpm.cycleDetected,
criticalPath: cpm.criticalPath,
projectDurationDays: cpm.projectDurationDays,
},
procurement: {
ready: procurementReady,
total: procurementSections.length,
sections: procurementSections,
},
recommendations,
};
}
function renderWorkload(panel, tasks) {
const rows = computeWorkload(tasks);
const named = rows.filter((r) => r.owner !== '미지정');
if (!named.length) return; // no owners assigned → nothing useful to show
const title = el('p', { class: 'cpm-summary' }, `담당자별 워크로드 (상위 ${Math.min(rows.length, 8)}명)`);
panel.appendChild(title);
const table = el('table', { class: 'workload-table' });
const thead = document.createElement('thead');
const hr = document.createElement('tr');
for (const h of ['담당자', '작업수', '계획평균', '실적평균', '지연']) hr.appendChild(el('th', {}, h));
thead.appendChild(hr);
const tbody = document.createElement('tbody');
for (const r of rows.slice(0, 8)) {
const tr = document.createElement('tr');
tr.appendChild(el('td', {}, r.owner));
tr.appendChild(el('td', {}, String(r.count)));
tr.appendChild(el('td', {}, `${r.avgPlanned.toFixed(1)}%`));
tr.appendChild(el('td', {}, `${r.avgActual.toFixed(1)}%`));
const behind = el('td', {}, r.behind ? `${r.behind}건` : '-');
if (r.behind) behind.classList.add('workload-behind');
tr.appendChild(behind);
tbody.appendChild(tr);
}
table.appendChild(thead);
table.appendChild(tbody);
const wrap = el('div', { class: 'workload-wrap' });
wrap.appendChild(table);
panel.appendChild(wrap);
}
function renderPanel({ pv, ev, tasks, baseDate, calcPlannedRatio, calcDuration, buildTimeline }) {
const panel = ensurePanel();
if (!panel) return;
const evm = computeEvm({ pv, ev });
panel.textContent = '';
const metric = (title, value, cls) => {
const card = el('div', { class: `evm-metric ${cls || ''}` });
card.appendChild(el('span', { class: 'evm-label' }, title));
card.appendChild(el('strong', { class: 'evm-value' }, value));
return card;
};
const row = el('div', { class: 'evm-metrics' });
row.appendChild(metric('PV 계획가치', pct(evm.pv)));
row.appendChild(metric('EV 획득가치', pct(evm.ev)));
row.appendChild(metric('SPI 일정효율', evm.spi === null ? 'N/A' : evm.spi.toFixed(2), `evm-${evm.status}`));
row.appendChild(metric('SV 일정편차', `${evm.sv >= 0 ? '+' : ''}${(evm.sv * 100).toFixed(1)}%p`, `evm-${evm.status}`));
const badge = el('span', { class: `evm-badge evm-${evm.status}` }, evm.label);
row.appendChild(badge);
panel.appendChild(row);
const series = buildScurve({ tasks, calcPlannedRatio, calcDuration, buildTimeline });
if (series.timeline.length >= 2) {
panel.appendChild(buildScurveSvg(series, evm, baseDate));
}
renderCpm(panel, tasks, calcDuration);
renderCostEvm(panel, tasks);
renderWorkload(panel, tasks);
renderPmAnalysis(panel, tasks, calcDuration);
}
// Critical-path summary + row highlighting. Only shown when tasks declare
// dependencies (task.predecessors). Row highlight is deferred to the next frame
// because this runs mid-renderAll, before app.js has appended the new rows.
function renderCpm(panel, tasks, calcDuration) {
const hasPreds = (tasks || []).some((t) => {
const p = t.predecessors;
return p && (Array.isArray(p) ? p.length : String(p).trim());
});
const highlight = (predicate) => {
requestAnimationFrame(() => {
document.querySelectorAll('tbody tr[data-task-id]').forEach((tr) => {
tr.classList.toggle('cpm-critical', predicate(tr.getAttribute('data-task-id')));
});
});
};
if (!hasPreds) { highlight(() => false); return; }
const cpm = computeCpm(tasks, { calcDuration });
const line = el('p', { class: `cpm-summary${cpm.cycleDetected ? ' cpm-warn' : ''}` },
cpm.cycleDetected
? '⚠ 순환 의존성이 감지되어 임계경로를 계산할 수 없습니다.'
: `임계경로(CPM): 프로젝트 기간 ${cpm.projectDurationDays}일 · 임계 작업 ${cpm.criticalPath.length}개`);
panel.appendChild(line);
highlight((id) => !cpm.cycleDetected && Boolean(cpm.perTask[id]?.critical));
}
function buildScurveSvg(series, evm, baseDate) {
const W = 640;
const H = 120;
const PAD = 4;
const n = series.timeline.length;
const x = (i) => PAD + (i / (n - 1)) * (W - 2 * PAD);
const y = (v) => H - PAD - v * (H - 2 * PAD);
const svg = document.createElementNS(SVGNS, 'svg');
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
svg.setAttribute('class', 'evm-scurve');
svg.setAttribute('role', 'img');
svg.setAttribute('aria-label', 'S-curve: 계획 누적 진척 곡선');
// baseline (0 / 50 / 100%)
for (const g of [0, 0.5, 1]) {
const line = document.createElementNS(SVGNS, 'line');
line.setAttribute('x1', PAD); line.setAttribute('x2', W - PAD);
line.setAttribute('y1', y(g)); line.setAttribute('y2', y(g));
line.setAttribute('class', 'evm-grid');
svg.appendChild(line);
}
// planned S-curve polyline
const pts = series.planned.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ');
const poly = document.createElementNS(SVGNS, 'polyline');
poly.setAttribute('points', pts);
poly.setAttribute('class', 'evm-plan-line');
svg.appendChild(poly);
// actual EV marker at baseDate x-position (nearest timeline index)
let idx = series.timeline.findIndex((d) => d >= baseDate);
if (idx === -1) idx = n - 1;
const evLine = document.createElementNS(SVGNS, 'line');
evLine.setAttribute('x1', x(idx)); evLine.setAttribute('x2', x(idx));
evLine.setAttribute('y1', y(0)); evLine.setAttribute('y2', y(1));
evLine.setAttribute('class', 'evm-today');
svg.appendChild(evLine);
const dot = document.createElementNS(SVGNS, 'circle');
dot.setAttribute('cx', x(idx)); dot.setAttribute('cy', y(evm.ev)); dot.setAttribute('r', 4);
dot.setAttribute('class', `evm-ev-dot evm-${evm.status}`);
svg.appendChild(dot);
const wrap = el('div', { class: 'evm-scurve-wrap' });
const cap = el('p', { class: 'evm-caption' },
'계획 누적 S-curve(가중치 기준) · 세로선=기준일, 점=현재 획득가치(EV)');
wrap.appendChild(svg);
wrap.appendChild(cap);
return wrap;
}
function renderPmAnalysis(panel, tasks, calcDuration) {
const analysis = computePmAnalysis(tasks, { calcDuration });
if (!analysis.tasks.total) return;
const krw = (v) => `₩${Math.round(v).toLocaleString('ko-KR')}`;
const section = el('section', { class: 'pm-analysis', 'aria-label': '요구사항 RFI RFP WBS 추정 분석' });
section.appendChild(el('p', { class: 'pm-title' }, 'PM 분석: 요구사항 · RFI/RFP · WBS 추정'));
const metric = (title, value, cls) => {
const card = el('div', { class: `evm-metric ${cls || ''}` });
card.appendChild(el('span', { class: 'evm-label' }, title));
card.appendChild(el('strong', { class: 'evm-value' }, value));
return card;
};
const row = el('div', { class: 'evm-metrics pm-metrics' });
row.appendChild(metric('준비도', `${analysis.readinessScore}점`, `evm-${analysis.readinessClass}`));
row.appendChild(metric('추정 WBS', `${analysis.estimates.estimateReady}/${analysis.tasks.leaf}`, ''));
row.appendChild(metric('의존성', `${analysis.dependencies.declaredLinks}개`, `evm-${analysis.dependencies.risk === 'low' ? 'active' : 'delay'}`));
row.appendChild(metric('RFI/RFP', `${analysis.procurement.ready}/${analysis.procurement.total}`, ''));
row.appendChild(metric('총예산', analysis.estimates.budget ? krw(analysis.estimates.budget) : 'N/A', ''));
section.appendChild(row);
const summary = `기간합 ${analysis.estimates.totalDurationDays}일 · 스토리포인트 ${analysis.estimates.storyPoints || 0} · 임계경로 ${analysis.dependencies.criticalPath.length}개 작업`;
section.appendChild(el('p', { class: 'pm-summary' }, summary));
const list = el('ul', { class: 'pm-section-list' });
for (const item of analysis.procurement.sections) {
const li = el('li', { class: item.present ? 'pm-present' : 'pm-missing' });
li.appendChild(el('span', {}, item.label));
li.appendChild(el('strong', {}, item.present ? '있음' : '보강'));
list.appendChild(li);
}
section.appendChild(list);
if (analysis.recommendations.length) {
const rec = el('ol', { class: 'pm-recommendations' });
analysis.recommendations.slice(0, 4).forEach((text) => rec.appendChild(el('li', {}, text)));
section.appendChild(rec);
}
panel.appendChild(section);
}
if (typeof window !== 'undefined') {
window.ScopeWeaveAnalytics = {
render: renderPanel,
computeEvm,
buildScurve,
computeCpm,
computeWorkload,
computeCostEvm,
computePmAnalysis,
};
}