Files

10127 lines
431 KiB
HTML

{% extends "base.html" %}
{% block title %}프로젝트 정보{% endblock %}
{% block head_extra %}
<style>
.page-hero {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 18px;
flex-wrap: wrap;
}
.hero-copy {
display: grid;
gap: 8px;
}
.hero-copy p {
color: var(--muted);
line-height: 1.7;
max-width: 920px;
}
.top-actions {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.overview-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.overview-title-row {
display: inline-flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.overview-year-select {
min-width: 156px;
}
.existing-rate-option {
background: #d9dde3;
color: var(--ink);
}
.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) 44px 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 {
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 {
transform: none;
background: transparent;
color: var(--ink);
}
.related-project-toggle {
font-weight: 900;
}
.related-project-remove {
border: none;
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;
}
.related-project-remove:hover {
background: rgba(15, 23, 42, 0.14);
color: var(--ink);
transform: none;
}
.related-project-remove svg {
width: 14px;
height: 14px;
stroke: currentColor;
stroke-width: 2;
fill: none;
}
.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: 24%;
}
.exec-labor-table col:nth-child(2) {
width: 18%;
}
.exec-labor-table col:nth-child(3) {
width: 28%;
}
.exec-labor-table col:nth-child(4) {
width: 20%;
}
.exec-labor-table col:nth-child(5) {
width: 10%;
}
.exec-labor-table .exec-labor-grade {
max-width: none;
min-width: 0;
width: 100%;
color: var(--ink);
background: #fff;
}
.exec-labor-table .exec-labor-hours {
max-width: none;
min-width: 0;
width: 100%;
text-align: right;
}
.exec-labor-table .exec-labor-rate-year {
max-width: none;
min-width: 0;
width: 100%;
padding-right: 24px;
color: var(--ink);
background: #fff;
}
.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;
}
}
.project-job-status {
display: inline-flex;
align-items: center;
min-height: 32px;
max-width: 300px;
padding: 0 10px;
border: 1px solid #d8e2ec;
border-radius: 999px;
background: #f8fafc;
color: #475569;
font-size: 12px;
font-weight: 800;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.project-job-status[data-state="queued"],
.project-job-status[data-state="running"] {
border-color: #bae6fd;
background: #f0f9ff;
color: #075985;
}
.project-job-status[data-state="done"] {
border-color: #bbf7d0;
background: #f0fdf4;
color: #166534;
}
.project-job-status[data-state="failed"] {
border-color: #fecaca;
background: #fef2f2;
color: #991b1b;
}
</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="projectRebuildCacheBtn" class="button-secondary">캐시 재계산</button>
<span class="project-job-status" id="projectJobStatus" data-state="">작업 상태 확인 전</span>
<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="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>
<button type="button" id="openRelatedProjectToolbar" class="button-icon button-secondary related-trigger-button" title="연관 프로젝트 추가" aria-label="연관 프로젝트 추가">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M10 13a5 5 0 0 0 7.1 0l2-2a5 5 0 0 0-7.1-7.1l-1.1 1.1"></path>
<path d="M14 11a5 5 0 0 0-7.1 0l-2 2A5 5 0 0 0 12 20.1l1.1-1.1"></path>
<path d="M12 8v8"></path>
<path d="M8 12h8"></path>
</svg>
<span class="sr-only">연관 프로젝트 추가</span>
</button>
<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">
<div class="table-total">합계: <span id="execBudgetTotalDisplay">0</span></div>
<button type="button" class="button-link button-secondary mini-button" data-save-scope-button="exec_budget">실행예산 저장</button>
</div>
</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 }}" class="{% if option.is_existing_value %}existing-rate-option{% endif %}" {% 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 }}" class="{% if option.is_existing_value %}existing-rate-option{% endif %}" {% 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>
<label for="laborRateCategorySelect" style="font-size:12px; color:var(--muted);">구분</label>
<select id="laborRateCategorySelect" class="inline-input" style="min-width:96px;">
<option value="설계">설계</option>
<option value="감리">감리</option>
<option value="지원">지원</option>
</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 laborRateCategories = ["설계", "감리", "지원"];
const derivedLaborRateGrades = ["수석", "책임", "선임", "연구원"];
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;
let revenueMix = [];
const revenueMixMode = {{ ("monthly" if selected_year else "yearly") | tojson }};
let projectCostRows = [];
const projectMonthlyCostRows = {{ project_monthly_cost_rows | tojson }};
let projectStatusRows = [];
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 defaultExecLaborRates = {{ default_exec_labor_rates | tojson }};
const projectPageSessionId = window.clientSessionId || "";
let projectStatusMap = {};
let projectAccountBreakdowns = {};
let projectAccountBreakdownsLoaded = false;
let projectAccountBreakdownsLoadedCodes = new Set();
let projectAccountBreakdownsPromise = null;
let projectJobPollTimer = null;
let projectBootstrapLoadError = "";
let projectCostMap = {};
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 defaultExpectedAsRateValues = new Set(["0", "2", "5", "10"]);
const defaultExpectedSgaRateValues = new Set(["13", "15", "20", "25"]);
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 laborRateCategorySelect = document.getElementById("laborRateCategorySelect");
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 roundPercentageRate(value) {
const numeric = parseAmount(value);
return Number.isFinite(numeric) ? Math.round(numeric) : 0;
}
function normalizePercentageSelectValue(input) {
if (!input) return "";
const rawValue = String(input.value ?? "").trim();
if (!rawValue) return "";
const roundedValue = String(roundPercentageRate(rawValue));
if (![...input.options].some((option) => option.value === roundedValue)) {
const option = new Option(`${roundedValue}%`, roundedValue);
if (!isDefaultPercentageRateValue(input, roundedValue)) {
option.classList.add("existing-rate-option");
}
input.add(option);
}
input.value = roundedValue;
return roundedValue;
}
function isDefaultPercentageRateValue(input, value) {
const normalizedValue = String(value ?? "").trim();
if (input === expectedAsRateInput) {
return defaultExpectedAsRateValues.has(normalizedValue);
}
if (input === expectedSgaRateInput) {
return defaultExpectedSgaRateValues.has(normalizedValue);
}
return false;
}
function setRoundedPercentageSelectValue(input, value) {
if (!input || value === null || value === undefined || value === "") {
if (input) input.value = "";
return;
}
const roundedValue = String(roundPercentageRate(value));
if (![...input.options].some((option) => option.value === roundedValue)) {
const option = new Option(`${roundedValue}%`, roundedValue);
if (!isDefaultPercentageRateValue(input, roundedValue)) {
option.classList.add("existing-rate-option");
}
input.add(option);
}
input.value = roundedValue;
}
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();
const normalizeGradeName = (grade) => {
const text = String(grade || "").replace(/\s+/g, "");
if (text === "전무이사") return "전무";
if (text === "상무이사") return "상무";
return text;
};
const normalizeCategory = (category) => {
const text = String(category || "").trim();
if (text.includes("감리")) return "감리";
if (text.includes("지원")) return "지원";
if (text.includes("설계")) return "설계";
if (text === "supervision") return "감리";
if (text === "support") return "지원";
return "설계";
};
const applySource = (rateSource, overwrite = true) => {
Object.entries(rateSource || {}).forEach(([key, value]) => {
if (/^\d{4}$/.test(String(key || "")) && value && typeof value === "object" && !Array.isArray(value)) {
const yearKey = String(key);
const yearBucket = next[yearKey] || { 설계: {}, 감리: {}, 지원: {} };
const hasCategoryBucket = Object.entries(value).some(([, bucket]) => bucket && typeof bucket === "object" && !Array.isArray(bucket));
if (hasCategoryBucket) {
Object.entries(value).forEach(([categoryKey, categoryBucket]) => {
if (!categoryBucket || typeof categoryBucket !== "object" || Array.isArray(categoryBucket)) return;
const category = normalizeCategory(categoryKey);
yearBucket[category] = yearBucket[category] || {};
Object.entries(categoryBucket).forEach(([grade, amount]) => {
const normalizedGrade = normalizeGradeName(grade);
if (normalizedGrade) {
const numericAmount = Number(amount || 0);
if (numericAmount > 0 && (overwrite || !yearBucket[category][normalizedGrade])) {
yearBucket[category][normalizedGrade] = numericAmount;
}
}
});
});
} else {
yearBucket.설계 = yearBucket.설계 || {};
Object.entries(value).forEach(([grade, amount]) => {
const normalizedGrade = normalizeGradeName(grade);
if (normalizedGrade) {
const numericAmount = Number(amount || 0);
if (numericAmount > 0 && (overwrite || !yearBucket.설계[normalizedGrade])) {
yearBucket.설계[normalizedGrade] = numericAmount;
}
}
});
}
if (!Object.keys(yearBucket.지원).length) {
yearBucket.지원 = { ...yearBucket.감리 };
}
next[yearKey] = yearBucket;
}
});
};
applySource(defaultExecLaborRates, true);
applySource(source, true);
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 ceilToHundreds(value) {
const amount = Number(value || 0);
if (!Number.isFinite(amount) || amount <= 0) return 0;
return Math.ceil(amount / 100) * 100;
}
function getNumericYearRate(yearRates, grade) {
return Number(yearRates?.[grade] || 0);
}
function hasPositiveYearRates(yearRates, grades) {
return grades.every((grade) => getNumericYearRate(yearRates, grade) > 0);
}
function getDerivedLaborRate(yearRates, grade) {
if (grade === "수석") {
const directorRate = getNumericYearRate(yearRates, "이사");
return directorRate > 0 ? directorRate : 0;
}
if (grade === "책임") {
if (!hasPositiveYearRates(yearRates, ["부장", "차장"])) return 0;
return ceilToHundreds(((getNumericYearRate(yearRates, "부장") * 5) + (getNumericYearRate(yearRates, "차장") * 2)) / 7);
}
if (grade === "선임") {
if (!hasPositiveYearRates(yearRates, ["차장", "과장", "대리"])) return 0;
return ceilToHundreds(((getNumericYearRate(yearRates, "차장") * 2) + (getNumericYearRate(yearRates, "과장") * 3) + getNumericYearRate(yearRates, "대리")) / 6);
}
if (grade === "연구원") {
if (!hasPositiveYearRates(yearRates, ["대리", "사원"])) return 0;
return ceilToHundreds(((getNumericYearRate(yearRates, "대리") * 2) + (getNumericYearRate(yearRates, "사원") * 3)) / 5);
}
return 0;
}
function normalizeLaborRateCategory(value) {
const text = String(value || "").trim();
if (text.includes("감리")) return "감리";
if (text.includes("지원")) return "지원";
if (text.includes("설계")) return "설계";
return "설계";
}
function getLaborRateLookupCategory(value) {
const category = normalizeLaborRateCategory(value);
return category === "지원" ? "감리" : category;
}
function getCurrentLaborRateCategory() {
return normalizeLaborRateCategory(projectTypeInput?.value || laborRateCategorySelect?.value || "설계");
}
function getLaborRateEditCategory() {
return getLaborRateLookupCategory(laborRateCategorySelect?.value || getCurrentLaborRateCategory());
}
function getResolvedLaborRatesForYear(year = "", category = "") {
const yearKey = String(year || getDefaultLaborRateYear());
const storedYearRates = currentLaborRates?.[yearKey] || {};
const lookupCategory = getLaborRateLookupCategory(category || getCurrentLaborRateCategory());
const categoryRates = storedYearRates?.[lookupCategory] || storedYearRates?.설계 || {};
const designRates = storedYearRates?.설계 || {};
const resolvedYearRates = {};
laborGradeOptions.forEach((grade) => {
resolvedYearRates[grade] = getNumericYearRate(categoryRates, grade);
});
derivedLaborRateGrades.forEach((grade) => {
const derived = getDerivedLaborRate(designRates, grade);
if (derived > 0) {
resolvedYearRates[grade] = derived;
}
});
return resolvedYearRates;
}
function getLaborRate(grade, year = "", category = "") {
const yearKey = String(year || getDefaultLaborRateYear());
const yearRates = getResolvedLaborRatesForYear(yearKey, category);
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 = "", category = "") {
return getLaborRate(grade, year, category) * 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 expectedAsRate = roundPercentageRate(expectedAsRateInput?.value);
const expectedSgaRate = roundPercentageRate(expectedSgaRateInput?.value);
const expectedAsCost = contractAmount * expectedAsRate / 100;
const expectedSgaBudget = contractAmount * expectedSgaRate / 100;
const expectedAsCostField = document.getElementById("expected_as_cost_display");
const expectedSgaBudgetField = document.getElementById("expected_sga_budget_display");
if (expectedAsCostField && document.activeElement !== expectedAsCostField) {
expectedAsCostField.value = expectedAsCost ? formatAmountInputValue(expectedAsCost) : "";
}
if (expectedSgaBudgetField && document.activeElement !== expectedSgaBudgetField) {
expectedSgaBudgetField.value = 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);
}
refreshProjectDerivedCaches();
}
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 selectedCategory = laborRateCategorySelect?.value || getCurrentLaborRateCategory();
const editCategory = getLaborRateLookupCategory(selectedCategory);
const resolvedYearRates = getResolvedLaborRatesForYear(selectedRateYear, selectedCategory);
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(resolvedYearRates?.[grade] || ""))}" ${derivedLaborRateGrades.includes(grade) ? 'readonly title="설계 단가 기준 자동 산출"' : ""}></td>
</tr>
`).join("");
bindCollectionFieldBehaviors(laborRateRows);
laborRateRows.querySelectorAll(".labor-rate-input").forEach((input) => {
if (input.hasAttribute("readonly")) return;
input.addEventListener("input", () => {
const grade = input.dataset.grade || "";
const targetYear = String(laborRateYearSelect?.value || getDefaultLaborRateYear());
const nextYearRates = { 설계: {}, 감리: {}, 지원: {}, ...(currentLaborRates?.[targetYear] || {}) };
const nextCategoryRates = { ...(nextYearRates?.[editCategory] || {}) };
nextCategoryRates[grade] = parseAmount(input.value);
nextYearRates[editCategory] = nextCategoryRates;
if (editCategory === "감리") {
nextYearRates.지원 = { ...nextCategoryRates };
}
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;
}
}
if (laborRateCategorySelect) {
laborRateCategorySelect.value = getCurrentLaborRateCategory();
}
renderLaborRateRows();
laborRateModal?.classList.add("open");
}
function saveLaborRateYear() {
const targetYear = String(laborRateYearSelect?.value || getDefaultLaborRateYear());
const selectedCategory = laborRateCategorySelect?.value || getCurrentLaborRateCategory();
const editCategory = getLaborRateLookupCategory(selectedCategory);
const nextYearRates = { 설계: {}, 감리: {}, 지원: {}, ...(currentLaborRates?.[targetYear] || {}) };
const nextCategoryRates = { ...(nextYearRates?.[editCategory] || {}) };
laborRateRows?.querySelectorAll(".labor-rate-input").forEach((input) => {
const grade = String(input.dataset.grade || "").trim();
if (!grade || input.hasAttribute("readonly")) return;
nextCategoryRates[grade] = parseAmount(input.value);
});
nextYearRates[editCategory] = nextCategoryRates;
if (editCategory === "감리") {
nextYearRates.지원 = { ...nextCategoryRates };
}
currentLaborRates[targetYear] = nextYearRates;
writeLaborRates(currentLaborRates);
recalculateExecLaborAmounts();
recalculateActualLaborAmounts();
renderLaborRateRows();
window.alert(`${targetYear}${selectedCategory} 기준인건비를 저장했습니다.`);
}
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 || "";
}
setRoundedPercentageSelectValue(expectedAsRateInput, normalized.expected_as_rate);
setRoundedPercentageSelectValue(expectedSgaRateInput, 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();
});
laborRateCategorySelect?.addEventListener("change", () => {
renderLaborRateRows();
});
projectTypeInput?.addEventListener("input", () => {
recalculateExecLaborAmounts();
recalculateActualLaborAmounts();
renderLaborRateRows();
});
laborRateModal?.addEventListener("click", (event) => {
if (event.target === laborRateModal) {
closeLaborRateModal();
}
});
contractAmountInput?.addEventListener("input", updateComputedFields);
expectedAsRateInput?.addEventListener("change", () => {
normalizePercentageSelectValue(expectedAsRateInput);
updateComputedFields();
});
expectedSgaRateInput?.addEventListener("change", () => {
normalizePercentageSelectValue(expectedSgaRateInput);
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;
}
const projectStatusDetailRequests = new Map();
async function ensureProjectStatusDetail(code) {
const normalizedCode = String(code || "").trim();
if (!normalizedCode) return null;
const current = projectStatusMap[normalizedCode] || null;
if (current && current._detail_loaded) return current;
if (!projectStatusDetailRequests.has(normalizedCode)) {
projectStatusDetailRequests.set(
normalizedCode,
fetch(`/projects/status-detail?code=${encodeURIComponent(normalizedCode)}`, {
credentials: "same-origin",
cache: "no-store",
headers: { "Accept": "application/json" },
})
.then(async (response) => {
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload?.ok === false || payload?.error) {
throw new Error(payload?.error || `HTTP ${response.status}`);
}
const item = payload?.item || null;
if (item?.support_dept_code) {
item._detail_loaded = true;
projectStatusMap[item.support_dept_code] = item;
const index = projectStatusRows.findIndex((row) => row.support_dept_code === item.support_dept_code);
if (index >= 0) {
projectStatusRows[index] = item;
} else {
projectStatusRows.push(item);
}
}
return item;
})
.finally(() => {
projectStatusDetailRequests.delete(normalizedCode);
}),
);
}
return projectStatusDetailRequests.get(normalizedCode);
}
function getSharedInputClusterCodes(item) {
if (!Array.isArray(item?.shared_input_cluster_codes)) return [];
return item.shared_input_cluster_codes
.map((value) => String(value || "").trim())
.filter(Boolean);
}
function shouldIncludeSharedInputs(item, includedCodeSet) {
const ownerCode = String(item?.shared_input_owner_code || "").trim();
if (!ownerCode || String(item?.support_dept_code || "").trim() !== ownerCode) {
return true;
}
const clusterCodes = getSharedInputClusterCodes(item);
if (!clusterCodes.length) return true;
return clusterCodes.every((code) => includedCodeSet.has(code));
}
function stripSharedClusterInputs(item) {
if (!item) return item;
return {
...item,
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,
expected_as_cost: 0,
expected_sga_budget: 0,
item_investment: 0,
task_plan_entries: [],
exec_budget_entries: [],
actual_input_entries: [],
planned_task_total: 0,
exec_budget_total: 0,
};
}
function normalizeAnalysisItemForSelection(item, includedCodeSet) {
if (!item) return item;
if (shouldIncludeSharedInputs(item, includedCodeSet)) {
return item;
}
return stripSharedClusterInputs(item);
}
function getSuppressedSharedOwnerCodes(includedCodeSet) {
const normalizedIncludedCodeSet = new Set(
[...(includedCodeSet || [])]
.map((value) => String(value || "").trim())
.filter(Boolean),
);
const suppressedOwnerCodes = new Set();
normalizedIncludedCodeSet.forEach((code) => {
const sourceItem = getAnalysisItem(code) || projectStatusMap[code] || null;
if (!sourceItem) return;
const ownerCode = String(sourceItem?.shared_input_owner_code || "").trim();
const clusterCodes = getSharedInputClusterCodes(sourceItem);
if (!ownerCode || !clusterCodes.length) return;
if (!clusterCodes.every((clusterCode) => normalizedIncludedCodeSet.has(clusterCode))) {
suppressedOwnerCodes.add(ownerCode);
}
});
return suppressedOwnerCodes;
}
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 buildProjectBreakdownRequestCodes(baseCode = "") {
const normalizedBaseCode = String(baseCode || selectedCode || "").trim();
if (!normalizedBaseCode) {
return [];
}
const codeSet = new Set([normalizedBaseCode]);
getCurrentRelatedCodes(normalizedBaseCode).forEach((code) => {
const normalizedCode = String(code || "").trim();
if (normalizedCode) {
codeSet.add(normalizedCode);
}
});
return [...codeSet];
}
function hasProjectBreakdownDataForCodes(codes = []) {
const requestedCodes = Array.isArray(codes)
? codes.map((code) => String(code || "").trim()).filter(Boolean)
: [];
if (!requestedCodes.length) {
return projectAccountBreakdownsLoaded;
}
return requestedCodes.every((code) => projectAccountBreakdownsLoadedCodes.has(code));
}
async function loadProjectAccountBreakdowns(force = false, codes = []) {
const requestedCodes = Array.isArray(codes)
? codes.map((code) => String(code || "").trim()).filter(Boolean)
: [];
const needsFullLoad = !requestedCodes.length;
if (!force) {
if (needsFullLoad && projectAccountBreakdownsLoaded) {
return projectAccountBreakdowns;
}
if (!needsFullLoad && requestedCodes.every((code) => projectAccountBreakdownsLoadedCodes.has(code))) {
return projectAccountBreakdowns;
}
if (projectAccountBreakdownsPromise) {
await projectAccountBreakdownsPromise;
if (needsFullLoad && projectAccountBreakdownsLoaded) {
return projectAccountBreakdowns;
}
if (!needsFullLoad && requestedCodes.every((code) => projectAccountBreakdownsLoadedCodes.has(code))) {
return projectAccountBreakdowns;
}
}
}
const codesToLoad = force || needsFullLoad
? requestedCodes
: requestedCodes.filter((code) => !projectAccountBreakdownsLoadedCodes.has(code));
const queryParams = new URLSearchParams();
if (selectedYear) {
queryParams.set("year", String(selectedYear));
}
if (codesToLoad.length) {
queryParams.set("codes", codesToLoad.join(","));
}
const query = queryParams.toString() ? `?${queryParams.toString()}` : "";
projectAccountBreakdownsPromise = fetch(`/projects/account-breakdowns${query}`, {
method: "GET",
credentials: "same-origin",
headers: {
"Accept": "application/json",
},
}).then(async (response) => {
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload?.error) {
throw new Error(payload?.error || `HTTP ${response.status}`);
}
const nextPayload = payload || {};
if (needsFullLoad) {
projectAccountBreakdowns = nextPayload;
projectAccountBreakdownsLoaded = true;
projectAccountBreakdownsLoadedCodes = new Set(Object.keys(nextPayload || {}));
} else {
projectAccountBreakdowns = {
...projectAccountBreakdowns,
...nextPayload,
};
codesToLoad.forEach((code) => {
projectAccountBreakdownsLoadedCodes.add(code);
});
}
return projectAccountBreakdowns;
}).catch((error) => {
console.error("프로젝트 계정 분해 조회 에러", error);
if (needsFullLoad) {
projectAccountBreakdowns = {};
projectAccountBreakdownsLoaded = false;
projectAccountBreakdownsLoadedCodes = new Set();
}
throw error;
}).finally(() => {
projectAccountBreakdownsPromise = null;
});
return projectAccountBreakdownsPromise;
}
async function fetchProjectJson(url, options = {}) {
const response = await fetch(url, {
...options,
cache: "no-store",
credentials: "same-origin",
headers: {
"Accept": "application/json",
"Cache-Control": "no-cache",
"X-Requested-With": "XMLHttpRequest",
...(options.headers || {}),
},
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload?.error || payload?.ok === false) {
throw new Error(payload?.error || `HTTP ${response.status}`);
}
return payload;
}
function describeProjectJob(job) {
if (!job) return { text: "최근 작업 없음", state: "" };
const status = String(job.status || "");
const range = job.start_year ? String(job.start_year) : "전체";
const message = job.error_message || job.message || "";
if (status === "queued") return { text: `${range} 대기 중`, state: status };
if (status === "running") return { text: `${range} 계산 중... ${message}`.trim(), state: status };
if (status === "done") return { text: `${range} 계산 완료`, state: status };
if (status === "failed") return { text: `${range} 실패: ${message || "오류"}`, state: status };
return { text: `${range} ${message || status || "작업 상태 확인 전"}`.trim(), state: status };
}
function renderProjectJob(job) {
const jobStatus = document.getElementById("projectJobStatus");
const rebuildButton = document.getElementById("projectRebuildCacheBtn");
if (!jobStatus) return;
const view = describeProjectJob(job);
jobStatus.textContent = view.text;
jobStatus.dataset.state = view.state || "";
if (rebuildButton) {
rebuildButton.disabled = view.state === "queued" || view.state === "running";
}
}
async function loadLatestProjectJob() {
const params = new URLSearchParams({
page_key: "projects",
job_type: "projects_bootstrap",
});
const payload = await fetchProjectJson(`/api/system-jobs/latest?${params.toString()}`);
renderProjectJob(payload.job || null);
return payload.job || null;
}
function pollProjectJob(jobId) {
if (!jobId) return;
if (projectJobPollTimer) window.clearInterval(projectJobPollTimer);
projectJobPollTimer = window.setInterval(async () => {
try {
const payload = await fetchProjectJson(`/api/system-jobs/${encodeURIComponent(jobId)}`);
const job = payload.job || null;
renderProjectJob(job);
if (!job || ["done", "failed", "cancelled"].includes(String(job.status || ""))) {
window.clearInterval(projectJobPollTimer);
projectJobPollTimer = null;
if (job && job.status === "done") {
window.setTimeout(() => window.location.reload(), 600);
}
}
} catch (_error) {
// Keep the current project list usable while transient locks clear.
}
}, 2500);
}
async function requestProjectCacheRebuild() {
const rebuildButton = document.getElementById("projectRebuildCacheBtn");
if (rebuildButton) rebuildButton.disabled = true;
const payload = await fetchProjectJson("/projects/api/rebuild-cache", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ year: selectedYear || null }),
});
renderProjectJob(payload.job || null);
pollProjectJob(payload.job?.id);
}
async function loadProjectBootstrapData(force = false) {
if (!force && projectStatusRows.length && projectCostRows.length) {
return;
}
projectBootstrapLoadError = "";
const query = selectedYear ? `?year=${encodeURIComponent(String(selectedYear))}` : "";
const response = await fetch(`/projects/bootstrap-data${query}`, {
method: "GET",
credentials: "same-origin",
headers: {
"Accept": "application/json",
},
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload?.error) {
throw new Error(payload?.error || `HTTP ${response.status}`);
}
revenueMix = Array.isArray(payload?.revenue_mix) ? payload.revenue_mix : [];
projectCostRows = Array.isArray(payload?.project_cost_by_year) ? payload.project_cost_by_year : [];
projectStatusRows = Array.isArray(payload?.project_status_rows) ? payload.project_status_rows : [];
refreshProjectDerivedCaches();
}
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 getAggregateContractAmount(items) {
return (items || []).reduce((sum, item) => {
return sum + Math.max(0, Number(item?.contract_amount || 0));
}, 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 = [], options = {}) {
if (!baseItem) return null;
const items = [baseItem, ...relatedItems].filter(Boolean);
const suppressedSharedOwnerCodes = new Set(
[...(options?.suppressedSharedOwnerCodes || [])]
.map((value) => String(value || "").trim())
.filter(Boolean),
);
const yearValues = items
.flatMap((item) => [Number(item.min_year || item.year || 0), Number(item.max_year || item.year || 0)])
.filter(Boolean);
const minYear = yearValues.length ? Math.min(...yearValues) : 0;
const maxYear = yearValues.length ? Math.max(...yearValues) : 0;
const aggregated = {
...baseItem,
_aggregateCodes: items.map((item) => item.support_dept_code),
_relatedItems: relatedItems,
_suppressedSharedOwnerCodes: [...suppressedSharedOwnerCodes],
min_year: minYear,
max_year: maxYear,
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",
"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.contract_amount = getAggregateContractAmount(items);
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);
aggregated.year_range = formatYearRange(aggregated);
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 formatCollectionComparisonDiff(planned, actual) {
const diff = Number(actual || 0) - Number(planned || 0);
return `<span class="${differenceClass(diff)}">${formatSignedAmount(diff)}</span>`;
}
function buildComparisonDetails(item) {
const suppressedSharedOwnerCodes = new Set(
Array.isArray(item?._suppressedSharedOwnerCodes)
? item._suppressedSharedOwnerCodes.map((value) => String(value || "").trim()).filter(Boolean)
: [],
);
const aggregateCodes = (
Array.isArray(item?._aggregateCodes) && item._aggregateCodes.length
? item._aggregateCodes
: [item.support_dept_code]
).filter((code) => !suppressedSharedOwnerCodes.has(String(code || "").trim()));
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" class="related-project-remove" data-remove-related="${escapeHtml(item.support_dept_code)}" title="연관 프로젝트 제거" aria-label="연관 프로젝트 제거">
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M4.5 4.5l7 7"></path>
<path d="M11.5 4.5l-7 7"></path>
</svg>
</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 class="${differenceClass(operatingBalance)}">${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>${key === "collection" ? formatCollectionComparisonDiff(planned, actual) : 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();
let aggregatedAllProjectCostRows = [];
let aggregatedAllProjectCostMap = new Map();
function refreshProjectDerivedCaches() {
projectStatusMap = Object.fromEntries(projectStatusRows.map((item) => [item.support_dept_code, item]));
projectCostMap = Object.fromEntries(projectCostRows.map((item) => [item.support_dept_code, item]));
aggregatedProjectCostDatasetCache.clear();
aggregatedAllProjectCostRows = mergeProjectExplorerRows(projectCostRows, projectStatusRows);
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);
return merged;
}
function syncGlobalRelatedProjectSelections() {
window.__projectRelatedSelections = relatedProjectSelections;
}
function replaceRelatedProjectSelections(source) {
if (!source || typeof source !== "object") return;
relatedProjectSelections.clear();
Object.entries(source).forEach(([code, values]) => {
const normalizedCode = String(code || "").trim();
if (!normalizedCode || !Array.isArray(values)) return;
const normalizedValues = values
.map((value) => String(value || "").trim())
.filter(Boolean);
if (normalizedValues.length) {
relatedProjectSelections.set(normalizedCode, new Set(normalizedValues));
}
});
syncGlobalRelatedProjectSelections();
}
function persistRelatedProjectSelections() {
return;
}
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);
}
replaceRelatedProjectSelections(result.related_project_links);
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에 저장하지 못했습니다.");
return;
}
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에 저장하지 못했습니다.");
return;
}
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;
}
if (item._detail_loaded === false) {
ensureProjectStatusDetail(item.support_dept_code)
.then((detailItem) => {
if (!detailItem) return;
if (String(currentSelectedProjectCode || "") !== String(item.support_dept_code || "")) return;
renderAnalysis(getAnalysisItem(item.support_dept_code));
})
.catch((error) => {
console.warn("프로젝트 상세 데이터 조회 에러", error);
});
}
const requestedBreakdownCodes = buildProjectBreakdownRequestCodes(item.support_dept_code);
if (!hasProjectBreakdownDataForCodes(requestedBreakdownCodes)) {
relatedBarBox.innerHTML = "";
heroBox.innerHTML = renderAnalysisHero(item);
metricsBox.innerHTML = renderAnalysisMetrics(item);
comparisonBox.innerHTML = `
<div class="detail-block">
<p class="stacked-note">프로젝트 세부 집계 데이터를 불러오는 중입니다...</p>
</div>
`;
notesBox.innerHTML = `
<div class="detail-block">
<p class="stacked-note">프로젝트 세부 집계 데이터를 준비한 뒤 표시합니다.</p>
</div>
`;
loadProjectAccountBreakdowns(false, requestedBreakdownCodes).then(() => {
const latestItem = getAnalysisItem(item.support_dept_code);
if (latestItem && String(currentSelectedProjectCode || "") === String(item.support_dept_code || "")) {
renderAnalysis(latestItem);
}
}).catch(() => {
comparisonBox.innerHTML = `
<div class="detail-block">
<p class="stacked-note">프로젝트 세부 집계 데이터를 불러오지 못했습니다. 잠시 후 다시 시도해 주세요.</p>
</div>
`;
});
scheduleStandaloneUncontractedDashboardRender();
return;
}
const baseCode = String(item.support_dept_code || "").trim();
const allRelatedItems = getRelatedItems(item.support_dept_code, { includeInactive: true });
const relatedItems = getRelatedItems(item.support_dept_code);
const includedCodeSet = new Set([
baseCode,
...relatedItems.map((relatedItem) => String(relatedItem?.support_dept_code || "").trim()).filter(Boolean),
]);
const suppressedSharedOwnerCodes = getSuppressedSharedOwnerCodes(includedCodeSet);
const normalizedBaseItem = normalizeAnalysisItemForSelection(item, includedCodeSet);
const normalizedRelatedItems = relatedItems.map((relatedItem) => {
return normalizeAnalysisItemForSelection(relatedItem, includedCodeSet);
});
const aggregateItem = aggregateAnalysisItem(normalizedBaseItem, normalizedRelatedItems, {
suppressedSharedOwnerCodes,
});
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", async (event) => {
event.preventDefault();
event.stopPropagation();
await 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);
}
},
};
let projectBootstrapEnsurePromise = null;
async function ensureProjectBootstrapData() {
if (projectStatusRows.length || projectCostRows.length) {
return true;
}
if (!projectBootstrapEnsurePromise) {
projectBootstrapEnsurePromise = loadProjectBootstrapData(true)
.then(() => true)
.catch((error) => {
console.error("프로젝트 부트스트랩 데이터 재조회 에러", error);
projectBootstrapLoadError = error?.message || "프로젝트 데이터를 불러오지 못했습니다.";
return false;
})
.finally(() => {
projectBootstrapEnsurePromise = null;
});
}
return projectBootstrapEnsurePromise;
}
function refresh(options = {}) {
const { renderDetail = false } = options;
if (!projectStatusRows.length && !projectCostRows.length) {
dropdownBox?.classList.toggle("open", Boolean((input?.value || "").trim()));
listBox.innerHTML = "";
emptyBox.textContent = projectBootstrapLoadError || "프로젝트 데이터를 불러오는 중입니다.";
ensureProjectBootstrapData().then((loaded) => {
if (loaded) {
refresh(options);
return;
}
listBox.innerHTML = "";
emptyBox.textContent = projectBootstrapLoadError || "프로젝트 데이터를 불러오지 못했습니다. 캐시 재계산 후 다시 시도해 주세요.";
});
return;
}
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", async (event) => {
const itemEl = event.target.closest(".explorer-item");
if (!itemEl) return;
await ensureProjectBootstrapData();
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", async (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;
await ensureProjectBootstrapData();
selectedCode = quickLinkButton.dataset.openQuickLink || "";
currentSelectedProjectCode = selectedCode;
const quickLinkItem = getAnalysisItem(selectedCode);
if (quickLinkItem && input) {
input.value = quickLinkItem.support_dept_name || "";
}
setAnalysisVisibility(true);
if (quickLinkItem) {
renderAnalysis(quickLinkItem);
} else {
relatedBarBox.innerHTML = "";
heroBox.innerHTML = "";
metricsBox.innerHTML = "";
comparisonBox.innerHTML = `
<div class="detail-block">
<p class="stacked-note">바로가기 프로젝트를 현재 데이터에서 찾지 못했습니다. 프로젝트 정보 캐시를 재계산한 뒤 다시 확인해 주세요.</p>
</div>
`;
notesBox.innerHTML = "";
}
});
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);
});
document.getElementById("projectRebuildCacheBtn")?.addEventListener("click", async () => {
const confirmed = window.confirm("현재 프로젝트 정보 조건의 데이터를 서버에서 다시 계산할까요? 계산 중에도 다른 화면을 사용할 수 있습니다.");
if (!confirmed) return;
try {
await requestProjectCacheRebuild();
} catch (error) {
const jobStatus = document.getElementById("projectJobStatus");
if (jobStatus) {
jobStatus.textContent = error.message || "작업 등록 실패";
jobStatus.dataset.state = "failed";
}
const rebuildButton = document.getElementById("projectRebuildCacheBtn");
if (rebuildButton) rebuildButton.disabled = false;
}
});
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();
try {
await loadProjectBootstrapData();
renderProjectQuickLinks();
} catch (error) {
console.error("프로젝트 부트스트랩 데이터 조회 에러", error);
}
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();
loadLatestProjectJob()
.then((job) => {
if (job && ["queued", "running"].includes(String(job.status || ""))) {
pollProjectJob(job.id);
}
})
.catch(() => {
const jobStatus = document.getElementById("projectJobStatus");
if (jobStatus) {
jobStatus.textContent = "작업 상태 조회 실패";
jobStatus.dataset.state = "failed";
}
});
})();
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 %}