Refine process cost metrics and project search dropdown behavior

This commit is contained in:
b17301
2026-04-28 12:22:20 +09:00
parent 21901fe8fb
commit 1d6470c770
4 changed files with 428 additions and 74 deletions
+244 -16
View File
@@ -48,6 +48,13 @@
color: #fff;
}
.pc-tab-row {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.pc-filter {
display: inline-flex;
align-items: center;
@@ -144,6 +151,83 @@
line-height: 1.6;
}
.pc-related-box {
border: 1px solid var(--line);
border-radius: 12px;
background: #fff;
padding: 12px;
display: grid;
gap: 10px;
}
.pc-related-box h3 {
font-size: 14px;
}
.pc-chip-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pc-chip {
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid var(--line);
border-radius: 999px;
background: #f8fafc;
padding: 6px 10px;
font-size: 12px;
min-height: 32px;
}
.pc-chip button {
border: none;
background: transparent;
color: var(--muted);
padding: 0;
min-width: 16px;
min-height: 16px;
line-height: 1;
cursor: pointer;
}
.pc-related-form {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 8px;
align-items: center;
}
.pc-related-list {
border: 1px solid var(--line);
border-radius: 10px;
background: #fff;
max-height: 200px;
overflow: auto;
display: none;
}
.pc-related-list.active {
display: block;
}
.pc-related-item {
width: 100%;
border: none;
background: transparent;
text-align: left;
padding: 9px 10px;
cursor: pointer;
font-size: 12px;
color: var(--ink);
}
.pc-related-item:hover {
background: #f5f7fa;
}
.pc-block {
border: 1px solid var(--line);
border-radius: 14px;
@@ -309,23 +393,28 @@
{% set overview = detail.overview or {} %}
{% set diagnostics = detail.diagnostics or {} %}
<section class="panel pc-shell">
<div class="section-title" style="margin-bottom: 0;">
<h2>프로세스 원가 관리</h2>
<p>토목설계회사 기준 초안 (직접인건비/외주비/설계경비/판관비 중심)</p>
</div>
<div class="pc-toolbar">
<div class="pc-source-tabs">
<a href="/process-cost?source=hanmac{% if process_cost_selected_year %}&year={{ process_cost_selected_year }}{% endif %}{% if process_cost_selected_code %}&code={{ process_cost_selected_code }}{% endif %}" class="{% if process_cost_source == 'hanmac' %}active{% endif %}">Hanmac ERP</a>
<a href="/process-cost?source=wehago{% if process_cost_selected_year %}&year={{ process_cost_selected_year }}{% endif %}{% if process_cost_selected_code %}&code={{ process_cost_selected_code }}{% endif %}" class="{% if process_cost_source == 'wehago' %}active{% endif %}">WEHAGO</a>
<div class="pc-tab-row">
<div class="pc-source-tabs">
<a href="/process-cost?source=hanmac{% if process_cost_selected_year %}&year={{ process_cost_selected_year }}{% endif %}{% if process_cost_selected_code %}&code={{ process_cost_selected_code }}{% endif %}{% if process_cost_include_related %}&include_related=1{% endif %}" class="{% if process_cost_source == 'hanmac' %}active{% endif %}">Hanmac ERP</a>
<a href="/process-cost?source=wehago{% if process_cost_selected_year %}&year={{ process_cost_selected_year }}{% endif %}{% if process_cost_selected_code %}&code={{ process_cost_selected_code }}{% endif %}{% if process_cost_include_related %}&include_related=1{% endif %}" class="{% if process_cost_source == 'wehago' %}active{% endif %}">WEHAGO</a>
</div>
<div class="pc-source-tabs">
<a href="/process-cost?source={{ process_cost_source }}{% if process_cost_selected_year %}&year={{ process_cost_selected_year }}{% endif %}{% if process_cost_selected_code %}&code={{ process_cost_selected_code }}{% endif %}" class="{% if not process_cost_include_related %}active{% endif %}">연계 미반영</a>
<a href="/process-cost?source={{ process_cost_source }}{% if process_cost_selected_year %}&year={{ process_cost_selected_year }}{% endif %}{% if process_cost_selected_code %}&code={{ process_cost_selected_code }}{% endif %}&include_related=1" class="{% if process_cost_include_related %}active{% endif %}">연계 반영</a>
</div>
</div>
<form method="get" action="/process-cost" class="pc-filter">
<input type="hidden" name="source" value="{{ process_cost_source }}">
<input type="hidden" name="code" value="{{ process_cost_selected_code }}">
{% if process_cost_include_related %}
<input type="hidden" name="include_related" value="1">
{% endif %}
<select name="year" aria-label="연도 선택">
{% if not process_cost_years %}
<option value="">연도 없음</option>
{% else %}
<option value="" {% if not process_cost_selected_year %}selected{% endif %}>전체 기간</option>
{% for y in process_cost_years|sort(reverse=True) %}
<option value="{{ y }}" {% if process_cost_selected_year == y %}selected{% endif %}>{{ y }}</option>
{% endfor %}
@@ -344,7 +433,7 @@
<div class="pc-project-list" id="projectList">
{% for item in process_cost_projects %}
<a
href="/process-cost?source={{ process_cost_source }}{% if process_cost_selected_year %}&year={{ process_cost_selected_year }}{% endif %}&code={{ item.support_dept_code }}"
href="/process-cost?source={{ process_cost_source }}{% if process_cost_selected_year %}&year={{ process_cost_selected_year }}{% endif %}&code={{ item.support_dept_code }}{% if process_cost_include_related %}&include_related=1{% endif %}"
class="pc-project-item {% if process_cost_selected_code == item.support_dept_code %}active{% endif %}"
data-project-search="{{ (item.support_dept_code ~ ' ' ~ item.support_dept_name ~ ' ' ~ (item.client_name or ''))|lower }}"
title="{{ item.support_dept_code }} {{ item.support_dept_name }}"
@@ -370,12 +459,10 @@
<article class="pc-card">
<span class="label">계약금액</span>
<strong class="value">{{ "{:,.0f}".format(overview.contract_amount|default(0)) }}원</strong>
<span class="meta">{{ overview.support_dept_name or '-' }}</span>
</article>
<article class="pc-card">
<span class="label">누적 수익</span>
<strong class="value">{{ "{:,.0f}".format(overview.revenue_amount|default(0)) }}원</strong>
<span class="meta">최근 증빙일 {{ overview.last_posting_date or '-' }}</span>
</article>
<article class="pc-card">
<span class="label">누적 비용</span>
@@ -389,9 +476,26 @@
</article>
</div>
<div class="pc-note">
설계회사 프로세스 원가 관리 초안: 목표원가(계약금액 기준 85%)와 실제 집행을 비교해 과다/과소 집행 구간을 빠르게 파악합니다.
</div>
<section class="pc-related-box">
<h3>연계 프로젝트</h3>
<div class="pc-chip-row" id="relatedChipRow">
{% if process_cost_selected_code %}
<span class="pc-chip">{{ process_cost_selected_code }} 기준</span>
{% endif %}
{% for related_code in process_cost_related_codes or [] %}
<span class="pc-chip" data-related-code="{{ related_code }}">
<span>{{ related_code }}</span>
<button type="button" class="pc-related-remove" data-related-remove="{{ related_code }}">x</button>
</span>
{% endfor %}
</div>
<div class="pc-related-form">
<input type="text" id="relatedSearchInput" placeholder="추가할 프로젝트 코드/명 검색" {% if not process_cost_selected_code %}disabled{% endif %}>
<button type="button" class="button-secondary" id="relatedSaveButton" {% if not process_cost_selected_code %}disabled{% endif %}>저장</button>
<button type="button" class="button-secondary" id="relatedResetButton" {% if not process_cost_selected_code %}disabled{% endif %}>초기화</button>
</div>
<div class="pc-related-list" id="relatedSuggestList"></div>
</section>
<section class="pc-block">
<h3>설계 프로세스별 원가 현황</h3>
@@ -474,7 +578,7 @@
<div class="pc-bar"><span data-rate="{{ diagnostics.outsourcing_ratio|default(0) }}"></span></div>
</div>
<div class="pc-kpi-row">
<div class="pc-kpi-head"><span>설계경비 비중</span><strong>{{ "{:.1f}".format(diagnostics.design_cost_ratio|default(0)) }}%</strong></div>
<div class="pc-kpi-head"><span>경비 비중</span><strong>{{ "{:.1f}".format(diagnostics.design_cost_ratio|default(0)) }}%</strong></div>
<div class="pc-bar"><span data-rate="{{ diagnostics.design_cost_ratio|default(0) }}"></span></div>
</div>
<div class="pc-kpi-row">
@@ -486,10 +590,11 @@
<section class="pc-kpi">
<h3>데이터 현황</h3>
<div class="pc-kpi-head"><span>선택 소스</span><strong>{% if process_cost_source == 'wehago' %}WEHAGO{% else %}Hanmac ERP{% endif %}</strong></div>
<div class="pc-kpi-head"><span>연도</span><strong>{{ process_cost_selected_year or '-' }}</strong></div>
<div class="pc-kpi-head"><span>연도</span><strong>{{ process_cost_selected_year or '전체 기간' }}</strong></div>
<div class="pc-kpi-head"><span>전표 건수</span><strong>{{ "{:,}".format(overview.voucher_count|default(0)) }}</strong></div>
<div class="pc-kpi-head"><span>행 건수</span><strong>{{ "{:,}".format(overview.row_count|default(0)) }}</strong></div>
<div class="pc-kpi-head"><span>실행률</span><strong>{{ "{:.1f}".format(overview.execution_rate|default(0)) }}%</strong></div>
<div class="pc-kpi-head"><span>연계 반영</span><strong>{% if process_cost_include_related %}반영{% else %}미반영{% endif %}</strong></div>
</section>
<section class="pc-mini-chart">
@@ -504,6 +609,9 @@
{% block script %}
<script>
(() => {
const processCostProjects = {{ process_cost_projects | tojson }};
const selectedCode = {{ process_cost_selected_code | tojson }};
const initialRelatedCodes = {{ (process_cost_related_codes or []) | tojson }};
const searchInput = document.getElementById("projectSearchInput");
const list = document.getElementById("projectList");
if (searchInput && list) {
@@ -522,6 +630,126 @@
bar.style.width = `${width}%`;
});
const relatedSearchInput = document.getElementById("relatedSearchInput");
const relatedSuggestList = document.getElementById("relatedSuggestList");
const relatedChipRow = document.getElementById("relatedChipRow");
const relatedSaveButton = document.getElementById("relatedSaveButton");
const relatedResetButton = document.getElementById("relatedResetButton");
const relatedEmptyState = document.getElementById("relatedEmptyState");
const relatedSet = new Set(initialRelatedCodes || []);
function renderRelatedChips() {
if (!relatedChipRow) return;
const fixedHead = selectedCode ? `<span class="pc-chip">${selectedCode} 기준</span>` : "";
const chips = [...relatedSet].sort().map((code) => `
<span class="pc-chip" data-related-code="${code}">
<span>${code}</span>
<button type="button" class="pc-related-remove" data-related-remove="${code}">x</button>
</span>
`).join("");
const empty = "";
relatedChipRow.innerHTML = fixedHead + chips + empty;
relatedChipRow.querySelectorAll("[data-related-remove]").forEach((button) => {
button.addEventListener("click", () => {
const code = button.dataset.relatedRemove || "";
if (code) {
relatedSet.delete(code);
renderRelatedChips();
}
});
});
}
function renderRelatedSuggestions(keyword) {
if (!relatedSuggestList) return;
const normalized = (keyword || "").trim().toLowerCase();
if (!normalized || !selectedCode) {
relatedSuggestList.classList.remove("active");
relatedSuggestList.innerHTML = "";
return;
}
const rows = processCostProjects
.filter((item) => item.support_dept_code !== selectedCode)
.filter((item) => !relatedSet.has(item.support_dept_code))
.filter((item) => `${item.support_dept_code} ${item.support_dept_name || ""} ${item.client_name || ""}`.toLowerCase().includes(normalized))
.slice(0, 30);
if (!rows.length) {
relatedSuggestList.classList.remove("active");
relatedSuggestList.innerHTML = "";
return;
}
relatedSuggestList.innerHTML = rows.map((item) => `
<button type="button" class="pc-related-item" data-related-pick="${item.support_dept_code}">
${item.support_dept_code} ${item.support_dept_name || ""}
</button>
`).join("");
relatedSuggestList.classList.add("active");
relatedSuggestList.querySelectorAll("[data-related-pick]").forEach((button) => {
button.addEventListener("click", () => {
const code = button.dataset.relatedPick || "";
if (code) {
relatedSet.add(code);
if (relatedSearchInput) relatedSearchInput.value = "";
relatedSuggestList.classList.remove("active");
relatedSuggestList.innerHTML = "";
renderRelatedChips();
}
});
});
}
if (relatedSearchInput) {
relatedSearchInput.addEventListener("input", () => {
renderRelatedSuggestions(relatedSearchInput.value || "");
});
relatedSearchInput.addEventListener("focus", () => {
renderRelatedSuggestions(relatedSearchInput.value || "");
});
}
if (relatedResetButton) {
relatedResetButton.addEventListener("click", () => {
relatedSet.clear();
for (const code of initialRelatedCodes || []) {
relatedSet.add(code);
}
if (relatedSearchInput) relatedSearchInput.value = "";
if (relatedSuggestList) {
relatedSuggestList.classList.remove("active");
relatedSuggestList.innerHTML = "";
}
renderRelatedChips();
});
}
if (relatedSaveButton) {
relatedSaveButton.addEventListener("click", async () => {
if (!selectedCode) return;
relatedSaveButton.disabled = true;
try {
const response = await fetch("/projects/related-links", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
base_code: selectedCode,
related_codes: [...relatedSet],
}),
});
const payload = await response.json();
if (!response.ok || payload.error) {
throw new Error(payload.error || "연계 프로젝트 저장에 실패했습니다.");
}
window.location.reload();
} catch (error) {
alert(error.message || "연계 프로젝트 저장에 실패했습니다.");
} finally {
relatedSaveButton.disabled = false;
}
});
}
renderRelatedChips();
const monthlyRows = {{ (process_cost_detail.monthly_rows or []) | tojson }};
const svg = document.getElementById("processMonthlyChart");
if (!svg || !Array.isArray(monthlyRows) || !monthlyRows.length) {