Fix contract linkage and persist related project groups

This commit is contained in:
b17301
2026-04-09 08:50:17 +09:00
parent 8f6f6533dc
commit ae658a332b
7 changed files with 1838 additions and 156 deletions
BIN
View File
Binary file not shown.
+1042 -6
View File
File diff suppressed because it is too large Load Diff
+71 -27
View File
@@ -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;
@@ -93,8 +136,11 @@
{% 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,18 +157,12 @@
{% 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>
<section class="panel">
</section>
</div>
<div class="chart-stack">
<section class="panel">
<div class="section-title">
<h2>비용 구조</h2>
</div>
@@ -130,9 +170,8 @@
<div class="legend chart-legend" id="expenseLegend"></div>
<svg id="expenseChart" class="chart-svg expense-chart-svg" viewBox="0 0 1600 420" preserveAspectRatio="xMidYMid meet"></svg>
</div>
</section>
<section class="panel">
</section>
<section class="panel">
<div class="section-title">
<h2>수금/비용/영업수지 그래프</h2>
</div>
@@ -140,6 +179,9 @@
<div class="legend chart-legend" id="balanceLegend"></div>
<svg id="balanceChart" class="chart-svg" viewBox="0 0 1200 420" preserveAspectRatio="xMidYMid meet"></svg>
</div>
</section>
</div>
</div>
</section>
{% endblock %}
@@ -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
View File
@@ -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>
+10
View File
@@ -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">
+29
View File
@@ -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
View File
@@ -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.