Fix contract linkage and persist related project groups
This commit is contained in:
@@ -1,13 +1,25 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}연도별 수익 비용 정리{% endblock %}
|
{% block title %}연도별 수익/비용{% endblock %}
|
||||||
|
|
||||||
{% block head_extra %}
|
{% block head_extra %}
|
||||||
<style>
|
<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 {
|
.filter-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 220px 220px 1fr;
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 12px;
|
gap: 8px;
|
||||||
align-items: end;
|
align-items: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,7 +27,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-top: 10px;
|
margin-top: 0;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend-item {
|
.legend-item {
|
||||||
@@ -43,7 +56,7 @@
|
|||||||
radial-gradient(circle at top left, rgba(17, 17, 17, 0.045), transparent 36%);
|
radial-gradient(circle at top left, rgba(17, 17, 17, 0.045), transparent 36%);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 16px;
|
padding: 14px;
|
||||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.92);
|
box-shadow: inset 0 1px 0 rgba(255,255,255,0.92);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,12 +64,6 @@
|
|||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-title {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-svg {
|
.chart-svg {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
@@ -78,11 +85,47 @@
|
|||||||
|
|
||||||
.metric-grid {
|
.metric-grid {
|
||||||
display: 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;
|
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) {
|
@media (max-width: 1000px) {
|
||||||
|
.summary-layout,
|
||||||
.filter-grid,
|
.filter-grid,
|
||||||
.metric-grid {
|
.metric-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -92,9 +135,12 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<section class="panel">
|
||||||
|
<div class="summary-layout">
|
||||||
|
<div class="summary-stack">
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
<h2>연도별 수익 비용 정리</h2>
|
<h2>수익/비용 현황</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-grid">
|
<div class="filter-grid">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -111,17 +157,11 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div></div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="panel">
|
|
||||||
<div class="section-title">
|
|
||||||
<h2>선택 구간 요약</h2>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-grid" id="metricGrid"></div>
|
<div class="metric-grid" id="metricGrid"></div>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
<div class="chart-stack">
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
<h2>비용 구조</h2>
|
<h2>비용 구조</h2>
|
||||||
@@ -131,7 +171,6 @@
|
|||||||
<svg id="expenseChart" class="chart-svg expense-chart-svg" viewBox="0 0 1600 420" preserveAspectRatio="xMidYMid meet"></svg>
|
<svg id="expenseChart" class="chart-svg expense-chart-svg" viewBox="0 0 1600 420" preserveAspectRatio="xMidYMid meet"></svg>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
<h2>수금/비용/영업수지 그래프</h2>
|
<h2>수금/비용/영업수지 그래프</h2>
|
||||||
@@ -141,6 +180,9 @@
|
|||||||
<svg id="balanceChart" class="chart-svg" viewBox="0 0 1200 420" preserveAspectRatio="xMidYMid meet"></svg>
|
<svg id="balanceChart" class="chart-svg" viewBox="0 0 1200 420" preserveAspectRatio="xMidYMid meet"></svg>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block script %}
|
{% block script %}
|
||||||
@@ -293,7 +335,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildSignedAxis(maxPositiveValue, minNegativeValue, width, height, margin) {
|
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 { tickStep, tickMax } = buildPositiveAxisScale(rangeMax, 4);
|
||||||
const plotHeight = height - margin.top - margin.bottom;
|
const plotHeight = height - margin.top - margin.bottom;
|
||||||
const zeroY = margin.top + (plotHeight * tickMax) / (tickMax * 2);
|
const zeroY = margin.top + (plotHeight * tickMax) / (tickMax * 2);
|
||||||
|
|||||||
+2
-1
@@ -477,7 +477,7 @@
|
|||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<a href="/" class="{% if request.url.path == '/' %}active{% endif %}">대시보드</a>
|
<a href="/" class="{% if request.url.path == '/' %}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="/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>
|
<div class="nav-spacer"></div>
|
||||||
<aside
|
<aside
|
||||||
class="sync-status"
|
class="sync-status"
|
||||||
@@ -496,6 +496,7 @@
|
|||||||
<div>마지막 확인: <strong id="syncLastChecked">-</strong></div>
|
<div>마지막 확인: <strong id="syncLastChecked">-</strong></div>
|
||||||
<div>서버 시간: <strong id="syncServerTime">{{ server_time or '-' }}</strong></div>
|
<div>서버 시간: <strong id="syncServerTime">{{ server_time or '-' }}</strong></div>
|
||||||
<div>데이터 버전: <strong id="syncDataVersion">{{ data_version or '-' }}</strong></div>
|
<div>데이터 버전: <strong id="syncDataVersion">{{ data_version or '-' }}</strong></div>
|
||||||
|
<div>계약/청구 동기화: <strong>{{ import_sync_summary.contract_project_count or 0 }}</strong> / <strong>{{ import_sync_summary.billing_project_count or 0 }}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -228,6 +228,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="dashboard-layout">
|
||||||
<section class="dashboard-status-panel">
|
<section class="dashboard-status-panel">
|
||||||
<div class="dashboard-status-grid">
|
<div class="dashboard-status-grid">
|
||||||
|
|||||||
@@ -431,6 +431,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section class="two-col">
|
||||||
<div class="stack">
|
<div class="stack">
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
|
|||||||
+633
-71
@@ -213,7 +213,7 @@
|
|||||||
|
|
||||||
.project-search-controls {
|
.project-search-controls {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 170px auto auto;
|
grid-template-columns: minmax(0, 1fr) 170px 210px;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: end;
|
align-items: end;
|
||||||
}
|
}
|
||||||
@@ -320,39 +320,20 @@
|
|||||||
|
|
||||||
.analysis-hero {
|
.analysis-hero {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr);
|
gap: 18px;
|
||||||
gap: 14px;
|
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.analysis-summary-card {
|
.analysis-hero-header {
|
||||||
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;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
align-items: start;
|
||||||
|
|
||||||
.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%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.analysis-title {
|
.analysis-title {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -369,19 +350,26 @@
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.analysis-meta-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.analysis-stat-grid {
|
.analysis-stat-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
gap: 10px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.analysis-stat {
|
.analysis-stat {
|
||||||
border: 1px solid #dfe6ee;
|
border: 1px solid #dfe6ee;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: rgba(255,255,255,0.94);
|
background: rgba(255,255,255,0.94);
|
||||||
padding: 12px 14px;
|
padding: 14px 16px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.analysis-stat span {
|
.analysis-stat span {
|
||||||
@@ -641,15 +629,17 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.analysis-kpi {
|
.analysis-kpi {
|
||||||
border: 1px solid #dfe6ee;
|
border: 1px solid #dfe6ee;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
padding: 12px;
|
padding: 12px 10px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
background: #fbfcfd;
|
background: #fbfcfd;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.analysis-kpi span {
|
.analysis-kpi span {
|
||||||
@@ -658,8 +648,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.analysis-kpi strong {
|
.analysis-kpi strong {
|
||||||
font-size: 18px;
|
font-size: 17px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.analysis-table {
|
.analysis-table {
|
||||||
@@ -696,10 +687,140 @@
|
|||||||
|
|
||||||
.analysis-note-grid {
|
.analysis-note-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: 1fr;
|
||||||
gap: 10px;
|
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 {
|
.search-bar {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(280px, 1fr) auto;
|
grid-template-columns: minmax(280px, 1fr) auto;
|
||||||
@@ -971,6 +1092,27 @@
|
|||||||
line-height: 1.4;
|
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 {
|
.section-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
@@ -1540,7 +1682,6 @@
|
|||||||
.overview-row,
|
.overview-row,
|
||||||
.analysis-toolbar,
|
.analysis-toolbar,
|
||||||
.project-search-controls,
|
.project-search-controls,
|
||||||
.analysis-hero,
|
|
||||||
.analysis-kpis,
|
.analysis-kpis,
|
||||||
.analysis-note-grid,
|
.analysis-note-grid,
|
||||||
.comparison-detail-grid,
|
.comparison-detail-grid,
|
||||||
@@ -1558,6 +1699,14 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.analysis-hero-header,
|
||||||
|
.analysis-stat-grid,
|
||||||
|
.uncontracted-summary-grid,
|
||||||
|
.uncontracted-dashboard-grid,
|
||||||
|
.uncontracted-toolbar {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1618,12 +1767,20 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<div class="analysis-content is-hidden" id="projectAnalysisContent">
|
<div class="analysis-content is-hidden" id="projectAnalysisContent">
|
||||||
<section class="analysis-panel analysis-related-bar" id="projectRelatedBar"></section>
|
<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="projectAnalysisMetrics"></section>
|
||||||
<section class="analysis-panel" id="projectAnalysisComparison"></section>
|
<section class="analysis-panel" id="projectAnalysisComparison"></section>
|
||||||
<section class="analysis-panel" id="projectAnalysisNotes"></section>
|
<section class="analysis-panel" id="projectAnalysisNotes"></section>
|
||||||
@@ -1632,6 +1789,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" id="projectUncontractedDashboard"></section>
|
||||||
|
|
||||||
<div class="modal-backdrop {% if project_edit.support_dept_code %}open{% endif %}" id="projectModal">
|
<div class="modal-backdrop {% if project_edit.support_dept_code %}open{% endif %}" id="projectModal">
|
||||||
<div class="modal-card">
|
<div class="modal-card">
|
||||||
<form action="/projects/save" method="post" class="modal-form" id="projectStatusForm" data-collab-form>
|
<form action="/projects/save" method="post" class="modal-form" id="projectStatusForm" data-collab-form>
|
||||||
@@ -1663,6 +1822,10 @@
|
|||||||
<h3>기본 정보</h3>
|
<h3>기본 정보</h3>
|
||||||
<div class="section-help">진행율은 계약금액 대비 수금액 합계로 자동 계산됩니다.</div>
|
<div class="section-help">진행율은 계약금액 대비 수금액 합계로 자동 계산됩니다.</div>
|
||||||
</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="section-grid">
|
||||||
<div class="field field-full">
|
<div class="field field-full">
|
||||||
<div class="lookup-grid">
|
<div class="lookup-grid">
|
||||||
@@ -2242,8 +2405,10 @@
|
|||||||
const revenueMix = {{ project_revenue_mix | tojson }};
|
const revenueMix = {{ project_revenue_mix | tojson }};
|
||||||
const revenueMixMode = {{ ("monthly" if selected_year else "yearly") | tojson }};
|
const revenueMixMode = {{ ("monthly" if selected_year else "yearly") | tojson }};
|
||||||
const projectCostRows = {{ project_cost_by_year | tojson }};
|
const projectCostRows = {{ project_cost_by_year | tojson }};
|
||||||
|
const projectMonthlyCostRows = {{ project_monthly_cost_rows | tojson }};
|
||||||
const projectStatusRows = {{ project_status_rows | tojson }};
|
const projectStatusRows = {{ project_status_rows | tojson }};
|
||||||
const persistedProjectPageState = {{ project_page_state | 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 projectStatusMap = Object.fromEntries(projectStatusRows.map((item) => [item.support_dept_code, item]));
|
||||||
const projectAccountBreakdowns = {{ project_account_breakdowns | tojson }};
|
const projectAccountBreakdowns = {{ project_account_breakdowns | tojson }};
|
||||||
const projectCostMap = Object.fromEntries(projectCostRows.map((item) => [item.support_dept_code, item]));
|
const projectCostMap = Object.fromEntries(projectCostRows.map((item) => [item.support_dept_code, item]));
|
||||||
@@ -2258,6 +2423,9 @@
|
|||||||
const projectStartDateInput = document.getElementById("project_start_date");
|
const projectStartDateInput = document.getElementById("project_start_date");
|
||||||
const projectEndDateInput = document.getElementById("project_end_date");
|
const projectEndDateInput = document.getElementById("project_end_date");
|
||||||
const notesInput = document.getElementById("notes");
|
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 supportDeptCodeResults = document.getElementById("supportDeptCodeResults");
|
||||||
const supportDeptNameResults = document.getElementById("supportDeptNameResults");
|
const supportDeptNameResults = document.getElementById("supportDeptNameResults");
|
||||||
const contractAmountInput = document.getElementById("contract_amount");
|
const contractAmountInput = document.getElementById("contract_amount");
|
||||||
@@ -3150,6 +3318,21 @@
|
|||||||
project_end_date: "",
|
project_end_date: "",
|
||||||
completion_status: "",
|
completion_status: "",
|
||||||
notes: "",
|
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: "",
|
updated_at: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -3207,6 +3390,12 @@
|
|||||||
projectStartDateInput.value = normalized.project_start_date || "";
|
projectStartDateInput.value = normalized.project_start_date || "";
|
||||||
projectEndDateInput.value = normalized.project_end_date || "";
|
projectEndDateInput.value = normalized.project_end_date || "";
|
||||||
notesInput.value = normalized.notes || "";
|
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");
|
expectedAsRateInput.value = String(normalized.expected_as_rate || "0");
|
||||||
expectedSgaRateInput.value = String(normalized.expected_sga_rate || "13");
|
expectedSgaRateInput.value = String(normalized.expected_sga_rate || "13");
|
||||||
if (editRevisionInput) {
|
if (editRevisionInput) {
|
||||||
@@ -3488,9 +3677,12 @@
|
|||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return baseDataset;
|
return baseDataset;
|
||||||
}
|
}
|
||||||
return baseDataset.filter((item) =>
|
return baseDataset.filter((item) => String(item._searchText || "").includes(normalized));
|
||||||
`${item.support_dept_code} ${item.support_dept_name}`.toLowerCase().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) {
|
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) {
|
function formatSignedAmount(value) {
|
||||||
const numeric = Number(value || 0);
|
const numeric = Number(value || 0);
|
||||||
const prefix = numeric > 0 ? "+" : "";
|
const prefix = numeric > 0 ? "+" : "";
|
||||||
@@ -3835,12 +4059,26 @@
|
|||||||
const editUrl = getProjectEditUrl(item.support_dept_code);
|
const editUrl = getProjectEditUrl(item.support_dept_code);
|
||||||
return `
|
return `
|
||||||
<div class="analysis-hero">
|
<div class="analysis-hero">
|
||||||
<div class="analysis-title-row">
|
<div class="analysis-hero-header">
|
||||||
<div class="analysis-title-block">
|
|
||||||
<div class="analysis-title">
|
<div class="analysis-title">
|
||||||
<h3>${item.support_dept_name}</h3>
|
<h3>${item.support_dept_name}</h3>
|
||||||
<p>${item.support_dept_code} · ${item.year_range} · 최신 반영 ${detailValue(item.latest_year, "-")}년 ${detailValue(item.latest_month, "-")}월</p>
|
<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-grid">
|
||||||
|
<div class="analysis-stat">
|
||||||
|
<span>사업종류</span>
|
||||||
|
<strong>${detailValue(item.project_type)}</strong>
|
||||||
|
</div>
|
||||||
<div class="analysis-stat">
|
<div class="analysis-stat">
|
||||||
<span>진행율</span>
|
<span>진행율</span>
|
||||||
<strong>${Number(item.progress_rate || 0).toFixed(2)}%</strong>
|
<strong>${Number(item.progress_rate || 0).toFixed(2)}%</strong>
|
||||||
@@ -3859,17 +4097,219 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
<div class="actions">
|
<div class="field">
|
||||||
<a class="button-link button-secondary button-icon" href="${editUrl}" data-edit-code="${item.support_dept_code}" title="수정" aria-label="수정">
|
<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">
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<path d="M4 20h4l10-10-4-4L4 16v4z"></path>
|
<path d="${uncontractedDetailVisible ? "M7 7l10 10 M17 7L7 17" : "M6 9l6 6 6-6"}"></path>
|
||||||
<path d="M13 7l4 4"></path>
|
|
||||||
</svg>
|
</svg>
|
||||||
<span class="sr-only">수정</span>
|
<span class="sr-only">세부 내역 열기/숨기기</span>
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
|
` : ""}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -3894,10 +4334,15 @@
|
|||||||
function renderAnalysisMetrics(item) {
|
function renderAnalysisMetrics(item) {
|
||||||
const details = buildComparisonDetails(item);
|
const details = buildComparisonDetails(item);
|
||||||
const actualExecutionCost =
|
const actualExecutionCost =
|
||||||
|
Number(item.total_expense || 0)
|
||||||
|
|| (
|
||||||
sumEntryAmounts(details.labor.actual)
|
sumEntryAmounts(details.labor.actual)
|
||||||
+ sumEntryAmounts(details.labor.actual_joint)
|
+ sumEntryAmounts(details.labor.actual_joint)
|
||||||
+ sumEntryAmounts(details.outsource.actual)
|
+ sumEntryAmounts(details.outsource.actual)
|
||||||
+ sumEntryAmounts(details.overhead.actual);
|
+ sumEntryAmounts(details.overhead.actual)
|
||||||
|
+ sumEntryAmounts(details.as.actual)
|
||||||
|
+ sumEntryAmounts(details.sga.actual)
|
||||||
|
);
|
||||||
return `
|
return `
|
||||||
<h4>핵심 비교 지표</h4>
|
<h4>핵심 비교 지표</h4>
|
||||||
<div class="analysis-kpis">
|
<div class="analysis-kpis">
|
||||||
@@ -4005,20 +4450,15 @@
|
|||||||
<h4>프로젝트 세부 입력 정보</h4>
|
<h4>프로젝트 세부 입력 정보</h4>
|
||||||
<div class="analysis-note-grid">
|
<div class="analysis-note-grid">
|
||||||
<div class="detail-block">
|
<div class="detail-block">
|
||||||
<h4>기본 정보</h4>
|
|
||||||
<div class="detail-grid">
|
<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>차수변경</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>예상 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 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((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.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.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 class="detail-item"><span>비고</span><strong>${detailValue(item.notes)}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -4125,7 +4565,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const aggregatedProjectCostDatasetCache = new Map();
|
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(
|
const aggregatedAllProjectCostMap = new Map(
|
||||||
aggregatedAllProjectCostRows.map((item) => [item.support_dept_code, item]),
|
aggregatedAllProjectCostRows.map((item) => [item.support_dept_code, item]),
|
||||||
);
|
);
|
||||||
@@ -4139,7 +4582,10 @@
|
|||||||
? aggregatedAllProjectCostRows
|
? aggregatedAllProjectCostRows
|
||||||
: aggregateProjectCostRows(
|
: aggregateProjectCostRows(
|
||||||
projectCostRows.filter((item) => String(item.year) === String(yearFilterValue)),
|
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);
|
aggregatedProjectCostDatasetCache.set(cacheKey, dataset);
|
||||||
return dataset;
|
return dataset;
|
||||||
}
|
}
|
||||||
@@ -4334,6 +4780,7 @@
|
|||||||
(() => {
|
(() => {
|
||||||
const input = document.getElementById("projectCostSearch");
|
const input = document.getElementById("projectCostSearch");
|
||||||
const yearSelect = document.getElementById("projectCostYearFilter");
|
const yearSelect = document.getElementById("projectCostYearFilter");
|
||||||
|
const contractFilterSelect = document.getElementById("projectContractFilter");
|
||||||
const dropdownBox = document.getElementById("projectSearchDropdown");
|
const dropdownBox = document.getElementById("projectSearchDropdown");
|
||||||
const listBox = document.getElementById("projectExplorerList");
|
const listBox = document.getElementById("projectExplorerList");
|
||||||
const emptyBox = document.getElementById("projectExplorerEmpty");
|
const emptyBox = document.getElementById("projectExplorerEmpty");
|
||||||
@@ -4346,6 +4793,7 @@
|
|||||||
const metricsBox = document.getElementById("projectAnalysisMetrics");
|
const metricsBox = document.getElementById("projectAnalysisMetrics");
|
||||||
const comparisonBox = document.getElementById("projectAnalysisComparison");
|
const comparisonBox = document.getElementById("projectAnalysisComparison");
|
||||||
const notesBox = document.getElementById("projectAnalysisNotes");
|
const notesBox = document.getElementById("projectAnalysisNotes");
|
||||||
|
const uncontractedBox = document.getElementById("projectUncontractedDashboard");
|
||||||
const relatedModal = document.getElementById("relatedProjectModal");
|
const relatedModal = document.getElementById("relatedProjectModal");
|
||||||
const relatedModalCloseButton = document.getElementById("closeRelatedProjectModal");
|
const relatedModalCloseButton = document.getElementById("closeRelatedProjectModal");
|
||||||
const relatedSearchInput = document.getElementById("relatedProjectSearchInput");
|
const relatedSearchInput = document.getElementById("relatedProjectSearchInput");
|
||||||
@@ -4357,6 +4805,14 @@
|
|||||||
const relatedProjectSelections = loadRelatedProjectSelections();
|
const relatedProjectSelections = loadRelatedProjectSelections();
|
||||||
|
|
||||||
function 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;
|
const serverState = persistedProjectPageState?.related_project_selections;
|
||||||
if (serverState && typeof serverState === "object") {
|
if (serverState && typeof serverState === "object") {
|
||||||
return new Map(
|
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) {
|
async function saveProjectPageStateToServer(showFeedback = false) {
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -4460,6 +4941,7 @@
|
|||||||
<div class="explorer-title">
|
<div class="explorer-title">
|
||||||
<strong>${item.support_dept_name}</strong>
|
<strong>${item.support_dept_name}</strong>
|
||||||
<span>${item.support_dept_code} · ${formatYearRange(item)}</span>
|
<span>${item.support_dept_code} · ${formatYearRange(item)}</span>
|
||||||
|
${getProjectContractStateBadge(getAnalysisItem(item.support_dept_code) || item)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="explorer-tags">
|
<div class="explorer-tags">
|
||||||
@@ -4469,18 +4951,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
`).join("");
|
`).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
|
emptyBox.textContent = dataset.length > visibleItems.length
|
||||||
? `검색 결과 ${dataset.length}건 중 상위 ${visibleItems.length}건을 표시합니다.`
|
? `검색 결과 ${dataset.length}건 중 상위 ${visibleItems.length}건을 표시합니다.`
|
||||||
: "";
|
: "";
|
||||||
@@ -4531,17 +5001,23 @@
|
|||||||
relatedSearchEmpty.textContent = "";
|
relatedSearchEmpty.textContent = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function addRelatedProject(code) {
|
async function addRelatedProject(code) {
|
||||||
if (!selectedCode || !code || code === selectedCode) return;
|
if (!selectedCode || !code || code === selectedCode) return;
|
||||||
const set = new Set(relatedProjectSelections.get(selectedCode) || []);
|
const set = new Set(relatedProjectSelections.get(selectedCode) || []);
|
||||||
set.add(code);
|
set.add(code);
|
||||||
relatedProjectSelections.set(selectedCode, set);
|
relatedProjectSelections.set(selectedCode, set);
|
||||||
persistRelatedProjectSelections();
|
persistRelatedProjectSelections();
|
||||||
|
try {
|
||||||
|
await saveRelatedProjectSelectionsForCode(selectedCode);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
window.alert("연관 프로젝트를 DB에 저장하지 못했습니다.");
|
||||||
|
}
|
||||||
closeRelatedProjectPicker();
|
closeRelatedProjectPicker();
|
||||||
renderAnalysis(getAnalysisItem(selectedCode));
|
renderAnalysis(getAnalysisItem(selectedCode));
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeRelatedProject(code) {
|
async function removeRelatedProject(code) {
|
||||||
if (!selectedCode) return;
|
if (!selectedCode) return;
|
||||||
const set = new Set(relatedProjectSelections.get(selectedCode) || []);
|
const set = new Set(relatedProjectSelections.get(selectedCode) || []);
|
||||||
set.delete(code);
|
set.delete(code);
|
||||||
@@ -4551,6 +5027,12 @@
|
|||||||
relatedProjectSelections.delete(selectedCode);
|
relatedProjectSelections.delete(selectedCode);
|
||||||
}
|
}
|
||||||
persistRelatedProjectSelections();
|
persistRelatedProjectSelections();
|
||||||
|
try {
|
||||||
|
await saveRelatedProjectSelectionsForCode(selectedCode);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
window.alert("연관 프로젝트 변경을 DB에 저장하지 못했습니다.");
|
||||||
|
}
|
||||||
renderAnalysis(getAnalysisItem(selectedCode));
|
renderAnalysis(getAnalysisItem(selectedCode));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4604,6 +5086,7 @@
|
|||||||
metricsBox.innerHTML = "";
|
metricsBox.innerHTML = "";
|
||||||
comparisonBox.innerHTML = "";
|
comparisonBox.innerHTML = "";
|
||||||
notesBox.innerHTML = "";
|
notesBox.innerHTML = "";
|
||||||
|
uncontractedBox.innerHTML = renderUncontractedDashboard(yearSelect?.value || "");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const relatedItems = getRelatedItems(item.support_dept_code);
|
const relatedItems = getRelatedItems(item.support_dept_code);
|
||||||
@@ -4631,16 +5114,92 @@
|
|||||||
function refresh(options = {}) {
|
function refresh(options = {}) {
|
||||||
const { renderDetail = false } = options;
|
const { renderDetail = false } = options;
|
||||||
const keyword = input?.value || "";
|
const keyword = input?.value || "";
|
||||||
const dataset = buildProjectCostDataset(keyword, yearSelect?.value || "");
|
const dataset = filterProjectCostDatasetByContractState(
|
||||||
|
buildProjectCostDataset(keyword, yearSelect?.value || ""),
|
||||||
|
contractFilterSelect?.value || "",
|
||||||
|
);
|
||||||
syncSelection(dataset, {
|
syncSelection(dataset, {
|
||||||
renderDetail: renderDetail && !keyword.trim(),
|
renderDetail: renderDetail && !keyword.trim(),
|
||||||
});
|
});
|
||||||
renderSuggestionList(dataset, keyword);
|
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 }));
|
yearSelect?.addEventListener("change", () => refresh({ renderDetail: true }));
|
||||||
|
contractFilterSelect?.addEventListener("change", () => refresh({ renderDetail: true }));
|
||||||
input?.addEventListener("focus", () => refresh({ renderDetail: false }));
|
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);
|
relatedSearchInput?.addEventListener("input", refreshRelatedProjectResults);
|
||||||
relatedModalCloseButton?.addEventListener("click", closeRelatedProjectPicker);
|
relatedModalCloseButton?.addEventListener("click", closeRelatedProjectPicker);
|
||||||
relatedModal?.addEventListener("click", (event) => {
|
relatedModal?.addEventListener("click", (event) => {
|
||||||
@@ -4668,6 +5227,9 @@
|
|||||||
dropdownBox?.classList.remove("open");
|
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
|
// Always start from a clean first view: keep the latest project as the
|
||||||
// internal default selection, but hide the detail workspace until the
|
// internal default selection, but hide the detail workspace until the
|
||||||
// user explicitly opens it or selects a search result.
|
// user explicitly opens it or selects a search result.
|
||||||
|
|||||||
Reference in New Issue
Block a user