Files
HM_project_Viewer_Board/templates/projects.html
T

9312 lines
395 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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: transparent;
border: 0;
border-top: 1px solid var(--line);
border-radius: 0;
padding: 14px 0 0;
color: var(--muted);
line-height: 1.7;
font-size: 14px;
}
.metric-note strong {
color: var(--ink);
}
.chart-stage {
background: transparent;
border: 0;
border-top: 1px solid var(--line);
border-radius: 0;
padding: 16px 0 0;
box-shadow: none;
}
.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: transparent;
border: 0;
border-radius: 0;
padding: 0;
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;
overflow: visible;
position: relative;
z-index: 90;
}
.analysis-shell {
display: grid;
gap: 16px;
overflow: visible;
}
.analysis-toolbar {
display: grid;
gap: 12px;
align-items: start;
position: relative;
z-index: 120;
}
.analysis-main {
display: grid;
gap: 16px;
overflow: visible;
}
.analysis-summary-list {
display: none;
}
.toolbar-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
min-height: var(--toolbar-row-height);
margin-bottom: 12px;
padding: 0 0 12px;
border-bottom: 1px solid var(--line);
}
.toolbar-head h3,
.toolbar-head h4 {
margin: 0;
height: var(--toolbar-row-height);
display: inline-flex;
align-items: center;
line-height: 1.2;
padding: 0;
}
.toolbar-title-group {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
min-width: 0;
flex: 1;
}
.analysis-toolbar h3 {
font-size: 16px;
margin-bottom: 0;
}
.toolbar-head-actions {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: var(--toolbar-row-height);
}
.project-quick-link-bar {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
min-width: 0;
}
.project-quick-link {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
max-width: 190px;
min-height: 27px;
padding: 3px 12px;
border: 1px solid var(--line);
border-radius: 999px;
background: rgba(255, 255, 255, 0.96);
color: var(--ink);
font-size: 12px;
font-weight: 700;
line-height: 1.05;
cursor: pointer;
transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
}
.project-quick-link:hover {
border-color: #c8d7e5;
box-shadow: 0 10px 18px rgba(22, 33, 50, 0.08);
transform: translateY(-1px);
}
.project-quick-link span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-quick-link-remove {
border: none;
background: transparent;
padding: 0;
margin: 0;
color: var(--muted);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex: 0 0 auto;
width: 14px;
height: 14px;
min-width: 14px;
min-height: 14px;
max-width: 14px;
max-height: 14px;
border-radius: 999px;
aspect-ratio: 1 / 1;
background: rgba(15, 23, 42, 0.08);
box-shadow: none !important;
transform: none !important;
line-height: 1;
}
.project-quick-link-remove:hover {
background: rgba(15, 23, 42, 0.14);
}
.project-quick-link-remove svg {
width: 14px;
height: 14px;
stroke: currentColor;
stroke-width: 2;
fill: none;
}
.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) 180px 236px;
gap: 12px;
align-items: center;
min-height: var(--toolbar-row-height);
}
.project-search-field {
position: relative;
display: grid;
gap: 8px;
z-index: 180;
}
.project-search-field > input,
.project-search-controls .field > select,
.uncontracted-toolbar .field > select {
height: 44px;
min-height: 44px;
padding-top: 0;
padding-bottom: 0;
line-height: 44px;
}
.project-search-dropdown {
display: none;
position: absolute;
top: calc(100% + 8px);
left: 0;
right: 0;
z-index: 4000;
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;
gap: 18px;
align-items: stretch;
}
.analysis-hero-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 14px;
align-items: start;
}
.analysis-title {
display: grid;
gap: 8px;
min-width: 0;
width: 100%;
}
.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-meta-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 4px;
}
.analysis-stat-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 12px;
}
.analysis-stat {
border: 0;
border-bottom: 1px solid rgba(217, 221, 227, 0.9);
border-radius: 0;
background: transparent;
padding: 0 0 12px;
display: grid;
gap: 5px;
}
.analysis-stat span {
color: var(--muted);
font-size: 12px;
}
.analysis-stat strong {
font-size: 22px;
line-height: 1.2;
}
.analysis-panel {
background: transparent;
border: 0;
border-top: 1px solid var(--line);
border-radius: 0;
padding: 18px 0 0;
display: grid;
gap: 14px;
}
.analysis-panel:empty {
display: none;
border-top: 0;
padding-top: 0;
}
.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-stack {
display: grid;
gap: 8px;
width: 100%;
}
.related-tag-label {
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.related-project-tag {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
max-width: 100%;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.96);
color: var(--ink);
border-radius: 999px;
min-height: 10px;
padding: 0 8px;
font-size: 12px;
font-weight: 700;
line-height: 1;
}
.related-project-tag.is-muted {
opacity: 0.72;
}
.related-project-main {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
flex: 1 1 auto;
}
.related-project-open {
border: 0;
background: transparent;
color: inherit;
font-size: 12px;
font-weight: 800;
line-height: 1;
padding: 0;
text-decoration: none;
cursor: pointer;
box-shadow: none !important;
transform: none !important;
flex: 0 0 auto;
}
.related-project-open:hover {
color: inherit;
background: transparent;
text-decoration: none;
}
.related-project-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #486072;
line-height: 1;
}
.related-project-actions {
display: inline-flex;
align-items: center;
gap: 4px;
flex: 0 0 auto;
}
.related-project-tag .related-project-toggle,
.related-project-tag [data-remove-related] {
width: 12px;
height: 12px;
min-width: 12px;
min-height: 12px;
max-width: 12px;
max-height: 12px;
padding: 0;
border-radius: 0;
aspect-ratio: 1 / 1;
border: 0;
background: transparent;
color: var(--muted);
box-shadow: none !important;
transform: none !important;
font-size: 10px;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
}
.related-project-tag .related-project-toggle:hover,
.related-project-tag [data-remove-related]:hover {
transform: none;
background: transparent;
color: var(--ink);
}
.related-project-toggle {
font-weight: 900;
}
.analysis-inline-note {
width: 100%;
min-height: 40px;
padding: 8px 10px;
border: 1px solid #d7dfe7;
border-radius: 12px;
background: #fff;
font: inherit;
color: var(--ink);
resize: vertical;
}
.related-modal-card {
width: min(680px, 100%);
max-height: min(70vh, 760px);
overflow: hidden;
background: #ffffff;
border-radius: 16px;
border: 1px solid var(--line);
box-shadow: 0 24px 64px rgba(21, 24, 29, 0.18);
padding: 14px;
display: flex;
flex-direction: column;
gap: 12px;
}
.related-modal-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.related-modal-head--singleline {
flex-wrap: nowrap;
align-items: center;
justify-content: space-between;
}
.related-modal-title {
font-size: 16px;
font-weight: 800;
line-height: 1.2;
margin: 0;
}
.related-modal-body {
display: grid;
gap: 10px;
overflow: auto;
min-height: 0;
overscroll-behavior: contain;
}
.related-modal-body .compact-table thead th {
position: sticky;
top: 0;
z-index: 2;
background: #f5f8fb;
box-shadow: inset 0 -1px 0 #e5ebf0;
}
.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-head-inline {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
background: #f5f8fb;
padding: 8px 10px;
}
.comparison-detail-head-inline h5 {
background: transparent;
padding: 0;
margin: 0;
}
.comparison-detail-list {
display: grid;
gap: 0;
}
.comparison-detail-table-wrap {
overflow: auto;
border-top: 1px solid #eef2f5;
}
.comparison-detail-table {
width: 100%;
min-width: 520px;
border-collapse: collapse;
font-size: 12px;
}
.comparison-detail-table th,
.comparison-detail-table td {
padding: 8px 10px;
border-top: 1px solid #eef2f5;
text-align: left;
white-space: nowrap;
}
.comparison-detail-table thead th {
background: #f8fafc;
color: #547085;
font-weight: 800;
border-top: 0;
}
.comparison-detail-table td:last-child,
.comparison-detail-table th:last-child {
text-align: right;
}
.comparison-inline-detail {
display: none;
padding: 0 10px 10px;
border-top: 1px solid #eef2f5;
background: #fbfcfd;
}
.comparison-inline-detail.is-open {
display: block;
}
.comparison-inline-detail-note {
padding: 12px 0;
color: var(--muted);
font-size: 12px;
line-height: 1.6;
text-align: center;
}
.comparison-detail-split {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.comparison-detail-split .comparison-detail-group {
min-width: 0;
}
.comparison-detail-checkbox {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 700;
color: #547085;
}
.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;
align-items: center;
min-height: 38px;
box-sizing: border-box;
}
.comparison-detail-item:first-child {
border-top: 0;
}
.comparison-detail-item span:first-child {
color: var(--muted);
line-height: 1.45;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.comparison-detail-item strong {
font-size: 12px;
text-align: right;
}
.comparison-inline-toggle {
cursor: pointer;
text-decoration: underline;
text-decoration-color: rgba(44, 95, 150, 0.28);
text-underline-offset: 2px;
}
.comparison-detail-item.is-subtotal,
.comparison-detail-item.is-total {
background: #f8fafc;
font-weight: 800;
}
.comparison-detail-item.is-total {
background: #f3f6f9;
}
.comparison-detail-item.is-placeholder {
visibility: hidden;
}
.comparison-detail-item.is-warning {
display: flex;
justify-content: center;
align-items: center;
padding: 8px 10px;
min-height: 38px;
}
.comparison-detail-warning-box {
border: 2px solid #dc2626;
border-radius: 12px;
padding: 10px 14px;
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 4px;
max-width: 100%;
box-sizing: border-box;
background: #fff;
}
.comparison-detail-warning-box span:first-child {
color: #dc2626;
font-size: 15px;
font-weight: 900;
line-height: 1.25;
}
.comparison-detail-warning-box strong {
color: #dc2626;
font-size: 12px;
font-weight: 800;
line-height: 1.35;
text-align: center;
}
.comparison-project-block {
display: grid;
gap: 0;
}
.comparison-project-detail-area {
display: grid;
gap: 0;
position: relative;
align-content: start;
}
.comparison-project-detail-area.is-empty {
position: relative;
}
.comparison-project-warning-anchor {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
padding: 8px 10px;
box-sizing: border-box;
}
.analysis-kpis {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 10px;
align-items: stretch;
}
.analysis-kpi {
border: 0;
border-bottom: 1px solid #dfe6ee;
border-radius: 0;
padding: 0 0 12px;
display: grid;
gap: 4px;
background: transparent;
min-width: 0;
}
.analysis-kpi span {
color: var(--muted);
font-size: 12px;
}
.analysis-kpi strong {
font-size: 17px;
line-height: 1.2;
word-break: break-word;
}
.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;
}
.comparison-note-input {
width: 100%;
min-height: 24px;
padding: 6px 8px;
border: 1px solid #d7dfe7;
border-radius: 10px;
background: #fff;
font: inherit;
color: var(--ink);
resize: vertical;
}
.comparison-note-input::placeholder {
color: #9aa8b5;
}
.comparison-note-auto {
margin-bottom: 6px;
color: #b91c1c;
font-size: 11px;
font-weight: 800;
line-height: 1.35;
text-align: left;
}
.delta-positive {
color: #15803d;
font-weight: 800;
}
.delta-negative {
color: #c2410c;
font-weight: 800;
}
.analysis-note-grid {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
}
.analysis-note-grid .detail-block h4 {
display: none;
}
.analysis-note-grid .detail-block {
padding-top: 16px;
}
.contract-state-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 24px;
padding: 3px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 800;
line-height: 1.2;
width: fit-content;
}
.contract-state-badge.is-contracted {
color: #155e75;
background: #ecfeff;
border: 1px solid #b6eff7;
}
.contract-state-badge.is-uncontracted {
color: #7c2d12;
background: #fff7ed;
border: 1px solid #fed7aa;
}
.contract-state-badge.is-uncontracted-cost {
color: #991b1b;
background: #fef2f2;
border: 1px solid #fecaca;
}
.uncontracted-dashboard {
display: grid;
gap: 16px;
}
#projectUncontractedDashboard {
padding: 0;
position: relative;
z-index: 1;
}
.analysis-root-panel {
position: relative;
z-index: 400;
overflow: visible;
content-visibility: visible;
contain: none;
}
.project-uncontracted-panel {
position: relative;
z-index: 1;
content-visibility: visible;
contain: none;
margin-top: 12px;
padding-top: 0;
border-top: 0;
}
.uncontracted-toolbar {
display: grid;
grid-template-columns: 180px 180px 44px;
gap: 12px;
align-items: center;
min-height: var(--toolbar-row-height);
width: 428px;
justify-content: end;
}
.uncontracted-dashboard .toolbar-head {
margin-bottom: 10px;
padding-top: 0;
}
.uncontracted-dashboard .toolbar-head h3 {
height: var(--toolbar-row-height);
line-height: 1;
}
.uncontracted-summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 10px;
}
.uncontracted-stat {
border: 0;
border-bottom: 1px solid rgba(217, 221, 227, 0.9);
border-radius: 0;
padding: 0 0 12px;
background: transparent;
display: grid;
gap: 4px;
text-align: left;
align-content: start;
justify-items: start;
min-width: 0;
min-height: 96px;
box-sizing: border-box;
box-shadow: none;
}
.uncontracted-stat.is-clickable {
cursor: pointer;
transition: color 0.16s ease, border-color 0.16s ease;
}
.uncontracted-stat.is-clickable:hover,
.uncontracted-stat.is-clickable.is-active {
border-color: #111827;
box-shadow: none;
transform: none;
}
.uncontracted-stat span {
color: var(--muted);
font-size: 12px;
display: block;
line-height: 1.35;
white-space: normal;
word-break: keep-all;
}
.uncontracted-stat strong {
color: var(--ink);
font-size: 20px;
line-height: 1.2;
word-break: break-word;
display: block;
}
.uncontracted-stat em {
color: var(--muted);
font-size: 12px;
font-style: normal;
line-height: 1.4;
font-weight: 700;
text-align: left;
display: block;
white-space: normal;
word-break: keep-all;
}
.uncontracted-stat-copy {
display: grid;
gap: 4px;
min-width: 0;
width: 100%;
text-align: left;
align-content: start;
justify-items: start;
}
.uncontracted-detail-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin-bottom: 10px;
}
.uncontracted-dashboard-grid {
display: grid;
grid-template-columns: 1.2fr 1fr;
gap: 12px;
}
.uncontracted-dashboard-grid .detail-block {
height: 100%;
background: transparent;
border: 0;
border-top: 1px solid var(--line);
border-radius: 0;
padding: 12px 0 0;
}
.uncontracted-dashboard-grid .detail-block h4 {
margin-bottom: 10px;
}
.compact-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.compact-table th,
.compact-table td {
padding: 9px 10px;
border-bottom: 1px solid #e7edf2;
font-size: 12px;
text-align: left;
vertical-align: middle;
word-break: break-word;
}
.compact-table th {
font-size: 11px;
color: #547085;
background: #f8fafc;
}
.compact-table td {
background: #ffffff;
}
.compact-table tbody tr:nth-child(even) td {
background: #fcfdff;
}
.compact-table td:last-child,
.compact-table th:last-child {
text-align: right;
}
.compact-table .classification-cell,
.compact-table th.classification-cell {
text-align: center;
width: 180px;
}
.compact-table th.uncontracted-col-project,
.compact-table td.uncontracted-col-project {
width: auto;
}
.compact-table th.uncontracted-col-amount,
.compact-table td.uncontracted-col-amount {
width: 180px;
white-space: nowrap;
}
.compact-table th.uncontracted-col-amount {
text-align: center;
}
.compact-table td.uncontracted-col-amount {
text-align: right;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
}
.compact-table td.uncontracted-col-amount.is-clickable {
cursor: pointer;
}
.compact-table td.uncontracted-col-amount::selection,
.compact-table td.uncontracted-col-amount *::selection {
background: transparent !important;
color: inherit !important;
}
.compact-table td.uncontracted-col-amount::-moz-selection,
.compact-table td.uncontracted-col-amount *::-moz-selection {
background: transparent !important;
color: inherit !important;
}
.detail-amount-trigger {
background: transparent;
color: inherit;
font: inherit;
font-weight: 700;
display: flex;
justify-content: flex-end;
align-items: center;
width: 100%;
user-select: none !important;
-webkit-user-select: none !important;
-moz-user-select: none !important;
-webkit-touch-callout: none;
caret-color: transparent;
-webkit-tap-highlight-color: transparent;
padding: 4px 8px;
border-radius: 8px;
transition: background 0.16s ease, color 0.16s ease;
text-align: right;
}
.detail-amount-trigger * {
user-select: none !important;
-webkit-user-select: none !important;
-moz-user-select: none !important;
}
.detail-amount-trigger::selection {
background: transparent;
color: inherit;
}
.detail-amount-trigger::-moz-selection {
background: transparent;
color: inherit;
}
.detail-amount-trigger:hover {
background: #eef2f6;
color: #1d4ed8;
}
.detail-amount-trigger.is-empty {
justify-content: center;
color: #8aa0b5;
font-weight: 600;
}
.uncontracted-col-amount.is-clickable:hover .detail-amount-trigger,
.uncontracted-col-amount.is-clickable:focus-within .detail-amount-trigger,
.uncontracted-col-amount.is-clickable.is-active .detail-amount-trigger {
background: #eef2f6;
color: #1d4ed8;
}
.sortable-header {
display: inline-flex;
align-items: center;
gap: 6px;
}
.sort-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
border: 0;
background: transparent;
color: #7b92a8;
cursor: pointer;
}
.sort-toggle:hover {
color: #1d4ed8;
}
.sort-toggle.is-active {
color: #1d4ed8;
}
.sort-toggle svg {
width: 14px;
height: 14px;
stroke: currentColor;
stroke-width: 2;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
.detail-classification-select {
width: 100%;
min-width: 0;
padding: 8px 10px;
font-size: 12px;
}
.compact-table .interactive-row {
cursor: pointer;
}
.modal-title-with-total {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
width: 100%;
}
.modal-title-with-total--compact {
flex: 1 1 auto;
min-width: 0;
width: auto;
align-items: center;
}
.modal-title-with-total--compact .related-modal-title {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.modal-inline-total {
font-size: 16px;
font-weight: 800;
color: #132238;
white-space: nowrap;
flex: 0 0 auto;
}
.vendor-period-toolbar {
display: inline-flex;
align-items: center;
gap: 8px;
margin-left: auto;
flex-wrap: nowrap;
flex: 0 0 auto;
min-width: 0;
}
.vendor-period-toolbar.is-hidden {
display: none;
}
.vendor-period-check {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #4b5563;
white-space: nowrap;
flex: 0 0 auto;
}
.vendor-period-toolbar input[type="date"] {
width: 112px;
min-width: 112px;
height: 36px;
padding: 0 8px;
border: 1px solid #d7dfe7;
border-radius: 10px;
background: #fff;
font: inherit;
color: var(--ink);
}
.related-modal-head--singleline > .button-icon {
flex: 0 0 auto;
margin-left: 4px;
}
.vendor-period-toolbar input[type="date"]:disabled {
background: #f5f7fa;
color: #94a3b8;
cursor: not-allowed;
}
.related-modal-head--singleline .mini-button {
flex: 0 0 auto;
white-space: nowrap;
}
.transaction-partner-link {
display: inline-flex;
align-items: center;
gap: 6px;
color: #0f172a;
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
}
.transaction-partner-link:hover {
color: #1d4ed8;
}
.compact-table .interactive-row:hover {
background: #f8fbfe;
}
.compact-table .interactive-row.is-active {
background: #eef6fb;
}
.search-bar {
display: grid;
grid-template-columns: minmax(280px, 1fr) auto;
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: transparent;
border: 0;
border-bottom: 1px solid rgba(217, 221, 227, 0.9);
border-radius: 0;
padding: 0 0 12px;
}
.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: transparent;
border: 0;
border-top: 1px solid var(--line);
border-radius: 0;
padding: 14px 0 0;
}
.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: 10000;
isolation: isolate;
}
.modal-backdrop.open {
display: flex;
}
.modal-card {
width: min(1280px, 100%);
max-height: 90vh;
overflow: auto;
position: relative;
z-index: 1;
background: #ffffff;
border-radius: 20px;
border: 0;
box-shadow: 0 28px 72px rgba(21, 24, 29, 0.16);
padding: 14px 18px 18px;
}
.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: 10px;
min-height: 34px;
font-size: 12px;
line-height: 1.2;
}
.modal-card textarea {
min-height: 68px;
}
.form-section-block {
border: 0;
border-top: 1px solid var(--line);
border-radius: 0;
padding: 12px 0 0;
background: transparent;
display: grid;
gap: 10px;
}
.form-section-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.form-section-actions {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.form-section-head h3 {
font-size: 14px;
}
.section-help {
color: var(--muted);
font-size: 11px;
line-height: 1.4;
}
.import-review-banner {
display: none;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 12px;
background: #fff7e7;
border: 1px solid #f2d188;
color: #7b5708;
font-size: 12px;
line-height: 1.5;
}
.import-review-banner.is-visible {
display: flex;
}
.import-review-banner strong {
color: #5f4102;
}
.section-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
}
.lookup-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.readonly-output {
min-height: 34px;
border: 0;
border-bottom: 1px solid var(--line);
background: transparent;
border-radius: 0;
padding: 7px 0;
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: 12000;
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:hover,
.lookup-option:focus-visible {
background: #eef2f6;
color: var(--ink);
outline: none;
}
.lookup-option,
.sort-toggle,
.detail-amount-button,
.button-secondary,
.button-icon {
-webkit-tap-highlight-color: transparent;
}
.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%;
}
.exec-labor-table col:nth-child(1) {
width: 18%;
}
.exec-labor-table col:nth-child(2) {
width: 16%;
}
.exec-labor-table col:nth-child(3) {
width: 30%;
}
.exec-labor-table col:nth-child(4) {
width: 26%;
}
.exec-labor-table col:nth-child(5) {
width: 10%;
}
.exec-labor-table .exec-labor-grade {
max-width: 90px;
}
.exec-labor-table .exec-labor-hours {
max-width: 88px;
text-align: right;
}
.exec-labor-table .exec-labor-rate-year {
max-width: 104px;
}
.section-columns {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
align-items: start;
}
.actual-input-layout {
display: grid;
gap: 8px;
}
.actual-input-top,
.actual-input-bottom {
display: grid;
grid-template-columns: repeat(2, 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%;
}
.actual-labor-table col:nth-child(1) {
width: 19%;
}
.actual-labor-table col:nth-child(2) {
width: 29%;
}
.actual-labor-table col:nth-child(3) {
width: 24%;
}
.actual-labor-table col:nth-child(4) {
width: 20%;
}
.actual-labor-table col:nth-child(5) {
width: 8%;
}
.actual-labor-table td:last-child {
text-align: right;
vertical-align: middle;
}
.actual-labor-table .row-remove-button {
margin-left: auto;
}
.actual-labor-table td:nth-child(2),
.actual-labor-table td:nth-child(3) {
min-width: 0;
}
.actual-labor-table .actual-labor-grade {
width: 100%;
max-width: 102px;
}
.actual-labor-table .actual-labor-amount {
width: 100%;
max-width: 118px;
margin-left: 0;
}
.actual-labor-table .actual-labor-rate-year {
width: 100%;
max-width: 116px;
}
.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;
text-align: left;
align-content: start;
justify-items: start;
box-sizing: border-box;
}
.summary-chip.is-clickable {
cursor: pointer;
transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease;
}
.summary-chip.is-clickable:hover {
border-color: #c8d7e5;
box-shadow: 0 12px 22px rgba(22, 33, 50, 0.08);
transform: translateY(-1px);
}
.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-tools-actions {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
justify-content: flex-end;
}
.table-tools-amount {
width: 132px;
max-width: 132px;
min-width: 132px;
}
.table-total {
color: var(--muted);
font-size: 11px;
font-weight: 700;
}
.mini-button {
padding: 5px 8px;
font-size: 11px;
box-shadow: none;
}
.mini-button.button-icon {
width: 28px;
min-width: 28px;
height: 28px;
padding: 0;
border-radius: 9px;
}
.mini-button.button-icon svg {
width: 14px;
height: 14px;
stroke: currentColor;
fill: none;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.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: 0 8px 10px 0;
}
.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: 4px;
}
.close-button {
background: #e7f0f5;
color: #204257;
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
min-width: 40px;
height: 40px;
padding: 0;
border-radius: 12px;
}
.close-button svg {
width: 16px;
height: 16px;
stroke: currentColor;
fill: none;
stroke-width: 2.2;
stroke-linecap: round;
stroke-linejoin: round;
}
.actual-time-grid {
display: inline-grid;
grid-template-columns: 52px 22px 34px 14px;
gap: 4px;
align-items: center;
justify-content: start;
width: auto;
max-width: 100%;
}
.actual-time-grid .inline-input {
min-width: 0;
text-align: right;
font-variant-numeric: tabular-nums;
padding-left: 6px;
padding-right: 6px;
}
.actual-time-unit {
font-size: 11px;
color: var(--muted);
font-weight: 600;
white-space: nowrap;
text-align: left;
}
.muted {
color: var(--muted);
font-size: 12px;
}
body[data-view-mode="dual"] .overview-row {
grid-template-columns: minmax(260px, 300px) minmax(0, 1fr);
gap: 12px;
}
body[data-view-mode="dual"] .metric-grid {
grid-template-columns: 1fr;
gap: 8px;
}
body[data-view-mode="dual"] .metric-note-grid {
gap: 10px;
}
body[data-view-mode="dual"] .chart-stage {
padding: 12px;
}
body[data-view-mode="dual"] .chart-svg {
min-height: 300px;
}
body[data-view-mode="dual"] .analysis-shell,
body[data-view-mode="dual"] .analysis-main,
body[data-view-mode="dual"] .analysis-content {
gap: 12px;
}
@media (max-width: 1180px) {
.overview-row,
.analysis-toolbar,
.project-search-controls,
.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;
}
.analysis-hero-header,
.analysis-stat-grid,
.uncontracted-summary-grid,
.uncontracted-dashboard-grid,
.uncontracted-toolbar {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}
{% block content %}
<section class="panel analysis-root-panel">
<div class="analysis-shell">
<div class="analysis-main">
<section class="analysis-toolbar">
<div class="toolbar-head">
<div class="toolbar-title-group">
<h3>프로젝트 검색</h3>
<div class="project-quick-link-bar" id="projectQuickLinkBar"></div>
</div>
<div class="toolbar-head-actions">
<button type="button" id="saveProjectPageState" class="button-icon button-secondary" title="페이지 입력사항 저장" aria-label="페이지 입력사항 저장">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 4h10l4 4v12H6z"></path>
<path d="M9 4v6h6V4"></path>
<path d="M9 20v-6h6v6"></path>
</svg>
<span class="sr-only">페이지 입력사항 저장</span>
</button>
<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|sort(reverse=True) %}
<option value="{{ year }}">{{ year }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<select id="projectContractFilter" aria-label="계약 상태 필터">
<option value="">전체 상태</option>
<option value="contracted">계약 진행</option>
<option value="uncontracted">미계약</option>
<option value="uncontracted_cost">미계약 비용 발생</option>
</select>
</div>
</div>
</section>
<div class="analysis-content is-hidden" id="projectAnalysisContent">
<section class="analysis-panel analysis-related-bar" id="projectRelatedBar"></section>
<section class="analysis-panel" id="projectAnalysisHero"></section>
<section class="analysis-panel" id="projectAnalysisMetrics"></section>
<section class="analysis-panel" id="projectAnalysisComparison"></section>
<section class="analysis-panel" id="projectAnalysisNotes"></section>
</div>
</div>
</div>
</section>
<section class="panel project-uncontracted-panel" id="projectUncontractedDashboard"></section>
<div class="modal-backdrop {% if project_edit.support_dept_code %}open{% endif %}" id="projectModal">
<div class="modal-card">
<form action="javascript:void(0)" method="post" class="modal-form" id="projectStatusForm" data-collab-form onsubmit="return false;">
<div class="modal-header">
<h2 class="modal-title">사업현황 추가/수정</h2>
<div class="modal-header-actions">
<button type="button" id="saveProjectModal" class="button-icon" title="사업현황 저장" aria-label="사업현황 저장">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 4h10l4 4v12H6z"></path>
<path d="M9 4v6h6V4"></path>
<path d="M9 20v-6h6v6"></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="M7 7l10 10"></path>
<path d="M17 7L7 17"></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" data-save-scope-section="basic_info">
<div class="form-section-head">
<h3>기본 정보</h3>
<div class="form-section-actions">
<button type="button" class="button-link button-secondary mini-button" data-save-scope-button="basic_info">기본 정보 저장</button>
</div>
</div>
<div id="projectImportReviewBanner" class="import-review-banner{% if project_edit.review_tag %} is-visible{% endif %}">
<strong id="projectImportReviewTag">{{ project_edit.review_tag }}</strong>
<span id="projectImportReviewNote">{{ project_edit.review_note }}</span>
</div>
<div class="section-grid">
<div class="field field-full">
<div class="lookup-grid">
<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">{{ "{:.1f}".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" data-save-scope-section="collection">
<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>
<div class="form-section-actions">
<button type="button" class="button-link button-secondary mini-button" data-save-scope-button="collection">수금 정보 저장</button>
<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>
<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>
<select class="inline-input" name="collection_progress_type[]">
{% for option in collection_progress_type_options %}
<option value="{{ option }}" {% if row.progress_type == option %}selected{% endif %}>{{ option }}</option>
{% endfor %}
</select>
</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>
<select class="inline-input" name="collection_billing_type[]">
{% for option in collection_billing_type_options %}
<option value="{{ option }}" {% if row.billing_type == option %}selected{% endif %}>{{ option }}</option>
{% endfor %}
</select>
</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" data-save-scope-section="task_plan">
<div class="form-section-head">
<h3>과업수행계획</h3>
<div class="form-section-actions">
<button type="button" class="button-link button-secondary mini-button" data-save-scope-button="task_plan">과업수행계획 저장</button>
</div>
</div>
<div class="table-total">계획 합계: <span id="taskPlanTotalDisplay">0</span></div>
<div class="section-columns">
<section class="mini-sector">
<div class="table-tools">
<h4>부서별배분</h4>
<button type="button" class="button-link button-secondary mini-button button-icon" data-add-row="task-plan-department" 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 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 button-icon" data-add-row="task-plan-outsource" 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 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 button-icon" data-add-row="task-plan-joint" 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 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" data-save-scope-section="exec_budget">
<div class="form-section-head">
<h3>실행예산계획</h3>
<div class="form-section-actions">
<button type="button" class="button-link button-secondary mini-button" data-save-scope-button="exec_budget">실행예산 저장</button>
</div>
</div>
<div class="table-total">실행예산 합계: <span id="execBudgetTotalDisplay">0</span></div>
<div class="section-columns">
<section class="mini-sector">
<div class="table-tools">
<h4>인건비</h4>
<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 button-icon" data-add-row="exec-labor" 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="row-table-wrap">
<table class="row-table compact exec-labor-table">
<colgroup>
<col>
<col>
<col>
<col>
<col>
</colgroup>
<thead>
<tr>
<th>직급</th>
<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 button-icon" data-add-row="exec-outsource" 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 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 button-icon" data-add-row="exec-cost-plan" 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 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" data-save-scope-section="actual_input">
<div class="form-section-head">
<h3>실투입 관리</h3>
<div class="form-section-actions">
<button type="button" class="button-link button-secondary mini-button" data-save-scope-button="actual_input">실투입 저장</button>
</div>
</div>
<div class="table-total">실투입 합계: <span id="actualInputTotalDisplay">0</span></div>
<div class="actual-input-layout">
<div class="actual-input-top">
<section class="mini-sector">
<div class="table-tools">
<h4>인건비</h4>
<div class="table-tools-actions">
<input id="actualLaborAdjustmentTotal" class="inline-input amount-input formatted-amount-input table-tools-amount" data-amount-input type="text" inputmode="numeric" name="actual_labor_adjustment_total" placeholder="조정금액">
<button type="button" class="button-link button-secondary mini-button button-icon" data-add-row="actual-labor" 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="row-table-wrap">
<table class="row-table actual-labor-table">
<colgroup>
<col>
<col>
<col>
<col>
<col>
</colgroup>
<thead>
<tr>
<th>직급</th>
<th>투입시간</th>
<th>금액(원)</th>
<th>기준연도</th>
<th></th>
</tr>
</thead>
<tbody id="actualLaborRows"></tbody>
</table>
</div>
</section>
<section class="mini-sector">
<div class="table-tools">
<h4>인건비(합사)</h4>
<button type="button" class="button-link button-secondary mini-button button-icon" data-add-row="actual-labor-joint" 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>
</tr>
</thead>
<tbody id="actualLaborJointRows"></tbody>
</table>
</div>
</section>
</div>
<div class="actual-input-bottom">
<section class="mini-sector">
<div class="table-tools">
<h4>A/S비</h4>
<button type="button" class="button-link button-secondary mini-button button-icon" data-add-row="actual-as" title="실투입 A/S비 행 추가" aria-label="실투입 A/S비 행 추가">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 5v14"></path>
<path d="M5 12h14"></path>
</svg>
<span class="sr-only">실투입 A/S비 행 추가</span>
</button>
</div>
<div class="row-table-wrap">
<table class="row-table">
<thead>
<tr>
<th>항목</th>
<th>금액(원)</th>
<th></th>
</tr>
</thead>
<tbody id="actualAsRows"></tbody>
</table>
</div>
</section>
<section class="mini-sector">
<div class="table-tools">
<h4>판관비</h4>
<button type="button" class="button-link button-secondary mini-button button-icon" data-add-row="actual-sga" 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>
</tr>
</thead>
<tbody id="actualSgaRows"></tbody>
</table>
</div>
</section>
</div>
</div>
</section>
<section class="form-section-block" data-save-scope-section="allocation">
<div class="form-section-head">
<h3>예상 배분 설정</h3>
<div class="form-section-actions">
<button type="button" class="button-link button-secondary mini-button" data-save-scope-button="allocation">배분 설정 저장</button>
</div>
</div>
<div class="section-grid">
<div class="field">
<label for="expected_as_rate">예상 A/S비 비율</label>
<select id="expected_as_rate" name="expected_as_rate">
{% for option in expected_as_rate_options %}
<option value="{{ option.value }}" {% if project_edit.expected_as_rate|string == option.value|string %}selected{% endif %}>{{ option.label }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="expected_as_cost_display">예상 A/S비 금액</label>
<input id="expected_as_cost_display" class="inline-input amount-input formatted-amount-input" type="text" inputmode="numeric" name="expected_as_cost" value="{{ "{:,.0f}".format(project_edit.expected_as_cost or 0) if project_edit.expected_as_cost else '' }}" placeholder="">
</div>
<div class="field">
<label for="expected_sga_rate">예상 판관비 비율</label>
<select id="expected_sga_rate" name="expected_sga_rate">
{% for option in expected_sga_rate_options %}
<option value="{{ option.value }}" {% if project_edit.expected_sga_rate|string == option.value|string %}selected{% endif %}>{{ option.label }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="expected_sga_budget_display">예상 판관비 금액</label>
<input id="expected_sga_budget_display" class="inline-input amount-input formatted-amount-input" type="text" inputmode="numeric" name="expected_sga_budget" value="{{ "{:,.0f}".format(project_edit.expected_sga_budget or 0) if project_edit.expected_sga_budget else '' }}" placeholder="">
</div>
</div>
</section>
<section class="form-section-block" data-save-scope-section="notes">
<div class="form-section-head">
<h3>비고</h3>
<div class="form-section-actions">
<button type="button" class="button-link button-secondary mini-button" data-save-scope-button="notes">비고 저장</button>
</div>
</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>
<div style="display:inline-flex; align-items:center; gap:8px;">
<label for="laborRateYearSelect" style="font-size:12px; color:var(--muted);">연도</label>
<select id="laborRateYearSelect" class="inline-input" style="min-width:96px;"></select>
</div>
<button type="button" class="button-secondary button-icon" id="saveLaborRateYearButton" title="기준인건비 연도 저장" aria-label="기준인건비 연도 저장">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 4h10l4 4v12H6z"></path>
<path d="M9 4v6h6V4"></path>
<path d="M9 20v-6h6v6"></path>
</svg>
<span class="sr-only">기준인건비 연도 저장</span>
</button>
<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>
<div class="modal-backdrop" id="detailYearSettingModal">
<div class="related-modal-card" style="max-width:460px;">
<div class="related-modal-head">
<h3 class="related-modal-title">세부내역 반영 연도 설정</h3>
<button type="button" class="button-secondary button-icon" id="closeDetailYearSettingModal" 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="field">
<label for="detailVisibleMinYearInput">세부내역 반영 시작 연도</label>
<input id="detailVisibleMinYearInput" type="number" min="2000" max="2099" step="1">
</div>
<p class="stacked-note">이 연도보다 이전에 생성된 프로젝트 코드는 세부 내역과 합계에서 제외됩니다.</p>
<div class="actions" style="justify-content:flex-end;">
<button type="button" class="button-icon" id="saveDetailYearSetting" title="설정 저장" aria-label="설정 저장">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 4h9l3 3v13H6z"></path>
<path d="M9 4v6h6V4"></path>
<path d="M9 17h6"></path>
</svg>
<span class="sr-only">설정 저장</span>
</button>
</div>
</div>
</div>
</div>
<div class="modal-backdrop" id="excludedDetailItemsModal">
<div class="related-modal-card" style="max-width:1080px; width:min(1080px, calc(100vw - 48px));">
<div class="related-modal-head">
<h3 class="related-modal-title">세부 내역 제외 항목</h3>
<button type="button" class="button-secondary button-icon" id="closeExcludedDetailItemsModal" 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">
<p class="stacked-note" id="excludedDetailItemsSummary"></p>
<div class="row-table-wrap">
<table class="compact-table">
<colgroup>
<col style="width: 118px;">
<col style="width: 260px;">
<col style="width: 120px;">
<col style="width: auto;">
<col style="width: 132px;">
</colgroup>
<thead>
<tr>
<th>프로젝트</th>
<th>생성연도</th>
<th>발생 비용</th>
<th>제외 사유</th>
</tr>
</thead>
<tbody id="excludedDetailItemsRows">
<tr><td colspan="4">표시할 제외 항목이 없습니다.</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="modal-backdrop" id="uncontractedTransactionModal">
<div class="related-modal-card" style="max-width:1120px; width:min(1120px, calc(100vw - 48px));">
<div class="related-modal-head related-modal-head--singleline">
<div class="modal-title-with-total modal-title-with-total--compact">
<h3 class="related-modal-title" id="uncontractedTransactionModalTitle">세부 거래내역</h3>
<strong class="modal-inline-total" id="uncontractedTransactionModalTotal">0원</strong>
</div>
<button type="button" class="button-secondary button-icon" id="closeUncontractedTransactionModal" 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="compact-table">
<colgroup>
<col style="width: 104px;">
<col style="width: 240px;">
<col style="width: 112px;">
<col style="width: auto;">
<col style="width: 136px;">
</colgroup>
<thead>
<tr>
<th>
<div class="sortable-header">
<span>전표작성일</span>
<button type="button" class="sort-toggle" data-uncontracted-transaction-sort="posting_date" aria-label="전표작성일 정렬">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 9l4-4 4 4"></path>
<path d="M8 15l4 4 4-4"></path>
</svg>
</button>
</div>
</th>
<th>
<div class="sortable-header">
<span>거래처</span>
<button type="button" class="sort-toggle" data-uncontracted-transaction-sort="partner" aria-label="거래처 정렬">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 9l4-4 4 4"></path>
<path d="M8 15l4 4 4-4"></path>
</svg>
</button>
</div>
</th>
<th>
<div class="sortable-header">
<span>원가부서</span>
<button type="button" class="sort-toggle" data-uncontracted-transaction-sort="cost_dept" aria-label="원가부서 정렬">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 9l4-4 4 4"></path>
<path d="M8 15l4 4 4-4"></path>
</svg>
</button>
</div>
</th>
<th>
<div class="sortable-header">
<span>계정명칭</span>
<button type="button" class="sort-toggle" data-uncontracted-transaction-sort="account" aria-label="계정명칭 정렬">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 9l4-4 4 4"></path>
<path d="M8 15l4 4 4-4"></path>
</svg>
</button>
</div>
</th>
<th class="uncontracted-col-amount">
<div class="sortable-header">
<span>금액</span>
<button type="button" class="sort-toggle" data-uncontracted-transaction-sort="amount" aria-label="금액 정렬">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 9l4-4 4 4"></path>
<path d="M8 15l4 4 4-4"></path>
</svg>
</button>
</div>
</th>
</tr>
</thead>
<tbody id="uncontractedTransactionRows">
<tr><td colspan="5">표시할 거래내역이 없습니다.</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="modal-backdrop" id="vendorTransactionModal">
<div class="related-modal-card" style="max-width:1240px; width:min(1240px, calc(100vw - 48px));">
<div class="related-modal-head related-modal-head--singleline">
<div class="modal-title-with-total modal-title-with-total--compact">
<h3 class="related-modal-title" id="vendorTransactionModalTitle">거래처 비용 내역</h3>
<strong class="modal-inline-total" id="vendorTransactionModalTotal">0원</strong>
</div>
<div class="vendor-period-toolbar is-hidden" id="vendorPeriodToolbar">
<label class="vendor-period-check">
<input type="checkbox" id="vendorUseProjectPeriod" checked>
<span>프로젝트기간</span>
</label>
<input type="date" id="vendorPeriodStart" aria-label="거래처 세부내역 시작일">
<input type="date" id="vendorPeriodEnd" aria-label="거래처 세부내역 종료일">
<button type="button" class="button-secondary mini-button" id="vendorPeriodSearch">검색</button>
</div>
<button type="button" class="button-secondary button-icon" id="closeVendorTransactionModal" 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="compact-table">
<colgroup>
<col style="width: 104px;">
<col style="width: 220px;">
<col style="width: 112px;">
<col style="width: auto;">
<col style="width: 230px;">
<col style="width: 136px;">
</colgroup>
<thead>
<tr>
<th>
<div class="sortable-header">
<span>전표작성일</span>
<button type="button" class="sort-toggle" data-vendor-transaction-sort="posting_date" aria-label="전표작성일 정렬">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 9l4-4 4 4"></path>
<path d="M8 15l4 4 4-4"></path>
</svg>
</button>
</div>
</th>
<th>
<div class="sortable-header">
<span>거래처</span>
<button type="button" class="sort-toggle" data-vendor-transaction-sort="partner" aria-label="거래처 정렬">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 9l4-4 4 4"></path>
<path d="M8 15l4 4 4-4"></path>
</svg>
</button>
</div>
</th>
<th>
<div class="sortable-header">
<span>원가부서</span>
<button type="button" class="sort-toggle" data-vendor-transaction-sort="cost_dept" aria-label="원가부서 정렬">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 9l4-4 4 4"></path>
<path d="M8 15l4 4 4-4"></path>
</svg>
</button>
</div>
</th>
<th>
<div class="sortable-header">
<span>계정명칭</span>
<button type="button" class="sort-toggle" data-vendor-transaction-sort="account" aria-label="계정명칭 정렬">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 9l4-4 4 4"></path>
<path d="M8 15l4 4 4-4"></path>
</svg>
</button>
</div>
</th>
<th>
<div class="sortable-header">
<span>지원부서명(지원부서코드)</span>
<button type="button" class="sort-toggle" data-vendor-transaction-sort="support_dept" aria-label="프로젝트명 정렬">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 9l4-4 4 4"></path>
<path d="M8 15l4 4 4-4"></path>
</svg>
</button>
</div>
</th>
<th class="uncontracted-col-amount">
<div class="sortable-header">
<span>금액</span>
<button type="button" class="sort-toggle" data-vendor-transaction-sort="amount" aria-label="금액 정렬">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 9l4-4 4 4"></path>
<path d="M8 15l4 4 4-4"></path>
</svg>
</button>
</div>
</th>
</tr>
</thead>
<tbody id="vendorTransactionRows">
<tr><td colspan="5">표시할 거래내역이 없습니다.</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
{% block script %}
<script>
const selectedYear = {{ selected_year or "null" }};
const projectYearOptions = {{ (project_year_options or []) | tojson }};
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 = {{ labor_grade_options | tojson }};
const uncontractedCategoryOptions = {{ uncontracted_category_options | tojson }};
const specialXClassificationRules = {{ special_x_classification_rules | tojson }};
const projectRuntimeSettings = {{ project_runtime_settings | tojson }};
let projectEdit = {{ project_edit | tojson }};
const serverFocusCode = {{ project_focus_code | 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 projectMonthlyCostRows = {{ project_monthly_cost_rows | tojson }};
const projectStatusRows = {{ project_status_rows | tojson }};
const projectComparisonNotes = {{ project_comparison_notes | tojson }};
const projectAnalysisSettings = {{ project_analysis_settings | tojson }};
const persistedProjectPageState = {{ project_page_state | tojson }};
const persistedProjectRelatedLinks = {{ project_related_links | tojson }};
const persistedUncontractedCategoryOverrides = {{ project_uncontracted_classifications | tojson }};
const projectPageSessionId = window.clientSessionId || "";
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 });
window.__projectRelatedSelections = new Map();
const persistedUncontractedFilterState = {
start_year: String(persistedProjectPageState?.uncontracted_year_start || ""),
end_year: String(persistedProjectPageState?.uncontracted_year_end || ""),
};
let currentSelectedProjectCode = projectEdit.support_dept_code || "";
const projectStatusForm = document.getElementById("projectStatusForm");
const modal = document.getElementById("projectModal");
const openModalButton = document.getElementById("openProjectModal");
const closeModalButton = document.getElementById("closeProjectModal");
const saveProjectModalButton = document.getElementById("saveProjectModal");
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 projectImportReviewBanner = document.getElementById("projectImportReviewBanner");
const projectImportReviewTag = document.getElementById("projectImportReviewTag");
const projectImportReviewNote = document.getElementById("projectImportReviewNote");
const supportDeptCodeResults = document.getElementById("supportDeptCodeResults");
const supportDeptNameResults = document.getElementById("supportDeptNameResults");
const contractAmountInput = document.getElementById("contract_amount");
const expectedAsRateInput = document.getElementById("expected_as_rate");
const expectedSgaRateInput = document.getElementById("expected_sga_rate");
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 actualLaborRows = document.getElementById("actualLaborRows");
const actualLaborJointRows = document.getElementById("actualLaborJointRows");
const actualLaborAdjustmentInput = document.getElementById("actualLaborAdjustmentTotal");
const actualAsRows = document.getElementById("actualAsRows");
const actualSgaRows = document.getElementById("actualSgaRows");
const execLaborRatesInput = document.getElementById("exec_labor_rates_json");
const laborRateModal = document.getElementById("laborRateModal");
const laborRateRows = document.getElementById("laborRateRows");
const laborRateYearSelect = document.getElementById("laborRateYearSelect");
const saveLaborRateYearButton = document.getElementById("saveLaborRateYearButton");
const openLaborRateModalButton = document.getElementById("openLaborRateModal");
const closeLaborRateModalButton = document.getElementById("closeLaborRateModal");
const detailYearSettingModal = document.getElementById("detailYearSettingModal");
const detailVisibleMinYearInput = document.getElementById("detailVisibleMinYearInput");
const closeDetailYearSettingModalButton = document.getElementById("closeDetailYearSettingModal");
const saveDetailYearSettingButton = document.getElementById("saveDetailYearSetting");
const excludedDetailItemsModal = document.getElementById("excludedDetailItemsModal");
const closeExcludedDetailItemsModalButton = document.getElementById("closeExcludedDetailItemsModal");
const excludedDetailItemsSummary = document.getElementById("excludedDetailItemsSummary");
const excludedDetailItemsRows = document.getElementById("excludedDetailItemsRows");
const PROJECT_STATUS_DRAFT_STORAGE_PREFIX = "project-status-draft-v1";
const PROJECT_STATUS_SCOPE_FIELDS = {
basic_info: [
"support_dept_code",
"support_dept_name",
"project_type",
"contract_amount",
"completion_status",
"change_round",
"project_start_date",
"project_end_date",
],
collection: [
"collection_progress_type[]",
"collection_billing_round[]",
"collection_billing_type[]",
"collection_billing_date[]",
"collection_billed_amount[]",
"collection_round[]",
"collection_date[]",
"collection_amount_row[]",
],
task_plan: [
"task_plan_department_dept[]",
"task_plan_department_work[]",
"task_plan_department_amount[]",
"task_plan_outsource_dept[]",
"task_plan_outsource_work[]",
"task_plan_outsource_amount[]",
"task_plan_joint_dept[]",
"task_plan_joint_work[]",
"task_plan_joint_amount[]",
],
exec_budget: [
"exec_labor_rates_json",
"exec_labor_grade[]",
"exec_labor_hours[]",
"exec_labor_rate_year[]",
"exec_labor_amount[]",
"exec_outsource_dept[]",
"exec_outsource_work[]",
"exec_outsource_amount[]",
"exec_cost_plan_code[]",
"exec_cost_plan_name[]",
"exec_cost_plan_amount[]",
],
actual_input: [
"actual_labor_grade[]",
"actual_labor_minutes[]",
"actual_labor_amount[]",
"actual_labor_rate_year[]",
"actual_labor_adjustment_total",
"actual_labor_joint_label[]",
"actual_labor_joint_amount[]",
"actual_as_label[]",
"actual_as_amount[]",
"actual_sga_label[]",
"actual_sga_amount[]",
],
allocation: [
"expected_as_rate",
"expected_as_cost",
"expected_sga_rate",
"expected_sga_budget",
],
notes: [
"notes",
],
};
const PROJECT_STATUS_COMMON_FIELDS = [
"selected_year",
"edit_revision",
"support_dept_code",
"support_dept_name",
"client_session_id",
"client_submitted_at",
];
const projectStatusDraftContainers = {
collectionRows,
taskPlanDepartmentRows,
taskPlanOutsourceRows,
taskPlanJointRows,
execLaborRows,
execOutsourceRows,
execCostPlanRows,
actualLaborRows,
actualLaborJointRows,
actualAsRows,
actualSgaRows,
};
const uncontractedTransactionModal = document.getElementById("uncontractedTransactionModal");
const closeUncontractedTransactionModalButton = document.getElementById("closeUncontractedTransactionModal");
const uncontractedTransactionModalTitle = document.getElementById("uncontractedTransactionModalTitle");
const uncontractedTransactionModalTotal = document.getElementById("uncontractedTransactionModalTotal");
const uncontractedTransactionRows = document.getElementById("uncontractedTransactionRows");
const vendorTransactionModal = document.getElementById("vendorTransactionModal");
const closeVendorTransactionModalButton = document.getElementById("closeVendorTransactionModal");
const vendorTransactionModalTitle = document.getElementById("vendorTransactionModalTitle");
const vendorTransactionModalTotal = document.getElementById("vendorTransactionModalTotal");
const vendorTransactionRows = document.getElementById("vendorTransactionRows");
const vendorPeriodToolbar = document.getElementById("vendorPeriodToolbar");
const vendorUseProjectPeriodCheckbox = document.getElementById("vendorUseProjectPeriod");
const vendorPeriodStartInput = document.getElementById("vendorPeriodStart");
const vendorPeriodEndInput = document.getElementById("vendorPeriodEnd");
const vendorPeriodSearchButton = document.getElementById("vendorPeriodSearch");
let currentLaborRates = {};
let latestExcludedDetailRows = [];
let uncontractedDetailSort = { key: "code", direction: "asc" };
let uncontractedTransactionSort = { key: "posting_date", direction: "desc" };
let vendorTransactionSort = { key: "posting_date", direction: "desc" };
let projectStatusDraftTimer = null;
let latestUncontractedTransactionRows = [];
let latestVendorTransactionRows = [];
let latestTransactionModalContext = { type: "uncontracted" };
let latestVendorModalContext = { type: "uncontracted" };
let uncontractedDashboardDataRevision = 0;
const uncontractedDashboardDataCache = new Map();
const comparisonInlineDetailCache = new Map();
function bumpUncontractedDashboardRevision() {
uncontractedDashboardDataRevision += 1;
uncontractedDashboardDataCache.clear();
}
function buildUncontractedDashboardCacheKey(startYear, endYear) {
return JSON.stringify({
revision: uncontractedDashboardDataRevision,
startYear: Number(startYear || 0),
endYear: Number(endYear || 0),
selection: {
type: String(uncontractedDetailSelection?.type || ""),
year: Number(uncontractedDetailSelection?.year || 0),
month: Number(uncontractedDetailSelection?.month || 0),
category: String(uncontractedDetailSelection?.category || ""),
},
visible: Boolean(uncontractedDetailVisible),
sort: {
key: String(uncontractedDetailSort?.key || "code"),
direction: String(uncontractedDetailSort?.direction || "asc"),
},
});
}
yearFilter?.addEventListener("change", (event) => {
const nextYear = event.target.value;
const nextUrl = nextYear ? `/projects?year=${encodeURIComponent(nextYear)}` : "/projects";
window.location.href = nextUrl;
});
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function formatNumber(value) {
return currencyFormatter.format(value || 0);
}
function formatDisplayAmount(value) {
const numeric = Number(value || 0);
if (!numeric) return "0";
if (Math.abs(numeric) >= 100000000) {
return `${(numeric / 100000000).toFixed(1).replace(/\.0$/, "")}억`;
}
if (Math.abs(numeric) >= 1000000) {
return `${(numeric / 1000000).toFixed(1).replace(/\.0$/, "")}백만`;
}
if (Math.abs(numeric) >= 1000) {
return `${(numeric / 1000).toFixed(1).replace(/\.0$/, "")}천`;
}
return formatNumber(numeric);
}
function sortUncontractedDetailRows(rows = []) {
const items = [...rows];
const key = uncontractedDetailSort?.key || "code";
const direction = uncontractedDetailSort?.direction === "desc" ? -1 : 1;
if (key === "expense") {
return items.sort((a, b) => (
(Number(a.expense_amount || 0) - Number(b.expense_amount || 0)) * direction
) || String(a.support_dept_code || "").localeCompare(String(b.support_dept_code || ""), "ko", { numeric: true }));
}
if (key === "revenue") {
return items.sort((a, b) => (
(Number(a.revenue_amount || 0) - Number(b.revenue_amount || 0)) * direction
) || String(a.support_dept_code || "").localeCompare(String(b.support_dept_code || ""), "ko", { numeric: true }));
}
return items.sort((a, b) => String(a.support_dept_code || "").localeCompare(String(b.support_dept_code || ""), "ko", { numeric: true }) * direction);
}
function renderSortToggle(key) {
const isActive = uncontractedDetailSort?.key === key;
const isDesc = isActive && uncontractedDetailSort?.direction === "desc";
return `
<button type="button" class="sort-toggle ${isActive ? "is-active" : ""}" data-uncontracted-sort="${key}" aria-label="${escapeHtml(key)} 정렬">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 9l4-4 4 4"></path>
<path d="M8 15l4 4 4-4"></path>
</svg>
</button>
`;
}
function renderModalSortToggle(state, attrName, key) {
const isActive = state?.key === key;
return `
<button type="button" class="sort-toggle ${isActive ? "is-active" : ""}" ${attrName}="${key}" aria-label="${escapeHtml(key)} 정렬">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 9l4-4 4 4"></path>
<path d="M8 15l4 4 4-4"></path>
</svg>
</button>
`;
}
function compareTransactionRows(a, b, key, direction) {
const multiplier = direction === "asc" ? 1 : -1;
const getText = (value) => String(value || "").toLocaleLowerCase();
if (key === "amount") {
return ((Number(a.amount || 0) - Number(b.amount || 0)) * multiplier)
|| getText(a.partner_name).localeCompare(getText(b.partner_name), "ko", { numeric: true });
}
const fieldMap = {
posting_date: "posting_date",
partner: "partner_name",
cost_dept: "cost_dept_name",
account: "account_name",
support_dept: "support_dept_name",
};
const field = fieldMap[key] || "posting_date";
return getText(a[field]).localeCompare(getText(b[field]), "ko", { numeric: true }) * multiplier;
}
function sortTransactionRows(rows, sortState) {
return [...(rows || [])].sort((a, b) =>
compareTransactionRows(a, b, sortState?.key || "posting_date", sortState?.direction || "desc")
);
}
function formatPartnerDisplay(name, code) {
const normalizedName = String(name || "").trim();
const normalizedCode = String(code || "").trim();
if (normalizedName && normalizedCode) return `${normalizedName} (${normalizedCode})`;
return normalizedName || normalizedCode || "-";
}
function formatAccountDisplay(name, code) {
const normalizedName = String(name || "").trim();
const normalizedCode = String(code || "").trim();
if (normalizedName && normalizedCode) return `${normalizedName} (${normalizedCode})`;
return normalizedName || normalizedCode || "-";
}
function formatSupportDeptDisplay(name, code) {
const normalizedName = String(name || "").trim();
const normalizedCode = String(code || "").trim();
if (normalizedName && normalizedCode) return `${normalizedName} (${normalizedCode})`;
return normalizedName || normalizedCode || "-";
}
function renderUncontractedTransactionRows() {
const rows = sortTransactionRows(latestUncontractedTransactionRows, uncontractedTransactionSort);
uncontractedTransactionRows.innerHTML = rows.length
? rows.map((row) => `
<tr>
<td>${escapeHtml(row.posting_date || "-")}</td>
<td>
<span
class="transaction-partner-link"
${latestTransactionModalContext?.type === "comparison"
? `
data-comparison-open-vendor="${escapeHtml(row.partner_code || "")}"
data-comparison-vendor-name="${escapeHtml(row.partner_name || "")}"
data-comparison-project-code="${escapeHtml(latestTransactionModalContext.supportDeptCode || row.support_dept_code || "")}"
data-comparison-project-name="${escapeHtml(latestTransactionModalContext.projectName || row.support_dept_name || "")}"
data-comparison-codes="${escapeHtml((latestTransactionModalContext.codes || []).join(","))}"
`
: `
data-uncontracted-open-vendor="${escapeHtml(row.partner_code || "")}"
data-uncontracted-vendor-name="${escapeHtml(row.partner_name || "")}"
data-uncontracted-project-code="${escapeHtml(row.support_dept_code || "")}"
data-uncontracted-project-name="${escapeHtml(row.support_dept_name || "")}"
`
}
>${escapeHtml(formatPartnerDisplay(row.partner_name, row.partner_code))}</span>
</td>
<td>${escapeHtml(row.cost_dept_name || "-")}</td>
<td>${escapeHtml(formatAccountDisplay(row.account_name, row.account_code))}</td>
<td class="uncontracted-col-amount">${formatNumber(row.amount || 0)}</td>
</tr>
`).join("")
: `<tr><td colspan="5">표시할 거래내역이 없습니다.</td></tr>`;
}
function renderVendorTransactionRows() {
const rows = sortTransactionRows(latestVendorTransactionRows, vendorTransactionSort);
vendorTransactionRows.innerHTML = rows.length
? rows.map((row) => `
<tr>
<td>${escapeHtml(row.posting_date || "-")}</td>
<td>${escapeHtml(formatPartnerDisplay(row.partner_name, row.partner_code))}</td>
<td>${escapeHtml(row.cost_dept_name || "-")}</td>
<td>${escapeHtml(formatAccountDisplay(row.account_name, row.account_code))}</td>
<td>${escapeHtml(formatSupportDeptDisplay(row.support_dept_name, row.support_dept_code))}</td>
<td class="uncontracted-col-amount">${formatNumber(row.amount || 0)}</td>
</tr>
`).join("")
: `<tr><td colspan="6">표시할 거래내역이 없습니다.</td></tr>`;
}
function syncVendorPeriodToolbar() {
const isComparison = latestVendorModalContext?.type === "comparison";
vendorPeriodToolbar?.classList.toggle("is-hidden", !isComparison);
const useProjectPeriod = vendorUseProjectPeriodCheckbox?.checked !== false;
if (vendorPeriodStartInput) vendorPeriodStartInput.disabled = !isComparison || useProjectPeriod;
if (vendorPeriodEndInput) vendorPeriodEndInput.disabled = !isComparison || useProjectPeriod;
if (vendorPeriodSearchButton) vendorPeriodSearchButton.disabled = !isComparison;
if (!isComparison) {
if (vendorUseProjectPeriodCheckbox) vendorUseProjectPeriodCheckbox.checked = true;
if (vendorPeriodStartInput) vendorPeriodStartInput.value = "";
if (vendorPeriodEndInput) vendorPeriodEndInput.value = "";
}
}
async function loadComparisonVendorTransactionRows({ useProjectPeriod = true } = {}) {
const normalizedPartnerCode = String(latestVendorModalContext.partnerCode || "").trim();
const normalizedPartnerName = String(latestVendorModalContext.partnerName || "").trim();
const normalizedProjectCode = String(latestVendorModalContext.supportDeptCode || "").trim();
const normalizedCodes = Array.isArray(latestVendorModalContext.codes) ? latestVendorModalContext.codes : [];
if ((!normalizedPartnerCode && !normalizedPartnerName) || !normalizedCodes.length) return;
const params = new URLSearchParams({
support_dept_code: normalizedProjectCode,
codes: normalizedCodes.join(","),
partner_code: normalizedPartnerCode,
partner_name: normalizedPartnerName,
});
if (!useProjectPeriod) {
const startDate = String(vendorPeriodStartInput?.value || "").trim();
const endDate = String(vendorPeriodEndInput?.value || "").trim();
if (startDate) params.set("start_date", startDate);
if (endDate) params.set("end_date", endDate);
}
vendorTransactionRows.innerHTML = `<tr><td colspan="6">거래내역을 불러오는 중입니다.</td></tr>`;
vendorTransactionModalTotal.textContent = "0원";
try {
const response = await fetch(`/projects/comparison-vendor-transactions?${params.toString()}`, {
credentials: "same-origin",
headers: { Accept: "application/json" },
});
const payload = await response.json();
if (!response.ok || payload?.error) {
throw new Error(payload?.error || `HTTP ${response.status}`);
}
latestVendorTransactionRows = Array.isArray(payload?.rows) ? payload.rows : [];
latestVendorModalContext.defaultStartDate = String(payload?.project_start_date || payload?.start_date || "").trim();
latestVendorModalContext.defaultEndDate = String(payload?.project_end_date || payload?.end_date || "").trim();
if (vendorPeriodStartInput && useProjectPeriod) {
vendorPeriodStartInput.value = latestVendorModalContext.defaultStartDate || "";
}
if (vendorPeriodEndInput && useProjectPeriod) {
vendorPeriodEndInput.value = latestVendorModalContext.defaultEndDate || "";
}
vendorTransactionModalTotal.textContent = `${formatNumber(payload?.total_amount || 0)}원`;
renderVendorTransactionRows();
} catch (error) {
console.error(error);
latestVendorTransactionRows = [];
vendorTransactionRows.innerHTML = `<tr><td colspan="6">거래내역을 불러오지 못했습니다.</td></tr>`;
vendorTransactionModalTotal.textContent = "0원";
}
}
function formatUncontractedCellAmount(value) {
const numeric = Number(value || 0);
if (!numeric) {
return { text: "-", empty: true };
}
return { text: formatNumber(numeric), empty: false };
}
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 numeric = parseAmount(value);
if (!numeric) return "";
return formatNumber(Math.round(numeric));
}
function parseHours(value) {
const normalized = String(value ?? "").replace(/,/g, ".").replace(/[^\d.]/g, "");
const numeric = Number(normalized);
return Number.isFinite(numeric) ? numeric : 0;
}
function parseExecHoursDigits(value) {
const digits = String(value ?? "").replace(/[^\d]/g, "").slice(0, 5);
const numeric = Number(digits);
return Number.isFinite(numeric) ? numeric : 0;
}
function formatHourInputValue(value) {
const numeric = parseExecHoursDigits(value);
if (!numeric) return "";
return formatNumber(Math.round(numeric));
}
const collectionProgressTypeOptions = {{ collection_progress_type_options | tojson }};
const collectionBillingTypeOptions = {{ collection_billing_type_options | tojson }};
function renderSelectOptions(options, selectedValue) {
const currentValue = String(selectedValue || "");
return options.map((option) => (
`<option value="${escapeHtml(option)}" ${currentValue === String(option) ? "selected" : ""}>${escapeHtml(option)}</option>`
)).join("");
}
function parseDurationMinutes(value) {
const raw = String(value ?? "").trim().toLowerCase();
if (!raw) return 0;
if (/^\d+$/.test(raw)) {
return Number(raw) * 60;
}
if (/^\d+(?:\.\d+)?$/.test(raw)) {
return Math.round(Number(raw) * 60);
}
const colonMatch = raw.match(/^(\d+)\s*:\s*(\d{1,2})$/);
if (colonMatch) {
return Number(colonMatch[1]) * 60 + Number(colonMatch[2]);
}
let total = 0;
const hourMatch = raw.match(/(\d+(?:\.\d+)?)\s*(?:h|시간)/);
const minuteMatch = raw.match(/(\d+)\s*(?:m|분)/);
if (hourMatch) {
total += Math.round(Number(hourMatch[1]) * 60);
}
if (minuteMatch) {
total += Number(minuteMatch[1]);
}
if (total) return total;
const digits = raw.replace(/[^\d]/g, "");
return digits ? Number(digits) : 0;
}
function formatDurationInputValue(value) {
const minutes = Number(value || 0);
if (!minutes) return "";
const hours = Math.floor(minutes / 60);
const remainder = minutes % 60;
if (!remainder) return `${hours}h`;
if (!hours) return `${remainder}m`;
return `${hours}h ${remainder}m`;
}
function splitDurationMinutes(value) {
const totalMinutes = Number(value || 0);
if (!totalMinutes) {
return { hours: "", minutes: "" };
}
return {
hours: String(Math.floor(totalMinutes / 60)),
minutes: String(totalMinutes % 60),
};
}
function getLaborRateYears() {
const years = new Set();
const currentYear = new Date().getFullYear();
for (let year = currentYear - 5; year <= currentYear + 2; year += 1) {
years.add(String(year));
}
projectYearOptions.forEach((year) => {
const numericYear = Number(year || 0);
if (numericYear) years.add(String(numericYear));
});
Object.keys(currentLaborRates || {}).forEach((yearKey) => {
if (/^\d{4}$/.test(String(yearKey || ""))) years.add(String(yearKey));
});
return [...years].sort((a, b) => Number(a) - Number(b));
}
function getDefaultLaborRateYear() {
const selected = Number(selectedYear || 0);
if (selected) return String(selected);
const existingYears = Object.keys(currentLaborRates || {})
.filter((yearKey) => /^\d{4}$/.test(String(yearKey || "")))
.sort((a, b) => Number(b) - Number(a));
if (existingYears.length) return String(existingYears[0]);
return String(new Date().getFullYear());
}
function normalizeLaborRates(rawRates) {
const source = rawRates && typeof rawRates === "object" ? rawRates : {};
const next = {};
const defaultYear = getDefaultLaborRateYear();
Object.entries(source).forEach(([key, value]) => {
if (/^\d{4}$/.test(String(key || "")) && value && typeof value === "object" && !Array.isArray(value)) {
const yearBucket = {};
laborGradeOptions.forEach((grade) => {
yearBucket[grade] = Number(value?.[grade] || 0);
});
next[String(key)] = yearBucket;
}
});
const hasYearBuckets = Object.keys(next).length > 0;
if (!hasYearBuckets) {
const migrated = {};
laborGradeOptions.forEach((grade) => {
migrated[grade] = Number(source?.[grade] || 0);
});
next[defaultYear] = migrated;
}
return next;
}
function readLaborRates() {
try {
return normalizeLaborRates(JSON.parse(execLaborRatesInput?.value || "{}") || {});
} catch (error) {
return normalizeLaborRates({});
}
}
function writeLaborRates(nextRates) {
currentLaborRates = normalizeLaborRates(nextRates);
if (execLaborRatesInput) {
execLaborRatesInput.value = JSON.stringify(currentLaborRates);
}
}
function getLaborRate(grade, year = "") {
const yearKey = String(year || getDefaultLaborRateYear());
const yearRates = currentLaborRates?.[yearKey] || {};
const direct = Number(yearRates?.[grade] || 0);
if (direct) return direct;
const gradeIndex = laborGradeOptions.indexOf(grade);
if (gradeIndex === -1) return 0;
for (let index = gradeIndex + 1; index < laborGradeOptions.length; index += 1) {
const fallback = Number(yearRates?.[laborGradeOptions[index]] || 0);
if (fallback) return fallback;
}
return 0;
}
function calculateLaborAmount(grade, hours, year = "") {
return getLaborRate(grade, year) * 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 getAllActualAmountInputs() {
return [
...actualLaborRows.querySelectorAll("[data-amount-input]"),
...actualLaborJointRows.querySelectorAll("[data-amount-input]"),
...(actualLaborAdjustmentInput ? [actualLaborAdjustmentInput] : []),
...actualAsRows.querySelectorAll("[data-amount-input]"),
...actualSgaRows.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(1)}%`;
const expectedAsCost = contractAmount * parseAmount(expectedAsRateInput?.value) / 100;
const expectedSgaBudget = contractAmount * parseAmount(expectedSgaRateInput?.value) / 100;
const expectedAsCostField = document.getElementById("expected_as_cost_display");
const expectedSgaBudgetField = document.getElementById("expected_sga_budget_display");
if (expectedAsCostField && document.activeElement !== expectedAsCostField) {
const keepManual = parseAmount(expectedAsCostField.value);
expectedAsCostField.value = keepManual ? formatAmountInputValue(keepManual) : (expectedAsCost ? formatAmountInputValue(expectedAsCost) : "");
}
if (expectedSgaBudgetField && document.activeElement !== expectedSgaBudgetField) {
const keepManual = parseAmount(expectedSgaBudgetField.value);
expectedSgaBudgetField.value = keepManual ? formatAmountInputValue(keepManual) : (expectedSgaBudget ? formatAmountInputValue(expectedSgaBudget) : "");
}
syncExpectedAllocationsToActualInputs();
const taskPlanTotal = getAllTaskPlanAmountInputs()
.reduce((sum, input) => sum + parseAmount(input.value), 0);
const execBudgetTotal = getAllExecBudgetAmountInputs()
.reduce((sum, input) => sum + parseAmount(input.value), 0);
const actualInputTotal = getAllActualAmountInputs()
.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);
}
function syncExpectedAllocationsToActualInputs() {
const expectedAsAmount = parseAmount(document.getElementById("expected_as_cost_display")?.value);
const expectedSgaAmount = parseAmount(document.getElementById("expected_sga_budget_display")?.value);
const ensureAutoSyncTarget = (container, labelText, expectedAmount) => {
let syncInputs = [...container.querySelectorAll('[data-auto-sync="true"]')];
if (syncInputs.length) return syncInputs;
const fallbackRow = [...container.querySelectorAll("tr")].find((row) => {
const labelInput = row.querySelector('input[type="text"][name$="_label[]"], input[type="text"]');
const amountInput = row.querySelector('[data-amount-input]');
const normalizedLabel = String(labelInput?.value || "").trim();
const amountValue = parseAmount(amountInput?.value);
return normalizedLabel === labelText && (!amountValue || amountValue === expectedAmount);
});
const fallbackInput = fallbackRow?.querySelector('[data-amount-input]');
if (fallbackInput) {
fallbackInput.dataset.autoSync = "true";
syncInputs = [fallbackInput];
}
return syncInputs;
};
const asSyncInputs = ensureAutoSyncTarget(actualAsRows, "A/S비", expectedAsAmount);
const sgaSyncInputs = ensureAutoSyncTarget(actualSgaRows, "판관비", expectedSgaAmount);
asSyncInputs.forEach((input) => {
input.value = expectedAsAmount ? formatAmountInputValue(expectedAsAmount) : "";
});
sgaSyncInputs.forEach((input) => {
input.value = expectedSgaAmount ? formatAmountInputValue(expectedSgaAmount) : "";
});
}
function upsertProjectStatusRow(projectRow) {
if (!projectRow?.support_dept_code) return;
const code = String(projectRow.support_dept_code);
projectStatusMap[code] = projectRow;
const index = projectStatusRows.findIndex((item) => item.support_dept_code === code);
if (index >= 0) {
projectStatusRows[index] = projectRow;
} else {
projectStatusRows.push(projectRow);
}
}
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>
<select class="inline-input" name="collection_progress_type[]">
${renderSelectOptions(collectionProgressTypeOptions, row.progress_type || "")}
</select>
</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>
<select class="inline-input" name="collection_billing_type[]">
${renderSelectOptions(collectionBillingTypeOptions, row.billing_type || "")}
</select>
</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 selectedRateYear = String(row.rate_year || getDefaultLaborRateYear());
const inputHours = row.hours || row.work_name || "";
const amountValue = row.amount || calculateLaborAmount(selectedGrade, parseExecHoursDigits(inputHours), selectedRateYear);
const rateYearOptions = getLaborRateYears();
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="numeric" 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>
<select class="inline-input exec-labor-rate-year" name="exec_labor_rate_year[]">
${rateYearOptions.map((year) => `<option value="${escapeHtml(String(year))}" ${selectedRateYear === String(year) ? "selected" : ""}>${escapeHtml(String(year))}</option>`).join("")}
</select>
</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 createActualLaborRow(row = {}) {
const selectedGrade = row.grade || "";
const selectedRateYear = String(row.rate_year || getDefaultLaborRateYear());
const inputMinutes = Number(row.minutes || row.work_name || 0);
const splitDuration = splitDurationMinutes(inputMinutes);
const amountValue = row.amount || calculateLaborAmount(selectedGrade, inputMinutes / 60, selectedRateYear);
const rateYearOptions = getLaborRateYears();
return `
<tr data-group="labor">
<td>
<select class="inline-input actual-labor-grade" name="actual_labor_grade[]">
<option value="">직급 선택</option>
${laborGradeOptions.map((grade) => `<option value="${escapeHtml(grade)}" ${selectedGrade === grade ? "selected" : ""}>${escapeHtml(grade)}</option>`).join("")}
</select>
</td>
<td>
<div class="actual-time-grid">
<input class="inline-input actual-labor-hours" type="text" inputmode="numeric" value="${escapeHtml(splitDuration.hours)}" placeholder="0">
<span class="actual-time-unit">시간</span>
<input class="inline-input actual-labor-minute-part" type="text" inputmode="numeric" value="${escapeHtml(splitDuration.minutes)}" placeholder="0">
<span class="actual-time-unit">분</span>
</div>
<input class="actual-labor-minutes" type="hidden" name="actual_labor_minutes[]" value="${escapeHtml(String(inputMinutes || ""))}">
</td>
<td><input class="inline-input amount-input formatted-amount-input actual-labor-amount" data-amount-input type="text" inputmode="numeric" name="actual_labor_amount[]" value="${escapeHtml(formatAmountInputValue(amountValue))}" readonly></td>
<td>
<select class="inline-input actual-labor-rate-year" name="actual_labor_rate_year[]">
${rateYearOptions.map((year) => `<option value="${escapeHtml(year)}" ${selectedRateYear === String(year) ? "selected" : ""}>${escapeHtml(String(year))}</option>`).join("")}
</select>
</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 createActualCostRow(group, row = {}) {
const fieldPrefix = group === "as" ? "actual_as" : (group === "sga" ? "actual_sga" : "actual_labor_joint");
const labelValue = row.label || row.note || "";
const autoSync = row.auto_sync === true;
return `
<tr data-group="${group}">
<td><input class="inline-input" type="text" name="${fieldPrefix}_label[]" value="${escapeHtml(labelValue)}"></td>
<td><input class="inline-input amount-input formatted-amount-input" data-amount-input ${autoSync ? 'data-auto-sync="true"' : ""} type="text" inputmode="numeric" name="${fieldPrefix}_amount[]" 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 recalculateActualLaborAmounts() {
actualLaborRows.querySelectorAll("tr").forEach((row) => {
const grade = row.querySelector(".actual-labor-grade")?.value || "";
const rateYear = row.querySelector(".actual-labor-rate-year")?.value || getDefaultLaborRateYear();
const hoursInput = row.querySelector(".actual-labor-hours");
const minuteInput = row.querySelector(".actual-labor-minute-part");
const hours = Number(String(hoursInput?.value || "").replace(/[^\d]/g, "")) || 0;
const minutesPartRaw = Number(String(minuteInput?.value || "").replace(/[^\d]/g, "")) || 0;
const normalizedHours = hours + Math.floor(minutesPartRaw / 60);
const normalizedMinutePart = minutesPartRaw % 60;
const minutes = normalizedHours * 60 + normalizedMinutePart;
const hiddenMinutesInput = row.querySelector(".actual-labor-minutes");
const amountInput = row.querySelector(".actual-labor-amount");
if (hoursInput) {
hoursInput.value = normalizedHours ? String(normalizedHours) : "";
}
if (minuteInput) {
minuteInput.value = normalizedMinutePart ? String(normalizedMinutePart) : "";
}
if (hiddenMinutesInput) {
hiddenMinutesInput.value = String(minutes || "");
}
if (amountInput) {
amountInput.value = formatAmountInputValue(calculateLaborAmount(grade, minutes / 60, rateYear));
}
});
}
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 (container === actualLaborRows) {
recalculateActualLaborAmounts();
}
updateComputedFields();
};
});
container.querySelectorAll("input, select").forEach((input) => {
input.oninput = () => {
if ((container === actualAsRows || container === actualSgaRows) && input.matches("[data-amount-input]")) {
input.dataset.autoSync = "false";
}
if (container === execLaborRows) {
recalculateExecLaborAmounts();
}
if (container === actualLaborRows) {
recalculateActualLaborAmounts();
}
updateComputedFields();
};
input.onchange = input.oninput;
});
if (container === actualLaborRows) {
container.querySelectorAll(".actual-labor-hours, .actual-labor-minute-part").forEach((input) => {
input.oninput = () => {
input.value = input.value.replace(/[^\d]/g, "").slice(0, 4);
recalculateActualLaborAmounts();
updateComputedFields();
};
input.onblur = () => {
recalculateActualLaborAmounts();
updateComputedFields();
};
});
}
if (container === execLaborRows) {
container.querySelectorAll(".exec-labor-hours").forEach((input) => {
input.oninput = () => {
const digits = String(input.value || "").replace(/[^\d]/g, "").slice(0, 5);
input.value = formatHourInputValue(digits);
recalculateExecLaborAmounts();
};
input.onblur = () => {
const digits = String(input.value || "").replace(/[^\d]/g, "").slice(0, 5);
input.value = formatHourInputValue(digits);
recalculateExecLaborAmounts();
};
});
}
}
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);
}
if (target === "exec-outsource") {
execOutsourceRows.insertAdjacentHTML("beforeend", createExecBudgetRow("outsource", row));
attachTableEvents(execOutsourceRows);
}
if (target === "exec-cost-plan") {
execCostPlanRows.insertAdjacentHTML("beforeend", createExecBudgetRow("cost_plan", row));
attachTableEvents(execCostPlanRows);
}
if (target === "actual-labor") {
actualLaborRows.insertAdjacentHTML("beforeend", createActualLaborRow(row));
attachTableEvents(actualLaborRows);
recalculateActualLaborAmounts();
}
if (target === "actual-labor-joint") {
actualLaborJointRows.insertAdjacentHTML("beforeend", createActualCostRow("labor_joint", row));
attachTableEvents(actualLaborJointRows);
}
if (target === "actual-as") {
actualAsRows.insertAdjacentHTML("beforeend", createActualCostRow("as", row));
attachTableEvents(actualAsRows);
}
if (target === "actual-sga") {
actualSgaRows.insertAdjacentHTML("beforeend", createActualCostRow("sga", row));
attachTableEvents(actualSgaRows);
}
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;
const selectedRateYear = String(laborRateYearSelect?.value || getDefaultLaborRateYear());
const yearRates = currentLaborRates?.[selectedRateYear] || {};
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(yearRates?.[grade] || ""))}"></td>
</tr>
`).join("");
bindCollectionFieldBehaviors(laborRateRows);
laborRateRows.querySelectorAll(".labor-rate-input").forEach((input) => {
input.addEventListener("input", () => {
const grade = input.dataset.grade || "";
const targetYear = String(laborRateYearSelect?.value || getDefaultLaborRateYear());
const nextYearRates = { ...(currentLaborRates?.[targetYear] || {}) };
nextYearRates[grade] = parseAmount(input.value);
currentLaborRates[targetYear] = nextYearRates;
writeLaborRates(currentLaborRates);
recalculateExecLaborAmounts();
recalculateActualLaborAmounts();
});
});
}
function openLaborRateModal() {
if (laborRateYearSelect) {
const years = getLaborRateYears();
const activeYear = String(laborRateYearSelect.value || getDefaultLaborRateYear());
laborRateYearSelect.innerHTML = years
.map((year) => `<option value="${escapeHtml(String(year))}" ${String(year) === activeYear ? "selected" : ""}>${escapeHtml(String(year))}</option>`)
.join("");
if (!laborRateYearSelect.value || !years.includes(String(laborRateYearSelect.value))) {
laborRateYearSelect.value = activeYear;
}
}
renderLaborRateRows();
laborRateModal?.classList.add("open");
}
function saveLaborRateYear() {
const targetYear = String(laborRateYearSelect?.value || getDefaultLaborRateYear());
const nextYearRates = { ...(currentLaborRates?.[targetYear] || {}) };
laborRateRows?.querySelectorAll(".labor-rate-input").forEach((input) => {
const grade = String(input.dataset.grade || "").trim();
if (!grade) return;
nextYearRates[grade] = parseAmount(input.value);
});
currentLaborRates[targetYear] = nextYearRates;
writeLaborRates(currentLaborRates);
recalculateExecLaborAmounts();
recalculateActualLaborAmounts();
window.alert(`${targetYear}년 기준인건비를 저장했습니다.`);
}
function closeLaborRateModal() {
laborRateModal?.classList.remove("open");
}
function recalculateExecLaborAmounts() {
execLaborRows.querySelectorAll("tr").forEach((row) => {
const grade = row.querySelector(".exec-labor-grade")?.value || "";
const hoursRaw = row.querySelector(".exec-labor-hours")?.value || "";
const hours = parseExecHoursDigits(hoursRaw);
const rateYear = row.querySelector(".exec-labor-rate-year")?.value || getDefaultLaborRateYear();
const amountInput = row.querySelector(".exec-labor-amount");
const hourInput = row.querySelector(".exec-labor-hours");
if (hourInput) {
hourInput.value = formatHourInputValue(String(hourInput.value || "").replace(/[^\d]/g, "").slice(0, 5));
}
if (amountInput) {
amountInput.value = formatAmountInputValue(calculateLaborAmount(grade, hours, rateYear));
}
});
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: [],
actual_labor_adjustment_total: "",
project_type: "",
expected_as_rate: "",
expected_sga_rate: "",
expected_as_cost: "",
expected_sga_budget: "",
project_start_date: "",
project_end_date: "",
completion_status: "",
notes: "",
client_name: "",
order_method: "",
joint_contract: "",
pm_name: "",
contract_status: "",
progress_status: "",
work_category: "",
review_tag: "",
review_note: "",
total_contract_amount: 0,
hanmac_contract_amount: 0,
billing_contract_amount: 0,
billed_amount: 0,
collection_balance_amount: 0,
latest_billing_date: "",
updated_at: "",
};
}
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 = "";
actualLaborRows.innerHTML = "";
actualLaborJointRows.innerHTML = "";
actualAsRows.innerHTML = "";
actualSgaRows.innerHTML = "";
}
function applyProjectEditData(editData) {
const actualEntries = Array.isArray(editData?.actual_input_entries) ? editData.actual_input_entries : [];
const actualLaborAdjustmentTotal = actualEntries.reduce((sum, entry) => {
const entryGroup = entry?.group || "labor";
if (entryGroup === "labor_adjustment") {
return sum + Number(entry?.amount || 0);
}
return sum + Number(entry?.adjustment_amount || 0);
}, 0);
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: actualEntries,
actual_labor_adjustment_total: actualLaborAdjustmentTotal,
};
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 || "";
if (projectImportReviewBanner && projectImportReviewTag && projectImportReviewNote) {
const hasReview = Boolean(normalized.review_tag || normalized.review_note);
projectImportReviewBanner.classList.toggle("is-visible", hasReview);
projectImportReviewTag.textContent = normalized.review_tag || "";
projectImportReviewNote.textContent = normalized.review_note || "";
}
expectedAsRateInput.value = normalized.expected_as_rate === null || normalized.expected_as_rate === undefined || normalized.expected_as_rate === "" ? "" : String(normalized.expected_as_rate);
expectedSgaRateInput.value = normalized.expected_sga_rate === null || normalized.expected_sga_rate === undefined || normalized.expected_sga_rate === "" ? "" : String(normalized.expected_sga_rate);
if (editRevisionInput) {
editRevisionInput.value = normalized.updated_at || "";
}
if (actualLaborAdjustmentInput) {
actualLaborAdjustmentInput.value = formatAmountInputValue(normalized.actual_labor_adjustment_total || "");
}
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) => {
if (row.group === "labor_joint") addRow("actual-labor-joint", row);
else if (row.group === "as") {
const autoSyncRow = {
...row,
auto_sync: row.auto_sync === true || (
String(row.label || row.note || "").trim() === "A/S비" &&
(!parseAmount(row.amount || 0) || parseAmount(row.amount || 0) === parseAmount(normalized.expected_as_cost || 0))
),
};
addRow("actual-as", autoSyncRow);
}
else if (row.group === "sga") {
const autoSyncRow = {
...row,
auto_sync: row.auto_sync === true || (
String(row.label || row.note || "").trim() === "판관비" &&
(!parseAmount(row.amount || 0) || parseAmount(row.amount || 0) === parseAmount(normalized.expected_sga_budget || 0))
),
};
addRow("actual-sga", autoSyncRow);
}
else if (row.group === "labor_adjustment") return;
else addRow("actual-labor", row);
});
if (!normalized.actual_input_entries?.length) {
addRow("actual-labor");
}
if (!actualLaborJointRows.children.length) addRow("actual-labor-joint");
if (!actualAsRows.children.length) addRow("actual-as", { label: "A/S비", amount: normalized.expected_as_cost || "", auto_sync: true });
if (!actualSgaRows.children.length) addRow("actual-sga", { label: "판관비", amount: normalized.expected_sga_budget || "", auto_sync: true });
bindDateTextInputs(document);
recalculateExecLaborAmounts();
recalculateActualLaborAmounts();
updateComputedFields();
}
async function openProjectModalForCode(code = "") {
const normalizedCode = String(code || "").trim();
if (!normalizedCode) {
openProjectModalForFreshEntry(currentSelectedProjectCode || "");
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);
}
projectEditCache.set(normalizedCode, cloneProjectEditData(payload));
modalOriginalEdit = cloneProjectEditData(payload);
applyProjectEditData(payload);
window.__suspendAutoRefresh = true;
modal?.classList.add("open");
restoreProjectStatusDraftIfPresent(normalizedCode);
} catch (error) {
console.error(error);
if (projectEditCache.has(normalizedCode)) {
const cachedData = cloneProjectEditData(projectEditCache.get(normalizedCode));
modalOriginalEdit = cloneProjectEditData(cachedData);
applyProjectEditData(cachedData);
window.__suspendAutoRefresh = true;
modal?.classList.add("open");
restoreProjectStatusDraftIfPresent(normalizedCode);
return;
}
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);
window.__suspendAutoRefresh = true;
modal?.classList.add("open");
restoreProjectStatusDraftIfPresent(normalizedCode);
}
function closeProjectModal() {
if (modalOriginalEdit) {
applyProjectEditData(cloneProjectEditData(modalOriginalEdit));
}
modal?.classList.remove("open");
window.__suspendAutoRefresh = false;
supportDeptCodeResults.classList.remove("open");
supportDeptNameResults.classList.remove("open");
document.activeElement?.blur?.();
}
function getProjectStatusDraftCode(code = "") {
return String(code || supportDeptCodeInput?.value || currentSelectedProjectCode || "__new__").trim() || "__new__";
}
function getProjectStatusDraftKey(code = "") {
return `${PROJECT_STATUS_DRAFT_STORAGE_PREFIX}:${getProjectStatusDraftCode(code)}`;
}
function buildProjectStatusDraftPayload() {
if (!(projectStatusForm instanceof HTMLFormElement)) return null;
const formData = new FormData(projectStatusForm);
const formEntries = [];
formData.forEach((value, key) => {
formEntries.push([key, String(value)]);
});
const containers = {};
Object.entries(projectStatusDraftContainers).forEach(([key, container]) => {
containers[key] = container?.innerHTML || "";
});
return {
code: getProjectStatusDraftCode(),
saved_at: new Date().toISOString(),
form_entries: formEntries,
containers,
};
}
function persistProjectStatusDraftNow() {
try {
const draft = buildProjectStatusDraftPayload();
if (!draft) return;
window.localStorage.setItem(getProjectStatusDraftKey(draft.code), JSON.stringify(draft));
} catch (error) {
console.warn("사업현황 임시 저장에 실패했습니다.", error);
}
}
function scheduleProjectStatusDraftSave() {
window.clearTimeout(projectStatusDraftTimer);
projectStatusDraftTimer = window.setTimeout(() => {
persistProjectStatusDraftNow();
}, 180);
}
function clearProjectStatusDraft(code = "") {
const keys = new Set([
getProjectStatusDraftKey(code),
getProjectStatusDraftKey(),
`${PROJECT_STATUS_DRAFT_STORAGE_PREFIX}:__new__`,
]);
keys.forEach((key) => {
try {
window.localStorage.removeItem(key);
} catch (error) {
console.warn("사업현황 임시 저장 삭제에 실패했습니다.", error);
}
});
}
function applyProjectStatusDraftEntries(entries) {
if (!(projectStatusForm instanceof HTMLFormElement) || !Array.isArray(entries)) return;
const grouped = new Map();
entries.forEach((entry) => {
if (!Array.isArray(entry) || entry.length < 2) return;
const name = String(entry[0] || "");
if (!name) return;
const values = grouped.get(name) || [];
values.push(String(entry[1] || ""));
grouped.set(name, values);
});
grouped.forEach((values, name) => {
const fields = [...projectStatusForm.querySelectorAll(`[name="${CSS.escape(name)}"]`)];
fields.forEach((field, index) => {
const value = values[index] ?? "";
if (field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement || field instanceof HTMLSelectElement) {
field.value = value;
}
});
});
}
function restoreProjectStatusDraftIfPresent(code = "") {
try {
const raw = window.localStorage.getItem(getProjectStatusDraftKey(code));
if (!raw) return false;
const draft = JSON.parse(raw);
const containers = draft?.containers || {};
Object.entries(projectStatusDraftContainers).forEach(([key, container]) => {
if (!container || typeof containers[key] !== "string") return;
container.innerHTML = containers[key];
});
applyProjectStatusDraftEntries(draft?.form_entries || []);
Object.values(projectStatusDraftContainers).forEach((container) => {
if (container) attachTableEvents(container);
});
bindDateTextInputs(document);
recalculateExecLaborAmounts();
recalculateActualLaborAmounts();
updateComputedFields();
return true;
} catch (error) {
console.warn("사업현황 임시 저장 복원에 실패했습니다.", error);
return false;
}
}
function buildScopedProjectStatusBody(scope = "all") {
if (!(projectStatusForm instanceof HTMLFormElement)) {
return new URLSearchParams();
}
const formData = new FormData(projectStatusForm);
if (selectedYear) {
formData.set("selected_year", String(selectedYear));
}
const body = new URLSearchParams();
if (scope === "all") {
formData.forEach((value, key) => {
body.append(key, String(value));
});
body.set("save_scope", "all");
return body;
}
const scopedNames = new Set([
...PROJECT_STATUS_COMMON_FIELDS,
...(PROJECT_STATUS_SCOPE_FIELDS[scope] || []),
]);
scopedNames.forEach((name) => {
const values = formData.getAll(name);
values.forEach((value) => {
body.append(name, String(value));
});
});
body.set("save_scope", scope);
return body;
}
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);
}
});
projectStatusForm?.addEventListener("input", scheduleProjectStatusDraftSave);
projectStatusForm?.addEventListener("change", scheduleProjectStatusDraftSave);
openLaborRateModalButton?.addEventListener("click", openLaborRateModal);
saveLaborRateYearButton?.addEventListener("click", saveLaborRateYear);
closeLaborRateModalButton?.addEventListener("click", closeLaborRateModal);
laborRateYearSelect?.addEventListener("change", () => {
renderLaborRateRows();
});
laborRateModal?.addEventListener("click", (event) => {
if (event.target === laborRateModal) {
closeLaborRateModal();
}
});
contractAmountInput?.addEventListener("input", updateComputedFields);
expectedAsRateInput?.addEventListener("change", updateComputedFields);
expectedSgaRateInput?.addEventListener("change", updateComputedFields);
document.getElementById("expected_as_cost_display")?.addEventListener("input", (event) => {
event.target.value = formatAmountInputValue(event.target.value);
syncExpectedAllocationsToActualInputs();
updateComputedFields();
});
document.getElementById("expected_as_cost_display")?.addEventListener("blur", (event) => {
event.target.value = formatAmountInputValue(event.target.value);
syncExpectedAllocationsToActualInputs();
updateComputedFields();
});
document.getElementById("expected_sga_budget_display")?.addEventListener("input", (event) => {
event.target.value = formatAmountInputValue(event.target.value);
syncExpectedAllocationsToActualInputs();
updateComputedFields();
});
document.getElementById("expected_sga_budget_display")?.addEventListener("blur", (event) => {
event.target.value = formatAmountInputValue(event.target.value);
syncExpectedAllocationsToActualInputs();
updateComputedFields();
});
if (actualLaborAdjustmentInput) {
actualLaborAdjustmentInput.value = formatAmountInputValue(actualLaborAdjustmentInput.value);
actualLaborAdjustmentInput.addEventListener("input", () => {
actualLaborAdjustmentInput.value = formatAmountInputValue(actualLaborAdjustmentInput.value);
updateComputedFields();
});
actualLaborAdjustmentInput.addEventListener("blur", () => {
actualLaborAdjustmentInput.value = formatAmountInputValue(actualLaborAdjustmentInput.value);
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);
async function handleProjectStatusSave(event, scope = "all") {
event?.preventDefault?.();
event?.stopPropagation?.();
event?.stopImmediatePropagation?.();
window.__suspendAutoRefresh = true;
resolveSupportDepartmentFromLookup();
const form = projectStatusForm;
if (!(form instanceof HTMLFormElement)) return;
const submittedAtInput = form.querySelector('input[name="client_submitted_at"]');
if (submittedAtInput instanceof HTMLInputElement) {
submittedAtInput.value = new Date().toISOString();
}
const projectPageHelpers = window.__projectPageHelpers || null;
const saveButtons = [
...form.querySelectorAll('button[type="submit"]'),
...form.querySelectorAll("[data-save-scope-button]"),
];
if (saveProjectModalButton) {
saveButtons.push(saveProjectModalButton);
}
saveButtons.forEach((button) => {
button.disabled = true;
});
try {
persistProjectStatusDraftNow();
const formData = new FormData(form);
const body = buildScopedProjectStatusBody(scope);
const response = await fetch("/projects/save-json", {
method: "POST",
credentials: "same-origin",
headers: {
"Accept": "application/json",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
},
body,
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload?.ok === false) {
const saveError = new Error(payload?.error || `HTTP ${response.status}`);
saveError.payload = payload;
saveError.status = response.status;
throw saveError;
}
const updatedEdit = cloneProjectEditData(payload?.project_edit || {});
const updatedRow = payload?.project_row || null;
const savedCode = String(
updatedRow?.support_dept_code
|| updatedEdit?.support_dept_code
|| formData.get("support_dept_code")
|| ""
).trim();
if (payload?.data_version && typeof window.__setPageDataVersion === "function") {
window.__setPageDataVersion(payload.data_version);
}
if (updatedEdit?.support_dept_code) {
projectEdit = updatedEdit;
modalOriginalEdit = cloneProjectEditData(updatedEdit);
projectEditCache.set(updatedEdit.support_dept_code, updatedEdit);
if (editRevisionInput) {
editRevisionInput.value = updatedEdit.updated_at || "";
}
if (scope === "all") {
applyProjectEditData(updatedEdit);
}
}
if (updatedRow?.support_dept_code) {
bumpUncontractedDashboardRevision();
if (projectPageHelpers?.upsertProjectStatusRow) {
projectPageHelpers.upsertProjectStatusRow(updatedRow);
}
if (projectPageHelpers?.refreshAnalysisForCode) {
projectPageHelpers.refreshAnalysisForCode(updatedRow.support_dept_code);
}
}
if (savedCode) {
selectedCode = savedCode;
currentSelectedProjectCode = savedCode;
analysisOpen = true;
if (projectPageHelpers?.refreshAnalysisForCode) {
projectPageHelpers.refreshAnalysisForCode(savedCode);
}
if (scope === "all") {
clearProjectStatusDraft(savedCode);
}
try {
const nextUrl = new URL(window.location.href);
nextUrl.searchParams.delete("edit_code");
nextUrl.searchParams.set("focus_code", savedCode);
const currentYearValue = String(selectedYear || yearSelect?.value || "").trim();
if (currentYearValue) {
nextUrl.searchParams.set("year", currentYearValue);
} else {
nextUrl.searchParams.delete("year");
}
window.history.replaceState({}, "", nextUrl.toString());
} catch (urlError) {
console.warn("저장 후 URL 상태를 갱신하지 못했습니다.", urlError);
}
try {
await saveProjectPageStateToServer(false);
} catch (stateError) {
console.warn("저장 후 페이지 상태를 저장하지 못했습니다.", stateError);
}
}
const scopeLabelMap = {
all: "사업현황",
basic_info: "기본 정보",
collection: "수금 정보",
task_plan: "과업수행계획",
exec_budget: "실행예산",
actual_input: "실투입",
allocation: "배분 설정",
notes: "비고",
};
const scopeLabel = scopeLabelMap[String(scope || "all")] || "사업현황";
window.alert(`${scopeLabel} 저장이 완료되었습니다.`);
} catch (error) {
console.error(error);
const payload = error?.payload || null;
if (payload?.conflict) {
window.alert(`${error?.message || "다른 사용자가 먼저 수정했습니다."}\n현재 입력값은 이 브라우저에 임시 보전되었습니다.`);
} else {
window.alert(error?.message || "사업현황 저장 중 오류가 발생했습니다.");
}
} finally {
saveButtons.forEach((button) => {
button.disabled = false;
});
}
}
projectStatusForm?.addEventListener("submit", handleProjectStatusSave);
saveProjectModalButton?.addEventListener("click", handleProjectStatusSave);
document.querySelectorAll("[data-save-scope-button]").forEach((button) => {
button.addEventListener("click", (event) => {
const scope = button.getAttribute("data-save-scope-button") || "all";
handleProjectStatusSave(event, scope);
});
});
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 baseDataset = getAggregatedProjectCostDataset(yearFilterValue);
if (!normalized) {
return baseDataset;
}
return baseDataset.filter((item) => String(item._searchText || "").includes(normalized));
}
function filterProjectCostDatasetByContractState(dataset, contractFilterValue) {
if (!contractFilterValue) return dataset;
return dataset.filter((item) => getProjectContractState(getAnalysisItem(item.support_dept_code) || item) === contractFilterValue);
}
function getAnalysisItem(code) {
const costItem = aggregatedAllProjectCostMap.get(code) || 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);
const mergedItem = {
...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 || {}),
};
mergedItem.item_investment = sumActualInputInvestment(mergedItem);
return mergedItem;
}
function getProjectContractState(item) {
const code = item?.support_dept_code || "";
const contractAmount = Number(item?.contract_amount || projectStatusMap[code]?.contract_amount || 0);
const expenseAmount = Number(item?.expense_amount || item?.total_expense || 0);
const collectionAmount = Number(
item?.collection_amount
|| item?.total_revenue
|| projectStatusMap[code]?.collection_amount
|| 0,
);
const hasRelatedContract = getCurrentRelatedCodes(code).some((relatedCode) => {
const relatedItem = getAnalysisItem(relatedCode) || projectStatusMap[relatedCode] || {};
const relatedContractAmount = Number(relatedItem?.contract_amount || 0);
const relatedCollectionAmount = Number(
relatedItem?.collection_amount
|| relatedItem?.total_revenue
|| 0,
);
return relatedCollectionAmount > 0 || relatedContractAmount > 0;
});
if (collectionAmount > 0) {
return "contracted";
}
if (contractAmount > 0) {
return "contracted";
}
if (hasRelatedContract) {
return "contracted";
}
if (expenseAmount > 0) {
return "uncontracted_cost";
}
return "uncontracted";
}
function classifyUncontractedSpecialType(item) {
const code = String(item?.support_dept_code || "");
const override = String(persistedUncontractedCategoryOverrides?.[code] || "");
if (override) return override;
const name = String(item?.support_dept_name || "");
if (!code.startsWith("X")) return "general";
const normalizedName = name.replace(/\s+/g, "");
for (const [categoryKey, keywords] of Object.entries(specialXClassificationRules || {})) {
if ((keywords || []).some((keyword) => normalizedName.includes(String(keyword || "").replace(/\s+/g, "")))) {
return categoryKey;
}
}
return "precontract";
}
function getProjectContractStateLabel(state) {
if (state === "contracted") return "계약 진행";
if (state === "uncontracted_cost") return "미계약 비용 발생";
return "미계약";
}
function getProjectContractStateBadge(item) {
const state = getProjectContractState(item);
return `<span class="contract-state-badge is-${state}">${getProjectContractStateLabel(state)}</span>`;
}
function formatSignedAmount(value) {
const numeric = Number(value || 0);
const prefix = numeric > 0 ? "+" : "";
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.minutes ? formatDurationInputValue(entry.minutes) : ""].filter(Boolean).join(" / ")
|| [entry.grade, entry.hours ? `예상 ${formatHourInputValue(entry.hours)}시간` : ""].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),
adjustment_amount: Number(entry.adjustment_amount || 0),
})).filter((entry) => entry.label || entry.amount || entry.adjustment_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 stripLeadingAccountCode(label) {
return String(label || "").replace(/^\d{6,8}\s*(?:[·ㆍ]|\/)\s*/u, "").trim();
}
function splitProjectEntryLabel(label) {
const normalized = String(label || "").trim();
const match = normalized.match(/^([A-Z]\d{5})\s*[·ㆍ]\s*(.+)$/u);
if (!match) {
return {
projectCode: "",
detailLabel: stripLeadingAccountCode(normalized) || "-",
};
}
return {
projectCode: match[1],
detailLabel: stripLeadingAccountCode(match[2]) || "-",
};
}
function buildGroupedComparisonEntries(entries) {
const groups = [];
const groupMap = new Map();
(entries || []).forEach((entry) => {
const { projectCode, detailLabel } = splitProjectEntryLabel(entry.label);
const groupCode = projectCode || "기타";
if (!groupMap.has(groupCode)) {
const group = {
code: groupCode,
items: [],
subtotal: 0,
};
groupMap.set(groupCode, group);
groups.push(group);
}
const group = groupMap.get(groupCode);
const amount = Number(entry.amount || 0);
group.items.push({
...entry,
label: detailLabel,
amount,
});
group.subtotal += amount;
});
return {
groups,
total: groups.reduce((sum, group) => sum + Number(group.subtotal || 0), 0),
};
}
function getProjectPairOrder(plannedEntries, actualEntries) {
const order = [];
const seen = new Set();
[...plannedEntries, ...actualEntries].forEach((entry) => {
const projectCode = splitProjectEntryLabel(entry.label).projectCode || "기타";
if (seen.has(projectCode)) return;
seen.add(projectCode);
order.push(projectCode);
});
return order;
}
function buildComparisonSideRows(group, emptyLabel, placeholderCount = 0, warningConfig = null) {
const rows = [];
const items = group?.items || [];
const groupCode = group?.code || "기타";
if (items.length) {
items.forEach((entry) => {
rows.push({
type: "item",
label: `${groupCode} · ${entry.label || "-"}`,
amount: Number(entry.amount || 0),
clickMeta: entry.clickMeta || null,
});
});
} else {
if (warningConfig) {
const warningRowCount = Math.max(1, Number(warningConfig.rowSpan || 1));
const warningRowIndex = Math.floor((warningRowCount - 1) / 2);
for (let index = 0; index < warningRowCount; index += 1) {
if (index === warningRowIndex) {
rows.push({
type: "warning",
title: warningConfig.title || "",
subtitle: warningConfig.subtitle || "",
});
} else {
rows.push({
type: "placeholder",
label: "",
amount: null,
});
}
}
} else {
rows.push({
type: "empty",
label: emptyLabel ? `${groupCode} · ${emptyLabel}` : "",
amount: null,
});
}
}
if (!warningConfig) {
for (let index = 0; index < placeholderCount; index += 1) {
rows.push({
type: "placeholder",
label: "",
amount: null,
});
}
}
rows.push({
type: "subtotal",
label: `${groupCode} 소계`,
amount: Number(group?.subtotal || 0),
});
return rows;
}
function renderComparisonSideRows(rows) {
return rows.map((row) => {
if (row.type === "warning") {
return `
<div class="comparison-detail-item is-warning">
<div class="comparison-detail-warning-box">
<span>${escapeHtml(row.title || "")}</span>
<strong>${escapeHtml(row.subtitle || "")}</strong>
</div>
</div>
`;
}
if (row.type === "placeholder") {
return `
<div class="comparison-detail-item is-placeholder">
<span>-</span>
<strong>-</strong>
</div>
`;
}
if (row.type === "empty") {
return `
<div class="comparison-detail-item">
<span>${escapeHtml(row.label || "")}</span>
<strong></strong>
</div>
`;
}
const extraClass = row.type === "subtotal" ? " is-subtotal" : "";
return `
<div class="comparison-detail-item${extraClass}">
<span>${escapeHtml(row.label || "-")}</span>
${row.amount === null || row.amount === undefined
? "<strong></strong>"
: row.clickMeta
? `
<strong
class="detail-amount-button"
role="button"
tabindex="0"
data-comparison-open-transaction="1"
data-support-dept-code="${escapeHtml(row.clickMeta.support_dept_code || "")}"
data-codes="${escapeHtml((row.clickMeta.codes || []).join(","))}"
data-comparison-group="${escapeHtml(row.clickMeta.group || "")}"
data-account-label="${escapeHtml(row.clickMeta.account_label || "")}"
data-project-code="${escapeHtml(row.clickMeta.project_code || "")}"
data-project-name="${escapeHtml(row.clickMeta.project_name || "")}"
>${formatNumber(row.amount)}</strong>
`
: `<strong>${formatNumber(row.amount)}</strong>`
}
</div>
`;
}).join("");
}
function renderGroupedComparisonSection(entries, emptyLabel, title, totalLabel = "총계") {
const grouped = buildGroupedComparisonEntries(entries);
const rows = [];
grouped.groups.forEach((group) => {
rows.push(...buildComparisonSideRows(group, emptyLabel, 0));
});
return `
<section class="comparison-detail-group">
${title ? `<h5>${escapeHtml(title)}</h5>` : ""}
<div class="comparison-detail-list">
${renderComparisonSideRows(rows)}
<div class="comparison-detail-item is-total">
<span>${escapeHtml(totalLabel)}</span>
<strong>${formatNumber(grouped.total)}</strong>
</div>
</div>
</section>
`;
}
function renderAlignedGroupedComparisonSplit(leftEntries, rightEntries, leftTitle, rightTitle, emptyLeftLabel, emptyRightLabel) {
const leftGrouped = buildGroupedComparisonEntries(leftEntries);
const rightGrouped = buildGroupedComparisonEntries(rightEntries);
const leftMap = new Map(leftGrouped.groups.map((group) => [group.code, group]));
const rightMap = new Map(rightGrouped.groups.map((group) => [group.code, group]));
const order = getProjectPairOrder(leftEntries, rightEntries);
const leftRows = [];
const rightRows = [];
order.forEach((projectCode) => {
const leftGroup = leftMap.get(projectCode) || { code: projectCode, items: [], subtotal: 0 };
const rightGroup = rightMap.get(projectCode) || { code: projectCode, items: [], subtotal: 0 };
const leftItemsCount = leftGroup.items?.length ? leftGroup.items.length : 1;
const rightItemsCount = rightGroup.items?.length ? rightGroup.items.length : 1;
const maxItemsCount = Math.max(leftItemsCount, rightItemsCount);
leftRows.push(...buildComparisonSideRows(leftGroup, emptyLeftLabel, maxItemsCount - leftItemsCount));
rightRows.push(...buildComparisonSideRows(rightGroup, emptyRightLabel, maxItemsCount - rightItemsCount));
});
return `
<div class="comparison-detail-split">
<section class="comparison-detail-group">
<h5>${escapeHtml(leftTitle)}</h5>
<div class="comparison-detail-list">
${renderComparisonSideRows(leftRows)}
<div class="comparison-detail-item is-total">
<span>${escapeHtml(`${leftTitle} 총계`)}</span>
<strong>${formatNumber(leftGrouped.total)}</strong>
</div>
</div>
</section>
<section class="comparison-detail-group">
<h5>${escapeHtml(rightTitle)}</h5>
<div class="comparison-detail-list">
${renderComparisonSideRows(rightRows)}
<div class="comparison-detail-item is-total">
<span>${escapeHtml(`${rightTitle} 총계`)}</span>
<strong>${formatNumber(rightGrouped.total)}</strong>
</div>
</div>
</section>
</div>
`;
}
function renderComparisonSideBox(rows, total, totalLabel, title = "") {
return `
<section class="comparison-detail-group">
${title ? `<h5>${escapeHtml(title)}</h5>` : ""}
<div class="comparison-detail-list">
${renderComparisonSideRows(rows)}
<div class="comparison-detail-item is-total">
<span>${escapeHtml(totalLabel)}</span>
<strong>${formatNumber(total)}</strong>
</div>
</div>
</section>
`;
}
function renderTransactionDetailTable(entries, columns) {
const rows = Array.isArray(entries) ? entries : [];
if (!rows.length) {
return `<div class="comparison-detail-item"><span>세부 내역 없음</span><strong>-</strong></div>`;
}
return `
<div class="comparison-detail-table-wrap">
<table class="comparison-detail-table">
<thead>
<tr>
${columns.map((column) => `<th>${escapeHtml(column.label)}</th>`).join("")}
</tr>
</thead>
<tbody>
${rows.map((row) => `
<tr>
${columns.map((column) => {
const rawValue = row?.[column.key];
const value = column.formatter ? column.formatter(rawValue, row) : rawValue;
return `<td>${escapeHtml(String(value ?? "-"))}</td>`;
}).join("")}
</tr>
`).join("")}
</tbody>
</table>
</div>
`;
}
function buildInlineComparisonDetailKey(meta) {
const code = meta?.support_dept_code || "";
const group = meta?.group || "";
const accountLabel = meta?.account_label || "";
const codes = Array.isArray(meta?.codes) ? meta.codes.join(",") : "";
return `${code}__${group}__${accountLabel}__${codes}`;
}
function renderOutsourceComparisonDetails(plannedEntries, actualEntries) {
const rows = (actualEntries || []).map((entry) => ({
posting_date: entry.posting_date || "-",
project_code: entry.project_code || "-",
account_label: entry.account_label || entry.account_name || entry.label || "-",
partner_name: entry.partner_name || "-",
amount: Number(entry.amount || 0),
}));
return `
<div class="comparison-detail-grid">
<section class="comparison-detail-group">
<h5>계획/입력 세부</h5>
<div class="comparison-detail-list">
${renderComparisonDetailList(plannedEntries, "")}
<div class="comparison-detail-item is-total">
<span>총계</span>
<strong>${formatNumber(sumEntryAmounts(plannedEntries))}</strong>
</div>
</div>
</section>
<section class="comparison-detail-group">
<h5>실제 집행 세부</h5>
${renderTransactionDetailTable(rows, [
{ key: "posting_date", label: "일자" },
{ key: "project_code", label: "프로젝트코드" },
{ key: "account_label", label: "계정" },
{ key: "partner_name", label: "거래처명" },
{ key: "amount", label: "금액", formatter: (value) => formatNumber(value || 0) },
])}
</section>
</div>
`;
}
function renderOverheadComparisonDetails(plannedEntries, actualEntries) {
const grouped = buildGroupedComparisonEntries(plannedEntries);
const actualRows = (actualEntries || []).map((entry) => {
const meta = entry.clickMeta || {};
return {
entry,
inlineKey: buildInlineComparisonDetailKey(meta),
};
});
return `
<div class="comparison-detail-grid">
<section class="comparison-detail-group">
<h5>계획/입력 세부</h5>
<div class="comparison-detail-list">
${renderComparisonDetailList(plannedEntries, "")}
<div class="comparison-detail-item is-total">
<span>총계</span>
<strong>${formatNumber(grouped.total)}</strong>
</div>
</div>
</section>
<section class="comparison-detail-group">
<h5>실제 집행 세부</h5>
<div class="comparison-detail-list">
${actualRows.length ? actualRows.map(({ entry, inlineKey }) => `
<div class="comparison-detail-item">
<span>${escapeHtml(entry.label || "-")}</span>
<strong
class="comparison-inline-toggle"
role="button"
tabindex="0"
aria-expanded="false"
data-comparison-inline-toggle="${escapeHtml(inlineKey)}"
data-support-dept-code="${escapeHtml(entry.clickMeta?.support_dept_code || "")}"
data-codes="${escapeHtml((entry.clickMeta?.codes || []).join(","))}"
data-comparison-group="${escapeHtml(entry.clickMeta?.group || "")}"
data-account-label="${escapeHtml(entry.clickMeta?.account_label || "")}"
>${formatNumber(entry.amount || 0)}</strong>
</div>
<div class="comparison-inline-detail" data-comparison-inline-detail="${escapeHtml(inlineKey)}"></div>
`).join("") : `<div class="comparison-detail-item"><span>세부 내역 없음</span><strong>-</strong></div>`}
<div class="comparison-detail-item is-total">
<span>총계</span>
<strong>${formatNumber(sumEntryAmounts(actualEntries))}</strong>
</div>
</div>
</section>
</div>
`;
}
function renderLaborComparisonDetails(baseCode, plannedEntries, erpEntries, jointEntries) {
const plannedGrouped = buildGroupedComparisonEntries(plannedEntries);
const erpGrouped = buildGroupedComparisonEntries(erpEntries);
const jointGrouped = buildGroupedComparisonEntries(jointEntries);
const analysisSetting = getProjectAnalysisSetting(baseCode);
const laborJointExempt = Boolean(analysisSetting.labor_joint_exempt);
const plannedMap = new Map(plannedGrouped.groups.map((group) => [group.code, group]));
const erpMap = new Map(erpGrouped.groups.map((group) => [group.code, group]));
const jointMap = new Map(jointGrouped.groups.map((group) => [group.code, group]));
const order = getProjectPairOrder([...plannedEntries, ...erpEntries], jointEntries);
const detailRowHeight = 38;
function renderProjectBlock(group, rowCount, mode = "default") {
const items = group.items || [];
const detailRows = [];
if (mode === "joint-warning" && !items.length) {
for (let index = 0; index < rowCount; index += 1) {
detailRows.push('<div class="comparison-detail-item is-placeholder"><span></span><strong></strong></div>');
}
const detailHeight = Math.max(rowCount, 1) * detailRowHeight;
return `
<div class="comparison-project-block">
<div class="comparison-project-detail-area is-empty" style="min-height:${detailHeight}px;">
${detailRows.join("")}
<div class="comparison-project-warning-anchor">
<div class="comparison-detail-warning-box">
<span>합사인건비 반영 필요</span>
<strong>(사업관리팀 자료 회신 대기 )</strong>
</div>
</div>
</div>
<div class="comparison-detail-item is-subtotal">
<span>${group.code} 소계</span>
<strong>${formatNumber(group.subtotal || 0)}</strong>
</div>
</div>
`;
}
for (let index = 0; index < rowCount; index += 1) {
const item = items[index];
if (item) {
detailRows.push(`
<div class="comparison-detail-item">
<span>${item.label}</span>
<strong>${formatNumber(item.amount)}</strong>
</div>
`);
} else {
detailRows.push('<div class="comparison-detail-item is-placeholder"><span></span><strong></strong></div>');
}
}
return `
<div class="comparison-project-block">
<div class="comparison-project-detail-area">
${detailRows.join("")}
</div>
<div class="comparison-detail-item is-subtotal">
<span>${group.code} 소계</span>
<strong>${formatNumber(group.subtotal || 0)}</strong>
</div>
</div>
`;
}
const plannedBlocks = [];
const erpBlocks = [];
const jointBlocks = [];
order.forEach((projectCode) => {
const plannedGroup = plannedMap.get(projectCode) || { code: projectCode, items: [], subtotal: 0 };
const erpGroup = erpMap.get(projectCode) || { code: projectCode, items: [], subtotal: 0 };
const jointGroup = jointMap.get(projectCode) || { code: projectCode, items: [], subtotal: 0 };
const maxCount = Math.max(
(plannedGroup.items || []).length || 1,
(erpGroup.items || []).length || 1,
(jointGroup.items || []).length || 1,
);
plannedBlocks.push(renderProjectBlock(plannedGroup, maxCount));
erpBlocks.push(renderProjectBlock(erpGroup, maxCount));
jointBlocks.push(renderProjectBlock(
jointGroup,
maxCount,
!(jointGroup.items || []).length && !laborJointExempt ? "joint-warning" : "default",
));
});
return `
<div class="comparison-detail-grid">
<section class="comparison-detail-group">
<h5>계획/입력 세부</h5>
<div class="comparison-detail-list">
${plannedBlocks.join("")}
<div class="comparison-detail-item is-total">
<span>총계</span>
<strong>${formatNumber(plannedGrouped.total)}</strong>
</div>
</div>
</section>
<section class="comparison-detail-group">
<div class="comparison-detail-head-inline">
<h5>실제 집행 세부</h5>
<label class="comparison-detail-checkbox">
<input type="checkbox" data-labor-joint-exempt="${escapeHtml(baseCode)}" ${laborJointExempt ? "checked" : ""}>
합사없음
</label>
</div>
<div class="comparison-detail-split">
<section class="comparison-detail-group">
<div class="comparison-detail-list">
${erpBlocks.join("")}
<div class="comparison-detail-item is-total">
<span>인건비(ERP) 총계</span>
<strong>${formatNumber(erpGrouped.total)}</strong>
</div>
</div>
</section>
<section class="comparison-detail-group">
<div class="comparison-detail-list">
${jointBlocks.join("")}
<div class="comparison-detail-item is-total">
<span>인건비(합사) 총계</span>
<strong>${formatNumber(jointGrouped.total)}</strong>
</div>
</div>
</section>
</div>
</section>
</div>
`;
}
function renderPairedComparisonDetails(plannedEntries, actualEntries, emptyPlannedLabel, emptyActualLabel) {
const planned = buildGroupedComparisonEntries(plannedEntries);
const actual = buildGroupedComparisonEntries(actualEntries);
const plannedMap = new Map(planned.groups.map((group) => [group.code, group]));
const actualMap = new Map(actual.groups.map((group) => [group.code, group]));
const order = getProjectPairOrder(plannedEntries, actualEntries);
const plannedRows = [];
const actualRows = [];
order.forEach((projectCode) => {
const plannedGroup = plannedMap.get(projectCode) || { code: projectCode, items: [], subtotal: 0 };
const actualGroup = actualMap.get(projectCode) || { code: projectCode, items: [], subtotal: 0 };
const plannedItemsCount = plannedGroup.items?.length ? plannedGroup.items.length : 1;
const actualItemsCount = actualGroup.items?.length ? actualGroup.items.length : 1;
const maxItemsCount = Math.max(plannedItemsCount, actualItemsCount);
plannedRows.push(...buildComparisonSideRows(plannedGroup, emptyPlannedLabel, maxItemsCount - plannedItemsCount));
actualRows.push(...buildComparisonSideRows(actualGroup, emptyActualLabel, maxItemsCount - actualItemsCount));
});
return `
<div class="comparison-detail-grid">
<section class="comparison-detail-group">
<h5>계획/입력 세부</h5>
<div class="comparison-detail-list">
${renderComparisonSideRows(plannedRows)}
<div class="comparison-detail-item is-total">
<span>총계</span>
<strong>${formatNumber(planned.total)}</strong>
</div>
</div>
</section>
<section class="comparison-detail-group">
<h5>실제 집행 세부</h5>
<div class="comparison-detail-list">
${renderComparisonSideRows(actualRows)}
<div class="comparison-detail-item is-total">
<span>총계</span>
<strong>${formatNumber(actual.total)}</strong>
</div>
</div>
</section>
</div>
`;
}
function sumEntryAmounts(entries) {
return (entries || []).reduce((sum, entry) => sum + Number(entry?.amount || 0), 0);
}
function sumActualInputInvestment(item) {
if (!Array.isArray(item?.actual_input_entries)) return 0;
return item.actual_input_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 getActualGroupEntries(item, group, fallbackLabel) {
const entries = Array.isArray(item?.actual_input_entries)
? item.actual_input_entries.filter((entry) => {
const entryGroup = entry?.group || "labor";
if (group === "labor") {
return entryGroup === "labor" || entryGroup === "labor_adjustment";
}
if (group === "labor_joint") {
return entryGroup === "labor_joint";
}
if (group === "overhead") {
return entryGroup === "overhead" || entryGroup === "cost_plan";
}
return entryGroup === group;
})
: [];
const normalized = normalizeDetailEntries(entries, fallbackLabel);
if (group === "labor") {
const expanded = [];
normalized.forEach((entry) => {
const entryGroup = entry.group || "labor";
if (entryGroup === "labor_adjustment") {
expanded.push({
label: entry.label === "인건비 조정비용" ? "인건비 조정" : (entry.label || "인건비 조정"),
amount: Number(entry.amount || 0),
});
return;
}
if (Number(entry.amount || 0)) {
expanded.push({
...entry,
amount: Number(entry.amount || 0),
});
}
if (Number(entry.adjustment_amount || 0)) {
expanded.push({
label: `${entry.label} / 조정`,
amount: Number(entry.adjustment_amount || 0),
});
}
});
return expanded;
}
return normalized;
}
function getCostBreakdownEntries(code) {
return Array.isArray(projectAccountBreakdowns?.[code]?.cost) ? projectAccountBreakdowns[code].cost : [];
}
function getCostTransactionEntries(code) {
return Array.isArray(projectAccountBreakdowns?.[code]?.cost_detail) ? projectAccountBreakdowns[code].cost_detail : [];
}
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 escapeSelectorValue(value) {
if (window.CSS?.escape) {
return window.CSS.escape(String(value || ""));
}
return String(value || "").replace(/(["\\])/g, "\\$1");
}
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 laborActual = getActualGroupEntries(item, "labor", "실투입 인건비");
const laborJointActual = getActualGroupEntries(item, "labor_joint", "실투입 인건비(합사)");
const outsourceInputActual = getActualGroupEntries(item, "outsource", "실투입 외주비");
const overheadInputActual = getActualGroupEntries(item, "overhead", "실투입 제경비");
const asActual = getActualGroupEntries(item, "as", "실투입 A/S비");
const sgaInputActual = getActualGroupEntries(item, "sga", "실투입 판관비");
const costTransactionEntries = aggregateCodes.flatMap((code) => getCostTransactionEntries(code).map((entry) => ({
...entry,
project_code: code,
account_label: entry.label || [entry.account_code, entry.account_name].filter(Boolean).join(" · ") || "-",
})));
const costEntries = aggregateCodes.flatMap((code) => getCostBreakdownEntries(code).map((entry) => ({
label: `${code} · ${entry.label || "-"}`,
amount: Number(entry.amount || 0),
account_label: String(entry.label || "").trim(),
account_code: String(entry.account_code || "").trim(),
account_name: String(entry.account_name || "").trim(),
support_dept_code: code,
})));
const outsourceActual = [
...outsourceInputActual,
...costTransactionEntries
.filter(isDesignOutsourceEntry)
.map((entry) => ({
posting_date: entry.posting_date || "-",
project_code: entry.project_code || item.support_dept_code,
partner_name: entry.partner_name || "-",
account_label: entry.account_label || entry.account_name || "",
amount: Number(entry.amount || 0),
})),
];
const overheadActual = [
...overheadInputActual,
...costEntries
.filter((entry) => !isDesignOutsourceEntry(entry))
.map((entry) => ({
...entry,
clickMeta: {
support_dept_code: item.support_dept_code,
codes: aggregateCodes,
group: "overhead",
account_label: entry.account_label || entry.account_name || "",
project_code: item.support_dept_code,
project_name: item.support_dept_name,
},
})),
];
return {
collection: {
planned: revenuePlanned,
actual: revenueActual,
},
labor: {
planned: laborPlanned,
actual: laborActual,
actual_joint: laborJointActual,
},
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: asActual,
},
sga: {
planned: item.expected_sga_budget ? [{ label: "예상 판관비", amount: Number(item.expected_sga_budget || 0) }] : [],
actual: sgaInputActual,
},
};
}
function getActualExecutionCostFromDetails(details) {
if (!details) return 0;
return (
sumEntryAmounts(details.labor?.actual)
+ sumEntryAmounts(details.labor?.actual_joint)
+ sumEntryAmounts(details.outsource?.actual)
+ sumEntryAmounts(details.overhead?.actual)
+ sumEntryAmounts(details.as?.actual)
+ sumEntryAmounts(details.sga?.actual)
);
}
function renderAnalysisHero(item) {
const editUrl = getProjectEditUrl(item.support_dept_code);
return `
<div class="analysis-hero">
<div class="analysis-hero-header">
<div class="analysis-title">
<h3>${item.support_dept_name}</h3>
<p>${item.support_dept_code} · ${item.year_range} · 최신 반영 ${detailValue(item.latest_year, "-")}${detailValue(item.latest_month, "-")}월</p>
</div>
<div class="actions">
<button type="button" class="button-link button-secondary button-icon" data-set-quick-link="${item.support_dept_code}" title="바로가기 설정" aria-label="바로가기 설정">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M7 4h10a1 1 0 0 1 1 1v15l-6-3-6 3V5a1 1 0 0 1 1-1z"></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>
<div class="analysis-stat-grid">
<div class="analysis-stat">
<span>사업종류</span>
<strong>${detailValue(item.project_type)}</strong>
</div>
<div class="analysis-stat">
<span>진행율</span>
<strong>${Number(item.progress_rate || 0).toFixed(1)}%</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>
`;
}
function formatMonthLabel(month) {
return `${Number(month || 0)}월`;
}
function getUncontractedCategoryLabel(category) {
const matched = (uncontractedCategoryOptions || []).find((item) => item.value === category);
return matched?.label || "일반 미계약";
}
function getProjectCodeYear(code) {
const match = String(code || "").match(/^[A-Z](\d{2})/i);
return match ? Number(match[1]) : 0;
}
function getProjectCodeFullYear(code) {
const shortYear = getProjectCodeYear(code);
if (!shortYear) return 0;
return 2000 + shortYear;
}
function hasProjectVariantMarker(name) {
return /(\d+\s*차|[nN]\s*차|변경|보완|지연보상금|가칭|연차|년분)/.test(String(name || ""));
}
function getProjectAnalysisSetting(code) {
const normalizedCode = String(code || "").trim();
const source = (normalizedCode && projectAnalysisSettings?.[normalizedCode]) || {};
const inactiveRelatedCodes = Array.isArray(source?.inactive_related_codes)
? source.inactive_related_codes.map((value) => String(value || "").trim()).filter(Boolean)
: [];
return {
detail_note: String(source?.detail_note || ""),
inactive_related_codes: inactiveRelatedCodes,
labor_joint_exempt: Boolean(source?.labor_joint_exempt),
};
}
async function saveProjectAnalysisSettingsToServer(code, patch = {}) {
const normalizedCode = String(code || "").trim();
if (!normalizedCode) return;
const current = getProjectAnalysisSetting(normalizedCode);
const payload = {
support_dept_code: normalizedCode,
detail_note: Object.prototype.hasOwnProperty.call(patch, "detail_note") ? patch.detail_note : current.detail_note,
inactive_related_codes: Object.prototype.hasOwnProperty.call(patch, "inactive_related_codes") ? patch.inactive_related_codes : current.inactive_related_codes,
labor_joint_exempt: Object.prototype.hasOwnProperty.call(patch, "labor_joint_exempt") ? patch.labor_joint_exempt : current.labor_joint_exempt,
};
const response = await fetch("/projects/analysis-settings", {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify(payload),
});
const result = await response.json().catch(() => ({}));
if (!response.ok || result?.error) {
throw new Error(result?.error || "프로젝트 상세 설정을 저장하지 못했습니다.");
}
projectAnalysisSettings[normalizedCode] = {
detail_note: String(payload.detail_note || ""),
inactive_related_codes: Array.isArray(payload.inactive_related_codes) ? payload.inactive_related_codes : [],
labor_joint_exempt: Boolean(payload.labor_joint_exempt),
};
bumpUncontractedDashboardRevision();
}
function getCurrentRelatedCodes(baseCode) {
const selectionMap = window.__projectRelatedSelections;
if (!(selectionMap instanceof Map)) return [];
return [...(selectionMap.get(baseCode) || new Set())];
}
function getActiveRelatedCodes(baseCode) {
const allCodes = getCurrentRelatedCodes(baseCode);
const inactiveCodes = new Set(getProjectAnalysisSetting(baseCode).inactive_related_codes || []);
return allCodes.filter((code) => !inactiveCodes.has(code));
}
let uncontractedDetailSelection = { type: "", year: null, month: null };
let uncontractedDetailVisible = false;
function getUncontractedYearRange() {
const currentYear = new Date().getFullYear();
const dataYears = [...new Set(projectCostRows.map((item) => Number(item.year || 0)).filter(Boolean))].sort((a, b) => a - b);
const minYear = 1994;
const maxYear = Math.max(currentYear, dataYears[dataYears.length - 1] || currentYear);
const years = Array.from({ length: Math.max(0, maxYear - minYear + 1) }, (_, index) => minYear + index);
const fallbackStart = Number(persistedUncontractedFilterState.start_year || years[0] || minYear);
const fallbackEnd = Number(persistedUncontractedFilterState.end_year || years[years.length - 1] || maxYear);
return {
allYears: years,
startYear: Number(
document.getElementById("uncontractedYearStart")?.value
|| persistedUncontractedFilterState.start_year
|| fallbackStart
|| 0
),
endYear: Number(
document.getElementById("uncontractedYearEnd")?.value
|| persistedUncontractedFilterState.end_year
|| fallbackEnd
|| 0
),
};
}
function buildUncontractedDashboardData() {
const { startYear, endYear } = getUncontractedYearRange();
const cacheKey = buildUncontractedDashboardCacheKey(startYear, endYear);
const cached = uncontractedDashboardDataCache.get(cacheKey);
if (cached) {
latestExcludedDetailRows = cached.excludedDetailRows || [];
return cached;
}
const filteredRows = projectCostRows.filter((row) => {
const year = Number(row.year || 0);
return year >= startYear && year <= endYear;
});
const filteredAggregate = aggregateProjectCostRows(filteredRows).filter((item) => {
const merged = getAnalysisItem(item.support_dept_code) || item;
return getProjectContractState(merged) !== "contracted";
}).filter((item) => Number(item.expense_amount || 0) > 0);
const filteredAggregateMap = new Map(filteredAggregate.map((item) => [item.support_dept_code, item]));
const filteredExpenseRows = filteredRows.filter((row) => {
if (!filteredAggregateMap.has(row.support_dept_code)) return false;
return Number(row.expense_amount || 0) > 0;
});
const summary = {
uncontracted_projects: filteredAggregate.length,
cost_incurred_projects: filteredAggregate.length,
expense_amount: filteredAggregate.reduce((sum, item) => sum + Number(item.expense_amount || 0), 0),
revenue_amount: filteredAggregate.reduce((sum, item) => sum + Number(item.revenue_amount || 0), 0),
review_needed_projects: filteredAggregate.filter((item) => Boolean(projectStatusMap[item.support_dept_code]?.review_tag)).length,
precontract_projects: 0,
precontract_expense_amount: 0,
corporate_rnd_projects: 0,
corporate_rnd_expense_amount: 0,
external_research_projects: 0,
external_research_expense_amount: 0,
};
filteredAggregate.forEach((item) => {
const type = classifyUncontractedSpecialType(item);
if (type === "corporate_rnd") {
summary.corporate_rnd_projects += 1;
summary.corporate_rnd_expense_amount += Number(item.expense_amount || 0);
} else if (type === "external_research") {
summary.external_research_projects += 1;
summary.external_research_expense_amount += Number(item.expense_amount || 0);
} else if (type === "precontract") {
summary.precontract_projects += 1;
summary.precontract_expense_amount += Number(item.expense_amount || 0);
}
});
const yearlyMap = new Map();
filteredExpenseRows.forEach((row) => {
const merged = getAnalysisItem(row.support_dept_code) || row;
if (getProjectContractState(merged) === "contracted") return;
const year = Number(row.year || 0);
const current = yearlyMap.get(year) || {
year,
uncontracted_projects: new Set(),
cost_incurred_projects: new Set(),
expense_amount: 0,
};
current.uncontracted_projects.add(row.support_dept_code);
current.cost_incurred_projects.add(row.support_dept_code);
current.expense_amount += Number(row.expense_amount || 0);
yearlyMap.set(year, current);
});
const yearlyRows = [];
for (let year = startYear; year <= endYear; year += 1) {
const row = yearlyMap.get(year);
yearlyRows.push({
year,
uncontracted_projects: row?.uncontracted_projects.size || 0,
cost_incurred_projects: row?.cost_incurred_projects.size || 0,
expense_amount: row?.expense_amount || 0,
});
}
const focusYear = Number(uncontractedDetailSelection.year || yearlyRows[yearlyRows.length - 1]?.year || endYear || 0);
const monthlyMap = new Map();
(projectMonthlyCostRows || []).forEach((row) => {
const year = Number(row.year || 0);
if (year !== focusYear) return;
const merged = getAnalysisItem(row.support_dept_code) || row;
if (getProjectContractState(merged) === "contracted") return;
const month = Number(row.month || 0);
const current = monthlyMap.get(month) || {
month,
cost_incurred_projects: new Set(),
expense_amount: 0,
};
const expenseAmount = Number(row.cost_sum || 0) + Number(row.sga_sum || 0);
if (expenseAmount <= 0) return;
if (expenseAmount > 0) current.cost_incurred_projects.add(row.support_dept_code);
current.expense_amount += expenseAmount;
monthlyMap.set(month, current);
});
const monthlyRows = [...monthlyMap.values()].sort((a, b) => a.month - b.month).map((row) => ({
month: row.month,
cost_incurred_projects: row.cost_incurred_projects.size,
expense_amount: row.expense_amount,
}));
let detailRows = [];
let excludedDetailRows = [];
const detailTitle = "세부 내역";
if (uncontractedDetailSelection.type === "month" && uncontractedDetailSelection.year && uncontractedDetailSelection.month) {
const map = new Map();
(projectMonthlyCostRows || []).forEach((row) => {
if (Number(row.year || 0) !== Number(uncontractedDetailSelection.year)) return;
if (Number(row.month || 0) !== Number(uncontractedDetailSelection.month)) return;
const merged = getAnalysisItem(row.support_dept_code) || row;
if (getProjectContractState(merged) === "contracted") return;
const current = map.get(row.support_dept_code) || {
support_dept_code: row.support_dept_code,
support_dept_name: row.support_dept_name,
expense_amount: 0,
};
current.expense_amount += Number(row.cost_sum || 0) + Number(row.sga_sum || 0);
map.set(row.support_dept_code, current);
});
detailRows = sortUncontractedDetailRows([...map.values()]);
} else if (uncontractedDetailSelection.type === "year" && uncontractedDetailSelection.year) {
const map = new Map();
const excludedMap = new Map();
filteredRows.forEach((row) => {
if (Number(row.year || 0) !== Number(uncontractedDetailSelection.year)) return;
const merged = getAnalysisItem(row.support_dept_code) || row;
if (getProjectContractState(merged) === "contracted") return;
if (Number(row.expense_amount || 0) <= 0) return;
const projectCreatedYear = getProjectCodeFullYear(row.support_dept_code || "");
if (!projectCreatedYear || projectCreatedYear < startYear || projectCreatedYear > endYear) {
const excludedCurrent = excludedMap.get(row.support_dept_code) || {
support_dept_code: row.support_dept_code,
support_dept_name: row.support_dept_name,
expense_amount: 0,
created_year: projectCreatedYear || null,
reason: "현재 연도 필터 범위 밖에서 생성된 프로젝트",
};
excludedCurrent.expense_amount += Number(row.expense_amount || 0);
excludedMap.set(row.support_dept_code, excludedCurrent);
return;
}
const current = map.get(row.support_dept_code) || {
support_dept_code: row.support_dept_code,
support_dept_name: row.support_dept_name,
expense_amount: 0,
revenue_amount: 0,
};
current.expense_amount += Number(row.expense_amount || 0);
current.revenue_amount += Number(row.revenue_amount || 0);
map.set(row.support_dept_code, current);
});
detailRows = sortUncontractedDetailRows([...map.values()]);
excludedDetailRows = [...excludedMap.values()].sort((a, b) => Number(b.expense_amount || 0) - Number(a.expense_amount || 0));
} else if (uncontractedDetailSelection.type === "category" && uncontractedDetailSelection.category) {
const map = new Map();
filteredAggregate.forEach((item) => {
if (classifyUncontractedSpecialType(item) !== uncontractedDetailSelection.category) return;
map.set(item.support_dept_code, {
support_dept_code: item.support_dept_code,
support_dept_name: item.support_dept_name,
expense_amount: Number(item.expense_amount || 0),
revenue_amount: Number(item.revenue_amount || 0),
category: classifyUncontractedSpecialType(item),
});
});
detailRows = sortUncontractedDetailRows([...map.values()]);
}
const selectedYearlyRow = uncontractedDetailSelection.type === "year"
? yearlyRows.find((row) => Number(row.year) === Number(uncontractedDetailSelection.year))
: null;
const detailExpenseTotal = detailRows.reduce((sum, row) => sum + Number(row.expense_amount || 0), 0);
const detailExpenseGap = Math.max(0, Number(selectedYearlyRow?.expense_amount || 0) - detailExpenseTotal);
const result = {
startYear,
endYear,
summary,
yearlyRows,
monthlyRows,
focusYear,
detailRows,
excludedDetailRows,
detailExpenseGap,
detailTitle,
};
uncontractedDashboardDataCache.set(cacheKey, result);
return result;
}
function renderUncontractedDashboard() {
const data = buildUncontractedDashboardData();
const allYears = getUncontractedYearRange().allYears;
const orderedYears = [...allYears].sort((a, b) => Number(b) - Number(a));
const startOptions = orderedYears.map((year) => `<option value="${year}"${Number(year) === Number(data.startYear) ? " selected" : ""}>${year}</option>`).join("");
const endOptions = orderedYears.map((year) => `<option value="${year}"${Number(year) === Number(data.endYear) ? " selected" : ""}>${year}</option>`).join("");
const detailExpenseTotal = (data.detailRows || []).reduce((sum, row) => sum + Number(row.expense_amount || 0), 0);
const detailRevenueTotal = (data.detailRows || []).reduce((sum, row) => sum + Number(row.revenue_amount || 0), 0);
latestExcludedDetailRows = data.excludedDetailRows || [];
return `
<div class="uncontracted-dashboard">
<div class="toolbar-head">
<h3>미계약 비용 발생 현황</h3>
<div class="uncontracted-toolbar">
<div class="field">
<select id="uncontractedYearStart" aria-label="미계약 시작 연도">${startOptions}</select>
</div>
<div class="field">
<select id="uncontractedYearEnd" aria-label="미계약 종료 연도">${endOptions}</select>
</div>
<button type="button" class="button-icon button-secondary" id="saveUncontractedFilterState" title="미계약 현황 필터 저장" aria-label="미계약 현황 필터 저장">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 4h9l3 3v13H6z"></path>
<path d="M9 4v6h6V4"></path>
<path d="M9 17h6"></path>
</svg>
<span class="sr-only">미계약 현황 필터 저장</span>
</button>
</div>
</div>
<div class="uncontracted-summary-grid">
<div class="uncontracted-stat"><span>미계약 건수</span><strong>${formatNumber(data.summary.uncontracted_projects || 0)}</strong></div>
<div class="uncontracted-stat"><span>비용 발생 건수</span><strong>${formatNumber(data.summary.cost_incurred_projects || 0)}</strong></div>
<div class="uncontracted-stat"><span>발생 비용</span><strong>${formatDisplayAmount(data.summary.expense_amount || 0)}</strong></div>
<div class="uncontracted-stat"><span>관련 수금</span><strong>${formatDisplayAmount(data.summary.revenue_amount || 0)}</strong></div>
<div class="uncontracted-stat"><span>검토 필요</span><strong>${formatNumber(data.summary.review_needed_projects || 0)}</strong></div>
<div class="uncontracted-stat uncontracted-stat-copy is-clickable ${uncontractedDetailSelection.type === "category" && uncontractedDetailSelection.category === "precontract" ? "is-active" : ""}" data-uncontracted-category="precontract" role="button" tabindex="0" aria-label="사전 사업 코드 세부 내역 보기">
<span>사전 사업 코드</span>
<strong>${formatNumber(data.summary.precontract_projects || 0)}</strong>
<em>${formatDisplayAmount(data.summary.precontract_expense_amount || 0)}</em>
</div>
<div class="uncontracted-stat uncontracted-stat-copy is-clickable ${uncontractedDetailSelection.type === "category" && uncontractedDetailSelection.category === "corporate_rnd" ? "is-active" : ""}" data-uncontracted-category="corporate_rnd" role="button" tabindex="0" aria-label="기업 연구개발 세부 내역 보기">
<span>기업 연구개발</span>
<strong>${formatNumber(data.summary.corporate_rnd_projects || 0)}</strong>
<em>${formatDisplayAmount(data.summary.corporate_rnd_expense_amount || 0)}</em>
</div>
<div class="uncontracted-stat uncontracted-stat-copy is-clickable ${uncontractedDetailSelection.type === "category" && uncontractedDetailSelection.category === "external_research" ? "is-active" : ""}" data-uncontracted-category="external_research" role="button" tabindex="0" aria-label="외부 연구과제 세부 내역 보기">
<span>외부 연구과제</span>
<strong>${formatNumber(data.summary.external_research_projects || 0)}</strong>
<em>${formatDisplayAmount(data.summary.external_research_expense_amount || 0)}</em>
</div>
</div>
<div class="uncontracted-dashboard-grid">
<div class="detail-block">
<h4>연도별 현황</h4>
<table class="compact-table">
<thead><tr><th>연도</th><th>미계약</th><th>비용 발생</th><th>발생 비용</th></tr></thead>
<tbody>
${(data.yearlyRows || []).length ? data.yearlyRows.map((row) => `
<tr class="interactive-row ${uncontractedDetailSelection.type === "year" && Number(uncontractedDetailSelection.year) === Number(row.year) ? "is-active" : ""}" data-uncontracted-year="${row.year}">
<td>${detailValue(row.year, "-")}</td>
<td>${formatNumber(row.uncontracted_projects || 0)}</td>
<td>${formatNumber(row.cost_incurred_projects || 0)}</td>
<td>${formatNumber(row.expense_amount || 0)}</td>
</tr>
`).join("") : `<tr><td colspan="4">표시할 연도별 데이터가 없습니다.</td></tr>`}
</tbody>
</table>
</div>
<div class="detail-block">
<h4>${data.focusYear ? `${data.focusYear}년 월별 비용` : "월별 비용"}</h4>
<table class="compact-table">
<thead><tr><th>월</th><th>비용 발생</th><th>발생 비용</th></tr></thead>
<tbody>
${(data.monthlyRows || []).length ? data.monthlyRows.map((row) => `
<tr class="interactive-row ${uncontractedDetailSelection.type === "month" && Number(uncontractedDetailSelection.year) === Number(data.focusYear) && Number(uncontractedDetailSelection.month) === Number(row.month) ? "is-active" : ""}" data-uncontracted-month="${row.month}" data-uncontracted-month-year="${data.focusYear}">
<td>${formatMonthLabel(row.month)}</td>
<td>${formatNumber(row.cost_incurred_projects || 0)}</td>
<td>${formatNumber(row.expense_amount || 0)}</td>
</tr>
`).join("") : `<tr><td colspan="3">표시할 월별 데이터가 없습니다.</td></tr>`}
</tbody>
</table>
</div>
</div>
<div class="toolbar-head">
<h4>세부 내역</h4>
<button type="button" class="button-icon button-secondary toggle-analysis-button ${uncontractedDetailVisible ? "is-open" : ""}" id="toggleUncontractedDetail" 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>
</div>
${uncontractedDetailVisible && uncontractedDetailSelection.type ? `
<div class="detail-block">
<div class="uncontracted-detail-summary">
<div class="summary-chip">
<span>세부 내역 합계</span>
<strong>${formatNumber(detailExpenseTotal)}</strong>
</div>
${uncontractedDetailSelection.type === "year" ? `
<div class="summary-chip is-clickable" id="openExcludedDetailItems" role="button" tabindex="0" title="제외된 항목 보기" aria-label="제외된 항목 보기">
<span>연도별 발생비용 차액</span>
<strong>${formatNumber(data.detailExpenseGap || 0)}</strong>
</div>
` : `<div class="summary-chip"><span>연도별 발생비용 차액</span><strong>0</strong></div>`}
<div class="summary-chip">
<span>관련수금 합계</span>
<strong>${formatNumber(detailRevenueTotal)}</strong>
</div>
</div>
<table class="compact-table">
<thead>
<tr>
<th class="uncontracted-col-project">
<div class="sortable-header">
<span>구분</span>
${renderSortToggle("code")}
</div>
</th>
<th class="uncontracted-col-amount">
<div class="sortable-header">
<span>발생 비용</span>
${renderSortToggle("expense")}
</div>
</th>
${uncontractedDetailSelection.type === "month" ? "" : `<th class="uncontracted-col-amount">
<div class="sortable-header">
<span>관련 수금</span>
${renderSortToggle("revenue")}
</div>
</th>`}
<th class="classification-cell">섹터 수정</th>
</tr>
</thead>
<tbody>
${(data.detailRows || []).length ? data.detailRows.map((row) => `
<tr class="interactive-row" data-open-project-code="${escapeHtml(row.support_dept_code)}">
<td class="uncontracted-col-project" title="${escapeHtml(row.support_dept_code)} · ${escapeHtml(row.support_dept_name)}">${escapeHtml(row.support_dept_code)} · ${escapeHtml(row.support_dept_name)}</td>
<td class="uncontracted-col-amount is-clickable" data-uncontracted-open-transaction="expense" data-uncontracted-code="${escapeHtml(row.support_dept_code)}" title="${formatNumber(row.expense_amount || 0)}">
<span class="detail-amount-trigger ${formatUncontractedCellAmount(row.expense_amount || 0).empty ? "is-empty" : ""}">
${formatUncontractedCellAmount(row.expense_amount || 0).text}
</span>
</td>
${uncontractedDetailSelection.type === "month" ? "" : `
<td class="uncontracted-col-amount is-clickable" data-uncontracted-open-transaction="revenue" data-uncontracted-code="${escapeHtml(row.support_dept_code)}" title="${formatNumber(row.revenue_amount || 0)}">
<span class="detail-amount-trigger ${formatUncontractedCellAmount(row.revenue_amount || 0).empty ? "is-empty" : ""}">
${formatUncontractedCellAmount(row.revenue_amount || 0).text}
</span>
</td>
`}
<td class="classification-cell">
<select class="detail-classification-select" data-uncontracted-classification="${escapeHtml(row.support_dept_code)}">
${(uncontractedCategoryOptions || []).map((option) => `
<option value="${escapeHtml(option.value)}" ${classifyUncontractedSpecialType(row) === option.value ? "selected" : ""}>${escapeHtml(option.label)}</option>
`).join("")}
</select>
</td>
</tr>
`).join("") : `<tr><td colspan="${uncontractedDetailSelection.type === "month" ? 3 : 4}">표시할 세부 내역이 없습니다.</td></tr>`}
</tbody>
</table>
</div>
` : ""}
</div>
`;
}
function renderRelatedProjectBar(baseItem, relatedItems) {
if (!baseItem) return "";
const analysisSetting = getProjectAnalysisSetting(baseItem.support_dept_code);
const inactiveCodes = new Set(analysisSetting.inactive_related_codes || []);
const tags = (relatedItems || []).map((item) => {
const isInactive = inactiveCodes.has(item.support_dept_code);
return `
<span class="related-project-tag${isInactive ? " is-muted" : ""}">
<span class="related-project-main">
<button type="button" class="related-project-open" data-open-related-project="${escapeHtml(item.support_dept_code)}" title="${escapeHtml(item.support_dept_name)}">
${escapeHtml(item.support_dept_code)}
</button>
<span class="related-project-name">${escapeHtml(item.support_dept_name)}</span>
</span>
<span class="related-project-actions">
<button type="button" class="related-project-toggle" data-toggle-related="${escapeHtml(item.support_dept_code)}" title="${isInactive ? "상세 반영 다시 켜기" : "상세 반영 끄기"}" aria-label="${isInactive ? "상세 반영 다시 켜기" : "상세 반영 끄기"}">${isInactive ? "+" : "-"}</button>
<button type="button" data-remove-related="${escapeHtml(item.support_dept_code)}" title="연관 프로젝트 제거" aria-label="연관 프로젝트 제거">×</button>
</span>
</span>
`;
}).join("");
if (!tags) return "";
return `
<div class="related-tag-stack">
<div class="related-tag-row">
<span class="related-tag-label">연관 프로젝트</span>
${tags}
</div>
<textarea class="analysis-inline-note" data-analysis-detail-note="${escapeHtml(baseItem.support_dept_code)}"></textarea>
</div>
`;
}
function renderAnalysisMetrics(item) {
const details = buildComparisonDetails(item);
const actualExecutionCost = getActualExecutionCostFromDetails(details);
const operatingBalance = Number(item.total_revenue || item.revenue_amount || 0) - actualExecutionCost;
return `
<h4>핵심 비교 지표</h4>
<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(actualExecutionCost)}</strong>
</div>
<div class="analysis-kpi">
<span>영업수지</span>
<strong>${formatDisplayAmount(operatingBalance)}</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 comparisonNotes = projectComparisonNotes?.[item.support_dept_code] || {};
const rows = [
["수금", item.collection_amount || 0, item.total_revenue || 0, "collection"],
["인건비", sumEntryAmounts(details.labor.planned), sumEntryAmounts(details.labor.actual) + sumEntryAmounts(details.labor.actual_joint), "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, sumEntryAmounts(details.as.actual), "as"],
["판관비", item.expected_sga_budget || 0, sumEntryAmounts(details.sga.actual), "sga"],
];
return `
<h4>계획 대비 실제 비교</h4>
<table class="analysis-table">
<thead>
<tr>
<th>항목</th>
<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);
const plannedEntries = detail?.planned || [];
const actualEntries = detail?.actual || [];
const analysisSetting = getProjectAnalysisSetting(item.support_dept_code);
const laborJointActualEntries = detail?.actual_joint || [];
const hasLaborJointInput = laborJointActualEntries.some((entry) => {
return Number(entry?.amount || 0) !== 0 || String(entry?.label || "").trim() !== "";
});
const showLaborJointPending = key === "labor" && !analysisSetting.labor_joint_exempt && !hasLaborJointInput;
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>
<td>
<textarea
class="comparison-note-input"
data-comparison-note-code="${escapeHtml(item.support_dept_code)}"
data-comparison-note-key="${escapeHtml(key)}"
placeholder="${showLaborJointPending ? "합사인건비 미반영" : ""}"
>${escapeHtml(comparisonNotes[key] || "")}</textarea>
</td>
</tr>
${hasDetail ? `
<tr class="comparison-detail-row" data-detail-row="${key}">
<td colspan="5" class="comparison-detail-cell">
<div class="comparison-detail-box">
${key === "labor"
? `
${renderLaborComparisonDetails(
item.support_dept_code,
plannedEntries,
detail?.actual || [],
detail?.actual_joint || [],
)}
`
: key === "outsource"
? renderOutsourceComparisonDetails(
plannedEntries,
actualEntries,
)
: key === "overhead"
? renderOverheadComparisonDetails(
plannedEntries,
actualEntries,
)
: renderPairedComparisonDetails(
plannedEntries,
actualEntries,
"",
"",
)}
</div>
</td>
</tr>` : ""}
`;
}).join("")}
</tbody>
</table>
`;
}
function renderAnalysisNotes(item) {
const details = buildComparisonDetails(item);
const actualLaborAmount = sumEntryAmounts(details.labor?.actual) + sumEntryAmounts(details.labor?.actual_joint);
const actualOutsourceAmount = sumEntryAmounts(details.outsource?.actual);
const actualInvestmentAmount = sumActualInputInvestment(item);
return `
<h4>프로젝트 세부 입력 정보</h4>
<div class="analysis-note-grid">
<div class="detail-block">
<div class="detail-grid">
<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 class="detail-item"><span>실집행 인건비</span><strong>${formatNumber(actualLaborAmount || 0)}</strong></div>
<div class="detail-item"><span>실집행 외주비</span><strong>${formatNumber(actualOutsourceAmount || 0)}</strong></div>
<div class="detail-item"><span>항목별투입액</span><strong>${formatNumber(actualInvestmentAmount || 0)}</strong></div>
<div class="detail-item"><span>청구합계</span><strong>${formatNumber(item.billed_amount || 0)}</strong></div>
<div class="detail-item"><span>수금잔액</span><strong>${formatNumber(item.collection_balance_amount || 0)}</strong></div>
<div class="detail-item"><span>비고</span><strong>${detailValue(item.notes)}</strong></div>
</div>
</div>
</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 getProjectStatusYearRange(item) {
const years = [
Number(item?.latest_year || 0),
Number(String(item?.project_start_date || "").slice(0, 4) || 0),
Number(String(item?.project_end_date || "").slice(0, 4) || 0),
getProjectCodeFullYear(item?.support_dept_code),
].filter(Boolean);
if (!years.length) {
return { min_year: 0, max_year: 0 };
}
return { min_year: Math.min(...years), max_year: Math.max(...years) };
}
function projectStatusMatchesYear(item, yearFilterValue) {
if (!yearFilterValue) return true;
const targetYear = Number(yearFilterValue || 0);
if (!targetYear) return true;
const range = getProjectStatusYearRange(item);
if (range.min_year && range.max_year) {
return range.min_year <= targetYear && targetYear <= range.max_year;
}
return false;
}
function mergeProjectExplorerRows(costRows, statusRows, yearFilterValue = "") {
const merged = new Map();
aggregateProjectCostRows(costRows).forEach((item) => {
merged.set(item.support_dept_code, { ...item });
});
statusRows.forEach((statusItem) => {
const code = String(statusItem?.support_dept_code || "").trim();
if (!code || !projectStatusMatchesYear(statusItem, yearFilterValue)) return;
const yearRange = getProjectStatusYearRange(statusItem);
const current = merged.get(code) || {
support_dept_code: code,
support_dept_name: statusItem.support_dept_name || code,
revenue_amount: 0,
expense_amount: 0,
min_year: yearRange.min_year,
max_year: yearRange.max_year,
matched_years: [],
};
const statusRevenue = Number(statusItem.total_revenue || statusItem.collection_amount || 0);
const statusExpense = Number(statusItem.total_cost || 0) + Number(statusItem.total_sga || 0);
current.support_dept_name = statusItem.support_dept_name || current.support_dept_name || code;
current.revenue_amount = Number(current.revenue_amount || 0) || statusRevenue;
current.expense_amount = Number(current.expense_amount || 0) || statusExpense;
current.min_year = Math.min(
...[current.min_year, yearRange.min_year].map(Number).filter(Boolean),
) || current.min_year || yearRange.min_year || 0;
current.max_year = Math.max(
...[current.max_year, yearRange.max_year].map(Number).filter(Boolean),
) || current.max_year || yearRange.max_year || 0;
current._hasProjectStatus = true;
current._searchText = `${code} ${current.support_dept_name || ""}`.toLowerCase();
merged.set(code, current);
});
return [...merged.values()]
.map((item) => ({
...item,
_searchText: `${item.support_dept_code || ""} ${item.support_dept_name || ""}`.toLowerCase(),
}))
.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));
});
}
const aggregatedProjectCostDatasetCache = new Map();
const aggregatedAllProjectCostRows = mergeProjectExplorerRows(projectCostRows, projectStatusRows);
const aggregatedAllProjectCostMap = new Map(
aggregatedAllProjectCostRows.map((item) => [item.support_dept_code, item]),
);
function getAggregatedProjectCostDataset(yearFilterValue) {
const cacheKey = String(yearFilterValue || "");
if (aggregatedProjectCostDatasetCache.has(cacheKey)) {
return aggregatedProjectCostDatasetCache.get(cacheKey);
}
const dataset = !yearFilterValue
? aggregatedAllProjectCostRows
: mergeProjectExplorerRows(
projectCostRows.filter((item) => String(item.year) === String(yearFilterValue)),
projectStatusRows,
yearFilterValue,
);
aggregatedProjectCostDatasetCache.set(cacheKey, dataset);
return dataset;
}
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(1)}% · 수금 ${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 details = buildComparisonDetails(item);
const actualLaborAmount = sumEntryAmounts(details.labor?.actual) + sumEntryAmounts(details.labor?.actual_joint);
const actualOutsourceAmount = sumEntryAmounts(details.outsource?.actual);
const actualInvestmentAmount = sumActualInputInvestment(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(1)}%</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(actualInvestmentAmount)}</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(sumEntryAmounts(getActualGroupEntries(item, "sga", "실투입 판관비")))}</strong></div>
<div class="detail-item"><span>실집행 인건비</span><strong>${formatNumber(actualLaborAmount)}</strong></div>
<div class="detail-item"><span>실집행 외주비</span><strong>${formatNumber(actualOutsourceAmount)}</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 contractFilterSelect = document.getElementById("projectContractFilter");
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 savePageStateButton = document.getElementById("saveProjectPageState");
const quickLinkBar = document.getElementById("projectQuickLinkBar");
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 uncontractedBox = document.getElementById("projectUncontractedDashboard");
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";
const serverEditCode = String(projectEdit?.support_dept_code || "").trim();
const initialFocusCode = String(serverFocusCode || "").trim();
let selectedCode = initialFocusCode || serverEditCode;
let analysisOpen = Boolean(initialFocusCode || serverEditCode);
let projectQuickLinks = [];
const relatedProjectSelections = loadRelatedProjectSelections();
window.__projectRelatedSelections = relatedProjectSelections;
function loadRelatedProjectSelections() {
const merged = new Map();
const mergeSource = (source) => {
if (!source || typeof source !== "object") return;
Object.entries(source).forEach(([code, values]) => {
if (typeof code !== "string" || !code.trim() || !Array.isArray(values)) return;
const current = new Set(merged.get(code) || []);
values.forEach((value) => {
if (typeof value === "string" && value.trim()) {
current.add(value.trim());
}
});
merged.set(code, current);
});
};
mergeSource(persistedProjectRelatedLinks);
mergeSource(persistedProjectPageState?.related_project_selections);
try {
const raw = window.localStorage.getItem(RELATED_PROJECT_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
mergeSource(parsed);
}
} catch (error) {
console.warn("Failed to load related project selections", error);
}
return merged;
}
function syncGlobalRelatedProjectSelections() {
window.__projectRelatedSelections = relatedProjectSelections;
}
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);
}
}
async function saveRelatedProjectSelectionsForCode(baseCode) {
const normalizedBaseCode = String(baseCode || "").trim();
if (!normalizedBaseCode) return;
const relatedCodes = [...(relatedProjectSelections.get(normalizedBaseCode) || new Set())];
const response = await fetch("/projects/related-links", {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify({
base_code: normalizedBaseCode,
related_codes: relatedCodes,
}),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const result = await response.json();
if (result?.error) {
throw new Error(result.error);
}
bumpUncontractedDashboardRevision();
}
async function saveProjectPageStateToServer(showFeedback = false) {
try {
const uncontractedYearStart = document.getElementById("uncontractedYearStart")?.value || persistedUncontractedFilterState.start_year || "";
const uncontractedYearEnd = document.getElementById("uncontractedYearEnd")?.value || persistedUncontractedFilterState.end_year || "";
const payload = {
session_id: projectPageSessionId,
selected_code: selectedCode,
selected_year: yearSelect?.value || "",
analysis_open: analysisOpen,
uncontracted_year_start: uncontractedYearStart,
uncontracted_year_end: uncontractedYearEnd,
related_project_selections: Object.fromEntries(
[...relatedProjectSelections.entries()].map(([code, values]) => [code, [...values]]),
),
};
const response = await fetch("/projects/page-state", {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const result = await response.json();
if (result?.error) {
throw new Error(result.error);
}
if (showFeedback) {
window.alert("페이지 입력사항을 DB에 저장했습니다.");
}
} catch (error) {
console.error(error);
window.alert("페이지 입력사항을 DB에 저장하지 못했습니다.");
}
}
async function loadProjectPageStateFromServer() {
if (!projectPageSessionId) return;
try {
const response = await fetch(`/projects/page-state?session_id=${encodeURIComponent(projectPageSessionId)}`, {
method: "GET",
credentials: "same-origin",
headers: {
"Accept": "application/json",
},
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const state = await response.json();
if (!state || typeof state !== "object" || state.error) {
return;
}
if (typeof state.selected_code === "string" && state.selected_code.trim()) {
selectedCode = state.selected_code.trim();
currentSelectedProjectCode = selectedCode;
}
analysisOpen = Boolean(state.analysis_open);
if (typeof state.uncontracted_year_start === "string") {
persistedUncontractedFilterState.start_year = state.uncontracted_year_start;
}
if (typeof state.uncontracted_year_end === "string") {
persistedUncontractedFilterState.end_year = state.uncontracted_year_end;
}
} catch (error) {
console.warn("Failed to load project page state", error);
}
}
async function saveUncontractedFilterState() {
const startYear = document.getElementById("uncontractedYearStart")?.value || "";
const endYear = document.getElementById("uncontractedYearEnd")?.value || "";
persistedUncontractedFilterState.start_year = startYear;
persistedUncontractedFilterState.end_year = endYear;
await saveProjectPageStateToServer(true);
}
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;
}
const visibleItems = dataset.slice(0, 20);
listBox.innerHTML = visibleItems.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>
${getProjectContractStateBadge(getAnalysisItem(item.support_dept_code) || item)}
</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("");
emptyBox.textContent = dataset.length > visibleItems.length
? `검색 결과 ${dataset.length}건 중 상위 ${visibleItems.length}건을 표시합니다.`
: "";
}
function syncSelection(dataset, options = {}) {
const { renderDetail = false } = options;
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;
if (renderDetail && analysisOpen) {
renderAnalysis(getAnalysisItem(selectedCode));
}
}
function getRelatedCodes(baseCode) {
return getCurrentRelatedCodes(baseCode);
}
function getRelatedItems(baseCode, { includeInactive = false } = {}) {
const codes = includeInactive ? getRelatedCodes(baseCode) : getActiveRelatedCodes(baseCode);
return codes
.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 openDetailYearSettingModal() {
if (detailVisibleMinYearInput) {
detailVisibleMinYearInput.value = String(Number(projectRuntimeSettings?.detail_visible_min_year || 2023));
}
detailYearSettingModal?.classList.add("open");
}
function closeDetailYearSettingModal() {
detailYearSettingModal?.classList.remove("open");
}
function openExcludedDetailItemsModal() {
const totalGap = latestExcludedDetailRows.reduce((sum, row) => sum + Number(row.expense_amount || 0), 0);
excludedDetailItemsSummary.textContent = latestExcludedDetailRows.length
? `세부 내역에서 제외된 ${latestExcludedDetailRows.length}건의 발생 비용 합계는 ${formatNumber(totalGap)}원입니다.`
: "현재 제외된 항목이 없습니다.";
excludedDetailItemsRows.innerHTML = latestExcludedDetailRows.length
? latestExcludedDetailRows.map((row) => `
<tr>
<td>${escapeHtml(row.support_dept_code)} · ${escapeHtml(row.support_dept_name)}</td>
<td>${detailValue(row.created_year, "-")}</td>
<td>${formatNumber(row.expense_amount || 0)}</td>
<td>${escapeHtml(row.reason || "-")}</td>
</tr>
`).join("")
: `<tr><td colspan="4">표시할 제외 항목이 없습니다.</td></tr>`;
excludedDetailItemsModal?.classList.add("open");
}
function closeExcludedDetailItemsModal() {
excludedDetailItemsModal?.classList.remove("open");
}
async function openUncontractedTransactionModal(code, kind) {
if (window.getSelection) {
const selection = window.getSelection();
if (selection && typeof selection.removeAllRanges === "function") {
selection.removeAllRanges();
}
}
const normalizedCode = String(code || "").trim();
const normalizedKind = String(kind || "").trim().toLowerCase();
if (!normalizedCode || !normalizedKind || !uncontractedDetailSelection.type) return;
const params = new URLSearchParams({
support_dept_code: normalizedCode,
kind: normalizedKind,
detail_type: uncontractedDetailSelection.type,
start_year: String(getUncontractedYearRange().startYear || ""),
end_year: String(getUncontractedYearRange().endYear || ""),
});
if (uncontractedDetailSelection.year) {
params.set("year", String(uncontractedDetailSelection.year));
}
if (uncontractedDetailSelection.month) {
params.set("month", String(uncontractedDetailSelection.month));
}
if (uncontractedDetailSelection.category) {
params.set("category", String(uncontractedDetailSelection.category));
}
uncontractedTransactionModalTitle.textContent = `${normalizedCode} · ${normalizedKind === "expense" ? "발생 비용" : "관련 수금"} 세부내역`;
uncontractedTransactionModalTotal.textContent = "0원";
uncontractedTransactionRows.innerHTML = `<tr><td colspan="5">거래내역을 불러오는 중입니다.</td></tr>`;
latestTransactionModalContext = {
type: "uncontracted",
supportDeptCode: normalizedCode,
codes: [normalizedCode],
projectName: "",
};
uncontractedTransactionModal?.classList.add("open");
try {
const response = await fetch(`/projects/uncontracted-detail-transactions?${params.toString()}`, {
credentials: "same-origin",
headers: { Accept: "application/json" },
});
const payload = await response.json();
if (!response.ok || payload?.error) {
throw new Error(payload?.error || `HTTP ${response.status}`);
}
latestUncontractedTransactionRows = Array.isArray(payload?.rows) ? payload.rows : [];
uncontractedTransactionModalTotal.textContent = `${formatNumber(payload?.total_amount || 0)}원`;
renderUncontractedTransactionRows();
} catch (error) {
console.error(error);
uncontractedTransactionRows.innerHTML = `<tr><td colspan="5">거래내역을 불러오지 못했습니다.</td></tr>`;
uncontractedTransactionModalTotal.textContent = "0원";
}
}
function closeUncontractedTransactionModal() {
uncontractedTransactionModal?.classList.remove("open");
}
async function openComparisonTransactionModal({ supportDeptCode = "", codes = [], group = "", accountLabel = "", projectCode = "", projectName = "" } = {}) {
if (window.getSelection) {
const selection = window.getSelection();
if (selection && typeof selection.removeAllRanges === "function") {
selection.removeAllRanges();
}
}
const normalizedSupportDeptCode = String(supportDeptCode || "").trim();
const normalizedCodes = (Array.isArray(codes) ? codes : String(codes || "").split(","))
.map((code) => String(code || "").trim())
.filter(Boolean);
const normalizedGroup = String(group || "").trim();
const normalizedAccountLabel = String(accountLabel || "").trim();
if (!normalizedSupportDeptCode || !normalizedCodes.length || !normalizedGroup) return;
const params = new URLSearchParams({
support_dept_code: normalizedSupportDeptCode,
codes: normalizedCodes.join(","),
group: normalizedGroup,
account_label: normalizedAccountLabel,
});
latestTransactionModalContext = {
type: "comparison",
supportDeptCode: normalizedSupportDeptCode,
codes: normalizedCodes,
projectName: String(projectName || "").trim(),
};
uncontractedTransactionModalTitle.textContent = `${String(projectCode || normalizedSupportDeptCode).trim()} · 실제 집행 세부내역`;
uncontractedTransactionModalTotal.textContent = "0원";
uncontractedTransactionRows.innerHTML = `<tr><td colspan="5">거래내역을 불러오는 중입니다.</td></tr>`;
uncontractedTransactionModal?.classList.add("open");
try {
const response = await fetch(`/projects/comparison-actual-transactions?${params.toString()}`, {
credentials: "same-origin",
headers: { Accept: "application/json" },
});
const payload = await response.json();
if (!response.ok || payload?.error) {
throw new Error(payload?.error || `HTTP ${response.status}`);
}
latestUncontractedTransactionRows = Array.isArray(payload?.rows) ? payload.rows : [];
uncontractedTransactionModalTotal.textContent = `${formatNumber(payload?.total_amount || 0)}원`;
renderUncontractedTransactionRows();
} catch (error) {
console.error(error);
latestUncontractedTransactionRows = [];
uncontractedTransactionRows.innerHTML = `<tr><td colspan="5">거래내역을 불러오지 못했습니다.</td></tr>`;
uncontractedTransactionModalTotal.textContent = "0원";
}
}
async function toggleComparisonInlineDetail(trigger) {
const inlineKey = String(trigger?.dataset.comparisonInlineToggle || "").trim();
const container = inlineKey
? comparisonBox?.querySelector(`[data-comparison-inline-detail="${escapeSelectorValue(inlineKey)}"]`)
: null;
if (!inlineKey || !container) return;
const isOpen = container.classList.contains("is-open");
if (isOpen) {
container.classList.remove("is-open");
container.innerHTML = "";
trigger.setAttribute("aria-expanded", "false");
return;
}
trigger.setAttribute("aria-expanded", "true");
container.classList.add("is-open");
container.innerHTML = `<div class="comparison-inline-detail-note">거래내역을 불러오는 중입니다.</div>`;
if (comparisonInlineDetailCache.has(inlineKey)) {
const cachedRows = comparisonInlineDetailCache.get(inlineKey) || [];
container.innerHTML = renderTransactionDetailTable(cachedRows, [
{ key: "posting_date", label: "일자" },
{ key: "account_label", label: "계정" },
{ key: "partner_name", label: "거래처명" },
{ key: "amount", label: "금액", formatter: (value) => formatNumber(value || 0) },
]);
return;
}
const params = new URLSearchParams({
support_dept_code: String(trigger.dataset.supportDeptCode || "").trim(),
codes: String(trigger.dataset.codes || "").trim(),
group: String(trigger.dataset.comparisonGroup || "").trim(),
account_label: String(trigger.dataset.accountLabel || "").trim(),
});
try {
const response = await fetch(`/projects/comparison-actual-transactions?${params.toString()}`, {
credentials: "same-origin",
headers: { Accept: "application/json" },
});
const payload = await response.json();
if (!response.ok || payload?.error) {
throw new Error(payload?.error || `HTTP ${response.status}`);
}
const rows = Array.isArray(payload?.rows) ? payload.rows.map((row) => ({
posting_date: row.posting_date || "-",
account_label: row.account_label || row.account_name || "-",
partner_name: row.partner_name || "-",
amount: Number(row.amount || 0),
})) : [];
comparisonInlineDetailCache.set(inlineKey, rows);
container.innerHTML = renderTransactionDetailTable(rows, [
{ key: "posting_date", label: "일자" },
{ key: "account_label", label: "계정" },
{ key: "partner_name", label: "거래처명" },
{ key: "amount", label: "금액", formatter: (value) => formatNumber(value || 0) },
]);
} catch (error) {
console.error(error);
container.innerHTML = `<div class="comparison-inline-detail-note">거래내역을 불러오지 못했습니다.</div>`;
}
}
async function openVendorTransactionModal(partnerCode, partnerName, projectCode, projectName) {
const normalizedPartnerCode = String(partnerCode || "").trim();
const normalizedPartnerName = String(partnerName || "").trim();
const normalizedProjectCode = String(projectCode || "").trim();
const normalizedProjectName = String(projectName || "").trim();
if (!normalizedPartnerCode && !normalizedPartnerName) return;
latestVendorModalContext = { type: "uncontracted" };
syncVendorPeriodToolbar();
const range = getUncontractedYearRange();
const params = new URLSearchParams({
partner_code: normalizedPartnerCode,
partner_name: normalizedPartnerName,
start_year: String(range.startYear || ""),
end_year: String(range.endYear || ""),
});
vendorTransactionModalTitle.textContent = "거래처 세부내역";
vendorTransactionModalTotal.textContent = "0원";
vendorTransactionRows.innerHTML = `<tr><td colspan="6">거래내역을 불러오는 중입니다.</td></tr>`;
vendorTransactionModal?.classList.add("open");
try {
const response = await fetch(`/projects/uncontracted-vendor-transactions?${params.toString()}`, {
credentials: "same-origin",
headers: { Accept: "application/json" },
});
const payload = await response.json();
if (!response.ok || payload?.error) {
throw new Error(payload?.error || `HTTP ${response.status}`);
}
latestVendorTransactionRows = Array.isArray(payload?.rows) ? payload.rows : [];
vendorTransactionModalTotal.textContent = `${formatNumber(payload?.total_amount || 0)}원`;
renderVendorTransactionRows();
} catch (error) {
console.error(error);
vendorTransactionRows.innerHTML = `<tr><td colspan="6">거래내역을 불러오지 못했습니다.</td></tr>`;
vendorTransactionModalTotal.textContent = "0원";
}
}
function closeVendorTransactionModal() {
vendorTransactionModal?.classList.remove("open");
}
async function openComparisonVendorTransactionModal(partnerCode, partnerName, projectCode, projectName, codes = []) {
const normalizedPartnerCode = String(partnerCode || "").trim();
const normalizedPartnerName = String(partnerName || "").trim();
const normalizedProjectCode = String(projectCode || "").trim();
const normalizedCodes = (Array.isArray(codes) ? codes : String(codes || "").split(","))
.map((code) => String(code || "").trim())
.filter(Boolean);
if ((!normalizedPartnerCode && !normalizedPartnerName) || !normalizedCodes.length) return;
latestVendorModalContext = {
type: "comparison",
supportDeptCode: normalizedProjectCode,
codes: normalizedCodes,
partnerCode: normalizedPartnerCode,
partnerName: normalizedPartnerName,
projectName: String(projectName || "").trim(),
projectCode: normalizedProjectCode,
defaultStartDate: "",
defaultEndDate: "",
};
vendorTransactionModalTitle.textContent = "거래처 세부내역";
vendorTransactionModalTotal.textContent = "0원";
vendorTransactionRows.innerHTML = `<tr><td colspan="6">거래내역을 불러오는 중입니다.</td></tr>`;
if (vendorUseProjectPeriodCheckbox) vendorUseProjectPeriodCheckbox.checked = true;
if (vendorPeriodStartInput) vendorPeriodStartInput.value = "";
if (vendorPeriodEndInput) vendorPeriodEndInput.value = "";
syncVendorPeriodToolbar();
vendorTransactionModal?.classList.add("open");
await loadComparisonVendorTransactionRows({ useProjectPeriod: true });
}
async function saveDetailYearSetting() {
const numericYear = Number(detailVisibleMinYearInput?.value || 2023);
if (!numericYear || numericYear < 2000 || numericYear > 2099) {
window.alert("세부내역 반영 시작 연도는 2000~2099 사이여야 합니다.");
return;
}
try {
const response = await fetch("/projects/runtime-setting", {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify({
item_key: "detail_visible_min_year",
value: String(numericYear),
}),
});
const payload = await response.json();
if (!response.ok || payload?.error) {
throw new Error(payload?.error || `HTTP ${response.status}`);
}
Object.assign(projectRuntimeSettings, payload?.settings || {}, { detail_visible_min_year: String(numericYear) });
bumpUncontractedDashboardRevision();
closeDetailYearSettingModal();
scheduleStandaloneUncontractedDashboardRender();
} catch (error) {
console.error(error);
window.alert("세부내역 반영 연도 설정을 저장하지 못했습니다.");
}
}
async function addRelatedProject(code) {
if (!selectedCode || !code || code === selectedCode) return;
const set = new Set(relatedProjectSelections.get(selectedCode) || []);
set.add(code);
relatedProjectSelections.set(selectedCode, set);
syncGlobalRelatedProjectSelections();
persistRelatedProjectSelections();
try {
await saveRelatedProjectSelectionsForCode(selectedCode);
} catch (error) {
console.error(error);
window.alert("연관 프로젝트를 DB에 저장하지 못했습니다.");
}
bumpUncontractedDashboardRevision();
closeRelatedProjectPicker();
renderAnalysis(getAnalysisItem(selectedCode));
}
async 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);
}
syncGlobalRelatedProjectSelections();
persistRelatedProjectSelections();
try {
await saveRelatedProjectSelectionsForCode(selectedCode);
} catch (error) {
console.error(error);
window.alert("연관 프로젝트 변경을 DB에 저장하지 못했습니다.");
}
bumpUncontractedDashboardRevision();
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 renderProjectQuickLinks() {
if (!quickLinkBar) return;
const items = projectQuickLinks
.map((code) => {
const item = getAnalysisItem(code) || projectStatusMap[code] || null;
if (item) {
return {
support_dept_code: code,
support_dept_name: item.support_dept_name || code,
};
}
return {
support_dept_code: code,
support_dept_name: code,
};
})
.slice(0, 6);
quickLinkBar.innerHTML = items.map((item) => `
<div class="project-quick-link" data-open-quick-link="${escapeHtml(item.support_dept_code)}" role="button" tabindex="0" title="${escapeHtml(item.support_dept_name)}" aria-label="${escapeHtml(item.support_dept_name)} 바로가기">
<span>${escapeHtml(item.support_dept_name)}</span>
<button type="button" class="project-quick-link-remove" data-remove-quick-link="${escapeHtml(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>
</button>
</div>
`).join("");
}
async function loadProjectQuickLinksFromServer() {
try {
const response = await fetch("/projects/quick-links", {
method: "GET",
credentials: "same-origin",
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);
}
projectQuickLinks = Array.isArray(payload?.codes) ? payload.codes.filter(Boolean) : [];
} catch (error) {
console.warn("Failed to load project quick links", error);
}
renderProjectQuickLinks();
}
async function saveProjectQuickLinksToServer(showFeedback = false) {
try {
const response = await fetch("/projects/quick-links", {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify({
session_id: projectPageSessionId,
codes: projectQuickLinks,
}),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = await response.json();
if (payload?.error) {
throw new Error(payload.error);
}
projectQuickLinks = Array.isArray(payload?.codes) ? payload.codes.filter(Boolean) : projectQuickLinks;
renderProjectQuickLinks();
if (showFeedback) {
window.alert("프로젝트 바로가기를 저장했습니다.");
}
} catch (error) {
console.error(error);
window.alert("프로젝트 바로가기를 저장하지 못했습니다.");
}
}
async function addProjectQuickLink(code) {
const normalizedCode = String(code || "").trim();
if (!normalizedCode) return;
projectQuickLinks = [normalizedCode, ...projectQuickLinks.filter((item) => item !== normalizedCode)].slice(0, 6);
renderProjectQuickLinks();
await saveProjectQuickLinksToServer(true);
}
async function removeProjectQuickLink(code) {
const normalizedCode = String(code || "").trim();
if (!normalizedCode) return;
projectQuickLinks = projectQuickLinks.filter((item) => item !== normalizedCode);
renderProjectQuickLinks();
await saveProjectQuickLinksToServer(false);
}
async function saveComparisonNoteToServer(code, itemKey, note) {
const response = await fetch("/projects/comparison-note", {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify({
support_dept_code: code,
item_key: itemKey,
note,
}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload?.error) {
throw new Error(payload?.error || "비고를 저장하지 못했습니다.");
}
projectComparisonNotes[code] = projectComparisonNotes[code] || {};
projectComparisonNotes[code][itemKey] = note;
}
function renderAnalysis(item) {
if (!item) {
relatedBarBox.innerHTML = "";
heroBox.innerHTML = "";
metricsBox.innerHTML = "";
comparisonBox.innerHTML = "";
notesBox.innerHTML = "";
scheduleStandaloneUncontractedDashboardRender();
return;
}
const allRelatedItems = getRelatedItems(item.support_dept_code, { includeInactive: true });
const relatedItems = getRelatedItems(item.support_dept_code);
const aggregateItem = aggregateAnalysisItem(item, relatedItems);
relatedBarBox.innerHTML = renderRelatedProjectBar(item, allRelatedItems);
const detailNoteInput = relatedBarBox.querySelector("[data-analysis-detail-note]");
if (detailNoteInput) {
detailNoteInput.value = getProjectAnalysisSetting(item.support_dept_code).detail_note || "";
}
heroBox.innerHTML = renderAnalysisHero(aggregateItem);
metricsBox.innerHTML = renderAnalysisMetrics(aggregateItem);
comparisonBox.innerHTML = renderAnalysisComparison(aggregateItem);
notesBox.innerHTML = renderAnalysisNotes(aggregateItem);
heroBox.querySelectorAll("[data-set-quick-link]").forEach((button) => {
button.addEventListener("click", async () => {
await addProjectQuickLink(button.dataset.setQuickLink || "");
});
});
relatedBarBox.querySelectorAll("[data-toggle-related]").forEach((button) => {
button.addEventListener("click", async (event) => {
event.preventDefault();
event.stopPropagation();
const toggleCode = String(button.dataset.toggleRelated || "").trim();
if (!toggleCode || !item.support_dept_code) return;
const setting = getProjectAnalysisSetting(item.support_dept_code);
const nextInactive = new Set(setting.inactive_related_codes || []);
if (nextInactive.has(toggleCode)) {
nextInactive.delete(toggleCode);
} else {
nextInactive.add(toggleCode);
}
try {
await saveProjectAnalysisSettingsToServer(item.support_dept_code, {
inactive_related_codes: [...nextInactive],
});
renderAnalysis(getAnalysisItem(item.support_dept_code));
} catch (error) {
console.error(error);
window.alert(error.message || "연관 프로젝트 반영 설정을 저장하지 못했습니다.");
}
});
});
relatedBarBox.querySelectorAll("[data-remove-related]").forEach((button) => {
button.addEventListener("click", () => removeRelatedProject(button.dataset.removeRelated || ""));
});
relatedBarBox.querySelectorAll("[data-open-related-project]").forEach((button) => {
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const code = String(button.dataset.openRelatedProject || "").trim();
if (!code) return;
const targetItem = getAnalysisItem(code);
if (!targetItem) return;
renderAnalysis(targetItem);
});
});
relatedBarBox.querySelectorAll("[data-analysis-detail-note]").forEach((inputEl) => {
inputEl.addEventListener("focusout", async () => {
const baseCode = String(inputEl.dataset.analysisDetailNote || "").trim();
if (!baseCode) return;
const note = inputEl.value || "";
const previous = getProjectAnalysisSetting(baseCode).detail_note || "";
if (note === previous) return;
try {
await saveProjectAnalysisSettingsToServer(baseCode, {
detail_note: note,
});
} catch (error) {
console.error(error);
window.alert(error.message || "프로젝트 설명을 저장하지 못했습니다.");
inputEl.value = previous;
}
});
});
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);
});
});
comparisonBox.querySelectorAll("[data-labor-joint-exempt]").forEach((checkbox) => {
checkbox.addEventListener("change", async () => {
const code = String(checkbox.dataset.laborJointExempt || "").trim();
if (!code) return;
const checked = Boolean(checkbox.checked);
try {
await saveProjectAnalysisSettingsToServer(code, {
labor_joint_exempt: checked,
});
renderAnalysis(getAnalysisItem(code));
} catch (error) {
console.error(error);
checkbox.checked = !checked;
window.alert(error.message || "합사없음 설정을 저장하지 못했습니다.");
}
});
});
comparisonBox.querySelectorAll("[data-comparison-open-transaction]").forEach((trigger) => {
const openModal = () => {
openComparisonTransactionModal({
supportDeptCode: trigger.dataset.supportDeptCode || "",
codes: trigger.dataset.codes || "",
group: trigger.dataset.comparisonGroup || "",
accountLabel: trigger.dataset.accountLabel || "",
projectCode: trigger.dataset.projectCode || "",
projectName: trigger.dataset.projectName || "",
});
};
trigger.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
openModal();
});
trigger.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
event.stopPropagation();
openModal();
});
});
comparisonBox.querySelectorAll("[data-comparison-inline-toggle]").forEach((trigger) => {
trigger.addEventListener("click", async (event) => {
event.preventDefault();
event.stopPropagation();
await toggleComparisonInlineDetail(trigger);
});
trigger.addEventListener("keydown", async (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
event.stopPropagation();
await toggleComparisonInlineDetail(trigger);
});
});
}
window.__projectPageHelpers = {
upsertProjectStatusRow,
refreshAnalysisForCode(code) {
const normalizedCode = String(code || "").trim();
if (!normalizedCode) return;
selectedCode = normalizedCode;
currentSelectedProjectCode = normalizedCode;
const nextItem = getAnalysisItem(normalizedCode);
if (nextItem) {
if (input) {
input.value = nextItem.support_dept_name || "";
}
setAnalysisVisibility(true);
renderAnalysis(nextItem);
}
},
};
function refresh(options = {}) {
const { renderDetail = false } = options;
const keyword = input?.value || "";
const dataset = filterProjectCostDatasetByContractState(
buildProjectCostDataset(keyword, yearSelect?.value || ""),
contractFilterSelect?.value || "",
);
syncSelection(dataset, {
renderDetail: renderDetail && !keyword.trim(),
});
renderSuggestionList(dataset, keyword);
}
let refreshTimer = null;
input?.addEventListener("input", () => {
window.clearTimeout(refreshTimer);
refreshTimer = window.setTimeout(() => refresh({ renderDetail: false }), 90);
});
yearSelect?.addEventListener("change", () => refresh({ renderDetail: true }));
contractFilterSelect?.addEventListener("change", () => refresh({ renderDetail: true }));
input?.addEventListener("focus", () => refresh({ renderDetail: false }));
function renderStandaloneUncontractedDashboard() {
if (!uncontractedBox) return;
try {
uncontractedBox.innerHTML = renderUncontractedDashboard();
} catch (error) {
console.error("미계약 비용 발생 현황 렌더 에러", error);
uncontractedBox.innerHTML = `
<div class="uncontracted-dashboard">
<div class="toolbar-head">
<h3>미계약 비용 발생 현황</h3>
</div>
<div class="detail-block">
<p class="stacked-note">미계약 비용 발생 현황을 불러오는 중 문제가 발생했습니다. 페이지를 새로고침한 뒤 다시 확인해 주세요.</p>
</div>
</div>
`;
}
}
let uncontractedDashboardRenderToken = null;
function scheduleStandaloneUncontractedDashboardRender(options = {}) {
const { urgent = false } = options;
if (uncontractedDashboardRenderToken) {
if (typeof uncontractedDashboardRenderToken === "number") {
window.clearTimeout(uncontractedDashboardRenderToken);
} else if (typeof window.cancelIdleCallback === "function") {
window.cancelIdleCallback(uncontractedDashboardRenderToken);
}
uncontractedDashboardRenderToken = null;
}
const runner = () => {
uncontractedDashboardRenderToken = null;
renderStandaloneUncontractedDashboard();
};
if (urgent) {
uncontractedDashboardRenderToken = window.setTimeout(runner, 0);
return;
}
if (typeof window.requestIdleCallback === "function") {
uncontractedDashboardRenderToken = window.requestIdleCallback(runner, { timeout: 600 });
return;
}
uncontractedDashboardRenderToken = window.setTimeout(runner, 120);
}
uncontractedBox?.addEventListener("change", async (event) => {
const startSelect = event.target.closest("#uncontractedYearStart");
const endSelect = event.target.closest("#uncontractedYearEnd");
const classificationSelect = event.target.closest("[data-uncontracted-classification]");
if (!startSelect && !endSelect && !classificationSelect) return;
if (classificationSelect) {
const supportDeptCode = classificationSelect.dataset.uncontractedClassification || "";
const category = classificationSelect.value || "general";
try {
const response = await fetch("/projects/uncontracted-category", {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify({
support_dept_code: supportDeptCode,
category,
}),
});
const payload = await response.json();
if (!response.ok || payload?.error) {
throw new Error(payload?.error || `HTTP ${response.status}`);
}
persistedUncontractedCategoryOverrides[supportDeptCode] = category;
bumpUncontractedDashboardRevision();
scheduleStandaloneUncontractedDashboardRender({ urgent: true });
} catch (error) {
console.error(error);
window.alert("미계약 섹터를 저장하지 못했습니다.");
}
return;
}
const startYear = Number(document.getElementById("uncontractedYearStart")?.value || 0);
const endYear = Number(document.getElementById("uncontractedYearEnd")?.value || 0);
if (startYear && endYear && startYear > endYear) {
if (startSelect) {
document.getElementById("uncontractedYearEnd").value = String(startYear);
} else {
document.getElementById("uncontractedYearStart").value = String(endYear);
}
}
uncontractedDetailSelection = { type: "", year: null, month: null };
uncontractedDetailVisible = false;
scheduleStandaloneUncontractedDashboardRender({ urgent: true });
});
uncontractedBox?.addEventListener("click", (event) => {
const yearRow = event.target.closest("[data-uncontracted-year]");
const monthRow = event.target.closest("[data-uncontracted-month]");
const categoryButton = event.target.closest("[data-uncontracted-category]");
const toggleButton = event.target.closest("#toggleUncontractedDetail");
const saveFilterButton = event.target.closest("#saveUncontractedFilterState");
const excludedItemsButton = event.target.closest("#openExcludedDetailItems");
const detailAmountButton = event.target.closest("[data-uncontracted-open-transaction]");
const sortButton = event.target.closest("[data-uncontracted-sort]");
const openProjectRow = event.target.closest("[data-open-project-code]");
if (toggleButton) {
uncontractedDetailVisible = !uncontractedDetailVisible;
scheduleStandaloneUncontractedDashboardRender({ urgent: true });
return;
}
if (saveFilterButton) {
saveUncontractedFilterState();
return;
}
if (excludedItemsButton) {
openExcludedDetailItemsModal();
return;
}
if (sortButton) {
const nextKey = sortButton.dataset.uncontractedSort || "code";
if (uncontractedDetailSort?.key === nextKey) {
uncontractedDetailSort = {
key: nextKey,
direction: uncontractedDetailSort.direction === "asc" ? "desc" : "asc",
};
} else {
uncontractedDetailSort = { key: nextKey, direction: nextKey === "code" ? "asc" : "desc" };
}
scheduleStandaloneUncontractedDashboardRender({ urgent: true });
return;
}
if (detailAmountButton) {
event.stopPropagation();
openUncontractedTransactionModal(
detailAmountButton.dataset.uncontractedCode || "",
detailAmountButton.dataset.uncontractedOpenTransaction || "expense",
);
return;
}
if (categoryButton) {
uncontractedDetailSelection = {
type: "category",
category: categoryButton.dataset.uncontractedCategory || "general",
year: null,
month: null,
};
uncontractedDetailVisible = true;
scheduleStandaloneUncontractedDashboardRender({ urgent: true });
return;
}
if (yearRow) {
uncontractedDetailSelection = {
type: "year",
year: Number(yearRow.dataset.uncontractedYear || 0),
month: null,
};
uncontractedDetailVisible = true;
scheduleStandaloneUncontractedDashboardRender({ urgent: true });
return;
}
if (monthRow) {
uncontractedDetailSelection = {
type: "month",
year: Number(monthRow.dataset.uncontractedMonthYear || 0),
month: Number(monthRow.dataset.uncontractedMonth || 0),
};
uncontractedDetailVisible = true;
scheduleStandaloneUncontractedDashboardRender({ urgent: true });
return;
}
if (openProjectRow) {
if (event.target.closest(".detail-classification-select")) return;
selectedCode = openProjectRow.dataset.openProjectCode || "";
currentSelectedProjectCode = selectedCode;
setAnalysisVisibility(true);
renderAnalysis(getAnalysisItem(selectedCode));
}
});
uncontractedBox?.addEventListener("mousedown", (event) => {
const detailAmountButton = event.target.closest("[data-uncontracted-open-transaction]");
if (!detailAmountButton) return;
event.preventDefault();
});
uncontractedBox?.addEventListener("pointerdown", (event) => {
const detailAmountButton = event.target.closest("[data-uncontracted-open-transaction]");
if (!detailAmountButton) return;
event.preventDefault();
});
uncontractedBox?.addEventListener("keydown", (event) => {
const categoryButton = event.target.closest("[data-uncontracted-category]");
const excludedItemsButton = event.target.closest("#openExcludedDetailItems");
const detailAmountButton = event.target.closest("[data-uncontracted-open-transaction]");
if (categoryButton) {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
categoryButton.click();
return;
}
if (detailAmountButton) {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
detailAmountButton.click();
return;
}
if (!excludedItemsButton) return;
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
excludedItemsButton.click();
});
listBox?.addEventListener("click", (event) => {
const itemEl = event.target.closest(".explorer-item");
if (!itemEl) return;
selectedCode = itemEl.dataset.code || "";
currentSelectedProjectCode = selectedCode;
input.value = itemEl.querySelector("strong")?.textContent || input.value;
dropdownBox?.classList.remove("open");
setAnalysisVisibility(true);
renderSuggestionList([], "");
renderAnalysis(getAnalysisItem(selectedCode));
});
quickLinkBar?.addEventListener("click", (event) => {
const removeButton = event.target.closest("[data-remove-quick-link]");
if (removeButton) {
event.stopPropagation();
removeProjectQuickLink(removeButton.dataset.removeQuickLink || "");
return;
}
const quickLinkButton = event.target.closest("[data-open-quick-link]");
if (!quickLinkButton) return;
selectedCode = quickLinkButton.dataset.openQuickLink || "";
currentSelectedProjectCode = selectedCode;
setAnalysisVisibility(true);
renderAnalysis(getAnalysisItem(selectedCode));
});
quickLinkBar?.addEventListener("keydown", (event) => {
const quickLinkButton = event.target.closest("[data-open-quick-link]");
if (!quickLinkButton) return;
if (event.key !== "Enter" && event.key !== " ") return;
if (event.target.closest("[data-remove-quick-link]")) return;
event.preventDefault();
quickLinkButton.click();
});
relatedSearchInput?.addEventListener("input", refreshRelatedProjectResults);
relatedModalCloseButton?.addEventListener("click", closeRelatedProjectPicker);
relatedModal?.addEventListener("click", (event) => {
if (event.target === relatedModal) {
closeRelatedProjectPicker();
}
});
closeDetailYearSettingModalButton?.addEventListener("click", closeDetailYearSettingModal);
saveDetailYearSettingButton?.addEventListener("click", saveDetailYearSetting);
detailYearSettingModal?.addEventListener("click", (event) => {
if (event.target === detailYearSettingModal) {
closeDetailYearSettingModal();
}
});
closeExcludedDetailItemsModalButton?.addEventListener("click", closeExcludedDetailItemsModal);
excludedDetailItemsModal?.addEventListener("click", (event) => {
if (event.target === excludedDetailItemsModal) {
closeExcludedDetailItemsModal();
}
});
closeUncontractedTransactionModalButton?.addEventListener("click", closeUncontractedTransactionModal);
closeVendorTransactionModalButton?.addEventListener("click", closeVendorTransactionModal);
uncontractedTransactionModal?.addEventListener("click", (event) => {
const sortButton = event.target.closest("[data-uncontracted-transaction-sort]");
const vendorLink = event.target.closest("[data-uncontracted-open-vendor]");
const comparisonVendorLink = event.target.closest("[data-comparison-open-vendor]");
if (sortButton) {
const nextKey = sortButton.dataset.uncontractedTransactionSort || "posting_date";
if (uncontractedTransactionSort?.key === nextKey) {
uncontractedTransactionSort = {
key: nextKey,
direction: uncontractedTransactionSort.direction === "asc" ? "desc" : "asc",
};
} else {
uncontractedTransactionSort = { key: nextKey, direction: nextKey === "amount" ? "desc" : "asc" };
}
renderUncontractedTransactionRows();
return;
}
if (vendorLink) {
openVendorTransactionModal(
vendorLink.dataset.uncontractedOpenVendor || "",
vendorLink.dataset.uncontractedVendorName || "",
vendorLink.dataset.uncontractedProjectCode || "",
vendorLink.dataset.uncontractedProjectName || "",
);
return;
}
if (comparisonVendorLink) {
openComparisonVendorTransactionModal(
comparisonVendorLink.dataset.comparisonOpenVendor || "",
comparisonVendorLink.dataset.comparisonVendorName || "",
comparisonVendorLink.dataset.comparisonProjectCode || "",
comparisonVendorLink.dataset.comparisonProjectName || "",
comparisonVendorLink.dataset.comparisonCodes || "",
);
return;
}
if (event.target === uncontractedTransactionModal) {
closeUncontractedTransactionModal();
}
});
vendorTransactionModal?.addEventListener("click", (event) => {
const sortButton = event.target.closest("[data-vendor-transaction-sort]");
if (sortButton) {
const nextKey = sortButton.dataset.vendorTransactionSort || "posting_date";
if (vendorTransactionSort?.key === nextKey) {
vendorTransactionSort = {
key: nextKey,
direction: vendorTransactionSort.direction === "asc" ? "desc" : "asc",
};
} else {
vendorTransactionSort = { key: nextKey, direction: nextKey === "amount" ? "desc" : "asc" };
}
renderVendorTransactionRows();
return;
}
if (event.target === vendorTransactionModal) {
closeVendorTransactionModal();
}
});
vendorUseProjectPeriodCheckbox?.addEventListener("change", async () => {
syncVendorPeriodToolbar();
if (latestVendorModalContext?.type !== "comparison") return;
if (vendorUseProjectPeriodCheckbox.checked) {
await loadComparisonVendorTransactionRows({ useProjectPeriod: true });
}
});
vendorPeriodSearchButton?.addEventListener("click", async () => {
if (latestVendorModalContext?.type !== "comparison") return;
if (vendorUseProjectPeriodCheckbox?.checked) {
await loadComparisonVendorTransactionRows({ useProjectPeriod: true });
return;
}
await loadComparisonVendorTransactionRows({ useProjectPeriod: false });
});
analysisToggle?.addEventListener("click", () => {
const nextOpen = !analysisOpen;
setAnalysisVisibility(nextOpen);
if (nextOpen && selectedCode) {
renderAnalysis(getAnalysisItem(selectedCode));
}
});
savePageStateButton?.addEventListener("click", async () => {
await saveProjectPageStateToServer(true);
});
relatedToolbarButton?.addEventListener("click", () => {
openRelatedProjectPicker();
});
document.addEventListener("click", (event) => {
if (!dropdownBox?.contains(event.target) && event.target !== input) {
dropdownBox?.classList.remove("open");
}
});
uncontractedDetailSelection = { type: "", year: null, month: null };
uncontractedDetailVisible = false;
(async () => {
await loadProjectPageStateFromServer();
await loadProjectQuickLinksFromServer();
if (serverEditCode) {
selectedCode = serverEditCode;
currentSelectedProjectCode = serverEditCode;
analysisOpen = true;
} else if (initialFocusCode) {
selectedCode = initialFocusCode;
currentSelectedProjectCode = initialFocusCode;
analysisOpen = true;
}
if (!analysisOpen) {
// Keep the first view lightweight unless the current browser
// session explicitly saved an open detail state.
setAnalysisVisibility(false);
} else {
setAnalysisVisibility(true);
}
refresh({ renderDetail: false });
const startupFocusCode = serverEditCode || initialFocusCode;
if (startupFocusCode) {
const initialItem = getAnalysisItem(startupFocusCode);
if (initialItem) {
if (input) {
input.value = initialItem.support_dept_name || "";
}
setAnalysisVisibility(true);
renderAnalysis(initialItem);
}
}
scheduleStandaloneUncontractedDashboardRender();
})();
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();
const fallbackHref = editButton.getAttribute("href") || "";
const targetCode = editButton.dataset.editCode || "";
try {
await openProjectModalForCode(targetCode);
} catch (error) {
console.error(error);
}
if (!modal?.classList.contains("open") && fallbackHref) {
window.location.href = fallbackHref;
}
});
document.addEventListener("focusout", async (event) => {
const noteInput = event.target.closest(".comparison-note-input");
if (!noteInput) return;
const code = noteInput.dataset.comparisonNoteCode || "";
const itemKey = noteInput.dataset.comparisonNoteKey || "";
const note = noteInput.value.trim();
const previous = (projectComparisonNotes?.[code] || {})[itemKey] || "";
if (note === previous) return;
try {
await saveComparisonNoteToServer(code, itemKey, note);
} catch (error) {
console.error(error);
alert(error.message || "비고를 저장하지 못했습니다.");
noteInput.value = previous;
}
});
if (modal?.classList.contains("open") && projectEdit.support_dept_code) {
modalOriginalEdit = cloneProjectEditData(projectEdit);
applyProjectEditData(projectEdit);
}
if (closeModalButton) {
closeModalButton.addEventListener("click", () => {
closeProjectModal();
});
}
modal?.addEventListener("click", (event) => {
if (event.target === modal) {
closeProjectModal();
}
});
renderRevenueChart();
})();
</script>
{% endblock %}