Files
HM_project_Viewer_Board/templates/projects.html
T
2026-04-07 15:15:15 +09:00

3060 lines
121 KiB
HTML

{% extends "base.html" %}
{% block title %}사업현황{% endblock %}
{% block head_extra %}
<style>
.page-hero {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 18px;
flex-wrap: wrap;
}
.hero-copy {
display: grid;
gap: 8px;
}
.hero-copy p {
color: var(--muted);
line-height: 1.7;
max-width: 920px;
}
.top-actions {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.overview-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.overview-title-row {
display: inline-flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.overview-year-select {
min-width: 156px;
}
.metric-grid {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
margin-top: 8px;
}
.overview-row {
display: grid;
grid-template-columns: minmax(230px, 0.23fr) minmax(0, 0.77fr);
gap: 16px;
align-items: stretch;
}
.metric-note-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
margin-top: 14px;
}
.metric-note {
background: rgba(255, 255, 255, 0.92);
border: 1px solid var(--line);
border-radius: 16px;
padding: 14px 16px;
color: var(--muted);
line-height: 1.7;
font-size: 14px;
}
.metric-note strong {
color: var(--ink);
}
.chart-stage {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(246, 247, 249, 0.98)),
radial-gradient(circle at top left, rgba(17, 17, 17, 0.045), transparent 34%);
border: 1px solid var(--line);
border-radius: 16px;
padding: 16px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9);
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 14px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.chart-header p {
color: var(--muted);
line-height: 1.6;
font-size: 14px;
}
.chart-legend {
display: flex;
flex-wrap: wrap;
gap: 10px 14px;
align-items: center;
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 8px;
color: #363b44;
font-size: 13px;
font-weight: 700;
background: rgba(255, 255, 255, 0.9);
border: 1px solid var(--line);
border-radius: 10px;
padding: 6px 10px;
line-height: 1.3;
}
.legend-swatch {
width: 10px;
height: 10px;
border-radius: 999px;
display: inline-block;
}
.chart-svg {
width: 100%;
height: auto;
aspect-ratio: 1120 / 330;
min-height: 290px;
display: block;
}
.chart-note {
margin-top: 10px;
color: var(--muted);
font-size: 13px;
line-height: 1.7;
}
.search-layout {
display: grid;
gap: 16px;
}
.analysis-shell {
display: grid;
gap: 16px;
}
.analysis-toolbar {
display: grid;
gap: 12px;
align-items: start;
}
.analysis-main {
display: grid;
gap: 16px;
}
.analysis-summary-list {
display: none;
}
.toolbar-field {
background: rgba(255, 255, 255, 0.95);
border: 1px solid var(--line);
border-radius: 16px;
padding: 14px;
}
.toolbar-field h3 {
font-size: 16px;
margin-bottom: 10px;
}
.project-search-controls {
display: grid;
grid-template-columns: minmax(0, 1fr) 170px auto auto;
gap: 12px;
align-items: end;
}
.project-search-field {
position: relative;
display: grid;
gap: 8px;
}
.project-search-dropdown {
display: none;
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 14px 24px rgba(20, 24, 31, 0.08);
overflow: hidden;
}
.project-search-dropdown.open {
display: grid;
}
.analysis-content {
display: grid;
gap: 16px;
}
.analysis-content.is-hidden {
display: none;
}
.explorer-list {
display: grid;
gap: 0;
max-height: 280px;
overflow: auto;
}
.explorer-item {
display: grid;
gap: 3px;
border: 0;
border-bottom: 1px solid #edf1f4;
border-radius: 0;
padding: 11px 14px;
background: rgba(255, 255, 255, 0.98);
cursor: pointer;
transition: background 0.16s ease, color 0.16s ease;
text-align: left;
}
.explorer-item.active {
background: #f3f8fc;
}
.explorer-head {
display: block;
}
.explorer-title {
display: grid;
gap: 4px;
}
.explorer-title strong {
font-size: 14px;
line-height: 1.45;
}
.explorer-title span {
color: var(--muted);
font-size: 12px;
line-height: 1.5;
}
.explorer-tags {
display: none;
}
.project-search-empty {
padding: 11px 14px;
color: var(--muted);
font-size: 13px;
text-align: left;
}
.mini-tag {
display: inline-flex;
align-items: center;
border: 1px solid #dce5ed;
border-radius: 999px;
padding: 4px 8px;
font-size: 11px;
color: #35526a;
background: #f8fbfe;
white-space: nowrap;
}
.analysis-hero {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(320px, 0.9fr);
gap: 14px;
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;
display: grid;
gap: 14px;
}
.analysis-title-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 14px;
}
.analysis-title {
display: grid;
gap: 6px;
}
.analysis-title h3 {
font-size: 28px;
line-height: 1.2;
letter-spacing: -0.03em;
}
.analysis-title p {
color: var(--muted);
font-size: 13px;
line-height: 1.6;
}
.analysis-stat-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.analysis-stat {
border: 1px solid #dfe6ee;
border-radius: 14px;
background: rgba(255,255,255,0.94);
padding: 12px 14px;
display: grid;
gap: 4px;
}
.analysis-stat span {
color: var(--muted);
font-size: 12px;
}
.analysis-stat strong {
font-size: 22px;
line-height: 1.2;
}
.analysis-panel {
background: rgba(255,255,255,0.98);
border: 1px solid var(--line);
border-radius: 18px;
padding: 18px;
display: grid;
gap: 14px;
}
.analysis-panel h4 {
font-size: 16px;
}
.comparison-main-row {
cursor: pointer;
}
.comparison-main-row td:first-child {
font-weight: 700;
}
.comparison-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
}
.comparison-chevron {
width: 16px;
height: 16px;
display: inline-block;
transition: transform 0.16s ease;
}
.comparison-main-row.is-open .comparison-chevron {
transform: rotate(90deg);
}
.comparison-detail-row {
display: none;
background: #fbfcfd;
}
.comparison-detail-row.is-open {
display: table-row;
}
.comparison-detail-cell {
padding: 0;
}
.comparison-detail-box {
padding: 12px 14px 14px;
display: grid;
gap: 10px;
}
.comparison-detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.comparison-detail-group {
border: 1px solid #e5ebf0;
border-radius: 12px;
background: #fff;
overflow: hidden;
}
.comparison-detail-group h5 {
font-size: 12px;
color: #547085;
background: #f5f8fb;
padding: 8px 10px;
}
.comparison-detail-list {
display: grid;
gap: 0;
}
.comparison-detail-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
padding: 8px 10px;
border-top: 1px solid #eef2f5;
font-size: 12px;
text-align: left;
}
.comparison-detail-item:first-child {
border-top: 0;
}
.comparison-detail-item span:first-child {
color: var(--muted);
line-height: 1.45;
}
.comparison-detail-item strong {
font-size: 12px;
text-align: right;
}
.analysis-kpis {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.analysis-kpi {
border: 1px solid #dfe6ee;
border-radius: 14px;
padding: 12px;
display: grid;
gap: 4px;
background: #fbfcfd;
}
.analysis-kpi span {
color: var(--muted);
font-size: 12px;
}
.analysis-kpi strong {
font-size: 20px;
line-height: 1.2;
}
.analysis-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.analysis-table th,
.analysis-table td {
padding: 10px 12px;
border-bottom: 1px solid #e7edf2;
font-size: 13px;
text-align: left;
vertical-align: middle;
word-break: break-word;
}
.analysis-table th {
font-size: 12px;
color: #547085;
background: #f5f8fb;
}
.delta-positive {
color: #15803d;
font-weight: 800;
}
.delta-negative {
color: #c2410c;
font-weight: 800;
}
.analysis-note-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.search-bar {
display: grid;
grid-template-columns: minmax(280px, 1fr) auto;
gap: 12px;
align-items: end;
}
.search-helper {
color: var(--muted);
font-size: 13px;
line-height: 1.7;
}
.search-helper:empty,
.empty-state:empty {
display: none;
}
.result-list {
display: grid;
gap: 10px;
}
.result-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
background: rgba(255, 255, 255, 0.94);
border: 1px solid var(--line);
border-radius: 14px;
padding: 14px 16px;
}
.result-title {
display: grid;
gap: 4px;
}
.result-title strong {
font-size: 15px;
}
.result-title span {
color: var(--muted);
font-size: 13px;
}
.compare-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.compare-card {
position: relative;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(247, 248, 250, 0.96)),
radial-gradient(circle at top right, rgba(17, 17, 17, 0.045), transparent 36%);
border: 1px solid rgba(216, 222, 229, 0.95);
border-radius: 16px;
padding: 16px;
box-shadow: 0 12px 24px rgba(21, 24, 29, 0.05);
}
.compare-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 14px;
}
.compare-head h3 {
font-size: 18px;
line-height: 1.45;
margin-bottom: 4px;
}
.compare-head p {
color: var(--muted);
font-size: 13px;
}
.remove-button {
min-width: 32px;
width: 32px;
height: 32px;
padding: 0;
border-radius: 10px;
background: #e7f0f5;
color: #204257;
box-shadow: none;
}
.remove-button:hover {
background: #d8e7ef;
}
.remove-button svg {
width: 14px;
height: 14px;
stroke: currentColor;
fill: none;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.info-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin-bottom: 14px;
}
.info-chip {
background: rgba(248, 249, 251, 0.98);
border: 1px solid var(--line);
border-radius: 14px;
padding: 12px 14px;
}
.info-chip .label {
color: var(--muted);
font-size: 12px;
margin-bottom: 6px;
}
.info-chip .value {
font-size: 20px;
font-weight: 800;
line-height: 1.2;
word-break: break-word;
}
.detail-stack {
display: grid;
gap: 12px;
}
.detail-block {
background: rgba(255, 255, 255, 0.94);
border: 1px solid var(--line);
border-radius: 16px;
padding: 14px;
}
.detail-block h4 {
font-size: 14px;
margin-bottom: 10px;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 12px;
}
.detail-item {
display: grid;
gap: 4px;
}
.detail-item span {
color: var(--muted);
font-size: 12px;
}
.detail-item strong {
font-size: 15px;
line-height: 1.5;
word-break: break-word;
}
.empty-state {
padding: 28px 20px;
text-align: center;
color: var(--muted);
background: rgba(255, 255, 255, 0.88);
border: 1px dashed #c4ccd5;
border-radius: 16px;
line-height: 1.8;
}
.inline-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
margin-top: 12px;
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(20, 33, 47, 0.5);
display: none;
align-items: center;
justify-content: center;
padding: 20px;
z-index: 50;
}
.modal-backdrop.open {
display: flex;
}
.modal-card {
width: min(1280px, 100%);
max-height: 90vh;
overflow: auto;
background: #ffffff;
border-radius: 14px;
border: 1px solid var(--line);
box-shadow: 0 24px 64px rgba(21, 24, 29, 0.18);
padding: 16px;
}
.modal-form {
display: grid;
gap: 12px;
}
.modal-card label {
font-size: 11px;
}
.modal-card input[type="text"],
.modal-card input[type="number"],
.modal-card input[type="date"],
.modal-card select,
.modal-card textarea {
padding: 6px 8px;
border-radius: 8px;
min-height: 34px;
font-size: 12px;
line-height: 1.2;
}
.modal-card textarea {
min-height: 68px;
}
.form-section-block {
border: 1px solid var(--line);
border-radius: 12px;
padding: 12px;
background: rgba(250, 251, 252, 0.92);
display: grid;
gap: 10px;
}
.form-section-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.form-section-head h3 {
font-size: 14px;
}
.section-help {
color: var(--muted);
font-size: 11px;
line-height: 1.4;
}
.section-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
}
.lookup-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.readonly-output {
min-height: 34px;
border: 1px solid var(--line);
background: #f5f7fa;
border-radius: 8px;
padding: 7px 9px;
display: flex;
align-items: center;
justify-content: flex-start;
text-align: left;
font-weight: 700;
color: var(--ink);
font-size: 12px;
line-height: 1.25;
}
.lookup-wrap {
position: relative;
}
.lookup-results {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
z-index: 5;
border: 1px solid var(--line);
border-radius: 10px;
background: #ffffff;
box-shadow: 0 14px 30px rgba(21, 24, 29, 0.08);
max-height: 180px;
overflow: auto;
display: none;
}
.lookup-results.open {
display: grid;
}
.lookup-option {
padding: 8px 10px;
border: none;
background: transparent;
text-align: left;
color: var(--ink);
cursor: pointer;
border-bottom: 1px solid #eef1f4;
}
.lookup-option:last-child {
border-bottom: none;
}
.lookup-option strong,
.lookup-option span {
display: block;
}
.lookup-option span {
color: var(--muted);
font-size: 11px;
margin-top: 2px;
}
.row-table-wrap {
overflow: hidden;
border: 1px solid var(--line);
border-radius: 10px;
background: #ffffff;
}
.row-table {
width: 100%;
min-width: 0;
border-collapse: collapse;
table-layout: fixed;
}
.row-table th,
.row-table td {
padding: 6px 7px;
border-bottom: 1px solid #eef1f4;
font-size: 12px;
vertical-align: middle;
}
.row-table th {
background: #f7f8fa;
color: #525a66;
font-size: 11px;
font-weight: 700;
}
.row-table td .inline-input {
width: 100%;
}
.section-columns {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.mini-sector {
border: 1px solid #e6eaf0;
border-radius: 10px;
background: rgba(255, 255, 255, 0.96);
padding: 10px;
display: grid;
gap: 8px;
}
.mini-sector h4 {
font-size: 12px;
line-height: 1.35;
}
.row-table.compact {
min-width: 100%;
}
.row-table.compact th,
.row-table.compact td {
padding: 6px;
}
.collection-footer {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.summary-chip {
border: 1px solid #e6eaf0;
border-radius: 10px;
background: #ffffff;
padding: 8px 10px;
display: grid;
gap: 3px;
}
.summary-chip span {
color: var(--muted);
font-size: 11px;
text-align: left;
}
.summary-chip strong {
font-size: 13px;
text-align: left;
line-height: 1.25;
word-break: break-word;
}
.collection-contract-mini {
font-size: 11px;
font-weight: 800;
letter-spacing: -0.02em;
line-height: 1.2;
white-space: normal;
word-break: break-word;
}
.amount-input {
text-align: right;
font-variant-numeric: tabular-nums;
}
.collection-entry-list {
display: grid;
gap: 8px;
}
.collection-entry-card {
border: 1px solid #e6eaf0;
border-radius: 12px;
background: linear-gradient(180deg, #ffffff, #f9fafb);
padding: 10px;
display: grid;
gap: 8px;
}
.collection-entry-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.collection-field {
display: grid;
gap: 4px;
min-width: 0;
}
.collection-field label {
font-size: 10px;
color: var(--muted);
font-weight: 700;
line-height: 1.2;
}
.collection-field.contract-field,
.collection-field.balance-field,
.collection-field.amount-field {
min-width: 0;
}
.section-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid #dbe2ea;
background: #ffffff;
color: var(--ink);
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.section-chip strong {
font-variant-numeric: tabular-nums;
}
.collection-field .readonly-output,
.collection-field .inline-input {
min-width: 0;
width: 100%;
}
.table-tools {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.table-total {
color: var(--muted);
font-size: 11px;
font-weight: 700;
}
.mini-button {
padding: 5px 8px;
font-size: 11px;
box-shadow: none;
}
.ghost-button {
background: #ffffff;
color: #1b1d21;
border-color: var(--line);
}
.danger-lite {
background: #fff5f5;
color: #a93a3a;
border-color: #f0c5c5;
}
.stacked-note {
color: var(--muted);
font-size: 12px;
line-height: 1.5;
}
.metric-grid .stat-card {
min-height: 78px;
padding: 11px 12px 10px;
}
.metric-grid .stat-card .value {
font-size: clamp(14px, 1.2vw, 22px);
line-height: 1.15;
}
.metric-grid .stat-card .label {
font-size: 11px;
margin-bottom: 4px;
}
.metric-grid .stat-card .meta {
font-size: 11px;
display: none;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.close-button {
background: #e7f0f5;
color: #204257;
}
.muted {
color: var(--muted);
font-size: 12px;
}
@media (max-width: 1180px) {
.overview-row,
.analysis-toolbar,
.project-search-controls,
.analysis-hero,
.analysis-kpis,
.analysis-note-grid,
.comparison-detail-grid,
.metric-grid,
.metric-note-grid,
.compare-grid,
.search-bar,
.detail-grid,
.info-metrics,
.section-grid,
.lookup-grid,
.section-columns,
.collection-footer,
.collection-entry-grid {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}
{% block content %}
<div class="overview-row">
<section class="panel">
<form method="get" action="/projects">
<div class="overview-header">
<div class="overview-title-row">
<div class="section-title" style="margin-bottom: 0;">
<h2>사업현황</h2>
</div>
<select id="year" name="year" class="overview-year-select">
<option value="" {% if not selected_year %}selected{% endif %}>최근 10개년</option>
{% for year in project_year_options %}
<option value="{{ year }}" {% if selected_year == year %}selected{% endif %}>{{ year }}</option>
{% endfor %}
</select>
</div>
</div>
</form>
<div class="metric-grid">
<div class="stat-card">
<div class="label">연관 프로젝트 수</div>
<div class="value">{{ project_dashboard.related_projects or 0 }}</div>
</div>
<div class="stat-card">
<div class="label">수금액</div>
<div class="value">{{ "{:,.0f}".format(project_dashboard.collection_amount or 0) }}</div>
</div>
<div class="stat-card">
<div class="label">지출액</div>
<div class="value">{{ "{:,.0f}".format(project_dashboard.expense_amount or 0) }}</div>
</div>
<div class="stat-card">
<div class="label">종료 프로젝트 수</div>
<div class="value">{{ project_dashboard.completed_projects or 0 }}</div>
</div>
</div>
</section>
<section class="panel">
<div class="chart-stage">
<div class="chart-header">
<div>
<div class="section-title" style="margin-bottom: 8px;">
<h2>수금액 구성</h2>
</div>
</div>
<div class="chart-legend" id="revenueLegend"></div>
</div>
<svg id="revenueMixChart" class="chart-svg" viewBox="0 0 1200 390" preserveAspectRatio="xMidYMid meet"></svg>
</div>
</section>
</div>
<section class="panel">
<div class="analysis-shell">
<div class="analysis-main">
<section class="analysis-toolbar">
<div class="toolbar-field">
<h3>프로젝트 검색</h3>
<div class="project-search-controls">
<div class="project-search-field">
<input id="projectCostSearch" type="text" placeholder="지원부서코드 또는 사업명을 입력하세요." autocomplete="off">
<div class="project-search-dropdown" id="projectSearchDropdown">
<div class="explorer-list" id="projectExplorerList"></div>
<div class="project-search-empty" id="projectExplorerEmpty"></div>
</div>
</div>
<div class="field">
<label for="projectCostYearFilter">연도 필터</label>
<select id="projectCostYearFilter">
<option value="">전체 연도</option>
{% for year in project_year_options %}
<option value="{{ year }}">{{ year }}</option>
{% endfor %}
</select>
</div>
<button type="button" id="toggleProjectAnalysis" class="button-icon button-secondary" title="프로젝트 상세 열기/숨기기" aria-label="프로젝트 상세 열기/숨기기">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 12h16"></path>
<path d="M12 4l8 8-8 8"></path>
</svg>
<span class="sr-only">프로젝트 상세 열기/숨기기</span>
</button>
<button type="button" id="openProjectModal" class="button-icon" title="사업현황 추가/수정" aria-label="사업현황 추가/수정">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 5v14"></path>
<path d="M5 12h14"></path>
</svg>
<span class="sr-only">사업현황 추가/수정</span>
</button>
</div>
</div>
</section>
<div class="analysis-content is-hidden" id="projectAnalysisContent">
<section class="analysis-summary-card" id="projectAnalysisHero"></section>
<section class="analysis-panel" id="projectAnalysisMetrics"></section>
<section class="analysis-panel" id="projectAnalysisComparison"></section>
<section class="analysis-panel" id="projectAnalysisNotes"></section>
</div>
</div>
</div>
</section>
<div class="modal-backdrop {% if project_edit.support_dept_code %}open{% endif %}" id="projectModal">
<div class="modal-card">
<div class="modal-header">
<div>
<h2 style="font-size: 24px;">사업현황 추가/수정</h2>
<div class="muted">추가 버튼 또는 상세 카드의 편집 버튼을 눌렀을 때만 열립니다.</div>
</div>
<button type="button" class="close-button button-secondary button-icon" id="closeProjectModal" title="닫기" aria-label="닫기">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 6l12 12"></path>
<path d="M18 6L6 18"></path>
</svg>
<span class="sr-only">닫기</span>
</button>
</div>
<form action="/projects/save" method="post" class="modal-form" id="projectStatusForm" data-collab-form>
<input type="hidden" name="selected_year" value="{{ selected_year or '' }}">
<input type="hidden" name="edit_revision" value="{{ project_edit.updated_at }}">
<input type="hidden" id="support_dept_code" name="support_dept_code" value="{{ project_edit.support_dept_code }}">
<section class="form-section-block">
<div class="form-section-head">
<h3>기본 정보</h3>
<div class="section-help">진행율은 계약금액 대비 수금액 합계로 자동 계산됩니다.</div>
</div>
<div class="section-grid">
<div class="field field-full">
<div class="lookup-grid">
<div class="field">
<label for="supportDeptCodeLookup">지원부서코드</label>
<div class="lookup-wrap">
<input id="supportDeptCodeLookup" type="text" value="{{ project_edit.support_dept_code }}" placeholder="코드를 검색하거나 입력하세요." autocomplete="off" required>
<div id="supportDeptCodeResults" class="lookup-results"></div>
</div>
</div>
<div class="field">
<label for="support_dept_name">지원부서명</label>
<div class="lookup-wrap">
<input id="support_dept_name" type="text" name="support_dept_name" value="{{ project_edit.support_dept_name }}" placeholder="부서명을 검색하거나 입력하세요." autocomplete="off" required>
<div id="supportDeptNameResults" class="lookup-results"></div>
</div>
</div>
</div>
</div>
<div class="field">
<label for="project_type">사업 종류</label>
<input id="project_type" type="text" name="project_type" value="{{ project_edit.project_type }}" placeholder="향후 A/S비·판관비 자동 연동 대비">
</div>
<div class="field">
<label for="contract_amount">계약금액</label>
<input id="contract_amount" type="number" step="0.01" name="contract_amount" value="{{ project_edit.contract_amount }}" placeholder="계약금액 입력">
</div>
<div class="field">
<label for="progress_rate_display">진행율(%)</label>
<div id="progress_rate_display" class="readonly-output">{{ "{:.2f}".format(project_edit.progress_rate or 0) }}%</div>
</div>
<div class="field">
<label for="completion_status">종료여부</label>
<input id="completion_status" type="text" name="completion_status" value="{{ project_edit.completion_status }}" placeholder="진행중 / 종료 / 완료">
</div>
<div class="field">
<label for="change_round">차수변경</label>
<input id="change_round" type="text" name="change_round" value="{{ project_edit.change_round }}">
</div>
<div class="field">
<label for="project_start_date">시작일</label>
<input id="project_start_date" class="date-text-input" type="text" inputmode="numeric" maxlength="10" name="project_start_date" value="{{ project_edit.project_start_date }}" placeholder="YYYY-MM-DD">
</div>
<div class="field">
<label for="project_end_date">종료일</label>
<input id="project_end_date" class="date-text-input" type="text" inputmode="numeric" maxlength="10" name="project_end_date" value="{{ project_edit.project_end_date }}" placeholder="YYYY-MM-DD">
</div>
</div>
</section>
<section class="form-section-block">
<div class="form-section-head">
<h3>수금 정보</h3>
<div class="table-tools">
<div class="actions">
<div class="table-total">계약, 청구, 수금 흐름을 차수 기준으로 관리합니다.</div>
<div class="section-chip">계약금액 <strong id="collectionContractInlineDisplay">{{ "{:,.0f}".format(project_edit.contract_amount or 0) }}원</strong></div>
</div>
<button type="button" class="button-link button-secondary mini-button button-icon" data-add-row="collection" title="수금 행 추가" aria-label="수금 행 추가">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 5v14"></path>
<path d="M5 12h14"></path>
</svg>
<span class="sr-only">수금 행 추가</span>
</button>
</div>
</div>
<div class="collection-entry-list" id="collectionRows">
{% for row in project_edit.collection_entries %}
<div class="collection-entry-card" data-collection-row>
<div class="collection-entry-grid">
<div class="collection-field">
<label>기성구분</label>
<input class="inline-input" type="text" name="collection_progress_type[]" value="{{ row.progress_type }}">
</div>
<div class="collection-field">
<label>기성차수</label>
<input class="inline-input round-input" type="number" min="1" step="1" inputmode="numeric" name="collection_billing_round[]" value="{{ row.billing_round or loop.index }}">
</div>
<div class="collection-field">
<label>청구구분</label>
<input class="inline-input" type="text" name="collection_billing_type[]" value="{{ row.billing_type }}">
</div>
<div class="collection-field">
<label>청구일자</label>
<input class="inline-input date-text-input" type="text" inputmode="numeric" maxlength="10" name="collection_billing_date[]" value="{{ row.billing_date }}" placeholder="YYYY-MM-DD">
</div>
<div class="collection-field amount-field">
<label>청구금액</label>
<input class="inline-input collection-billed-amount amount-input formatted-amount-input" type="text" inputmode="numeric" name="collection_billed_amount[]" value="{{ "{:,.0f}".format(row.billed_amount or 0) if row.billed_amount else '' }}">
</div>
<div class="collection-field">
<label>수금차수</label>
<input class="inline-input round-input" type="number" min="1" step="1" inputmode="numeric" name="collection_round[]" value="{{ row.round or loop.index }}">
</div>
<div class="collection-field">
<label>수금일자</label>
<input class="inline-input date-text-input" type="text" inputmode="numeric" maxlength="10" name="collection_date[]" value="{{ row.date }}" placeholder="YYYY-MM-DD">
</div>
<div class="collection-field amount-field">
<label>수금금액</label>
<input class="inline-input collection-amount amount-input formatted-amount-input" type="text" inputmode="numeric" name="collection_amount_row[]" value="{{ "{:,.0f}".format(row.amount or 0) if row.amount else '' }}">
</div>
<div class="collection-field balance-field">
<label>수금잔액</label>
<div class="readonly-output collection-balance-cell"></div>
</div>
</div>
<div class="actions" style="justify-content: flex-end;">
<button type="button" class="button-link danger-lite mini-button button-icon" data-remove-row title="행 삭제" aria-label="행 삭제">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 7h16"></path>
<path d="M9 7V5h6v2"></path>
<path d="M9 11v6"></path>
<path d="M15 11v6"></path>
<path d="M6 7l1 12h10l1-12"></path>
</svg>
<span class="sr-only">행 삭제</span>
</button>
</div>
</div>
{% endfor %}
</div>
<div class="collection-footer">
<div class="summary-chip">
<span>계약금액</span>
<strong id="collectionContractDisplay">{{ "{:,.0f}".format(project_edit.contract_amount or 0) }}원</strong>
</div>
<div class="summary-chip">
<span>청구금액 합계</span>
<strong id="collectionBilledTotalDisplay">0원</strong>
</div>
<div class="summary-chip">
<span>수금금액 합계</span>
<strong id="collectionTotalDisplay">{{ "{:,.0f}".format(project_edit.collection_amount or 0) }}원</strong>
</div>
<div class="summary-chip">
<span>수금잔액</span>
<strong id="collectionBalanceTotalDisplay">0원</strong>
</div>
</div>
</section>
<section class="form-section-block">
<div class="form-section-head">
<h3>과업수행계획</h3>
<div class="section-help">과업수행계획 아래에서 부서별배분, 외주비, 합사운영비를 나누어 입력합니다.</div>
</div>
<div class="table-total">계획 합계: <span id="taskPlanTotalDisplay">0</span></div>
<div class="section-columns">
<section class="mini-sector">
<div class="table-tools">
<h4>부서별배분</h4>
<button type="button" class="button-link button-secondary mini-button" data-add-row="task-plan-department">추가</button>
</div>
<div class="row-table-wrap">
<table class="row-table compact">
<thead>
<tr>
<th>항목</th>
<th>금액(원)</th>
<th></th>
</tr>
</thead>
<tbody id="taskPlanDepartmentRows"></tbody>
</table>
</div>
</section>
<section class="mini-sector">
<div class="table-tools">
<h4>외주비</h4>
<button type="button" class="button-link button-secondary mini-button" data-add-row="task-plan-outsource">추가</button>
</div>
<div class="row-table-wrap">
<table class="row-table compact">
<thead>
<tr>
<th>항목</th>
<th>금액(원)</th>
<th>비고</th>
<th></th>
</tr>
</thead>
<tbody id="taskPlanOutsourceRows"></tbody>
</table>
</div>
</section>
<section class="mini-sector">
<div class="table-tools">
<h4>합사운영비</h4>
<button type="button" class="button-link button-secondary mini-button" data-add-row="task-plan-joint">추가</button>
</div>
<div class="row-table-wrap">
<table class="row-table compact">
<thead>
<tr>
<th>항목</th>
<th>금액(원)</th>
<th></th>
</tr>
</thead>
<tbody id="taskPlanJointRows"></tbody>
</table>
</div>
</section>
</div>
</section>
<section class="form-section-block">
<div class="form-section-head">
<h3>실행예산계획</h3>
<div class="section-help">실행예산계획 아래에서 인건비, 외주비, 계정별비용계획을 나누어 입력합니다.</div>
</div>
<div class="table-total">실행예산 합계: <span id="execBudgetTotalDisplay">0</span></div>
<div class="section-columns">
<section class="mini-sector">
<div class="table-tools">
<h4>인건비</h4>
<button type="button" class="button-link button-secondary mini-button" data-add-row="exec-labor">추가</button>
</div>
<div class="row-table-wrap">
<table class="row-table compact">
<thead>
<tr>
<th>항목</th>
<th>금액(원)</th>
<th></th>
</tr>
</thead>
<tbody id="execLaborRows"></tbody>
</table>
</div>
</section>
<section class="mini-sector">
<div class="table-tools">
<h4>외주비</h4>
<button type="button" class="button-link button-secondary mini-button" data-add-row="exec-outsource">추가</button>
</div>
<div class="row-table-wrap">
<table class="row-table compact">
<thead>
<tr>
<th>항목</th>
<th>금액(원)</th>
<th>비고</th>
<th></th>
</tr>
</thead>
<tbody id="execOutsourceRows"></tbody>
</table>
</div>
</section>
<section class="mini-sector">
<div class="table-tools">
<h4>계정별비용계획</h4>
<button type="button" class="button-link button-secondary mini-button" data-add-row="exec-cost-plan">추가</button>
</div>
<div class="row-table-wrap">
<table class="row-table compact">
<thead>
<tr>
<th>항목</th>
<th>금액(원)</th>
<th></th>
</tr>
</thead>
<tbody id="execCostPlanRows"></tbody>
</table>
</div>
</section>
</div>
</section>
<section class="form-section-block">
<div class="form-section-head">
<h3>실투입 관리</h3>
<div class="section-help">실행예산 항목과 연계된 실제 투입액을 입력합니다.</div>
</div>
<div class="table-tools">
<div class="table-total">실투입 합계: <span id="actualInputTotalDisplay">0</span></div>
<button type="button" class="button-link button-secondary mini-button button-icon" data-add-row="actual-input" title="실투입 행 추가" aria-label="실투입 행 추가">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 5v14"></path>
<path d="M5 12h14"></path>
</svg>
<span class="sr-only">실투입 행 추가</span>
</button>
</div>
<div class="row-table-wrap">
<table class="row-table">
<thead>
<tr>
<th>연계 실행예산 항목</th>
<th>실투입액(원)</th>
<th>비고</th>
<th></th>
</tr>
</thead>
<tbody id="actualInputRows"></tbody>
</table>
</div>
</section>
<section class="form-section-block">
<div class="form-section-head">
<h3>예상 배분 설정</h3>
<div class="section-help">향후 사업 종류별 자동 비율 추천과 실제 집행 비교를 위해 비율과 계산 결과를 분리해 저장합니다.</div>
</div>
<div class="section-grid">
<div class="field">
<label for="expected_as_rate">예상 A/S비 비율</label>
<select id="expected_as_rate" name="expected_as_rate">
{% for rate in [0, 2, 5, 10] %}
<option value="{{ rate }}" {% if project_edit.expected_as_rate|string == rate|string %}selected{% endif %}>{{ rate }}%</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="expected_as_cost_display">예상 A/S비 금액</label>
<div id="expected_as_cost_display" class="readonly-output">{{ "{:,.0f}".format(project_edit.expected_as_cost or 0) }}원</div>
</div>
<div class="field">
<label for="expected_sga_rate">예상 판관비 비율</label>
<select id="expected_sga_rate" name="expected_sga_rate">
{% for rate in [13, 20, 25] %}
<option value="{{ rate }}" {% if project_edit.expected_sga_rate|string == rate|string %}selected{% endif %}>{{ rate }}%</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="expected_sga_budget_display">예상 판관비 금액</label>
<div id="expected_sga_budget_display" class="readonly-output">{{ "{:,.0f}".format(project_edit.expected_sga_budget or 0) }}원</div>
</div>
</div>
</section>
<section class="form-section-block">
<div class="form-section-head">
<h3>비고</h3>
</div>
<div class="field field-full">
<label for="notes">메모</label>
<textarea id="notes" name="notes">{{ project_edit.notes }}</textarea>
</div>
</section>
<div class="actions">
<button type="submit" class="button-icon" title="사업현황 저장" aria-label="사업현황 저장">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M5 12l5 5 9-10"></path>
</svg>
<span class="sr-only">사업현황 저장</span>
</button>
<a class="button-link button-secondary button-icon" href="/projects{% if selected_year %}?year={{ selected_year }}{% endif %}" title="취소" aria-label="취소">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 6l12 12"></path>
<path d="M18 6L6 18"></path>
</svg>
<span class="sr-only">취소</span>
</a>
</div>
</form>
</div>
</div>
{% endblock %}
{% block script %}
<script>
const selectedYear = {{ selected_year or "null" }};
const yearFilter = document.getElementById("year");
const supportDepartmentOptions = {{ support_department_options | tojson }};
const projectEdit = {{ project_edit | tojson }};
const revenueMix = {{ project_revenue_mix | tojson }};
const revenueMixMode = {{ ("monthly" if selected_year else "yearly") | tojson }};
const projectCostRows = {{ project_cost_by_year | tojson }};
const projectStatusRows = {{ project_status_rows | 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]));
const currencyFormatter = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 0 });
let currentSelectedProjectCode = projectEdit.support_dept_code || "";
const supportDeptCodeLookup = document.getElementById("supportDeptCodeLookup");
const supportDeptCodeInput = document.getElementById("support_dept_code");
const supportDeptNameInput = document.getElementById("support_dept_name");
const supportDeptCodeResults = document.getElementById("supportDeptCodeResults");
const supportDeptNameResults = document.getElementById("supportDeptNameResults");
const contractAmountInput = document.getElementById("contract_amount");
const expectedAsRateInput = document.getElementById("expected_as_rate");
const expectedSgaRateInput = document.getElementById("expected_sga_rate");
const collectionRows = document.getElementById("collectionRows");
const taskPlanDepartmentRows = document.getElementById("taskPlanDepartmentRows");
const taskPlanOutsourceRows = document.getElementById("taskPlanOutsourceRows");
const taskPlanJointRows = document.getElementById("taskPlanJointRows");
const execLaborRows = document.getElementById("execLaborRows");
const execOutsourceRows = document.getElementById("execOutsourceRows");
const execCostPlanRows = document.getElementById("execCostPlanRows");
const actualInputRows = document.getElementById("actualInputRows");
yearFilter?.addEventListener("change", (event) => {
const nextYear = event.target.value;
const nextUrl = nextYear ? `/projects?year=${encodeURIComponent(nextYear)}` : "/projects";
window.location.href = nextUrl;
});
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function formatNumber(value) {
return currencyFormatter.format(value || 0);
}
function formatDisplayAmount(value) {
const numeric = Number(value || 0);
if (!numeric) return "0";
if (Math.abs(numeric) >= 100000000) {
return `${(numeric / 100000000).toFixed(1).replace(/\.0$/, "")}억`;
}
if (Math.abs(numeric) >= 1000000) {
return `${(numeric / 1000000).toFixed(1).replace(/\.0$/, "")}백만`;
}
if (Math.abs(numeric) >= 1000) {
return `${(numeric / 1000).toFixed(1).replace(/\.0$/, "")}천`;
}
return formatNumber(numeric);
}
function formatCompactCurrency(value) {
const numeric = Number(value || 0);
if (!numeric) return "0원";
if (Math.abs(numeric) >= 100000000) {
return `${(numeric / 100000000).toFixed(1).replace(/\.0$/, "")}억원`;
}
if (Math.abs(numeric) >= 1000000) {
return `${(numeric / 1000000).toFixed(1).replace(/\.0$/, "")}백만원`;
}
return `${formatNumber(numeric)}원`;
}
function formatAxisLabel(value) {
const numeric = Number(value || 0);
if (!numeric) return "0";
if (numeric >= 100000000) {
return `${numeric / 100000000}억`;
}
if (numeric >= 1000000) {
return `${numeric / 1000000}백만`;
}
if (numeric >= 1000) {
return `${numeric / 1000}천`;
}
return formatNumber(numeric);
}
function pickTickStep(maxValue) {
const baseUnit = maxValue < 1000000 ? 1000 : 1000000;
const units = [1, 2, 5];
const raw = Math.max(maxValue / 5 / baseUnit, 1);
let power = 1;
while (power * 10 <= raw) {
power *= 10;
}
for (const unit of units) {
const candidate = unit * power;
if (candidate >= raw) {
return candidate * baseUnit;
}
}
return power * 10 * baseUnit;
}
function parseAmount(value) {
const numeric = Number(String(value ?? "").replace(/,/g, ""));
return Number.isFinite(numeric) ? numeric : 0;
}
function digitsOnly(value) {
return String(value ?? "").replace(/\D/g, "");
}
function formatAmountInputValue(value) {
const digits = digitsOnly(value);
if (!digits) return "";
return formatNumber(Number(digits));
}
function formatDateInputValue(value) {
const digits = digitsOnly(value).slice(0, 8);
if (digits.length <= 4) return digits;
if (digits.length <= 6) return `${digits.slice(0, 4)}-${digits.slice(4)}`;
return `${digits.slice(0, 4)}-${digits.slice(4, 6)}-${digits.slice(6)}`;
}
function getNextRoundValue(fieldName) {
const values = [...collectionRows.querySelectorAll(`input[name="${fieldName}"]`)]
.map((input) => Number(input.value || 0))
.filter((value) => Number.isFinite(value) && value > 0);
return String((values.length ? Math.max(...values) : 0) + 1);
}
function getAllTaskPlanAmountInputs() {
return [
...taskPlanDepartmentRows.querySelectorAll("[data-amount-input]"),
...taskPlanOutsourceRows.querySelectorAll("[data-amount-input]"),
...taskPlanJointRows.querySelectorAll("[data-amount-input]"),
];
}
function getAllExecBudgetAmountInputs() {
return [
...execLaborRows.querySelectorAll("[data-amount-input]"),
...execOutsourceRows.querySelectorAll("[data-amount-input]"),
...execCostPlanRows.querySelectorAll("[data-amount-input]"),
];
}
function updateCollectionContractCells(contractAmount, collectionTotal) {
const contractDisplay = document.getElementById("collectionContractDisplay");
const contractInlineDisplay = document.getElementById("collectionContractInlineDisplay");
if (contractDisplay) {
contractDisplay.textContent = `${formatNumber(contractAmount)}원`;
}
if (contractInlineDisplay) {
contractInlineDisplay.textContent = `${formatNumber(contractAmount)}원`;
}
let runningCollected = 0;
collectionRows.querySelectorAll("[data-collection-row]").forEach((row) => {
const amountInput = row.querySelector(".collection-amount");
runningCollected += parseAmount(amountInput?.value);
const balanceCell = row.querySelector(".collection-balance-cell");
if (balanceCell) {
balanceCell.textContent = `${formatNumber(Math.max(contractAmount - runningCollected, 0))}원`;
}
});
const balance = Math.max(contractAmount - collectionTotal, 0);
document.getElementById("collectionBalanceTotalDisplay").textContent = `${formatNumber(balance)}원`;
}
function updateComputedFields() {
const contractAmount = parseAmount(contractAmountInput?.value);
const billedTotal = [...document.querySelectorAll(".collection-billed-amount")]
.reduce((sum, input) => sum + parseAmount(input.value), 0);
const collectionTotal = [...document.querySelectorAll(".collection-amount")]
.reduce((sum, input) => sum + parseAmount(input.value), 0);
const progressRate = contractAmount ? (collectionTotal / contractAmount) * 100 : 0;
document.getElementById("collectionBilledTotalDisplay").textContent = `${formatNumber(billedTotal)}원`;
document.getElementById("collectionTotalDisplay").textContent = `${formatNumber(collectionTotal)}원`;
updateCollectionContractCells(contractAmount, collectionTotal);
document.getElementById("progress_rate_display").textContent = `${progressRate.toFixed(2)}%`;
const taskPlanTotal = getAllTaskPlanAmountInputs()
.reduce((sum, input) => sum + parseAmount(input.value), 0);
const execBudgetTotal = getAllExecBudgetAmountInputs()
.reduce((sum, input) => sum + parseAmount(input.value), 0);
const actualInputTotal = [...actualInputRows.querySelectorAll("[data-amount-input]")]
.reduce((sum, input) => sum + parseAmount(input.value), 0);
document.getElementById("taskPlanTotalDisplay").textContent = formatNumber(taskPlanTotal);
document.getElementById("execBudgetTotalDisplay").textContent = formatNumber(execBudgetTotal);
document.getElementById("actualInputTotalDisplay").textContent = formatNumber(actualInputTotal);
const expectedAsCost = contractAmount * parseAmount(expectedAsRateInput?.value) / 100;
const expectedSgaBudget = contractAmount * parseAmount(expectedSgaRateInput?.value) / 100;
document.getElementById("expected_as_cost_display").textContent = `${formatNumber(expectedAsCost)}원`;
document.getElementById("expected_sga_budget_display").textContent = `${formatNumber(expectedSgaBudget)}원`;
}
function createCollectionRow(row = {}) {
const billingRound = row.billing_round || getNextRoundValue("collection_billing_round[]");
const collectionRound = row.round || getNextRoundValue("collection_round[]");
return `
<div class="collection-entry-card" data-collection-row>
<div class="collection-entry-grid">
<div class="collection-field">
<label>기성구분</label>
<input class="inline-input" type="text" name="collection_progress_type[]" value="${escapeHtml(row.progress_type || "")}">
</div>
<div class="collection-field">
<label>기성차수</label>
<input class="inline-input round-input" type="number" min="1" step="1" inputmode="numeric" name="collection_billing_round[]" value="${escapeHtml(billingRound)}">
</div>
<div class="collection-field">
<label>청구구분</label>
<input class="inline-input" type="text" name="collection_billing_type[]" value="${escapeHtml(row.billing_type || "")}">
</div>
<div class="collection-field">
<label>청구일자</label>
<input class="inline-input date-text-input" type="text" inputmode="numeric" maxlength="10" name="collection_billing_date[]" value="${escapeHtml(row.billing_date || "")}" placeholder="YYYY-MM-DD">
</div>
<div class="collection-field amount-field">
<label>청구금액</label>
<input class="inline-input collection-billed-amount amount-input formatted-amount-input" data-billed-amount-input type="text" inputmode="numeric" name="collection_billed_amount[]" value="${escapeHtml(formatAmountInputValue(row.billed_amount || ""))}">
</div>
<div class="collection-field">
<label>수금차수</label>
<input class="inline-input round-input" type="number" min="1" step="1" inputmode="numeric" name="collection_round[]" value="${escapeHtml(collectionRound)}">
</div>
<div class="collection-field">
<label>수금일자</label>
<input class="inline-input date-text-input" type="text" inputmode="numeric" maxlength="10" name="collection_date[]" value="${escapeHtml(row.date || "")}" placeholder="YYYY-MM-DD">
</div>
<div class="collection-field amount-field">
<label>수금금액</label>
<input class="inline-input collection-amount amount-input formatted-amount-input" data-amount-input type="text" inputmode="numeric" name="collection_amount_row[]" value="${escapeHtml(formatAmountInputValue(row.amount || ""))}">
</div>
<div class="collection-field balance-field">
<label>수금잔액</label>
<div class="readonly-output collection-balance-cell"></div>
</div>
</div>
<div class="actions" style="justify-content: flex-end;">
<button type="button" class="button-link danger-lite mini-button button-icon" data-remove-row title="행 삭제" aria-label="행 삭제">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 7h16"></path>
<path d="M9 7V5h6v2"></path>
<path d="M9 11v6"></path>
<path d="M15 11v6"></path>
<path d="M6 7l1 12h10l1-12"></path>
</svg>
<span class="sr-only">행 삭제</span>
</button>
</div>
</div>
`;
}
function createTaskPlanRow(group, row = {}) {
const fieldMap = {
department: ["task_plan_department_label[]", "task_plan_department_amount[]"],
outsource: ["task_plan_outsource_vendor[]", "task_plan_outsource_amount[]"],
joint: ["task_plan_joint_label[]", "task_plan_joint_amount[]"],
};
const [labelName, amountName] = fieldMap[group];
const noteField = group === "outsource"
? `<input class="inline-input" type="text" name="task_plan_outsource_note[]" value="${escapeHtml(row.note || "")}">`
: "";
return `
<tr data-group="${group}">
<td><input class="inline-input" type="text" name="${labelName}" value="${escapeHtml(row.label || "")}"></td>
<td><input class="inline-input" data-amount-input type="number" step="0.01" name="${amountName}" value="${escapeHtml(row.amount || "")}"></td>
${group === "outsource" ? `<td>${noteField}</td>` : ""}
<td><button type="button" class="button-link danger-lite mini-button button-icon" data-remove-row title="행 삭제" aria-label="행 삭제">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 7h16"></path>
<path d="M9 7V5h6v2"></path>
<path d="M9 11v6"></path>
<path d="M15 11v6"></path>
<path d="M6 7l1 12h10l1-12"></path>
</svg>
<span class="sr-only">행 삭제</span>
</button></td>
</tr>
`;
}
function createExecBudgetRow(group, row = {}) {
const fieldMap = {
labor: ["exec_labor_role[]", "exec_labor_amount[]"],
outsource: ["exec_outsource_vendor[]", "exec_outsource_amount[]"],
cost_plan: ["exec_cost_plan_label[]", "exec_cost_plan_amount[]"],
};
const [labelName, amountName] = fieldMap[group];
const noteField = group === "outsource"
? `<input class="inline-input" type="text" name="exec_outsource_note[]" value="${escapeHtml(row.note || "")}">`
: "";
return `
<tr data-group="${group}">
<td><input class="inline-input" type="text" name="${labelName}" value="${escapeHtml(row.label || "")}"></td>
<td><input class="inline-input" data-amount-input type="number" step="0.01" name="${amountName}" value="${escapeHtml(row.amount || "")}"></td>
${group === "outsource" ? `<td>${noteField}</td>` : ""}
<td><button type="button" class="button-link danger-lite mini-button button-icon" data-remove-row title="행 삭제" aria-label="행 삭제">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 7h16"></path>
<path d="M9 7V5h6v2"></path>
<path d="M9 11v6"></path>
<path d="M15 11v6"></path>
<path d="M6 7l1 12h10l1-12"></path>
</svg>
<span class="sr-only">행 삭제</span>
</button></td>
</tr>
`;
}
function getActualInputOptions() {
const rows = [
...execLaborRows.querySelectorAll("tr"),
...execOutsourceRows.querySelectorAll("tr"),
...execCostPlanRows.querySelectorAll("tr"),
];
return rows.map((row, index) => {
const label = row.querySelector("td:first-child input")?.value?.trim();
const containerTitle = row.closest(".mini-sector")?.querySelector("h4")?.textContent?.trim() || "";
if (!label) return null;
return {
value: `${containerTitle}::${label}`,
label: `${containerTitle} / ${label}`,
key: `exec-${index}`,
};
}).filter(Boolean);
}
function createActualInputRow(row = {}) {
const options = getActualInputOptions().map((option) => `
<option value="${escapeHtml(option.value)}" ${String(row.reference || "") === option.value ? "selected" : ""}>${escapeHtml(option.label)}</option>
`).join("");
return `
<tr>
<td>
<select class="inline-input" name="actual_input_ref[]">
<option value="">실행예산 항목 선택</option>
${options}
</select>
</td>
<td><input class="inline-input" data-amount-input type="number" step="0.01" name="actual_input_amount[]" value="${escapeHtml(row.amount || "")}"></td>
<td><input class="inline-input" type="text" name="actual_input_note[]" value="${escapeHtml(row.note || "")}"></td>
<td><button type="button" class="button-link danger-lite mini-button button-icon" data-remove-row title="행 삭제" aria-label="행 삭제">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 7h16"></path>
<path d="M9 7V5h6v2"></path>
<path d="M9 11v6"></path>
<path d="M15 11v6"></path>
<path d="M6 7l1 12h10l1-12"></path>
</svg>
<span class="sr-only">행 삭제</span>
</button></td>
</tr>
`;
}
function refreshActualInputOptions() {
const rows = [...actualInputRows.querySelectorAll("tr")].map((row) => ({
reference: row.querySelector('select[name="actual_input_ref[]"]')?.value || "",
amount: row.querySelector('input[name="actual_input_amount[]"]')?.value || "",
note: row.querySelector('input[name="actual_input_note[]"]')?.value || "",
}));
actualInputRows.innerHTML = rows.map((row) => createActualInputRow(row)).join("");
attachTableEvents(actualInputRows);
}
function bindCollectionFieldBehaviors(container) {
container.querySelectorAll(".formatted-amount-input").forEach((input) => {
input.value = formatAmountInputValue(input.value);
input.addEventListener("input", () => {
input.value = formatAmountInputValue(input.value);
});
input.addEventListener("blur", () => {
input.value = formatAmountInputValue(input.value);
});
});
container.querySelectorAll(".date-text-input").forEach((input) => {
input.value = formatDateInputValue(input.value);
input.addEventListener("input", () => {
input.value = formatDateInputValue(input.value);
});
input.addEventListener("blur", () => {
input.value = formatDateInputValue(input.value);
});
});
container.querySelectorAll(".round-input").forEach((input) => {
input.addEventListener("input", () => {
input.value = digitsOnly(input.value).slice(0, 3);
});
input.addEventListener("blur", () => {
if (!input.value) {
input.value = "1";
}
});
});
}
function bindDateTextInputs(scope = document) {
scope.querySelectorAll(".date-text-input").forEach((input) => {
if (input.dataset.dateBound === "true") return;
input.dataset.dateBound = "true";
input.value = formatDateInputValue(input.value);
input.addEventListener("input", () => {
input.value = formatDateInputValue(input.value);
});
input.addEventListener("blur", () => {
input.value = formatDateInputValue(input.value);
});
});
}
function getSupportDeptMatch(keyword) {
const normalized = (keyword || "").trim().toLowerCase();
if (!normalized) return null;
const exact = supportDepartmentOptions.find((item) =>
item.support_dept_code.toLowerCase() === normalized || item.support_dept_name.toLowerCase() === normalized
);
if (exact) return exact;
const matches = supportDepartmentOptions.filter((item) =>
`${item.support_dept_code} ${item.support_dept_name}`.toLowerCase().includes(normalized)
);
return matches.length === 1 ? matches[0] : null;
}
function applySupportDepartment(item) {
if (!item) return;
supportDeptCodeInput.value = item.support_dept_code || "";
supportDeptCodeLookup.value = item.support_dept_code || "";
supportDeptNameInput.value = item.support_dept_name || "";
}
function attachTableEvents(container) {
if (container === collectionRows) {
bindCollectionFieldBehaviors(container);
}
container.querySelectorAll("[data-remove-row]").forEach((button) => {
button.onclick = () => {
button.closest("[data-collection-row], tr")?.remove();
if ([execLaborRows, execOutsourceRows, execCostPlanRows].includes(container)) {
refreshActualInputOptions();
}
updateComputedFields();
};
});
container.querySelectorAll("input, select").forEach((input) => {
input.oninput = () => {
if ([execLaborRows, execOutsourceRows, execCostPlanRows].includes(container)) {
refreshActualInputOptions();
}
updateComputedFields();
};
input.onchange = input.oninput;
});
}
function addRow(target, row = {}) {
if (target === "collection") {
collectionRows.insertAdjacentHTML("beforeend", createCollectionRow(row));
attachTableEvents(collectionRows);
}
if (target === "task-plan-department") {
taskPlanDepartmentRows.insertAdjacentHTML("beforeend", createTaskPlanRow("department", row));
attachTableEvents(taskPlanDepartmentRows);
}
if (target === "task-plan-outsource") {
taskPlanOutsourceRows.insertAdjacentHTML("beforeend", createTaskPlanRow("outsource", row));
attachTableEvents(taskPlanOutsourceRows);
}
if (target === "task-plan-joint") {
taskPlanJointRows.insertAdjacentHTML("beforeend", createTaskPlanRow("joint", row));
attachTableEvents(taskPlanJointRows);
}
if (target === "exec-labor") {
execLaborRows.insertAdjacentHTML("beforeend", createExecBudgetRow("labor", row));
attachTableEvents(execLaborRows);
refreshActualInputOptions();
}
if (target === "exec-outsource") {
execOutsourceRows.insertAdjacentHTML("beforeend", createExecBudgetRow("outsource", row));
attachTableEvents(execOutsourceRows);
refreshActualInputOptions();
}
if (target === "exec-cost-plan") {
execCostPlanRows.insertAdjacentHTML("beforeend", createExecBudgetRow("cost_plan", row));
attachTableEvents(execCostPlanRows);
refreshActualInputOptions();
}
if (target === "actual-input") {
actualInputRows.insertAdjacentHTML("beforeend", createActualInputRow(row));
attachTableEvents(actualInputRows);
}
updateComputedFields();
}
function renderSupportDeptResults(keyword, target) {
const normalized = keyword.trim().toLowerCase();
if (!normalized) {
target.classList.remove("open");
target.innerHTML = "";
return;
}
const matches = supportDepartmentOptions
.filter((item) => `${item.support_dept_code} ${item.support_dept_name}`.toLowerCase().includes(normalized))
.slice(0, 10);
if (!matches.length) {
target.classList.remove("open");
target.innerHTML = "";
return;
}
target.innerHTML = matches.map((item) => `
<button type="button" class="lookup-option" data-code="${escapeHtml(item.support_dept_code)}" data-name="${escapeHtml(item.support_dept_name)}">
<strong>${escapeHtml(item.support_dept_code)}</strong>
<span>${escapeHtml(item.support_dept_name)}</span>
</button>
`).join("");
target.classList.add("open");
target.querySelectorAll(".lookup-option").forEach((button) => {
button.addEventListener("click", () => {
applySupportDepartment({
support_dept_code: button.dataset.code || "",
support_dept_name: button.dataset.name || "",
});
supportDeptCodeResults.classList.remove("open");
supportDeptNameResults.classList.remove("open");
});
});
}
function bindSupportLookup(input, target) {
input?.addEventListener("input", (event) => {
renderSupportDeptResults(event.target.value, target);
const match = getSupportDeptMatch(event.target.value);
if (match) applySupportDepartment(match);
});
input?.addEventListener("blur", () => {
setTimeout(() => target.classList.remove("open"), 120);
const match = getSupportDeptMatch(input.value);
if (match) applySupportDepartment(match);
});
}
function resolveSupportDepartmentFromLookup() {
const match = getSupportDeptMatch(supportDeptCodeLookup?.value || "")
|| getSupportDeptMatch(supportDeptNameInput?.value || "");
if (match) {
applySupportDepartment(match);
} else {
supportDeptCodeInput.value = supportDeptCodeLookup?.value?.trim() || "";
supportDeptNameInput.value = supportDeptNameInput?.value?.trim() || "";
}
}
bindSupportLookup(supportDeptCodeLookup, supportDeptCodeResults);
bindSupportLookup(supportDeptNameInput, supportDeptNameResults);
bindDateTextInputs(document);
contractAmountInput?.addEventListener("input", updateComputedFields);
expectedAsRateInput?.addEventListener("change", updateComputedFields);
expectedSgaRateInput?.addEventListener("change", updateComputedFields);
document.querySelectorAll("[data-add-row]").forEach((button) => {
button.addEventListener("click", () => {
addRow(button.dataset.addRow);
});
});
(projectEdit.collection_entries || []).forEach((row) => addRow("collection", row));
if (!projectEdit.collection_entries?.length) {
addRow("collection");
}
(projectEdit.task_plan_entries || []).forEach((row) => {
if (row.group === "outsource") addRow("task-plan-outsource", row);
else if (row.group === "joint") addRow("task-plan-joint", row);
else addRow("task-plan-department", row);
});
if (!projectEdit.task_plan_entries?.length) {
addRow("task-plan-department");
}
(projectEdit.exec_budget_entries || []).forEach((row) => {
if (row.group === "outsource") addRow("exec-outsource", row);
else if (row.group === "cost_plan") addRow("exec-cost-plan", row);
else addRow("exec-labor", row);
});
if (!projectEdit.exec_budget_entries?.length) {
addRow("exec-labor");
}
(projectEdit.actual_input_entries || []).forEach((row) => addRow("actual-input", row));
if (!projectEdit.actual_input_entries?.length) {
addRow("actual-input");
}
document.getElementById("projectStatusForm")?.addEventListener("submit", () => {
resolveSupportDepartmentFromLookup();
});
updateComputedFields();
function renderRevenueLegend(items) {
const legend = document.getElementById("revenueLegend");
if (!legend) return;
legend.innerHTML = items.map((item) => `
<span class="legend-item">
<span class="legend-swatch" style="background:${item.color}"></span>
${item.label}
</span>
`).join("");
}
function renderRevenueChart() {
const svg = document.getElementById("revenueMixChart");
if (!svg) return;
const width = 1200;
const height = 390;
const margin = { top: 70, right: 10, bottom: 64, left: 64 };
const isMonthlyView = revenueMixMode === "monthly";
const items = [
{ key: "design_revenue", label: "설계", color: "#4f7cff" },
{ key: "design_other_revenue", label: "설계 외", color: "#67c7c9" },
{ key: "supervision_revenue", label: "감리", color: "#233a5a" },
{ key: "inspection_revenue", label: "점검", color: "#ffb54a" },
];
renderRevenueLegend(items);
const rows = revenueMix || [];
const values = rows.flatMap((row) => items.map((item) => Number(row[item.key] || 0)));
const maxValue = Math.max(...values, 1);
const tickStep = pickTickStep(maxValue);
const tickMax = Math.ceil(maxValue / tickStep) * tickStep;
const plotWidth = width - margin.left - margin.right;
const plotHeight = height - margin.top - margin.bottom;
const groupWidth = plotWidth / Math.max(rows.length, 1);
const barWidth = Math.min(groupWidth / Math.max(items.length + (isMonthlyView ? 0.9 : 0.7), 1), isMonthlyView ? 24 : 34);
const axisBaseY = margin.top + plotHeight;
let markup = `
<defs>
<linearGradient id="chartPanelBg" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.88" />
<stop offset="100%" stop-color="#f2f7fa" stop-opacity="0.96" />
</linearGradient>
<filter id="barShadow" x="-20%" y="-20%" width="140%" height="160%">
<feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="rgba(44, 68, 89, 0.14)" />
</filter>
</defs>
<rect x="0" y="0" width="${width}" height="${height}" rx="8" fill="url(#chartPanelBg)"></rect>
<rect x="${margin.left}" y="${margin.top}" width="${plotWidth}" height="${plotHeight}" rx="4" fill="rgba(255,255,255,0.66)"></rect>
`;
for (let value = 0; value <= tickMax; value += tickStep) {
const y = margin.top + plotHeight - (value / tickMax) * plotHeight;
markup += `<line x1="${margin.left}" y1="${y}" x2="${width - margin.right}" y2="${y}" stroke="#d6e2e8" stroke-dasharray="4 7"></line>`;
markup += `<text x="${margin.left - 10}" y="${y + 5}" text-anchor="end" fill="#62717f" font-size="12">${formatAxisLabel(value)}</text>`;
}
markup += `<line x1="${margin.left}" y1="${axisBaseY}" x2="${width - margin.right}" y2="${axisBaseY}" stroke="#8ba0ae" stroke-width="1.2"></line>`;
rows.forEach((row, rowIndex) => {
const baseX = margin.left + rowIndex * groupWidth;
items.forEach((item, itemIndex) => {
const value = Number(row[item.key] || 0);
const drawWidth = Math.max(barWidth - 5, 12);
const x = baseX + 8 + itemIndex * barWidth;
const barHeight = tickMax ? (value / tickMax) * plotHeight : 0;
const y = margin.top + plotHeight - barHeight;
markup += `<rect x="${x}" y="${y}" width="${drawWidth}" height="${barHeight}" rx="3" fill="${item.color}" filter="url(#barShadow)"></rect>`;
if (!isMonthlyView || barHeight > 26) {
markup += `<text x="${x + drawWidth / 2}" y="${Math.max(y - 8, 18)}" text-anchor="middle" fill="#344b5e" font-size="${isMonthlyView ? 9.5 : 10.5}" font-weight="800">${formatDisplayAmount(value)}</text>`;
}
});
markup += `<text x="${baseX + groupWidth / 2}" y="${height - 26}" text-anchor="middle" fill="#425464" font-size="${isMonthlyView ? 12 : 13}" font-weight="700">${row.label || row.year}</text>`;
});
svg.innerHTML = markup;
}
function buildSearchResults(keyword, dataset, renderItem) {
if (!keyword) return [];
const normalized = keyword.trim().toLowerCase();
if (!normalized) return [];
return dataset
.filter((item) => `${item.support_dept_code} ${item.support_dept_name}`.toLowerCase().includes(normalized))
.slice(0, 8)
.map(renderItem)
.join("");
}
function buildProjectCostDataset(keyword, yearFilterValue) {
const normalized = (keyword || "").trim().toLowerCase();
const filteredRows = projectCostRows.filter((item) => {
const yearMatched = !yearFilterValue || String(item.year) === String(yearFilterValue);
const keywordMatched = !normalized
|| `${item.support_dept_code} ${item.support_dept_name}`.toLowerCase().includes(normalized);
return yearMatched && keywordMatched;
});
return aggregateProjectCostRows(filteredRows);
}
function getAnalysisItem(code) {
const costItem = aggregateProjectCostRows(projectCostRows.filter((item) => item.support_dept_code === code))[0] || null;
const statusItem = projectStatusMap[code] || null;
if (!costItem && !statusItem) return null;
const supportDeptName = statusItem?.support_dept_name || costItem?.support_dept_name || code;
const supportDeptCode = code;
const totalRevenue = Number(statusItem?.total_revenue ?? costItem?.revenue_amount ?? 0);
const totalExpense = Number((statusItem?.total_cost || 0) + (statusItem?.total_sga || 0) || costItem?.expense_amount || 0);
const plannedTaskTotal = Number(statusItem?.task_plan_department_budget || 0) + Number(statusItem?.task_plan_outsource_budget || 0) + Number(statusItem?.task_plan_joint_operating_cost || 0);
const execBudgetTotal = Number(statusItem?.exec_budget_labor_by_grade || 0) + Number(statusItem?.exec_budget_outsource || 0) + Number(statusItem?.exec_budget_cost_plan || 0);
return {
...costItem,
...statusItem,
support_dept_name: supportDeptName,
support_dept_code: supportDeptCode,
revenue_amount: Number(costItem?.revenue_amount || 0),
expense_amount: Number(costItem?.expense_amount || 0),
total_revenue: totalRevenue,
total_expense: totalExpense,
planned_task_total: plannedTaskTotal,
exec_budget_total: execBudgetTotal,
operating_balance: totalRevenue - totalExpense,
year_range: formatYearRange(costItem || statusItem || {}),
};
}
function formatSignedAmount(value) {
const numeric = Number(value || 0);
const prefix = numeric > 0 ? "+" : "";
return `${prefix}${formatNumber(numeric)}`;
}
function differenceClass(value) {
const numeric = Number(value || 0);
if (numeric > 0) return "delta-positive";
if (numeric < 0) return "delta-negative";
return "";
}
function getBreakdownList(code, kind) {
const item = projectAccountBreakdowns?.[code] || {};
return Array.isArray(item?.[kind]) ? item[kind] : [];
}
function normalizeDetailEntries(entries, fallbackLabel) {
if (!Array.isArray(entries) || !entries.length) return [];
return entries.map((entry, index) => ({
label: entry.label || entry.reference || entry.note || `${fallbackLabel} ${index + 1}`,
amount: Number(entry.amount || 0),
})).filter((entry) => entry.label || entry.amount);
}
function renderComparisonDetailList(entries, emptyLabel) {
if (!entries.length) {
return `<div class="comparison-detail-item"><span>${emptyLabel}</span><strong>-</strong></div>`;
}
return entries.map((entry) => `
<div class="comparison-detail-item">
<span>${escapeHtml(entry.label || "-")}</span>
<strong>${formatNumber(entry.amount || 0)}</strong>
</div>
`).join("");
}
function buildComparisonDetails(item) {
return {
revenue: {
planned: normalizeDetailEntries(item.collection_entries, "수금 입력"),
actual: getBreakdownList(item.support_dept_code, "revenue"),
},
cost: {
planned: normalizeDetailEntries(item.task_plan_entries, "과업수행계획"),
actual: getBreakdownList(item.support_dept_code, "cost"),
},
sga: {
planned: item.expected_sga_budget ? [{ label: "예상 판관비", amount: Number(item.expected_sga_budget || 0) }] : [],
actual: getBreakdownList(item.support_dept_code, "sga"),
},
};
}
function renderAnalysisHero(item) {
const editUrl = getProjectEditUrl(item.support_dept_code);
return `
<div class="analysis-hero">
<div class="analysis-summary-card">
<div class="analysis-title-row">
<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}" 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-kpis">
<div class="analysis-kpi">
<span>실집행 수익</span>
<strong>${formatDisplayAmount(item.total_revenue)}</strong>
</div>
<div class="analysis-kpi">
<span>실집행 비용</span>
<strong>${formatDisplayAmount(item.total_expense)}</strong>
</div>
<div class="analysis-kpi">
<span>수금액</span>
<strong>${formatDisplayAmount(item.collection_amount)}</strong>
</div>
<div class="analysis-kpi">
<span>영업수지</span>
<strong>${formatDisplayAmount(item.operating_balance)}</strong>
</div>
</div>
</div>
<div class="analysis-summary-card">
<div class="analysis-stat-grid">
<div class="analysis-stat">
<span>진행율</span>
<strong>${Number(item.progress_rate || 0).toFixed(2)}%</strong>
</div>
<div class="analysis-stat">
<span>종료여부</span>
<strong>${detailValue(item.completion_status)}</strong>
</div>
<div class="analysis-stat">
<span>시작일</span>
<strong>${detailValue(item.project_start_date)}</strong>
</div>
<div class="analysis-stat">
<span>종료일</span>
<strong>${detailValue(item.project_end_date)}</strong>
</div>
</div>
</div>
</div>
`;
}
function renderAnalysisMetrics(item) {
return `
<h4>핵심 비교 지표</h4>
<div class="analysis-kpis">
<div class="analysis-kpi">
<span>연도별 집계 수익</span>
<strong>${formatDisplayAmount(item.revenue_amount)}</strong>
</div>
<div class="analysis-kpi">
<span>연도별 집계 지출</span>
<strong>${formatDisplayAmount(item.expense_amount)}</strong>
</div>
<div class="analysis-kpi">
<span>과업수행계획 합계</span>
<strong>${formatDisplayAmount(item.planned_task_total)}</strong>
</div>
<div class="analysis-kpi">
<span>실행예산 합계</span>
<strong>${formatDisplayAmount(item.exec_budget_total)}</strong>
</div>
</div>
`;
}
function renderAnalysisComparison(item) {
const details = buildComparisonDetails(item);
const rows = [
["수익", item.collection_amount || 0, item.total_revenue || 0, "revenue"],
["원가", item.planned_task_total || 0, item.total_cost || 0, "cost"],
["판관비", item.expected_sga_budget || 0, item.total_sga || 0, "sga"],
["실행예산", item.exec_budget_total || 0, item.item_investment || 0, ""],
];
return `
<h4>계획 대비 실제 비교</h4>
<table class="analysis-table">
<thead>
<tr>
<th>항목</th>
<th>계획/입력값</th>
<th>실제 집행값</th>
<th>차이</th>
</tr>
</thead>
<tbody>
${rows.map(([label, planned, actual, key]) => {
const diff = Number(actual || 0) - Number(planned || 0);
const detail = key ? details[key] : null;
const hasDetail = Boolean(detail);
return `
<tr class="comparison-main-row${hasDetail ? "" : " no-detail"}" ${hasDetail ? `data-detail-target="${key}"` : ""}>
<td>
${hasDetail ? `
<span class="comparison-toggle">
<svg class="comparison-chevron" viewBox="0 0 24 24" aria-hidden="true">
<path d="M9 6l6 6-6 6"></path>
</svg>
${label}
</span>
` : label}
</td>
<td>${formatNumber(planned)}</td>
<td>${formatNumber(actual)}</td>
<td class="${differenceClass(diff)}">${formatSignedAmount(diff)}</td>
</tr>
${hasDetail ? `
<tr class="comparison-detail-row" data-detail-row="${key}">
<td colspan="4" class="comparison-detail-cell">
<div class="comparison-detail-box">
<div class="comparison-detail-grid">
<section class="comparison-detail-group">
<h5>계획/입력 세부</h5>
<div class="comparison-detail-list">
${renderComparisonDetailList(detail.planned, "등록된 계획 세부 항목이 없습니다.")}
</div>
</section>
<section class="comparison-detail-group">
<h5>실제 집행 세부</h5>
<div class="comparison-detail-list">
${renderComparisonDetailList(detail.actual, "연결된 실제 계정이 없습니다.")}
</div>
</section>
</div>
</div>
</td>
</tr>` : ""}
`;
}).join("")}
</tbody>
</table>
`;
}
function renderAnalysisNotes(item) {
return `
<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(item.actual_labor || 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>${detailValue(item.notes)}</strong></div>
</div>
</div>
</div>
`;
}
function createSearchController(options) {
const input = document.getElementById(options.inputId);
const resultBox = document.getElementById(options.resultsId);
const cardsBox = document.getElementById(options.cardsId);
const emptyBox = document.getElementById(options.emptyId);
const selectedCodes = new Set();
function refreshEmptyState() {
if (!emptyBox) return;
emptyBox.style.display = cardsBox.children.length ? "none" : "";
}
function removeCard(code) {
const card = cardsBox.querySelector(`[data-card-code="${code}"]`);
if (card) {
card.remove();
}
selectedCodes.delete(code);
refreshEmptyState();
}
function attachCardEvents(card) {
card.querySelector("[data-remove-code]")?.addEventListener("click", () => {
removeCard(card.dataset.cardCode);
});
}
function addItem(code) {
if (selectedCodes.has(code)) return;
const item = options.lookup(code);
if (!item) return;
const wrapper = document.createElement("div");
wrapper.dataset.cardCode = code;
wrapper.innerHTML = options.renderCard(item);
cardsBox.appendChild(wrapper.firstElementChild);
selectedCodes.add(code);
attachCardEvents(cardsBox.lastElementChild);
refreshEmptyState();
}
input?.addEventListener("input", () => {
resultBox.innerHTML = buildSearchResults(input.value, options.dataset, options.renderResult);
resultBox.querySelectorAll("[data-add-code]").forEach((button) => {
button.addEventListener("click", () => {
addItem(button.dataset.addCode);
});
});
});
refreshEmptyState();
return { addItem };
}
function detailValue(value, fallback = "-") {
if (value === null || value === undefined || value === "") return fallback;
return value;
}
function getProjectEditUrl(code) {
return selectedYear ? `/projects?edit_code=${encodeURIComponent(code)}&year=${selectedYear}` : `/projects?edit_code=${encodeURIComponent(code)}`;
}
function formatYearRange(item) {
const startYear = Number(item.min_year || item.year || 0);
const endYear = Number(item.max_year || item.year || 0);
if (!startYear && !endYear) return "-";
if (startYear && endYear && startYear !== endYear) {
return `${startYear}년 - ${endYear}년`;
}
return `${endYear || startYear}년`;
}
function aggregateProjectCostRows(rows) {
const map = new Map();
rows.forEach((item) => {
const key = item.support_dept_code;
const current = map.get(key) || {
support_dept_code: item.support_dept_code,
support_dept_name: item.support_dept_name,
revenue_amount: 0,
expense_amount: 0,
min_year: item.year,
max_year: item.year,
matched_years: [],
};
current.revenue_amount += Number(item.revenue_amount || 0);
current.expense_amount += Number(item.expense_amount || 0);
current.min_year = Math.min(current.min_year ?? item.year, item.year);
current.max_year = Math.max(current.max_year ?? item.year, item.year);
current.matched_years.push(item.year);
map.set(key, current);
});
return [...map.values()].sort((a, b) => {
if ((b.max_year || 0) !== (a.max_year || 0)) return (b.max_year || 0) - (a.max_year || 0);
return String(a.support_dept_code).localeCompare(String(b.support_dept_code));
});
}
function renderCostSearchItem(item) {
const editUrl = getProjectEditUrl(item.support_dept_code);
return `
<div class="result-item">
<div class="result-title">
<strong>${item.support_dept_name}</strong>
<span>${item.support_dept_code} · ${formatYearRange(item)} · 수익 ${formatDisplayAmount(item.revenue_amount)} · 지출 ${formatDisplayAmount(item.expense_amount)}</span>
</div>
<div class="actions">
<button type="button" class="button-icon button-secondary" data-add-code="${item.support_dept_code}" title="불러오기" aria-label="불러오기">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 5v14"></path>
<path d="M5 12h14"></path>
</svg>
<span class="sr-only">불러오기</span>
</button>
<a class="button-link button-secondary button-icon" href="${editUrl}" 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>
`;
}
function renderCostCard(item) {
const margin = (item.revenue_amount || 0) - (item.expense_amount || 0);
const editUrl = getProjectEditUrl(item.support_dept_code);
return `
<article class="compare-card" data-card-code="${item.support_dept_code}">
<div class="compare-head">
<div>
<h3>${item.support_dept_name}</h3>
<p>${item.support_dept_code} · ${formatYearRange(item)} 프로젝트 현황</p>
</div>
<button type="button" class="remove-button" data-remove-code="${item.support_dept_code}" title="닫기" aria-label="닫기">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 6l12 12"></path>
<path d="M18 6L6 18"></path>
</svg>
<span class="sr-only">닫기</span>
</button>
</div>
<div class="info-metrics">
<div class="info-chip">
<div class="label">수익</div>
<div class="value">${formatDisplayAmount(item.revenue_amount)}</div>
</div>
<div class="info-chip">
<div class="label">지출액</div>
<div class="value">${formatDisplayAmount(item.expense_amount)}</div>
</div>
<div class="info-chip">
<div class="label">수익-지출</div>
<div class="value">${formatDisplayAmount(margin)}</div>
</div>
</div>
<div class="detail-block">
<h4>선택 범위 기준 원시값</h4>
<div class="detail-grid">
<div class="detail-item"><span>수익</span><strong>${formatNumber(item.revenue_amount)}</strong></div>
<div class="detail-item"><span>지출액</span><strong>${formatNumber(item.expense_amount)}</strong></div>
</div>
</div>
<div class="inline-actions">
<a class="button-link button-secondary button-icon" href="${editUrl}" 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>
</article>
`;
}
function renderDetailSearchItem(item) {
return `
<div class="result-item">
<div class="result-title">
<strong>${item.support_dept_name}</strong>
<span>${item.support_dept_code} · 진행율 ${Number(item.progress_rate || 0).toFixed(2)}% · 수금 ${formatDisplayAmount(item.collection_amount)}</span>
</div>
<button type="button" class="button-icon button-secondary" data-add-code="${item.support_dept_code}" title="불러오기" aria-label="불러오기">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 5v14"></path>
<path d="M5 12h14"></path>
</svg>
<span class="sr-only">불러오기</span>
</button>
</div>
`;
}
function renderDetailCard(item) {
const periodLabel = item.project_duration_days !== null && item.project_duration_days !== undefined
? `${item.project_duration_days}일`
: "-";
const editUrl = selectedYear ? `/projects?edit_code=${encodeURIComponent(item.support_dept_code)}&year=${selectedYear}` : `/projects?edit_code=${encodeURIComponent(item.support_dept_code)}`;
return `
<article class="compare-card" data-card-code="${item.support_dept_code}">
<div class="compare-head">
<div>
<h3>${item.support_dept_name}</h3>
<p>${item.support_dept_code} · 최신 반영 ${detailValue(item.latest_year, "-")}${detailValue(item.latest_month, "-")}월</p>
</div>
<button type="button" class="remove-button" data-remove-code="${item.support_dept_code}" title="닫기" aria-label="닫기">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 6l12 12"></path>
<path d="M18 6L6 18"></path>
</svg>
<span class="sr-only">닫기</span>
</button>
</div>
<div class="info-metrics">
<div class="info-chip">
<div class="label">진행율</div>
<div class="value">${Number(item.progress_rate || 0).toFixed(2)}%</div>
</div>
<div class="info-chip">
<div class="label">수금액</div>
<div class="value">${formatDisplayAmount(item.collection_amount)}</div>
</div>
<div class="info-chip">
<div class="label">종료여부</div>
<div class="value">${detailValue(item.completion_status)}</div>
</div>
</div>
<div class="detail-stack">
<div class="detail-block">
<h4>기본 정보</h4>
<div class="detail-grid">
<div class="detail-item"><span>시작일</span><strong>${detailValue(item.project_start_date)}</strong></div>
<div class="detail-item"><span>종료일</span><strong>${detailValue(item.project_end_date)}</strong></div>
<div class="detail-item"><span>진행 기간</span><strong>${periodLabel}</strong></div>
<div class="detail-item"><span>차수변경</span><strong>${detailValue(item.change_round)}</strong></div>
</div>
</div>
<div class="detail-block">
<h4>과업수행계획</h4>
<div class="detail-grid">
<div class="detail-item"><span>부서별 배분액</span><strong>${formatNumber(item.task_plan_department_budget)}</strong></div>
<div class="detail-item"><span>외주비</span><strong>${formatNumber(item.task_plan_outsource_budget)}</strong></div>
<div class="detail-item"><span>합사운영비</span><strong>${formatNumber(item.task_plan_joint_operating_cost)}</strong></div>
<div class="detail-item"><span>외주별 배분액</span><strong>${detailValue(item.task_plan_outsource_detail)}</strong></div>
</div>
</div>
<div class="detail-block">
<h4>실행예산</h4>
<div class="detail-grid">
<div class="detail-item"><span>직급별 인건비</span><strong>${formatNumber(item.exec_budget_labor_by_grade)}</strong></div>
<div class="detail-item"><span>외주비</span><strong>${formatNumber(item.exec_budget_outsource)}</strong></div>
<div class="detail-item"><span>원가계정별 비용계획</span><strong>${formatNumber(item.exec_budget_cost_plan)}</strong></div>
<div class="detail-item"><span>예상 A/S비</span><strong>${formatNumber(item.expected_as_cost)}</strong></div>
<div class="detail-item"><span>예상 판관비</span><strong>${formatNumber(item.expected_sga_budget)}</strong></div>
<div class="detail-item"><span>항목별투입액</span><strong>${formatNumber(item.item_investment)}</strong></div>
</div>
</div>
<div class="detail-block">
<h4>실제 집행</h4>
<div class="detail-grid">
<div class="detail-item"><span>실집행 수익</span><strong>${formatNumber(item.total_revenue)}</strong></div>
<div class="detail-item"><span>실집행 원가</span><strong>${formatNumber(item.total_cost)}</strong></div>
<div class="detail-item"><span>실집행 판관비</span><strong>${formatNumber(item.total_sga)}</strong></div>
<div class="detail-item"><span>실집행 인건비</span><strong>${formatNumber(item.actual_labor)}</strong></div>
<div class="detail-item"><span>실집행 외주비</span><strong>${formatNumber(item.actual_outsource)}</strong></div>
<div class="detail-item"><span>비고</span><strong>${detailValue(item.notes)}</strong></div>
</div>
</div>
</div>
<div class="inline-actions">
<a class="button-link button-secondary button-icon" href="${editUrl}" 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>
</article>
`;
}
(() => {
const input = document.getElementById("projectCostSearch");
const yearSelect = document.getElementById("projectCostYearFilter");
const dropdownBox = document.getElementById("projectSearchDropdown");
const listBox = document.getElementById("projectExplorerList");
const emptyBox = document.getElementById("projectExplorerEmpty");
const analysisContent = document.getElementById("projectAnalysisContent");
const analysisToggle = document.getElementById("toggleProjectAnalysis");
const heroBox = document.getElementById("projectAnalysisHero");
const metricsBox = document.getElementById("projectAnalysisMetrics");
const comparisonBox = document.getElementById("projectAnalysisComparison");
const notesBox = document.getElementById("projectAnalysisNotes");
let selectedCode = currentSelectedProjectCode;
let analysisOpen = false;
function setAnalysisVisibility(nextOpen) {
analysisOpen = Boolean(nextOpen);
analysisContent?.classList.toggle("is-hidden", !analysisOpen);
analysisToggle?.setAttribute("title", analysisOpen ? "프로젝트 상세 숨기기" : "프로젝트 상세 열기");
analysisToggle?.setAttribute("aria-label", analysisOpen ? "프로젝트 상세 숨기기" : "프로젝트 상세 열기");
}
function renderSuggestionList(dataset, keyword) {
const showDropdown = Boolean(keyword.trim());
dropdownBox?.classList.toggle("open", showDropdown);
if (!showDropdown) {
listBox.innerHTML = "";
emptyBox.textContent = "";
return;
}
if (!dataset.length) {
listBox.innerHTML = "";
emptyBox.textContent = "검색어나 연도 조건에 맞는 프로젝트가 없습니다.";
return;
}
listBox.innerHTML = dataset.map((item) => `
<article class="explorer-item ${selectedCode === item.support_dept_code ? "active" : ""}" data-code="${item.support_dept_code}">
<div class="explorer-head">
<div class="explorer-title">
<strong>${item.support_dept_name}</strong>
<span>${item.support_dept_code} · ${formatYearRange(item)}</span>
</div>
</div>
<div class="explorer-tags">
<span class="mini-tag">수익 ${formatDisplayAmount(item.revenue_amount)}</span>
<span class="mini-tag">지출 ${formatDisplayAmount(item.expense_amount)}</span>
<span class="mini-tag">범위 ${formatYearRange(item)}</span>
</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 = "";
}
function syncSelection(dataset) {
if (!dataset.length) {
heroBox.innerHTML = "";
metricsBox.innerHTML = "";
comparisonBox.innerHTML = "";
notesBox.innerHTML = "";
return;
}
if (!selectedCode || !dataset.some((item) => item.support_dept_code === selectedCode)) {
selectedCode = dataset[0].support_dept_code;
}
currentSelectedProjectCode = selectedCode;
renderAnalysis(getAnalysisItem(selectedCode));
}
function renderAnalysis(item) {
if (!item) {
heroBox.innerHTML = "";
metricsBox.innerHTML = "";
comparisonBox.innerHTML = "";
notesBox.innerHTML = "";
return;
}
heroBox.innerHTML = renderAnalysisHero(item);
metricsBox.innerHTML = renderAnalysisMetrics(item);
comparisonBox.innerHTML = renderAnalysisComparison(item);
notesBox.innerHTML = renderAnalysisNotes(item);
comparisonBox.querySelectorAll(".comparison-main-row[data-detail-target]").forEach((row) => {
row.addEventListener("click", () => {
const key = row.dataset.detailTarget;
const detailRow = comparisonBox.querySelector(`[data-detail-row="${key}"]`);
const isOpen = detailRow?.classList.contains("is-open");
row.classList.toggle("is-open", !isOpen);
detailRow?.classList.toggle("is-open", !isOpen);
});
});
}
function refresh() {
const keyword = input?.value || "";
const dataset = buildProjectCostDataset(keyword, yearSelect?.value || "");
syncSelection(dataset);
renderSuggestionList(dataset, keyword);
if (keyword.trim() && dataset.length) {
setAnalysisVisibility(true);
}
}
input?.addEventListener("input", refresh);
yearSelect?.addEventListener("change", refresh);
input?.addEventListener("focus", refresh);
analysisToggle?.addEventListener("click", () => {
setAnalysisVisibility(!analysisOpen);
});
document.addEventListener("click", (event) => {
if (!dropdownBox?.contains(event.target) && event.target !== input) {
dropdownBox?.classList.remove("open");
}
});
setAnalysisVisibility(false);
refresh();
})();
const modal = document.getElementById("projectModal");
const openModalButton = document.getElementById("openProjectModal");
const closeModalButton = document.getElementById("closeProjectModal");
if (openModalButton) {
openModalButton.addEventListener("click", () => {
if (currentSelectedProjectCode) {
window.location.href = getProjectEditUrl(currentSelectedProjectCode);
return;
}
modal.classList.add("open");
});
}
if (closeModalButton) {
closeModalButton.addEventListener("click", () => {
window.location.href = "/projects{% if selected_year %}?year={{ selected_year }}{% endif %}";
});
}
modal?.addEventListener("click", (event) => {
if (event.target === modal) {
window.location.href = "/projects{% if selected_year %}?year={{ selected_year }}{% endif %}";
}
});
renderRevenueChart();
</script>
{% endblock %}