Update intranet tools and voucher comparison
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}사용자 관리{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
.admin-user-form {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.admin-user-main-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(110px, 0.9fr) minmax(120px, 1fr) minmax(140px, 1fr) minmax(130px, 0.9fr) auto;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.admin-user-form .field {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-user-form input:not([type="checkbox"]),
|
||||
.admin-user-form select {
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: var(--ink);
|
||||
box-sizing: border-box;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.admin-user-actions {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: flex-end;
|
||||
min-width: 76px;
|
||||
}
|
||||
|
||||
.admin-user-actions button {
|
||||
width: 76px;
|
||||
min-height: 36px;
|
||||
padding: 0 14px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.admin-user-permission-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.permission-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 7px 0 0;
|
||||
}
|
||||
|
||||
.permission-grid label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
min-height: 28px;
|
||||
padding: 4px 9px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: #ffffff;
|
||||
color: var(--ink);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.permission-grid input[type="checkbox"],
|
||||
.admin-active-toggle input[type="checkbox"] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
accent-color: #111827;
|
||||
}
|
||||
|
||||
.permission-chip-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.permission-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 22px;
|
||||
padding: 2px 7px;
|
||||
border: 1px solid var(--line);
|
||||
background: #f7f9fb;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.admin-active-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 30px;
|
||||
padding-top: 19px;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.admin-user-main-row {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.admin-user-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.admin-user-main-row,
|
||||
.admin-user-permission-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2>사용자 관리</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="admin-user-form" method="post" action="/admin/users">
|
||||
<div class="admin-user-main-row">
|
||||
<div class="field">
|
||||
<label for="username">아이디</label>
|
||||
<input id="username" name="username" required autocomplete="off">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="displayName">이름</label>
|
||||
<input id="displayName" name="display_name">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">비밀번호</label>
|
||||
<input id="password" name="password" type="password" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="role">역할</label>
|
||||
<select id="role" name="role">
|
||||
<option value="viewer">사용자별 권한</option>
|
||||
<option value="admin">관리자</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="admin-user-actions">
|
||||
<button type="submit">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-user-permission-row">
|
||||
<div>
|
||||
<label>페이지 권한</label>
|
||||
<div class="permission-grid">
|
||||
{% for permission in permission_options %}
|
||||
<label>
|
||||
<input type="checkbox" name="permissions" value="{{ permission.key }}" {% if permission.key != 'admin' %}checked{% endif %}>
|
||||
{{ permission.label }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<label class="admin-active-toggle">
|
||||
<input type="checkbox" name="is_active" value="1" checked>
|
||||
활성
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>아이디</th>
|
||||
<th>이름</th>
|
||||
<th>역할</th>
|
||||
<th>페이지 권한</th>
|
||||
<th>상태</th>
|
||||
<th>관리자</th>
|
||||
<th>수정일</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr
|
||||
data-username="{{ user.username }}"
|
||||
data-display-name="{{ user.display_name }}"
|
||||
data-role="{{ 'admin' if user.is_admin else 'viewer' }}"
|
||||
data-active="{{ '1' if user.is_active else '0' }}"
|
||||
data-permissions="{{ ','.join(user.direct_permissions) }}"
|
||||
>
|
||||
<td><button type="button" class="button-secondary user-edit-button">{{ user.username }}</button></td>
|
||||
<td>{{ user.display_name }}</td>
|
||||
<td>{{ ', '.join(user.roles) }}</td>
|
||||
<td>
|
||||
<div class="permission-chip-list">
|
||||
{% for permission in permission_options %}
|
||||
{% if permission.key in user.direct_permissions %}
|
||||
<span class="permission-chip">{{ permission.label }}</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if user.is_admin %}
|
||||
<span class="permission-chip">전체</span>
|
||||
{% elif not user.direct_permissions %}
|
||||
<span class="muted">역할 기본값</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ '활성' if user.is_active else '비활성' }}</td>
|
||||
<td>{{ '예' if user.is_admin else '-' }}</td>
|
||||
<td>{{ user.updated_at }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="empty">사용자가 없습니다.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
(() => {
|
||||
const form = document.querySelector("form[action='/admin/users']");
|
||||
if (!form) return;
|
||||
const username = form.querySelector("[name='username']");
|
||||
const displayName = form.querySelector("[name='display_name']");
|
||||
const password = form.querySelector("[name='password']");
|
||||
const role = form.querySelector("[name='role']");
|
||||
const active = form.querySelector("[name='is_active']");
|
||||
const permissionInputs = [...form.querySelectorAll("[name='permissions']")];
|
||||
|
||||
document.querySelectorAll(".user-edit-button").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const row = button.closest("tr");
|
||||
const permissions = new Set(String(row?.dataset.permissions || "").split(",").filter(Boolean));
|
||||
username.value = row?.dataset.username || "";
|
||||
displayName.value = row?.dataset.displayName || "";
|
||||
password.value = "";
|
||||
role.value = row?.dataset.role || "viewer";
|
||||
active.checked = row?.dataset.active === "1";
|
||||
permissionInputs.forEach((input) => {
|
||||
input.checked = permissions.has(input.value) || (role.value !== "admin" && !permissions.size && input.value !== "admin");
|
||||
});
|
||||
username.focus();
|
||||
});
|
||||
});
|
||||
|
||||
role.addEventListener("change", () => {
|
||||
const isAdmin = role.value === "admin";
|
||||
permissionInputs.forEach((input) => {
|
||||
input.disabled = isAdmin;
|
||||
if (isAdmin) input.checked = false;
|
||||
});
|
||||
});
|
||||
|
||||
form.addEventListener("submit", () => {
|
||||
const isAdmin = role.value === "admin";
|
||||
permissionInputs.forEach((input) => {
|
||||
input.disabled = isAdmin;
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -25,6 +25,49 @@
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.summary-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-job-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
max-width: 320px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #d8e2ec;
|
||||
border-radius: 999px;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.page-job-status[data-state="queued"],
|
||||
.page-job-status[data-state="running"] {
|
||||
border-color: #bae6fd;
|
||||
background: #f0f9ff;
|
||||
color: #075985;
|
||||
}
|
||||
|
||||
.page-job-status[data-state="done"] {
|
||||
border-color: #bbf7d0;
|
||||
background: #f0fdf4;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.page-job-status[data-state="failed"] {
|
||||
border-color: #fecaca;
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -207,6 +250,10 @@
|
||||
<section class="panel">
|
||||
<div class="section-title" style="margin-bottom: 14px;">
|
||||
<h2>수익/비용 현황</h2>
|
||||
<div class="summary-actions">
|
||||
<button type="button" class="button-secondary" id="annualRebuildCacheBtn">캐시 재계산</button>
|
||||
<span class="page-job-status" id="annualJobStatus" data-state="">작업 상태 확인 전</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-panel">
|
||||
<div class="summary-overview">
|
||||
@@ -253,6 +300,7 @@
|
||||
let yearlySeries = [];
|
||||
let monthlySeries = [];
|
||||
let availableYears = [];
|
||||
let annualJobPollTimer = null;
|
||||
const annualMetricCards = {{ annual_metric_cards | tojson }};
|
||||
const annualExpenseChartMetrics = {{ annual_expense_chart_metrics | tojson }};
|
||||
const annualBalanceChartMetrics = {{ annual_balance_chart_metrics | tojson }};
|
||||
@@ -356,6 +404,89 @@
|
||||
return String(availableYears[availableYears.length - 1] || "recent10");
|
||||
}
|
||||
|
||||
async function fetchAnnualJson(url, options = {}) {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
cache: "no-store",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok || payload?.error || payload?.ok === false) {
|
||||
throw new Error(payload?.error || `HTTP ${response.status}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function describeAnnualJob(job) {
|
||||
if (!job) return { text: "최근 작업 없음", state: "" };
|
||||
const status = String(job.status || "");
|
||||
const message = job.error_message || job.message || "";
|
||||
if (status === "queued") return { text: "작업 대기 중", state: status };
|
||||
if (status === "running") return { text: `계산 중... ${message}`.trim(), state: status };
|
||||
if (status === "done") return { text: "계산 완료", state: status };
|
||||
if (status === "failed") return { text: `실패: ${message || "오류"}`, state: status };
|
||||
return { text: message || status || "작업 상태 확인 전", state: status };
|
||||
}
|
||||
|
||||
function renderAnnualJob(job) {
|
||||
const jobStatus = document.getElementById("annualJobStatus");
|
||||
const rebuildButton = document.getElementById("annualRebuildCacheBtn");
|
||||
if (!jobStatus) return;
|
||||
const view = describeAnnualJob(job);
|
||||
jobStatus.textContent = view.text;
|
||||
jobStatus.dataset.state = view.state || "";
|
||||
if (rebuildButton) {
|
||||
rebuildButton.disabled = view.state === "queued" || view.state === "running";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLatestAnnualJob() {
|
||||
const params = new URLSearchParams({
|
||||
page_key: "annual_summary",
|
||||
job_type: "annual_summary_bootstrap",
|
||||
});
|
||||
const payload = await fetchAnnualJson(`/api/system-jobs/latest?${params.toString()}`);
|
||||
renderAnnualJob(payload.job || null);
|
||||
return payload.job || null;
|
||||
}
|
||||
|
||||
function pollAnnualJob(jobId) {
|
||||
if (!jobId) return;
|
||||
if (annualJobPollTimer) window.clearInterval(annualJobPollTimer);
|
||||
annualJobPollTimer = window.setInterval(async () => {
|
||||
try {
|
||||
const payload = await fetchAnnualJson(`/api/system-jobs/${encodeURIComponent(jobId)}`);
|
||||
const job = payload.job || null;
|
||||
renderAnnualJob(job);
|
||||
if (!job || ["done", "failed", "cancelled"].includes(String(job.status || ""))) {
|
||||
window.clearInterval(annualJobPollTimer);
|
||||
annualJobPollTimer = null;
|
||||
if (job && job.status === "done") {
|
||||
window.setTimeout(() => window.location.reload(), 600);
|
||||
}
|
||||
}
|
||||
} catch (_error) {
|
||||
// Keep existing charts visible during transient lock or network delays.
|
||||
}
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
async function requestAnnualCacheRebuild() {
|
||||
const rebuildButton = document.getElementById("annualRebuildCacheBtn");
|
||||
if (rebuildButton) rebuildButton.disabled = true;
|
||||
const payload = await fetchAnnualJson("/annual-summary/api/rebuild-cache", {
|
||||
method: "POST",
|
||||
});
|
||||
renderAnnualJob(payload.job || null);
|
||||
pollAnnualJob(payload.job?.id);
|
||||
}
|
||||
|
||||
async function loadAnnualSummaryBootstrapData() {
|
||||
const response = await fetch("/annual-summary/bootstrap-data", {
|
||||
method: "GET",
|
||||
@@ -590,6 +721,21 @@
|
||||
|
||||
document.getElementById("granularity").addEventListener("change", renderAll);
|
||||
document.getElementById("yearFilter").addEventListener("change", renderAll);
|
||||
document.getElementById("annualRebuildCacheBtn")?.addEventListener("click", async () => {
|
||||
const confirmed = window.confirm("연도별 수익/비용 데이터를 서버에서 다시 계산할까요? 계산 중에도 다른 화면을 사용할 수 있습니다.");
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await requestAnnualCacheRebuild();
|
||||
} catch (error) {
|
||||
const jobStatus = document.getElementById("annualJobStatus");
|
||||
if (jobStatus) {
|
||||
jobStatus.textContent = error.message || "작업 등록 실패";
|
||||
jobStatus.dataset.state = "failed";
|
||||
}
|
||||
const rebuildButton = document.getElementById("annualRebuildCacheBtn");
|
||||
if (rebuildButton) rebuildButton.disabled = false;
|
||||
}
|
||||
});
|
||||
const granularitySelect = document.getElementById("granularity");
|
||||
if (granularitySelect) {
|
||||
granularitySelect.value = "yearly";
|
||||
@@ -605,6 +751,19 @@
|
||||
console.error("연도별 수익/비용 부트스트랩 데이터 조회 에러", error);
|
||||
}
|
||||
renderAll();
|
||||
loadLatestAnnualJob()
|
||||
.then((job) => {
|
||||
if (job && ["queued", "running"].includes(String(job.status || ""))) {
|
||||
pollAnnualJob(job.id);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
const jobStatus = document.getElementById("annualJobStatus");
|
||||
if (jobStatus) {
|
||||
jobStatus.textContent = "작업 상태 조회 실패";
|
||||
jobStatus.dataset.state = "failed";
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
+70
-17
@@ -520,7 +520,7 @@
|
||||
|
||||
.sync-status {
|
||||
margin-left: auto;
|
||||
max-width: 240px;
|
||||
max-width: 320px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: rgba(248, 250, 252, 0.92);
|
||||
@@ -645,6 +645,31 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sync-refresh-button {
|
||||
display: none;
|
||||
height: calc(var(--status-widget-height) - 10px);
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 999px;
|
||||
background: #ffffff;
|
||||
color: #111827;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
padding: 0 10px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sync-status.has-pending {
|
||||
border-color: #f59e0b;
|
||||
background: #fff7ed;
|
||||
}
|
||||
|
||||
.sync-status.has-pending .sync-refresh-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.stats {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -682,14 +707,15 @@
|
||||
<body>
|
||||
<div class="page">
|
||||
<nav class="nav">
|
||||
<a href="/" class="{% if request.url.path == '/' %}active{% endif %}">대시보드</a>
|
||||
<a href="/annual-summary" class="{% if request.url.path == '/annual-summary' %}active{% endif %}">연도별 수익/비용</a>
|
||||
<a href="/process-cost" class="{% if request.url.path == '/process-cost' %}active{% endif %}">프로젝트 원가</a>
|
||||
<a href="/projects" class="{% if request.url.path == '/projects' %}active{% endif %}">프로젝트 정보</a>
|
||||
<a href="/wehago-compare" class="{% if request.url.path == '/wehago-compare' %}active{% endif %}">전표비교</a>
|
||||
<a href="/hanmac-browser" class="{% if request.url.path == '/hanmac-browser' %}active{% endif %}">hanmac DB_external</a>
|
||||
<a href="/db-browser" class="{% if request.url.path.startswith('/db') %}active{% endif %}">DB 조회</a>
|
||||
{% for item in nav_items %}
|
||||
{% set is_active = request.url.path == item.href or (item.active == 'db' and request.url.path.startswith('/db')) or (item.active == 'admin' and request.url.path.startswith('/admin')) %}
|
||||
<a href="{{ item.href }}" class="{% if is_active %}active{% endif %}">{{ item.label }}</a>
|
||||
{% endfor %}
|
||||
<div class="nav-spacer"></div>
|
||||
{% if current_user %}
|
||||
<span class="sync-pill">{{ current_user.display_name }}</span>
|
||||
<a href="/logout">로그아웃</a>
|
||||
{% endif %}
|
||||
<button type="button" class="view-mode-switch" id="viewModeSwitch" data-mode="dual" aria-label="화면 구성 전환">
|
||||
<span class="label" id="viewModeSwitchLabel">듀얼</span>
|
||||
</button>
|
||||
@@ -698,19 +724,20 @@
|
||||
id="syncStatusWidget"
|
||||
data-data-version="{{ data_version or '' }}"
|
||||
data-refresh-url="{{ request.url.path }}{% if request.url.query %}?{{ request.url.query }}{% endif %}"
|
||||
data-refresh-mode="{{ 'disabled' if request.url.path in ['/projects', '/wehago-compare'] else 'auto' }}"
|
||||
data-refresh-mode="manual"
|
||||
>
|
||||
<div class="sync-status-head">
|
||||
<div class="sync-status-title">
|
||||
<span class="sync-dot" id="syncStatusDot"></span>
|
||||
<span id="syncStatusLabel">연결 확인 중</span>
|
||||
</div>
|
||||
<button type="button" class="sync-refresh-button" id="syncRefreshButton">새로고침</button>
|
||||
<span class="sync-pill" id="syncSessionPill">세션 준비 중</span>
|
||||
</div>
|
||||
<div class="sync-meta">
|
||||
<div>마지막 확인: <strong id="syncLastChecked">-</strong></div>
|
||||
<div>서버 시간: <strong id="syncServerTime">{{ server_time or '-' }}</strong></div>
|
||||
<div>데이터 버전: <strong id="syncDataVersion">{{ data_version or '-' }}</strong></div>
|
||||
<div>업무 데이터 버전: <strong id="syncDataVersion">{{ data_version or '-' }}</strong></div>
|
||||
<div>계약/청구 동기화: <strong>{{ import_sync_summary.contract_project_count or 0 }}</strong> / <strong>{{ import_sync_summary.billing_project_count or 0 }}</strong></div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -774,10 +801,11 @@
|
||||
const dot = document.getElementById("syncStatusDot");
|
||||
const label = document.getElementById("syncStatusLabel");
|
||||
const sessionPill = document.getElementById("syncSessionPill");
|
||||
const refreshButton = document.getElementById("syncRefreshButton");
|
||||
const lastChecked = document.getElementById("syncLastChecked");
|
||||
const serverTime = document.getElementById("syncServerTime");
|
||||
const dataVersion = document.getElementById("syncDataVersion");
|
||||
if (!dot || !label || !sessionPill || !lastChecked || !serverTime || !dataVersion) return;
|
||||
if (!dot || !label || !sessionPill || !refreshButton || !lastChecked || !serverTime || !dataVersion) return;
|
||||
let pageVersion = widget.dataset.dataVersion || "";
|
||||
const refreshUrl = widget.dataset.refreshUrl || window.location.href;
|
||||
const refreshMode = widget.dataset.refreshMode || "auto";
|
||||
@@ -856,6 +884,7 @@
|
||||
pageVersion = nextVersion || "";
|
||||
pendingVersion = "";
|
||||
widget.dataset.dataVersion = pageVersion;
|
||||
widget.classList.remove("has-pending");
|
||||
dataVersion.textContent = pageVersion || "-";
|
||||
updateWidgetTitle();
|
||||
}
|
||||
@@ -877,7 +906,7 @@
|
||||
|
||||
async function refreshPageWhenSafe(nextVersion) {
|
||||
if (refreshInFlight) return;
|
||||
if (refreshMode === "disabled") {
|
||||
if (refreshMode === "disabled" || refreshMode === "manual") {
|
||||
setPageDataVersion(nextVersion || pageVersion);
|
||||
setStatus("online", "서버 정상 연결");
|
||||
return;
|
||||
@@ -900,6 +929,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
function markPendingBusinessData(nextVersion) {
|
||||
pendingVersion = nextVersion || pendingVersion || pageVersion;
|
||||
widget.classList.add("has-pending");
|
||||
dataVersion.textContent = pendingVersion || "-";
|
||||
setStatus("online", "새 업무 데이터 있음");
|
||||
}
|
||||
|
||||
refreshButton.addEventListener("click", () => {
|
||||
if (refreshInFlight) return;
|
||||
refreshInFlight = true;
|
||||
setStatus("online", "새 데이터 반영 중");
|
||||
window.location.replace(refreshUrl);
|
||||
});
|
||||
|
||||
async function pollHealth() {
|
||||
if (document.hidden) {
|
||||
return;
|
||||
@@ -908,18 +951,28 @@
|
||||
const response = await fetch(`/health?ts=${Date.now()}`, { cache: "no-store" });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const payload = await response.json();
|
||||
const businessDataVersion = payload.business_data_version || payload.data_version || "";
|
||||
serverTime.textContent = payload.server_time || "-";
|
||||
dataVersion.textContent = payload.data_version || "-";
|
||||
dataVersion.textContent = businessDataVersion || "-";
|
||||
updateWidgetTitle();
|
||||
if (payload.data_version && payload.data_version !== pageVersion) {
|
||||
pendingVersion = payload.data_version;
|
||||
await refreshPageWhenSafe(payload.data_version);
|
||||
if (businessDataVersion && businessDataVersion !== pageVersion) {
|
||||
if (refreshMode === "auto") {
|
||||
pendingVersion = businessDataVersion;
|
||||
await refreshPageWhenSafe(businessDataVersion);
|
||||
} else {
|
||||
markPendingBusinessData(businessDataVersion);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (pendingVersion && pendingVersion !== pageVersion) {
|
||||
await refreshPageWhenSafe(pendingVersion);
|
||||
if (refreshMode === "auto") {
|
||||
await refreshPageWhenSafe(pendingVersion);
|
||||
} else {
|
||||
markPendingBusinessData(pendingVersion);
|
||||
}
|
||||
return;
|
||||
}
|
||||
widget.classList.remove("has-pending");
|
||||
setStatus("online", "서버 정상 연결");
|
||||
} catch (error) {
|
||||
setStatus("error", "연결 오류");
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}HM-BIZ-PROCESS{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
body[data-view-mode="single"],
|
||||
body[data-view-mode="dual"] {
|
||||
--page-frame-width: calc(100vw - (var(--page-gutter) * 2));
|
||||
}
|
||||
|
||||
.biz-process-shell {
|
||||
min-height: calc(100vh - 112px);
|
||||
}
|
||||
|
||||
.biz-process-frame {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: calc(100vh - 122px);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: #ffffff;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="panel biz-process-shell">
|
||||
<iframe
|
||||
class="biz-process-frame"
|
||||
src="{{ biz_process_src }}"
|
||||
title="HM-BIZ-PROCESS"
|
||||
></iframe>
|
||||
</section>
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,42 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-job-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
max-width: 320px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #d8e2ec;
|
||||
border-radius: 999px;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.page-job-status[data-state="queued"],
|
||||
.page-job-status[data-state="running"] {
|
||||
border-color: #bae6fd;
|
||||
background: #f0f9ff;
|
||||
color: #075985;
|
||||
}
|
||||
|
||||
.page-job-status[data-state="done"] {
|
||||
border-color: #bbf7d0;
|
||||
background: #f0fdf4;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.page-job-status[data-state="failed"] {
|
||||
border-color: #fecaca;
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.upload-actions:hover .upload-tooltip,
|
||||
.upload-actions:focus-within .upload-tooltip {
|
||||
opacity: 1;
|
||||
@@ -266,6 +302,8 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
<button type="button" class="button-secondary" id="dashboardRebuildCacheBtn">캐시 재계산</button>
|
||||
<span class="page-job-status" id="dashboardJobStatus" data-state="">작업 상태 확인 전</span>
|
||||
<div class="upload-actions">
|
||||
<form action="/upload" method="post" enctype="multipart/form-data" id="uploadForm">
|
||||
<input id="excel_file" class="hidden-file-input" type="file" name="excel_file" accept=".xlsx,.xlsm,.xltx,.xltm" required>
|
||||
@@ -382,6 +420,7 @@
|
||||
let monthlySummary = [];
|
||||
let revenueYearly = [];
|
||||
let revenueMonthly = [];
|
||||
let dashboardJobPollTimer = null;
|
||||
const dashboardRevenueMetricOptions = {{ dashboard_revenue_metric_options | tojson }};
|
||||
const dashboardExpenseMetricOptions = {{ dashboard_expense_metric_options | tojson }};
|
||||
|
||||
@@ -548,6 +587,92 @@
|
||||
return String(availableYears[availableYears.length - 1] || "all");
|
||||
}
|
||||
|
||||
async function fetchDashboardJson(url, options = {}) {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
cache: "no-store",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok || payload?.error || payload?.ok === false) {
|
||||
throw new Error(payload?.error || `HTTP ${response.status}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function describeDashboardJob(job) {
|
||||
if (!job) return { text: "최근 작업 없음", state: "" };
|
||||
const status = String(job.status || "");
|
||||
const range = job.start_year ? String(job.start_year) : "전체";
|
||||
const message = job.error_message || job.message || "";
|
||||
if (status === "queued") return { text: `${range} 대기 중`, state: status };
|
||||
if (status === "running") return { text: `${range} 계산 중... ${message}`.trim(), state: status };
|
||||
if (status === "done") return { text: `${range} 계산 완료`, state: status };
|
||||
if (status === "failed") return { text: `${range} 실패: ${message || "오류"}`, state: status };
|
||||
return { text: `${range} ${message || status || "작업 상태 확인 전"}`.trim(), state: status };
|
||||
}
|
||||
|
||||
function renderDashboardJob(job) {
|
||||
const jobStatus = document.getElementById("dashboardJobStatus");
|
||||
const rebuildButton = document.getElementById("dashboardRebuildCacheBtn");
|
||||
if (!jobStatus) return;
|
||||
const view = describeDashboardJob(job);
|
||||
jobStatus.textContent = view.text;
|
||||
jobStatus.dataset.state = view.state || "";
|
||||
if (rebuildButton) {
|
||||
rebuildButton.disabled = view.state === "queued" || view.state === "running";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLatestDashboardJob() {
|
||||
const params = new URLSearchParams({
|
||||
page_key: "dashboard",
|
||||
job_type: "dashboard_bootstrap",
|
||||
});
|
||||
const payload = await fetchDashboardJson(`/api/system-jobs/latest?${params.toString()}`);
|
||||
renderDashboardJob(payload.job || null);
|
||||
return payload.job || null;
|
||||
}
|
||||
|
||||
function pollDashboardJob(jobId) {
|
||||
if (!jobId) return;
|
||||
if (dashboardJobPollTimer) window.clearInterval(dashboardJobPollTimer);
|
||||
dashboardJobPollTimer = window.setInterval(async () => {
|
||||
try {
|
||||
const payload = await fetchDashboardJson(`/api/system-jobs/${encodeURIComponent(jobId)}`);
|
||||
const job = payload.job || null;
|
||||
renderDashboardJob(job);
|
||||
if (!job || ["done", "failed", "cancelled"].includes(String(job.status || ""))) {
|
||||
window.clearInterval(dashboardJobPollTimer);
|
||||
dashboardJobPollTimer = null;
|
||||
if (job && job.status === "done") {
|
||||
window.setTimeout(() => window.location.reload(), 600);
|
||||
}
|
||||
}
|
||||
} catch (_error) {
|
||||
// Transient DB locks should not block the dashboard controls.
|
||||
}
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
async function requestDashboardCacheRebuild() {
|
||||
const rebuildButton = document.getElementById("dashboardRebuildCacheBtn");
|
||||
if (rebuildButton) rebuildButton.disabled = true;
|
||||
const payload = await fetchDashboardJson("/dashboard/api/rebuild-cache", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ overview_year: pageSelectedYear || null }),
|
||||
});
|
||||
renderDashboardJob(payload.job || null);
|
||||
pollDashboardJob(payload.job?.id);
|
||||
}
|
||||
|
||||
function syncYearSelection(selectId, granularity) {
|
||||
const select = document.getElementById(selectId);
|
||||
if (!select) return;
|
||||
@@ -645,6 +770,22 @@
|
||||
event.target.form?.submit();
|
||||
});
|
||||
|
||||
document.getElementById("dashboardRebuildCacheBtn")?.addEventListener("click", async () => {
|
||||
const confirmed = window.confirm("현재 대시보드 조건의 데이터를 서버에서 다시 계산할까요? 계산 중에도 다른 화면을 사용할 수 있습니다.");
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await requestDashboardCacheRebuild();
|
||||
} catch (error) {
|
||||
const jobStatus = document.getElementById("dashboardJobStatus");
|
||||
if (jobStatus) {
|
||||
jobStatus.textContent = error.message || "작업 등록 실패";
|
||||
jobStatus.dataset.state = "failed";
|
||||
}
|
||||
const rebuildButton = document.getElementById("dashboardRebuildCacheBtn");
|
||||
if (rebuildButton) rebuildButton.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
const uploadButton = document.getElementById("uploadButton");
|
||||
const excelInput = document.getElementById("excel_file");
|
||||
const uploadForm = document.getElementById("uploadForm");
|
||||
@@ -669,6 +810,19 @@
|
||||
syncYearSelection("expenseYear", document.getElementById("expenseGranularity")?.value || "yearly");
|
||||
updateRevenueChart();
|
||||
updateExpenseChart();
|
||||
loadLatestDashboardJob()
|
||||
.then((job) => {
|
||||
if (job && ["queued", "running"].includes(String(job.status || ""))) {
|
||||
pollDashboardJob(job.id);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
const jobStatus = document.getElementById("dashboardJobStatus");
|
||||
if (jobStatus) {
|
||||
jobStatus.textContent = "작업 상태 조회 실패";
|
||||
jobStatus.dataset.state = "failed";
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
+1189
-98
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,97 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>로그인</title>
|
||||
<style>
|
||||
:root {
|
||||
--line: #dfe4ea;
|
||||
--muted: #667085;
|
||||
--ink: #172033;
|
||||
--accent: #0f766e;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #f5f7fa;
|
||||
color: var(--ink);
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: min(360px, calc(100vw - 32px));
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
padding: 26px;
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 18px;
|
||||
font-size: 22px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin: 14px 0 6px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-height: 40px;
|
||||
border: 1px solid var(--line);
|
||||
padding: 8px 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
margin-top: 18px;
|
||||
border: 0;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-weight: 900;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-top: 12px;
|
||||
color: #b42318;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin-top: 14px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<form class="login-card" method="post" action="/login">
|
||||
<h1>한맥 인트라넷 로그인</h1>
|
||||
<input type="hidden" name="next" value="{{ next_url }}">
|
||||
<label for="username">아이디</label>
|
||||
<input id="username" name="username" autocomplete="username" required autofocus>
|
||||
<label for="password">비밀번호</label>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
||||
<button type="submit">로그인</button>
|
||||
{% if error %}
|
||||
<div class="error">{{ error }}</div>
|
||||
{% endif %}
|
||||
<div class="meta">계정은 관리자에게 요청하세요.</div>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
@@ -67,6 +67,42 @@
|
||||
min-width: 110px;
|
||||
}
|
||||
|
||||
.pc-job-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
max-width: 360px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #d8e2ec;
|
||||
border-radius: 999px;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.pc-job-status[data-state="queued"],
|
||||
.pc-job-status[data-state="running"] {
|
||||
border-color: #bae6fd;
|
||||
background: #f0f9ff;
|
||||
color: #075985;
|
||||
}
|
||||
|
||||
.pc-job-status[data-state="done"] {
|
||||
border-color: #bbf7d0;
|
||||
background: #f0fdf4;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.pc-job-status[data-state="failed"] {
|
||||
border-color: #fecaca;
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.pc-side-panel {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
@@ -793,6 +829,10 @@
|
||||
</select>
|
||||
<button type="submit" class="button-secondary">적용</button>
|
||||
</form>
|
||||
<div class="pc-tab-row">
|
||||
<button type="button" class="button-secondary" id="processCostRebuildCacheBtn">캐시 재계산</button>
|
||||
<span class="pc-job-status" id="processCostJobStatus" data-state="">작업 상태 확인 전</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pc-grid">
|
||||
@@ -1081,6 +1121,9 @@
|
||||
const processModalNote = document.getElementById("processFlowModalNote");
|
||||
const processModalClose = document.getElementById("processFlowModalClose");
|
||||
const processButtons = document.querySelectorAll("[data-process-modal]");
|
||||
const rebuildCacheButton = document.getElementById("processCostRebuildCacheBtn");
|
||||
const jobStatus = document.getElementById("processCostJobStatus");
|
||||
let jobPollTimer = null;
|
||||
let selectedStartYear = pageState.selectedStartYear;
|
||||
let selectedEndYear = pageState.selectedEndYear;
|
||||
let selectedSource = typeof pageState.source === "string" ? pageState.source : "hanmac";
|
||||
@@ -1124,6 +1167,112 @@
|
||||
monthlyRows = Array.isArray(pageState.monthlyRows) ? pageState.monthlyRows : [];
|
||||
}
|
||||
|
||||
async function fetchJson(url, options = {}) {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
cache: "no-store",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok || payload?.error || payload?.ok === false) {
|
||||
throw new Error(payload?.error || `HTTP ${response.status}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function describeJob(job) {
|
||||
if (!job) return { text: "최근 작업 없음", state: "" };
|
||||
const status = String(job.status || "");
|
||||
const source = String((job.params && job.params.source) || "");
|
||||
const range = job.start_year && job.end_year ? `${job.start_year}~${job.end_year}` : "";
|
||||
const label = [source ? source.toUpperCase() : "", range].filter(Boolean).join(" ");
|
||||
const message = job.error_message || job.message || "";
|
||||
if (status === "queued") return { text: `${label} 대기 중`.trim(), state: status };
|
||||
if (status === "running") return { text: `${label} 계산 중... ${message}`.trim(), state: status };
|
||||
if (status === "done") return { text: `${label} 계산 완료`.trim(), state: status };
|
||||
if (status === "failed") return { text: `${label} 실패: ${message || "오류"}`.trim(), state: status };
|
||||
return { text: `${label} ${message || status || "작업 상태 확인 전"}`.trim(), state: status };
|
||||
}
|
||||
|
||||
function renderJob(job) {
|
||||
if (!jobStatus) return;
|
||||
const view = describeJob(job);
|
||||
jobStatus.textContent = view.text;
|
||||
jobStatus.dataset.state = view.state || "";
|
||||
if (rebuildCacheButton) {
|
||||
rebuildCacheButton.disabled = view.state === "queued" || view.state === "running";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLatestJob() {
|
||||
const params = new URLSearchParams({
|
||||
page_key: "process_cost",
|
||||
job_type: "process_cost_bootstrap",
|
||||
});
|
||||
const payload = await fetchJson(`/api/system-jobs/latest?${params.toString()}`);
|
||||
renderJob(payload.job || null);
|
||||
return payload.job || null;
|
||||
}
|
||||
|
||||
function pollJob(jobId) {
|
||||
if (!jobId) return;
|
||||
if (jobPollTimer) window.clearInterval(jobPollTimer);
|
||||
jobPollTimer = window.setInterval(async () => {
|
||||
try {
|
||||
const payload = await fetchJson(`/api/system-jobs/${encodeURIComponent(jobId)}`);
|
||||
const job = payload.job || null;
|
||||
renderJob(job);
|
||||
if (!job || ["done", "failed", "cancelled"].includes(String(job.status || ""))) {
|
||||
window.clearInterval(jobPollTimer);
|
||||
jobPollTimer = null;
|
||||
if (job && job.status === "done") {
|
||||
window.setTimeout(() => window.location.reload(), 600);
|
||||
}
|
||||
}
|
||||
} catch (_error) {
|
||||
// Keep the current page usable while transient locks clear.
|
||||
}
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
async function requestCacheRebuild() {
|
||||
if (!rebuildCacheButton) return;
|
||||
rebuildCacheButton.disabled = true;
|
||||
if (jobStatus) {
|
||||
jobStatus.textContent = "작업 등록 중...";
|
||||
jobStatus.dataset.state = "queued";
|
||||
}
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const payload = await fetchJson("/process-cost/api/rebuild-cache", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
source: params.get("source") || selectedSource || "hanmac",
|
||||
start_year: params.get("start_year") || selectedStartYear || null,
|
||||
end_year: params.get("end_year") || selectedEndYear || null,
|
||||
code: params.get("code") || selectedCode || "",
|
||||
include_related: params.get("include_related") || (includeRelatedEnabled ? "1" : "0"),
|
||||
active_related: params.get("active_related") || activeRelatedCodes.join(","),
|
||||
}),
|
||||
});
|
||||
renderJob(payload.job || null);
|
||||
pollJob(payload.job?.id);
|
||||
} catch (error) {
|
||||
if (jobStatus) {
|
||||
jobStatus.textContent = error.message || "작업 등록 실패";
|
||||
jobStatus.dataset.state = "failed";
|
||||
}
|
||||
rebuildCacheButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildProcessCostUrl(code, options = {}) {
|
||||
const params = new URLSearchParams();
|
||||
params.set("source", selectedSource);
|
||||
@@ -1539,6 +1688,14 @@
|
||||
syncYearRangeOptions();
|
||||
}
|
||||
|
||||
if (rebuildCacheButton) {
|
||||
rebuildCacheButton.addEventListener("click", async () => {
|
||||
const confirmed = window.confirm("현재 프로젝트 원가 조건의 데이터를 서버에서 다시 계산할까요? 계산 중에도 다른 화면을 사용할 수 있습니다.");
|
||||
if (!confirmed) return;
|
||||
await requestCacheRebuild();
|
||||
});
|
||||
}
|
||||
|
||||
const svg = document.getElementById("processMonthlyChart");
|
||||
const renderMonthlyChart = () => {
|
||||
if (!svg || !Array.isArray(monthlyRows) || !monthlyRows.length) {
|
||||
@@ -1590,6 +1747,18 @@
|
||||
filterProjectList();
|
||||
renderRelatedChips();
|
||||
renderMonthlyChart();
|
||||
loadLatestJob()
|
||||
.then((job) => {
|
||||
if (job && ["queued", "running"].includes(String(job.status || ""))) {
|
||||
pollJob(job.id);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (jobStatus) {
|
||||
jobStatus.textContent = "작업 상태 조회 실패";
|
||||
jobStatus.dataset.state = "failed";
|
||||
}
|
||||
});
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
|
||||
+398
-33
@@ -2640,6 +2640,42 @@
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.project-job-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
max-width: 300px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #d8e2ec;
|
||||
border-radius: 999px;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.project-job-status[data-state="queued"],
|
||||
.project-job-status[data-state="running"] {
|
||||
border-color: #bae6fd;
|
||||
background: #f0f9ff;
|
||||
color: #075985;
|
||||
}
|
||||
|
||||
.project-job-status[data-state="done"] {
|
||||
border-color: #bbf7d0;
|
||||
background: #f0fdf4;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.project-job-status[data-state="failed"] {
|
||||
border-color: #fecaca;
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -2654,6 +2690,8 @@
|
||||
<div class="project-quick-link-bar" id="projectQuickLinkBar"></div>
|
||||
</div>
|
||||
<div class="toolbar-head-actions">
|
||||
<button type="button" id="projectRebuildCacheBtn" class="button-secondary">캐시 재계산</button>
|
||||
<span class="project-job-status" id="projectJobStatus" data-state="">작업 상태 확인 전</span>
|
||||
<button type="button" id="saveProjectPageState" class="button-icon button-secondary" title="페이지 입력사항 저장" aria-label="페이지 입력사항 저장">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M6 4h10l4 4v12H6z"></path>
|
||||
@@ -3313,6 +3351,12 @@
|
||||
<div style="display:inline-flex; align-items:center; gap:8px;">
|
||||
<label for="laborRateYearSelect" style="font-size:12px; color:var(--muted);">연도</label>
|
||||
<select id="laborRateYearSelect" class="inline-input" style="min-width:96px;"></select>
|
||||
<label for="laborRateCategorySelect" style="font-size:12px; color:var(--muted);">구분</label>
|
||||
<select id="laborRateCategorySelect" class="inline-input" style="min-width:96px;">
|
||||
<option value="설계">설계</option>
|
||||
<option value="감리">감리</option>
|
||||
<option value="지원">지원</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" class="button-secondary button-icon" id="saveLaborRateYearButton" title="기준인건비 연도 저장" aria-label="기준인건비 연도 저장">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
@@ -3340,7 +3384,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>직급</th>
|
||||
<th>기준인건비(연도별)</th>
|
||||
<th>기준인건비(연도/구분별)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="laborRateRows"></tbody>
|
||||
@@ -3659,6 +3703,8 @@
|
||||
const costDepartmentOptions = {{ cost_department_options | tojson }};
|
||||
const costAccountOptions = {{ cost_account_options | tojson }};
|
||||
const laborGradeOptions = {{ labor_grade_options | tojson }};
|
||||
const laborRateCategories = ["설계", "감리", "지원"];
|
||||
const derivedLaborRateGrades = ["수석", "책임", "선임", "연구원"];
|
||||
const uncontractedCategoryOptions = {{ uncontracted_category_options | tojson }};
|
||||
const specialXClassificationRules = {{ special_x_classification_rules | tojson }};
|
||||
const projectRuntimeSettings = {{ project_runtime_settings | tojson }};
|
||||
@@ -3676,12 +3722,15 @@
|
||||
const persistedProjectPageState = {{ project_page_state | tojson }};
|
||||
const persistedProjectRelatedLinks = {{ project_related_links | tojson }};
|
||||
const persistedUncontractedCategoryOverrides = {{ project_uncontracted_classifications | tojson }};
|
||||
const defaultExecLaborRates = {{ default_exec_labor_rates | tojson }};
|
||||
const projectPageSessionId = window.clientSessionId || "";
|
||||
let projectStatusMap = {};
|
||||
let projectAccountBreakdowns = {};
|
||||
let projectAccountBreakdownsLoaded = false;
|
||||
let projectAccountBreakdownsLoadedCodes = new Set();
|
||||
let projectAccountBreakdownsPromise = null;
|
||||
let projectJobPollTimer = null;
|
||||
let projectBootstrapLoadError = "";
|
||||
let projectCostMap = {};
|
||||
const currencyFormatter = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 0 });
|
||||
window.__projectRelatedSelections = new Map();
|
||||
@@ -3731,6 +3780,7 @@
|
||||
const laborRateModal = document.getElementById("laborRateModal");
|
||||
const laborRateRows = document.getElementById("laborRateRows");
|
||||
const laborRateYearSelect = document.getElementById("laborRateYearSelect");
|
||||
const laborRateCategorySelect = document.getElementById("laborRateCategorySelect");
|
||||
const saveLaborRateYearButton = document.getElementById("saveLaborRateYearButton");
|
||||
const openLaborRateModalButton = document.getElementById("openLaborRateModal");
|
||||
const closeLaborRateModalButton = document.getElementById("closeLaborRateModal");
|
||||
@@ -4347,20 +4397,68 @@
|
||||
const source = rawRates && typeof rawRates === "object" ? rawRates : {};
|
||||
const next = {};
|
||||
const defaultYear = getDefaultLaborRateYear();
|
||||
Object.entries(source).forEach(([key, value]) => {
|
||||
const normalizeGradeName = (grade) => {
|
||||
const text = String(grade || "").replace(/\s+/g, "");
|
||||
if (text === "전무이사") return "전무";
|
||||
if (text === "상무이사") return "상무";
|
||||
return text;
|
||||
};
|
||||
const normalizeCategory = (category) => {
|
||||
const text = String(category || "").trim();
|
||||
if (text.includes("감리")) return "감리";
|
||||
if (text.includes("지원")) return "지원";
|
||||
if (text.includes("설계")) return "설계";
|
||||
if (text === "supervision") return "감리";
|
||||
if (text === "support") return "지원";
|
||||
return "설계";
|
||||
};
|
||||
const applySource = (rateSource, overwrite = true) => {
|
||||
Object.entries(rateSource || {}).forEach(([key, value]) => {
|
||||
if (/^\d{4}$/.test(String(key || "")) && value && typeof value === "object" && !Array.isArray(value)) {
|
||||
const yearBucket = {};
|
||||
laborGradeOptions.forEach((grade) => {
|
||||
yearBucket[grade] = Number(value?.[grade] || 0);
|
||||
});
|
||||
next[String(key)] = yearBucket;
|
||||
const yearKey = String(key);
|
||||
const yearBucket = next[yearKey] || { 설계: {}, 감리: {}, 지원: {} };
|
||||
const hasCategoryBucket = Object.entries(value).some(([, bucket]) => bucket && typeof bucket === "object" && !Array.isArray(bucket));
|
||||
if (hasCategoryBucket) {
|
||||
Object.entries(value).forEach(([categoryKey, categoryBucket]) => {
|
||||
if (!categoryBucket || typeof categoryBucket !== "object" || Array.isArray(categoryBucket)) return;
|
||||
const category = normalizeCategory(categoryKey);
|
||||
yearBucket[category] = yearBucket[category] || {};
|
||||
Object.entries(categoryBucket).forEach(([grade, amount]) => {
|
||||
const normalizedGrade = normalizeGradeName(grade);
|
||||
if (normalizedGrade) {
|
||||
const numericAmount = Number(amount || 0);
|
||||
if (numericAmount > 0 && (overwrite || !yearBucket[category][normalizedGrade])) {
|
||||
yearBucket[category][normalizedGrade] = numericAmount;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
yearBucket.설계 = yearBucket.설계 || {};
|
||||
Object.entries(value).forEach(([grade, amount]) => {
|
||||
const normalizedGrade = normalizeGradeName(grade);
|
||||
if (normalizedGrade) {
|
||||
const numericAmount = Number(amount || 0);
|
||||
if (numericAmount > 0 && (overwrite || !yearBucket.설계[normalizedGrade])) {
|
||||
yearBucket.설계[normalizedGrade] = numericAmount;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!Object.keys(yearBucket.지원).length) {
|
||||
yearBucket.지원 = { ...yearBucket.감리 };
|
||||
}
|
||||
next[yearKey] = yearBucket;
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
applySource(defaultExecLaborRates, true);
|
||||
applySource(source, true);
|
||||
const hasYearBuckets = Object.keys(next).length > 0;
|
||||
if (!hasYearBuckets) {
|
||||
const migrated = {};
|
||||
const migrated = { 설계: {}, 감리: {}, 지원: {} };
|
||||
laborGradeOptions.forEach((grade) => {
|
||||
migrated[grade] = Number(source?.[grade] || 0);
|
||||
migrated.설계[grade] = Number(source?.[grade] || 0);
|
||||
});
|
||||
next[defaultYear] = migrated;
|
||||
}
|
||||
@@ -4416,15 +4514,39 @@
|
||||
return 0;
|
||||
}
|
||||
|
||||
function getResolvedLaborRatesForYear(year = "") {
|
||||
function normalizeLaborRateCategory(value) {
|
||||
const text = String(value || "").trim();
|
||||
if (text.includes("감리")) return "감리";
|
||||
if (text.includes("지원")) return "지원";
|
||||
if (text.includes("설계")) return "설계";
|
||||
return "설계";
|
||||
}
|
||||
|
||||
function getLaborRateLookupCategory(value) {
|
||||
const category = normalizeLaborRateCategory(value);
|
||||
return category === "지원" ? "감리" : category;
|
||||
}
|
||||
|
||||
function getCurrentLaborRateCategory() {
|
||||
return normalizeLaborRateCategory(projectTypeInput?.value || laborRateCategorySelect?.value || "설계");
|
||||
}
|
||||
|
||||
function getLaborRateEditCategory() {
|
||||
return getLaborRateLookupCategory(laborRateCategorySelect?.value || getCurrentLaborRateCategory());
|
||||
}
|
||||
|
||||
function getResolvedLaborRatesForYear(year = "", category = "") {
|
||||
const yearKey = String(year || getDefaultLaborRateYear());
|
||||
const storedYearRates = currentLaborRates?.[yearKey] || {};
|
||||
const lookupCategory = getLaborRateLookupCategory(category || getCurrentLaborRateCategory());
|
||||
const categoryRates = storedYearRates?.[lookupCategory] || storedYearRates?.설계 || {};
|
||||
const designRates = storedYearRates?.설계 || {};
|
||||
const resolvedYearRates = {};
|
||||
laborGradeOptions.forEach((grade) => {
|
||||
resolvedYearRates[grade] = getNumericYearRate(storedYearRates, grade);
|
||||
resolvedYearRates[grade] = getNumericYearRate(categoryRates, grade);
|
||||
});
|
||||
["수석", "책임", "선임", "연구원"].forEach((grade) => {
|
||||
const derived = getDerivedLaborRate(storedYearRates, grade);
|
||||
derivedLaborRateGrades.forEach((grade) => {
|
||||
const derived = getDerivedLaborRate(designRates, grade);
|
||||
if (derived > 0) {
|
||||
resolvedYearRates[grade] = derived;
|
||||
}
|
||||
@@ -4432,9 +4554,9 @@
|
||||
return resolvedYearRates;
|
||||
}
|
||||
|
||||
function getLaborRate(grade, year = "") {
|
||||
function getLaborRate(grade, year = "", category = "") {
|
||||
const yearKey = String(year || getDefaultLaborRateYear());
|
||||
const yearRates = getResolvedLaborRatesForYear(yearKey);
|
||||
const yearRates = getResolvedLaborRatesForYear(yearKey, category);
|
||||
const direct = Number(yearRates?.[grade] || 0);
|
||||
if (direct) return direct;
|
||||
const gradeIndex = laborGradeOptions.indexOf(grade);
|
||||
@@ -4446,8 +4568,8 @@
|
||||
return 0;
|
||||
}
|
||||
|
||||
function calculateLaborAmount(grade, hours, year = "") {
|
||||
return getLaborRate(grade, year) * parseHours(hours);
|
||||
function calculateLaborAmount(grade, hours, year = "", category = "") {
|
||||
return getLaborRate(grade, year, category) * parseHours(hours);
|
||||
}
|
||||
|
||||
function formatDateInputValue(value) {
|
||||
@@ -5121,12 +5243,13 @@
|
||||
function renderLaborRateRows() {
|
||||
if (!laborRateRows) return;
|
||||
const selectedRateYear = String(laborRateYearSelect?.value || getDefaultLaborRateYear());
|
||||
const yearRates = currentLaborRates?.[selectedRateYear] || {};
|
||||
const resolvedYearRates = getResolvedLaborRatesForYear(selectedRateYear);
|
||||
const selectedCategory = laborRateCategorySelect?.value || getCurrentLaborRateCategory();
|
||||
const editCategory = getLaborRateLookupCategory(selectedCategory);
|
||||
const resolvedYearRates = getResolvedLaborRatesForYear(selectedRateYear, selectedCategory);
|
||||
laborRateRows.innerHTML = laborGradeOptions.map((grade) => `
|
||||
<tr>
|
||||
<td>${escapeHtml(grade)}</td>
|
||||
<td><input class="inline-input amount-input formatted-amount-input labor-rate-input" type="text" inputmode="numeric" data-grade="${escapeHtml(grade)}" value="${escapeHtml(formatAmountInputValue(resolvedYearRates?.[grade] || ""))}" ${["수석", "책임", "선임", "연구원"].includes(grade) ? 'readonly title="자동 산출"' : ""}></td>
|
||||
<td><input class="inline-input amount-input formatted-amount-input labor-rate-input" type="text" inputmode="numeric" data-grade="${escapeHtml(grade)}" value="${escapeHtml(formatAmountInputValue(resolvedYearRates?.[grade] || ""))}" ${derivedLaborRateGrades.includes(grade) ? 'readonly title="설계 단가 기준 자동 산출"' : ""}></td>
|
||||
</tr>
|
||||
`).join("");
|
||||
bindCollectionFieldBehaviors(laborRateRows);
|
||||
@@ -5135,8 +5258,13 @@
|
||||
input.addEventListener("input", () => {
|
||||
const grade = input.dataset.grade || "";
|
||||
const targetYear = String(laborRateYearSelect?.value || getDefaultLaborRateYear());
|
||||
const nextYearRates = { ...(currentLaborRates?.[targetYear] || {}) };
|
||||
nextYearRates[grade] = parseAmount(input.value);
|
||||
const nextYearRates = { 설계: {}, 감리: {}, 지원: {}, ...(currentLaborRates?.[targetYear] || {}) };
|
||||
const nextCategoryRates = { ...(nextYearRates?.[editCategory] || {}) };
|
||||
nextCategoryRates[grade] = parseAmount(input.value);
|
||||
nextYearRates[editCategory] = nextCategoryRates;
|
||||
if (editCategory === "감리") {
|
||||
nextYearRates.지원 = { ...nextCategoryRates };
|
||||
}
|
||||
currentLaborRates[targetYear] = nextYearRates;
|
||||
writeLaborRates(currentLaborRates);
|
||||
recalculateExecLaborAmounts();
|
||||
@@ -5156,26 +5284,34 @@
|
||||
laborRateYearSelect.value = activeYear;
|
||||
}
|
||||
}
|
||||
if (laborRateCategorySelect) {
|
||||
laborRateCategorySelect.value = getCurrentLaborRateCategory();
|
||||
}
|
||||
renderLaborRateRows();
|
||||
laborRateModal?.classList.add("open");
|
||||
}
|
||||
|
||||
function saveLaborRateYear() {
|
||||
const targetYear = String(laborRateYearSelect?.value || getDefaultLaborRateYear());
|
||||
const nextYearRates = { ...(currentLaborRates?.[targetYear] || {}) };
|
||||
const selectedCategory = laborRateCategorySelect?.value || getCurrentLaborRateCategory();
|
||||
const editCategory = getLaborRateLookupCategory(selectedCategory);
|
||||
const nextYearRates = { 설계: {}, 감리: {}, 지원: {}, ...(currentLaborRates?.[targetYear] || {}) };
|
||||
const nextCategoryRates = { ...(nextYearRates?.[editCategory] || {}) };
|
||||
laborRateRows?.querySelectorAll(".labor-rate-input").forEach((input) => {
|
||||
const grade = String(input.dataset.grade || "").trim();
|
||||
if (!grade || input.hasAttribute("readonly")) return;
|
||||
nextYearRates[grade] = parseAmount(input.value);
|
||||
});
|
||||
["수석", "책임", "선임", "연구원"].forEach((grade) => {
|
||||
nextYearRates[grade] = getDerivedLaborRate(nextYearRates, grade);
|
||||
nextCategoryRates[grade] = parseAmount(input.value);
|
||||
});
|
||||
nextYearRates[editCategory] = nextCategoryRates;
|
||||
if (editCategory === "감리") {
|
||||
nextYearRates.지원 = { ...nextCategoryRates };
|
||||
}
|
||||
currentLaborRates[targetYear] = nextYearRates;
|
||||
writeLaborRates(currentLaborRates);
|
||||
recalculateExecLaborAmounts();
|
||||
recalculateActualLaborAmounts();
|
||||
window.alert(`${targetYear}년 기준인건비를 저장했습니다.`);
|
||||
renderLaborRateRows();
|
||||
window.alert(`${targetYear}년 ${selectedCategory} 기준인건비를 저장했습니다.`);
|
||||
}
|
||||
|
||||
function closeLaborRateModal() {
|
||||
@@ -5660,6 +5796,14 @@
|
||||
laborRateYearSelect?.addEventListener("change", () => {
|
||||
renderLaborRateRows();
|
||||
});
|
||||
laborRateCategorySelect?.addEventListener("change", () => {
|
||||
renderLaborRateRows();
|
||||
});
|
||||
projectTypeInput?.addEventListener("input", () => {
|
||||
recalculateExecLaborAmounts();
|
||||
recalculateActualLaborAmounts();
|
||||
renderLaborRateRows();
|
||||
});
|
||||
laborRateModal?.addEventListener("click", (event) => {
|
||||
if (event.target === laborRateModal) {
|
||||
closeLaborRateModal();
|
||||
@@ -5990,6 +6134,47 @@
|
||||
return mergedItem;
|
||||
}
|
||||
|
||||
const projectStatusDetailRequests = new Map();
|
||||
|
||||
async function ensureProjectStatusDetail(code) {
|
||||
const normalizedCode = String(code || "").trim();
|
||||
if (!normalizedCode) return null;
|
||||
const current = projectStatusMap[normalizedCode] || null;
|
||||
if (current && current._detail_loaded) return current;
|
||||
if (!projectStatusDetailRequests.has(normalizedCode)) {
|
||||
projectStatusDetailRequests.set(
|
||||
normalizedCode,
|
||||
fetch(`/projects/status-detail?code=${encodeURIComponent(normalizedCode)}`, {
|
||||
credentials: "same-origin",
|
||||
cache: "no-store",
|
||||
headers: { "Accept": "application/json" },
|
||||
})
|
||||
.then(async (response) => {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok || payload?.ok === false || payload?.error) {
|
||||
throw new Error(payload?.error || `HTTP ${response.status}`);
|
||||
}
|
||||
const item = payload?.item || null;
|
||||
if (item?.support_dept_code) {
|
||||
item._detail_loaded = true;
|
||||
projectStatusMap[item.support_dept_code] = item;
|
||||
const index = projectStatusRows.findIndex((row) => row.support_dept_code === item.support_dept_code);
|
||||
if (index >= 0) {
|
||||
projectStatusRows[index] = item;
|
||||
} else {
|
||||
projectStatusRows.push(item);
|
||||
}
|
||||
}
|
||||
return item;
|
||||
})
|
||||
.finally(() => {
|
||||
projectStatusDetailRequests.delete(normalizedCode);
|
||||
}),
|
||||
);
|
||||
}
|
||||
return projectStatusDetailRequests.get(normalizedCode);
|
||||
}
|
||||
|
||||
function getSharedInputClusterCodes(item) {
|
||||
if (!Array.isArray(item?.shared_input_cluster_codes)) return [];
|
||||
return item.shared_input_cluster_codes
|
||||
@@ -6136,7 +6321,7 @@
|
||||
return [];
|
||||
}
|
||||
const codeSet = new Set([normalizedBaseCode]);
|
||||
getRelatedCodes(normalizedBaseCode).forEach((code) => {
|
||||
getCurrentRelatedCodes(normalizedBaseCode).forEach((code) => {
|
||||
const normalizedCode = String(code || "").trim();
|
||||
if (normalizedCode) {
|
||||
codeSet.add(normalizedCode);
|
||||
@@ -6228,10 +6413,97 @@
|
||||
return projectAccountBreakdownsPromise;
|
||||
}
|
||||
|
||||
async function fetchProjectJson(url, options = {}) {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
cache: "no-store",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok || payload?.error || payload?.ok === false) {
|
||||
throw new Error(payload?.error || `HTTP ${response.status}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function describeProjectJob(job) {
|
||||
if (!job) return { text: "최근 작업 없음", state: "" };
|
||||
const status = String(job.status || "");
|
||||
const range = job.start_year ? String(job.start_year) : "전체";
|
||||
const message = job.error_message || job.message || "";
|
||||
if (status === "queued") return { text: `${range} 대기 중`, state: status };
|
||||
if (status === "running") return { text: `${range} 계산 중... ${message}`.trim(), state: status };
|
||||
if (status === "done") return { text: `${range} 계산 완료`, state: status };
|
||||
if (status === "failed") return { text: `${range} 실패: ${message || "오류"}`, state: status };
|
||||
return { text: `${range} ${message || status || "작업 상태 확인 전"}`.trim(), state: status };
|
||||
}
|
||||
|
||||
function renderProjectJob(job) {
|
||||
const jobStatus = document.getElementById("projectJobStatus");
|
||||
const rebuildButton = document.getElementById("projectRebuildCacheBtn");
|
||||
if (!jobStatus) return;
|
||||
const view = describeProjectJob(job);
|
||||
jobStatus.textContent = view.text;
|
||||
jobStatus.dataset.state = view.state || "";
|
||||
if (rebuildButton) {
|
||||
rebuildButton.disabled = view.state === "queued" || view.state === "running";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLatestProjectJob() {
|
||||
const params = new URLSearchParams({
|
||||
page_key: "projects",
|
||||
job_type: "projects_bootstrap",
|
||||
});
|
||||
const payload = await fetchProjectJson(`/api/system-jobs/latest?${params.toString()}`);
|
||||
renderProjectJob(payload.job || null);
|
||||
return payload.job || null;
|
||||
}
|
||||
|
||||
function pollProjectJob(jobId) {
|
||||
if (!jobId) return;
|
||||
if (projectJobPollTimer) window.clearInterval(projectJobPollTimer);
|
||||
projectJobPollTimer = window.setInterval(async () => {
|
||||
try {
|
||||
const payload = await fetchProjectJson(`/api/system-jobs/${encodeURIComponent(jobId)}`);
|
||||
const job = payload.job || null;
|
||||
renderProjectJob(job);
|
||||
if (!job || ["done", "failed", "cancelled"].includes(String(job.status || ""))) {
|
||||
window.clearInterval(projectJobPollTimer);
|
||||
projectJobPollTimer = null;
|
||||
if (job && job.status === "done") {
|
||||
window.setTimeout(() => window.location.reload(), 600);
|
||||
}
|
||||
}
|
||||
} catch (_error) {
|
||||
// Keep the current project list usable while transient locks clear.
|
||||
}
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
async function requestProjectCacheRebuild() {
|
||||
const rebuildButton = document.getElementById("projectRebuildCacheBtn");
|
||||
if (rebuildButton) rebuildButton.disabled = true;
|
||||
const payload = await fetchProjectJson("/projects/api/rebuild-cache", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ year: selectedYear || null }),
|
||||
});
|
||||
renderProjectJob(payload.job || null);
|
||||
pollProjectJob(payload.job?.id);
|
||||
}
|
||||
|
||||
async function loadProjectBootstrapData(force = false) {
|
||||
if (!force && projectStatusRows.length && projectCostRows.length) {
|
||||
return;
|
||||
}
|
||||
projectBootstrapLoadError = "";
|
||||
const query = selectedYear ? `?year=${encodeURIComponent(String(selectedYear))}` : "";
|
||||
const response = await fetch(`/projects/bootstrap-data${query}`, {
|
||||
method: "GET",
|
||||
@@ -9082,6 +9354,17 @@
|
||||
scheduleStandaloneUncontractedDashboardRender();
|
||||
return;
|
||||
}
|
||||
if (item._detail_loaded === false) {
|
||||
ensureProjectStatusDetail(item.support_dept_code)
|
||||
.then((detailItem) => {
|
||||
if (!detailItem) return;
|
||||
if (String(currentSelectedProjectCode || "") !== String(item.support_dept_code || "")) return;
|
||||
renderAnalysis(getAnalysisItem(item.support_dept_code));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("프로젝트 상세 데이터 조회 에러", error);
|
||||
});
|
||||
}
|
||||
const requestedBreakdownCodes = buildProjectBreakdownRequestCodes(item.support_dept_code);
|
||||
if (!hasProjectBreakdownDataForCodes(requestedBreakdownCodes)) {
|
||||
relatedBarBox.innerHTML = "";
|
||||
@@ -9284,8 +9567,42 @@
|
||||
},
|
||||
};
|
||||
|
||||
let projectBootstrapEnsurePromise = null;
|
||||
async function ensureProjectBootstrapData() {
|
||||
if (projectStatusRows.length || projectCostRows.length) {
|
||||
return true;
|
||||
}
|
||||
if (!projectBootstrapEnsurePromise) {
|
||||
projectBootstrapEnsurePromise = loadProjectBootstrapData(true)
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
console.error("프로젝트 부트스트랩 데이터 재조회 에러", error);
|
||||
projectBootstrapLoadError = error?.message || "프로젝트 데이터를 불러오지 못했습니다.";
|
||||
return false;
|
||||
})
|
||||
.finally(() => {
|
||||
projectBootstrapEnsurePromise = null;
|
||||
});
|
||||
}
|
||||
return projectBootstrapEnsurePromise;
|
||||
}
|
||||
|
||||
function refresh(options = {}) {
|
||||
const { renderDetail = false } = options;
|
||||
if (!projectStatusRows.length && !projectCostRows.length) {
|
||||
dropdownBox?.classList.toggle("open", Boolean((input?.value || "").trim()));
|
||||
listBox.innerHTML = "";
|
||||
emptyBox.textContent = projectBootstrapLoadError || "프로젝트 데이터를 불러오는 중입니다.";
|
||||
ensureProjectBootstrapData().then((loaded) => {
|
||||
if (loaded) {
|
||||
refresh(options);
|
||||
return;
|
||||
}
|
||||
listBox.innerHTML = "";
|
||||
emptyBox.textContent = projectBootstrapLoadError || "프로젝트 데이터를 불러오지 못했습니다. 캐시 재계산 후 다시 시도해 주세요.";
|
||||
});
|
||||
return;
|
||||
}
|
||||
const keyword = input?.value || "";
|
||||
const dataset = filterProjectCostDatasetByContractState(
|
||||
buildProjectCostDataset(keyword, yearSelect?.value || ""),
|
||||
@@ -9510,9 +9827,10 @@
|
||||
event.preventDefault();
|
||||
excludedItemsButton.click();
|
||||
});
|
||||
listBox?.addEventListener("click", (event) => {
|
||||
listBox?.addEventListener("click", async (event) => {
|
||||
const itemEl = event.target.closest(".explorer-item");
|
||||
if (!itemEl) return;
|
||||
await ensureProjectBootstrapData();
|
||||
selectedCode = itemEl.dataset.code || "";
|
||||
currentSelectedProjectCode = selectedCode;
|
||||
input.value = itemEl.querySelector("strong")?.textContent || input.value;
|
||||
@@ -9521,7 +9839,7 @@
|
||||
renderSuggestionList([], "");
|
||||
renderAnalysis(getAnalysisItem(selectedCode));
|
||||
});
|
||||
quickLinkBar?.addEventListener("click", (event) => {
|
||||
quickLinkBar?.addEventListener("click", async (event) => {
|
||||
const removeButton = event.target.closest("[data-remove-quick-link]");
|
||||
if (removeButton) {
|
||||
event.stopPropagation();
|
||||
@@ -9530,10 +9848,27 @@
|
||||
}
|
||||
const quickLinkButton = event.target.closest("[data-open-quick-link]");
|
||||
if (!quickLinkButton) return;
|
||||
await ensureProjectBootstrapData();
|
||||
selectedCode = quickLinkButton.dataset.openQuickLink || "";
|
||||
currentSelectedProjectCode = selectedCode;
|
||||
const quickLinkItem = getAnalysisItem(selectedCode);
|
||||
if (quickLinkItem && input) {
|
||||
input.value = quickLinkItem.support_dept_name || "";
|
||||
}
|
||||
setAnalysisVisibility(true);
|
||||
renderAnalysis(getAnalysisItem(selectedCode));
|
||||
if (quickLinkItem) {
|
||||
renderAnalysis(quickLinkItem);
|
||||
} else {
|
||||
relatedBarBox.innerHTML = "";
|
||||
heroBox.innerHTML = "";
|
||||
metricsBox.innerHTML = "";
|
||||
comparisonBox.innerHTML = `
|
||||
<div class="detail-block">
|
||||
<p class="stacked-note">바로가기 프로젝트를 현재 데이터에서 찾지 못했습니다. 프로젝트 정보 캐시를 재계산한 뒤 다시 확인해 주세요.</p>
|
||||
</div>
|
||||
`;
|
||||
notesBox.innerHTML = "";
|
||||
}
|
||||
});
|
||||
quickLinkBar?.addEventListener("keydown", (event) => {
|
||||
const quickLinkButton = event.target.closest("[data-open-quick-link]");
|
||||
@@ -9651,6 +9986,22 @@
|
||||
await saveProjectPageStateToServer(true);
|
||||
});
|
||||
|
||||
document.getElementById("projectRebuildCacheBtn")?.addEventListener("click", async () => {
|
||||
const confirmed = window.confirm("현재 프로젝트 정보 조건의 데이터를 서버에서 다시 계산할까요? 계산 중에도 다른 화면을 사용할 수 있습니다.");
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await requestProjectCacheRebuild();
|
||||
} catch (error) {
|
||||
const jobStatus = document.getElementById("projectJobStatus");
|
||||
if (jobStatus) {
|
||||
jobStatus.textContent = error.message || "작업 등록 실패";
|
||||
jobStatus.dataset.state = "failed";
|
||||
}
|
||||
const rebuildButton = document.getElementById("projectRebuildCacheBtn");
|
||||
if (rebuildButton) rebuildButton.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
relatedToolbarButton?.addEventListener("click", () => {
|
||||
openRelatedProjectPicker();
|
||||
});
|
||||
@@ -9666,6 +10017,7 @@
|
||||
await loadProjectQuickLinksFromServer();
|
||||
try {
|
||||
await loadProjectBootstrapData();
|
||||
renderProjectQuickLinks();
|
||||
} catch (error) {
|
||||
console.error("프로젝트 부트스트랩 데이터 조회 에러", error);
|
||||
}
|
||||
@@ -9698,6 +10050,19 @@
|
||||
}
|
||||
}
|
||||
scheduleStandaloneUncontractedDashboardRender();
|
||||
loadLatestProjectJob()
|
||||
.then((job) => {
|
||||
if (job && ["queued", "running"].includes(String(job.status || ""))) {
|
||||
pollProjectJob(job.id);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
const jobStatus = document.getElementById("projectJobStatus");
|
||||
if (jobStatus) {
|
||||
jobStatus.textContent = "작업 상태 조회 실패";
|
||||
jobStatus.dataset.state = "failed";
|
||||
}
|
||||
});
|
||||
})();
|
||||
if (openModalButton) {
|
||||
openModalButton.addEventListener("click", async () => {
|
||||
|
||||
+690
-65
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user