Update intranet tools and voucher comparison

This commit is contained in:
b17301
2026-06-04 09:00:51 +09:00
parent 2ab74bac88
commit b93289bfa8
55 changed files with 32809 additions and 1063 deletions
+289
View File
@@ -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 %}
+159
View File
@@ -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
View File
@@ -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", "연결 오류");
+35
View File
@@ -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
+154
View File
@@ -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 %}
File diff suppressed because it is too large Load Diff
+97
View File
@@ -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>
+169
View File
@@ -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
View File
@@ -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 () => {
File diff suppressed because it is too large Load Diff