Update project pages and add DB browser
This commit is contained in:
@@ -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";
|
||||||
|
|||||||
@@ -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
@@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
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
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user