Fix contract linkage and persist related project groups
This commit is contained in:
@@ -1,13 +1,25 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}연도별 수익 비용 정리{% endblock %}
|
||||
{% block title %}연도별 수익/비용{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
.summary-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 320px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.summary-stack {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.filter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 220px 1fr;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
@@ -15,7 +27,8 @@
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
margin-top: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
@@ -43,7 +56,7 @@
|
||||
radial-gradient(circle at top left, rgba(17, 17, 17, 0.045), transparent 36%);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
padding: 14px;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.92);
|
||||
}
|
||||
|
||||
@@ -51,12 +64,6 @@
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.chart-svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
@@ -78,11 +85,47 @@
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chart-stack {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.summary-stack .panel {
|
||||
padding: 14px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.summary-stack .section-title h2,
|
||||
.chart-stack .section-title h2 {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.summary-stack .field select {
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.summary-stack .stat-card {
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.summary-stack .stat-card .label {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.summary-stack .stat-card .value {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.summary-layout,
|
||||
.filter-grid,
|
||||
.metric-grid {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -92,9 +135,12 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="panel">
|
||||
<div class="summary-layout">
|
||||
<div class="summary-stack">
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<h2>연도별 수익 비용 정리</h2>
|
||||
<h2>수익/비용 현황</h2>
|
||||
</div>
|
||||
<div class="filter-grid">
|
||||
<div class="field">
|
||||
@@ -111,17 +157,11 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<h2>선택 구간 요약</h2>
|
||||
</div>
|
||||
<div class="metric-grid" id="metricGrid"></div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
<div class="chart-stack">
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<h2>비용 구조</h2>
|
||||
@@ -131,7 +171,6 @@
|
||||
<svg id="expenseChart" class="chart-svg expense-chart-svg" viewBox="0 0 1600 420" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<h2>수금/비용/영업수지 그래프</h2>
|
||||
@@ -141,6 +180,9 @@
|
||||
<svg id="balanceChart" class="chart-svg" viewBox="0 0 1200 420" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
@@ -293,7 +335,9 @@
|
||||
}
|
||||
|
||||
function buildSignedAxis(maxPositiveValue, minNegativeValue, width, height, margin) {
|
||||
const rangeMax = Math.max(Math.abs(maxPositiveValue || 0), Math.abs(minNegativeValue || 0), 1);
|
||||
const forcedNegativeFloor = -20000000000;
|
||||
const normalizedNegative = Math.min(Number(minNegativeValue || 0), forcedNegativeFloor);
|
||||
const rangeMax = Math.max(Math.abs(maxPositiveValue || 0), Math.abs(normalizedNegative || 0), 1);
|
||||
const { tickStep, tickMax } = buildPositiveAxisScale(rangeMax, 4);
|
||||
const plotHeight = height - margin.top - margin.bottom;
|
||||
const zeroY = margin.top + (plotHeight * tickMax) / (tickMax * 2);
|
||||
|
||||
+2
-1
@@ -477,7 +477,7 @@
|
||||
<nav class="nav">
|
||||
<a href="/" class="{% if request.url.path == '/' %}active{% endif %}">대시보드</a>
|
||||
<a href="/projects" class="{% if request.url.path == '/projects' %}active{% endif %}">프로젝트 정보</a>
|
||||
<a href="/annual-summary" class="{% if request.url.path == '/annual-summary' %}active{% endif %}">연도별 수익 비용 정리</a>
|
||||
<a href="/annual-summary" class="{% if request.url.path == '/annual-summary' %}active{% endif %}">연도별 수익/비용</a>
|
||||
<div class="nav-spacer"></div>
|
||||
<aside
|
||||
class="sync-status"
|
||||
@@ -496,6 +496,7 @@
|
||||
<div>마지막 확인: <strong id="syncLastChecked">-</strong></div>
|
||||
<div>서버 시간: <strong id="syncServerTime">{{ server_time or '-' }}</strong></div>
|
||||
<div>데이터 버전: <strong id="syncDataVersion">{{ data_version or '-' }}</strong></div>
|
||||
<div>계약/청구 동기화: <strong>{{ import_sync_summary.contract_project_count or 0 }}</strong> / <strong>{{ import_sync_summary.billing_project_count or 0 }}</strong></div>
|
||||
</div>
|
||||
</aside>
|
||||
</nav>
|
||||
|
||||
@@ -228,6 +228,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-shell" style="margin-bottom: 16px;">
|
||||
<div class="legend-box" style="justify-content:flex-start;">
|
||||
<span class="legend-item"><span class="legend-swatch" style="background:#111827;"></span>계약 프로젝트 {{ import_sync_summary.contract_project_count or 0 }}건</span>
|
||||
<span class="legend-item"><span class="legend-swatch" style="background:#0f766e;"></span>청구 프로젝트 {{ import_sync_summary.billing_project_count or 0 }}건</span>
|
||||
<span class="legend-item"><span class="legend-swatch" style="background:#f59e0b;"></span>변경계약 검토 {{ import_sync_summary.review_needed_count or 0 }}건</span>
|
||||
<span class="legend-item"><span class="legend-swatch" style="background:#1d4ed8;"></span>한맥 계약합계 {{ "{:,.0f}".format(import_sync_summary.total_hanmac_contract_amount or 0) }}</span>
|
||||
<span class="legend-item"><span class="legend-swatch" style="background:#7c3aed;"></span>청구 수금합계 {{ "{:,.0f}".format(import_sync_summary.total_collected_amount or 0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-layout">
|
||||
<section class="dashboard-status-panel">
|
||||
<div class="dashboard-status-grid">
|
||||
|
||||
@@ -431,6 +431,35 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<h2>계약·청구 동기화 현황</h2>
|
||||
<p>계약현황 / 기성청구현황 파일 기준</p>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<div class="label">계약 프로젝트</div>
|
||||
<div class="value">{{ import_sync_summary.contract_project_count or 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">청구 프로젝트</div>
|
||||
<div class="value">{{ import_sync_summary.billing_project_count or 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">청구 행 수</div>
|
||||
<div class="value">{{ import_sync_summary.billing_entry_count or 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">변경계약 검토</div>
|
||||
<div class="value">{{ import_sync_summary.review_needed_count or 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">청구 수금합계</div>
|
||||
<div class="value">{{ "{:,.0f}".format(import_sync_summary.total_collected_amount or 0) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="two-col">
|
||||
<div class="stack">
|
||||
<section class="panel">
|
||||
|
||||
+633
-71
@@ -213,7 +213,7 @@
|
||||
|
||||
.project-search-controls {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 170px auto auto;
|
||||
grid-template-columns: minmax(0, 1fr) 170px 210px;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
}
|
||||
@@ -320,39 +320,20 @@
|
||||
|
||||
.analysis-hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
gap: 18px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.analysis-summary-card {
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.99), rgba(245,248,251,0.98));
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
padding: 18px;
|
||||
.analysis-hero-header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.analysis-title-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.analysis-title-block {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.analysis-title {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -369,19 +350,26 @@
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.analysis-meta-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.analysis-stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.analysis-stat {
|
||||
border: 1px solid #dfe6ee;
|
||||
border-radius: 14px;
|
||||
background: rgba(255,255,255,0.94);
|
||||
padding: 12px 14px;
|
||||
padding: 14px 16px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.analysis-stat span {
|
||||
@@ -641,15 +629,17 @@
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.analysis-kpi {
|
||||
border: 1px solid #dfe6ee;
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
padding: 12px 10px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
background: #fbfcfd;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.analysis-kpi span {
|
||||
@@ -658,8 +648,9 @@
|
||||
}
|
||||
|
||||
.analysis-kpi strong {
|
||||
font-size: 18px;
|
||||
font-size: 17px;
|
||||
line-height: 1.2;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.analysis-table {
|
||||
@@ -696,10 +687,140 @@
|
||||
|
||||
.analysis-note-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.analysis-note-grid .detail-block h4 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.analysis-note-grid .detail-block {
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.contract-state-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 24px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.contract-state-badge.is-contracted {
|
||||
color: #155e75;
|
||||
background: #ecfeff;
|
||||
border: 1px solid #b6eff7;
|
||||
}
|
||||
|
||||
.contract-state-badge.is-uncontracted {
|
||||
color: #7c2d12;
|
||||
background: #fff7ed;
|
||||
border: 1px solid #fed7aa;
|
||||
}
|
||||
|
||||
.contract-state-badge.is-uncontracted-cost {
|
||||
color: #991b1b;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.uncontracted-dashboard {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
#projectUncontractedDashboard {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.uncontracted-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 180px) 1fr;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.uncontracted-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.uncontracted-stat {
|
||||
border: 1px solid #dfe6ee;
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
background: #fbfcfd;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.uncontracted-stat span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.uncontracted-stat strong {
|
||||
font-size: 20px;
|
||||
line-height: 1.2;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.uncontracted-dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.uncontracted-dashboard-grid .detail-block {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.compact-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.compact-table th,
|
||||
.compact-table td {
|
||||
padding: 9px 10px;
|
||||
border-bottom: 1px solid #e7edf2;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.compact-table th {
|
||||
font-size: 11px;
|
||||
color: #547085;
|
||||
background: #f5f8fb;
|
||||
}
|
||||
|
||||
.compact-table td:last-child,
|
||||
.compact-table th:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.compact-table .interactive-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.compact-table .interactive-row:hover {
|
||||
background: #f8fbfe;
|
||||
}
|
||||
|
||||
.compact-table .interactive-row.is-active {
|
||||
background: #eef6fb;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 1fr) auto;
|
||||
@@ -971,6 +1092,27 @@
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.import-review-banner {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: #fff7e7;
|
||||
border: 1px solid #f2d188;
|
||||
color: #7b5708;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.import-review-banner.is-visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.import-review-banner strong {
|
||||
color: #5f4102;
|
||||
}
|
||||
|
||||
.section-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
@@ -1540,7 +1682,6 @@
|
||||
.overview-row,
|
||||
.analysis-toolbar,
|
||||
.project-search-controls,
|
||||
.analysis-hero,
|
||||
.analysis-kpis,
|
||||
.analysis-note-grid,
|
||||
.comparison-detail-grid,
|
||||
@@ -1558,6 +1699,14 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.analysis-hero-header,
|
||||
.analysis-stat-grid,
|
||||
.uncontracted-summary-grid,
|
||||
.uncontracted-dashboard-grid,
|
||||
.uncontracted-toolbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -1618,12 +1767,20 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<select id="projectContractFilter" aria-label="계약 상태 필터">
|
||||
<option value="">전체 상태</option>
|
||||
<option value="contracted">계약 진행</option>
|
||||
<option value="uncontracted">미계약</option>
|
||||
<option value="uncontracted_cost">미계약 비용 발생</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="analysis-content is-hidden" id="projectAnalysisContent">
|
||||
<section class="analysis-panel analysis-related-bar" id="projectRelatedBar"></section>
|
||||
<section class="analysis-summary-card" id="projectAnalysisHero"></section>
|
||||
<section class="analysis-panel" id="projectAnalysisHero"></section>
|
||||
<section class="analysis-panel" id="projectAnalysisMetrics"></section>
|
||||
<section class="analysis-panel" id="projectAnalysisComparison"></section>
|
||||
<section class="analysis-panel" id="projectAnalysisNotes"></section>
|
||||
@@ -1632,6 +1789,8 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="projectUncontractedDashboard"></section>
|
||||
|
||||
<div class="modal-backdrop {% if project_edit.support_dept_code %}open{% endif %}" id="projectModal">
|
||||
<div class="modal-card">
|
||||
<form action="/projects/save" method="post" class="modal-form" id="projectStatusForm" data-collab-form>
|
||||
@@ -1663,6 +1822,10 @@
|
||||
<h3>기본 정보</h3>
|
||||
<div class="section-help">진행율은 계약금액 대비 수금액 합계로 자동 계산됩니다.</div>
|
||||
</div>
|
||||
<div id="projectImportReviewBanner" class="import-review-banner{% if project_edit.review_tag %} is-visible{% endif %}">
|
||||
<strong id="projectImportReviewTag">{{ project_edit.review_tag }}</strong>
|
||||
<span id="projectImportReviewNote">{{ project_edit.review_note }}</span>
|
||||
</div>
|
||||
<div class="section-grid">
|
||||
<div class="field field-full">
|
||||
<div class="lookup-grid">
|
||||
@@ -2242,8 +2405,10 @@
|
||||
const revenueMix = {{ project_revenue_mix | tojson }};
|
||||
const revenueMixMode = {{ ("monthly" if selected_year else "yearly") | tojson }};
|
||||
const projectCostRows = {{ project_cost_by_year | tojson }};
|
||||
const projectMonthlyCostRows = {{ project_monthly_cost_rows | tojson }};
|
||||
const projectStatusRows = {{ project_status_rows | tojson }};
|
||||
const persistedProjectPageState = {{ project_page_state | tojson }};
|
||||
const persistedProjectRelatedLinks = {{ project_related_links | tojson }};
|
||||
const projectStatusMap = Object.fromEntries(projectStatusRows.map((item) => [item.support_dept_code, item]));
|
||||
const projectAccountBreakdowns = {{ project_account_breakdowns | tojson }};
|
||||
const projectCostMap = Object.fromEntries(projectCostRows.map((item) => [item.support_dept_code, item]));
|
||||
@@ -2258,6 +2423,9 @@
|
||||
const projectStartDateInput = document.getElementById("project_start_date");
|
||||
const projectEndDateInput = document.getElementById("project_end_date");
|
||||
const notesInput = document.getElementById("notes");
|
||||
const projectImportReviewBanner = document.getElementById("projectImportReviewBanner");
|
||||
const projectImportReviewTag = document.getElementById("projectImportReviewTag");
|
||||
const projectImportReviewNote = document.getElementById("projectImportReviewNote");
|
||||
const supportDeptCodeResults = document.getElementById("supportDeptCodeResults");
|
||||
const supportDeptNameResults = document.getElementById("supportDeptNameResults");
|
||||
const contractAmountInput = document.getElementById("contract_amount");
|
||||
@@ -3150,6 +3318,21 @@
|
||||
project_end_date: "",
|
||||
completion_status: "",
|
||||
notes: "",
|
||||
client_name: "",
|
||||
order_method: "",
|
||||
joint_contract: "",
|
||||
pm_name: "",
|
||||
contract_status: "",
|
||||
progress_status: "",
|
||||
work_category: "",
|
||||
review_tag: "",
|
||||
review_note: "",
|
||||
total_contract_amount: 0,
|
||||
hanmac_contract_amount: 0,
|
||||
billing_contract_amount: 0,
|
||||
billed_amount: 0,
|
||||
collection_balance_amount: 0,
|
||||
latest_billing_date: "",
|
||||
updated_at: "",
|
||||
};
|
||||
}
|
||||
@@ -3207,6 +3390,12 @@
|
||||
projectStartDateInput.value = normalized.project_start_date || "";
|
||||
projectEndDateInput.value = normalized.project_end_date || "";
|
||||
notesInput.value = normalized.notes || "";
|
||||
if (projectImportReviewBanner && projectImportReviewTag && projectImportReviewNote) {
|
||||
const hasReview = Boolean(normalized.review_tag || normalized.review_note);
|
||||
projectImportReviewBanner.classList.toggle("is-visible", hasReview);
|
||||
projectImportReviewTag.textContent = normalized.review_tag || "";
|
||||
projectImportReviewNote.textContent = normalized.review_note || "";
|
||||
}
|
||||
expectedAsRateInput.value = String(normalized.expected_as_rate || "0");
|
||||
expectedSgaRateInput.value = String(normalized.expected_sga_rate || "13");
|
||||
if (editRevisionInput) {
|
||||
@@ -3488,9 +3677,12 @@
|
||||
if (!normalized) {
|
||||
return baseDataset;
|
||||
}
|
||||
return baseDataset.filter((item) =>
|
||||
`${item.support_dept_code} ${item.support_dept_name}`.toLowerCase().includes(normalized)
|
||||
);
|
||||
return baseDataset.filter((item) => String(item._searchText || "").includes(normalized));
|
||||
}
|
||||
|
||||
function filterProjectCostDatasetByContractState(dataset, contractFilterValue) {
|
||||
if (!contractFilterValue) return dataset;
|
||||
return dataset.filter((item) => getProjectContractState(getAnalysisItem(item.support_dept_code) || item) === contractFilterValue);
|
||||
}
|
||||
|
||||
function getAnalysisItem(code) {
|
||||
@@ -3519,6 +3711,38 @@
|
||||
};
|
||||
}
|
||||
|
||||
function getProjectContractState(item) {
|
||||
const contractAmount = Number(item?.contract_amount || projectStatusMap[item?.support_dept_code || ""]?.contract_amount || 0);
|
||||
const expenseAmount = Number(item?.expense_amount || item?.total_expense || 0);
|
||||
const collectionAmount = Number(
|
||||
item?.collection_amount
|
||||
|| item?.total_revenue
|
||||
|| projectStatusMap[item?.support_dept_code || ""]?.collection_amount
|
||||
|| 0,
|
||||
);
|
||||
if (collectionAmount > 0) {
|
||||
return "contracted";
|
||||
}
|
||||
if (contractAmount > 0) {
|
||||
return "contracted";
|
||||
}
|
||||
if (expenseAmount > 0) {
|
||||
return "uncontracted_cost";
|
||||
}
|
||||
return "uncontracted";
|
||||
}
|
||||
|
||||
function getProjectContractStateLabel(state) {
|
||||
if (state === "contracted") return "계약 진행";
|
||||
if (state === "uncontracted_cost") return "미계약 비용 발생";
|
||||
return "미계약";
|
||||
}
|
||||
|
||||
function getProjectContractStateBadge(item) {
|
||||
const state = getProjectContractState(item);
|
||||
return `<span class="contract-state-badge is-${state}">${getProjectContractStateLabel(state)}</span>`;
|
||||
}
|
||||
|
||||
function formatSignedAmount(value) {
|
||||
const numeric = Number(value || 0);
|
||||
const prefix = numeric > 0 ? "+" : "";
|
||||
@@ -3835,12 +4059,26 @@
|
||||
const editUrl = getProjectEditUrl(item.support_dept_code);
|
||||
return `
|
||||
<div class="analysis-hero">
|
||||
<div class="analysis-title-row">
|
||||
<div class="analysis-title-block">
|
||||
<div class="analysis-hero-header">
|
||||
<div class="analysis-title">
|
||||
<h3>${item.support_dept_name}</h3>
|
||||
<p>${item.support_dept_code} · ${item.year_range} · 최신 반영 ${detailValue(item.latest_year, "-")}년 ${detailValue(item.latest_month, "-")}월</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a class="button-link button-secondary button-icon" href="${editUrl}" data-edit-code="${item.support_dept_code}" title="수정" aria-label="수정">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4 20h4l10-10-4-4L4 16v4z"></path>
|
||||
<path d="M13 7l4 4"></path>
|
||||
</svg>
|
||||
<span class="sr-only">수정</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="analysis-stat-grid">
|
||||
<div class="analysis-stat">
|
||||
<span>사업종류</span>
|
||||
<strong>${detailValue(item.project_type)}</strong>
|
||||
</div>
|
||||
<div class="analysis-stat">
|
||||
<span>진행율</span>
|
||||
<strong>${Number(item.progress_rate || 0).toFixed(2)}%</strong>
|
||||
@@ -3859,17 +4097,219 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function formatMonthLabel(month) {
|
||||
return `${Number(month || 0)}월`;
|
||||
}
|
||||
|
||||
let uncontractedDetailSelection = { type: "", year: null, month: null };
|
||||
let uncontractedDetailVisible = false;
|
||||
|
||||
function getUncontractedYearRange() {
|
||||
const years = [...new Set(projectCostRows.map((item) => Number(item.year || 0)).filter(Boolean))].sort((a, b) => a - b);
|
||||
return {
|
||||
allYears: years,
|
||||
startYear: Number(document.getElementById("uncontractedYearStart")?.value || years[0] || 0),
|
||||
endYear: Number(document.getElementById("uncontractedYearEnd")?.value || years[years.length - 1] || 0),
|
||||
};
|
||||
}
|
||||
|
||||
function buildUncontractedDashboardData() {
|
||||
const { startYear, endYear } = getUncontractedYearRange();
|
||||
const filteredRows = projectCostRows.filter((row) => {
|
||||
const year = Number(row.year || 0);
|
||||
return year >= startYear && year <= endYear;
|
||||
});
|
||||
const filteredAggregate = aggregateProjectCostRows(filteredRows).filter((item) => {
|
||||
const merged = getAnalysisItem(item.support_dept_code) || item;
|
||||
return getProjectContractState(merged) !== "contracted";
|
||||
});
|
||||
const summary = {
|
||||
uncontracted_projects: filteredAggregate.length,
|
||||
cost_incurred_projects: filteredAggregate.filter((item) => Number(item.expense_amount || 0) > 0).length,
|
||||
expense_amount: filteredAggregate.reduce((sum, item) => sum + Number(item.expense_amount || 0), 0),
|
||||
revenue_amount: filteredAggregate.reduce((sum, item) => sum + Number(item.revenue_amount || 0), 0),
|
||||
review_needed_projects: filteredAggregate.filter((item) => Boolean(projectStatusMap[item.support_dept_code]?.review_tag)).length,
|
||||
};
|
||||
|
||||
const yearlyMap = new Map();
|
||||
filteredRows.forEach((row) => {
|
||||
const merged = getAnalysisItem(row.support_dept_code) || row;
|
||||
if (getProjectContractState(merged) === "contracted") return;
|
||||
const year = Number(row.year || 0);
|
||||
const current = yearlyMap.get(year) || {
|
||||
year,
|
||||
uncontracted_projects: new Set(),
|
||||
cost_incurred_projects: new Set(),
|
||||
expense_amount: 0,
|
||||
};
|
||||
current.uncontracted_projects.add(row.support_dept_code);
|
||||
if (Number(row.expense_amount || 0) > 0) current.cost_incurred_projects.add(row.support_dept_code);
|
||||
current.expense_amount += Number(row.expense_amount || 0);
|
||||
yearlyMap.set(year, current);
|
||||
});
|
||||
const yearlyRows = [...yearlyMap.values()].sort((a, b) => a.year - b.year).map((row) => ({
|
||||
year: row.year,
|
||||
uncontracted_projects: row.uncontracted_projects.size,
|
||||
cost_incurred_projects: row.cost_incurred_projects.size,
|
||||
expense_amount: row.expense_amount,
|
||||
}));
|
||||
|
||||
const focusYear = Number(uncontractedDetailSelection.year || yearlyRows[yearlyRows.length - 1]?.year || endYear || 0);
|
||||
const monthlyMap = new Map();
|
||||
(projectMonthlyCostRows || []).forEach((row) => {
|
||||
const year = Number(row.year || 0);
|
||||
if (year !== focusYear) return;
|
||||
const merged = getAnalysisItem(row.support_dept_code) || row;
|
||||
if (getProjectContractState(merged) === "contracted") return;
|
||||
const month = Number(row.month || 0);
|
||||
const current = monthlyMap.get(month) || {
|
||||
month,
|
||||
cost_incurred_projects: new Set(),
|
||||
expense_amount: 0,
|
||||
};
|
||||
const expenseAmount = Number(row.cost_sum || 0) + Number(row.sga_sum || 0);
|
||||
if (expenseAmount > 0) current.cost_incurred_projects.add(row.support_dept_code);
|
||||
current.expense_amount += expenseAmount;
|
||||
monthlyMap.set(month, current);
|
||||
});
|
||||
const monthlyRows = [...monthlyMap.values()].sort((a, b) => a.month - b.month).map((row) => ({
|
||||
month: row.month,
|
||||
cost_incurred_projects: row.cost_incurred_projects.size,
|
||||
expense_amount: row.expense_amount,
|
||||
}));
|
||||
|
||||
let detailRows = [];
|
||||
const detailTitle = "세부 내역";
|
||||
if (uncontractedDetailSelection.type === "month" && uncontractedDetailSelection.year && uncontractedDetailSelection.month) {
|
||||
const map = new Map();
|
||||
(projectMonthlyCostRows || []).forEach((row) => {
|
||||
if (Number(row.year || 0) !== Number(uncontractedDetailSelection.year)) return;
|
||||
if (Number(row.month || 0) !== Number(uncontractedDetailSelection.month)) return;
|
||||
const merged = getAnalysisItem(row.support_dept_code) || row;
|
||||
if (getProjectContractState(merged) === "contracted") return;
|
||||
const current = map.get(row.support_dept_code) || {
|
||||
support_dept_code: row.support_dept_code,
|
||||
support_dept_name: row.support_dept_name,
|
||||
expense_amount: 0,
|
||||
};
|
||||
current.expense_amount += Number(row.cost_sum || 0) + Number(row.sga_sum || 0);
|
||||
map.set(row.support_dept_code, current);
|
||||
});
|
||||
detailRows = [...map.values()].sort((a, b) => Number(b.expense_amount || 0) - Number(a.expense_amount || 0));
|
||||
} else if (uncontractedDetailSelection.type === "year" && uncontractedDetailSelection.year) {
|
||||
const map = new Map();
|
||||
filteredRows.forEach((row) => {
|
||||
if (Number(row.year || 0) !== Number(uncontractedDetailSelection.year)) return;
|
||||
const merged = getAnalysisItem(row.support_dept_code) || row;
|
||||
if (getProjectContractState(merged) === "contracted") return;
|
||||
const current = map.get(row.support_dept_code) || {
|
||||
support_dept_code: row.support_dept_code,
|
||||
support_dept_name: row.support_dept_name,
|
||||
expense_amount: 0,
|
||||
revenue_amount: 0,
|
||||
};
|
||||
current.expense_amount += Number(row.expense_amount || 0);
|
||||
current.revenue_amount += Number(row.revenue_amount || 0);
|
||||
map.set(row.support_dept_code, current);
|
||||
});
|
||||
detailRows = [...map.values()].sort((a, b) => Number(b.expense_amount || 0) - Number(a.expense_amount || 0));
|
||||
}
|
||||
|
||||
return { startYear, endYear, summary, yearlyRows, monthlyRows, focusYear, detailRows, detailTitle };
|
||||
}
|
||||
|
||||
function renderUncontractedDashboard() {
|
||||
const data = buildUncontractedDashboardData();
|
||||
const allYears = getUncontractedYearRange().allYears;
|
||||
const startOptions = allYears.map((year) => `<option value="${year}"${Number(year) === Number(data.startYear) ? " selected" : ""}>${year}</option>`).join("");
|
||||
const endOptions = allYears.map((year) => `<option value="${year}"${Number(year) === Number(data.endYear) ? " selected" : ""}>${year}</option>`).join("");
|
||||
return `
|
||||
<div class="uncontracted-dashboard">
|
||||
<div class="toolbar-head">
|
||||
<h3>미계약 비용 발생 현황</h3>
|
||||
<div class="uncontracted-toolbar">
|
||||
<div class="field">
|
||||
<select id="uncontractedYearStart" aria-label="미계약 시작 연도">${startOptions}</select>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a class="button-link button-secondary button-icon" href="${editUrl}" data-edit-code="${item.support_dept_code}" title="수정" aria-label="수정">
|
||||
<div class="field">
|
||||
<select id="uncontractedYearEnd" aria-label="미계약 종료 연도">${endOptions}</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="uncontracted-summary-grid">
|
||||
<div class="uncontracted-stat"><span>미계약 건수</span><strong>${formatNumber(data.summary.uncontracted_projects || 0)}</strong></div>
|
||||
<div class="uncontracted-stat"><span>비용 발생 건수</span><strong>${formatNumber(data.summary.cost_incurred_projects || 0)}</strong></div>
|
||||
<div class="uncontracted-stat"><span>발생 비용</span><strong>${formatDisplayAmount(data.summary.expense_amount || 0)}</strong></div>
|
||||
<div class="uncontracted-stat"><span>관련 수금</span><strong>${formatDisplayAmount(data.summary.revenue_amount || 0)}</strong></div>
|
||||
<div class="uncontracted-stat"><span>검토 필요</span><strong>${formatNumber(data.summary.review_needed_projects || 0)}</strong></div>
|
||||
</div>
|
||||
<div class="uncontracted-dashboard-grid">
|
||||
<div class="detail-block">
|
||||
<h4>연도별 현황</h4>
|
||||
<table class="compact-table">
|
||||
<thead><tr><th>연도</th><th>미계약</th><th>비용 발생</th><th>발생 비용</th></tr></thead>
|
||||
<tbody>
|
||||
${(data.yearlyRows || []).length ? data.yearlyRows.map((row) => `
|
||||
<tr class="interactive-row ${uncontractedDetailSelection.type === "year" && Number(uncontractedDetailSelection.year) === Number(row.year) ? "is-active" : ""}" data-uncontracted-year="${row.year}">
|
||||
<td>${detailValue(row.year, "-")}</td>
|
||||
<td>${formatNumber(row.uncontracted_projects || 0)}</td>
|
||||
<td>${formatNumber(row.cost_incurred_projects || 0)}</td>
|
||||
<td>${formatNumber(row.expense_amount || 0)}</td>
|
||||
</tr>
|
||||
`).join("") : `<tr><td colspan="4">표시할 연도별 데이터가 없습니다.</td></tr>`}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="detail-block">
|
||||
<h4>${data.focusYear ? `${data.focusYear}년 월별 비용` : "월별 비용"}</h4>
|
||||
<table class="compact-table">
|
||||
<thead><tr><th>월</th><th>비용 발생</th><th>발생 비용</th></tr></thead>
|
||||
<tbody>
|
||||
${(data.monthlyRows || []).length ? data.monthlyRows.map((row) => `
|
||||
<tr class="interactive-row ${uncontractedDetailSelection.type === "month" && Number(uncontractedDetailSelection.year) === Number(data.focusYear) && Number(uncontractedDetailSelection.month) === Number(row.month) ? "is-active" : ""}" data-uncontracted-month="${row.month}" data-uncontracted-month-year="${data.focusYear}">
|
||||
<td>${formatMonthLabel(row.month)}</td>
|
||||
<td>${formatNumber(row.cost_incurred_projects || 0)}</td>
|
||||
<td>${formatNumber(row.expense_amount || 0)}</td>
|
||||
</tr>
|
||||
`).join("") : `<tr><td colspan="3">표시할 월별 데이터가 없습니다.</td></tr>`}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar-head">
|
||||
<h4>세부 내역</h4>
|
||||
<button type="button" class="button-icon button-secondary" id="toggleUncontractedDetail" title="세부 내역 열기/숨기기" aria-label="세부 내역 열기/숨기기">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4 20h4l10-10-4-4L4 16v4z"></path>
|
||||
<path d="M13 7l4 4"></path>
|
||||
<path d="${uncontractedDetailVisible ? "M7 7l10 10 M17 7L7 17" : "M6 9l6 6 6-6"}"></path>
|
||||
</svg>
|
||||
<span class="sr-only">수정</span>
|
||||
</a>
|
||||
<span class="sr-only">세부 내역 열기/숨기기</span>
|
||||
</button>
|
||||
</div>
|
||||
${uncontractedDetailVisible && uncontractedDetailSelection.type ? `
|
||||
<div class="detail-block">
|
||||
<table class="compact-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>프로젝트</th>
|
||||
<th>발생 비용</th>
|
||||
${uncontractedDetailSelection.type === "month" ? "" : "<th>관련 수금</th>"}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${(data.detailRows || []).length ? data.detailRows.map((row) => `
|
||||
<tr class="interactive-row" data-open-project-code="${escapeHtml(row.support_dept_code)}">
|
||||
<td>${escapeHtml(row.support_dept_code)} · ${escapeHtml(row.support_dept_name)}</td>
|
||||
<td>${formatNumber(row.expense_amount || 0)}</td>
|
||||
${uncontractedDetailSelection.type === "month" ? "" : `<td>${formatNumber(row.revenue_amount || 0)}</td>`}
|
||||
</tr>
|
||||
`).join("") : `<tr><td colspan="${uncontractedDetailSelection.type === "month" ? 2 : 3}">표시할 세부 내역이 없습니다.</td></tr>`}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
` : ""}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -3894,10 +4334,15 @@
|
||||
function renderAnalysisMetrics(item) {
|
||||
const details = buildComparisonDetails(item);
|
||||
const actualExecutionCost =
|
||||
Number(item.total_expense || 0)
|
||||
|| (
|
||||
sumEntryAmounts(details.labor.actual)
|
||||
+ sumEntryAmounts(details.labor.actual_joint)
|
||||
+ sumEntryAmounts(details.outsource.actual)
|
||||
+ sumEntryAmounts(details.overhead.actual);
|
||||
+ sumEntryAmounts(details.overhead.actual)
|
||||
+ sumEntryAmounts(details.as.actual)
|
||||
+ sumEntryAmounts(details.sga.actual)
|
||||
);
|
||||
return `
|
||||
<h4>핵심 비교 지표</h4>
|
||||
<div class="analysis-kpis">
|
||||
@@ -4005,20 +4450,15 @@
|
||||
<h4>프로젝트 세부 입력 정보</h4>
|
||||
<div class="analysis-note-grid">
|
||||
<div class="detail-block">
|
||||
<h4>기본 정보</h4>
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item"><span>사업 종류</span><strong>${detailValue(item.project_type)}</strong></div>
|
||||
<div class="detail-item"><span>차수변경</span><strong>${detailValue(item.change_round)}</strong></div>
|
||||
<div class="detail-item"><span>예상 A/S비</span><strong>${formatNumber(item.expected_as_cost || 0)}</strong></div>
|
||||
<div class="detail-item"><span>예상 판관비</span><strong>${formatNumber(item.expected_sga_budget || 0)}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-block">
|
||||
<h4>메모 및 실행 참고</h4>
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item"><span>실집행 인건비</span><strong>${formatNumber((sumEntryAmounts(getActualGroupEntries(item, "labor", "실투입 인건비")) + sumEntryAmounts(getActualGroupEntries(item, "labor_joint", "실투입 인건비(합사)"))) || 0)}</strong></div>
|
||||
<div class="detail-item"><span>실집행 외주비</span><strong>${formatNumber(item.actual_outsource || 0)}</strong></div>
|
||||
<div class="detail-item"><span>항목별투입액</span><strong>${formatNumber(item.item_investment || 0)}</strong></div>
|
||||
<div class="detail-item"><span>청구합계</span><strong>${formatNumber(item.billed_amount || 0)}</strong></div>
|
||||
<div class="detail-item"><span>수금잔액</span><strong>${formatNumber(item.collection_balance_amount || 0)}</strong></div>
|
||||
<div class="detail-item"><span>비고</span><strong>${detailValue(item.notes)}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4125,7 +4565,10 @@
|
||||
}
|
||||
|
||||
const aggregatedProjectCostDatasetCache = new Map();
|
||||
const aggregatedAllProjectCostRows = aggregateProjectCostRows(projectCostRows);
|
||||
const aggregatedAllProjectCostRows = aggregateProjectCostRows(projectCostRows).map((item) => ({
|
||||
...item,
|
||||
_searchText: `${item.support_dept_code || ""} ${item.support_dept_name || ""}`.toLowerCase(),
|
||||
}));
|
||||
const aggregatedAllProjectCostMap = new Map(
|
||||
aggregatedAllProjectCostRows.map((item) => [item.support_dept_code, item]),
|
||||
);
|
||||
@@ -4139,7 +4582,10 @@
|
||||
? aggregatedAllProjectCostRows
|
||||
: aggregateProjectCostRows(
|
||||
projectCostRows.filter((item) => String(item.year) === String(yearFilterValue)),
|
||||
);
|
||||
).map((item) => ({
|
||||
...item,
|
||||
_searchText: `${item.support_dept_code || ""} ${item.support_dept_name || ""}`.toLowerCase(),
|
||||
}));
|
||||
aggregatedProjectCostDatasetCache.set(cacheKey, dataset);
|
||||
return dataset;
|
||||
}
|
||||
@@ -4334,6 +4780,7 @@
|
||||
(() => {
|
||||
const input = document.getElementById("projectCostSearch");
|
||||
const yearSelect = document.getElementById("projectCostYearFilter");
|
||||
const contractFilterSelect = document.getElementById("projectContractFilter");
|
||||
const dropdownBox = document.getElementById("projectSearchDropdown");
|
||||
const listBox = document.getElementById("projectExplorerList");
|
||||
const emptyBox = document.getElementById("projectExplorerEmpty");
|
||||
@@ -4346,6 +4793,7 @@
|
||||
const metricsBox = document.getElementById("projectAnalysisMetrics");
|
||||
const comparisonBox = document.getElementById("projectAnalysisComparison");
|
||||
const notesBox = document.getElementById("projectAnalysisNotes");
|
||||
const uncontractedBox = document.getElementById("projectUncontractedDashboard");
|
||||
const relatedModal = document.getElementById("relatedProjectModal");
|
||||
const relatedModalCloseButton = document.getElementById("closeRelatedProjectModal");
|
||||
const relatedSearchInput = document.getElementById("relatedProjectSearchInput");
|
||||
@@ -4357,6 +4805,14 @@
|
||||
const relatedProjectSelections = loadRelatedProjectSelections();
|
||||
|
||||
function loadRelatedProjectSelections() {
|
||||
if (persistedProjectRelatedLinks && typeof persistedProjectRelatedLinks === "object") {
|
||||
return new Map(
|
||||
Object.entries(persistedProjectRelatedLinks).map(([code, values]) => [
|
||||
code,
|
||||
new Set(Array.isArray(values) ? values.filter((value) => typeof value === "string" && value.trim()) : []),
|
||||
]),
|
||||
);
|
||||
}
|
||||
const serverState = persistedProjectPageState?.related_project_selections;
|
||||
if (serverState && typeof serverState === "object") {
|
||||
return new Map(
|
||||
@@ -4394,6 +4850,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRelatedProjectSelectionsForCode(baseCode) {
|
||||
const normalizedBaseCode = String(baseCode || "").trim();
|
||||
if (!normalizedBaseCode) return;
|
||||
const relatedCodes = [...(relatedProjectSelections.get(normalizedBaseCode) || new Set())];
|
||||
const response = await fetch("/projects/related-links", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
base_code: normalizedBaseCode,
|
||||
related_codes: relatedCodes,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
const result = await response.json();
|
||||
if (result?.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProjectPageStateToServer(showFeedback = false) {
|
||||
try {
|
||||
const payload = {
|
||||
@@ -4460,6 +4941,7 @@
|
||||
<div class="explorer-title">
|
||||
<strong>${item.support_dept_name}</strong>
|
||||
<span>${item.support_dept_code} · ${formatYearRange(item)}</span>
|
||||
${getProjectContractStateBadge(getAnalysisItem(item.support_dept_code) || item)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="explorer-tags">
|
||||
@@ -4469,18 +4951,6 @@
|
||||
</div>
|
||||
</article>
|
||||
`).join("");
|
||||
|
||||
listBox.querySelectorAll(".explorer-item").forEach((itemEl) => {
|
||||
itemEl.addEventListener("click", () => {
|
||||
selectedCode = itemEl.dataset.code || "";
|
||||
currentSelectedProjectCode = selectedCode;
|
||||
input.value = itemEl.querySelector("strong")?.textContent || input.value;
|
||||
dropdownBox?.classList.remove("open");
|
||||
setAnalysisVisibility(true);
|
||||
renderSuggestionList(dataset, "");
|
||||
renderAnalysis(getAnalysisItem(selectedCode));
|
||||
});
|
||||
});
|
||||
emptyBox.textContent = dataset.length > visibleItems.length
|
||||
? `검색 결과 ${dataset.length}건 중 상위 ${visibleItems.length}건을 표시합니다.`
|
||||
: "";
|
||||
@@ -4531,17 +5001,23 @@
|
||||
relatedSearchEmpty.textContent = "";
|
||||
}
|
||||
|
||||
function addRelatedProject(code) {
|
||||
async function addRelatedProject(code) {
|
||||
if (!selectedCode || !code || code === selectedCode) return;
|
||||
const set = new Set(relatedProjectSelections.get(selectedCode) || []);
|
||||
set.add(code);
|
||||
relatedProjectSelections.set(selectedCode, set);
|
||||
persistRelatedProjectSelections();
|
||||
try {
|
||||
await saveRelatedProjectSelectionsForCode(selectedCode);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
window.alert("연관 프로젝트를 DB에 저장하지 못했습니다.");
|
||||
}
|
||||
closeRelatedProjectPicker();
|
||||
renderAnalysis(getAnalysisItem(selectedCode));
|
||||
}
|
||||
|
||||
function removeRelatedProject(code) {
|
||||
async function removeRelatedProject(code) {
|
||||
if (!selectedCode) return;
|
||||
const set = new Set(relatedProjectSelections.get(selectedCode) || []);
|
||||
set.delete(code);
|
||||
@@ -4551,6 +5027,12 @@
|
||||
relatedProjectSelections.delete(selectedCode);
|
||||
}
|
||||
persistRelatedProjectSelections();
|
||||
try {
|
||||
await saveRelatedProjectSelectionsForCode(selectedCode);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
window.alert("연관 프로젝트 변경을 DB에 저장하지 못했습니다.");
|
||||
}
|
||||
renderAnalysis(getAnalysisItem(selectedCode));
|
||||
}
|
||||
|
||||
@@ -4604,6 +5086,7 @@
|
||||
metricsBox.innerHTML = "";
|
||||
comparisonBox.innerHTML = "";
|
||||
notesBox.innerHTML = "";
|
||||
uncontractedBox.innerHTML = renderUncontractedDashboard(yearSelect?.value || "");
|
||||
return;
|
||||
}
|
||||
const relatedItems = getRelatedItems(item.support_dept_code);
|
||||
@@ -4631,16 +5114,92 @@
|
||||
function refresh(options = {}) {
|
||||
const { renderDetail = false } = options;
|
||||
const keyword = input?.value || "";
|
||||
const dataset = buildProjectCostDataset(keyword, yearSelect?.value || "");
|
||||
const dataset = filterProjectCostDatasetByContractState(
|
||||
buildProjectCostDataset(keyword, yearSelect?.value || ""),
|
||||
contractFilterSelect?.value || "",
|
||||
);
|
||||
syncSelection(dataset, {
|
||||
renderDetail: renderDetail && !keyword.trim(),
|
||||
});
|
||||
renderSuggestionList(dataset, keyword);
|
||||
}
|
||||
|
||||
input?.addEventListener("input", () => refresh({ renderDetail: false }));
|
||||
let refreshTimer = null;
|
||||
input?.addEventListener("input", () => {
|
||||
window.clearTimeout(refreshTimer);
|
||||
refreshTimer = window.setTimeout(() => refresh({ renderDetail: false }), 90);
|
||||
});
|
||||
yearSelect?.addEventListener("change", () => refresh({ renderDetail: true }));
|
||||
contractFilterSelect?.addEventListener("change", () => refresh({ renderDetail: true }));
|
||||
input?.addEventListener("focus", () => refresh({ renderDetail: false }));
|
||||
function renderStandaloneUncontractedDashboard() {
|
||||
uncontractedBox.innerHTML = renderUncontractedDashboard();
|
||||
}
|
||||
uncontractedBox?.addEventListener("change", (event) => {
|
||||
const startSelect = event.target.closest("#uncontractedYearStart");
|
||||
const endSelect = event.target.closest("#uncontractedYearEnd");
|
||||
if (!startSelect && !endSelect) return;
|
||||
const startYear = Number(document.getElementById("uncontractedYearStart")?.value || 0);
|
||||
const endYear = Number(document.getElementById("uncontractedYearEnd")?.value || 0);
|
||||
if (startYear && endYear && startYear > endYear) {
|
||||
if (startSelect) {
|
||||
document.getElementById("uncontractedYearEnd").value = String(startYear);
|
||||
} else {
|
||||
document.getElementById("uncontractedYearStart").value = String(endYear);
|
||||
}
|
||||
}
|
||||
uncontractedDetailSelection = { type: "", year: null, month: null };
|
||||
uncontractedDetailVisible = false;
|
||||
renderStandaloneUncontractedDashboard();
|
||||
});
|
||||
uncontractedBox?.addEventListener("click", (event) => {
|
||||
const yearRow = event.target.closest("[data-uncontracted-year]");
|
||||
const monthRow = event.target.closest("[data-uncontracted-month]");
|
||||
const toggleButton = event.target.closest("#toggleUncontractedDetail");
|
||||
const openProjectRow = event.target.closest("[data-open-project-code]");
|
||||
if (toggleButton) {
|
||||
uncontractedDetailVisible = !uncontractedDetailVisible;
|
||||
renderStandaloneUncontractedDashboard();
|
||||
return;
|
||||
}
|
||||
if (yearRow) {
|
||||
uncontractedDetailSelection = {
|
||||
type: "year",
|
||||
year: Number(yearRow.dataset.uncontractedYear || 0),
|
||||
month: null,
|
||||
};
|
||||
uncontractedDetailVisible = true;
|
||||
renderStandaloneUncontractedDashboard();
|
||||
return;
|
||||
}
|
||||
if (monthRow) {
|
||||
uncontractedDetailSelection = {
|
||||
type: "month",
|
||||
year: Number(monthRow.dataset.uncontractedMonthYear || 0),
|
||||
month: Number(monthRow.dataset.uncontractedMonth || 0),
|
||||
};
|
||||
uncontractedDetailVisible = true;
|
||||
renderStandaloneUncontractedDashboard();
|
||||
return;
|
||||
}
|
||||
if (openProjectRow) {
|
||||
selectedCode = openProjectRow.dataset.openProjectCode || "";
|
||||
currentSelectedProjectCode = selectedCode;
|
||||
setAnalysisVisibility(true);
|
||||
renderAnalysis(getAnalysisItem(selectedCode));
|
||||
}
|
||||
});
|
||||
listBox?.addEventListener("click", (event) => {
|
||||
const itemEl = event.target.closest(".explorer-item");
|
||||
if (!itemEl) return;
|
||||
selectedCode = itemEl.dataset.code || "";
|
||||
currentSelectedProjectCode = selectedCode;
|
||||
input.value = itemEl.querySelector("strong")?.textContent || input.value;
|
||||
dropdownBox?.classList.remove("open");
|
||||
setAnalysisVisibility(true);
|
||||
renderSuggestionList([], "");
|
||||
renderAnalysis(getAnalysisItem(selectedCode));
|
||||
});
|
||||
relatedSearchInput?.addEventListener("input", refreshRelatedProjectResults);
|
||||
relatedModalCloseButton?.addEventListener("click", closeRelatedProjectPicker);
|
||||
relatedModal?.addEventListener("click", (event) => {
|
||||
@@ -4668,6 +5227,9 @@
|
||||
dropdownBox?.classList.remove("open");
|
||||
}
|
||||
});
|
||||
uncontractedDetailSelection = { type: "", year: null, month: null };
|
||||
uncontractedDetailVisible = false;
|
||||
renderStandaloneUncontractedDashboard();
|
||||
// Always start from a clean first view: keep the latest project as the
|
||||
// internal default selection, but hide the detail workspace until the
|
||||
// user explicitly opens it or selects a search result.
|
||||
|
||||
Reference in New Issue
Block a user