6780 lines
284 KiB
HTML
6780 lines
284 KiB
HTML
{% extends "base.html" %}
|
||
|
||
{% block title %}프로젝트 정보{% endblock %}
|
||
|
||
{% block head_extra %}
|
||
<style>
|
||
.page-hero {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
gap: 18px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.hero-copy {
|
||
display: grid;
|
||
gap: 8px;
|
||
}
|
||
|
||
.hero-copy p {
|
||
color: var(--muted);
|
||
line-height: 1.7;
|
||
max-width: 920px;
|
||
}
|
||
|
||
.top-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.overview-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.overview-title-row {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.overview-year-select {
|
||
min-width: 156px;
|
||
}
|
||
|
||
.metric-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr;
|
||
gap: 8px;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.overview-row {
|
||
display: grid;
|
||
grid-template-columns: minmax(230px, 0.23fr) minmax(0, 0.77fr);
|
||
gap: 16px;
|
||
align-items: stretch;
|
||
}
|
||
|
||
.metric-note-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 14px;
|
||
margin-top: 14px;
|
||
}
|
||
|
||
.metric-note {
|
||
background: rgba(255, 255, 255, 0.92);
|
||
border: 1px solid var(--line);
|
||
border-radius: 16px;
|
||
padding: 14px 16px;
|
||
color: var(--muted);
|
||
line-height: 1.7;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.metric-note strong {
|
||
color: var(--ink);
|
||
}
|
||
|
||
.chart-stage {
|
||
background:
|
||
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(246, 247, 249, 0.98)),
|
||
radial-gradient(circle at top left, rgba(17, 17, 17, 0.045), transparent 34%);
|
||
border: 1px solid var(--line);
|
||
border-radius: 16px;
|
||
padding: 16px;
|
||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9);
|
||
}
|
||
|
||
.chart-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
gap: 14px;
|
||
margin-bottom: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.chart-header p {
|
||
color: var(--muted);
|
||
line-height: 1.6;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.chart-legend {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px 14px;
|
||
align-items: center;
|
||
}
|
||
|
||
.legend-item {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
color: #363b44;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
background: rgba(255, 255, 255, 0.9);
|
||
border: 1px solid var(--line);
|
||
border-radius: 10px;
|
||
padding: 6px 10px;
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.legend-swatch {
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 999px;
|
||
display: inline-block;
|
||
}
|
||
|
||
.chart-svg {
|
||
width: 100%;
|
||
height: auto;
|
||
aspect-ratio: 1120 / 330;
|
||
min-height: 290px;
|
||
display: block;
|
||
}
|
||
|
||
.chart-note {
|
||
margin-top: 10px;
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
line-height: 1.7;
|
||
}
|
||
|
||
.search-layout {
|
||
display: grid;
|
||
gap: 16px;
|
||
}
|
||
|
||
.analysis-shell {
|
||
display: grid;
|
||
gap: 16px;
|
||
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-field {
|
||
background: rgba(255, 255, 255, 0.95);
|
||
border: 1px solid var(--line);
|
||
border-radius: 16px;
|
||
padding: 14px;
|
||
position: relative;
|
||
overflow: visible;
|
||
z-index: 2;
|
||
}
|
||
|
||
.toolbar-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.toolbar-title-group {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
min-width: 0;
|
||
flex: 1;
|
||
}
|
||
|
||
.toolbar-field h3 {
|
||
font-size: 16px;
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.toolbar-head-actions {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.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;
|
||
padding: 8px 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.3;
|
||
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;
|
||
}
|
||
|
||
.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) 170px 210px;
|
||
gap: 12px;
|
||
align-items: end;
|
||
}
|
||
|
||
.project-search-field {
|
||
position: relative;
|
||
display: grid;
|
||
gap: 8px;
|
||
z-index: 180;
|
||
}
|
||
|
||
.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: 1px solid #dfe6ee;
|
||
border-radius: 14px;
|
||
background: rgba(255,255,255,0.94);
|
||
padding: 14px 16px;
|
||
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: rgba(255,255,255,0.98);
|
||
border: 1px solid var(--line);
|
||
border-radius: 18px;
|
||
padding: 18px;
|
||
display: grid;
|
||
gap: 14px;
|
||
}
|
||
|
||
.analysis-panel h4 {
|
||
font-size: 16px;
|
||
}
|
||
|
||
.related-trigger-button {
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.analysis-related-bar {
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
|
||
.analysis-related-bar:empty {
|
||
display: none;
|
||
}
|
||
|
||
.related-tag-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
|
||
.related-tag-label {
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.related-project-tag {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
border: 1px solid #d7e2eb;
|
||
background: #f7fbfe;
|
||
color: #29485f;
|
||
border-radius: 999px;
|
||
padding: 5px 10px;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.related-project-tag button {
|
||
width: 18px;
|
||
height: 18px;
|
||
min-width: 18px;
|
||
padding: 0;
|
||
border-radius: 999px;
|
||
border: 0;
|
||
background: rgba(41, 72, 95, 0.08);
|
||
color: #29485f;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.related-project-tag button:hover {
|
||
transform: none;
|
||
background: rgba(41, 72, 95, 0.14);
|
||
}
|
||
|
||
.related-modal-card {
|
||
width: min(680px, 100%);
|
||
max-height: min(70vh, 760px);
|
||
overflow: auto;
|
||
background: #ffffff;
|
||
border-radius: 16px;
|
||
border: 1px solid var(--line);
|
||
box-shadow: 0 24px 64px rgba(21, 24, 29, 0.18);
|
||
padding: 14px;
|
||
display: grid;
|
||
gap: 12px;
|
||
}
|
||
|
||
.related-modal-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.related-modal-title {
|
||
font-size: 16px;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.related-modal-body {
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
|
||
.related-search-results {
|
||
display: grid;
|
||
gap: 0;
|
||
border: 1px solid #e5ebf0;
|
||
border-radius: 14px;
|
||
overflow: hidden;
|
||
background: #fff;
|
||
}
|
||
|
||
.related-search-item {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 11px 14px;
|
||
border-top: 1px solid #eef2f5;
|
||
}
|
||
|
||
.related-search-item:first-child {
|
||
border-top: 0;
|
||
}
|
||
|
||
.related-search-copy {
|
||
display: grid;
|
||
gap: 4px;
|
||
text-align: left;
|
||
min-width: 0;
|
||
}
|
||
|
||
.related-search-copy strong {
|
||
font-size: 13px;
|
||
line-height: 1.45;
|
||
}
|
||
|
||
.related-search-copy span {
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
line-height: 1.45;
|
||
}
|
||
|
||
.comparison-main-row {
|
||
cursor: pointer;
|
||
}
|
||
|
||
.comparison-main-row td:first-child {
|
||
font-weight: 700;
|
||
}
|
||
|
||
.comparison-toggle {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.comparison-chevron {
|
||
width: 16px;
|
||
height: 16px;
|
||
display: inline-block;
|
||
transition: transform 0.16s ease;
|
||
}
|
||
|
||
.comparison-main-row.is-open .comparison-chevron {
|
||
transform: rotate(90deg);
|
||
}
|
||
|
||
.comparison-detail-row {
|
||
display: none;
|
||
background: #fbfcfd;
|
||
}
|
||
|
||
.comparison-detail-row.is-open {
|
||
display: table-row;
|
||
}
|
||
|
||
.comparison-detail-cell {
|
||
padding: 0;
|
||
}
|
||
|
||
.comparison-detail-box {
|
||
padding: 12px 14px 14px;
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
|
||
.comparison-detail-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 12px;
|
||
}
|
||
|
||
.comparison-detail-group {
|
||
border: 1px solid #e5ebf0;
|
||
border-radius: 12px;
|
||
background: #fff;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.comparison-detail-group h5 {
|
||
font-size: 12px;
|
||
color: #547085;
|
||
background: #f5f8fb;
|
||
padding: 8px 10px;
|
||
}
|
||
|
||
.comparison-detail-list {
|
||
display: grid;
|
||
gap: 0;
|
||
}
|
||
|
||
.comparison-detail-split {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 10px;
|
||
}
|
||
|
||
.comparison-detail-split .comparison-detail-group {
|
||
min-width: 0;
|
||
}
|
||
|
||
.comparison-detail-item {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) auto;
|
||
gap: 10px;
|
||
padding: 8px 10px;
|
||
border-top: 1px solid #eef2f5;
|
||
font-size: 12px;
|
||
text-align: left;
|
||
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-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;
|
||
}
|
||
|
||
.analysis-kpis {
|
||
display: grid;
|
||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||
gap: 10px;
|
||
align-items: stretch;
|
||
}
|
||
|
||
.analysis-kpi {
|
||
border: 1px solid #dfe6ee;
|
||
border-radius: 14px;
|
||
padding: 12px 10px;
|
||
display: grid;
|
||
gap: 4px;
|
||
background: #fbfcfd;
|
||
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;
|
||
}
|
||
|
||
.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: 20px;
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
|
||
.analysis-root-panel {
|
||
position: relative;
|
||
z-index: 40;
|
||
overflow: visible;
|
||
}
|
||
|
||
.project-uncontracted-panel {
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
|
||
.uncontracted-toolbar {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, 180px) auto;
|
||
gap: 12px;
|
||
align-items: end;
|
||
}
|
||
|
||
.uncontracted-summary-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||
gap: 10px;
|
||
}
|
||
|
||
.uncontracted-stat {
|
||
border: 1px solid #dfe6ee;
|
||
border-radius: 14px;
|
||
padding: 12px;
|
||
background: #fbfcfd;
|
||
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: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease;
|
||
}
|
||
|
||
.uncontracted-stat.is-clickable:hover,
|
||
.uncontracted-stat.is-clickable.is-active {
|
||
border-color: #c8d7e5;
|
||
box-shadow: 0 12px 22px rgba(22, 33, 50, 0.08);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.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%;
|
||
}
|
||
|
||
.compact-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
table-layout: fixed;
|
||
}
|
||
|
||
.compact-table th,
|
||
.compact-table td {
|
||
padding: 9px 10px;
|
||
border-bottom: 1px solid #e7edf2;
|
||
font-size: 12px;
|
||
text-align: left;
|
||
vertical-align: middle;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.compact-table th {
|
||
font-size: 11px;
|
||
color: #547085;
|
||
background: #f5f8fb;
|
||
}
|
||
|
||
.compact-table td:last-child,
|
||
.compact-table th:last-child {
|
||
text-align: right;
|
||
}
|
||
|
||
.compact-table .classification-cell,
|
||
.compact-table th.classification-cell {
|
||
text-align: center;
|
||
width: 180px;
|
||
}
|
||
|
||
.detail-classification-select {
|
||
width: 100%;
|
||
min-width: 0;
|
||
padding: 8px 10px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.compact-table .interactive-row {
|
||
cursor: pointer;
|
||
}
|
||
|
||
.compact-table .interactive-row:hover {
|
||
background: #f8fbfe;
|
||
}
|
||
|
||
.compact-table .interactive-row.is-active {
|
||
background: #eef6fb;
|
||
}
|
||
|
||
.search-bar {
|
||
display: grid;
|
||
grid-template-columns: minmax(280px, 1fr) auto;
|
||
gap: 12px;
|
||
align-items: end;
|
||
}
|
||
|
||
.search-helper {
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
line-height: 1.7;
|
||
}
|
||
|
||
.search-helper:empty,
|
||
.empty-state:empty {
|
||
display: none;
|
||
}
|
||
|
||
.result-list {
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
|
||
.result-item {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 12px;
|
||
background: rgba(255, 255, 255, 0.94);
|
||
border: 1px solid var(--line);
|
||
border-radius: 14px;
|
||
padding: 14px 16px;
|
||
}
|
||
|
||
.result-title {
|
||
display: grid;
|
||
gap: 4px;
|
||
}
|
||
|
||
.result-title strong {
|
||
font-size: 15px;
|
||
}
|
||
|
||
.result-title span {
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.compare-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 16px;
|
||
}
|
||
|
||
.compare-card {
|
||
position: relative;
|
||
background:
|
||
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(247, 248, 250, 0.96)),
|
||
radial-gradient(circle at top right, rgba(17, 17, 17, 0.045), transparent 36%);
|
||
border: 1px solid rgba(216, 222, 229, 0.95);
|
||
border-radius: 16px;
|
||
padding: 16px;
|
||
box-shadow: 0 12px 24px rgba(21, 24, 29, 0.05);
|
||
}
|
||
|
||
.compare-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
gap: 12px;
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.compare-head h3 {
|
||
font-size: 18px;
|
||
line-height: 1.45;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.compare-head p {
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.remove-button {
|
||
min-width: 32px;
|
||
width: 32px;
|
||
height: 32px;
|
||
padding: 0;
|
||
border-radius: 10px;
|
||
background: #e7f0f5;
|
||
color: #204257;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.remove-button:hover {
|
||
background: #d8e7ef;
|
||
}
|
||
|
||
.remove-button svg {
|
||
width: 14px;
|
||
height: 14px;
|
||
stroke: currentColor;
|
||
fill: none;
|
||
stroke-width: 1.8;
|
||
stroke-linecap: round;
|
||
stroke-linejoin: round;
|
||
}
|
||
|
||
.info-metrics {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 10px;
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.info-chip {
|
||
background: rgba(248, 249, 251, 0.98);
|
||
border: 1px solid var(--line);
|
||
border-radius: 14px;
|
||
padding: 12px 14px;
|
||
}
|
||
|
||
.info-chip .label {
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.info-chip .value {
|
||
font-size: 20px;
|
||
font-weight: 800;
|
||
line-height: 1.2;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.detail-stack {
|
||
display: grid;
|
||
gap: 12px;
|
||
}
|
||
|
||
.detail-block {
|
||
background: rgba(255, 255, 255, 0.94);
|
||
border: 1px solid var(--line);
|
||
border-radius: 16px;
|
||
padding: 14px;
|
||
}
|
||
|
||
.detail-block h4 {
|
||
font-size: 14px;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.detail-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 10px 12px;
|
||
}
|
||
|
||
.detail-item {
|
||
display: grid;
|
||
gap: 4px;
|
||
}
|
||
|
||
.detail-item span {
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.detail-item strong {
|
||
font-size: 15px;
|
||
line-height: 1.5;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.empty-state {
|
||
padding: 28px 20px;
|
||
text-align: center;
|
||
color: var(--muted);
|
||
background: rgba(255, 255, 255, 0.88);
|
||
border: 1px dashed #c4ccd5;
|
||
border-radius: 16px;
|
||
line-height: 1.8;
|
||
}
|
||
|
||
.inline-actions {
|
||
display: flex;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
margin-top: 12px;
|
||
}
|
||
|
||
.modal-backdrop {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(20, 33, 47, 0.5);
|
||
display: none;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 20px;
|
||
z-index: 50;
|
||
}
|
||
|
||
.modal-backdrop.open {
|
||
display: flex;
|
||
}
|
||
|
||
.modal-card {
|
||
width: min(1280px, 100%);
|
||
max-height: 90vh;
|
||
overflow: auto;
|
||
background: #ffffff;
|
||
border-radius: 14px;
|
||
border: 1px solid var(--line);
|
||
box-shadow: 0 24px 64px rgba(21, 24, 29, 0.18);
|
||
padding: 12px;
|
||
}
|
||
|
||
.modal-form {
|
||
display: grid;
|
||
gap: 12px;
|
||
}
|
||
|
||
.modal-card label {
|
||
font-size: 11px;
|
||
}
|
||
|
||
.modal-card input[type="text"],
|
||
.modal-card input[type="number"],
|
||
.modal-card input[type="date"],
|
||
.modal-card select,
|
||
.modal-card textarea {
|
||
padding: 6px 8px;
|
||
border-radius: 8px;
|
||
min-height: 34px;
|
||
font-size: 12px;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.modal-card textarea {
|
||
min-height: 68px;
|
||
}
|
||
|
||
.form-section-block {
|
||
border: 1px solid var(--line);
|
||
border-radius: 12px;
|
||
padding: 12px;
|
||
background: rgba(250, 251, 252, 0.92);
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
|
||
.form-section-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.form-section-head h3 {
|
||
font-size: 14px;
|
||
}
|
||
|
||
.section-help {
|
||
color: var(--muted);
|
||
font-size: 11px;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.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: 1px solid var(--line);
|
||
background: #f5f7fa;
|
||
border-radius: 8px;
|
||
padding: 7px 9px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: flex-start;
|
||
text-align: left;
|
||
font-weight: 700;
|
||
color: var(--ink);
|
||
font-size: 12px;
|
||
line-height: 1.25;
|
||
}
|
||
|
||
.lookup-wrap {
|
||
position: relative;
|
||
}
|
||
|
||
.lookup-results {
|
||
position: absolute;
|
||
top: calc(100% + 6px);
|
||
left: 0;
|
||
right: 0;
|
||
z-index: 2800;
|
||
border: 1px solid var(--line);
|
||
border-radius: 10px;
|
||
background: #ffffff;
|
||
box-shadow: 0 14px 30px rgba(21, 24, 29, 0.08);
|
||
max-height: 240px;
|
||
overflow: auto;
|
||
display: none;
|
||
}
|
||
|
||
.lookup-results.open {
|
||
display: grid;
|
||
justify-items: stretch;
|
||
align-items: start;
|
||
}
|
||
|
||
.lookup-option {
|
||
padding: 8px 10px;
|
||
border: none;
|
||
background: transparent;
|
||
display: grid;
|
||
justify-items: start;
|
||
align-items: start;
|
||
text-align: left;
|
||
color: var(--ink);
|
||
cursor: pointer;
|
||
border-bottom: 1px solid #eef1f4;
|
||
white-space: normal;
|
||
overflow-wrap: anywhere;
|
||
line-height: 1.35;
|
||
width: 100%;
|
||
}
|
||
|
||
.lookup-option:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.lookup-option strong,
|
||
.lookup-option span {
|
||
display: block;
|
||
width: 100%;
|
||
white-space: normal;
|
||
overflow-wrap: anywhere;
|
||
text-align: left;
|
||
}
|
||
|
||
.lookup-option strong {
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
line-height: 1.35;
|
||
}
|
||
|
||
.lookup-option span {
|
||
color: var(--muted);
|
||
font-size: 10px;
|
||
margin-top: 2px;
|
||
line-height: 1.35;
|
||
}
|
||
|
||
#supportDeptCodeResults,
|
||
#supportDeptNameResults {
|
||
text-align: left;
|
||
}
|
||
|
||
#supportDeptCodeResults .lookup-option,
|
||
#supportDeptNameResults .lookup-option {
|
||
display: block;
|
||
width: 100%;
|
||
justify-self: stretch;
|
||
text-align: left !important;
|
||
}
|
||
|
||
#supportDeptCodeResults .lookup-option strong,
|
||
#supportDeptCodeResults .lookup-option span,
|
||
#supportDeptNameResults .lookup-option strong,
|
||
#supportDeptNameResults .lookup-option span {
|
||
width: 100%;
|
||
margin: 0;
|
||
text-align: left !important;
|
||
}
|
||
|
||
.row-table-wrap {
|
||
overflow: visible;
|
||
border: 1px solid var(--line);
|
||
border-radius: 10px;
|
||
background: #ffffff;
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
|
||
.row-table {
|
||
width: 100%;
|
||
min-width: 0;
|
||
border-collapse: collapse;
|
||
table-layout: fixed;
|
||
}
|
||
|
||
.row-table th,
|
||
.row-table td {
|
||
padding: 6px 7px;
|
||
border-bottom: 1px solid #eef1f4;
|
||
font-size: 12px;
|
||
vertical-align: top;
|
||
}
|
||
|
||
.row-table th {
|
||
background: #f7f8fa;
|
||
color: #525a66;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.row-table td .inline-input {
|
||
width: 100%;
|
||
}
|
||
|
||
.row-table td .lookup-wrap {
|
||
width: 100%;
|
||
}
|
||
|
||
.row-table .lookup-results {
|
||
max-height: 150px;
|
||
}
|
||
|
||
.modal-col-dept,
|
||
.modal-col-work,
|
||
.modal-col-amount,
|
||
.modal-col-remove {
|
||
width: auto;
|
||
}
|
||
|
||
.modal-col-dept {
|
||
width: 34%;
|
||
}
|
||
|
||
.modal-col-work {
|
||
width: 28%;
|
||
}
|
||
|
||
.modal-col-amount {
|
||
width: 28%;
|
||
}
|
||
|
||
.modal-col-remove {
|
||
width: 10%;
|
||
}
|
||
|
||
.section-columns {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 8px;
|
||
align-items: start;
|
||
}
|
||
|
||
.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: 21%;
|
||
}
|
||
|
||
.actual-labor-table col:nth-child(2) {
|
||
width: 33%;
|
||
}
|
||
|
||
.actual-labor-table col:nth-child(3) {
|
||
width: 30%;
|
||
}
|
||
|
||
.actual-labor-table col:nth-child(4) {
|
||
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;
|
||
}
|
||
|
||
.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: 11px 12px 10px;
|
||
}
|
||
|
||
.metric-grid .stat-card .value {
|
||
font-size: clamp(14px, 1.2vw, 22px);
|
||
line-height: 1.15;
|
||
}
|
||
|
||
.metric-grid .stat-card .label {
|
||
font-size: 11px;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.metric-grid .stat-card .meta {
|
||
font-size: 11px;
|
||
display: none;
|
||
}
|
||
|
||
.modal-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 12px;
|
||
position: sticky;
|
||
top: -12px;
|
||
z-index: 4;
|
||
margin: -12px -12px 8px;
|
||
padding: 9px 12px;
|
||
background: rgba(255, 255, 255, 0.98);
|
||
border-bottom: 1px solid #e4e9ef;
|
||
backdrop-filter: blur(10px);
|
||
}
|
||
|
||
.modal-title {
|
||
font-size: 16px;
|
||
line-height: 1.2;
|
||
letter-spacing: -0.02em;
|
||
}
|
||
|
||
.modal-header-actions {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 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;
|
||
}
|
||
|
||
@media (max-width: 1180px) {
|
||
.overview-row,
|
||
.analysis-toolbar,
|
||
.project-search-controls,
|
||
.analysis-kpis,
|
||
.analysis-note-grid,
|
||
.comparison-detail-grid,
|
||
.metric-grid,
|
||
.metric-note-grid,
|
||
.compare-grid,
|
||
.search-bar,
|
||
.detail-grid,
|
||
.info-metrics,
|
||
.section-grid,
|
||
.lookup-grid,
|
||
.section-columns,
|
||
.collection-footer,
|
||
.collection-entry-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.analysis-hero-header,
|
||
.analysis-stat-grid,
|
||
.uncontracted-summary-grid,
|
||
.uncontracted-dashboard-grid,
|
||
.uncontracted-toolbar {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
}
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
<section class="panel analysis-root-panel">
|
||
<div class="analysis-shell">
|
||
<div class="analysis-main">
|
||
<section class="analysis-toolbar">
|
||
<div class="toolbar-field">
|
||
<div class="toolbar-head">
|
||
<div class="toolbar-title-group">
|
||
<h3>프로젝트 검색</h3>
|
||
<div class="project-quick-link-bar" id="projectQuickLinkBar"></div>
|
||
</div>
|
||
<div class="toolbar-head-actions">
|
||
<button type="button" id="saveProjectPageState" class="button-icon button-secondary" title="페이지 입력사항 저장" aria-label="페이지 입력사항 저장">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M6 4h10l4 4v12H6z"></path>
|
||
<path d="M9 4v6h6V4"></path>
|
||
<path d="M9 20v-6h6v6"></path>
|
||
</svg>
|
||
<span class="sr-only">페이지 입력사항 저장</span>
|
||
</button>
|
||
<button type="button" id="openRelatedProjectToolbar" class="button-icon button-secondary" title="연관 프로젝트 검색" aria-label="연관 프로젝트 검색">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M10.8 13.2l2.4-2.4"></path>
|
||
<path d="M7.5 14.7l-1.6 1.6a3 3 0 1 1-4.2-4.2l3-3a3 3 0 0 1 4.2 0"></path>
|
||
<path d="M16.5 9.3l1.6-1.6a3 3 0 1 1 4.2 4.2l-3 3a3 3 0 0 1-4.2 0"></path>
|
||
</svg>
|
||
<span class="sr-only">연관 프로젝트 검색</span>
|
||
</button>
|
||
<button type="button" id="toggleProjectAnalysis" class="button-icon button-secondary toggle-analysis-button" title="프로젝트 상세 열기/숨기기" aria-label="프로젝트 상세 열기/숨기기">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M6 9l6 6 6-6"></path>
|
||
</svg>
|
||
<span class="sr-only">프로젝트 상세 열기/숨기기</span>
|
||
</button>
|
||
<button type="button" id="openProjectModal" class="button-icon" title="사업현황 추가/수정" aria-label="사업현황 추가/수정">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M12 5v14"></path>
|
||
<path d="M5 12h14"></path>
|
||
</svg>
|
||
<span class="sr-only">사업현황 추가/수정</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="project-search-controls">
|
||
<div class="project-search-field">
|
||
<input id="projectCostSearch" type="text" placeholder="지원부서코드 또는 사업명을 입력하세요." autocomplete="off">
|
||
<div class="project-search-dropdown" id="projectSearchDropdown">
|
||
<div class="explorer-list" id="projectExplorerList"></div>
|
||
<div class="project-search-empty" id="projectExplorerEmpty"></div>
|
||
</div>
|
||
</div>
|
||
<div class="field">
|
||
<select id="projectCostYearFilter" aria-label="연도 필터">
|
||
<option value="">전체 연도</option>
|
||
{% for year in project_year_options %}
|
||
<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>
|
||
</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="M5 12l5 5 9-10"></path>
|
||
</svg>
|
||
<span class="sr-only">사업현황 저장</span>
|
||
</button>
|
||
<button type="button" class="close-button button-secondary button-icon" id="closeProjectModal" title="닫기" aria-label="닫기">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="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">
|
||
<div class="form-section-head">
|
||
<h3>기본 정보</h3>
|
||
<div class="section-help">진행율은 계약금액 대비 수금액 합계로 자동 계산됩니다.</div>
|
||
</div>
|
||
<div id="projectImportReviewBanner" class="import-review-banner{% if project_edit.review_tag %} is-visible{% endif %}">
|
||
<strong id="projectImportReviewTag">{{ project_edit.review_tag }}</strong>
|
||
<span id="projectImportReviewNote">{{ project_edit.review_note }}</span>
|
||
</div>
|
||
<div class="section-grid">
|
||
<div class="field field-full">
|
||
<div class="lookup-grid">
|
||
<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">
|
||
<div class="form-section-head">
|
||
<h3>수금 정보</h3>
|
||
<div class="table-tools">
|
||
<div class="actions">
|
||
<div class="section-chip">계약금액 <strong id="collectionContractInlineDisplay">{{ "{:,.0f}".format(project_edit.contract_amount or 0) }}원</strong></div>
|
||
</div>
|
||
<button type="button" class="button-link button-secondary mini-button button-icon" data-add-row="collection" title="수금 행 추가" aria-label="수금 행 추가">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M12 5v14"></path>
|
||
<path d="M5 12h14"></path>
|
||
</svg>
|
||
<span class="sr-only">수금 행 추가</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="collection-entry-list" id="collectionRows">
|
||
{% for row in project_edit.collection_entries %}
|
||
<div class="collection-entry-card" data-collection-row>
|
||
<div class="collection-entry-grid">
|
||
<div class="collection-field">
|
||
<label>기성구분</label>
|
||
<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">
|
||
<div class="form-section-head">
|
||
<h3>과업수행계획</h3>
|
||
</div>
|
||
<div class="table-total">계획 합계: <span id="taskPlanTotalDisplay">0</span>원</div>
|
||
<div class="section-columns">
|
||
<section class="mini-sector">
|
||
<div class="table-tools">
|
||
<h4>부서별배분</h4>
|
||
<button type="button" class="button-link button-secondary mini-button 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">
|
||
<div class="form-section-head">
|
||
<h3>실행예산계획</h3>
|
||
</div>
|
||
<div class="table-total">실행예산 합계: <span id="execBudgetTotalDisplay">0</span>원</div>
|
||
<div class="section-columns">
|
||
<section class="mini-sector">
|
||
<div class="table-tools">
|
||
<h4>인건비</h4>
|
||
<div style="display:inline-flex; gap:6px; align-items:center;">
|
||
<button type="button" class="button-link button-secondary mini-button" id="openLaborRateModal">기준인건비</button>
|
||
<button type="button" class="button-link button-secondary mini-button 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">
|
||
<colgroup>
|
||
<col class="modal-col-dept">
|
||
<col class="modal-col-work">
|
||
<col class="modal-col-amount">
|
||
<col class="modal-col-remove">
|
||
</colgroup>
|
||
<thead>
|
||
<tr>
|
||
<th>직급</th>
|
||
<th>투입시간</th>
|
||
<th>금액(원)</th>
|
||
<th></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="execLaborRows"></tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
<section class="mini-sector">
|
||
<div class="table-tools">
|
||
<h4>외주비</h4>
|
||
<button type="button" class="button-link button-secondary mini-button 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">
|
||
<div class="form-section-head">
|
||
<h3>실투입 관리</h3>
|
||
</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>
|
||
</colgroup>
|
||
<thead>
|
||
<tr>
|
||
<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">
|
||
<div class="form-section-head">
|
||
<h3>예상 배분 설정</h3>
|
||
</div>
|
||
<div class="section-grid">
|
||
<div class="field">
|
||
<label for="expected_as_rate">예상 A/S비 비율</label>
|
||
<select id="expected_as_rate" name="expected_as_rate">
|
||
{% for option in expected_as_rate_options %}
|
||
<option value="{{ option.value }}" {% if project_edit.expected_as_rate|string == option.value|string %}selected{% endif %}>{{ option.label }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
<div class="field">
|
||
<label for="expected_as_cost_display">예상 A/S비 금액</label>
|
||
<input id="expected_as_cost_display" class="inline-input amount-input formatted-amount-input" type="text" inputmode="numeric" name="expected_as_cost" value="{{ "{:,.0f}".format(project_edit.expected_as_cost or 0) if project_edit.expected_as_cost else '' }}" placeholder="">
|
||
</div>
|
||
<div class="field">
|
||
<label for="expected_sga_rate">예상 판관비 비율</label>
|
||
<select id="expected_sga_rate" name="expected_sga_rate">
|
||
{% for option in expected_sga_rate_options %}
|
||
<option value="{{ option.value }}" {% if project_edit.expected_sga_rate|string == option.value|string %}selected{% endif %}>{{ option.label }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
<div class="field">
|
||
<label for="expected_sga_budget_display">예상 판관비 금액</label>
|
||
<input id="expected_sga_budget_display" class="inline-input amount-input formatted-amount-input" type="text" inputmode="numeric" name="expected_sga_budget" value="{{ "{:,.0f}".format(project_edit.expected_sga_budget or 0) if project_edit.expected_sga_budget else '' }}" placeholder="">
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="form-section-block">
|
||
<div class="form-section-head">
|
||
<h3>비고</h3>
|
||
</div>
|
||
<div class="field field-full">
|
||
<label for="notes">메모</label>
|
||
<textarea id="notes" name="notes">{{ project_edit.notes }}</textarea>
|
||
</div>
|
||
</section>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-backdrop" id="laborRateModal">
|
||
<div class="related-modal-card" style="max-width:720px;">
|
||
<div class="related-modal-head">
|
||
<h3 class="related-modal-title">기준인건비</h3>
|
||
<button type="button" class="button-secondary button-icon" id="closeLaborRateModal" title="닫기" aria-label="닫기">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M6 6l12 12"></path>
|
||
<path d="M18 6L6 18"></path>
|
||
</svg>
|
||
<span class="sr-only">닫기</span>
|
||
</button>
|
||
</div>
|
||
<div class="related-modal-body">
|
||
<div class="row-table-wrap">
|
||
<table class="row-table compact">
|
||
<colgroup>
|
||
<col style="width:40%">
|
||
<col style="width:60%">
|
||
</colgroup>
|
||
<thead>
|
||
<tr>
|
||
<th>직급</th>
|
||
<th>기준인건비</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="laborRateRows"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-backdrop" id="relatedProjectModal">
|
||
<div class="related-modal-card">
|
||
<div class="related-modal-head">
|
||
<h3 class="related-modal-title">연관 프로젝트 선택</h3>
|
||
<button type="button" class="button-secondary button-icon" id="closeRelatedProjectModal" title="닫기" aria-label="닫기">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M6 6l12 12"></path>
|
||
<path d="M18 6L6 18"></path>
|
||
</svg>
|
||
<span class="sr-only">닫기</span>
|
||
</button>
|
||
</div>
|
||
<div class="related-modal-body">
|
||
<input id="relatedProjectSearchInput" type="text" placeholder="지원부서코드 또는 사업명을 검색하세요." autocomplete="off">
|
||
<div class="related-search-results" id="relatedProjectSearchResults"></div>
|
||
<div class="project-search-empty" id="relatedProjectSearchEmpty"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<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">
|
||
<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>
|
||
{% endblock %}
|
||
|
||
{% block script %}
|
||
<script>
|
||
const selectedYear = {{ selected_year or "null" }};
|
||
const yearFilter = document.getElementById("year");
|
||
const supportDepartmentOptions = {{ support_department_options | tojson }};
|
||
const costDepartmentOptions = {{ cost_department_options | tojson }};
|
||
const costAccountOptions = {{ cost_account_options | tojson }};
|
||
const laborGradeOptions = {{ labor_grade_options | tojson }};
|
||
const uncontractedCategoryOptions = {{ uncontracted_category_options | tojson }};
|
||
const specialXClassificationRules = {{ special_x_classification_rules | tojson }};
|
||
const projectRuntimeSettings = {{ project_runtime_settings | tojson }};
|
||
let projectEdit = {{ project_edit | tojson }};
|
||
const serverFocusCode = {{ project_focus_code | tojson }};
|
||
const projectEditCache = new Map();
|
||
let modalOriginalEdit = null;
|
||
const revenueMix = {{ project_revenue_mix | tojson }};
|
||
const revenueMixMode = {{ ("monthly" if selected_year else "yearly") | tojson }};
|
||
const projectCostRows = {{ project_cost_by_year | tojson }};
|
||
const projectMonthlyCostRows = {{ project_monthly_cost_rows | tojson }};
|
||
const projectStatusRows = {{ project_status_rows | tojson }};
|
||
const projectComparisonNotes = {{ project_comparison_notes | tojson }};
|
||
const persistedProjectPageState = {{ project_page_state | tojson }};
|
||
const persistedProjectRelatedLinks = {{ project_related_links | tojson }};
|
||
const persistedUncontractedCategoryOverrides = {{ project_uncontracted_classifications | tojson }};
|
||
const projectPageSessionId = window.clientSessionId || "";
|
||
const projectStatusMap = Object.fromEntries(projectStatusRows.map((item) => [item.support_dept_code, item]));
|
||
const projectAccountBreakdowns = {{ project_account_breakdowns | tojson }};
|
||
const projectCostMap = Object.fromEntries(projectCostRows.map((item) => [item.support_dept_code, item]));
|
||
const currencyFormatter = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 0 });
|
||
window.__projectRelatedSelections = new Map();
|
||
const persistedUncontractedFilterState = {
|
||
start_year: String(persistedProjectPageState?.uncontracted_year_start || ""),
|
||
end_year: String(persistedProjectPageState?.uncontracted_year_end || ""),
|
||
};
|
||
let currentSelectedProjectCode = projectEdit.support_dept_code || "";
|
||
const projectStatusForm = document.getElementById("projectStatusForm");
|
||
const modal = document.getElementById("projectModal");
|
||
const openModalButton = document.getElementById("openProjectModal");
|
||
const closeModalButton = document.getElementById("closeProjectModal");
|
||
const saveProjectModalButton = document.getElementById("saveProjectModal");
|
||
const supportDeptCodeLookup = document.getElementById("supportDeptCodeLookup");
|
||
const supportDeptCodeInput = document.getElementById("support_dept_code");
|
||
const supportDeptNameInput = document.getElementById("support_dept_name");
|
||
const projectTypeInput = document.getElementById("project_type");
|
||
const completionStatusInput = document.getElementById("completion_status");
|
||
const changeRoundInput = document.getElementById("change_round");
|
||
const projectStartDateInput = document.getElementById("project_start_date");
|
||
const projectEndDateInput = document.getElementById("project_end_date");
|
||
const notesInput = document.getElementById("notes");
|
||
const projectImportReviewBanner = document.getElementById("projectImportReviewBanner");
|
||
const projectImportReviewTag = document.getElementById("projectImportReviewTag");
|
||
const projectImportReviewNote = document.getElementById("projectImportReviewNote");
|
||
const supportDeptCodeResults = document.getElementById("supportDeptCodeResults");
|
||
const supportDeptNameResults = document.getElementById("supportDeptNameResults");
|
||
const contractAmountInput = document.getElementById("contract_amount");
|
||
const expectedAsRateInput = document.getElementById("expected_as_rate");
|
||
const expectedSgaRateInput = document.getElementById("expected_sga_rate");
|
||
const editRevisionInput = projectStatusForm?.querySelector('input[name="edit_revision"]');
|
||
const collectionRows = document.getElementById("collectionRows");
|
||
const taskPlanDepartmentRows = document.getElementById("taskPlanDepartmentRows");
|
||
const taskPlanOutsourceRows = document.getElementById("taskPlanOutsourceRows");
|
||
const taskPlanJointRows = document.getElementById("taskPlanJointRows");
|
||
const execLaborRows = document.getElementById("execLaborRows");
|
||
const execOutsourceRows = document.getElementById("execOutsourceRows");
|
||
const execCostPlanRows = document.getElementById("execCostPlanRows");
|
||
const actualLaborRows = document.getElementById("actualLaborRows");
|
||
const actualLaborJointRows = document.getElementById("actualLaborJointRows");
|
||
const actualLaborAdjustmentInput = document.getElementById("actualLaborAdjustmentTotal");
|
||
const actualAsRows = document.getElementById("actualAsRows");
|
||
const actualSgaRows = document.getElementById("actualSgaRows");
|
||
const execLaborRatesInput = document.getElementById("exec_labor_rates_json");
|
||
const laborRateModal = document.getElementById("laborRateModal");
|
||
const laborRateRows = document.getElementById("laborRateRows");
|
||
const 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");
|
||
let currentLaborRates = {};
|
||
let latestExcludedDetailRows = [];
|
||
|
||
yearFilter?.addEventListener("change", (event) => {
|
||
const nextYear = event.target.value;
|
||
const nextUrl = nextYear ? `/projects?year=${encodeURIComponent(nextYear)}` : "/projects";
|
||
window.location.href = nextUrl;
|
||
});
|
||
|
||
function escapeHtml(value) {
|
||
return String(value ?? "")
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
function formatNumber(value) {
|
||
return currencyFormatter.format(value || 0);
|
||
}
|
||
|
||
function formatDisplayAmount(value) {
|
||
const numeric = Number(value || 0);
|
||
if (!numeric) return "0";
|
||
if (Math.abs(numeric) >= 100000000) {
|
||
return `${(numeric / 100000000).toFixed(1).replace(/\.0$/, "")}억`;
|
||
}
|
||
if (Math.abs(numeric) >= 1000000) {
|
||
return `${(numeric / 1000000).toFixed(1).replace(/\.0$/, "")}백만`;
|
||
}
|
||
if (Math.abs(numeric) >= 1000) {
|
||
return `${(numeric / 1000).toFixed(1).replace(/\.0$/, "")}천`;
|
||
}
|
||
return formatNumber(numeric);
|
||
}
|
||
|
||
function formatCompactCurrency(value) {
|
||
const numeric = Number(value || 0);
|
||
if (!numeric) return "0원";
|
||
if (Math.abs(numeric) >= 100000000) {
|
||
return `${(numeric / 100000000).toFixed(1).replace(/\.0$/, "")}억원`;
|
||
}
|
||
if (Math.abs(numeric) >= 1000000) {
|
||
return `${(numeric / 1000000).toFixed(1).replace(/\.0$/, "")}백만원`;
|
||
}
|
||
return `${formatNumber(numeric)}원`;
|
||
}
|
||
|
||
function formatAxisLabel(value) {
|
||
const numeric = Number(value || 0);
|
||
if (!numeric) return "0";
|
||
if (numeric >= 100000000) {
|
||
return `${numeric / 100000000}억`;
|
||
}
|
||
if (numeric >= 1000000) {
|
||
return `${numeric / 1000000}백만`;
|
||
}
|
||
if (numeric >= 1000) {
|
||
return `${numeric / 1000}천`;
|
||
}
|
||
return formatNumber(numeric);
|
||
}
|
||
|
||
function pickTickStep(maxValue) {
|
||
const baseUnit = maxValue < 1000000 ? 1000 : 1000000;
|
||
const units = [1, 2, 5];
|
||
const raw = Math.max(maxValue / baseUnit, 1);
|
||
let power = 1;
|
||
while (power * 10 <= raw) {
|
||
power *= 10;
|
||
}
|
||
for (const unit of units) {
|
||
const candidate = unit * power;
|
||
if (candidate >= raw) {
|
||
return candidate * baseUnit;
|
||
}
|
||
}
|
||
return power * 10 * baseUnit;
|
||
}
|
||
|
||
function buildPositiveAxis(maxValue, tickCount = 4) {
|
||
const safeMax = Math.max(Number(maxValue || 0), 1);
|
||
const paddedMax = safeMax * (safeMax < 1000 ? 1.12 : 1.08);
|
||
const tickStep = pickTickStep(paddedMax / tickCount);
|
||
const tickMax = Math.max(tickStep, Math.ceil(paddedMax / tickStep) * tickStep);
|
||
return { tickStep, tickMax };
|
||
}
|
||
|
||
function parseAmount(value) {
|
||
const numeric = Number(String(value ?? "").replace(/,/g, ""));
|
||
return Number.isFinite(numeric) ? numeric : 0;
|
||
}
|
||
|
||
function digitsOnly(value) {
|
||
return String(value ?? "").replace(/\D/g, "");
|
||
}
|
||
|
||
function formatAmountInputValue(value) {
|
||
const 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 formatHourInputValue(value) {
|
||
const numeric = parseHours(value);
|
||
if (!numeric) return "";
|
||
return String(Number(numeric.toFixed(2)));
|
||
}
|
||
|
||
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 readLaborRates() {
|
||
try {
|
||
return JSON.parse(execLaborRatesInput?.value || "{}") || {};
|
||
} catch (error) {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
function writeLaborRates(nextRates) {
|
||
currentLaborRates = { ...nextRates };
|
||
if (execLaborRatesInput) {
|
||
execLaborRatesInput.value = JSON.stringify(currentLaborRates);
|
||
}
|
||
}
|
||
|
||
function getLaborRate(grade) {
|
||
const direct = Number(currentLaborRates?.[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(currentLaborRates?.[laborGradeOptions[index]] || 0);
|
||
if (fallback) return fallback;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
function calculateLaborAmount(grade, hours) {
|
||
return getLaborRate(grade) * parseHours(hours);
|
||
}
|
||
|
||
function formatDateInputValue(value) {
|
||
const digits = digitsOnly(value).slice(0, 8);
|
||
if (digits.length <= 4) return digits;
|
||
if (digits.length <= 6) return `${digits.slice(0, 4)}-${digits.slice(4)}`;
|
||
return `${digits.slice(0, 4)}-${digits.slice(4, 6)}-${digits.slice(6)}`;
|
||
}
|
||
|
||
function getNextRoundValue(fieldName) {
|
||
const values = [...collectionRows.querySelectorAll(`input[name="${fieldName}"]`)]
|
||
.map((input) => Number(input.value || 0))
|
||
.filter((value) => Number.isFinite(value) && value > 0);
|
||
return String((values.length ? Math.max(...values) : 0) + 1);
|
||
}
|
||
|
||
function getAllTaskPlanAmountInputs() {
|
||
return [
|
||
...taskPlanDepartmentRows.querySelectorAll("[data-amount-input]"),
|
||
...taskPlanOutsourceRows.querySelectorAll("[data-amount-input]"),
|
||
...taskPlanJointRows.querySelectorAll("[data-amount-input]"),
|
||
];
|
||
}
|
||
|
||
function getAllExecBudgetAmountInputs() {
|
||
return [
|
||
...execLaborRows.querySelectorAll("[data-amount-input]"),
|
||
...execOutsourceRows.querySelectorAll("[data-amount-input]"),
|
||
...execCostPlanRows.querySelectorAll("[data-amount-input]"),
|
||
];
|
||
}
|
||
|
||
function getAllActualAmountInputs() {
|
||
return [
|
||
...actualLaborRows.querySelectorAll("[data-amount-input]"),
|
||
...actualLaborJointRows.querySelectorAll("[data-amount-input]"),
|
||
...(actualLaborAdjustmentInput ? [actualLaborAdjustmentInput] : []),
|
||
...actualAsRows.querySelectorAll("[data-amount-input]"),
|
||
...actualSgaRows.querySelectorAll("[data-amount-input]"),
|
||
];
|
||
}
|
||
|
||
function updateCollectionContractCells(contractAmount, collectionTotal) {
|
||
const contractDisplay = document.getElementById("collectionContractDisplay");
|
||
const contractInlineDisplay = document.getElementById("collectionContractInlineDisplay");
|
||
if (contractDisplay) {
|
||
contractDisplay.textContent = `${formatNumber(contractAmount)}원`;
|
||
}
|
||
if (contractInlineDisplay) {
|
||
contractInlineDisplay.textContent = `${formatNumber(contractAmount)}원`;
|
||
}
|
||
let runningCollected = 0;
|
||
collectionRows.querySelectorAll("[data-collection-row]").forEach((row) => {
|
||
const amountInput = row.querySelector(".collection-amount");
|
||
runningCollected += parseAmount(amountInput?.value);
|
||
const balanceCell = row.querySelector(".collection-balance-cell");
|
||
if (balanceCell) {
|
||
balanceCell.textContent = `${formatNumber(Math.max(contractAmount - runningCollected, 0))}원`;
|
||
}
|
||
});
|
||
const balance = Math.max(contractAmount - collectionTotal, 0);
|
||
document.getElementById("collectionBalanceTotalDisplay").textContent = `${formatNumber(balance)}원`;
|
||
}
|
||
|
||
function updateComputedFields() {
|
||
const contractAmount = parseAmount(contractAmountInput?.value);
|
||
const billedTotal = [...document.querySelectorAll(".collection-billed-amount")]
|
||
.reduce((sum, input) => sum + parseAmount(input.value), 0);
|
||
const collectionTotal = [...document.querySelectorAll(".collection-amount")]
|
||
.reduce((sum, input) => sum + parseAmount(input.value), 0);
|
||
const progressRate = contractAmount ? (collectionTotal / contractAmount) * 100 : 0;
|
||
document.getElementById("collectionBilledTotalDisplay").textContent = `${formatNumber(billedTotal)}원`;
|
||
document.getElementById("collectionTotalDisplay").textContent = `${formatNumber(collectionTotal)}원`;
|
||
updateCollectionContractCells(contractAmount, collectionTotal);
|
||
document.getElementById("progress_rate_display").textContent = `${progressRate.toFixed(1)}%`;
|
||
|
||
const expectedAsCost = contractAmount * parseAmount(expectedAsRateInput?.value) / 100;
|
||
const expectedSgaBudget = contractAmount * parseAmount(expectedSgaRateInput?.value) / 100;
|
||
const expectedAsCostField = document.getElementById("expected_as_cost_display");
|
||
const expectedSgaBudgetField = document.getElementById("expected_sga_budget_display");
|
||
if (expectedAsCostField && document.activeElement !== expectedAsCostField) {
|
||
const keepManual = parseAmount(expectedAsCostField.value);
|
||
expectedAsCostField.value = keepManual ? formatAmountInputValue(keepManual) : (expectedAsCost ? formatAmountInputValue(expectedAsCost) : "");
|
||
}
|
||
if (expectedSgaBudgetField && document.activeElement !== expectedSgaBudgetField) {
|
||
const keepManual = parseAmount(expectedSgaBudgetField.value);
|
||
expectedSgaBudgetField.value = keepManual ? formatAmountInputValue(keepManual) : (expectedSgaBudget ? formatAmountInputValue(expectedSgaBudget) : "");
|
||
}
|
||
syncExpectedAllocationsToActualInputs();
|
||
|
||
const taskPlanTotal = getAllTaskPlanAmountInputs()
|
||
.reduce((sum, input) => sum + parseAmount(input.value), 0);
|
||
const execBudgetTotal = getAllExecBudgetAmountInputs()
|
||
.reduce((sum, input) => sum + parseAmount(input.value), 0);
|
||
const actualInputTotal = getAllActualAmountInputs()
|
||
.reduce((sum, input) => sum + parseAmount(input.value), 0);
|
||
document.getElementById("taskPlanTotalDisplay").textContent = formatNumber(taskPlanTotal);
|
||
document.getElementById("execBudgetTotalDisplay").textContent = formatNumber(execBudgetTotal);
|
||
document.getElementById("actualInputTotalDisplay").textContent = formatNumber(actualInputTotal);
|
||
}
|
||
|
||
function syncExpectedAllocationsToActualInputs() {
|
||
const expectedAsAmount = parseAmount(document.getElementById("expected_as_cost_display")?.value);
|
||
const expectedSgaAmount = parseAmount(document.getElementById("expected_sga_budget_display")?.value);
|
||
const ensureAutoSyncTarget = (container, labelText, expectedAmount) => {
|
||
let syncInputs = [...container.querySelectorAll('[data-auto-sync="true"]')];
|
||
if (syncInputs.length) return syncInputs;
|
||
const fallbackRow = [...container.querySelectorAll("tr")].find((row) => {
|
||
const labelInput = row.querySelector('input[type="text"][name$="_label[]"], input[type="text"]');
|
||
const amountInput = row.querySelector('[data-amount-input]');
|
||
const normalizedLabel = String(labelInput?.value || "").trim();
|
||
const amountValue = parseAmount(amountInput?.value);
|
||
return normalizedLabel === labelText && (!amountValue || amountValue === expectedAmount);
|
||
});
|
||
const fallbackInput = fallbackRow?.querySelector('[data-amount-input]');
|
||
if (fallbackInput) {
|
||
fallbackInput.dataset.autoSync = "true";
|
||
syncInputs = [fallbackInput];
|
||
}
|
||
return syncInputs;
|
||
};
|
||
const asSyncInputs = ensureAutoSyncTarget(actualAsRows, "A/S비", expectedAsAmount);
|
||
const sgaSyncInputs = ensureAutoSyncTarget(actualSgaRows, "판관비", expectedSgaAmount);
|
||
asSyncInputs.forEach((input) => {
|
||
input.value = expectedAsAmount ? formatAmountInputValue(expectedAsAmount) : "";
|
||
});
|
||
sgaSyncInputs.forEach((input) => {
|
||
input.value = expectedSgaAmount ? formatAmountInputValue(expectedSgaAmount) : "";
|
||
});
|
||
}
|
||
|
||
function upsertProjectStatusRow(projectRow) {
|
||
if (!projectRow?.support_dept_code) return;
|
||
const code = String(projectRow.support_dept_code);
|
||
projectStatusMap[code] = projectRow;
|
||
const index = projectStatusRows.findIndex((item) => item.support_dept_code === code);
|
||
if (index >= 0) {
|
||
projectStatusRows[index] = projectRow;
|
||
} else {
|
||
projectStatusRows.push(projectRow);
|
||
}
|
||
}
|
||
|
||
function createCollectionRow(row = {}) {
|
||
const billingRound = row.billing_round || getNextRoundValue("collection_billing_round[]");
|
||
const collectionRound = row.round || getNextRoundValue("collection_round[]");
|
||
return `
|
||
<div class="collection-entry-card" data-collection-row>
|
||
<div class="collection-entry-grid">
|
||
<div class="collection-field">
|
||
<label>기성구분</label>
|
||
<select class="inline-input" name="collection_progress_type[]">
|
||
${renderSelectOptions(collectionProgressTypeOptions, row.progress_type || "")}
|
||
</select>
|
||
</div>
|
||
<div class="collection-field">
|
||
<label>기성차수</label>
|
||
<input class="inline-input round-input" type="number" min="1" step="1" inputmode="numeric" name="collection_billing_round[]" value="${escapeHtml(billingRound)}">
|
||
</div>
|
||
<div class="collection-field">
|
||
<label>청구구분</label>
|
||
<select class="inline-input" name="collection_billing_type[]">
|
||
${renderSelectOptions(collectionBillingTypeOptions, row.billing_type || "")}
|
||
</select>
|
||
</div>
|
||
<div class="collection-field">
|
||
<label>청구일자</label>
|
||
<input class="inline-input date-text-input" type="text" inputmode="numeric" maxlength="10" name="collection_billing_date[]" value="${escapeHtml(row.billing_date || "")}" placeholder="YYYY-MM-DD">
|
||
</div>
|
||
<div class="collection-field amount-field">
|
||
<label>청구금액</label>
|
||
<input class="inline-input collection-billed-amount amount-input formatted-amount-input" data-billed-amount-input type="text" inputmode="numeric" name="collection_billed_amount[]" value="${escapeHtml(formatAmountInputValue(row.billed_amount || ""))}">
|
||
</div>
|
||
<div class="collection-field">
|
||
<label>수금차수</label>
|
||
<input class="inline-input round-input" type="number" min="1" step="1" inputmode="numeric" name="collection_round[]" value="${escapeHtml(collectionRound)}">
|
||
</div>
|
||
<div class="collection-field">
|
||
<label>수금일자</label>
|
||
<input class="inline-input date-text-input" type="text" inputmode="numeric" maxlength="10" name="collection_date[]" value="${escapeHtml(row.date || "")}" placeholder="YYYY-MM-DD">
|
||
</div>
|
||
<div class="collection-field amount-field">
|
||
<label>수금금액</label>
|
||
<input class="inline-input collection-amount amount-input formatted-amount-input" data-amount-input type="text" inputmode="numeric" name="collection_amount_row[]" value="${escapeHtml(formatAmountInputValue(row.amount || ""))}">
|
||
</div>
|
||
<div class="collection-field balance-field">
|
||
<label>수금잔액</label>
|
||
<div class="readonly-output collection-balance-cell"></div>
|
||
</div>
|
||
</div>
|
||
<div class="actions" style="justify-content: flex-end;">
|
||
<button type="button" class="button-link danger-lite mini-button button-icon row-remove-button" data-remove-row title="행 삭제" aria-label="행 삭제">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M6 12h12"></path>
|
||
</svg>
|
||
<span class="sr-only">행 삭제</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function createTaskPlanRow(group, row = {}) {
|
||
const fieldMap = {
|
||
department: ["task_plan_department_dept[]", "task_plan_department_work[]", "task_plan_department_amount[]"],
|
||
outsource: ["task_plan_outsource_dept[]", "task_plan_outsource_work[]", "task_plan_outsource_amount[]"],
|
||
joint: ["task_plan_joint_dept[]", "task_plan_joint_work[]", "task_plan_joint_amount[]"],
|
||
};
|
||
const [deptName, workName, amountName] = fieldMap[group];
|
||
const deptValue = row.dept_name || row.label || "";
|
||
const workValue = row.work_name || row.note || "";
|
||
const deptField = `
|
||
<div class="lookup-wrap">
|
||
<input class="inline-input cost-dept-lookup" type="text" name="${deptName}" value="${escapeHtml(deptValue)}" autocomplete="off">
|
||
<div class="lookup-results cost-dept-results"></div>
|
||
</div>
|
||
`;
|
||
const workField = `<input class="inline-input" type="text" name="${workName}" value="${escapeHtml(workValue)}">`;
|
||
return `
|
||
<tr data-group="${group}">
|
||
<td>${deptField}</td>
|
||
<td>${workField}</td>
|
||
<td><input class="inline-input amount-input formatted-amount-input" data-amount-input type="text" inputmode="numeric" name="${amountName}" value="${escapeHtml(formatAmountInputValue(row.amount || ""))}"></td>
|
||
<td><button type="button" class="button-link danger-lite mini-button button-icon row-remove-button" data-remove-row title="행 삭제" aria-label="행 삭제">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M6 12h12"></path>
|
||
</svg>
|
||
<span class="sr-only">행 삭제</span>
|
||
</button></td>
|
||
</tr>
|
||
`;
|
||
}
|
||
|
||
function createExecBudgetRow(group, row = {}) {
|
||
if (group === "labor") {
|
||
const selectedGrade = row.grade || row.dept_name || "";
|
||
const inputHours = row.hours || row.work_name || "";
|
||
const amountValue = row.amount || calculateLaborAmount(selectedGrade, inputHours);
|
||
return `
|
||
<tr data-group="${group}">
|
||
<td>
|
||
<select class="inline-input exec-labor-grade" name="exec_labor_grade[]">
|
||
<option value="">직급 선택</option>
|
||
${laborGradeOptions.map((grade) => `<option value="${escapeHtml(grade)}" ${selectedGrade === grade ? "selected" : ""}>${escapeHtml(grade)}</option>`).join("")}
|
||
</select>
|
||
</td>
|
||
<td><input class="inline-input exec-labor-hours" type="text" inputmode="decimal" name="exec_labor_hours[]" value="${escapeHtml(formatHourInputValue(inputHours))}"></td>
|
||
<td><input class="inline-input amount-input formatted-amount-input exec-labor-amount" data-amount-input type="text" inputmode="numeric" name="exec_labor_amount[]" value="${escapeHtml(formatAmountInputValue(amountValue))}" readonly></td>
|
||
<td><button type="button" class="button-link danger-lite mini-button button-icon row-remove-button" data-remove-row title="행 삭제" aria-label="행 삭제">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M6 12h12"></path>
|
||
</svg>
|
||
<span class="sr-only">행 삭제</span>
|
||
</button></td>
|
||
</tr>
|
||
`;
|
||
}
|
||
const fieldMap = {
|
||
outsource: ["exec_outsource_dept[]", "exec_outsource_work[]", "exec_outsource_amount[]"],
|
||
cost_plan: ["exec_cost_plan_code[]", "exec_cost_plan_name[]", "exec_cost_plan_amount[]"],
|
||
};
|
||
const [firstName, secondName, amountName] = fieldMap[group];
|
||
const firstValue = row.dept_name || row.account_code || row.label || "";
|
||
const secondValue = row.work_name || row.account_name || row.note || "";
|
||
return `
|
||
<tr data-group="${group}">
|
||
<td>
|
||
${group === "cost_plan" ? `
|
||
<div class="lookup-wrap">
|
||
<input class="inline-input cost-account-code-lookup" type="text" name="${firstName}" value="${escapeHtml(firstValue)}" autocomplete="off">
|
||
<div class="lookup-results cost-account-results"></div>
|
||
</div>
|
||
` : `
|
||
<div class="lookup-wrap">
|
||
<input class="inline-input cost-dept-lookup" type="text" name="${firstName}" value="${escapeHtml(firstValue)}" autocomplete="off">
|
||
<div class="lookup-results cost-dept-results"></div>
|
||
</div>
|
||
`}
|
||
</td>
|
||
<td>
|
||
${group === "cost_plan" ? `
|
||
<div class="lookup-wrap">
|
||
<input class="inline-input cost-account-name-lookup" type="text" name="${secondName}" value="${escapeHtml(secondValue)}" autocomplete="off">
|
||
<div class="lookup-results cost-account-results"></div>
|
||
</div>
|
||
` : `<input class="inline-input" type="text" name="${secondName}" value="${escapeHtml(secondValue)}">`}
|
||
</td>
|
||
<td><input class="inline-input amount-input formatted-amount-input" data-amount-input type="text" inputmode="numeric" name="${amountName}" value="${escapeHtml(formatAmountInputValue(row.amount || ""))}"></td>
|
||
<td><button type="button" class="button-link danger-lite mini-button button-icon row-remove-button" data-remove-row title="행 삭제" aria-label="행 삭제">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M6 12h12"></path>
|
||
</svg>
|
||
<span class="sr-only">행 삭제</span>
|
||
</button></td>
|
||
</tr>
|
||
`;
|
||
}
|
||
|
||
function createActualLaborRow(row = {}) {
|
||
const selectedGrade = row.grade || "";
|
||
const inputMinutes = Number(row.minutes || row.work_name || 0);
|
||
const splitDuration = splitDurationMinutes(inputMinutes);
|
||
const amountValue = row.amount || calculateLaborAmount(selectedGrade, inputMinutes / 60);
|
||
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><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 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));
|
||
}
|
||
});
|
||
}
|
||
|
||
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();
|
||
};
|
||
});
|
||
}
|
||
}
|
||
|
||
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;
|
||
laborRateRows.innerHTML = laborGradeOptions.map((grade) => `
|
||
<tr>
|
||
<td>${escapeHtml(grade)}</td>
|
||
<td><input class="inline-input amount-input formatted-amount-input labor-rate-input" type="text" inputmode="numeric" data-grade="${escapeHtml(grade)}" value="${escapeHtml(formatAmountInputValue(currentLaborRates?.[grade] || ""))}"></td>
|
||
</tr>
|
||
`).join("");
|
||
bindCollectionFieldBehaviors(laborRateRows);
|
||
laborRateRows.querySelectorAll(".labor-rate-input").forEach((input) => {
|
||
input.addEventListener("input", () => {
|
||
const grade = input.dataset.grade || "";
|
||
currentLaborRates[grade] = parseAmount(input.value);
|
||
writeLaborRates(currentLaborRates);
|
||
recalculateExecLaborAmounts();
|
||
});
|
||
});
|
||
}
|
||
|
||
function openLaborRateModal() {
|
||
renderLaborRateRows();
|
||
laborRateModal?.classList.add("open");
|
||
}
|
||
|
||
function closeLaborRateModal() {
|
||
laborRateModal?.classList.remove("open");
|
||
}
|
||
|
||
function recalculateExecLaborAmounts() {
|
||
execLaborRows.querySelectorAll("tr").forEach((row) => {
|
||
const grade = row.querySelector(".exec-labor-grade")?.value || "";
|
||
const hours = row.querySelector(".exec-labor-hours")?.value || "";
|
||
const amountInput = row.querySelector(".exec-labor-amount");
|
||
if (amountInput) {
|
||
amountInput.value = formatAmountInputValue(calculateLaborAmount(grade, hours));
|
||
}
|
||
});
|
||
updateComputedFields();
|
||
}
|
||
|
||
function bindSupportLookup(input, target) {
|
||
input?.addEventListener("input", (event) => {
|
||
renderSupportDeptResults(event.target.value, target);
|
||
const match = getSupportDeptMatch(event.target.value);
|
||
if (match) applySupportDepartment(match);
|
||
});
|
||
input?.addEventListener("blur", () => {
|
||
setTimeout(() => target.classList.remove("open"), 120);
|
||
const match = getSupportDeptMatch(input.value);
|
||
if (match) applySupportDepartment(match);
|
||
});
|
||
}
|
||
|
||
function resolveSupportDepartmentFromLookup() {
|
||
const match = getSupportDeptMatch(supportDeptCodeLookup?.value || "")
|
||
|| getSupportDeptMatch(supportDeptNameInput?.value || "");
|
||
if (match) {
|
||
applySupportDepartment(match);
|
||
} else {
|
||
supportDeptCodeInput.value = supportDeptCodeLookup?.value?.trim() || "";
|
||
supportDeptNameInput.value = supportDeptNameInput?.value?.trim() || "";
|
||
}
|
||
}
|
||
|
||
function createEmptyProjectEdit(code = "", name = "") {
|
||
return {
|
||
support_dept_code: code,
|
||
support_dept_name: name,
|
||
progress_rate: "",
|
||
contract_amount: "",
|
||
collection_amount: "",
|
||
collection_entries: [],
|
||
change_round: "",
|
||
item_investment: "",
|
||
task_plan_department_budget: "",
|
||
task_plan_outsource_budget: "",
|
||
task_plan_outsource_detail: "",
|
||
task_plan_joint_operating_cost: "",
|
||
task_plan_entries: [],
|
||
exec_budget_labor_by_grade: "",
|
||
exec_labor_rates: {},
|
||
exec_budget_outsource: "",
|
||
exec_budget_cost_plan: "",
|
||
exec_budget_entries: [],
|
||
actual_input_entries: [],
|
||
actual_labor_adjustment_total: "",
|
||
project_type: "",
|
||
expected_as_rate: "",
|
||
expected_sga_rate: "",
|
||
expected_as_cost: "",
|
||
expected_sga_budget: "",
|
||
project_start_date: "",
|
||
project_end_date: "",
|
||
completion_status: "",
|
||
notes: "",
|
||
client_name: "",
|
||
order_method: "",
|
||
joint_contract: "",
|
||
pm_name: "",
|
||
contract_status: "",
|
||
progress_status: "",
|
||
work_category: "",
|
||
review_tag: "",
|
||
review_note: "",
|
||
total_contract_amount: 0,
|
||
hanmac_contract_amount: 0,
|
||
billing_contract_amount: 0,
|
||
billed_amount: 0,
|
||
collection_balance_amount: 0,
|
||
latest_billing_date: "",
|
||
updated_at: "",
|
||
};
|
||
}
|
||
|
||
function cloneProjectEditData(data) {
|
||
return JSON.parse(JSON.stringify(data || createEmptyProjectEdit()));
|
||
}
|
||
|
||
function clearDynamicRows() {
|
||
collectionRows.innerHTML = "";
|
||
taskPlanDepartmentRows.innerHTML = "";
|
||
taskPlanOutsourceRows.innerHTML = "";
|
||
taskPlanJointRows.innerHTML = "";
|
||
execLaborRows.innerHTML = "";
|
||
execOutsourceRows.innerHTML = "";
|
||
execCostPlanRows.innerHTML = "";
|
||
actualLaborRows.innerHTML = "";
|
||
actualLaborJointRows.innerHTML = "";
|
||
actualAsRows.innerHTML = "";
|
||
actualSgaRows.innerHTML = "";
|
||
}
|
||
|
||
function applyProjectEditData(editData) {
|
||
const actualEntries = Array.isArray(editData?.actual_input_entries) ? editData.actual_input_entries : [];
|
||
const actualLaborAdjustmentTotal = actualEntries.reduce((sum, entry) => {
|
||
const entryGroup = entry?.group || "labor";
|
||
if (entryGroup === "labor_adjustment") {
|
||
return sum + Number(entry?.amount || 0);
|
||
}
|
||
return sum + Number(entry?.adjustment_amount || 0);
|
||
}, 0);
|
||
const normalized = {
|
||
...createEmptyProjectEdit(),
|
||
...(editData || {}),
|
||
collection_entries: Array.isArray(editData?.collection_entries) ? editData.collection_entries : [],
|
||
task_plan_entries: Array.isArray(editData?.task_plan_entries) ? editData.task_plan_entries : [],
|
||
exec_budget_entries: Array.isArray(editData?.exec_budget_entries) ? editData.exec_budget_entries : [],
|
||
actual_input_entries: actualEntries,
|
||
actual_labor_adjustment_total: actualLaborAdjustmentTotal,
|
||
};
|
||
projectEdit = normalized;
|
||
currentLaborRates = { ...(normalized.exec_labor_rates || {}) };
|
||
writeLaborRates(currentLaborRates);
|
||
if (normalized.support_dept_code) {
|
||
projectEditCache.set(normalized.support_dept_code, normalized);
|
||
}
|
||
currentSelectedProjectCode = normalized.support_dept_code || currentSelectedProjectCode || "";
|
||
supportDeptCodeInput.value = normalized.support_dept_code || "";
|
||
supportDeptCodeLookup.value = normalized.support_dept_code || "";
|
||
supportDeptNameInput.value = normalized.support_dept_name || "";
|
||
projectTypeInput.value = normalized.project_type || "";
|
||
contractAmountInput.value = normalized.contract_amount || "";
|
||
completionStatusInput.value = normalized.completion_status || "";
|
||
changeRoundInput.value = normalized.change_round || "";
|
||
projectStartDateInput.value = normalized.project_start_date || "";
|
||
projectEndDateInput.value = normalized.project_end_date || "";
|
||
notesInput.value = normalized.notes || "";
|
||
if (projectImportReviewBanner && projectImportReviewTag && projectImportReviewNote) {
|
||
const hasReview = Boolean(normalized.review_tag || normalized.review_note);
|
||
projectImportReviewBanner.classList.toggle("is-visible", hasReview);
|
||
projectImportReviewTag.textContent = normalized.review_tag || "";
|
||
projectImportReviewNote.textContent = normalized.review_note || "";
|
||
}
|
||
expectedAsRateInput.value = normalized.expected_as_rate === null || normalized.expected_as_rate === undefined || normalized.expected_as_rate === "" ? "" : String(normalized.expected_as_rate);
|
||
expectedSgaRateInput.value = normalized.expected_sga_rate === null || normalized.expected_sga_rate === undefined || normalized.expected_sga_rate === "" ? "" : String(normalized.expected_sga_rate);
|
||
if (editRevisionInput) {
|
||
editRevisionInput.value = normalized.updated_at || "";
|
||
}
|
||
if (actualLaborAdjustmentInput) {
|
||
actualLaborAdjustmentInput.value = formatAmountInputValue(normalized.actual_labor_adjustment_total || "");
|
||
}
|
||
|
||
clearDynamicRows();
|
||
|
||
(normalized.collection_entries || []).forEach((row) => addRow("collection", row));
|
||
if (!normalized.collection_entries?.length) {
|
||
addRow("collection");
|
||
}
|
||
|
||
(normalized.task_plan_entries || []).forEach((row) => {
|
||
if (row.group === "outsource") addRow("task-plan-outsource", row);
|
||
else if (row.group === "joint") addRow("task-plan-joint", row);
|
||
else addRow("task-plan-department", row);
|
||
});
|
||
if (!normalized.task_plan_entries?.length) {
|
||
addRow("task-plan-department");
|
||
}
|
||
|
||
const execEntries = [...(normalized.exec_budget_entries || [])];
|
||
if (!execEntries.some((row) => row.group === "outsource")) {
|
||
execEntries.push(
|
||
...(normalized.task_plan_entries || [])
|
||
.filter((row) => row.group === "outsource")
|
||
.map((row) => ({ ...row, group: "outsource" }))
|
||
);
|
||
}
|
||
|
||
execEntries.forEach((row) => {
|
||
if (row.group === "outsource") addRow("exec-outsource", row);
|
||
else if (row.group === "cost_plan") addRow("exec-cost-plan", row);
|
||
else addRow("exec-labor", row);
|
||
});
|
||
if (!execEntries.length) {
|
||
addRow("exec-labor");
|
||
}
|
||
|
||
(normalized.actual_input_entries || []).forEach((row) => {
|
||
if (row.group === "labor_joint") addRow("actual-labor-joint", row);
|
||
else if (row.group === "as") {
|
||
const autoSyncRow = {
|
||
...row,
|
||
auto_sync: row.auto_sync === true || (
|
||
String(row.label || row.note || "").trim() === "A/S비" &&
|
||
(!parseAmount(row.amount || 0) || parseAmount(row.amount || 0) === parseAmount(normalized.expected_as_cost || 0))
|
||
),
|
||
};
|
||
addRow("actual-as", autoSyncRow);
|
||
}
|
||
else if (row.group === "sga") {
|
||
const autoSyncRow = {
|
||
...row,
|
||
auto_sync: row.auto_sync === true || (
|
||
String(row.label || row.note || "").trim() === "판관비" &&
|
||
(!parseAmount(row.amount || 0) || parseAmount(row.amount || 0) === parseAmount(normalized.expected_sga_budget || 0))
|
||
),
|
||
};
|
||
addRow("actual-sga", autoSyncRow);
|
||
}
|
||
else if (row.group === "labor_adjustment") return;
|
||
else addRow("actual-labor", row);
|
||
});
|
||
if (!normalized.actual_input_entries?.length) {
|
||
addRow("actual-labor");
|
||
}
|
||
if (!actualLaborJointRows.children.length) addRow("actual-labor-joint");
|
||
if (!actualAsRows.children.length) addRow("actual-as", { label: "A/S비", amount: normalized.expected_as_cost || "", auto_sync: true });
|
||
if (!actualSgaRows.children.length) addRow("actual-sga", { label: "판관비", amount: normalized.expected_sga_budget || "", auto_sync: true });
|
||
|
||
bindDateTextInputs(document);
|
||
recalculateExecLaborAmounts();
|
||
recalculateActualLaborAmounts();
|
||
updateComputedFields();
|
||
}
|
||
|
||
async function openProjectModalForCode(code = "") {
|
||
const normalizedCode = String(code || "").trim();
|
||
if (!normalizedCode) {
|
||
openProjectModalForFreshEntry(currentSelectedProjectCode || "");
|
||
return;
|
||
}
|
||
try {
|
||
const response = await fetch(`/projects/edit-data?code=${encodeURIComponent(normalizedCode)}`, {
|
||
credentials: "same-origin",
|
||
cache: "no-store",
|
||
headers: { "Accept": "application/json" },
|
||
});
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}`);
|
||
}
|
||
const payload = await response.json();
|
||
if (payload?.error) {
|
||
throw new Error(payload.error);
|
||
}
|
||
projectEditCache.set(normalizedCode, cloneProjectEditData(payload));
|
||
modalOriginalEdit = cloneProjectEditData(payload);
|
||
applyProjectEditData(payload);
|
||
window.__suspendAutoRefresh = true;
|
||
modal?.classList.add("open");
|
||
} 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");
|
||
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");
|
||
}
|
||
|
||
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?.();
|
||
}
|
||
|
||
bindSupportLookup(supportDeptCodeLookup, supportDeptCodeResults);
|
||
bindSupportLookup(supportDeptNameInput, supportDeptNameResults);
|
||
bindDateTextInputs(document);
|
||
|
||
projectStatusForm?.addEventListener("input", (event) => {
|
||
const lookupInput = event.target.closest(".cost-dept-lookup");
|
||
if (lookupInput) {
|
||
const resultBox = lookupInput.parentElement?.querySelector(".cost-dept-results");
|
||
renderCostDeptResults(lookupInput.value || "", resultBox, lookupInput);
|
||
return;
|
||
}
|
||
const accountCodeInput = event.target.closest(".cost-account-code-lookup");
|
||
if (accountCodeInput) {
|
||
const row = accountCodeInput.closest("tr");
|
||
const resultBox = accountCodeInput.parentElement?.querySelector(".cost-account-results");
|
||
renderCostAccountResults(accountCodeInput.value || "", resultBox, row, "code");
|
||
return;
|
||
}
|
||
const accountNameInput = event.target.closest(".cost-account-name-lookup");
|
||
if (accountNameInput) {
|
||
const row = accountNameInput.closest("tr");
|
||
const resultBox = accountNameInput.parentElement?.querySelector(".cost-account-results");
|
||
renderCostAccountResults(accountNameInput.value || "", resultBox, row, "name");
|
||
}
|
||
});
|
||
|
||
projectStatusForm?.addEventListener("focusout", (event) => {
|
||
const lookupInput = event.target.closest(".cost-dept-lookup");
|
||
if (lookupInput) {
|
||
const resultBox = lookupInput.parentElement?.querySelector(".cost-dept-results");
|
||
setTimeout(() => resultBox?.classList.remove("open"), 120);
|
||
return;
|
||
}
|
||
const accountLookup = event.target.closest(".cost-account-code-lookup, .cost-account-name-lookup");
|
||
if (accountLookup) {
|
||
const resultBox = accountLookup.parentElement?.querySelector(".cost-account-results");
|
||
setTimeout(() => resultBox?.classList.remove("open"), 120);
|
||
}
|
||
});
|
||
|
||
openLaborRateModalButton?.addEventListener("click", openLaborRateModal);
|
||
closeLaborRateModalButton?.addEventListener("click", closeLaborRateModal);
|
||
laborRateModal?.addEventListener("click", (event) => {
|
||
if (event.target === laborRateModal) {
|
||
closeLaborRateModal();
|
||
}
|
||
});
|
||
|
||
contractAmountInput?.addEventListener("input", updateComputedFields);
|
||
expectedAsRateInput?.addEventListener("change", updateComputedFields);
|
||
expectedSgaRateInput?.addEventListener("change", updateComputedFields);
|
||
document.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) {
|
||
event?.preventDefault?.();
|
||
event?.stopPropagation?.();
|
||
event?.stopImmediatePropagation?.();
|
||
window.__suspendAutoRefresh = true;
|
||
resolveSupportDepartmentFromLookup();
|
||
const form = projectStatusForm;
|
||
if (!(form instanceof HTMLFormElement)) return;
|
||
const saveButtons = [...form.querySelectorAll('button[type="submit"]')];
|
||
if (saveProjectModalButton) {
|
||
saveButtons.push(saveProjectModalButton);
|
||
}
|
||
saveButtons.forEach((button) => {
|
||
button.disabled = true;
|
||
});
|
||
try {
|
||
const formData = new FormData(form);
|
||
if (selectedYear) {
|
||
formData.set("selected_year", String(selectedYear));
|
||
}
|
||
const body = new URLSearchParams();
|
||
formData.forEach((value, key) => {
|
||
body.append(key, String(value));
|
||
});
|
||
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) {
|
||
throw new Error(payload?.error || `HTTP ${response.status}`);
|
||
}
|
||
const updatedEdit = cloneProjectEditData(payload?.project_edit || {});
|
||
const updatedRow = payload?.project_row || null;
|
||
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);
|
||
applyProjectEditData(updatedEdit);
|
||
}
|
||
if (updatedRow?.support_dept_code) {
|
||
const projectPageHelpers = window.__projectPageHelpers || null;
|
||
if (projectPageHelpers?.upsertProjectStatusRow) {
|
||
projectPageHelpers.upsertProjectStatusRow(updatedRow);
|
||
}
|
||
if (projectPageHelpers?.refreshAnalysisForCode) {
|
||
projectPageHelpers.refreshAnalysisForCode(updatedRow.support_dept_code);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error(error);
|
||
window.alert(error?.message || "사업현황 저장 중 오류가 발생했습니다.");
|
||
} finally {
|
||
saveButtons.forEach((button) => {
|
||
button.disabled = false;
|
||
});
|
||
}
|
||
}
|
||
|
||
projectStatusForm?.addEventListener("submit", handleProjectStatusSave);
|
||
saveProjectModalButton?.addEventListener("click", handleProjectStatusSave);
|
||
|
||
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);
|
||
return {
|
||
...costItem,
|
||
...statusItem,
|
||
support_dept_name: supportDeptName,
|
||
support_dept_code: supportDeptCode,
|
||
revenue_amount: Number(costItem?.revenue_amount || 0),
|
||
expense_amount: Number(costItem?.expense_amount || 0),
|
||
total_revenue: totalRevenue,
|
||
total_expense: totalExpense,
|
||
planned_task_total: plannedTaskTotal,
|
||
exec_budget_total: execBudgetTotal,
|
||
operating_balance: totalRevenue - totalExpense,
|
||
year_range: formatYearRange(costItem || statusItem || {}),
|
||
};
|
||
}
|
||
|
||
function getProjectContractState(item) {
|
||
const code = item?.support_dept_code || "";
|
||
const contractAmount = Number(item?.contract_amount || projectStatusMap[code]?.contract_amount || 0);
|
||
const expenseAmount = Number(item?.expense_amount || item?.total_expense || 0);
|
||
const collectionAmount = Number(
|
||
item?.collection_amount
|
||
|| item?.total_revenue
|
||
|| projectStatusMap[code]?.collection_amount
|
||
|| 0,
|
||
);
|
||
const hasRelatedContract = getCurrentRelatedCodes(code).some((relatedCode) => {
|
||
const relatedItem = getAnalysisItem(relatedCode) || projectStatusMap[relatedCode] || {};
|
||
const relatedContractAmount = Number(relatedItem?.contract_amount || 0);
|
||
const relatedCollectionAmount = Number(
|
||
relatedItem?.collection_amount
|
||
|| relatedItem?.total_revenue
|
||
|| 0,
|
||
);
|
||
return relatedCollectionAmount > 0 || relatedContractAmount > 0;
|
||
});
|
||
if (collectionAmount > 0) {
|
||
return "contracted";
|
||
}
|
||
if (contractAmount > 0) {
|
||
return "contracted";
|
||
}
|
||
if (hasRelatedContract) {
|
||
return "contracted";
|
||
}
|
||
if (expenseAmount > 0) {
|
||
return "uncontracted_cost";
|
||
}
|
||
return "uncontracted";
|
||
}
|
||
|
||
function classifyUncontractedSpecialType(item) {
|
||
const code = String(item?.support_dept_code || "");
|
||
const override = String(persistedUncontractedCategoryOverrides?.[code] || "");
|
||
if (override) return override;
|
||
const name = String(item?.support_dept_name || "");
|
||
if (!code.startsWith("X")) return "general";
|
||
const normalizedName = name.replace(/\s+/g, "");
|
||
for (const [categoryKey, keywords] of Object.entries(specialXClassificationRules || {})) {
|
||
if ((keywords || []).some((keyword) => normalizedName.includes(String(keyword || "").replace(/\s+/g, "")))) {
|
||
return categoryKey;
|
||
}
|
||
}
|
||
return "precontract";
|
||
}
|
||
|
||
function getProjectContractStateLabel(state) {
|
||
if (state === "contracted") return "계약 진행";
|
||
if (state === "uncontracted_cost") return "미계약 비용 발생";
|
||
return "미계약";
|
||
}
|
||
|
||
function getProjectContractStateBadge(item) {
|
||
const state = getProjectContractState(item);
|
||
return `<span class="contract-state-badge is-${state}">${getProjectContractStateLabel(state)}</span>`;
|
||
}
|
||
|
||
function formatSignedAmount(value) {
|
||
const numeric = Number(value || 0);
|
||
const prefix = numeric > 0 ? "+" : "";
|
||
return `${prefix}${formatNumber(numeric)}`;
|
||
}
|
||
|
||
function differenceClass(value) {
|
||
const numeric = Number(value || 0);
|
||
if (numeric > 0) return "delta-positive";
|
||
if (numeric < 0) return "delta-negative";
|
||
return "";
|
||
}
|
||
|
||
function getBreakdownList(code, kind) {
|
||
const item = projectAccountBreakdowns?.[code] || {};
|
||
return Array.isArray(item?.[kind]) ? item[kind] : [];
|
||
}
|
||
|
||
function normalizeDetailEntries(entries, fallbackLabel) {
|
||
if (!Array.isArray(entries) || !entries.length) return [];
|
||
return entries.map((entry, index) => ({
|
||
...entry,
|
||
label:
|
||
entry.label
|
||
|| [entry.grade, entry.minutes ? formatDurationInputValue(entry.minutes) : ""].filter(Boolean).join(" / ")
|
||
|| [entry.grade, entry.hours ? `예상 ${formatHourInputValue(entry.hours)}시간` : ""].filter(Boolean).join(" / ")
|
||
|| [entry.account_code, entry.account_name].filter(Boolean).join(" / ")
|
||
|| [entry.dept_name, entry.work_name].filter(Boolean).join(" / ")
|
||
|| entry.reference
|
||
|| entry.note
|
||
|| `${fallbackLabel} ${index + 1}`,
|
||
amount: Number(entry.amount || 0),
|
||
adjustment_amount: Number(entry.adjustment_amount || 0),
|
||
})).filter((entry) => entry.label || entry.amount || entry.adjustment_amount);
|
||
}
|
||
|
||
function renderComparisonDetailList(entries, emptyLabel) {
|
||
if (!entries.length) {
|
||
return `<div class="comparison-detail-item"><span>${emptyLabel}</span><strong>-</strong></div>`;
|
||
}
|
||
return entries.map((entry) => `
|
||
<div class="comparison-detail-item">
|
||
<span>${escapeHtml(entry.label || "-")}</span>
|
||
<strong>${formatNumber(entry.amount || 0)}</strong>
|
||
</div>
|
||
`).join("");
|
||
}
|
||
|
||
function stripLeadingAccountCode(label) {
|
||
return String(label || "").replace(/^\d{6,8}\s*(?:[·ㆍ]|\/)\s*/u, "").trim();
|
||
}
|
||
|
||
function splitProjectEntryLabel(label) {
|
||
const normalized = String(label || "").trim();
|
||
const match = normalized.match(/^([A-Z]\d{5})\s*[·ㆍ]\s*(.+)$/u);
|
||
if (!match) {
|
||
return {
|
||
projectCode: "",
|
||
detailLabel: stripLeadingAccountCode(normalized) || "-",
|
||
};
|
||
}
|
||
return {
|
||
projectCode: match[1],
|
||
detailLabel: stripLeadingAccountCode(match[2]) || "-",
|
||
};
|
||
}
|
||
|
||
function buildGroupedComparisonEntries(entries) {
|
||
const groups = [];
|
||
const groupMap = new Map();
|
||
(entries || []).forEach((entry) => {
|
||
const { projectCode, detailLabel } = splitProjectEntryLabel(entry.label);
|
||
const groupCode = projectCode || "기타";
|
||
if (!groupMap.has(groupCode)) {
|
||
const group = {
|
||
code: groupCode,
|
||
items: [],
|
||
subtotal: 0,
|
||
};
|
||
groupMap.set(groupCode, group);
|
||
groups.push(group);
|
||
}
|
||
const group = groupMap.get(groupCode);
|
||
const amount = Number(entry.amount || 0);
|
||
group.items.push({
|
||
...entry,
|
||
label: detailLabel,
|
||
amount,
|
||
});
|
||
group.subtotal += amount;
|
||
});
|
||
return {
|
||
groups,
|
||
total: groups.reduce((sum, group) => sum + Number(group.subtotal || 0), 0),
|
||
};
|
||
}
|
||
|
||
function getProjectPairOrder(plannedEntries, actualEntries) {
|
||
const order = [];
|
||
const seen = new Set();
|
||
[...plannedEntries, ...actualEntries].forEach((entry) => {
|
||
const projectCode = splitProjectEntryLabel(entry.label).projectCode || "기타";
|
||
if (seen.has(projectCode)) return;
|
||
seen.add(projectCode);
|
||
order.push(projectCode);
|
||
});
|
||
return order;
|
||
}
|
||
|
||
function buildComparisonSideRows(group, emptyLabel, placeholderCount = 0, warningConfig = null) {
|
||
const rows = [];
|
||
const items = group?.items || [];
|
||
const groupCode = group?.code || "기타";
|
||
if (items.length) {
|
||
items.forEach((entry) => {
|
||
rows.push({
|
||
type: "item",
|
||
label: `${groupCode} · ${entry.label || "-"}`,
|
||
amount: Number(entry.amount || 0),
|
||
});
|
||
});
|
||
} 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>
|
||
<strong>${row.amount === null || row.amount === undefined ? "" : 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 renderLaborComparisonDetails(plannedEntries, erpEntries, jointEntries) {
|
||
const plannedGrouped = buildGroupedComparisonEntries(plannedEntries);
|
||
const erpGrouped = buildGroupedComparisonEntries(erpEntries);
|
||
const jointGrouped = buildGroupedComparisonEntries(jointEntries);
|
||
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 plannedRows = [];
|
||
const erpRows = [];
|
||
const jointRows = [];
|
||
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 plannedCount = plannedGroup.items?.length ? plannedGroup.items.length : 1;
|
||
const erpCount = erpGroup.items?.length ? erpGroup.items.length : 1;
|
||
const jointCount = jointGroup.items?.length ? jointGroup.items.length : 1;
|
||
const maxCount = Math.max(plannedCount, erpCount, jointCount);
|
||
plannedRows.push(...buildComparisonSideRows(plannedGroup, "", maxCount - plannedCount));
|
||
erpRows.push(...buildComparisonSideRows(erpGroup, "", maxCount - erpCount));
|
||
jointRows.push(...buildComparisonSideRows(
|
||
jointGroup,
|
||
"",
|
||
maxCount - jointCount,
|
||
!(jointGroup.items || []).length ? {
|
||
title: "합사인건비 반영 필요",
|
||
subtitle: "(사업관리팀 자료 회신 대기 중)",
|
||
rowSpan: maxCount,
|
||
} : null,
|
||
));
|
||
});
|
||
return `
|
||
<div class="comparison-detail-grid">
|
||
${renderComparisonSideBox(plannedRows, plannedGrouped.total, "총계", "계획/입력 세부")}
|
||
<section class="comparison-detail-group">
|
||
<h5>실제 집행 세부</h5>
|
||
<div class="comparison-detail-split">
|
||
${renderComparisonSideBox(erpRows, erpGrouped.total, "인건비(ERP) 총계")}
|
||
${renderComparisonSideBox(jointRows, jointGrouped.total, "인건비(합사) 총계")}
|
||
</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 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";
|
||
}
|
||
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 sumNumericFields(target, source, keys) {
|
||
keys.forEach((key) => {
|
||
target[key] = Number(target[key] || 0) + Number(source?.[key] || 0);
|
||
});
|
||
}
|
||
|
||
function mergeLabeledAmountEntries(entries) {
|
||
const merged = new Map();
|
||
(entries || []).forEach((entry) => {
|
||
const label = String(entry?.label || entry?.reference || entry?.note || "").trim() || "미분류";
|
||
const current = merged.get(label) || { label, amount: 0 };
|
||
current.amount += Number(entry?.amount || 0);
|
||
merged.set(label, current);
|
||
});
|
||
return [...merged.values()].sort((a, b) => Number(b.amount || 0) - Number(a.amount || 0));
|
||
}
|
||
|
||
function prefixProjectEntries(item, entries, fallbackLabel) {
|
||
return normalizeDetailEntries(entries, fallbackLabel).map((entry) => ({
|
||
...entry,
|
||
label: `${item.support_dept_code} · ${entry.label}`,
|
||
amount: Number(entry.amount || 0),
|
||
}));
|
||
}
|
||
|
||
function getCombinedBreakdownList(codes, kind) {
|
||
const merged = [];
|
||
(codes || []).forEach((code) => {
|
||
const entries = getBreakdownList(code, kind);
|
||
entries.forEach((entry) => {
|
||
merged.push({
|
||
label: `${code} · ${entry.label || "-"}`,
|
||
amount: Number(entry.amount || 0),
|
||
});
|
||
});
|
||
});
|
||
return mergeLabeledAmountEntries(merged);
|
||
}
|
||
|
||
function aggregateAnalysisItem(baseItem, relatedItems = []) {
|
||
if (!baseItem) return null;
|
||
const items = [baseItem, ...relatedItems].filter(Boolean);
|
||
const aggregated = {
|
||
...baseItem,
|
||
_aggregateCodes: items.map((item) => item.support_dept_code),
|
||
_relatedItems: relatedItems,
|
||
min_year: Math.min(...items.map((item) => Number(item.min_year || item.year || 0)).filter(Boolean)),
|
||
max_year: Math.max(...items.map((item) => Number(item.max_year || item.year || 0)).filter(Boolean)),
|
||
latest_year: baseItem.latest_year,
|
||
latest_month: baseItem.latest_month,
|
||
revenue_amount: 0,
|
||
expense_amount: 0,
|
||
total_revenue: 0,
|
||
total_expense: 0,
|
||
total_cost: 0,
|
||
total_sga: 0,
|
||
contract_amount: 0,
|
||
collection_amount: 0,
|
||
planned_task_total: 0,
|
||
exec_budget_total: 0,
|
||
operating_balance: 0,
|
||
expected_as_cost: 0,
|
||
expected_sga_budget: 0,
|
||
task_plan_department_budget: 0,
|
||
task_plan_outsource_budget: 0,
|
||
task_plan_joint_operating_cost: 0,
|
||
exec_budget_labor_by_grade: 0,
|
||
exec_budget_outsource: 0,
|
||
exec_budget_cost_plan: 0,
|
||
actual_labor: 0,
|
||
actual_outsource: 0,
|
||
item_investment: 0,
|
||
collection_entries: [],
|
||
task_plan_entries: [],
|
||
exec_budget_entries: [],
|
||
actual_input_entries: [],
|
||
};
|
||
const sumKeys = [
|
||
"revenue_amount",
|
||
"expense_amount",
|
||
"total_revenue",
|
||
"total_cost",
|
||
"total_sga",
|
||
"contract_amount",
|
||
"collection_amount",
|
||
"planned_task_total",
|
||
"exec_budget_total",
|
||
"expected_as_cost",
|
||
"expected_sga_budget",
|
||
"task_plan_department_budget",
|
||
"task_plan_outsource_budget",
|
||
"task_plan_joint_operating_cost",
|
||
"exec_budget_labor_by_grade",
|
||
"exec_budget_outsource",
|
||
"exec_budget_cost_plan",
|
||
"actual_labor",
|
||
"actual_outsource",
|
||
"item_investment",
|
||
];
|
||
items.forEach((item) => {
|
||
sumNumericFields(aggregated, item, sumKeys);
|
||
aggregated.collection_entries.push(...prefixProjectEntries(item, item.collection_entries, "수금 입력"));
|
||
aggregated.task_plan_entries.push(...prefixProjectEntries(item, item.task_plan_entries, "과업수행계획"));
|
||
aggregated.exec_budget_entries.push(...prefixProjectEntries(item, item.exec_budget_entries, "실행예산"));
|
||
aggregated.actual_input_entries.push(...prefixProjectEntries(item, item.actual_input_entries, "실투입"));
|
||
const itemLatest = Number(item.latest_year || 0) * 100 + Number(item.latest_month || 0);
|
||
const aggregatedLatest = Number(aggregated.latest_year || 0) * 100 + Number(aggregated.latest_month || 0);
|
||
if (itemLatest > aggregatedLatest) {
|
||
aggregated.latest_year = item.latest_year;
|
||
aggregated.latest_month = item.latest_month;
|
||
}
|
||
});
|
||
aggregated.total_expense = Number(aggregated.total_cost || 0) + Number(aggregated.total_sga || 0);
|
||
aggregated.operating_balance = Number(aggregated.total_revenue || 0) - Number(aggregated.total_expense || 0);
|
||
return aggregated;
|
||
}
|
||
|
||
function isDesignOutsourceEntry(entry) {
|
||
const label = String(entry?.label || "");
|
||
return label.includes("기술협력비");
|
||
}
|
||
|
||
function formatComparisonCell(value) {
|
||
return value === null || value === undefined ? "" : formatNumber(value);
|
||
}
|
||
|
||
function formatComparisonDiff(planned, actual) {
|
||
const diff = Number(planned || 0) - Number(actual || 0);
|
||
return `<span class="${differenceClass(diff)}">${formatSignedAmount(diff)}</span>`;
|
||
}
|
||
|
||
function buildComparisonDetails(item) {
|
||
const aggregateCodes = Array.isArray(item?._aggregateCodes) && item._aggregateCodes.length
|
||
? item._aggregateCodes
|
||
: [item.support_dept_code];
|
||
const revenuePlanned = normalizeDetailEntries(item.collection_entries, "수금 입력");
|
||
const revenueActual = getCombinedBreakdownList(aggregateCodes, "revenue");
|
||
const laborPlanned = getExecBudgetGroupEntries(item, "labor", "직급별 인건비");
|
||
const outsourcePlanned = getExecBudgetGroupEntries(item, "outsource", "외주비");
|
||
const overheadPlanned = getExecBudgetGroupEntries(item, "cost_plan", "계정별비용계획");
|
||
const laborActual = getActualGroupEntries(item, "labor", "실투입 인건비");
|
||
const laborJointActual = getActualGroupEntries(item, "labor_joint", "실투입 인건비(합사)");
|
||
const asActual = getActualGroupEntries(item, "as", "실투입 A/S비");
|
||
const sgaInputActual = getActualGroupEntries(item, "sga", "실투입 판관비");
|
||
const costEntries = aggregateCodes.flatMap((code) => getCostBreakdownEntries(code).map((entry) => ({
|
||
label: `${code} · ${entry.label || "-"}`,
|
||
amount: Number(entry.amount || 0),
|
||
})));
|
||
const outsourceActual = costEntries.filter(isDesignOutsourceEntry);
|
||
const overheadActual = costEntries.filter((entry) => !isDesignOutsourceEntry(entry));
|
||
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 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 getCurrentRelatedCodes(baseCode) {
|
||
const selectionMap = window.__projectRelatedSelections;
|
||
if (!(selectionMap instanceof Map)) return [];
|
||
return [...(selectionMap.get(baseCode) || new Set())];
|
||
}
|
||
|
||
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 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 = [...map.values()].sort((a, b) => Number(b.expense_amount || 0) - Number(a.expense_amount || 0));
|
||
} else if (uncontractedDetailSelection.type === "year" && uncontractedDetailSelection.year) {
|
||
const map = new Map();
|
||
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 = [...map.values()].sort((a, b) => Number(b.expense_amount || 0) - Number(a.expense_amount || 0));
|
||
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 = [...map.values()].sort((a, b) => Number(b.expense_amount || 0) - Number(a.expense_amount || 0));
|
||
}
|
||
|
||
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);
|
||
|
||
return {
|
||
startYear,
|
||
endYear,
|
||
summary,
|
||
yearlyRows,
|
||
monthlyRows,
|
||
focusYear,
|
||
detailRows,
|
||
excludedDetailRows,
|
||
detailExpenseGap,
|
||
detailTitle,
|
||
};
|
||
}
|
||
|
||
function renderUncontractedDashboard() {
|
||
const data = buildUncontractedDashboardData();
|
||
const allYears = getUncontractedYearRange().allYears;
|
||
const startOptions = allYears.map((year) => `<option value="${year}"${Number(year) === Number(data.startYear) ? " selected" : ""}>${year}</option>`).join("");
|
||
const endOptions = allYears.map((year) => `<option value="${year}"${Number(year) === Number(data.endYear) ? " selected" : ""}>${year}</option>`).join("");
|
||
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" id="toggleUncontractedDetail" title="세부 내역 열기/숨기기" aria-label="세부 내역 열기/숨기기">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="${uncontractedDetailVisible ? "M7 7l10 10 M17 7L7 17" : "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>프로젝트</th>
|
||
<th>발생 비용</th>
|
||
${uncontractedDetailSelection.type === "month" ? "" : "<th>관련 수금</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>${escapeHtml(row.support_dept_code)} · ${escapeHtml(row.support_dept_name)}</td>
|
||
<td>${formatNumber(row.expense_amount || 0)}</td>
|
||
${uncontractedDetailSelection.type === "month" ? "" : `<td>${formatNumber(row.revenue_amount || 0)}</td>`}
|
||
<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 tags = (relatedItems || []).map((item) => `
|
||
<span class="related-project-tag">
|
||
${escapeHtml(item.support_dept_code)} · ${escapeHtml(item.support_dept_name)}
|
||
<button type="button" data-remove-related="${escapeHtml(item.support_dept_code)}" title="연관 프로젝트 제거" aria-label="연관 프로젝트 제거">×</button>
|
||
</span>
|
||
`).join("");
|
||
if (!tags) return "";
|
||
return `
|
||
<div class="related-tag-row">
|
||
<span class="related-tag-label">연관 프로젝트</span>
|
||
${tags}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderAnalysisMetrics(item) {
|
||
const details = buildComparisonDetails(item);
|
||
const actualExecutionCost =
|
||
Number(item.total_expense || 0)
|
||
|| (
|
||
sumEntryAmounts(details.labor.actual)
|
||
+ sumEntryAmounts(details.labor.actual_joint)
|
||
+ sumEntryAmounts(details.outsource.actual)
|
||
+ sumEntryAmounts(details.overhead.actual)
|
||
+ sumEntryAmounts(details.as.actual)
|
||
+ sumEntryAmounts(details.sga.actual)
|
||
);
|
||
return `
|
||
<h4>핵심 비교 지표</h4>
|
||
<div class="analysis-kpis">
|
||
<div class="analysis-kpi">
|
||
<span>계약금액</span>
|
||
<strong>${formatDisplayAmount(item.contract_amount)}</strong>
|
||
</div>
|
||
<div class="analysis-kpi">
|
||
<span>수금액</span>
|
||
<strong>${formatDisplayAmount(item.collection_amount)}</strong>
|
||
</div>
|
||
<div class="analysis-kpi">
|
||
<span>집행비용</span>
|
||
<strong>${formatDisplayAmount(actualExecutionCost)}</strong>
|
||
</div>
|
||
<div class="analysis-kpi">
|
||
<span>영업수지</span>
|
||
<strong>${formatDisplayAmount(item.operating_balance)}</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 || [];
|
||
return `
|
||
<tr class="comparison-main-row${hasDetail ? "" : " no-detail"}" ${hasDetail ? `data-detail-target="${key}"` : ""}>
|
||
<td>
|
||
${hasDetail ? `
|
||
<span class="comparison-toggle">
|
||
<svg class="comparison-chevron" viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M9 6l6 6-6 6"></path>
|
||
</svg>
|
||
${label}
|
||
</span>
|
||
` : label}
|
||
</td>
|
||
<td>${formatComparisonCell(planned)}</td>
|
||
<td>${formatComparisonCell(actual)}</td>
|
||
<td>${formatComparisonDiff(planned, actual)}</td>
|
||
<td>
|
||
<textarea
|
||
class="comparison-note-input"
|
||
data-comparison-note-code="${escapeHtml(item.support_dept_code)}"
|
||
data-comparison-note-key="${escapeHtml(key)}"
|
||
>${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(
|
||
plannedEntries,
|
||
detail?.actual || [],
|
||
detail?.actual_joint || [],
|
||
)}
|
||
`
|
||
: renderPairedComparisonDetails(
|
||
plannedEntries,
|
||
actualEntries,
|
||
"",
|
||
"",
|
||
)}
|
||
</div>
|
||
</td>
|
||
</tr>` : ""}
|
||
`;
|
||
}).join("")}
|
||
</tbody>
|
||
</table>
|
||
`;
|
||
}
|
||
|
||
function renderAnalysisNotes(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((sumEntryAmounts(getActualGroupEntries(item, "labor", "실투입 인건비")) + sumEntryAmounts(getActualGroupEntries(item, "labor_joint", "실투입 인건비(합사)"))) || 0)}</strong></div>
|
||
<div class="detail-item"><span>실집행 외주비</span><strong>${formatNumber(item.actual_outsource || 0)}</strong></div>
|
||
<div class="detail-item"><span>항목별투입액</span><strong>${formatNumber(item.item_investment || 0)}</strong></div>
|
||
<div class="detail-item"><span>청구합계</span><strong>${formatNumber(item.billed_amount || 0)}</strong></div>
|
||
<div class="detail-item"><span>수금잔액</span><strong>${formatNumber(item.collection_balance_amount || 0)}</strong></div>
|
||
<div class="detail-item"><span>비고</span><strong>${detailValue(item.notes)}</strong></div>
|
||
</div>
|
||
</div>
|
||
</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));
|
||
});
|
||
}
|
||
|
||
const aggregatedProjectCostDatasetCache = new Map();
|
||
const aggregatedAllProjectCostRows = aggregateProjectCostRows(projectCostRows).map((item) => ({
|
||
...item,
|
||
_searchText: `${item.support_dept_code || ""} ${item.support_dept_name || ""}`.toLowerCase(),
|
||
}));
|
||
const aggregatedAllProjectCostMap = new Map(
|
||
aggregatedAllProjectCostRows.map((item) => [item.support_dept_code, item]),
|
||
);
|
||
|
||
function getAggregatedProjectCostDataset(yearFilterValue) {
|
||
const cacheKey = String(yearFilterValue || "");
|
||
if (aggregatedProjectCostDatasetCache.has(cacheKey)) {
|
||
return aggregatedProjectCostDatasetCache.get(cacheKey);
|
||
}
|
||
const dataset = !yearFilterValue
|
||
? aggregatedAllProjectCostRows
|
||
: aggregateProjectCostRows(
|
||
projectCostRows.filter((item) => String(item.year) === String(yearFilterValue)),
|
||
).map((item) => ({
|
||
...item,
|
||
_searchText: `${item.support_dept_code || ""} ${item.support_dept_name || ""}`.toLowerCase(),
|
||
}));
|
||
aggregatedProjectCostDatasetCache.set(cacheKey, dataset);
|
||
return dataset;
|
||
}
|
||
|
||
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 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(item.item_investment)}</strong></div>
|
||
</div>
|
||
</div>
|
||
<div class="detail-block">
|
||
<h4>실제 집행</h4>
|
||
<div class="detail-grid">
|
||
<div class="detail-item"><span>실집행 수익</span><strong>${formatNumber(item.total_revenue)}</strong></div>
|
||
<div class="detail-item"><span>실집행 원가</span><strong>${formatNumber(item.total_cost)}</strong></div>
|
||
<div class="detail-item"><span>실집행 판관비</span><strong>${formatNumber(sumEntryAmounts(getActualGroupEntries(item, "sga", "실투입 판관비")))}</strong></div>
|
||
<div class="detail-item"><span>실집행 인건비</span><strong>${formatNumber(sumEntryAmounts(getActualGroupEntries(item, "labor", "실투입 인건비")) + sumEntryAmounts(getActualGroupEntries(item, "labor_joint", "실투입 인건비(합사)")))}</strong></div>
|
||
<div class="detail-item"><span>실집행 외주비</span><strong>${formatNumber(item.actual_outsource)}</strong></div>
|
||
<div class="detail-item"><span>비고</span><strong>${detailValue(item.notes)}</strong></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="inline-actions">
|
||
<a class="button-link button-secondary button-icon" href="${editUrl}" title="수정" aria-label="수정">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M4 20h4l10-10-4-4L4 16v4z"></path>
|
||
<path d="M13 7l4 4"></path>
|
||
</svg>
|
||
<span class="sr-only">수정</span>
|
||
</a>
|
||
</div>
|
||
</article>
|
||
`;
|
||
}
|
||
|
||
(() => {
|
||
const input = document.getElementById("projectCostSearch");
|
||
const yearSelect = document.getElementById("projectCostYearFilter");
|
||
const contractFilterSelect = document.getElementById("projectContractFilter");
|
||
const dropdownBox = document.getElementById("projectSearchDropdown");
|
||
const listBox = document.getElementById("projectExplorerList");
|
||
const emptyBox = document.getElementById("projectExplorerEmpty");
|
||
const analysisContent = document.getElementById("projectAnalysisContent");
|
||
const analysisToggle = document.getElementById("toggleProjectAnalysis");
|
||
const savePageStateButton = document.getElementById("saveProjectPageState");
|
||
const quickLinkBar = document.getElementById("projectQuickLinkBar");
|
||
const relatedToolbarButton = document.getElementById("openRelatedProjectToolbar");
|
||
const relatedBarBox = document.getElementById("projectRelatedBar");
|
||
const heroBox = document.getElementById("projectAnalysisHero");
|
||
const metricsBox = document.getElementById("projectAnalysisMetrics");
|
||
const comparisonBox = document.getElementById("projectAnalysisComparison");
|
||
const notesBox = document.getElementById("projectAnalysisNotes");
|
||
const uncontractedBox = document.getElementById("projectUncontractedDashboard");
|
||
const relatedModal = document.getElementById("relatedProjectModal");
|
||
const relatedModalCloseButton = document.getElementById("closeRelatedProjectModal");
|
||
const relatedSearchInput = document.getElementById("relatedProjectSearchInput");
|
||
const relatedSearchResults = document.getElementById("relatedProjectSearchResults");
|
||
const relatedSearchEmpty = document.getElementById("relatedProjectSearchEmpty");
|
||
const RELATED_PROJECT_STORAGE_KEY = "project-related-selections-v1";
|
||
const serverEditCode = String(projectEdit?.support_dept_code || "").trim();
|
||
const initialFocusCode = String(serverFocusCode || "").trim();
|
||
let selectedCode = initialFocusCode || serverEditCode;
|
||
let analysisOpen = Boolean(initialFocusCode || serverEditCode);
|
||
let projectQuickLinks = [];
|
||
const relatedProjectSelections = loadRelatedProjectSelections();
|
||
window.__projectRelatedSelections = relatedProjectSelections;
|
||
|
||
function loadRelatedProjectSelections() {
|
||
const merged = new Map();
|
||
const mergeSource = (source) => {
|
||
if (!source || typeof source !== "object") return;
|
||
Object.entries(source).forEach(([code, values]) => {
|
||
if (typeof code !== "string" || !code.trim() || !Array.isArray(values)) return;
|
||
const current = new Set(merged.get(code) || []);
|
||
values.forEach((value) => {
|
||
if (typeof value === "string" && value.trim()) {
|
||
current.add(value.trim());
|
||
}
|
||
});
|
||
merged.set(code, current);
|
||
});
|
||
};
|
||
mergeSource(persistedProjectRelatedLinks);
|
||
mergeSource(persistedProjectPageState?.related_project_selections);
|
||
try {
|
||
const raw = window.localStorage.getItem(RELATED_PROJECT_STORAGE_KEY);
|
||
if (raw) {
|
||
const parsed = JSON.parse(raw);
|
||
mergeSource(parsed);
|
||
}
|
||
} catch (error) {
|
||
console.warn("Failed to load related project selections", error);
|
||
}
|
||
return merged;
|
||
}
|
||
|
||
function syncGlobalRelatedProjectSelections() {
|
||
window.__projectRelatedSelections = relatedProjectSelections;
|
||
}
|
||
|
||
function persistRelatedProjectSelections() {
|
||
try {
|
||
const payload = Object.fromEntries(
|
||
[...relatedProjectSelections.entries()].map(([code, values]) => [code, [...values]]),
|
||
);
|
||
window.localStorage.setItem(RELATED_PROJECT_STORAGE_KEY, JSON.stringify(payload));
|
||
} catch (error) {
|
||
console.warn("Failed to persist related project selections", error);
|
||
}
|
||
}
|
||
|
||
async function saveRelatedProjectSelectionsForCode(baseCode) {
|
||
const normalizedBaseCode = String(baseCode || "").trim();
|
||
if (!normalizedBaseCode) return;
|
||
const relatedCodes = [...(relatedProjectSelections.get(normalizedBaseCode) || new Set())];
|
||
const response = await fetch("/projects/related-links", {
|
||
method: "POST",
|
||
credentials: "same-origin",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"Accept": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
base_code: normalizedBaseCode,
|
||
related_codes: relatedCodes,
|
||
}),
|
||
});
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}`);
|
||
}
|
||
const result = await response.json();
|
||
if (result?.error) {
|
||
throw new Error(result.error);
|
||
}
|
||
}
|
||
|
||
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) {
|
||
return getRelatedCodes(baseCode)
|
||
.map((code) => getAnalysisItem(code))
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function openRelatedProjectPicker() {
|
||
if (!selectedCode) return;
|
||
relatedModal?.classList.add("open");
|
||
relatedSearchInput.value = "";
|
||
refreshRelatedProjectResults();
|
||
relatedSearchInput?.focus();
|
||
}
|
||
|
||
function closeRelatedProjectPicker() {
|
||
relatedModal?.classList.remove("open");
|
||
relatedSearchInput.value = "";
|
||
relatedSearchResults.innerHTML = "";
|
||
relatedSearchEmpty.textContent = "";
|
||
}
|
||
|
||
function 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 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) });
|
||
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에 저장하지 못했습니다.");
|
||
}
|
||
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에 저장하지 못했습니다.");
|
||
}
|
||
renderAnalysis(getAnalysisItem(selectedCode));
|
||
}
|
||
|
||
function refreshRelatedProjectResults() {
|
||
if (!selectedCode) {
|
||
relatedSearchResults.innerHTML = "";
|
||
relatedSearchEmpty.textContent = "먼저 프로젝트를 선택하세요.";
|
||
return;
|
||
}
|
||
const keyword = (relatedSearchInput?.value || "").trim().toLowerCase();
|
||
const alreadySelected = new Set(getRelatedCodes(selectedCode));
|
||
const baseDataset = buildProjectCostDataset("", yearSelect?.value || "");
|
||
const candidates = baseDataset.filter((item) => {
|
||
if (item.support_dept_code === selectedCode) return false;
|
||
if (alreadySelected.has(item.support_dept_code)) return false;
|
||
if (!keyword) return true;
|
||
return `${item.support_dept_code} ${item.support_dept_name}`.toLowerCase().includes(keyword);
|
||
}).slice(0, 20);
|
||
|
||
if (!candidates.length) {
|
||
relatedSearchResults.innerHTML = "";
|
||
relatedSearchEmpty.textContent = keyword ? "검색 결과가 없습니다." : "추가 가능한 연관 프로젝트가 없습니다.";
|
||
return;
|
||
}
|
||
|
||
relatedSearchResults.innerHTML = candidates.map((item) => `
|
||
<div class="related-search-item">
|
||
<div class="related-search-copy">
|
||
<strong>${escapeHtml(item.support_dept_name)}</strong>
|
||
<span>${escapeHtml(item.support_dept_code)} · ${escapeHtml(formatYearRange(item))} · 수익 ${escapeHtml(formatDisplayAmount(item.revenue_amount))} · 지출 ${escapeHtml(formatDisplayAmount(item.expense_amount))}</span>
|
||
</div>
|
||
<button type="button" class="button-icon button-secondary" data-add-related="${escapeHtml(item.support_dept_code)}" title="연관 프로젝트 추가" aria-label="연관 프로젝트 추가">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M12 5v14"></path>
|
||
<path d="M5 12h14"></path>
|
||
</svg>
|
||
<span class="sr-only">연관 프로젝트 추가</span>
|
||
</button>
|
||
</div>
|
||
`).join("");
|
||
relatedSearchEmpty.textContent = "";
|
||
relatedSearchResults.querySelectorAll("[data-add-related]").forEach((button) => {
|
||
button.addEventListener("click", () => addRelatedProject(button.dataset.addRelated || ""));
|
||
});
|
||
}
|
||
|
||
function renderProjectQuickLinks() {
|
||
if (!quickLinkBar) return;
|
||
const items = projectQuickLinks
|
||
.map((code) => {
|
||
const item = getAnalysisItem(code) || projectStatusMap[code] || null;
|
||
if (item) {
|
||
return {
|
||
support_dept_code: code,
|
||
support_dept_name: item.support_dept_name || code,
|
||
};
|
||
}
|
||
return {
|
||
support_dept_code: code,
|
||
support_dept_name: code,
|
||
};
|
||
})
|
||
.slice(0, 6);
|
||
quickLinkBar.innerHTML = items.map((item) => `
|
||
<div class="project-quick-link" data-open-quick-link="${escapeHtml(item.support_dept_code)}" role="button" tabindex="0" title="${escapeHtml(item.support_dept_name)}" aria-label="${escapeHtml(item.support_dept_name)} 바로가기">
|
||
<span>${escapeHtml(item.support_dept_name)}</span>
|
||
<button type="button" class="project-quick-link-remove" data-remove-quick-link="${escapeHtml(item.support_dept_code)}" title="바로가기 제거" aria-label="바로가기 제거">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M6 6l12 12"></path>
|
||
<path d="M18 6L6 18"></path>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
`).join("");
|
||
}
|
||
|
||
async function loadProjectQuickLinksFromServer() {
|
||
try {
|
||
const response = await fetch("/projects/quick-links", {
|
||
method: "GET",
|
||
credentials: "same-origin",
|
||
headers: {
|
||
"Accept": "application/json",
|
||
},
|
||
});
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}`);
|
||
}
|
||
const payload = await response.json();
|
||
if (payload?.error) {
|
||
throw new Error(payload.error);
|
||
}
|
||
projectQuickLinks = Array.isArray(payload?.codes) ? payload.codes.filter(Boolean) : [];
|
||
} catch (error) {
|
||
console.warn("Failed to load project quick links", error);
|
||
}
|
||
renderProjectQuickLinks();
|
||
}
|
||
|
||
async function saveProjectQuickLinksToServer(showFeedback = false) {
|
||
try {
|
||
const response = await fetch("/projects/quick-links", {
|
||
method: "POST",
|
||
credentials: "same-origin",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"Accept": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
session_id: projectPageSessionId,
|
||
codes: projectQuickLinks,
|
||
}),
|
||
});
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}`);
|
||
}
|
||
const payload = await response.json();
|
||
if (payload?.error) {
|
||
throw new Error(payload.error);
|
||
}
|
||
projectQuickLinks = Array.isArray(payload?.codes) ? payload.codes.filter(Boolean) : projectQuickLinks;
|
||
renderProjectQuickLinks();
|
||
if (showFeedback) {
|
||
window.alert("프로젝트 바로가기를 저장했습니다.");
|
||
}
|
||
} catch (error) {
|
||
console.error(error);
|
||
window.alert("프로젝트 바로가기를 저장하지 못했습니다.");
|
||
}
|
||
}
|
||
|
||
async function addProjectQuickLink(code) {
|
||
const normalizedCode = String(code || "").trim();
|
||
if (!normalizedCode) return;
|
||
projectQuickLinks = [normalizedCode, ...projectQuickLinks.filter((item) => item !== normalizedCode)].slice(0, 6);
|
||
renderProjectQuickLinks();
|
||
await saveProjectQuickLinksToServer(true);
|
||
}
|
||
|
||
async function removeProjectQuickLink(code) {
|
||
const normalizedCode = String(code || "").trim();
|
||
if (!normalizedCode) return;
|
||
projectQuickLinks = projectQuickLinks.filter((item) => item !== normalizedCode);
|
||
renderProjectQuickLinks();
|
||
await saveProjectQuickLinksToServer(false);
|
||
}
|
||
|
||
async function saveComparisonNoteToServer(code, itemKey, note) {
|
||
const response = await fetch("/projects/comparison-note", {
|
||
method: "POST",
|
||
credentials: "same-origin",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"Accept": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
support_dept_code: code,
|
||
item_key: itemKey,
|
||
note,
|
||
}),
|
||
});
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok || payload?.error) {
|
||
throw new Error(payload?.error || "비고를 저장하지 못했습니다.");
|
||
}
|
||
projectComparisonNotes[code] = projectComparisonNotes[code] || {};
|
||
projectComparisonNotes[code][itemKey] = note;
|
||
}
|
||
|
||
function renderAnalysis(item) {
|
||
if (!item) {
|
||
relatedBarBox.innerHTML = "";
|
||
heroBox.innerHTML = "";
|
||
metricsBox.innerHTML = "";
|
||
comparisonBox.innerHTML = "";
|
||
notesBox.innerHTML = "";
|
||
scheduleStandaloneUncontractedDashboardRender();
|
||
return;
|
||
}
|
||
const relatedItems = getRelatedItems(item.support_dept_code);
|
||
const aggregateItem = aggregateAnalysisItem(item, relatedItems);
|
||
relatedBarBox.innerHTML = renderRelatedProjectBar(item, relatedItems);
|
||
heroBox.innerHTML = renderAnalysisHero(aggregateItem);
|
||
metricsBox.innerHTML = renderAnalysisMetrics(aggregateItem);
|
||
comparisonBox.innerHTML = renderAnalysisComparison(aggregateItem);
|
||
notesBox.innerHTML = renderAnalysisNotes(aggregateItem);
|
||
heroBox.querySelectorAll("[data-set-quick-link]").forEach((button) => {
|
||
button.addEventListener("click", async () => {
|
||
await addProjectQuickLink(button.dataset.setQuickLink || "");
|
||
});
|
||
});
|
||
|
||
relatedBarBox.querySelectorAll("[data-remove-related]").forEach((button) => {
|
||
button.addEventListener("click", () => removeRelatedProject(button.dataset.removeRelated || ""));
|
||
});
|
||
comparisonBox.querySelectorAll(".comparison-main-row[data-detail-target]").forEach((row) => {
|
||
row.addEventListener("click", () => {
|
||
const key = row.dataset.detailTarget;
|
||
const detailRow = comparisonBox.querySelector(`[data-detail-row="${key}"]`);
|
||
const isOpen = detailRow?.classList.contains("is-open");
|
||
row.classList.toggle("is-open", !isOpen);
|
||
detailRow?.classList.toggle("is-open", !isOpen);
|
||
});
|
||
});
|
||
}
|
||
|
||
window.__projectPageHelpers = {
|
||
upsertProjectStatusRow,
|
||
refreshAnalysisForCode(code) {
|
||
const normalizedCode = String(code || "").trim();
|
||
if (!normalizedCode) return;
|
||
selectedCode = normalizedCode;
|
||
currentSelectedProjectCode = normalizedCode;
|
||
const nextItem = getAnalysisItem(normalizedCode);
|
||
if (nextItem) {
|
||
if (input) {
|
||
input.value = nextItem.support_dept_name || "";
|
||
}
|
||
setAnalysisVisibility(true);
|
||
renderAnalysis(nextItem);
|
||
}
|
||
},
|
||
};
|
||
|
||
function refresh(options = {}) {
|
||
const { renderDetail = false } = options;
|
||
const keyword = input?.value || "";
|
||
const dataset = filterProjectCostDatasetByContractState(
|
||
buildProjectCostDataset(keyword, yearSelect?.value || ""),
|
||
contractFilterSelect?.value || "",
|
||
);
|
||
syncSelection(dataset, {
|
||
renderDetail: renderDetail && !keyword.trim(),
|
||
});
|
||
renderSuggestionList(dataset, keyword);
|
||
}
|
||
|
||
let refreshTimer = null;
|
||
input?.addEventListener("input", () => {
|
||
window.clearTimeout(refreshTimer);
|
||
refreshTimer = window.setTimeout(() => refresh({ renderDetail: false }), 90);
|
||
});
|
||
yearSelect?.addEventListener("change", () => refresh({ renderDetail: true }));
|
||
contractFilterSelect?.addEventListener("change", () => refresh({ renderDetail: true }));
|
||
input?.addEventListener("focus", () => refresh({ renderDetail: false }));
|
||
function renderStandaloneUncontractedDashboard() {
|
||
if (!uncontractedBox) return;
|
||
try {
|
||
uncontractedBox.innerHTML = renderUncontractedDashboard();
|
||
} catch (error) {
|
||
console.error("미계약 비용 발생 현황 렌더 에러", error);
|
||
uncontractedBox.innerHTML = `
|
||
<div class="uncontracted-dashboard">
|
||
<div class="toolbar-head">
|
||
<h3>미계약 비용 발생 현황</h3>
|
||
</div>
|
||
<div class="detail-block">
|
||
<p class="stacked-note">미계약 비용 발생 현황을 불러오는 중 문제가 발생했습니다. 페이지를 새로고침한 뒤 다시 확인해 주세요.</p>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
let uncontractedDashboardRenderToken = null;
|
||
function scheduleStandaloneUncontractedDashboardRender(options = {}) {
|
||
const { urgent = false } = options;
|
||
if (uncontractedDashboardRenderToken) {
|
||
if (typeof uncontractedDashboardRenderToken === "number") {
|
||
window.clearTimeout(uncontractedDashboardRenderToken);
|
||
} else if (typeof window.cancelIdleCallback === "function") {
|
||
window.cancelIdleCallback(uncontractedDashboardRenderToken);
|
||
}
|
||
uncontractedDashboardRenderToken = null;
|
||
}
|
||
const runner = () => {
|
||
uncontractedDashboardRenderToken = null;
|
||
renderStandaloneUncontractedDashboard();
|
||
};
|
||
if (urgent) {
|
||
uncontractedDashboardRenderToken = window.setTimeout(runner, 0);
|
||
return;
|
||
}
|
||
if (typeof window.requestIdleCallback === "function") {
|
||
uncontractedDashboardRenderToken = window.requestIdleCallback(runner, { timeout: 600 });
|
||
return;
|
||
}
|
||
uncontractedDashboardRenderToken = window.setTimeout(runner, 120);
|
||
}
|
||
uncontractedBox?.addEventListener("change", async (event) => {
|
||
const startSelect = event.target.closest("#uncontractedYearStart");
|
||
const endSelect = event.target.closest("#uncontractedYearEnd");
|
||
const classificationSelect = event.target.closest("[data-uncontracted-classification]");
|
||
if (!startSelect && !endSelect && !classificationSelect) return;
|
||
if (classificationSelect) {
|
||
const supportDeptCode = classificationSelect.dataset.uncontractedClassification || "";
|
||
const category = classificationSelect.value || "general";
|
||
try {
|
||
const response = await fetch("/projects/uncontracted-category", {
|
||
method: "POST",
|
||
credentials: "same-origin",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"Accept": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
support_dept_code: supportDeptCode,
|
||
category,
|
||
}),
|
||
});
|
||
const payload = await response.json();
|
||
if (!response.ok || payload?.error) {
|
||
throw new Error(payload?.error || `HTTP ${response.status}`);
|
||
}
|
||
persistedUncontractedCategoryOverrides[supportDeptCode] = category;
|
||
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 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 (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("keydown", (event) => {
|
||
const categoryButton = event.target.closest("[data-uncontracted-category]");
|
||
const excludedItemsButton = event.target.closest("#openExcludedDetailItems");
|
||
if (categoryButton) {
|
||
if (event.key !== "Enter" && event.key !== " ") return;
|
||
event.preventDefault();
|
||
categoryButton.click();
|
||
return;
|
||
}
|
||
if (!excludedItemsButton) return;
|
||
if (event.key !== "Enter" && event.key !== " ") return;
|
||
event.preventDefault();
|
||
excludedItemsButton.click();
|
||
});
|
||
listBox?.addEventListener("click", (event) => {
|
||
const itemEl = event.target.closest(".explorer-item");
|
||
if (!itemEl) return;
|
||
selectedCode = itemEl.dataset.code || "";
|
||
currentSelectedProjectCode = selectedCode;
|
||
input.value = itemEl.querySelector("strong")?.textContent || input.value;
|
||
dropdownBox?.classList.remove("open");
|
||
setAnalysisVisibility(true);
|
||
renderSuggestionList([], "");
|
||
renderAnalysis(getAnalysisItem(selectedCode));
|
||
});
|
||
quickLinkBar?.addEventListener("click", (event) => {
|
||
const removeButton = event.target.closest("[data-remove-quick-link]");
|
||
if (removeButton) {
|
||
event.stopPropagation();
|
||
removeProjectQuickLink(removeButton.dataset.removeQuickLink || "");
|
||
return;
|
||
}
|
||
const quickLinkButton = event.target.closest("[data-open-quick-link]");
|
||
if (!quickLinkButton) return;
|
||
selectedCode = quickLinkButton.dataset.openQuickLink || "";
|
||
currentSelectedProjectCode = selectedCode;
|
||
setAnalysisVisibility(true);
|
||
renderAnalysis(getAnalysisItem(selectedCode));
|
||
});
|
||
quickLinkBar?.addEventListener("keydown", (event) => {
|
||
const quickLinkButton = event.target.closest("[data-open-quick-link]");
|
||
if (!quickLinkButton) return;
|
||
if (event.key !== "Enter" && event.key !== " ") return;
|
||
if (event.target.closest("[data-remove-quick-link]")) return;
|
||
event.preventDefault();
|
||
quickLinkButton.click();
|
||
});
|
||
relatedSearchInput?.addEventListener("input", refreshRelatedProjectResults);
|
||
relatedModalCloseButton?.addEventListener("click", closeRelatedProjectPicker);
|
||
relatedModal?.addEventListener("click", (event) => {
|
||
if (event.target === relatedModal) {
|
||
closeRelatedProjectPicker();
|
||
}
|
||
});
|
||
closeDetailYearSettingModalButton?.addEventListener("click", closeDetailYearSettingModal);
|
||
saveDetailYearSettingButton?.addEventListener("click", saveDetailYearSetting);
|
||
detailYearSettingModal?.addEventListener("click", (event) => {
|
||
if (event.target === detailYearSettingModal) {
|
||
closeDetailYearSettingModal();
|
||
}
|
||
});
|
||
closeExcludedDetailItemsModalButton?.addEventListener("click", closeExcludedDetailItemsModal);
|
||
excludedDetailItemsModal?.addEventListener("click", (event) => {
|
||
if (event.target === excludedDetailItemsModal) {
|
||
closeExcludedDetailItemsModal();
|
||
}
|
||
});
|
||
analysisToggle?.addEventListener("click", () => {
|
||
const nextOpen = !analysisOpen;
|
||
setAnalysisVisibility(nextOpen);
|
||
if (nextOpen && selectedCode) {
|
||
renderAnalysis(getAnalysisItem(selectedCode));
|
||
}
|
||
});
|
||
|
||
savePageStateButton?.addEventListener("click", async () => {
|
||
await saveProjectPageStateToServer(true);
|
||
});
|
||
|
||
relatedToolbarButton?.addEventListener("click", () => {
|
||
openRelatedProjectPicker();
|
||
});
|
||
document.addEventListener("click", (event) => {
|
||
if (!dropdownBox?.contains(event.target) && event.target !== input) {
|
||
dropdownBox?.classList.remove("open");
|
||
}
|
||
});
|
||
uncontractedDetailSelection = { type: "", year: null, month: null };
|
||
uncontractedDetailVisible = false;
|
||
(async () => {
|
||
await loadProjectPageStateFromServer();
|
||
await loadProjectQuickLinksFromServer();
|
||
if (serverEditCode) {
|
||
selectedCode = serverEditCode;
|
||
currentSelectedProjectCode = serverEditCode;
|
||
analysisOpen = true;
|
||
} else if (initialFocusCode) {
|
||
selectedCode = initialFocusCode;
|
||
currentSelectedProjectCode = initialFocusCode;
|
||
analysisOpen = true;
|
||
}
|
||
if (!analysisOpen) {
|
||
// Keep the first view lightweight unless the current browser
|
||
// session explicitly saved an open detail state.
|
||
setAnalysisVisibility(false);
|
||
} else {
|
||
setAnalysisVisibility(true);
|
||
}
|
||
refresh({ renderDetail: false });
|
||
const startupFocusCode = serverEditCode || initialFocusCode;
|
||
if (startupFocusCode) {
|
||
const initialItem = getAnalysisItem(startupFocusCode);
|
||
if (initialItem) {
|
||
if (input) {
|
||
input.value = initialItem.support_dept_name || "";
|
||
}
|
||
setAnalysisVisibility(true);
|
||
renderAnalysis(initialItem);
|
||
}
|
||
}
|
||
scheduleStandaloneUncontractedDashboardRender();
|
||
})();
|
||
if (openModalButton) {
|
||
openModalButton.addEventListener("click", async () => {
|
||
openProjectModalForFreshEntry("");
|
||
});
|
||
}
|
||
|
||
document.addEventListener("click", async (event) => {
|
||
const editButton = event.target.closest("[data-edit-code]");
|
||
if (!editButton) return;
|
||
event.preventDefault();
|
||
const fallbackHref = editButton.getAttribute("href") || "";
|
||
const targetCode = editButton.dataset.editCode || "";
|
||
try {
|
||
await openProjectModalForCode(targetCode);
|
||
} catch (error) {
|
||
console.error(error);
|
||
}
|
||
if (!modal?.classList.contains("open") && fallbackHref) {
|
||
window.location.href = fallbackHref;
|
||
}
|
||
});
|
||
|
||
document.addEventListener("focusout", async (event) => {
|
||
const noteInput = event.target.closest(".comparison-note-input");
|
||
if (!noteInput) return;
|
||
const code = noteInput.dataset.comparisonNoteCode || "";
|
||
const itemKey = noteInput.dataset.comparisonNoteKey || "";
|
||
const note = noteInput.value.trim();
|
||
const previous = (projectComparisonNotes?.[code] || {})[itemKey] || "";
|
||
if (note === previous) return;
|
||
try {
|
||
await saveComparisonNoteToServer(code, itemKey, note);
|
||
} catch (error) {
|
||
console.error(error);
|
||
alert(error.message || "비고를 저장하지 못했습니다.");
|
||
noteInput.value = previous;
|
||
}
|
||
});
|
||
|
||
if (modal?.classList.contains("open") && projectEdit.support_dept_code) {
|
||
modalOriginalEdit = cloneProjectEditData(projectEdit);
|
||
applyProjectEditData(projectEdit);
|
||
}
|
||
|
||
if (closeModalButton) {
|
||
closeModalButton.addEventListener("click", () => {
|
||
closeProjectModal();
|
||
});
|
||
}
|
||
|
||
modal?.addEventListener("click", (event) => {
|
||
if (event.target === modal) {
|
||
closeProjectModal();
|
||
}
|
||
});
|
||
|
||
renderRevenueChart();
|
||
})();
|
||
</script>
|
||
{% endblock %}
|