Update project pages and add DB browser

This commit is contained in:
b17301
2026-05-08 23:51:20 +09:00
parent 99137e5f97
commit b0b4647bb4
4 changed files with 556 additions and 190 deletions
+2
View File
@@ -687,6 +687,7 @@
<a href="/process-cost" class="{% if request.url.path == '/process-cost' %}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="/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="/wehago-compare" class="{% if request.url.path == '/wehago-compare' %}active{% endif %}">전표비교</a>
<a href="/db-browser" class="{% if request.url.path.startswith('/db') %}active{% endif %}">DB 조회</a>
<div class="nav-spacer"></div> <div class="nav-spacer"></div>
<button type="button" class="view-mode-switch" id="viewModeSwitch" data-mode="dual" aria-label="화면 구성 전환"> <button type="button" class="view-mode-switch" id="viewModeSwitch" data-mode="dual" aria-label="화면 구성 전환">
<span class="label" id="viewModeSwitchLabel">듀얼</span> <span class="label" id="viewModeSwitchLabel">듀얼</span>
@@ -775,6 +776,7 @@
const lastChecked = document.getElementById("syncLastChecked"); const lastChecked = document.getElementById("syncLastChecked");
const serverTime = document.getElementById("syncServerTime"); const serverTime = document.getElementById("syncServerTime");
const dataVersion = document.getElementById("syncDataVersion"); const dataVersion = document.getElementById("syncDataVersion");
if (!dot || !label || !sessionPill || !lastChecked || !serverTime || !dataVersion) return;
let pageVersion = widget.dataset.dataVersion || ""; let pageVersion = widget.dataset.dataVersion || "";
const refreshUrl = widget.dataset.refreshUrl || window.location.href; const refreshUrl = widget.dataset.refreshUrl || window.location.href;
const refreshMode = widget.dataset.refreshMode || "auto"; const refreshMode = widget.dataset.refreshMode || "auto";
+97
View File
@@ -0,0 +1,97 @@
{% extends "base.html" %}
{% block title %}DB 조회{% endblock %}
{% block head_extra %}
<style>
.db-browser-page {
display: grid;
gap: 16px;
}
.db-browser-head {
display: grid;
gap: 14px;
padding-bottom: 14px;
border-bottom: 1px solid var(--line);
}
.db-browser-head h2 {
margin: 0;
}
.db-browser-links {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.db-browser-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 36px;
padding: 0 12px;
border: 1px solid var(--line);
border-radius: 999px;
background: #fff;
color: var(--ink);
text-decoration: none;
font-size: 13px;
font-weight: 700;
}
.db-browser-link:hover {
background: #f4f7fb;
}
.db-browser-link.is-active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.db-browser-frame-shell {
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 10px 24px rgba(21, 24, 29, 0.06);
overflow: hidden;
}
.db-browser-frame {
display: block;
width: 100%;
min-height: 78vh;
border: 0;
background: #fff;
}
</style>
{% endblock %}
{% block content %}
<section class="panel db-browser-page">
<div class="db-browser-head">
<div class="section-title">
<h2>DB 조회</h2>
</div>
<div class="db-browser-links">
{% for item in db_browser_links %}
<a
href="/db-browser?target={{ item.key }}"
class="db-browser-link {% if db_browser_target_key == item.key %}is-active{% endif %}"
>{{ item.label }}</a>
{% endfor %}
</div>
</div>
<div class="db-browser-frame-shell">
<iframe
class="db-browser-frame"
src="{{ db_browser_target_url }}"
title="DB 조회"
loading="lazy"
referrerpolicy="same-origin"
></iframe>
</div>
</section>
{% endblock %}
+108 -124
View File
@@ -146,8 +146,7 @@
min-width: 0; min-width: 0;
} }
.pc-quick-link, .pc-quick-link {
.pc-related-link {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
@@ -165,8 +164,7 @@
} }
.pc-quick-link strong, .pc-quick-link strong,
.pc-quick-link span, .pc-quick-link span {
.pc-related-link span {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
@@ -422,6 +420,10 @@
line-height: 1.15; line-height: 1.15;
} }
.pc-card .value.negative {
color: #dc2626;
}
.pc-card .meta { .pc-card .meta {
font-size: 12px; font-size: 12px;
color: var(--muted); color: var(--muted);
@@ -826,24 +828,6 @@
<div class="pc-selector-field"> <div class="pc-selector-field">
<input type="text" id="projectSearchInput" class="pc-project-search" placeholder="프로젝트 코드/명 검색" autocomplete="off"> <input type="text" id="projectSearchInput" class="pc-project-search" placeholder="프로젝트 코드/명 검색" autocomplete="off">
<div class="pc-project-dropdown" id="projectList" hidden> <div class="pc-project-dropdown" id="projectList" hidden>
{% for item in process_cost_projects %}
<a
href="/process-cost?source={{ process_cost_source }}{% if process_cost_selected_start_year %}&start_year={{ process_cost_selected_start_year }}{% endif %}{% if process_cost_selected_end_year %}&end_year={{ process_cost_selected_end_year }}{% endif %}&code={{ item.support_dept_code }}{% if process_cost_include_related %}&include_related=1&active_related={% if process_cost_active_related_codes %}{{ process_cost_active_related_codes|join(',') }}{% else %}-{% endif %}{% 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 }}"
data-project-kind="{{ item.project_kind_code|lower }}"
title="{{ item.support_dept_code }} {{ item.support_dept_name }}"
>
<div class="pc-project-head">
<span class="pc-project-code">{{ item.support_dept_code }}</span>
<span class="pc-type-pill">{{ item.project_kind_label }}</span>
</div>
<div class="pc-project-name">{{ item.support_dept_name }}</div>
{% if item.client_name %}
<div class="pc-project-name">{{ item.client_name }}</div>
{% endif %}
</a>
{% endfor %}
<div class="pc-note" id="projectListEmpty" hidden style="padding: 12px;">검색 결과가 없습니다.</div> <div class="pc-note" id="projectListEmpty" hidden style="padding: 12px;">검색 결과가 없습니다.</div>
</div> </div>
</div> </div>
@@ -906,7 +890,7 @@
</article> </article>
<article class="pc-card"> <article class="pc-card">
<span class="label">영업수지</span> <span class="label">영업수지</span>
<strong class="value">{{ "{:,.0f}".format(overview.profit_amount|default(0)) }}원</strong> <strong class="value {% if (overview.profit_amount|default(0)) < 0 %}negative{% endif %}">{{ "{:,.0f}".format(overview.profit_amount|default(0)) }}원</strong>
<span class="meta">마진율 {{ "{:.1f}".format(overview.profit_rate|default(0)) }}%</span> <span class="meta">마진율 {{ "{:.1f}".format(overview.profit_rate|default(0)) }}%</span>
</article> </article>
</div> </div>
@@ -935,7 +919,6 @@
{% for related_code in process_cost_related_codes or [] %} {% for related_code in process_cost_related_codes or [] %}
<span class="pc-chip" data-related-code="{{ related_code }}"> <span class="pc-chip" data-related-code="{{ related_code }}">
<span>{{ related_code }}</span> <span>{{ related_code }}</span>
<button type="button" class="pc-related-remove" data-related-remove="{{ related_code }}">x</button>
</span> </span>
{% endfor %} {% endfor %}
</div> </div>
@@ -1065,25 +1048,50 @@
<div class="pc-note" id="processFlowModalNote"></div> <div class="pc-note" id="processFlowModalNote"></div>
</div> </div>
</div> </div>
<script id="processCostPageState" type="application/json">
{{ {
"projects": process_cost_projects or [],
"selectedCode": process_cost_selected_code,
"selectedProject": process_cost_selected_project or {},
"relatedCodes": process_cost_related_codes or [],
"activeRelatedCodes": process_cost_active_related_codes or [],
"quickLinkCodes": process_cost_quick_link_codes or [],
"source": process_cost_source,
"selectedStartYear": process_cost_selected_start_year,
"selectedEndYear": process_cost_selected_end_year,
"includeRelatedEnabled": process_cost_include_related,
"monthlyRows": (process_cost_detail.monthly_rows or []),
} | tojson }}
</script>
{% endblock %} {% endblock %}
{% block script %} {% block script %}
<script> <script>
(() => { (() => {
const processCostProjects = {{ process_cost_projects | tojson }}; const stateNode = document.getElementById("processCostPageState");
const pageState = stateNode ? JSON.parse(stateNode.textContent || "{}") : {};
const processCostProjects = Array.isArray(pageState.projects) ? pageState.projects : [];
const escapeHtml = (value) => String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
const processCostProjectMap = new Map( const processCostProjectMap = new Map(
processCostProjects processCostProjects
.filter((item) => item && item.support_dept_code) .filter((item) => item && item.support_dept_code)
.map((item) => [item.support_dept_code, item]) .map((item) => [item.support_dept_code, item])
); );
const selectedCode = {{ process_cost_selected_code | tojson }}; const selectedCode = typeof pageState.selectedCode === "string" ? pageState.selectedCode : "";
const selectedProject = {{ (process_cost_selected_project or {}) | tojson }}; const selectedProject = pageState.selectedProject && typeof pageState.selectedProject === "object"
const initialRelatedCodes = {{ (process_cost_related_codes or []) | tojson }}; ? pageState.selectedProject
const initialActiveRelatedCodes = {{ (process_cost_active_related_codes or []) | tojson }}; : {};
const initialQuickLinkCodes = {{ (process_cost_quick_link_codes or []) | tojson }}; const initialRelatedCodes = Array.isArray(pageState.relatedCodes) ? pageState.relatedCodes : [];
const initialActiveRelatedCodes = Array.isArray(pageState.activeRelatedCodes) ? pageState.activeRelatedCodes : [];
const initialQuickLinkCodes = Array.isArray(pageState.quickLinkCodes) ? pageState.quickLinkCodes : [];
const searchInput = document.getElementById("projectSearchInput"); const searchInput = document.getElementById("projectSearchInput");
const list = document.getElementById("projectList"); const list = document.getElementById("projectList");
const projectListEmpty = document.getElementById("projectListEmpty"); let projectListEmpty = document.getElementById("projectListEmpty");
const typeFilter = document.getElementById("projectTypeFilter"); const typeFilter = document.getElementById("projectTypeFilter");
const quickLinksContainer = document.getElementById("pcQuickLinks"); const quickLinksContainer = document.getElementById("pcQuickLinks");
const selectedProjectBookmark = document.getElementById("selectedProjectBookmark"); const selectedProjectBookmark = document.getElementById("selectedProjectBookmark");
@@ -1093,18 +1101,21 @@
const processModalNote = document.getElementById("processFlowModalNote"); const processModalNote = document.getElementById("processFlowModalNote");
const processModalClose = document.getElementById("processFlowModalClose"); const processModalClose = document.getElementById("processFlowModalClose");
const processButtons = document.querySelectorAll("[data-process-modal]"); const processButtons = document.querySelectorAll("[data-process-modal]");
const includeRelatedEnabled = {{ 1 if process_cost_include_related else 0 }}; const selectedStartYear = pageState.selectedStartYear;
const selectedEndYear = pageState.selectedEndYear;
const selectedSource = typeof pageState.source === "string" ? pageState.source : "hanmac";
const includeRelatedEnabled = Boolean(pageState.includeRelatedEnabled);
let bookmarkedCodes = Array.isArray(initialQuickLinkCodes) ? [...initialQuickLinkCodes] : []; let bookmarkedCodes = Array.isArray(initialQuickLinkCodes) ? [...initialQuickLinkCodes] : [];
let activeRelatedCodes = Array.isArray(initialActiveRelatedCodes) ? [...initialActiveRelatedCodes] : []; let activeRelatedCodes = Array.isArray(initialActiveRelatedCodes) ? [...initialActiveRelatedCodes] : [];
function buildProcessCostUrl(code, options = {}) { function buildProcessCostUrl(code, options = {}) {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set("source", {{ process_cost_source | tojson }}); params.set("source", selectedSource);
if ({{ process_cost_selected_start_year | tojson }}) { if (selectedStartYear) {
params.set("start_year", {{ process_cost_selected_start_year | tojson }}); params.set("start_year", String(selectedStartYear));
} }
if ({{ process_cost_selected_end_year | tojson }}) { if (selectedEndYear) {
params.set("end_year", {{ process_cost_selected_end_year | tojson }}); params.set("end_year", String(selectedEndYear));
} }
if (code) { if (code) {
params.set("code", code); params.set("code", code);
@@ -1128,6 +1139,44 @@
.filter((item) => item && item.support_dept_code); .filter((item) => item && item.support_dept_code);
} }
function getFilteredProjectItems() {
const keyword = (searchInput?.value || "").trim().toLowerCase();
const typeValue = (typeFilter?.value || "").trim().toLowerCase();
const bookmarkedSet = new Set(bookmarkedCodes);
return processCostProjects
.filter((item) => item && item.support_dept_code)
.filter((item) => {
const text = `${item.support_dept_code} ${item.support_dept_name || ""} ${item.client_name || ""}`.toLowerCase();
const kind = String(item.project_kind_code || "").toLowerCase();
const matchesKeyword = !keyword || text.includes(keyword);
const matchesType = !typeValue || kind === typeValue;
return matchesKeyword && matchesType && !bookmarkedSet.has(item.support_dept_code);
});
}
function renderProjectListItems() {
if (!list) return 0;
const items = getFilteredProjectItems();
list.innerHTML = `${items.map((item) => `
<a
href="${buildProcessCostUrl(item.support_dept_code)}"
class="pc-project-item ${selectedCode === item.support_dept_code ? "active" : ""}"
data-project-search="${escapeHtml(`${item.support_dept_code} ${item.support_dept_name || ""} ${item.client_name || ""}`.toLowerCase())}"
data-project-kind="${escapeHtml(String(item.project_kind_code || "").toLowerCase())}"
title="${escapeHtml(`${item.support_dept_code} ${item.support_dept_name || ""}`.trim())}"
>
<div class="pc-project-head">
<span class="pc-project-code">${escapeHtml(item.support_dept_code)}</span>
<span class="pc-type-pill">${escapeHtml(item.project_kind_label || "")}</span>
</div>
<div class="pc-project-name">${escapeHtml(item.support_dept_name || "")}</div>
${item.client_name ? `<div class="pc-project-name">${escapeHtml(item.client_name)}</div>` : ""}
</a>
`).join("")}<div class="pc-note" id="projectListEmpty" ${items.length ? "hidden" : ""} style="padding: 12px;">검색 결과가 없습니다.</div>`;
projectListEmpty = document.getElementById("projectListEmpty");
return items.length;
}
async function persistBookmarkedCodes(nextCodes) { async function persistBookmarkedCodes(nextCodes) {
const response = await fetch("/process-cost/quick-links", { const response = await fetch("/process-cost/quick-links", {
method: "POST", method: "POST",
@@ -1171,10 +1220,10 @@
return; return;
} }
quickLinksContainer.innerHTML = items.map((item) => ` quickLinksContainer.innerHTML = items.map((item) => `
<div class="pc-quick-link" title="${item.support_dept_code} ${item.support_dept_name || ""}"> <div class="pc-quick-link" title="${escapeHtml(item.support_dept_code)} ${escapeHtml(item.support_dept_name || "")}">
<a href="${buildProcessCostUrl(item.support_dept_code)}" class="pc-quick-link-main"> <a href="${buildProcessCostUrl(item.support_dept_code)}" class="pc-quick-link-main">
<strong>${item.support_dept_code}</strong> <strong>${escapeHtml(item.support_dept_code)}</strong>
<span>${item.support_dept_name || ""}</span> <span>${escapeHtml(item.support_dept_name || "")}</span>
</a> </a>
<button type="button" class="pc-bookmark-button is-active" data-bookmark-toggle="${item.support_dept_code}" aria-label="바로가기 해제" title="바로가기 해제"> <button type="button" class="pc-bookmark-button is-active" data-bookmark-toggle="${item.support_dept_code}" aria-label="바로가기 해제" title="바로가기 해제">
<svg viewBox="0 0 24 24" aria-hidden="true"> <svg viewBox="0 0 24 24" aria-hidden="true">
@@ -1205,42 +1254,16 @@
syncSelectedProjectCard(); syncSelectedProjectCard();
} }
function getVisibleProjectItems() {
return [...list.querySelectorAll("[data-project-search]")].filter((item) => item.style.display !== "none");
}
function filterProjectList() { function filterProjectList() {
if (!searchInput || !list) return; return renderProjectListItems();
const keyword = (searchInput.value || "").trim().toLowerCase();
const typeValue = (typeFilter?.value || "").trim().toLowerCase();
const bookmarkedSet = new Set(bookmarkedCodes);
let visibleCount = 0;
list.querySelectorAll("[data-project-search]").forEach((item) => {
const text = (item.dataset.projectSearch || "").toLowerCase();
const kind = (item.dataset.projectKind || "").toLowerCase();
const matchesKeyword = !keyword || text.includes(keyword);
const matchesType = !typeValue || kind === typeValue;
const codeNode = item.querySelector(".pc-project-code");
const code = (codeNode?.textContent || "").trim();
const isVisible = matchesKeyword && matchesType && !bookmarkedSet.has(code);
item.hidden = !isVisible;
if (isVisible) {
visibleCount += 1;
}
});
if (projectListEmpty) {
projectListEmpty.hidden = visibleCount !== 0;
}
return visibleCount;
} }
function openProjectList() { function openProjectList() {
if (!list) return; if (!list) return;
const visibleCount = filterProjectList(); const visibleCount = filterProjectList();
const visibleItems = getVisibleProjectItems();
list.hidden = false; list.hidden = false;
if (projectListEmpty) { if (projectListEmpty) {
projectListEmpty.hidden = (visibleCount || visibleItems.length) !== 0; projectListEmpty.hidden = visibleCount !== 0;
} }
} }
@@ -1375,7 +1398,7 @@
<a <a
href="${buildProcessCostUrl(code, { activeRelatedCodes: openActiveCodes, includeRelated: includeRelatedEnabled })}" href="${buildProcessCostUrl(code, { activeRelatedCodes: openActiveCodes, includeRelated: includeRelatedEnabled })}"
class="pc-chip-link" class="pc-chip-link"
>${code}</a> >${escapeHtml(code)}</a>
<button <button
type="button" type="button"
class="pc-related-include ${isActive ? "is-active" : ""}" class="pc-related-include ${isActive ? "is-active" : ""}"
@@ -1385,7 +1408,6 @@
title="${isActive ? "합산 제외" : "합산 포함"}" title="${isActive ? "합산 제외" : "합산 포함"}"
${includeRelatedEnabled ? "" : "disabled"} ${includeRelatedEnabled ? "" : "disabled"}
>${isActive ? "" : "+"}</button> >${isActive ? "" : "+"}</button>
<button type="button" class="pc-related-remove" data-related-remove="${code}">x</button>
</span> </span>
`; `;
}).join(""); }).join("");
@@ -1406,16 +1428,6 @@
}); });
}); });
}); });
relatedChipRow.querySelectorAll("[data-related-remove]").forEach((button) => {
button.addEventListener("click", () => {
const code = button.dataset.relatedRemove || "";
if (code) {
relatedSet.delete(code);
activeRelatedCodes = activeRelatedCodes.filter((item) => item !== code);
renderRelatedChips();
}
});
});
} }
function renderRelatedSuggestions(keyword) { function renderRelatedSuggestions(keyword) {
@@ -1438,20 +1450,13 @@
} }
relatedSuggestList.innerHTML = rows.map((item) => ` relatedSuggestList.innerHTML = rows.map((item) => `
<button type="button" class="pc-related-item" data-related-pick="${item.support_dept_code}"> <button type="button" class="pc-related-item" data-related-pick="${item.support_dept_code}">
${item.support_dept_code} ${item.support_dept_name || ""} ${escapeHtml(item.support_dept_code)} ${escapeHtml(item.support_dept_name || "")}
</button> </button>
`).join(""); `).join("");
relatedSuggestList.classList.add("active"); relatedSuggestList.classList.add("active");
relatedSuggestList.querySelectorAll("[data-related-pick]").forEach((button) => { relatedSuggestList.querySelectorAll("[data-related-pick]").forEach((button) => {
button.addEventListener("click", () => { button.addEventListener("click", () => {
const code = button.dataset.relatedPick || ""; window.alert("연계 프로젝트 링크는 DB 자동 규칙으로만 계산됩니다.");
if (code) {
relatedSet.add(code);
if (relatedSearchInput) relatedSearchInput.value = "";
relatedSuggestList.classList.remove("active");
relatedSuggestList.innerHTML = "";
renderRelatedChips();
}
}); });
}); });
} }
@@ -1463,47 +1468,26 @@
relatedSearchInput.addEventListener("focus", () => { relatedSearchInput.addEventListener("focus", () => {
renderRelatedSuggestions(relatedSearchInput.value || ""); renderRelatedSuggestions(relatedSearchInput.value || "");
}); });
document.addEventListener("click", (event) => {
const target = event.target;
if (!(target instanceof Element)) return;
if (target.closest(".pc-related-form")) return;
relatedSuggestList?.classList.remove("active");
});
} }
if (relatedResetButton) { if (relatedResetButton) {
relatedResetButton.addEventListener("click", () => { relatedResetButton.addEventListener("click", () => {
relatedSet.clear(); window.location.href = buildProcessCostUrl(selectedCode, {
for (const code of initialRelatedCodes || []) { includeRelated: includeRelatedEnabled,
relatedSet.add(code); activeRelatedCodes: Array.isArray(initialRelatedCodes) ? [...initialRelatedCodes] : [],
} });
activeRelatedCodes = Array.isArray(initialActiveRelatedCodes) ? [...initialActiveRelatedCodes] : [];
if (relatedSearchInput) relatedSearchInput.value = "";
if (relatedSuggestList) {
relatedSuggestList.classList.remove("active");
relatedSuggestList.innerHTML = "";
}
renderRelatedChips();
}); });
} }
if (relatedSaveButton) { if (relatedSaveButton) {
relatedSaveButton.addEventListener("click", async () => { relatedSaveButton.addEventListener("click", () => {
if (!selectedCode) return; window.alert("연계 프로젝트 링크는 DB 자동 규칙으로만 계산됩니다.");
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;
}
}); });
} }
@@ -1546,7 +1530,7 @@
syncYearRangeOptions(); syncYearRangeOptions();
} }
const monthlyRows = {{ (process_cost_detail.monthly_rows or []) | tojson }}; const monthlyRows = Array.isArray(pageState.monthlyRows) ? pageState.monthlyRows : [];
const svg = document.getElementById("processMonthlyChart"); const svg = document.getElementById("processMonthlyChart");
if (!svg || !Array.isArray(monthlyRows) || !monthlyRows.length) { if (!svg || !Array.isArray(monthlyRows) || !monthlyRows.length) {
return; return;
@@ -1571,7 +1555,7 @@
let labels = ""; let labels = "";
monthlyRows.forEach((row, i) => { monthlyRows.forEach((row, i) => {
labels += `<text x="${x(i)}" y="${height - 12}" text-anchor="middle" fill="#677182" font-size="10">${(row.month_label || '').slice(2)}</text>`; labels += `<text x="${x(i)}" y="${height - 12}" text-anchor="middle" fill="#677182" font-size="10">${escapeHtml((row.month_label || "").slice(2))}</text>`;
}); });
svg.innerHTML = ` svg.innerHTML = `
+348 -65
View File
@@ -49,6 +49,11 @@
min-width: 156px; min-width: 156px;
} }
.existing-rate-option {
background: #d9dde3;
color: var(--ink);
}
.metric-grid { .metric-grid {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -314,7 +319,7 @@
.project-search-controls { .project-search-controls {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 180px 236px; grid-template-columns: minmax(0, 1fr) 44px 180px 236px;
gap: 12px; gap: 12px;
align-items: center; align-items: center;
min-height: var(--toolbar-row-height); min-height: var(--toolbar-row-height);
@@ -614,8 +619,7 @@
flex: 0 0 auto; flex: 0 0 auto;
} }
.related-project-tag .related-project-toggle, .related-project-tag .related-project-toggle {
.related-project-tag [data-remove-related] {
width: 12px; width: 12px;
height: 12px; height: 12px;
min-width: 12px; min-width: 12px;
@@ -637,8 +641,7 @@
justify-content: center; justify-content: center;
} }
.related-project-tag .related-project-toggle:hover, .related-project-tag .related-project-toggle:hover {
.related-project-tag [data-remove-related]:hover {
transform: none; transform: none;
background: transparent; background: transparent;
color: var(--ink); color: var(--ink);
@@ -648,6 +651,44 @@
font-weight: 900; font-weight: 900;
} }
.related-project-remove {
border: none;
padding: 0;
margin: 0;
color: var(--muted);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex: 0 0 auto;
width: 14px;
height: 14px;
min-width: 14px;
min-height: 14px;
max-width: 14px;
max-height: 14px;
border-radius: 999px;
aspect-ratio: 1 / 1;
background: rgba(15, 23, 42, 0.08);
box-shadow: none !important;
transform: none !important;
line-height: 1;
}
.related-project-remove:hover {
background: rgba(15, 23, 42, 0.14);
color: var(--ink);
transform: none;
}
.related-project-remove svg {
width: 14px;
height: 14px;
stroke: currentColor;
stroke-width: 2;
fill: none;
}
.analysis-inline-note { .analysis-inline-note {
width: 100%; width: 100%;
min-height: 40px; min-height: 40px;
@@ -2093,19 +2134,19 @@
} }
.exec-labor-table col:nth-child(1) { .exec-labor-table col:nth-child(1) {
width: 18%; width: 24%;
} }
.exec-labor-table col:nth-child(2) { .exec-labor-table col:nth-child(2) {
width: 16%; width: 18%;
} }
.exec-labor-table col:nth-child(3) { .exec-labor-table col:nth-child(3) {
width: 30%; width: 28%;
} }
.exec-labor-table col:nth-child(4) { .exec-labor-table col:nth-child(4) {
width: 26%; width: 20%;
} }
.exec-labor-table col:nth-child(5) { .exec-labor-table col:nth-child(5) {
@@ -2113,16 +2154,27 @@
} }
.exec-labor-table .exec-labor-grade { .exec-labor-table .exec-labor-grade {
max-width: 90px; max-width: none;
min-width: 0;
width: 100%;
color: var(--ink);
background: #fff;
} }
.exec-labor-table .exec-labor-hours { .exec-labor-table .exec-labor-hours {
max-width: 88px; max-width: none;
min-width: 0;
width: 100%;
text-align: right; text-align: right;
} }
.exec-labor-table .exec-labor-rate-year { .exec-labor-table .exec-labor-rate-year {
max-width: 104px; max-width: none;
min-width: 0;
width: 100%;
padding-right: 24px;
color: var(--ink);
background: #fff;
} }
.section-columns { .section-columns {
@@ -2610,14 +2662,6 @@
</svg> </svg>
<span class="sr-only">페이지 입력사항 저장</span> <span class="sr-only">페이지 입력사항 저장</span>
</button> </button>
<button type="button" id="openRelatedProjectToolbar" class="button-icon button-secondary" title="연관 프로젝트 검색" aria-label="연관 프로젝트 검색">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M10.8 13.2l2.4-2.4"></path>
<path d="M7.5 14.7l-1.6 1.6a3 3 0 1 1-4.2-4.2l3-3a3 3 0 0 1 4.2 0"></path>
<path d="M16.5 9.3l1.6-1.6a3 3 0 1 1 4.2 4.2l-3 3a3 3 0 0 1-4.2 0"></path>
</svg>
<span class="sr-only">연관 프로젝트 검색</span>
</button>
<button type="button" id="toggleProjectAnalysis" class="button-icon button-secondary toggle-analysis-button" title="프로젝트 상세 열기/숨기기" aria-label="프로젝트 상세 열기/숨기기"> <button type="button" id="toggleProjectAnalysis" class="button-icon button-secondary toggle-analysis-button" title="프로젝트 상세 열기/숨기기" aria-label="프로젝트 상세 열기/숨기기">
<svg viewBox="0 0 24 24" aria-hidden="true"> <svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 9l6 6 6-6"></path> <path d="M6 9l6 6 6-6"></path>
@@ -2641,6 +2685,15 @@
<div class="project-search-empty" id="projectExplorerEmpty"></div> <div class="project-search-empty" id="projectExplorerEmpty"></div>
</div> </div>
</div> </div>
<button type="button" id="openRelatedProjectToolbar" class="button-icon button-secondary related-trigger-button" title="연관 프로젝트 추가" aria-label="연관 프로젝트 추가">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M10 13a5 5 0 0 0 7.1 0l2-2a5 5 0 0 0-7.1-7.1l-1.1 1.1"></path>
<path d="M14 11a5 5 0 0 0-7.1 0l-2 2A5 5 0 0 0 12 20.1l1.1-1.1"></path>
<path d="M12 8v8"></path>
<path d="M8 12h8"></path>
</svg>
<span class="sr-only">연관 프로젝트 추가</span>
</button>
<div class="field"> <div class="field">
<select id="projectCostYearFilter" aria-label="연도 필터"> <select id="projectCostYearFilter" aria-label="연도 필터">
<option value="">전체 연도</option> <option value="">전체 연도</option>
@@ -2969,10 +3022,10 @@
<div class="form-section-head"> <div class="form-section-head">
<h3>실행예산계획</h3> <h3>실행예산계획</h3>
<div class="form-section-actions"> <div class="form-section-actions">
<div class="table-total">합계: <span id="execBudgetTotalDisplay">0</span></div>
<button type="button" class="button-link button-secondary mini-button" data-save-scope-button="exec_budget">실행예산 저장</button> <button type="button" class="button-link button-secondary mini-button" data-save-scope-button="exec_budget">실행예산 저장</button>
</div> </div>
</div> </div>
<div class="table-total">실행예산 합계: <span id="execBudgetTotalDisplay">0</span></div>
<div class="section-columns"> <div class="section-columns">
<section class="mini-sector"> <section class="mini-sector">
<div class="table-tools"> <div class="table-tools">
@@ -3214,7 +3267,7 @@
<label for="expected_as_rate">예상 A/S비 비율</label> <label for="expected_as_rate">예상 A/S비 비율</label>
<select id="expected_as_rate" name="expected_as_rate"> <select id="expected_as_rate" name="expected_as_rate">
{% for option in expected_as_rate_options %} {% for option in expected_as_rate_options %}
<option value="{{ option.value }}" {% if project_edit.expected_as_rate|string == option.value|string %}selected{% endif %}>{{ option.label }}</option> <option value="{{ option.value }}" class="{% if option.is_existing_value %}existing-rate-option{% endif %}" {% if project_edit.expected_as_rate|string == option.value|string %}selected{% endif %}>{{ option.label }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
@@ -3226,7 +3279,7 @@
<label for="expected_sga_rate">예상 판관비 비율</label> <label for="expected_sga_rate">예상 판관비 비율</label>
<select id="expected_sga_rate" name="expected_sga_rate"> <select id="expected_sga_rate" name="expected_sga_rate">
{% for option in expected_sga_rate_options %} {% for option in expected_sga_rate_options %}
<option value="{{ option.value }}" {% if project_edit.expected_sga_rate|string == option.value|string %}selected{% endif %}>{{ option.label }}</option> <option value="{{ option.value }}" class="{% if option.is_existing_value %}existing-rate-option{% endif %}" {% if project_edit.expected_sga_rate|string == option.value|string %}selected{% endif %}>{{ option.label }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
@@ -3656,6 +3709,8 @@
const contractAmountInput = document.getElementById("contract_amount"); const contractAmountInput = document.getElementById("contract_amount");
const expectedAsRateInput = document.getElementById("expected_as_rate"); const expectedAsRateInput = document.getElementById("expected_as_rate");
const expectedSgaRateInput = document.getElementById("expected_sga_rate"); const expectedSgaRateInput = document.getElementById("expected_sga_rate");
const defaultExpectedAsRateValues = new Set(["0", "2", "5", "10"]);
const defaultExpectedSgaRateValues = new Set(["13", "15", "20", "25"]);
const editRevisionInput = projectStatusForm?.querySelector('input[name="edit_revision"]'); const editRevisionInput = projectStatusForm?.querySelector('input[name="edit_revision"]');
const collectionRows = document.getElementById("collectionRows"); const collectionRows = document.getElementById("collectionRows");
const taskPlanDepartmentRows = document.getElementById("taskPlanDepartmentRows"); const taskPlanDepartmentRows = document.getElementById("taskPlanDepartmentRows");
@@ -4125,6 +4180,54 @@
return Number.isFinite(numeric) ? numeric : 0; return Number.isFinite(numeric) ? numeric : 0;
} }
function roundPercentageRate(value) {
const numeric = parseAmount(value);
return Number.isFinite(numeric) ? Math.round(numeric) : 0;
}
function normalizePercentageSelectValue(input) {
if (!input) return "";
const rawValue = String(input.value ?? "").trim();
if (!rawValue) return "";
const roundedValue = String(roundPercentageRate(rawValue));
if (![...input.options].some((option) => option.value === roundedValue)) {
const option = new Option(`${roundedValue}%`, roundedValue);
if (!isDefaultPercentageRateValue(input, roundedValue)) {
option.classList.add("existing-rate-option");
}
input.add(option);
}
input.value = roundedValue;
return roundedValue;
}
function isDefaultPercentageRateValue(input, value) {
const normalizedValue = String(value ?? "").trim();
if (input === expectedAsRateInput) {
return defaultExpectedAsRateValues.has(normalizedValue);
}
if (input === expectedSgaRateInput) {
return defaultExpectedSgaRateValues.has(normalizedValue);
}
return false;
}
function setRoundedPercentageSelectValue(input, value) {
if (!input || value === null || value === undefined || value === "") {
if (input) input.value = "";
return;
}
const roundedValue = String(roundPercentageRate(value));
if (![...input.options].some((option) => option.value === roundedValue)) {
const option = new Option(`${roundedValue}%`, roundedValue);
if (!isDefaultPercentageRateValue(input, roundedValue)) {
option.classList.add("existing-rate-option");
}
input.add(option);
}
input.value = roundedValue;
}
function digitsOnly(value) { function digitsOnly(value) {
return String(value ?? "").replace(/\D/g, ""); return String(value ?? "").replace(/\D/g, "");
} }
@@ -4276,9 +4379,59 @@
} }
} }
function ceilToHundreds(value) {
const amount = Number(value || 0);
if (!Number.isFinite(amount) || amount <= 0) return 0;
return Math.ceil(amount / 100) * 100;
}
function getNumericYearRate(yearRates, grade) {
return Number(yearRates?.[grade] || 0);
}
function hasPositiveYearRates(yearRates, grades) {
return grades.every((grade) => getNumericYearRate(yearRates, grade) > 0);
}
function getDerivedLaborRate(yearRates, grade) {
if (grade === "수석") {
const directorRate = getNumericYearRate(yearRates, "이사");
return directorRate > 0 ? directorRate : 0;
}
if (grade === "책임") {
if (!hasPositiveYearRates(yearRates, ["부장", "차장"])) return 0;
return ceilToHundreds(((getNumericYearRate(yearRates, "부장") * 5) + (getNumericYearRate(yearRates, "차장") * 2)) / 7);
}
if (grade === "선임") {
if (!hasPositiveYearRates(yearRates, ["차장", "과장", "대리"])) return 0;
return ceilToHundreds(((getNumericYearRate(yearRates, "차장") * 2) + (getNumericYearRate(yearRates, "과장") * 3) + getNumericYearRate(yearRates, "대리")) / 6);
}
if (grade === "연구원") {
if (!hasPositiveYearRates(yearRates, ["대리", "사원"])) return 0;
return ceilToHundreds(((getNumericYearRate(yearRates, "대리") * 2) + (getNumericYearRate(yearRates, "사원") * 3)) / 5);
}
return 0;
}
function getResolvedLaborRatesForYear(year = "") {
const yearKey = String(year || getDefaultLaborRateYear());
const storedYearRates = currentLaborRates?.[yearKey] || {};
const resolvedYearRates = {};
laborGradeOptions.forEach((grade) => {
resolvedYearRates[grade] = getNumericYearRate(storedYearRates, grade);
});
["수석", "책임", "선임", "연구원"].forEach((grade) => {
const derived = getDerivedLaborRate(storedYearRates, grade);
if (derived > 0) {
resolvedYearRates[grade] = derived;
}
});
return resolvedYearRates;
}
function getLaborRate(grade, year = "") { function getLaborRate(grade, year = "") {
const yearKey = String(year || getDefaultLaborRateYear()); const yearKey = String(year || getDefaultLaborRateYear());
const yearRates = currentLaborRates?.[yearKey] || {}; const yearRates = getResolvedLaborRatesForYear(yearKey);
const direct = Number(yearRates?.[grade] || 0); const direct = Number(yearRates?.[grade] || 0);
if (direct) return direct; if (direct) return direct;
const gradeIndex = laborGradeOptions.indexOf(grade); const gradeIndex = laborGradeOptions.indexOf(grade);
@@ -4368,17 +4521,17 @@
updateCollectionContractCells(contractAmount, collectionTotal); updateCollectionContractCells(contractAmount, collectionTotal);
document.getElementById("progress_rate_display").textContent = `${progressRate.toFixed(1)}%`; document.getElementById("progress_rate_display").textContent = `${progressRate.toFixed(1)}%`;
const expectedAsCost = contractAmount * parseAmount(expectedAsRateInput?.value) / 100; const expectedAsRate = roundPercentageRate(expectedAsRateInput?.value);
const expectedSgaBudget = contractAmount * parseAmount(expectedSgaRateInput?.value) / 100; const expectedSgaRate = roundPercentageRate(expectedSgaRateInput?.value);
const expectedAsCost = contractAmount * expectedAsRate / 100;
const expectedSgaBudget = contractAmount * expectedSgaRate / 100;
const expectedAsCostField = document.getElementById("expected_as_cost_display"); const expectedAsCostField = document.getElementById("expected_as_cost_display");
const expectedSgaBudgetField = document.getElementById("expected_sga_budget_display"); const expectedSgaBudgetField = document.getElementById("expected_sga_budget_display");
if (expectedAsCostField && document.activeElement !== expectedAsCostField) { if (expectedAsCostField && document.activeElement !== expectedAsCostField) {
const keepManual = parseAmount(expectedAsCostField.value); expectedAsCostField.value = expectedAsCost ? formatAmountInputValue(expectedAsCost) : "";
expectedAsCostField.value = keepManual ? formatAmountInputValue(keepManual) : (expectedAsCost ? formatAmountInputValue(expectedAsCost) : "");
} }
if (expectedSgaBudgetField && document.activeElement !== expectedSgaBudgetField) { if (expectedSgaBudgetField && document.activeElement !== expectedSgaBudgetField) {
const keepManual = parseAmount(expectedSgaBudgetField.value); expectedSgaBudgetField.value = expectedSgaBudget ? formatAmountInputValue(expectedSgaBudget) : "";
expectedSgaBudgetField.value = keepManual ? formatAmountInputValue(keepManual) : (expectedSgaBudget ? formatAmountInputValue(expectedSgaBudget) : "");
} }
syncExpectedAllocationsToActualInputs(); syncExpectedAllocationsToActualInputs();
@@ -4965,14 +5118,16 @@
if (!laborRateRows) return; if (!laborRateRows) return;
const selectedRateYear = String(laborRateYearSelect?.value || getDefaultLaborRateYear()); const selectedRateYear = String(laborRateYearSelect?.value || getDefaultLaborRateYear());
const yearRates = currentLaborRates?.[selectedRateYear] || {}; const yearRates = currentLaborRates?.[selectedRateYear] || {};
const resolvedYearRates = getResolvedLaborRatesForYear(selectedRateYear);
laborRateRows.innerHTML = laborGradeOptions.map((grade) => ` laborRateRows.innerHTML = laborGradeOptions.map((grade) => `
<tr> <tr>
<td>${escapeHtml(grade)}</td> <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(yearRates?.[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>
</tr> </tr>
`).join(""); `).join("");
bindCollectionFieldBehaviors(laborRateRows); bindCollectionFieldBehaviors(laborRateRows);
laborRateRows.querySelectorAll(".labor-rate-input").forEach((input) => { laborRateRows.querySelectorAll(".labor-rate-input").forEach((input) => {
if (input.hasAttribute("readonly")) return;
input.addEventListener("input", () => { input.addEventListener("input", () => {
const grade = input.dataset.grade || ""; const grade = input.dataset.grade || "";
const targetYear = String(laborRateYearSelect?.value || getDefaultLaborRateYear()); const targetYear = String(laborRateYearSelect?.value || getDefaultLaborRateYear());
@@ -5006,9 +5161,12 @@
const nextYearRates = { ...(currentLaborRates?.[targetYear] || {}) }; const nextYearRates = { ...(currentLaborRates?.[targetYear] || {}) };
laborRateRows?.querySelectorAll(".labor-rate-input").forEach((input) => { laborRateRows?.querySelectorAll(".labor-rate-input").forEach((input) => {
const grade = String(input.dataset.grade || "").trim(); const grade = String(input.dataset.grade || "").trim();
if (!grade) return; if (!grade || input.hasAttribute("readonly")) return;
nextYearRates[grade] = parseAmount(input.value); nextYearRates[grade] = parseAmount(input.value);
}); });
["수석", "책임", "선임", "연구원"].forEach((grade) => {
nextYearRates[grade] = getDerivedLaborRate(nextYearRates, grade);
});
currentLaborRates[targetYear] = nextYearRates; currentLaborRates[targetYear] = nextYearRates;
writeLaborRates(currentLaborRates); writeLaborRates(currentLaborRates);
recalculateExecLaborAmounts(); recalculateExecLaborAmounts();
@@ -5171,8 +5329,8 @@
projectImportReviewTag.textContent = normalized.review_tag || ""; projectImportReviewTag.textContent = normalized.review_tag || "";
projectImportReviewNote.textContent = normalized.review_note || ""; projectImportReviewNote.textContent = normalized.review_note || "";
} }
expectedAsRateInput.value = normalized.expected_as_rate === null || normalized.expected_as_rate === undefined || normalized.expected_as_rate === "" ? "" : String(normalized.expected_as_rate); setRoundedPercentageSelectValue(expectedAsRateInput, normalized.expected_as_rate);
expectedSgaRateInput.value = normalized.expected_sga_rate === null || normalized.expected_sga_rate === undefined || normalized.expected_sga_rate === "" ? "" : String(normalized.expected_sga_rate); setRoundedPercentageSelectValue(expectedSgaRateInput, normalized.expected_sga_rate);
if (editRevisionInput) { if (editRevisionInput) {
editRevisionInput.value = normalized.updated_at || ""; editRevisionInput.value = normalized.updated_at || "";
} }
@@ -5505,8 +5663,14 @@
}); });
contractAmountInput?.addEventListener("input", updateComputedFields); contractAmountInput?.addEventListener("input", updateComputedFields);
expectedAsRateInput?.addEventListener("change", updateComputedFields); expectedAsRateInput?.addEventListener("change", () => {
expectedSgaRateInput?.addEventListener("change", updateComputedFields); normalizePercentageSelectValue(expectedAsRateInput);
updateComputedFields();
});
expectedSgaRateInput?.addEventListener("change", () => {
normalizePercentageSelectValue(expectedSgaRateInput);
updateComputedFields();
});
document.getElementById("expected_as_cost_display")?.addEventListener("input", (event) => { document.getElementById("expected_as_cost_display")?.addEventListener("input", (event) => {
event.target.value = formatAmountInputValue(event.target.value); event.target.value = formatAmountInputValue(event.target.value);
syncExpectedAllocationsToActualInputs(); syncExpectedAllocationsToActualInputs();
@@ -5822,6 +5986,72 @@
return mergedItem; return mergedItem;
} }
function getSharedInputClusterCodes(item) {
if (!Array.isArray(item?.shared_input_cluster_codes)) return [];
return item.shared_input_cluster_codes
.map((value) => String(value || "").trim())
.filter(Boolean);
}
function shouldIncludeSharedInputs(item, includedCodeSet) {
const ownerCode = String(item?.shared_input_owner_code || "").trim();
if (!ownerCode || String(item?.support_dept_code || "").trim() !== ownerCode) {
return true;
}
const clusterCodes = getSharedInputClusterCodes(item);
if (!clusterCodes.length) return true;
return clusterCodes.every((code) => includedCodeSet.has(code));
}
function stripSharedClusterInputs(item) {
if (!item) return item;
return {
...item,
task_plan_department_budget: 0,
task_plan_outsource_budget: 0,
task_plan_joint_operating_cost: 0,
exec_budget_labor_by_grade: 0,
exec_budget_outsource: 0,
exec_budget_cost_plan: 0,
expected_as_cost: 0,
expected_sga_budget: 0,
item_investment: 0,
task_plan_entries: [],
exec_budget_entries: [],
actual_input_entries: [],
planned_task_total: 0,
exec_budget_total: 0,
};
}
function normalizeAnalysisItemForSelection(item, includedCodeSet) {
if (!item) return item;
if (shouldIncludeSharedInputs(item, includedCodeSet)) {
return item;
}
return stripSharedClusterInputs(item);
}
function getSuppressedSharedOwnerCodes(includedCodeSet) {
const normalizedIncludedCodeSet = new Set(
[...(includedCodeSet || [])]
.map((value) => String(value || "").trim())
.filter(Boolean),
);
const suppressedOwnerCodes = new Set();
normalizedIncludedCodeSet.forEach((code) => {
const sourceItem = getAnalysisItem(code) || projectStatusMap[code] || null;
if (!sourceItem) return;
const ownerCode = String(sourceItem?.shared_input_owner_code || "").trim();
const clusterCodes = getSharedInputClusterCodes(sourceItem);
if (!ownerCode || !clusterCodes.length) return;
if (!clusterCodes.every((clusterCode) => normalizedIncludedCodeSet.has(clusterCode))) {
suppressedOwnerCodes.add(ownerCode);
}
});
return suppressedOwnerCodes;
}
function getProjectContractState(item) { function getProjectContractState(item) {
const code = item?.support_dept_code || ""; const code = item?.support_dept_code || "";
const contractAmount = Number(item?.contract_amount || projectStatusMap[code]?.contract_amount || 0); const contractAmount = Number(item?.contract_amount || projectStatusMap[code]?.contract_amount || 0);
@@ -6576,6 +6806,12 @@
return [...merged.values()].sort((a, b) => Number(b.amount || 0) - Number(a.amount || 0)); return [...merged.values()].sort((a, b) => Number(b.amount || 0) - Number(a.amount || 0));
} }
function getAggregateContractAmount(items) {
return (items || []).reduce((sum, item) => {
return sum + Math.max(0, Number(item?.contract_amount || 0));
}, 0);
}
function prefixProjectEntries(item, entries, fallbackLabel) { function prefixProjectEntries(item, entries, fallbackLabel) {
return normalizeDetailEntries(entries, fallbackLabel).map((entry) => ({ return normalizeDetailEntries(entries, fallbackLabel).map((entry) => ({
...entry, ...entry,
@@ -6598,15 +6834,26 @@
return mergeLabeledAmountEntries(merged); return mergeLabeledAmountEntries(merged);
} }
function aggregateAnalysisItem(baseItem, relatedItems = []) { function aggregateAnalysisItem(baseItem, relatedItems = [], options = {}) {
if (!baseItem) return null; if (!baseItem) return null;
const items = [baseItem, ...relatedItems].filter(Boolean); const items = [baseItem, ...relatedItems].filter(Boolean);
const suppressedSharedOwnerCodes = new Set(
[...(options?.suppressedSharedOwnerCodes || [])]
.map((value) => String(value || "").trim())
.filter(Boolean),
);
const yearValues = items
.flatMap((item) => [Number(item.min_year || item.year || 0), Number(item.max_year || item.year || 0)])
.filter(Boolean);
const minYear = yearValues.length ? Math.min(...yearValues) : 0;
const maxYear = yearValues.length ? Math.max(...yearValues) : 0;
const aggregated = { const aggregated = {
...baseItem, ...baseItem,
_aggregateCodes: items.map((item) => item.support_dept_code), _aggregateCodes: items.map((item) => item.support_dept_code),
_relatedItems: relatedItems, _relatedItems: relatedItems,
min_year: Math.min(...items.map((item) => Number(item.min_year || item.year || 0)).filter(Boolean)), _suppressedSharedOwnerCodes: [...suppressedSharedOwnerCodes],
max_year: Math.max(...items.map((item) => Number(item.max_year || item.year || 0)).filter(Boolean)), min_year: minYear,
max_year: maxYear,
latest_year: baseItem.latest_year, latest_year: baseItem.latest_year,
latest_month: baseItem.latest_month, latest_month: baseItem.latest_month,
revenue_amount: 0, revenue_amount: 0,
@@ -6642,7 +6889,6 @@
"total_revenue", "total_revenue",
"total_cost", "total_cost",
"total_sga", "total_sga",
"contract_amount",
"collection_amount", "collection_amount",
"planned_task_total", "planned_task_total",
"exec_budget_total", "exec_budget_total",
@@ -6671,8 +6917,10 @@
aggregated.latest_month = item.latest_month; aggregated.latest_month = item.latest_month;
} }
}); });
aggregated.contract_amount = getAggregateContractAmount(items);
aggregated.total_expense = Number(aggregated.total_cost || 0) + Number(aggregated.total_sga || 0); aggregated.total_expense = Number(aggregated.total_cost || 0) + Number(aggregated.total_sga || 0);
aggregated.operating_balance = Number(aggregated.total_revenue || 0) - Number(aggregated.total_expense || 0); aggregated.operating_balance = Number(aggregated.total_revenue || 0) - Number(aggregated.total_expense || 0);
aggregated.year_range = formatYearRange(aggregated);
return aggregated; return aggregated;
} }
@@ -6697,10 +6945,22 @@
return `<span class="${differenceClass(diff)}">${formatSignedAmount(diff)}</span>`; return `<span class="${differenceClass(diff)}">${formatSignedAmount(diff)}</span>`;
} }
function formatCollectionComparisonDiff(planned, actual) {
const diff = Number(actual || 0) - Number(planned || 0);
return `<span class="${differenceClass(diff)}">${formatSignedAmount(diff)}</span>`;
}
function buildComparisonDetails(item) { function buildComparisonDetails(item) {
const aggregateCodes = Array.isArray(item?._aggregateCodes) && item._aggregateCodes.length const suppressedSharedOwnerCodes = new Set(
Array.isArray(item?._suppressedSharedOwnerCodes)
? item._suppressedSharedOwnerCodes.map((value) => String(value || "").trim()).filter(Boolean)
: [],
);
const aggregateCodes = (
Array.isArray(item?._aggregateCodes) && item._aggregateCodes.length
? item._aggregateCodes ? item._aggregateCodes
: [item.support_dept_code]; : [item.support_dept_code]
).filter((code) => !suppressedSharedOwnerCodes.has(String(code || "").trim()));
const revenuePlanned = normalizeDetailEntries(item.collection_entries, "수금 입력"); const revenuePlanned = normalizeDetailEntries(item.collection_entries, "수금 입력");
const revenueActual = getCombinedBreakdownList(aggregateCodes, "revenue"); const revenueActual = getCombinedBreakdownList(aggregateCodes, "revenue");
const laborPlanned = getExecBudgetGroupEntries(item, "labor", "직급별 인건비"); const laborPlanned = getExecBudgetGroupEntries(item, "labor", "직급별 인건비");
@@ -7327,7 +7587,12 @@
</span> </span>
<span class="related-project-actions"> <span class="related-project-actions">
<button type="button" class="related-project-toggle" data-toggle-related="${escapeHtml(item.support_dept_code)}" title="${isInactive ? "상세 반영 다시 켜기" : "상세 반영 끄기"}" aria-label="${isInactive ? "상세 반영 다시 켜기" : "상세 반영 끄기"}">${isInactive ? "+" : "-"}</button> <button type="button" class="related-project-toggle" data-toggle-related="${escapeHtml(item.support_dept_code)}" title="${isInactive ? "상세 반영 다시 켜기" : "상세 반영 끄기"}" aria-label="${isInactive ? "상세 반영 다시 켜기" : "상세 반영 끄기"}">${isInactive ? "+" : "-"}</button>
<button type="button" data-remove-related="${escapeHtml(item.support_dept_code)}" title="연관 프로젝트 제거" aria-label="연관 프로젝트 제거">×</button> <button type="button" class="related-project-remove" data-remove-related="${escapeHtml(item.support_dept_code)}" title="연관 프로젝트 제거" aria-label="연관 프로젝트 제거">
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M4.5 4.5l7 7"></path>
<path d="M11.5 4.5l-7 7"></path>
</svg>
</button>
</span> </span>
</span> </span>
`; `;
@@ -7365,7 +7630,7 @@
</div> </div>
<div class="analysis-kpi"> <div class="analysis-kpi">
<span>영업수지</span> <span>영업수지</span>
<strong>${formatDisplayAmount(operatingBalance)}</strong> <strong class="${differenceClass(operatingBalance)}">${formatDisplayAmount(operatingBalance)}</strong>
</div> </div>
<div class="analysis-kpi"> <div class="analysis-kpi">
<span>과업수행계획비용</span> <span>과업수행계획비용</span>
@@ -7428,7 +7693,7 @@
</td> </td>
<td>${formatComparisonCell(planned)}</td> <td>${formatComparisonCell(planned)}</td>
<td>${formatComparisonCell(actual)}</td> <td>${formatComparisonCell(actual)}</td>
<td>${formatComparisonDiff(planned, actual)}</td> <td>${key === "collection" ? formatCollectionComparisonDiff(planned, actual) : formatComparisonDiff(planned, actual)}</td>
<td> <td>
<textarea <textarea
class="comparison-note-input" class="comparison-note-input"
@@ -7929,16 +8194,6 @@
}); });
}; };
mergeSource(persistedProjectRelatedLinks); mergeSource(persistedProjectRelatedLinks);
mergeSource(persistedProjectPageState?.related_project_selections);
try {
const raw = window.localStorage.getItem(RELATED_PROJECT_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
mergeSource(parsed);
}
} catch (error) {
console.warn("Failed to load related project selections", error);
}
return merged; return merged;
} }
@@ -7946,15 +8201,24 @@
window.__projectRelatedSelections = relatedProjectSelections; window.__projectRelatedSelections = relatedProjectSelections;
} }
function persistRelatedProjectSelections() { function replaceRelatedProjectSelections(source) {
try { if (!source || typeof source !== "object") return;
const payload = Object.fromEntries( relatedProjectSelections.clear();
[...relatedProjectSelections.entries()].map(([code, values]) => [code, [...values]]), Object.entries(source).forEach(([code, values]) => {
); const normalizedCode = String(code || "").trim();
window.localStorage.setItem(RELATED_PROJECT_STORAGE_KEY, JSON.stringify(payload)); if (!normalizedCode || !Array.isArray(values)) return;
} catch (error) { const normalizedValues = values
console.warn("Failed to persist related project selections", error); .map((value) => String(value || "").trim())
.filter(Boolean);
if (normalizedValues.length) {
relatedProjectSelections.set(normalizedCode, new Set(normalizedValues));
} }
});
syncGlobalRelatedProjectSelections();
}
function persistRelatedProjectSelections() {
return;
} }
async function saveRelatedProjectSelectionsForCode(baseCode) { async function saveRelatedProjectSelectionsForCode(baseCode) {
@@ -7980,6 +8244,7 @@
if (result?.error) { if (result?.error) {
throw new Error(result.error); throw new Error(result.error);
} }
replaceRelatedProjectSelections(result.related_project_links);
bumpUncontractedDashboardRevision(); bumpUncontractedDashboardRevision();
} }
@@ -8480,6 +8745,7 @@
} catch (error) { } catch (error) {
console.error(error); console.error(error);
window.alert("연관 프로젝트를 DB에 저장하지 못했습니다."); window.alert("연관 프로젝트를 DB에 저장하지 못했습니다.");
return;
} }
bumpUncontractedDashboardRevision(); bumpUncontractedDashboardRevision();
closeRelatedProjectPicker(); closeRelatedProjectPicker();
@@ -8502,6 +8768,7 @@
} catch (error) { } catch (error) {
console.error(error); console.error(error);
window.alert("연관 프로젝트 변경을 DB에 저장하지 못했습니다."); window.alert("연관 프로젝트 변경을 DB에 저장하지 못했습니다.");
return;
} }
bumpUncontractedDashboardRevision(); bumpUncontractedDashboardRevision();
renderAnalysis(getAnalysisItem(selectedCode)); renderAnalysis(getAnalysisItem(selectedCode));
@@ -8683,9 +8950,21 @@
scheduleStandaloneUncontractedDashboardRender(); scheduleStandaloneUncontractedDashboardRender();
return; return;
} }
const baseCode = String(item.support_dept_code || "").trim();
const allRelatedItems = getRelatedItems(item.support_dept_code, { includeInactive: true }); const allRelatedItems = getRelatedItems(item.support_dept_code, { includeInactive: true });
const relatedItems = getRelatedItems(item.support_dept_code); const relatedItems = getRelatedItems(item.support_dept_code);
const aggregateItem = aggregateAnalysisItem(item, relatedItems); const includedCodeSet = new Set([
baseCode,
...relatedItems.map((relatedItem) => String(relatedItem?.support_dept_code || "").trim()).filter(Boolean),
]);
const suppressedSharedOwnerCodes = getSuppressedSharedOwnerCodes(includedCodeSet);
const normalizedBaseItem = normalizeAnalysisItemForSelection(item, includedCodeSet);
const normalizedRelatedItems = relatedItems.map((relatedItem) => {
return normalizeAnalysisItemForSelection(relatedItem, includedCodeSet);
});
const aggregateItem = aggregateAnalysisItem(normalizedBaseItem, normalizedRelatedItems, {
suppressedSharedOwnerCodes,
});
relatedBarBox.innerHTML = renderRelatedProjectBar(item, allRelatedItems); relatedBarBox.innerHTML = renderRelatedProjectBar(item, allRelatedItems);
const detailNoteInput = relatedBarBox.querySelector("[data-analysis-detail-note]"); const detailNoteInput = relatedBarBox.querySelector("[data-analysis-detail-note]");
if (detailNoteInput) { if (detailNoteInput) {
@@ -8726,7 +9005,11 @@
}); });
}); });
relatedBarBox.querySelectorAll("[data-remove-related]").forEach((button) => { relatedBarBox.querySelectorAll("[data-remove-related]").forEach((button) => {
button.addEventListener("click", () => removeRelatedProject(button.dataset.removeRelated || "")); button.addEventListener("click", async (event) => {
event.preventDefault();
event.stopPropagation();
await removeRelatedProject(button.dataset.removeRelated || "");
});
}); });
relatedBarBox.querySelectorAll("[data-open-related-project]").forEach((button) => { relatedBarBox.querySelectorAll("[data-open-related-project]").forEach((button) => {
button.addEventListener("click", (event) => { button.addEventListener("click", (event) => {