soyailabs / templates /admin_webtoon_planner.html
SOY NV AI
feat(gantt): enhance gantt chart side list with click interaction and grouping
75b98a7
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>μ›Ήνˆ° μ œμž‘ κΈ°κ°„ 톡계/μ‚°μ • - SOY NV AI</title>
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/frappe-gantt.css">
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/frappe-gantt.min.js"></script>
<style>
:root{
--bg:#f6f7fb;--card:#fff;--text:#202124;--muted:#5f6368;--border:#e5e7eb;--border-strong:#dadce0;
--primary:#1a73e8;--primary-hover:#1557b0;--shadow:0 10px 24px rgba(17,24,39,.08);--radius:12px;
}
*{margin:0;padding:0;box-sizing:border-box}
body{
font-family:'Inter', -apple-system, BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
background: radial-gradient(1200px 600px at 20% -10%, rgba(26,115,232,0.10), rgba(255,255,255,0)) , var(--bg);
color:var(--text);overflow-x:hidden
}
.container{max-width:1180px;margin:22px auto;padding:0 24px}
.page-header{margin:8px 0 14px}
.page-header h1{font-size:18px;font-weight:700;letter-spacing:-.2px}
.page-header p{margin-top:6px;color:var(--muted);font-size:13px;line-height:1.6}
.card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;box-shadow:var(--shadow);margin-bottom:14px}
.card-header{padding:14px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;background:linear-gradient(to bottom,#fff,#fbfbff)}
.card-title{font-size:15px;font-weight:700}
.card-body{padding:16px}
.row{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
.input,.select{
padding:10px 12px;border:1px solid var(--border-strong);border-radius:10px;font-size:13px;outline:none;background:#fff;
}
.input{min-width:180px}
.select{min-width:220px}
.btn{padding:10px 12px;border:1px solid var(--border);border-radius:10px;font-size:13px;font-weight:700;cursor:pointer;background:#fff;color:var(--text)}
.btn-primary{background:var(--primary);border-color:var(--primary);color:#fff}
.btn-primary:hover{background:var(--primary-hover);border-color:var(--primary-hover)}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:12px}
.alert{margin-bottom:12px;padding:10px 12px;border-radius:10px;font-size:13px;border:1px solid transparent}
.alert.success{background:#e6f4ea;border-color:#b7dfc0;color:#137333}
.alert.error{background:#fce8e6;border-color:#f6aea9;color:#c5221f}
.hint{color:var(--muted);font-size:12px;line-height:1.5}
table{width:100%;border-collapse:collapse}
thead th{text-align:left;font-size:12px;color:var(--muted);padding:10px 8px;border-bottom:1px solid var(--border);background:#fbfbff}
tbody td{font-size:13px;padding:10px 8px;border-bottom:1px solid var(--border);vertical-align:top}
tbody tr:hover{background:#fafbff}
.split{display:grid;grid-template-columns: 0.48fr 0.52fr; gap: 12px;}
@media (max-width: 980px){ .split{grid-template-columns:1fr;} }
.pill{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--border);border-radius:999px;padding:6px 10px;font-size:12px;color:var(--muted);background:#fff}
.pill strong{color:var(--text)}
details{border:1px solid var(--border);border-radius:12px;background:#fff;padding:10px}
details summary{cursor:pointer;font-weight:800;font-size:13px}
/* Frappe Gantt Custom Colors */
.gantt .bar-label { fill: #444 !important; font-size: 11px; font-weight: 500; }
/* λŒ€λΆ„λ₯˜(회차) μŠ€νƒ€μΌ - λ§ˆμΌμŠ€ν†€ λŠλ‚Œ */
.gantt-ep-group .bar { fill: #333 !important; opacity: 0.8; }
.gantt-ep-group .bar-label { fill: #fff !important; font-weight: bold; }
/* μ†ŒλΆ„λ₯˜(단계) μŠ€νƒ€μΌ - κΈ°μ‘΄ μœ μ§€ν•˜λ˜ μ•½κ°„ 투λͺ…도 μ‘°μ • */
.gantt-ep-0 .bar { fill: #4285f4 !important; opacity: 0.9; }
.gantt-ep-1 .bar { fill: #34a853 !important; opacity: 0.9; }
.gantt-ep-2 .bar { fill: #fbbc04 !important; opacity: 0.9; }
.gantt-ep-3 .bar { fill: #ea4335 !important; opacity: 0.9; }
.gantt-ep-4 .bar { fill: #ab47bc !important; opacity: 0.9; }
.gantt-global .bar { fill: #5c6bc0 !important; }
/* λ·° λͺ¨λ“œ λ²„νŠΌ ν™œμ„±ν™” μŠ€νƒ€μΌ */
.view-btn.active {
background-color: var(--primary);
color: #fff;
border-color: var(--primary);
}
</style>
</head>
<body>
{% set admin_nav_title = 'μ›Ήνˆ°' %}
{% set admin_nav_icon = 'πŸ“ˆ' %}
{% include '_admin_nav.html' %}
<div class="container">
<div class="page-header">
<h1>μ œμž‘ κΈ°κ°„ 톡계/μ‚°μ •</h1>
<p>μ—¬λŸ¬ μ›Ήνˆ° ν”„λ‘œμ νŠΈμ˜ 1~20ν™” μ œμž‘ μ†Œμš” κΈ°κ°„ 톡계λ₯Ό 기반으둜, μ‹œμž‘μΌ/런칭일 κΈ°μ€€ 일정(λ§ˆμΌμŠ€ν†€)을 μ‚°μ •ν•©λ‹ˆλ‹€.</p>
</div>
<div id="alertContainer"></div>
<div class="card">
<div class="card-header">
<div class="card-title">톡계</div>
<div class="row">
<button class="btn" type="button" onclick="loadStats()">μƒˆλ‘œκ³ μΉ¨</button>
</div>
</div>
<div class="card-body">
<div class="row">
<span id="statsSummary" class="pill mono"></span>
<span id="cadenceSummary" class="pill mono"></span>
</div>
<div class="hint" style="margin-top:10px;">
- ν†΅κ³„λŠ” <strong>κΈ°κ°„ν‘œ(1~20)</strong>μ—μ„œ β€œκΈ°κ°„ 뢄석(μ‚°μ •)β€λœ ν”„λ‘œμ νŠΈλ“€λ§Œ ν¬ν•¨λ©λ‹ˆλ‹€.
</div>
</div>
</div>
<div class="split">
<div class="card">
<div class="card-header">
<div class="card-title">μž…λ ₯</div>
</div>
<div class="card-body">
<div class="row">
<label class="hint">κΈ°μ€€</label>
<select id="basis" class="select">
<option value="median">쀑앙값(ꢌμž₯)</option>
<option value="avg">평균</option>
<option value="p75">보수적(P75)</option>
<option value="p25">곡격적(P25)</option>
</select>
</div>
<div class="row" style="margin-top:10px;">
<label class="hint">회차 착수 간격(일)</label>
<input id="cadence" class="input" type="number" placeholder="λΉ„μš°λ©΄ 톡계 쀑앙값" />
</div>
<div class="row" style="margin-top:10px;">
<label class="hint">μ‹œμž‘μΌ(Forward)</label>
<input id="startDate" class="input" type="date" />
<button class="btn btn-primary" type="button" onclick="planForward()">μ‹œμž‘μΌ κΈ°μ€€ μ‚°μ •</button>
</div>
<div class="row" style="margin-top:10px;">
<label class="hint">런칭일(20ν™” μ œμž‘ 마무리 κ°€μ •)</label>
<input id="launchDate" class="input" type="date" />
<button class="btn btn-primary" type="button" onclick="planLaunch()">런칭일 κΈ°μ€€ μ—­μ‚°</button>
</div>
<div class="hint" style="margin-top:12px;">
- 런칭일 역산은 <strong>β€œ20ν™” μ œμž‘ μ’…λ£Œ(μ™„λ£Œ) = λŸ°μΉ­μΌβ€</strong>둜 κ°€μ •ν•©λ‹ˆλ‹€.<br>
- νšŒμ°¨λŠ” β€œμ°©μˆ˜ 간격” κΈ°μ€€μœΌλ‘œ κ²Ήμ³μ„œ 진행될 수 μžˆμŠ΅λ‹ˆλ‹€(ν˜„μ‹€ μ œμž‘ νŒŒμ΄ν”„λΌμΈ 반영).
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title">μ‚°μ • κ²°κ³Ό</div>
<div class="row"><span id="planSummary" class="mono" style="color:var(--muted)"></span></div>
</div>
<div class="card-body">
<table>
<thead>
<tr>
<th style="width:70px;">회차</th>
<th style="width:130px;">μ‹œμž‘</th>
<th style="width:130px;">μ’…λ£Œ</th>
<th style="width:90px;">일수</th>
</tr>
</thead>
<tbody id="planTbody">
<tr><td colspan="4" style="text-align:center; color: var(--muted); padding: 18px;">μ’ŒμΈ‘μ—μ„œ 산정을 μ‹€ν–‰ν•˜μ„Έμš”.</td></tr>
</tbody>
</table>
<div class="hint" style="margin-top:10px;" id="milestones"></div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title">μ—ν”Όμ†Œλ“œλ³„ 톡계(일)</div>
</div>
<div class="card-body">
<table>
<thead>
<tr>
<th style="width:70px;">회차</th>
<th style="width:70px;">ν‘œλ³Έ</th>
<th style="width:90px;">쀑앙</th>
<th style="width:90px;">평균</th>
<th style="width:90px;">P25</th>
<th style="width:90px;">P75</th>
<th style="width:90px;">μ΅œμ†Œ</th>
<th style="width:90px;">μ΅œλŒ€</th>
</tr>
</thead>
<tbody id="statsTbody">
<tr><td colspan="8" style="text-align:center; color: var(--muted); padding: 18px;">λΆˆλŸ¬μ˜€λŠ” 쀑...</td></tr>
</tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title">μ œμž‘λ‹¨κ³„ 톡계(일)</div>
<div class="row">
<span class="mono" style="color:var(--muted)">단계킀(예: 06, 05-2) κΈ°μ€€</span>
</div>
</div>
<div class="card-body">
<table>
<thead>
<tr>
<th style="width:90px;">단계킀</th>
<th style="width:70px;">ν‘œλ³Έ</th>
<th style="width:90px;">ꢌμž₯</th>
<th style="width:90px;">쀑앙</th>
<th style="width:90px;">평균</th>
<th>곡정(μ£Όμš” 업무/단계λͺ…)</th>
</tr>
</thead>
<tbody id="stageStatsTbody">
<tr><td colspan="6" style="text-align:center; color: var(--muted); padding: 18px;">λΆˆλŸ¬μ˜€λŠ” 쀑...</td></tr>
</tbody>
</table>
<div class="hint" style="margin-top:10px;">
- β€œκΆŒμž₯”은 μƒλ‹¨μ—μ„œ μ„ νƒν•œ κΈ°μ€€(쀑앙/평균/P75/P25)에 따라 λ‹¬λΌμ§‘λ‹ˆλ‹€.
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title">μ‚°μ • κ²°κ³Ό Β· μ œμž‘λ‹¨κ³„ κ³΅μ •ν‘œ(νšŒμ°¨λ³„)</div>
</div>
<div class="card-body">
<div class="hint" style="margin-bottom:10px;">
- μ‚°μ • 결과에 β€œνšŒμ°¨λ³„ μ œμž‘λ‹¨κ³„ κ³΅μ •ν‘œβ€κ°€ ν•¨κ»˜ ν¬ν•¨λ©λ‹ˆλ‹€. (κΈ°κ°„ 톡계 기반 λΉ„μœ¨ λ°°λΆ„)
</div>
<div id="stagePlanWrap">
<div class="mono" style="color:var(--muted)">μ’ŒμΈ‘μ—μ„œ 산정을 μ‹€ν–‰ν•˜λ©΄ ν‘œμ‹œλ©λ‹ˆλ‹€.</div>
</div>
<div id="ganttSection" style="margin-top:24px; border-top:1px dashed var(--border); padding-top:24px; display:none;">
<div class="card-title" style="font-size:14px; margin-bottom:12px; display:flex; justify-content:space-between; align-items:center;">
<span>κ°„νŠΈ 차트 μ‹œκ°ν™”</span>
<div class="row" style="gap:4px;">
<button class="btn view-btn" data-mode="Day" style="padding:6px 10px; font-size:11px;" onclick="changeViewMode('Day')">일</button>
<button class="btn view-btn active" data-mode="Week" style="padding:6px 10px; font-size:11px;" onclick="changeViewMode('Week')">μ£Ό</button>
<button class="btn view-btn" data-mode="Month" style="padding:6px 10px; font-size:11px;" onclick="changeViewMode('Month')">μ›”</button>
</div>
</div>
<div style="display:flex; border:1px solid var(--border); border-radius:8px; overflow:hidden;">
<!-- 쒌츑 리슀트 νŒ¨λ„ -->
<div id="gantt-side" style="width:220px; flex-shrink:0; border-right:1px solid var(--border); background:#fff; z-index:10;"></div>
<!-- 우츑 κ°„νŠΈ 차트 νŒ¨λ„ -->
<div style="flex-grow:1; overflow-x:auto; background:#fff;">
<svg id="gantt"></svg>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
const alertContainer = document.getElementById('alertContainer');
const statsSummary = document.getElementById('statsSummary');
const cadenceSummary = document.getElementById('cadenceSummary');
const statsTbody = document.getElementById('statsTbody');
const stageStatsTbody = document.getElementById('stageStatsTbody');
const planTbody = document.getElementById('planTbody');
const planSummary = document.getElementById('planSummary');
const milestonesEl = document.getElementById('milestones');
const stagePlanWrap = document.getElementById('stagePlanWrap');
const ganttSection = document.getElementById('ganttSection');
let ganttChart = null;
let currentTasks = []; // ν˜„μž¬ λ Œλ”λ§λœ νƒœμŠ€ν¬ μ €μž₯용
const basisEl = document.getElementById('basis');
const cadenceEl = document.getElementById('cadence');
const startDateEl = document.getElementById('startDate');
const launchDateEl = document.getElementById('launchDate');
let stageKeyMap = {};
function showAlert(message, type) {
alertContainer.innerHTML = `<div class="alert ${type}">${message}</div>`;
setTimeout(() => { alertContainer.innerHTML = ''; }, 6000);
}
async function fetchJson(url, options = {}) {
const res = await fetch(url, { credentials: 'include', ...options });
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch { data = { raw: text }; }
return { res, data };
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text == null ? '' : String(text);
return div.innerHTML;
}
async function loadStageKeyMap() {
const { res, data } = await fetchJson('/api/webtoons/stage-keys');
if (!res.ok) {
stageKeyMap = {};
return;
}
const items = data?.mappings || [];
stageKeyMap = {};
for (const it of items) {
const k = String(it?.stage_key || '').trim();
const v = String(it?.label || '').trim();
if (k && v) stageKeyMap[k] = v;
}
}
function displayStageLabel(stageKey, fallbackText = '') {
const k = String(stageKey || '').trim();
const mapped = stageKeyMap[k];
if (mapped) return mapped;
return k || (fallbackText ? String(fallbackText) : '');
}
async function loadStats() {
const { res, data } = await fetchJson('/api/webtoons/duration/stats');
if (!res.ok) {
showAlert(data?.error || `톡계 쑰회 μ‹€νŒ¨ (${res.status})`, 'error');
return;
}
const projectCount = data?.project_count ?? 0;
statsSummary.innerHTML = `<strong>${projectCount}</strong>개 ν”„λ‘œμ νŠΈ κΈ°μ€€`;
const c = data?.cadence || {};
cadenceSummary.innerHTML = `착수 간격(일): <strong>${c.median_days ?? '-'}</strong> (ν‘œλ³Έ ${c.count ?? 0})`;
const rows = data?.episode_stats || [];
statsTbody.innerHTML = rows.map(r => `
<tr>
<td><strong>${r.episode_num}ν™”</strong></td>
<td>${r.count ?? 0}</td>
<td>${r.median_days ?? '-'}</td>
<td>${r.avg_days ?? '-'}</td>
<td>${r.p25_days ?? '-'}</td>
<td>${r.p75_days ?? '-'}</td>
<td>${r.min_days ?? '-'}</td>
<td>${r.max_days ?? '-'}</td>
</tr>
`).join('') || `<tr><td colspan="8" style="text-align:center; color: var(--muted); padding: 18px;">데이터 μ—†μŒ</td></tr>`;
await loadStageStats();
}
async function loadStageStats() {
const basis = basisEl.value || 'median';
const { res, data } = await fetchJson(`/api/webtoons/duration/stage-stats?basis=${encodeURIComponent(basis)}`);
if (!res.ok) {
stageStatsTbody.innerHTML = `<tr><td colspan="6" style="text-align:center; color: var(--muted); padding: 18px;">쑰회 μ‹€νŒ¨</td></tr>`;
return;
}
const rows = data?.stages || [];
stageStatsTbody.innerHTML = rows.slice(0, 80).map(r => {
const title = (r.top_texts && r.top_texts[0]) ? r.top_texts[0].value : '';
const tasks = (r.top_tasks || []).slice(0, 5).map(x => x.value).join(', ');
const meta = [title, tasks].filter(Boolean).join(' / ');
const label = displayStageLabel(r.stage_key, title);
return `
<tr>
<td><strong title="${escapeHtml(r.stage_key || '')}">${escapeHtml(label || '')}</strong></td>
<td>${r.count ?? 0}</td>
<td>${r.recommended_days ?? '-'}</td>
<td>${r.median_days ?? '-'}</td>
<td>${r.avg_days ?? '-'}</td>
<td class="mono">${escapeHtml(meta || '')}</td>
</tr>
`;
}).join('') || `<tr><td colspan="6" style="text-align:center; color: var(--muted); padding: 18px;">데이터 μ—†μŒ</td></tr>`;
}
function renderGantt(tasks) {
if (!tasks || !tasks.length) {
ganttSection.style.display = 'none';
return;
}
ganttSection.style.display = 'block';
document.getElementById('gantt').innerHTML = '';
const headerHeight = 50;
const barHeight = 25;
const padding = 15; // bar_height(25) + padding(15) = 40px Row Height
const rowHeight = barHeight + padding;
ganttChart = new Gantt("#gantt", tasks, {
header_height: headerHeight,
column_width: 30,
step: 24,
view_modes: ['Quarter Day', 'Half Day', 'Day', 'Week', 'Month'],
bar_height: barHeight,
bar_corner_radius: 4,
arrow_curve: 5,
padding: padding,
view_mode: 'Week',
date_format: 'YYYY-MM-DD',
// language: 'ko', // 였λ₯˜ λ°œμƒμœΌλ‘œ κΈ°λ³Έκ°’(en) μ‚¬μš©
popup_trigger: 'click mouseover',
on_click: function(task) {
if (task._target_id) {
const el = document.getElementById(task._target_id);
if (el) {
el.open = true; // details μ—΄κΈ°
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
// ν•˜μ΄λΌμ΄νŠΈ 효과
el.style.transition = 'background-color 0.5s';
el.style.backgroundColor = '#fff8e1';
setTimeout(() => { el.style.backgroundColor = ''; }, 1500);
}
}
},
custom_popup_html: function(task) {
return `
<div class="details-container" style="padding:10px; background:#fff; border:1px solid #eee; border-radius:4px; box-shadow:0 2px 8px rgba(0,0,0,0.1); width:200px; font-size:12px;">
<div style="font-weight:bold; margin-bottom:4px;">${task.name}</div>
<div style="color:#666;">${task.start} ~ ${task.end}</div>
<div style="color:#666;">${task._duration}일 μ†Œμš”</div>
<div style="color:#888; font-size:11px; margin-top:4px;">진행도: ${task.progress}%</div>
</div>
`;
}
});
// 쒌츑 리슀트 λ Œλ”λ§
const sideEl = document.getElementById('gantt-side');
let sideHtml = `<div style="height:${headerHeight}px; border-bottom:1px solid #e5e7eb; display:flex; align-items:center; padding-left:12px; font-weight:bold; background:#fbfbff; font-size:12px; color:#5f6368;">ν•­λͺ©</div>`;
tasks.forEach(t => {
const isGroup = (t.custom_class || '').includes('group');
const pl = isGroup ? '12px' : '28px';
const fw = isGroup ? '700' : '400';
const col = isGroup ? '#202124' : '#5f6368';
const bg = isGroup ? '#f8f9fa' : '#fff';
const name = t.name.trim();
const cursor = t._target_id ? 'pointer' : 'default'; // 클릭 κ°€λŠ₯ μ‹œ 포인터
sideHtml += `
<div onclick="handleClickSide('${t.id}')" style="height:${rowHeight}px; display:flex; align-items:center; padding-left:${pl}; padding-right:8px; font-weight:${fw}; font-size:12px; color:${col}; background:${bg}; border-bottom:1px solid #f1f3f4; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; cursor:${cursor};" title="${name}">
${name}
</div>
`;
});
sideEl.innerHTML = sideHtml;
}
// 쒌츑 리슀트 클릭 ν•Έλ“€λŸ¬
function handleClickSide(taskId) {
if (!ganttChart) return;
// ganttChart λ‚΄λΆ€μ˜ tasksλ₯Ό μ°Ύμ•„μ„œ _target_id 확인
// Frappe GanttλŠ” λ‚΄λΆ€μ μœΌλ‘œ tasksλ₯Ό λ³΅μ‚¬ν•΄μ„œ μ“°λ―€λ‘œ, 원본 tasks 배열을 μ°Έμ‘°ν•˜κ±°λ‚˜ APIλ₯Ό 톡해야 함.
// ν•˜μ§€λ§Œ renderGantt ν•¨μˆ˜ μŠ€μ½”ν”„ λ°–μ΄λ―€λ‘œ, tasks 데이터λ₯Ό 전역에 μ €μž₯ν•˜κ±°λ‚˜ ganttChart κ°μ²΄μ—μ„œ μ°Ύμ•„μ•Ό 함.
// μ—¬κΈ°μ„œλŠ” tasksκ°€ renderPlan 호좜 μ‹œλ§ˆλ‹€ μƒμ„±λ˜λ―€λ‘œ, μ „μ—­ λ³€μˆ˜μ— μ €μž₯ν•΄λ‘λŠ” 것이 μ’‹μŒ.
const task = (currentTasks || []).find(t => t.id === taskId);
if (task && task._target_id) {
const el = document.getElementById(task._target_id);
if (el) {
el.open = true;
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.style.transition = 'background-color 0.5s';
el.style.backgroundColor = '#fff8e1';
setTimeout(() => { el.style.backgroundColor = ''; }, 1500);
}
}
}
function changeViewMode(mode) {
if(ganttChart) ganttChart.change_view_mode(mode);
// λ²„νŠΌ ν™œμ„±ν™” μƒνƒœ μ—…λ°μ΄νŠΈ
document.querySelectorAll('.view-btn').forEach(btn => {
if (btn.dataset.mode === mode) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
}
function renderPlan(plan) {
if (!plan) return;
const p = plan.project || {};
planSummary.textContent = `ν”„λ‘œμ νŠΈ κΈ°κ°„: ${p.start_date || '?'} ~ ${p.end_date || '?'} Β· 총 ${p.duration_days || 0}일 Β· 착수 간격 ${plan.cadence_days || '-'}일 Β· κΈ°μ€€ ${plan.basis || ''}`;
const eps = plan.episodes || [];
planTbody.innerHTML = eps.map(e => `
<tr>
<td><strong>${e.episode_num}ν™”</strong></td>
<td class="mono">${escapeHtml(e.start_date || '')}</td>
<td class="mono">${escapeHtml(e.end_date || '')}</td>
<td>${e.duration_days || 0}</td>
</tr>
`).join('') || `<tr><td colspan="4" style="text-align:center; color: var(--muted); padding: 18px;">κ²°κ³Ό μ—†μŒ</td></tr>`;
const ms = plan.milestones || [];
milestonesEl.innerHTML = ms.map(m => `- <strong>${escapeHtml(m.label || '')}</strong>: <span class="mono">${escapeHtml(m.date || '')}</span>`).join('<br>');
// 단계 κ³΅μ •ν‘œ(νšŒμ°¨λ³„)
const stageTpl = plan.stage_template || [];
if (!eps.length || !stageTpl.length) {
stagePlanWrap.innerHTML = `<div class="mono" style="color:var(--muted)">단계 κ³΅μ •ν‘œλ₯Ό 생성할 수 μ—†μŠ΅λ‹ˆλ‹€. (단계 톡계 데이터가 μ—†κ±°λ‚˜ μ‚°μ • κ²°κ³Όκ°€ μ—†μŒ)</div>`;
renderGantt([]);
return;
}
const tasks = [];
currentTasks = []; // μ΄ˆκΈ°ν™”
stagePlanWrap.innerHTML = eps.map(e => {
const stages = e.stages || [];
const header = `${e.episode_num}ν™” Β· ${e.start_date} ~ ${e.end_date} (${e.duration_days}일)`;
// 1. λŒ€λΆ„λ₯˜: 회차 전체 (λ§ˆμΌμŠ€ν†€ κ·Έλ£Ή)
if (e.start_date && e.end_date) {
const t = {
id: `ep_summary_${e.episode_num}`,
name: `${e.episode_num}ν™” 전체`,
start: e.start_date,
end: e.end_date,
progress: 0,
dependencies: '',
custom_class: 'gantt-ep-group',
_duration: e.duration_days,
_target_id: `details-ep-${e.episode_num}`
};
tasks.push(t);
currentTasks.push(t);
}
const rows = stages.map(s => {
// 2. μ†ŒλΆ„λ₯˜: μž‘μ—… 단계
if (s.start_date && s.end_date) {
const t = {
id: `ep${e.episode_num}_${s.stage_key}`,
name: ` ${displayStageLabel(s.stage_key, '')}`,
start: s.start_date,
end: s.end_date,
progress: 0,
custom_class: `gantt-ep-${e.episode_num % 5}`,
_duration: s.duration_days
};
tasks.push(t);
currentTasks.push(t);
}
return `
<tr>
<td style="width:90px;"><strong title="${escapeHtml(s.stage_key || '')}">${escapeHtml(displayStageLabel(s.stage_key, ''))}</strong></td>
<td style="width:130px;" class="mono">${escapeHtml(s.start_date || '')}</td>
<td style="width:130px;" class="mono">${escapeHtml(s.end_date || '')}</td>
<td style="width:90px;">${s.duration_days || 0}</td>
</tr>
`}).join('');
return `
<details id="details-ep-${e.episode_num}" style="margin-bottom:10px;">
<summary>${escapeHtml(header)}</summary>
<div style="margin-top:10px;">
<table>
<thead>
<tr><th style="width:90px;">단계</th><th style="width:130px;">μ‹œμž‘</th><th style="width:130px;">μ’…λ£Œ</th><th style="width:90px;">일수</th></tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
</details>
`;
}).join('');
renderGantt(tasks);
}
async function planForward() {
const start = startDateEl.value;
if (!start) return showAlert('μ‹œμž‘μΌμ„ μž…λ ₯ν•΄μ£Όμ„Έμš”.', 'error');
const payload = {
mode: 'forward',
basis: basisEl.value,
cadence_days: cadenceEl.value ? Number(cadenceEl.value) : null,
start_date: start,
include_stages: true
};
const { res, data } = await fetchJson('/api/webtoons/duration/plan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) return showAlert(data?.error || `μ‚°μ • μ‹€νŒ¨ (${res.status})`, 'error');
renderPlan(data);
showAlert('산정이 μ™„λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.', 'success');
}
async function planLaunch() {
const launch = launchDateEl.value;
if (!launch) return showAlert('λŸ°μΉ­μΌμ„ μž…λ ₯ν•΄μ£Όμ„Έμš”.', 'error');
const payload = {
mode: 'launch',
basis: basisEl.value,
cadence_days: cadenceEl.value ? Number(cadenceEl.value) : null,
launch_date: launch,
include_stages: true
};
const { res, data } = await fetchJson('/api/webtoons/duration/plan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) return showAlert(data?.error || `μ—­μ‚° μ‹€νŒ¨ (${res.status})`, 'error');
renderPlan(data);
showAlert('역산이 μ™„λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.', 'success');
}
async function init() {
await loadStageKeyMap();
await loadStats();
basisEl.addEventListener('change', () => loadStats());
}
init();
</script>
</body>
</html>