|
|
<!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} |
|
|
|
|
|
|
|
|
.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; |
|
|
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', |
|
|
|
|
|
popup_trigger: 'click mouseover', |
|
|
on_click: function(task) { |
|
|
if (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); |
|
|
} |
|
|
} |
|
|
}, |
|
|
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; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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}μΌ)`; |
|
|
|
|
|
|
|
|
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 => { |
|
|
|
|
|
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> |
|
|
|
|
|
|
|
|
|