4195 lines
166 KiB
HTML
4195 lines
166 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-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.toolbar-field h3 {
|
||
font-size: 16px;
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.toolbar-head-actions {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.toggle-analysis-button svg {
|
||
transition: transform 0.16s ease;
|
||
}
|
||
|
||
.toggle-analysis-button.is-open svg {
|
||
transform: rotate(180deg);
|
||
}
|
||
|
||
.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-block {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 10px;
|
||
min-width: 0;
|
||
flex: 1 1 auto;
|
||
}
|
||
|
||
.analysis-title {
|
||
display: grid;
|
||
gap: 6px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.related-trigger-button {
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.analysis-related-bar {
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
|
||
.analysis-related-bar:empty {
|
||
display: none;
|
||
}
|
||
|
||
.related-tag-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
|
||
.related-tag-label {
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.related-project-tag {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
border: 1px solid #d7e2eb;
|
||
background: #f7fbfe;
|
||
color: #29485f;
|
||
border-radius: 999px;
|
||
padding: 5px 10px;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.related-project-tag button {
|
||
width: 18px;
|
||
height: 18px;
|
||
min-width: 18px;
|
||
padding: 0;
|
||
border-radius: 999px;
|
||
border: 0;
|
||
background: rgba(41, 72, 95, 0.08);
|
||
color: #29485f;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.related-project-tag button:hover {
|
||
transform: none;
|
||
background: rgba(41, 72, 95, 0.14);
|
||
}
|
||
|
||
.related-modal-card {
|
||
width: min(680px, 100%);
|
||
max-height: min(70vh, 760px);
|
||
overflow: auto;
|
||
background: #ffffff;
|
||
border-radius: 16px;
|
||
border: 1px solid var(--line);
|
||
box-shadow: 0 24px 64px rgba(21, 24, 29, 0.18);
|
||
padding: 14px;
|
||
display: grid;
|
||
gap: 12px;
|
||
}
|
||
|
||
.related-modal-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.related-modal-title {
|
||
font-size: 16px;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.related-modal-body {
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
|
||
.related-search-results {
|
||
display: grid;
|
||
gap: 0;
|
||
border: 1px solid #e5ebf0;
|
||
border-radius: 14px;
|
||
overflow: hidden;
|
||
background: #fff;
|
||
}
|
||
|
||
.related-search-item {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 11px 14px;
|
||
border-top: 1px solid #eef2f5;
|
||
}
|
||
|
||
.related-search-item:first-child {
|
||
border-top: 0;
|
||
}
|
||
|
||
.related-search-copy {
|
||
display: grid;
|
||
gap: 4px;
|
||
text-align: left;
|
||
min-width: 0;
|
||
}
|
||
|
||
.related-search-copy strong {
|
||
font-size: 13px;
|
||
line-height: 1.45;
|
||
}
|
||
|
||
.related-search-copy span {
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
line-height: 1.45;
|
||
}
|
||
|
||
.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: 12px;
|
||
}
|
||
|
||
.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: 240px;
|
||
overflow: auto;
|
||
display: none;
|
||
}
|
||
|
||
.lookup-results.open {
|
||
display: grid;
|
||
justify-items: stretch;
|
||
align-items: start;
|
||
}
|
||
|
||
.lookup-option {
|
||
padding: 8px 10px;
|
||
border: none;
|
||
background: transparent;
|
||
display: grid;
|
||
justify-items: start;
|
||
align-items: start;
|
||
text-align: left;
|
||
color: var(--ink);
|
||
cursor: pointer;
|
||
border-bottom: 1px solid #eef1f4;
|
||
white-space: normal;
|
||
overflow-wrap: anywhere;
|
||
line-height: 1.35;
|
||
width: 100%;
|
||
}
|
||
|
||
.lookup-option:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.lookup-option strong,
|
||
.lookup-option span {
|
||
display: block;
|
||
width: 100%;
|
||
white-space: normal;
|
||
overflow-wrap: anywhere;
|
||
text-align: left;
|
||
}
|
||
|
||
.lookup-option strong {
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
line-height: 1.35;
|
||
}
|
||
|
||
.lookup-option span {
|
||
color: var(--muted);
|
||
font-size: 10px;
|
||
margin-top: 2px;
|
||
line-height: 1.35;
|
||
}
|
||
|
||
#supportDeptCodeResults,
|
||
#supportDeptNameResults {
|
||
text-align: left;
|
||
}
|
||
|
||
#supportDeptCodeResults .lookup-option,
|
||
#supportDeptNameResults .lookup-option {
|
||
display: block;
|
||
width: 100%;
|
||
justify-self: stretch;
|
||
text-align: left !important;
|
||
}
|
||
|
||
#supportDeptCodeResults .lookup-option strong,
|
||
#supportDeptCodeResults .lookup-option span,
|
||
#supportDeptNameResults .lookup-option strong,
|
||
#supportDeptNameResults .lookup-option span {
|
||
width: 100%;
|
||
margin: 0;
|
||
text-align: left !important;
|
||
}
|
||
|
||
.row-table-wrap {
|
||
overflow: visible;
|
||
border: 1px solid var(--line);
|
||
border-radius: 10px;
|
||
background: #ffffff;
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
|
||
.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: top;
|
||
}
|
||
|
||
.row-table th {
|
||
background: #f7f8fa;
|
||
color: #525a66;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.row-table td .inline-input {
|
||
width: 100%;
|
||
}
|
||
|
||
.row-table td .lookup-wrap {
|
||
width: 100%;
|
||
}
|
||
|
||
.row-table .lookup-results {
|
||
max-height: 150px;
|
||
}
|
||
|
||
.modal-col-dept,
|
||
.modal-col-work,
|
||
.modal-col-amount,
|
||
.modal-col-remove {
|
||
width: auto;
|
||
}
|
||
|
||
.modal-col-dept {
|
||
width: 34%;
|
||
}
|
||
|
||
.modal-col-work {
|
||
width: 28%;
|
||
}
|
||
|
||
.modal-col-amount {
|
||
width: 28%;
|
||
}
|
||
|
||
.modal-col-remove {
|
||
width: 10%;
|
||
}
|
||
|
||
.section-columns {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 8px;
|
||
align-items: start;
|
||
}
|
||
|
||
.mini-sector {
|
||
border: 1px solid #e6eaf0;
|
||
border-radius: 10px;
|
||
background: rgba(255, 255, 255, 0.96);
|
||
padding: 10px;
|
||
display: grid;
|
||
gap: 8px;
|
||
align-content: start;
|
||
overflow: visible;
|
||
}
|
||
|
||
.mini-sector h4 {
|
||
font-size: 12px;
|
||
line-height: 1.35;
|
||
}
|
||
|
||
.row-remove-button {
|
||
min-width: 16px;
|
||
width: 16px;
|
||
height: 16px;
|
||
padding: 0;
|
||
border-radius: 5px;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.row-remove-button svg {
|
||
width: 10px;
|
||
height: 10px;
|
||
stroke: currentColor;
|
||
fill: none;
|
||
stroke-width: 2;
|
||
stroke-linecap: round;
|
||
stroke-linejoin: round;
|
||
}
|
||
|
||
.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;
|
||
position: sticky;
|
||
top: -12px;
|
||
z-index: 4;
|
||
margin: -12px -12px 8px;
|
||
padding: 9px 12px;
|
||
background: rgba(255, 255, 255, 0.98);
|
||
border-bottom: 1px solid #e4e9ef;
|
||
backdrop-filter: blur(10px);
|
||
}
|
||
|
||
.modal-title {
|
||
font-size: 16px;
|
||
line-height: 1.2;
|
||
letter-spacing: -0.02em;
|
||
}
|
||
|
||
.modal-header-actions {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.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">
|
||
<div class="toolbar-head">
|
||
<h3>프로젝트 검색</h3>
|
||
<div class="toolbar-head-actions">
|
||
<button type="button" id="openRelatedProjectToolbar" class="button-icon button-secondary" title="연관 프로젝트 검색" aria-label="연관 프로젝트 검색">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M10.8 13.2l2.4-2.4"></path>
|
||
<path d="M7.5 14.7l-1.6 1.6a3 3 0 1 1-4.2-4.2l3-3a3 3 0 0 1 4.2 0"></path>
|
||
<path d="M16.5 9.3l1.6-1.6a3 3 0 1 1 4.2 4.2l-3 3a3 3 0 0 1-4.2 0"></path>
|
||
</svg>
|
||
<span class="sr-only">연관 프로젝트 검색</span>
|
||
</button>
|
||
<button type="button" id="toggleProjectAnalysis" class="button-icon button-secondary toggle-analysis-button" title="프로젝트 상세 열기/숨기기" aria-label="프로젝트 상세 열기/숨기기">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M6 9l6 6 6-6"></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>
|
||
<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">
|
||
<select id="projectCostYearFilter" aria-label="연도 필터">
|
||
<option value="">전체 연도</option>
|
||
{% for year in project_year_options %}
|
||
<option value="{{ year }}">{{ year }}</option>
|
||
{% endfor %}
|
||
</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="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">
|
||
<form action="/projects/save" method="post" class="modal-form" id="projectStatusForm" data-collab-form>
|
||
<div class="modal-header">
|
||
<h2 class="modal-title">사업현황 추가/수정</h2>
|
||
<div class="modal-header-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>
|
||
<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>
|
||
</div>
|
||
<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 }}">
|
||
<input type="hidden" id="exec_labor_rates_json" name="exec_labor_rates_json" value='{{ project_edit.exec_labor_rates | tojson | e }}'>
|
||
|
||
<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="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 row-remove-button" data-remove-row title="행 삭제" aria-label="행 삭제">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M6 12h12"></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>
|
||
<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">
|
||
<colgroup>
|
||
<col class="modal-col-dept">
|
||
<col class="modal-col-work">
|
||
<col class="modal-col-amount">
|
||
<col class="modal-col-remove">
|
||
</colgroup>
|
||
<thead>
|
||
<tr>
|
||
<th>부서명</th>
|
||
<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">
|
||
<colgroup>
|
||
<col class="modal-col-dept">
|
||
<col class="modal-col-work">
|
||
<col class="modal-col-amount">
|
||
<col class="modal-col-remove">
|
||
</colgroup>
|
||
<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">
|
||
<colgroup>
|
||
<col class="modal-col-dept">
|
||
<col class="modal-col-work">
|
||
<col class="modal-col-amount">
|
||
<col class="modal-col-remove">
|
||
</colgroup>
|
||
<thead>
|
||
<tr>
|
||
<th>부서명</th>
|
||
<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>
|
||
<div class="table-total">실행예산 합계: <span id="execBudgetTotalDisplay">0</span>원</div>
|
||
<div class="section-columns">
|
||
<section class="mini-sector">
|
||
<div class="table-tools">
|
||
<h4>인건비</h4>
|
||
<div style="display:inline-flex; gap:6px; align-items:center;">
|
||
<button type="button" class="button-link button-secondary mini-button" id="openLaborRateModal">기준인건비</button>
|
||
<button type="button" class="button-link button-secondary mini-button" data-add-row="exec-labor">추가</button>
|
||
</div>
|
||
</div>
|
||
<div class="row-table-wrap">
|
||
<table class="row-table compact">
|
||
<colgroup>
|
||
<col class="modal-col-dept">
|
||
<col class="modal-col-work">
|
||
<col class="modal-col-amount">
|
||
<col class="modal-col-remove">
|
||
</colgroup>
|
||
<thead>
|
||
<tr>
|
||
<th>직급</th>
|
||
<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">
|
||
<colgroup>
|
||
<col class="modal-col-dept">
|
||
<col class="modal-col-work">
|
||
<col class="modal-col-amount">
|
||
<col class="modal-col-remove">
|
||
</colgroup>
|
||
<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">
|
||
<colgroup>
|
||
<col class="modal-col-dept">
|
||
<col class="modal-col-work">
|
||
<col class="modal-col-amount">
|
||
<col class="modal-col-remove">
|
||
</colgroup>
|
||
<thead>
|
||
<tr>
|
||
<th>계정코드</th>
|
||
<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>
|
||
<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>
|
||
<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>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-backdrop" id="laborRateModal">
|
||
<div class="related-modal-card" style="max-width:720px;">
|
||
<div class="related-modal-head">
|
||
<h3 class="related-modal-title">기준인건비</h3>
|
||
<button type="button" class="button-secondary button-icon" id="closeLaborRateModal" 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="related-modal-body">
|
||
<div class="row-table-wrap">
|
||
<table class="row-table compact">
|
||
<colgroup>
|
||
<col style="width:40%">
|
||
<col style="width:60%">
|
||
</colgroup>
|
||
<thead>
|
||
<tr>
|
||
<th>직급</th>
|
||
<th>기준인건비</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="laborRateRows"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-backdrop" id="relatedProjectModal">
|
||
<div class="related-modal-card">
|
||
<div class="related-modal-head">
|
||
<h3 class="related-modal-title">연관 프로젝트 선택</h3>
|
||
<button type="button" class="button-secondary button-icon" id="closeRelatedProjectModal" 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="related-modal-body">
|
||
<input id="relatedProjectSearchInput" type="text" placeholder="지원부서코드 또는 사업명을 검색하세요." autocomplete="off">
|
||
<div class="related-search-results" id="relatedProjectSearchResults"></div>
|
||
<div class="project-search-empty" id="relatedProjectSearchEmpty"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block script %}
|
||
<script>
|
||
const selectedYear = {{ selected_year or "null" }};
|
||
const yearFilter = document.getElementById("year");
|
||
const supportDepartmentOptions = {{ support_department_options | tojson }};
|
||
const costDepartmentOptions = {{ cost_department_options | tojson }};
|
||
const costAccountOptions = {{ cost_account_options | tojson }};
|
||
const laborGradeOptions = ["사장", "부사장", "전무", "상무", "이사", "부장", "차장", "과장", "대리", "사원", "수석", "책임", "선임", "연구원"];
|
||
let projectEdit = {{ project_edit | tojson }};
|
||
const projectEditCache = new Map();
|
||
let modalOriginalEdit = null;
|
||
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 projectTypeInput = document.getElementById("project_type");
|
||
const completionStatusInput = document.getElementById("completion_status");
|
||
const changeRoundInput = document.getElementById("change_round");
|
||
const projectStartDateInput = document.getElementById("project_start_date");
|
||
const projectEndDateInput = document.getElementById("project_end_date");
|
||
const notesInput = document.getElementById("notes");
|
||
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 projectStatusForm = document.getElementById("projectStatusForm");
|
||
const editRevisionInput = projectStatusForm?.querySelector('input[name="edit_revision"]');
|
||
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");
|
||
const execLaborRatesInput = document.getElementById("exec_labor_rates_json");
|
||
const laborRateModal = document.getElementById("laborRateModal");
|
||
const laborRateRows = document.getElementById("laborRateRows");
|
||
const openLaborRateModalButton = document.getElementById("openLaborRateModal");
|
||
const closeLaborRateModalButton = document.getElementById("closeLaborRateModal");
|
||
let currentLaborRates = {};
|
||
|
||
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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
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 / 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 buildPositiveAxis(maxValue, tickCount = 4) {
|
||
const safeMax = Math.max(Number(maxValue || 0), 1);
|
||
const paddedMax = safeMax * (safeMax < 1000 ? 1.12 : 1.08);
|
||
const tickStep = pickTickStep(paddedMax / tickCount);
|
||
const tickMax = Math.max(tickStep, Math.ceil(paddedMax / tickStep) * tickStep);
|
||
return { tickStep, tickMax };
|
||
}
|
||
|
||
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 parseHours(value) {
|
||
const normalized = String(value ?? "").replace(/,/g, ".").replace(/[^\d.]/g, "");
|
||
const numeric = Number(normalized);
|
||
return Number.isFinite(numeric) ? numeric : 0;
|
||
}
|
||
|
||
function formatHourInputValue(value) {
|
||
const numeric = parseHours(value);
|
||
if (!numeric) return "";
|
||
return String(Number(numeric.toFixed(2)));
|
||
}
|
||
|
||
function readLaborRates() {
|
||
try {
|
||
return JSON.parse(execLaborRatesInput?.value || "{}") || {};
|
||
} catch (error) {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
function writeLaborRates(nextRates) {
|
||
currentLaborRates = { ...nextRates };
|
||
if (execLaborRatesInput) {
|
||
execLaborRatesInput.value = JSON.stringify(currentLaborRates);
|
||
}
|
||
}
|
||
|
||
function getLaborRate(grade) {
|
||
return Number(currentLaborRates?.[grade] || 0);
|
||
}
|
||
|
||
function calculateLaborAmount(grade, hours) {
|
||
return getLaborRate(grade) * parseHours(hours);
|
||
}
|
||
|
||
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 row-remove-button" data-remove-row title="행 삭제" aria-label="행 삭제">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M6 12h12"></path>
|
||
</svg>
|
||
<span class="sr-only">행 삭제</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function createTaskPlanRow(group, row = {}) {
|
||
const fieldMap = {
|
||
department: ["task_plan_department_dept[]", "task_plan_department_work[]", "task_plan_department_amount[]"],
|
||
outsource: ["task_plan_outsource_dept[]", "task_plan_outsource_work[]", "task_plan_outsource_amount[]"],
|
||
joint: ["task_plan_joint_dept[]", "task_plan_joint_work[]", "task_plan_joint_amount[]"],
|
||
};
|
||
const [deptName, workName, amountName] = fieldMap[group];
|
||
const deptValue = row.dept_name || row.label || "";
|
||
const workValue = row.work_name || row.note || "";
|
||
const deptField = `
|
||
<div class="lookup-wrap">
|
||
<input class="inline-input cost-dept-lookup" type="text" name="${deptName}" value="${escapeHtml(deptValue)}" autocomplete="off">
|
||
<div class="lookup-results cost-dept-results"></div>
|
||
</div>
|
||
`;
|
||
const workField = `<input class="inline-input" type="text" name="${workName}" value="${escapeHtml(workValue)}">`;
|
||
return `
|
||
<tr data-group="${group}">
|
||
<td>${deptField}</td>
|
||
<td>${workField}</td>
|
||
<td><input class="inline-input amount-input formatted-amount-input" data-amount-input type="text" inputmode="numeric" name="${amountName}" value="${escapeHtml(formatAmountInputValue(row.amount || ""))}"></td>
|
||
<td><button type="button" class="button-link danger-lite mini-button button-icon row-remove-button" data-remove-row title="행 삭제" aria-label="행 삭제">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M6 12h12"></path>
|
||
</svg>
|
||
<span class="sr-only">행 삭제</span>
|
||
</button></td>
|
||
</tr>
|
||
`;
|
||
}
|
||
|
||
function createExecBudgetRow(group, row = {}) {
|
||
if (group === "labor") {
|
||
const selectedGrade = row.grade || row.dept_name || "";
|
||
const inputHours = row.hours || row.work_name || "";
|
||
const amountValue = row.amount || calculateLaborAmount(selectedGrade, inputHours);
|
||
return `
|
||
<tr data-group="${group}">
|
||
<td>
|
||
<select class="inline-input exec-labor-grade" name="exec_labor_grade[]">
|
||
<option value="">직급 선택</option>
|
||
${laborGradeOptions.map((grade) => `<option value="${escapeHtml(grade)}" ${selectedGrade === grade ? "selected" : ""}>${escapeHtml(grade)}</option>`).join("")}
|
||
</select>
|
||
</td>
|
||
<td><input class="inline-input exec-labor-hours" type="text" inputmode="decimal" name="exec_labor_hours[]" value="${escapeHtml(formatHourInputValue(inputHours))}"></td>
|
||
<td><input class="inline-input amount-input formatted-amount-input exec-labor-amount" data-amount-input type="text" inputmode="numeric" name="exec_labor_amount[]" value="${escapeHtml(formatAmountInputValue(amountValue))}" readonly></td>
|
||
<td><button type="button" class="button-link danger-lite mini-button button-icon row-remove-button" data-remove-row title="행 삭제" aria-label="행 삭제">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M6 12h12"></path>
|
||
</svg>
|
||
<span class="sr-only">행 삭제</span>
|
||
</button></td>
|
||
</tr>
|
||
`;
|
||
}
|
||
const fieldMap = {
|
||
outsource: ["exec_outsource_dept[]", "exec_outsource_work[]", "exec_outsource_amount[]"],
|
||
cost_plan: ["exec_cost_plan_code[]", "exec_cost_plan_name[]", "exec_cost_plan_amount[]"],
|
||
};
|
||
const [firstName, secondName, amountName] = fieldMap[group];
|
||
const firstValue = row.dept_name || row.account_code || row.label || "";
|
||
const secondValue = row.work_name || row.account_name || row.note || "";
|
||
return `
|
||
<tr data-group="${group}">
|
||
<td>
|
||
${group === "cost_plan" ? `
|
||
<div class="lookup-wrap">
|
||
<input class="inline-input cost-account-code-lookup" type="text" name="${firstName}" value="${escapeHtml(firstValue)}" autocomplete="off">
|
||
<div class="lookup-results cost-account-results"></div>
|
||
</div>
|
||
` : `
|
||
<div class="lookup-wrap">
|
||
<input class="inline-input cost-dept-lookup" type="text" name="${firstName}" value="${escapeHtml(firstValue)}" autocomplete="off">
|
||
<div class="lookup-results cost-dept-results"></div>
|
||
</div>
|
||
`}
|
||
</td>
|
||
<td>
|
||
${group === "cost_plan" ? `
|
||
<div class="lookup-wrap">
|
||
<input class="inline-input cost-account-name-lookup" type="text" name="${secondName}" value="${escapeHtml(secondValue)}" autocomplete="off">
|
||
<div class="lookup-results cost-account-results"></div>
|
||
</div>
|
||
` : `<input class="inline-input" type="text" name="${secondName}" value="${escapeHtml(secondValue)}">`}
|
||
</td>
|
||
<td><input class="inline-input amount-input formatted-amount-input" data-amount-input type="text" inputmode="numeric" name="${amountName}" value="${escapeHtml(formatAmountInputValue(row.amount || ""))}"></td>
|
||
<td><button type="button" class="button-link danger-lite mini-button button-icon row-remove-button" data-remove-row title="행 삭제" aria-label="행 삭제">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M6 12h12"></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 dept = row.querySelector("td:nth-child(1) input, td:nth-child(1) select")?.value?.trim();
|
||
const work = row.querySelector("td:nth-child(2) input, td:nth-child(2) select")?.value?.trim();
|
||
const containerTitle = row.closest(".mini-sector")?.querySelector("h4")?.textContent?.trim() || "";
|
||
if (!dept && !work) return null;
|
||
const label = [dept, work].filter(Boolean).join(" / ");
|
||
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 amount-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 row-remove-button" data-remove-row title="행 삭제" aria-label="행 삭제">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M6 12h12"></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) {
|
||
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 (container === execLaborRows) {
|
||
recalculateExecLaborAmounts();
|
||
}
|
||
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 renderCostDeptResults(keyword, target, input) {
|
||
const normalized = keyword.trim().toLowerCase();
|
||
if (!normalized || !target) {
|
||
target?.classList.remove("open");
|
||
if (target) target.innerHTML = "";
|
||
return;
|
||
}
|
||
const matches = costDepartmentOptions
|
||
.filter((item) => String(item.cost_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-cost-dept-name="${escapeHtml(item.cost_dept_name)}">
|
||
<strong>${escapeHtml(item.cost_dept_name)}</strong>
|
||
</button>
|
||
`).join("");
|
||
target.classList.add("open");
|
||
target.querySelectorAll("[data-cost-dept-name]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
input.value = button.dataset.costDeptName || "";
|
||
target.classList.remove("open");
|
||
target.innerHTML = "";
|
||
input.focus();
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderCostAccountResults(keyword, target, row, mode) {
|
||
const normalized = keyword.trim().toLowerCase();
|
||
if (!normalized || !target) {
|
||
target?.classList.remove("open");
|
||
if (target) target.innerHTML = "";
|
||
return;
|
||
}
|
||
const matches = costAccountOptions
|
||
.filter((item) => `${item.account_code} ${item.account_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-account-code="${escapeHtml(item.account_code)}" data-account-name="${escapeHtml(item.account_name)}">
|
||
<strong>${escapeHtml(item.account_code)} · ${escapeHtml(item.account_name)}</strong>
|
||
</button>
|
||
`).join("");
|
||
target.classList.add("open");
|
||
target.querySelectorAll("[data-account-code]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
applyCostAccountSelection(row, {
|
||
account_code: button.dataset.accountCode || "",
|
||
account_name: button.dataset.accountName || "",
|
||
});
|
||
target.classList.remove("open");
|
||
target.innerHTML = "";
|
||
const nextField = mode === "code"
|
||
? row.querySelector(".cost-account-name-lookup")
|
||
: row.querySelector(".cost-account-code-lookup");
|
||
nextField?.focus();
|
||
});
|
||
});
|
||
}
|
||
|
||
function applyCostAccountSelection(row, item) {
|
||
row.querySelector(".cost-account-code-lookup").value = item.account_code || "";
|
||
row.querySelector(".cost-account-name-lookup").value = item.account_name || "";
|
||
}
|
||
|
||
function renderLaborRateRows() {
|
||
if (!laborRateRows) return;
|
||
laborRateRows.innerHTML = laborGradeOptions.map((grade) => `
|
||
<tr>
|
||
<td>${escapeHtml(grade)}</td>
|
||
<td><input class="inline-input amount-input formatted-amount-input labor-rate-input" type="text" inputmode="numeric" data-grade="${escapeHtml(grade)}" value="${escapeHtml(formatAmountInputValue(currentLaborRates?.[grade] || ""))}"></td>
|
||
</tr>
|
||
`).join("");
|
||
bindCollectionFieldBehaviors(laborRateRows);
|
||
laborRateRows.querySelectorAll(".labor-rate-input").forEach((input) => {
|
||
input.addEventListener("input", () => {
|
||
const grade = input.dataset.grade || "";
|
||
currentLaborRates[grade] = parseAmount(input.value);
|
||
writeLaborRates(currentLaborRates);
|
||
recalculateExecLaborAmounts();
|
||
});
|
||
});
|
||
}
|
||
|
||
function openLaborRateModal() {
|
||
renderLaborRateRows();
|
||
laborRateModal?.classList.add("open");
|
||
}
|
||
|
||
function closeLaborRateModal() {
|
||
laborRateModal?.classList.remove("open");
|
||
}
|
||
|
||
function recalculateExecLaborAmounts() {
|
||
execLaborRows.querySelectorAll("tr").forEach((row) => {
|
||
const grade = row.querySelector(".exec-labor-grade")?.value || "";
|
||
const hours = row.querySelector(".exec-labor-hours")?.value || "";
|
||
const amountInput = row.querySelector(".exec-labor-amount");
|
||
if (amountInput) {
|
||
amountInput.value = formatAmountInputValue(calculateLaborAmount(grade, hours));
|
||
}
|
||
});
|
||
updateComputedFields();
|
||
}
|
||
|
||
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() || "";
|
||
}
|
||
}
|
||
|
||
function createEmptyProjectEdit(code = "", name = "") {
|
||
return {
|
||
support_dept_code: code,
|
||
support_dept_name: name,
|
||
progress_rate: "",
|
||
contract_amount: "",
|
||
collection_amount: "",
|
||
collection_entries: [],
|
||
change_round: "",
|
||
item_investment: "",
|
||
task_plan_department_budget: "",
|
||
task_plan_outsource_budget: "",
|
||
task_plan_outsource_detail: "",
|
||
task_plan_joint_operating_cost: "",
|
||
task_plan_entries: [],
|
||
exec_budget_labor_by_grade: "",
|
||
exec_labor_rates: {},
|
||
exec_budget_outsource: "",
|
||
exec_budget_cost_plan: "",
|
||
exec_budget_entries: [],
|
||
actual_input_entries: [],
|
||
project_type: "",
|
||
expected_as_rate: "0",
|
||
expected_sga_rate: "13",
|
||
expected_as_cost: "",
|
||
expected_sga_budget: "",
|
||
project_start_date: "",
|
||
project_end_date: "",
|
||
completion_status: "",
|
||
notes: "",
|
||
updated_at: "",
|
||
};
|
||
}
|
||
|
||
function cloneProjectEditData(data) {
|
||
return JSON.parse(JSON.stringify(data || createEmptyProjectEdit()));
|
||
}
|
||
|
||
function clearDynamicRows() {
|
||
collectionRows.innerHTML = "";
|
||
taskPlanDepartmentRows.innerHTML = "";
|
||
taskPlanOutsourceRows.innerHTML = "";
|
||
taskPlanJointRows.innerHTML = "";
|
||
execLaborRows.innerHTML = "";
|
||
execOutsourceRows.innerHTML = "";
|
||
execCostPlanRows.innerHTML = "";
|
||
actualInputRows.innerHTML = "";
|
||
}
|
||
|
||
function applyProjectEditData(editData) {
|
||
const normalized = {
|
||
...createEmptyProjectEdit(),
|
||
...(editData || {}),
|
||
collection_entries: Array.isArray(editData?.collection_entries) ? editData.collection_entries : [],
|
||
task_plan_entries: Array.isArray(editData?.task_plan_entries) ? editData.task_plan_entries : [],
|
||
exec_budget_entries: Array.isArray(editData?.exec_budget_entries) ? editData.exec_budget_entries : [],
|
||
actual_input_entries: Array.isArray(editData?.actual_input_entries) ? editData.actual_input_entries : [],
|
||
};
|
||
projectEdit = normalized;
|
||
currentLaborRates = { ...(normalized.exec_labor_rates || {}) };
|
||
writeLaborRates(currentLaborRates);
|
||
if (normalized.support_dept_code) {
|
||
projectEditCache.set(normalized.support_dept_code, normalized);
|
||
}
|
||
currentSelectedProjectCode = normalized.support_dept_code || currentSelectedProjectCode || "";
|
||
supportDeptCodeInput.value = normalized.support_dept_code || "";
|
||
supportDeptCodeLookup.value = normalized.support_dept_code || "";
|
||
supportDeptNameInput.value = normalized.support_dept_name || "";
|
||
projectTypeInput.value = normalized.project_type || "";
|
||
contractAmountInput.value = normalized.contract_amount || "";
|
||
completionStatusInput.value = normalized.completion_status || "";
|
||
changeRoundInput.value = normalized.change_round || "";
|
||
projectStartDateInput.value = normalized.project_start_date || "";
|
||
projectEndDateInput.value = normalized.project_end_date || "";
|
||
notesInput.value = normalized.notes || "";
|
||
expectedAsRateInput.value = String(normalized.expected_as_rate || "0");
|
||
expectedSgaRateInput.value = String(normalized.expected_sga_rate || "13");
|
||
if (editRevisionInput) {
|
||
editRevisionInput.value = normalized.updated_at || "";
|
||
}
|
||
|
||
clearDynamicRows();
|
||
|
||
(normalized.collection_entries || []).forEach((row) => addRow("collection", row));
|
||
if (!normalized.collection_entries?.length) {
|
||
addRow("collection");
|
||
}
|
||
|
||
(normalized.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 (!normalized.task_plan_entries?.length) {
|
||
addRow("task-plan-department");
|
||
}
|
||
|
||
const execEntries = [...(normalized.exec_budget_entries || [])];
|
||
if (!execEntries.some((row) => row.group === "outsource")) {
|
||
execEntries.push(
|
||
...(normalized.task_plan_entries || [])
|
||
.filter((row) => row.group === "outsource")
|
||
.map((row) => ({ ...row, group: "outsource" }))
|
||
);
|
||
}
|
||
|
||
execEntries.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 (!execEntries.length) {
|
||
addRow("exec-labor");
|
||
}
|
||
|
||
(normalized.actual_input_entries || []).forEach((row) => addRow("actual-input", row));
|
||
if (!normalized.actual_input_entries?.length) {
|
||
addRow("actual-input");
|
||
}
|
||
|
||
bindDateTextInputs(document);
|
||
recalculateExecLaborAmounts();
|
||
updateComputedFields();
|
||
}
|
||
|
||
async function openProjectModalForCode(code = "") {
|
||
const normalizedCode = String(code || "").trim();
|
||
if (!normalizedCode) {
|
||
openProjectModalForFreshEntry(currentSelectedProjectCode || "");
|
||
return;
|
||
}
|
||
if (projectEditCache.has(normalizedCode)) {
|
||
const cachedData = cloneProjectEditData(projectEditCache.get(normalizedCode));
|
||
modalOriginalEdit = cloneProjectEditData(cachedData);
|
||
applyProjectEditData(cachedData);
|
||
modal?.classList.add("open");
|
||
return;
|
||
}
|
||
try {
|
||
const response = await fetch(`/projects/edit-data?code=${encodeURIComponent(normalizedCode)}`, {
|
||
credentials: "same-origin",
|
||
cache: "no-store",
|
||
headers: { "Accept": "application/json" },
|
||
});
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}`);
|
||
}
|
||
const payload = await response.json();
|
||
if (payload?.error) {
|
||
throw new Error(payload.error);
|
||
}
|
||
modalOriginalEdit = cloneProjectEditData(payload);
|
||
applyProjectEditData(payload);
|
||
modal?.classList.add("open");
|
||
} catch (error) {
|
||
console.error(error);
|
||
window.alert("사업현황 데이터를 불러오지 못했습니다.");
|
||
}
|
||
}
|
||
|
||
function openProjectModalForFreshEntry(code = "") {
|
||
const normalizedCode = String(code || "").trim();
|
||
const selectedItem = supportDepartmentOptions.find((item) => item.support_dept_code === normalizedCode);
|
||
const emptyData = createEmptyProjectEdit(normalizedCode, selectedItem?.support_dept_name || "");
|
||
modalOriginalEdit = cloneProjectEditData(emptyData);
|
||
applyProjectEditData(emptyData);
|
||
modal?.classList.add("open");
|
||
}
|
||
|
||
bindSupportLookup(supportDeptCodeLookup, supportDeptCodeResults);
|
||
bindSupportLookup(supportDeptNameInput, supportDeptNameResults);
|
||
bindDateTextInputs(document);
|
||
|
||
projectStatusForm?.addEventListener("input", (event) => {
|
||
const lookupInput = event.target.closest(".cost-dept-lookup");
|
||
if (lookupInput) {
|
||
const resultBox = lookupInput.parentElement?.querySelector(".cost-dept-results");
|
||
renderCostDeptResults(lookupInput.value || "", resultBox, lookupInput);
|
||
return;
|
||
}
|
||
const accountCodeInput = event.target.closest(".cost-account-code-lookup");
|
||
if (accountCodeInput) {
|
||
const row = accountCodeInput.closest("tr");
|
||
const resultBox = accountCodeInput.parentElement?.querySelector(".cost-account-results");
|
||
renderCostAccountResults(accountCodeInput.value || "", resultBox, row, "code");
|
||
return;
|
||
}
|
||
const accountNameInput = event.target.closest(".cost-account-name-lookup");
|
||
if (accountNameInput) {
|
||
const row = accountNameInput.closest("tr");
|
||
const resultBox = accountNameInput.parentElement?.querySelector(".cost-account-results");
|
||
renderCostAccountResults(accountNameInput.value || "", resultBox, row, "name");
|
||
}
|
||
});
|
||
|
||
projectStatusForm?.addEventListener("focusout", (event) => {
|
||
const lookupInput = event.target.closest(".cost-dept-lookup");
|
||
if (lookupInput) {
|
||
const resultBox = lookupInput.parentElement?.querySelector(".cost-dept-results");
|
||
setTimeout(() => resultBox?.classList.remove("open"), 120);
|
||
return;
|
||
}
|
||
const accountLookup = event.target.closest(".cost-account-code-lookup, .cost-account-name-lookup");
|
||
if (accountLookup) {
|
||
const resultBox = accountLookup.parentElement?.querySelector(".cost-account-results");
|
||
setTimeout(() => resultBox?.classList.remove("open"), 120);
|
||
}
|
||
});
|
||
|
||
openLaborRateModalButton?.addEventListener("click", openLaborRateModal);
|
||
closeLaborRateModalButton?.addEventListener("click", closeLaborRateModal);
|
||
laborRateModal?.addEventListener("click", (event) => {
|
||
if (event.target === laborRateModal) {
|
||
closeLaborRateModal();
|
||
}
|
||
});
|
||
|
||
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);
|
||
});
|
||
});
|
||
|
||
if (projectEdit?.support_dept_code) {
|
||
projectEditCache.set(projectEdit.support_dept_code, projectEdit);
|
||
}
|
||
applyProjectEditData(projectEdit);
|
||
|
||
document.getElementById("projectStatusForm")?.addEventListener("submit", () => {
|
||
resolveSupportDepartmentFromLookup();
|
||
});
|
||
|
||
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, tickMax } = buildPositiveAxis(maxValue, 4);
|
||
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) => ({
|
||
...entry,
|
||
label:
|
||
entry.label
|
||
|| [entry.grade, entry.hours ? `${entry.hours}h` : ""].filter(Boolean).join(" / ")
|
||
|| [entry.account_code, entry.account_name].filter(Boolean).join(" / ")
|
||
|| [entry.dept_name, entry.work_name].filter(Boolean).join(" / ")
|
||
|| 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 sumEntryAmounts(entries) {
|
||
return (entries || []).reduce((sum, entry) => sum + Number(entry?.amount || 0), 0);
|
||
}
|
||
|
||
function getExecBudgetGroupEntries(item, group, fallbackLabel) {
|
||
const entries = Array.isArray(item?.exec_budget_entries)
|
||
? item.exec_budget_entries.filter((entry) => (entry?.group || "labor") === group)
|
||
: [];
|
||
const normalized = normalizeDetailEntries(entries, fallbackLabel);
|
||
if (normalized.length) return normalized;
|
||
const fallbackAmountMap = {
|
||
labor: Number(item?.exec_budget_labor_by_grade || 0),
|
||
outsource: Number(item?.exec_budget_outsource || 0),
|
||
cost_plan: Number(item?.exec_budget_cost_plan || 0),
|
||
};
|
||
const amount = fallbackAmountMap[group] || 0;
|
||
return amount ? [{ label: fallbackLabel, amount }] : [];
|
||
}
|
||
|
||
function getCostBreakdownEntries(code) {
|
||
return Array.isArray(projectAccountBreakdowns?.[code]?.cost) ? projectAccountBreakdowns[code].cost : [];
|
||
}
|
||
|
||
function sumNumericFields(target, source, keys) {
|
||
keys.forEach((key) => {
|
||
target[key] = Number(target[key] || 0) + Number(source?.[key] || 0);
|
||
});
|
||
}
|
||
|
||
function mergeLabeledAmountEntries(entries) {
|
||
const merged = new Map();
|
||
(entries || []).forEach((entry) => {
|
||
const label = String(entry?.label || entry?.reference || entry?.note || "").trim() || "미분류";
|
||
const current = merged.get(label) || { label, amount: 0 };
|
||
current.amount += Number(entry?.amount || 0);
|
||
merged.set(label, current);
|
||
});
|
||
return [...merged.values()].sort((a, b) => Number(b.amount || 0) - Number(a.amount || 0));
|
||
}
|
||
|
||
function prefixProjectEntries(item, entries, fallbackLabel) {
|
||
return normalizeDetailEntries(entries, fallbackLabel).map((entry) => ({
|
||
...entry,
|
||
label: `${item.support_dept_code} · ${entry.label}`,
|
||
amount: Number(entry.amount || 0),
|
||
}));
|
||
}
|
||
|
||
function getCombinedBreakdownList(codes, kind) {
|
||
const merged = [];
|
||
(codes || []).forEach((code) => {
|
||
const entries = getBreakdownList(code, kind);
|
||
entries.forEach((entry) => {
|
||
merged.push({
|
||
label: `${code} · ${entry.label || "-"}`,
|
||
amount: Number(entry.amount || 0),
|
||
});
|
||
});
|
||
});
|
||
return mergeLabeledAmountEntries(merged);
|
||
}
|
||
|
||
function aggregateAnalysisItem(baseItem, relatedItems = []) {
|
||
if (!baseItem) return null;
|
||
const items = [baseItem, ...relatedItems].filter(Boolean);
|
||
const aggregated = {
|
||
...baseItem,
|
||
_aggregateCodes: items.map((item) => item.support_dept_code),
|
||
_relatedItems: relatedItems,
|
||
min_year: Math.min(...items.map((item) => Number(item.min_year || item.year || 0)).filter(Boolean)),
|
||
max_year: Math.max(...items.map((item) => Number(item.max_year || item.year || 0)).filter(Boolean)),
|
||
latest_year: baseItem.latest_year,
|
||
latest_month: baseItem.latest_month,
|
||
revenue_amount: 0,
|
||
expense_amount: 0,
|
||
total_revenue: 0,
|
||
total_expense: 0,
|
||
total_cost: 0,
|
||
total_sga: 0,
|
||
contract_amount: 0,
|
||
collection_amount: 0,
|
||
planned_task_total: 0,
|
||
exec_budget_total: 0,
|
||
operating_balance: 0,
|
||
expected_as_cost: 0,
|
||
expected_sga_budget: 0,
|
||
task_plan_department_budget: 0,
|
||
task_plan_outsource_budget: 0,
|
||
task_plan_joint_operating_cost: 0,
|
||
exec_budget_labor_by_grade: 0,
|
||
exec_budget_outsource: 0,
|
||
exec_budget_cost_plan: 0,
|
||
actual_labor: 0,
|
||
actual_outsource: 0,
|
||
item_investment: 0,
|
||
collection_entries: [],
|
||
task_plan_entries: [],
|
||
exec_budget_entries: [],
|
||
actual_input_entries: [],
|
||
};
|
||
const sumKeys = [
|
||
"revenue_amount",
|
||
"expense_amount",
|
||
"total_revenue",
|
||
"total_cost",
|
||
"total_sga",
|
||
"contract_amount",
|
||
"collection_amount",
|
||
"planned_task_total",
|
||
"exec_budget_total",
|
||
"expected_as_cost",
|
||
"expected_sga_budget",
|
||
"task_plan_department_budget",
|
||
"task_plan_outsource_budget",
|
||
"task_plan_joint_operating_cost",
|
||
"exec_budget_labor_by_grade",
|
||
"exec_budget_outsource",
|
||
"exec_budget_cost_plan",
|
||
"actual_labor",
|
||
"actual_outsource",
|
||
"item_investment",
|
||
];
|
||
items.forEach((item) => {
|
||
sumNumericFields(aggregated, item, sumKeys);
|
||
aggregated.collection_entries.push(...prefixProjectEntries(item, item.collection_entries, "수금 입력"));
|
||
aggregated.task_plan_entries.push(...prefixProjectEntries(item, item.task_plan_entries, "과업수행계획"));
|
||
aggregated.exec_budget_entries.push(...prefixProjectEntries(item, item.exec_budget_entries, "실행예산"));
|
||
aggregated.actual_input_entries.push(...prefixProjectEntries(item, item.actual_input_entries, "실투입"));
|
||
const itemLatest = Number(item.latest_year || 0) * 100 + Number(item.latest_month || 0);
|
||
const aggregatedLatest = Number(aggregated.latest_year || 0) * 100 + Number(aggregated.latest_month || 0);
|
||
if (itemLatest > aggregatedLatest) {
|
||
aggregated.latest_year = item.latest_year;
|
||
aggregated.latest_month = item.latest_month;
|
||
}
|
||
});
|
||
aggregated.total_expense = Number(aggregated.total_cost || 0) + Number(aggregated.total_sga || 0);
|
||
aggregated.operating_balance = Number(aggregated.total_revenue || 0) - Number(aggregated.total_expense || 0);
|
||
return aggregated;
|
||
}
|
||
|
||
function isDesignOutsourceEntry(entry) {
|
||
const label = String(entry?.label || "");
|
||
return label.includes("기술협력비");
|
||
}
|
||
|
||
function formatComparisonCell(value) {
|
||
return value === null || value === undefined ? "" : formatNumber(value);
|
||
}
|
||
|
||
function formatComparisonDiff(planned, actual) {
|
||
const diff = Number(planned || 0) - Number(actual || 0);
|
||
return `<span class="${differenceClass(diff)}">${formatSignedAmount(diff)}</span>`;
|
||
}
|
||
|
||
function buildComparisonDetails(item) {
|
||
const aggregateCodes = Array.isArray(item?._aggregateCodes) && item._aggregateCodes.length
|
||
? item._aggregateCodes
|
||
: [item.support_dept_code];
|
||
const revenuePlanned = normalizeDetailEntries(item.collection_entries, "수금 입력");
|
||
const revenueActual = getCombinedBreakdownList(aggregateCodes, "revenue");
|
||
const laborPlanned = getExecBudgetGroupEntries(item, "labor", "직급별 인건비");
|
||
const outsourcePlanned = getExecBudgetGroupEntries(item, "outsource", "외주비");
|
||
const overheadPlanned = getExecBudgetGroupEntries(item, "cost_plan", "계정별비용계획");
|
||
const costEntries = aggregateCodes.flatMap((code) => getCostBreakdownEntries(code).map((entry) => ({
|
||
label: `${code} · ${entry.label || "-"}`,
|
||
amount: Number(entry.amount || 0),
|
||
})));
|
||
const outsourceActual = costEntries.filter(isDesignOutsourceEntry);
|
||
const overheadActual = costEntries.filter((entry) => !isDesignOutsourceEntry(entry));
|
||
const sgaActual = getCombinedBreakdownList(aggregateCodes, "sga");
|
||
return {
|
||
collection: {
|
||
planned: revenuePlanned,
|
||
actual: revenueActual,
|
||
},
|
||
labor: {
|
||
planned: laborPlanned,
|
||
actual: [],
|
||
},
|
||
outsource: {
|
||
planned: outsourcePlanned,
|
||
actual: outsourceActual,
|
||
},
|
||
overhead: {
|
||
planned: overheadPlanned,
|
||
actual: overheadActual,
|
||
},
|
||
as: {
|
||
planned: item.expected_as_cost ? [{ label: "예상 A/S비", amount: Number(item.expected_as_cost || 0) }] : [],
|
||
actual: [],
|
||
},
|
||
sga: {
|
||
planned: item.expected_sga_budget ? [{ label: "예상 판관비", amount: Number(item.expected_sga_budget || 0) }] : [],
|
||
actual: sgaActual,
|
||
},
|
||
};
|
||
}
|
||
|
||
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-block">
|
||
<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>
|
||
<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-kpis">
|
||
<div class="analysis-kpi">
|
||
<span>계약금액</span>
|
||
<strong>${formatDisplayAmount(item.contract_amount)}</strong>
|
||
</div>
|
||
<div class="analysis-kpi">
|
||
<span>수금액</span>
|
||
<strong>${formatDisplayAmount(item.collection_amount)}</strong>
|
||
</div>
|
||
<div class="analysis-kpi">
|
||
<span>실집행 비용</span>
|
||
<strong>${formatDisplayAmount(item.total_expense)}</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 renderRelatedProjectBar(baseItem, relatedItems) {
|
||
if (!baseItem) return "";
|
||
const tags = (relatedItems || []).map((item) => `
|
||
<span class="related-project-tag">
|
||
${escapeHtml(item.support_dept_code)} · ${escapeHtml(item.support_dept_name)}
|
||
<button type="button" data-remove-related="${escapeHtml(item.support_dept_code)}" title="연관 프로젝트 제거" aria-label="연관 프로젝트 제거">×</button>
|
||
</span>
|
||
`).join("");
|
||
if (!tags) return "";
|
||
return `
|
||
<div class="related-tag-row">
|
||
<span class="related-tag-label">연관 프로젝트</span>
|
||
${tags}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderAnalysisMetrics(item) {
|
||
return `
|
||
<h4>핵심 비교 지표</h4>
|
||
<div class="analysis-kpis">
|
||
<div class="analysis-kpi">
|
||
<span>수금집계</span>
|
||
<strong>${formatDisplayAmount(item.collection_amount)}</strong>
|
||
</div>
|
||
<div class="analysis-kpi">
|
||
<span>비용집계</span>
|
||
<strong>${formatDisplayAmount(item.total_expense)}</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, "collection"],
|
||
["인건비", sumEntryAmounts(details.labor.planned), item.actual_labor || 0, "labor"],
|
||
["외주비", sumEntryAmounts(details.outsource.planned), sumEntryAmounts(details.outsource.actual), "outsource"],
|
||
["제경비", sumEntryAmounts(details.overhead.planned), sumEntryAmounts(details.overhead.actual), "overhead"],
|
||
["A/S비", item.expected_as_cost || 0, 0, "as"],
|
||
["판관비", item.expected_sga_budget || 0, item.total_sga || 0, "sga"],
|
||
];
|
||
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 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>${formatComparisonCell(planned)}</td>
|
||
<td>${formatComparisonCell(actual)}</td>
|
||
<td>${formatComparisonDiff(planned, actual)}</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}" 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>
|
||
`;
|
||
}
|
||
|
||
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}" 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>
|
||
</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 relatedToolbarButton = document.getElementById("openRelatedProjectToolbar");
|
||
const relatedBarBox = document.getElementById("projectRelatedBar");
|
||
const heroBox = document.getElementById("projectAnalysisHero");
|
||
const metricsBox = document.getElementById("projectAnalysisMetrics");
|
||
const comparisonBox = document.getElementById("projectAnalysisComparison");
|
||
const notesBox = document.getElementById("projectAnalysisNotes");
|
||
const relatedModal = document.getElementById("relatedProjectModal");
|
||
const relatedModalCloseButton = document.getElementById("closeRelatedProjectModal");
|
||
const relatedSearchInput = document.getElementById("relatedProjectSearchInput");
|
||
const relatedSearchResults = document.getElementById("relatedProjectSearchResults");
|
||
const relatedSearchEmpty = document.getElementById("relatedProjectSearchEmpty");
|
||
const RELATED_PROJECT_STORAGE_KEY = "project-related-selections-v1";
|
||
let selectedCode = currentSelectedProjectCode;
|
||
let analysisOpen = false;
|
||
const relatedProjectSelections = loadRelatedProjectSelections();
|
||
|
||
function loadRelatedProjectSelections() {
|
||
try {
|
||
const raw = window.localStorage.getItem(RELATED_PROJECT_STORAGE_KEY);
|
||
if (!raw) return new Map();
|
||
const parsed = JSON.parse(raw);
|
||
if (!parsed || typeof parsed !== "object") return new Map();
|
||
return new Map(
|
||
Object.entries(parsed).map(([code, values]) => [
|
||
code,
|
||
new Set(Array.isArray(values) ? values.filter((value) => typeof value === "string" && value.trim()) : []),
|
||
]),
|
||
);
|
||
} catch (error) {
|
||
console.warn("Failed to load related project selections", error);
|
||
return new Map();
|
||
}
|
||
}
|
||
|
||
function persistRelatedProjectSelections() {
|
||
try {
|
||
const payload = Object.fromEntries(
|
||
[...relatedProjectSelections.entries()].map(([code, values]) => [code, [...values]]),
|
||
);
|
||
window.localStorage.setItem(RELATED_PROJECT_STORAGE_KEY, JSON.stringify(payload));
|
||
} catch (error) {
|
||
console.warn("Failed to persist related project selections", error);
|
||
}
|
||
}
|
||
|
||
function setAnalysisVisibility(nextOpen) {
|
||
analysisOpen = Boolean(nextOpen);
|
||
analysisContent?.classList.toggle("is-hidden", !analysisOpen);
|
||
analysisToggle?.classList.toggle("is-open", analysisOpen);
|
||
analysisToggle?.setAttribute("aria-expanded", analysisOpen ? "true" : "false");
|
||
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) {
|
||
relatedBarBox.innerHTML = "";
|
||
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 getRelatedCodes(baseCode) {
|
||
return [...(relatedProjectSelections.get(baseCode) || new Set())];
|
||
}
|
||
|
||
function getRelatedItems(baseCode) {
|
||
return getRelatedCodes(baseCode)
|
||
.map((code) => getAnalysisItem(code))
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function openRelatedProjectPicker() {
|
||
if (!selectedCode) return;
|
||
relatedModal?.classList.add("open");
|
||
relatedSearchInput.value = "";
|
||
refreshRelatedProjectResults();
|
||
relatedSearchInput?.focus();
|
||
}
|
||
|
||
function closeRelatedProjectPicker() {
|
||
relatedModal?.classList.remove("open");
|
||
relatedSearchInput.value = "";
|
||
relatedSearchResults.innerHTML = "";
|
||
relatedSearchEmpty.textContent = "";
|
||
}
|
||
|
||
function addRelatedProject(code) {
|
||
if (!selectedCode || !code || code === selectedCode) return;
|
||
const set = new Set(relatedProjectSelections.get(selectedCode) || []);
|
||
set.add(code);
|
||
relatedProjectSelections.set(selectedCode, set);
|
||
persistRelatedProjectSelections();
|
||
closeRelatedProjectPicker();
|
||
renderAnalysis(getAnalysisItem(selectedCode));
|
||
}
|
||
|
||
function removeRelatedProject(code) {
|
||
if (!selectedCode) return;
|
||
const set = new Set(relatedProjectSelections.get(selectedCode) || []);
|
||
set.delete(code);
|
||
if (set.size) {
|
||
relatedProjectSelections.set(selectedCode, set);
|
||
} else {
|
||
relatedProjectSelections.delete(selectedCode);
|
||
}
|
||
persistRelatedProjectSelections();
|
||
renderAnalysis(getAnalysisItem(selectedCode));
|
||
}
|
||
|
||
function refreshRelatedProjectResults() {
|
||
if (!selectedCode) {
|
||
relatedSearchResults.innerHTML = "";
|
||
relatedSearchEmpty.textContent = "먼저 프로젝트를 선택하세요.";
|
||
return;
|
||
}
|
||
const keyword = (relatedSearchInput?.value || "").trim().toLowerCase();
|
||
const alreadySelected = new Set(getRelatedCodes(selectedCode));
|
||
const baseDataset = buildProjectCostDataset("", yearSelect?.value || "");
|
||
const candidates = baseDataset.filter((item) => {
|
||
if (item.support_dept_code === selectedCode) return false;
|
||
if (alreadySelected.has(item.support_dept_code)) return false;
|
||
if (!keyword) return true;
|
||
return `${item.support_dept_code} ${item.support_dept_name}`.toLowerCase().includes(keyword);
|
||
}).slice(0, 20);
|
||
|
||
if (!candidates.length) {
|
||
relatedSearchResults.innerHTML = "";
|
||
relatedSearchEmpty.textContent = keyword ? "검색 결과가 없습니다." : "추가 가능한 연관 프로젝트가 없습니다.";
|
||
return;
|
||
}
|
||
|
||
relatedSearchResults.innerHTML = candidates.map((item) => `
|
||
<div class="related-search-item">
|
||
<div class="related-search-copy">
|
||
<strong>${escapeHtml(item.support_dept_name)}</strong>
|
||
<span>${escapeHtml(item.support_dept_code)} · ${escapeHtml(formatYearRange(item))} · 수익 ${escapeHtml(formatDisplayAmount(item.revenue_amount))} · 지출 ${escapeHtml(formatDisplayAmount(item.expense_amount))}</span>
|
||
</div>
|
||
<button type="button" class="button-icon button-secondary" data-add-related="${escapeHtml(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>
|
||
`).join("");
|
||
relatedSearchEmpty.textContent = "";
|
||
relatedSearchResults.querySelectorAll("[data-add-related]").forEach((button) => {
|
||
button.addEventListener("click", () => addRelatedProject(button.dataset.addRelated || ""));
|
||
});
|
||
}
|
||
|
||
function renderAnalysis(item) {
|
||
if (!item) {
|
||
relatedBarBox.innerHTML = "";
|
||
heroBox.innerHTML = "";
|
||
metricsBox.innerHTML = "";
|
||
comparisonBox.innerHTML = "";
|
||
notesBox.innerHTML = "";
|
||
return;
|
||
}
|
||
const relatedItems = getRelatedItems(item.support_dept_code);
|
||
const aggregateItem = aggregateAnalysisItem(item, relatedItems);
|
||
relatedBarBox.innerHTML = renderRelatedProjectBar(item, relatedItems);
|
||
heroBox.innerHTML = renderAnalysisHero(aggregateItem);
|
||
metricsBox.innerHTML = renderAnalysisMetrics(aggregateItem);
|
||
comparisonBox.innerHTML = renderAnalysisComparison(aggregateItem);
|
||
notesBox.innerHTML = renderAnalysisNotes(aggregateItem);
|
||
|
||
relatedBarBox.querySelectorAll("[data-remove-related]").forEach((button) => {
|
||
button.addEventListener("click", () => removeRelatedProject(button.dataset.removeRelated || ""));
|
||
});
|
||
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);
|
||
relatedSearchInput?.addEventListener("input", refreshRelatedProjectResults);
|
||
relatedModalCloseButton?.addEventListener("click", closeRelatedProjectPicker);
|
||
relatedModal?.addEventListener("click", (event) => {
|
||
if (event.target === relatedModal) {
|
||
closeRelatedProjectPicker();
|
||
}
|
||
});
|
||
analysisToggle?.addEventListener("click", () => {
|
||
setAnalysisVisibility(!analysisOpen);
|
||
});
|
||
|
||
relatedToolbarButton?.addEventListener("click", () => {
|
||
openRelatedProjectPicker();
|
||
});
|
||
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", async () => {
|
||
openProjectModalForFreshEntry("");
|
||
});
|
||
}
|
||
|
||
document.addEventListener("click", async (event) => {
|
||
const editButton = event.target.closest("[data-edit-code]");
|
||
if (!editButton) return;
|
||
event.preventDefault();
|
||
await openProjectModalForCode(editButton.dataset.editCode || "");
|
||
});
|
||
|
||
if (modal?.classList.contains("open") && projectEdit.support_dept_code) {
|
||
modalOriginalEdit = cloneProjectEditData(projectEdit);
|
||
applyProjectEditData(projectEdit);
|
||
}
|
||
|
||
function closeProjectModal() {
|
||
if (modalOriginalEdit) {
|
||
applyProjectEditData(cloneProjectEditData(modalOriginalEdit));
|
||
}
|
||
modal?.classList.remove("open");
|
||
supportDeptCodeResults.classList.remove("open");
|
||
supportDeptNameResults.classList.remove("open");
|
||
document.activeElement?.blur?.();
|
||
}
|
||
|
||
if (closeModalButton) {
|
||
closeModalButton.addEventListener("click", () => {
|
||
closeProjectModal();
|
||
});
|
||
}
|
||
|
||
modal?.addEventListener("click", (event) => {
|
||
if (event.target === modal) {
|
||
closeProjectModal();
|
||
}
|
||
});
|
||
|
||
renderRevenueChart();
|
||
</script>
|
||
{% endblock %}
|