Files

4725 lines
190 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}프로젝트 정보{% endblock %}
{% block head_extra %}
<style>
.page-hero {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 18px;
flex-wrap: wrap;
}
.hero-copy {
display: grid;
gap: 8px;
}
.hero-copy p {
color: var(--muted);
line-height: 1.7;
max-width: 920px;
}
.top-actions {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.overview-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.overview-title-row {
display: inline-flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.overview-year-select {
min-width: 156px;
}
.metric-grid {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
margin-top: 8px;
}
.overview-row {
display: grid;
grid-template-columns: minmax(230px, 0.23fr) minmax(0, 0.77fr);
gap: 16px;
align-items: stretch;
}
.metric-note-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
margin-top: 14px;
}
.metric-note {
background: rgba(255, 255, 255, 0.92);
border: 1px solid var(--line);
border-radius: 16px;
padding: 14px 16px;
color: var(--muted);
line-height: 1.7;
font-size: 14px;
}
.metric-note strong {
color: var(--ink);
}
.chart-stage {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(246, 247, 249, 0.98)),
radial-gradient(circle at top left, rgba(17, 17, 17, 0.045), transparent 34%);
border: 1px solid var(--line);
border-radius: 16px;
padding: 16px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9);
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 14px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.chart-header p {
color: var(--muted);
line-height: 1.6;
font-size: 14px;
}
.chart-legend {
display: flex;
flex-wrap: wrap;
gap: 10px 14px;
align-items: center;
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 8px;
color: #363b44;
font-size: 13px;
font-weight: 700;
background: rgba(255, 255, 255, 0.9);
border: 1px solid var(--line);
border-radius: 10px;
padding: 6px 10px;
line-height: 1.3;
}
.legend-swatch {
width: 10px;
height: 10px;
border-radius: 999px;
display: inline-block;
}
.chart-svg {
width: 100%;
height: auto;
aspect-ratio: 1120 / 330;
min-height: 290px;
display: block;
}
.chart-note {
margin-top: 10px;
color: var(--muted);
font-size: 13px;
line-height: 1.7;
}
.search-layout {
display: grid;
gap: 16px;
}
.analysis-shell {
display: grid;
gap: 16px;
}
.analysis-toolbar {
display: grid;
gap: 12px;
align-items: start;
}
.analysis-main {
display: grid;
gap: 16px;
}
.analysis-summary-list {
display: none;
}
.toolbar-field {
background: rgba(255, 255, 255, 0.95);
border: 1px solid var(--line);
border-radius: 16px;
padding: 14px;
}
.toolbar-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.toolbar-field h3 {
font-size: 16px;
margin-bottom: 0;
}
.toolbar-head-actions {
display: inline-flex;
align-items: center;
gap: 8px;
}
.toggle-analysis-button svg {
transition: transform 0.16s ease;
}
.toggle-analysis-button.is-open svg {
transform: rotate(180deg);
}
.project-search-controls {
display: grid;
grid-template-columns: minmax(0, 1fr) 170px auto auto;
gap: 12px;
align-items: end;
}
.project-search-field {
position: relative;
display: grid;
gap: 8px;
}
.project-search-dropdown {
display: none;
position: absolute;
top: calc(100% + 8px);
left: 0;
right: 0;
z-index: 12;
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 14px 24px rgba(20, 24, 31, 0.08);
overflow: hidden;
}
.project-search-dropdown.open {
display: grid;
}
.analysis-content {
display: grid;
gap: 16px;
}
.analysis-content.is-hidden {
display: none;
}
.explorer-list {
display: grid;
gap: 0;
max-height: 280px;
overflow: auto;
}
.explorer-item {
display: grid;
gap: 3px;
border: 0;
border-bottom: 1px solid #edf1f4;
border-radius: 0;
padding: 11px 14px;
background: rgba(255, 255, 255, 0.98);
cursor: pointer;
transition: background 0.16s ease, color 0.16s ease;
text-align: left;
}
.explorer-item.active {
background: #f3f8fc;
}
.explorer-head {
display: block;
}
.explorer-title {
display: grid;
gap: 4px;
}
.explorer-title strong {
font-size: 14px;
line-height: 1.45;
}
.explorer-title span {
color: var(--muted);
font-size: 12px;
line-height: 1.5;
}
.explorer-tags {
display: none;
}
.project-search-empty {
padding: 11px 14px;
color: var(--muted);
font-size: 13px;
text-align: left;
}
.mini-tag {
display: inline-flex;
align-items: center;
border: 1px solid #dce5ed;
border-radius: 999px;
padding: 4px 8px;
font-size: 11px;
color: #35526a;
background: #f8fbfe;
white-space: nowrap;
}
.analysis-hero {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 14px;
align-items: stretch;
}
.analysis-summary-card {
background: linear-gradient(180deg, rgba(255,255,255,0.99), rgba(245,248,251,0.98));
border: 1px solid var(--line);
border-radius: 18px;
padding: 18px;
display: grid;
gap: 14px;
}
.analysis-title-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 14px;
}
.analysis-title-block {
display: flex;
align-items: flex-start;
gap: 10px;
min-width: 0;
flex: 1 1 auto;
width: 100%;
}
.analysis-title {
display: grid;
gap: 6px;
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-stat-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.analysis-stat {
border: 1px solid #dfe6ee;
border-radius: 14px;
background: rgba(255,255,255,0.94);
padding: 12px 14px;
display: grid;
gap: 4px;
}
.analysis-stat span {
color: var(--muted);
font-size: 12px;
}
.analysis-stat strong {
font-size: 22px;
line-height: 1.2;
}
.analysis-panel {
background: rgba(255,255,255,0.98);
border: 1px solid var(--line);
border-radius: 18px;
padding: 18px;
display: grid;
gap: 14px;
}
.analysis-panel h4 {
font-size: 16px;
}
.related-trigger-button {
margin-top: 2px;
}
.analysis-related-bar {
display: grid;
gap: 10px;
}
.analysis-related-bar:empty {
display: none;
}
.related-tag-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.related-tag-label {
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.related-project-tag {
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid #d7e2eb;
background: #f7fbfe;
color: #29485f;
border-radius: 999px;
padding: 5px 10px;
font-size: 12px;
font-weight: 700;
line-height: 1.2;
}
.related-project-tag button {
width: 18px;
height: 18px;
min-width: 18px;
padding: 0;
border-radius: 999px;
border: 0;
background: rgba(41, 72, 95, 0.08);
color: #29485f;
box-shadow: none;
}
.related-project-tag button:hover {
transform: none;
background: rgba(41, 72, 95, 0.14);
}
.related-modal-card {
width: min(680px, 100%);
max-height: min(70vh, 760px);
overflow: auto;
background: #ffffff;
border-radius: 16px;
border: 1px solid var(--line);
box-shadow: 0 24px 64px rgba(21, 24, 29, 0.18);
padding: 14px;
display: grid;
gap: 12px;
}
.related-modal-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.related-modal-title {
font-size: 16px;
font-weight: 800;
}
.related-modal-body {
display: grid;
gap: 10px;
}
.related-search-results {
display: grid;
gap: 0;
border: 1px solid #e5ebf0;
border-radius: 14px;
overflow: hidden;
background: #fff;
}
.related-search-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 11px 14px;
border-top: 1px solid #eef2f5;
}
.related-search-item:first-child {
border-top: 0;
}
.related-search-copy {
display: grid;
gap: 4px;
text-align: left;
min-width: 0;
}
.related-search-copy strong {
font-size: 13px;
line-height: 1.45;
}
.related-search-copy span {
font-size: 12px;
color: var(--muted);
line-height: 1.45;
}
.comparison-main-row {
cursor: pointer;
}
.comparison-main-row td:first-child {
font-weight: 700;
}
.comparison-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
}
.comparison-chevron {
width: 16px;
height: 16px;
display: inline-block;
transition: transform 0.16s ease;
}
.comparison-main-row.is-open .comparison-chevron {
transform: rotate(90deg);
}
.comparison-detail-row {
display: none;
background: #fbfcfd;
}
.comparison-detail-row.is-open {
display: table-row;
}
.comparison-detail-cell {
padding: 0;
}
.comparison-detail-box {
padding: 12px 14px 14px;
display: grid;
gap: 10px;
}
.comparison-detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.comparison-detail-group {
border: 1px solid #e5ebf0;
border-radius: 12px;
background: #fff;
overflow: hidden;
}
.comparison-detail-group h5 {
font-size: 12px;
color: #547085;
background: #f5f8fb;
padding: 8px 10px;
}
.comparison-detail-list {
display: grid;
gap: 0;
}
.comparison-detail-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;
}
.comparison-detail-item:first-child {
border-top: 0;
}
.comparison-detail-item span:first-child {
color: var(--muted);
line-height: 1.45;
}
.comparison-detail-item strong {
font-size: 12px;
text-align: right;
}
.analysis-kpis {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 10px;
}
.analysis-kpi {
border: 1px solid #dfe6ee;
border-radius: 14px;
padding: 12px;
display: grid;
gap: 4px;
background: #fbfcfd;
}
.analysis-kpi span {
color: var(--muted);
font-size: 12px;
}
.analysis-kpi strong {
font-size: 18px;
line-height: 1.2;
}
.analysis-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.analysis-table th,
.analysis-table td {
padding: 10px 12px;
border-bottom: 1px solid #e7edf2;
font-size: 13px;
text-align: left;
vertical-align: middle;
word-break: break-word;
}
.analysis-table th {
font-size: 12px;
color: #547085;
background: #f5f8fb;
}
.delta-positive {
color: #15803d;
font-weight: 800;
}
.delta-negative {
color: #c2410c;
font-weight: 800;
}
.analysis-note-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.search-bar {
display: grid;
grid-template-columns: minmax(280px, 1fr) auto;
gap: 12px;
align-items: end;
}
.search-helper {
color: var(--muted);
font-size: 13px;
line-height: 1.7;
}
.search-helper:empty,
.empty-state:empty {
display: none;
}
.result-list {
display: grid;
gap: 10px;
}
.result-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
background: rgba(255, 255, 255, 0.94);
border: 1px solid var(--line);
border-radius: 14px;
padding: 14px 16px;
}
.result-title {
display: grid;
gap: 4px;
}
.result-title strong {
font-size: 15px;
}
.result-title span {
color: var(--muted);
font-size: 13px;
}
.compare-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.compare-card {
position: relative;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(247, 248, 250, 0.96)),
radial-gradient(circle at top right, rgba(17, 17, 17, 0.045), transparent 36%);
border: 1px solid rgba(216, 222, 229, 0.95);
border-radius: 16px;
padding: 16px;
box-shadow: 0 12px 24px rgba(21, 24, 29, 0.05);
}
.compare-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 14px;
}
.compare-head h3 {
font-size: 18px;
line-height: 1.45;
margin-bottom: 4px;
}
.compare-head p {
color: var(--muted);
font-size: 13px;
}
.remove-button {
min-width: 32px;
width: 32px;
height: 32px;
padding: 0;
border-radius: 10px;
background: #e7f0f5;
color: #204257;
box-shadow: none;
}
.remove-button:hover {
background: #d8e7ef;
}
.remove-button svg {
width: 14px;
height: 14px;
stroke: currentColor;
fill: none;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.info-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin-bottom: 14px;
}
.info-chip {
background: rgba(248, 249, 251, 0.98);
border: 1px solid var(--line);
border-radius: 14px;
padding: 12px 14px;
}
.info-chip .label {
color: var(--muted);
font-size: 12px;
margin-bottom: 6px;
}
.info-chip .value {
font-size: 20px;
font-weight: 800;
line-height: 1.2;
word-break: break-word;
}
.detail-stack {
display: grid;
gap: 12px;
}
.detail-block {
background: rgba(255, 255, 255, 0.94);
border: 1px solid var(--line);
border-radius: 16px;
padding: 14px;
}
.detail-block h4 {
font-size: 14px;
margin-bottom: 10px;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 12px;
}
.detail-item {
display: grid;
gap: 4px;
}
.detail-item span {
color: var(--muted);
font-size: 12px;
}
.detail-item strong {
font-size: 15px;
line-height: 1.5;
word-break: break-word;
}
.empty-state {
padding: 28px 20px;
text-align: center;
color: var(--muted);
background: rgba(255, 255, 255, 0.88);
border: 1px dashed #c4ccd5;
border-radius: 16px;
line-height: 1.8;
}
.inline-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
margin-top: 12px;
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(20, 33, 47, 0.5);
display: none;
align-items: center;
justify-content: center;
padding: 20px;
z-index: 50;
}
.modal-backdrop.open {
display: flex;
}
.modal-card {
width: min(1280px, 100%);
max-height: 90vh;
overflow: auto;
background: #ffffff;
border-radius: 14px;
border: 1px solid var(--line);
box-shadow: 0 24px 64px rgba(21, 24, 29, 0.18);
padding: 12px;
}
.modal-form {
display: grid;
gap: 12px;
}
.modal-card label {
font-size: 11px;
}
.modal-card input[type="text"],
.modal-card input[type="number"],
.modal-card input[type="date"],
.modal-card select,
.modal-card textarea {
padding: 6px 8px;
border-radius: 8px;
min-height: 34px;
font-size: 12px;
line-height: 1.2;
}
.modal-card textarea {
min-height: 68px;
}
.form-section-block {
border: 1px solid var(--line);
border-radius: 12px;
padding: 12px;
background: rgba(250, 251, 252, 0.92);
display: grid;
gap: 10px;
}
.form-section-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.form-section-head h3 {
font-size: 14px;
}
.section-help {
color: var(--muted);
font-size: 11px;
line-height: 1.4;
}
.section-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
}
.lookup-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.readonly-output {
min-height: 34px;
border: 1px solid var(--line);
background: #f5f7fa;
border-radius: 8px;
padding: 7px 9px;
display: flex;
align-items: center;
justify-content: flex-start;
text-align: left;
font-weight: 700;
color: var(--ink);
font-size: 12px;
line-height: 1.25;
}
.lookup-wrap {
position: relative;
}
.lookup-results {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
z-index: 5;
border: 1px solid var(--line);
border-radius: 10px;
background: #ffffff;
box-shadow: 0 14px 30px rgba(21, 24, 29, 0.08);
max-height: 240px;
overflow: auto;
display: none;
}
.lookup-results.open {
display: grid;
justify-items: stretch;
align-items: start;
}
.lookup-option {
padding: 8px 10px;
border: none;
background: transparent;
display: grid;
justify-items: start;
align-items: start;
text-align: left;
color: var(--ink);
cursor: pointer;
border-bottom: 1px solid #eef1f4;
white-space: normal;
overflow-wrap: anywhere;
line-height: 1.35;
width: 100%;
}
.lookup-option:last-child {
border-bottom: none;
}
.lookup-option strong,
.lookup-option span {
display: block;
width: 100%;
white-space: normal;
overflow-wrap: anywhere;
text-align: left;
}
.lookup-option strong {
font-size: 11px;
font-weight: 600;
line-height: 1.35;
}
.lookup-option span {
color: var(--muted);
font-size: 10px;
margin-top: 2px;
line-height: 1.35;
}
#supportDeptCodeResults,
#supportDeptNameResults {
text-align: left;
}
#supportDeptCodeResults .lookup-option,
#supportDeptNameResults .lookup-option {
display: block;
width: 100%;
justify-self: stretch;
text-align: left !important;
}
#supportDeptCodeResults .lookup-option strong,
#supportDeptCodeResults .lookup-option span,
#supportDeptNameResults .lookup-option strong,
#supportDeptNameResults .lookup-option span {
width: 100%;
margin: 0;
text-align: left !important;
}
.row-table-wrap {
overflow: visible;
border: 1px solid var(--line);
border-radius: 10px;
background: #ffffff;
position: relative;
z-index: 1;
}
.row-table {
width: 100%;
min-width: 0;
border-collapse: collapse;
table-layout: fixed;
}
.row-table th,
.row-table td {
padding: 6px 7px;
border-bottom: 1px solid #eef1f4;
font-size: 12px;
vertical-align: top;
}
.row-table th {
background: #f7f8fa;
color: #525a66;
font-size: 11px;
font-weight: 700;
}
.row-table td .inline-input {
width: 100%;
}
.row-table td .lookup-wrap {
width: 100%;
}
.row-table .lookup-results {
max-height: 150px;
}
.modal-col-dept,
.modal-col-work,
.modal-col-amount,
.modal-col-remove {
width: auto;
}
.modal-col-dept {
width: 34%;
}
.modal-col-work {
width: 28%;
}
.modal-col-amount {
width: 28%;
}
.modal-col-remove {
width: 10%;
}
.section-columns {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
align-items: start;
}
.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;
}
.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-hero,
.analysis-kpis,
.analysis-note-grid,
.comparison-detail-grid,
.metric-grid,
.metric-note-grid,
.compare-grid,
.search-bar,
.detail-grid,
.info-metrics,
.section-grid,
.lookup-grid,
.section-columns,
.collection-footer,
.collection-entry-grid {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}
{% block content %}
<section class="panel">
<div class="analysis-shell">
<div class="analysis-main">
<section class="analysis-toolbar">
<div class="toolbar-field">
<div class="toolbar-head">
<h3>프로젝트 검색</h3>
<div class="toolbar-head-actions">
<button type="button" id="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>
</div>
</section>
<div class="analysis-content is-hidden" id="projectAnalysisContent">
<section class="analysis-panel analysis-related-bar" id="projectRelatedBar"></section>
<section class="analysis-summary-card" id="projectAnalysisHero"></section>
<section class="analysis-panel" id="projectAnalysisMetrics"></section>
<section class="analysis-panel" id="projectAnalysisComparison"></section>
<section class="analysis-panel" id="projectAnalysisNotes"></section>
</div>
</div>
</div>
</section>
<div class="modal-backdrop {% if project_edit.support_dept_code %}open{% endif %}" id="projectModal">
<div class="modal-card">
<form action="/projects/save" method="post" class="modal-form" id="projectStatusForm" data-collab-form>
<div class="modal-header">
<h2 class="modal-title">사업현황 추가/수정</h2>
<div class="modal-header-actions">
<button type="submit" class="button-icon" title="사업현황 저장" aria-label="사업현황 저장">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M5 12l5 5 9-10"></path>
</svg>
<span class="sr-only">사업현황 저장</span>
</button>
<button type="button" class="close-button button-secondary button-icon" id="closeProjectModal" title="닫기" aria-label="닫기">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="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 class="section-grid">
<div class="field field-full">
<div class="lookup-grid">
<div class="field">
<label for="supportDeptCodeLookup">지원부서코드</label>
<div class="lookup-wrap">
<input id="supportDeptCodeLookup" type="text" value="{{ project_edit.support_dept_code }}" placeholder="코드를 검색하거나 입력하세요." autocomplete="off" required>
<div id="supportDeptCodeResults" class="lookup-results"></div>
</div>
</div>
<div class="field">
<label for="support_dept_name">지원부서명</label>
<div class="lookup-wrap">
<input id="support_dept_name" type="text" name="support_dept_name" value="{{ project_edit.support_dept_name }}" placeholder="부서명을 검색하거나 입력하세요." autocomplete="off" required>
<div id="supportDeptNameResults" class="lookup-results"></div>
</div>
</div>
</div>
</div>
<div class="field">
<label for="project_type">사업 종류</label>
<input id="project_type" type="text" name="project_type" value="{{ project_edit.project_type }}" placeholder="향후 A/S비·판관비 자동 연동 대비">
</div>
<div class="field">
<label for="contract_amount">계약금액</label>
<input id="contract_amount" type="number" step="0.01" name="contract_amount" value="{{ project_edit.contract_amount }}" placeholder="계약금액 입력">
</div>
<div class="field">
<label for="progress_rate_display">진행율(%)</label>
<div id="progress_rate_display" class="readonly-output">{{ "{:.2f}".format(project_edit.progress_rate or 0) }}%</div>
</div>
<div class="field">
<label for="completion_status">종료여부</label>
<input id="completion_status" type="text" name="completion_status" value="{{ project_edit.completion_status }}" placeholder="진행중 / 종료 / 완료">
</div>
<div class="field">
<label for="change_round">차수변경</label>
<input id="change_round" type="text" name="change_round" value="{{ project_edit.change_round }}">
</div>
<div class="field">
<label for="project_start_date">시작일</label>
<input id="project_start_date" class="date-text-input" type="text" inputmode="numeric" maxlength="10" name="project_start_date" value="{{ project_edit.project_start_date }}" placeholder="YYYY-MM-DD">
</div>
<div class="field">
<label for="project_end_date">종료일</label>
<input id="project_end_date" class="date-text-input" type="text" inputmode="numeric" maxlength="10" name="project_end_date" value="{{ project_edit.project_end_date }}" placeholder="YYYY-MM-DD">
</div>
</div>
</section>
<section class="form-section-block">
<div class="form-section-head">
<h3>수금 정보</h3>
<div class="table-tools">
<div class="actions">
<div class="section-chip">계약금액 <strong id="collectionContractInlineDisplay">{{ "{:,.0f}".format(project_edit.contract_amount or 0) }}원</strong></div>
</div>
<button type="button" class="button-link button-secondary mini-button button-icon" data-add-row="collection" title="수금 행 추가" aria-label="수금 행 추가">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 5v14"></path>
<path d="M5 12h14"></path>
</svg>
<span class="sr-only">수금 행 추가</span>
</button>
</div>
</div>
<div class="collection-entry-list" id="collectionRows">
{% for row in project_edit.collection_entries %}
<div class="collection-entry-card" data-collection-row>
<div class="collection-entry-grid">
<div class="collection-field">
<label>기성구분</label>
<input class="inline-input" type="text" name="collection_progress_type[]" value="{{ row.progress_type }}">
</div>
<div class="collection-field">
<label>기성차수</label>
<input class="inline-input round-input" type="number" min="1" step="1" inputmode="numeric" name="collection_billing_round[]" value="{{ row.billing_round or loop.index }}">
</div>
<div class="collection-field">
<label>청구구분</label>
<input class="inline-input" type="text" name="collection_billing_type[]" value="{{ row.billing_type }}">
</div>
<div class="collection-field">
<label>청구일자</label>
<input class="inline-input date-text-input" type="text" inputmode="numeric" maxlength="10" name="collection_billing_date[]" value="{{ row.billing_date }}" placeholder="YYYY-MM-DD">
</div>
<div class="collection-field amount-field">
<label>청구금액</label>
<input class="inline-input collection-billed-amount amount-input formatted-amount-input" type="text" inputmode="numeric" name="collection_billed_amount[]" value="{{ "{:,.0f}".format(row.billed_amount or 0) if row.billed_amount else '' }}">
</div>
<div class="collection-field">
<label>수금차수</label>
<input class="inline-input round-input" type="number" min="1" step="1" inputmode="numeric" name="collection_round[]" value="{{ row.round or loop.index }}">
</div>
<div class="collection-field">
<label>수금일자</label>
<input class="inline-input date-text-input" type="text" inputmode="numeric" maxlength="10" name="collection_date[]" value="{{ row.date }}" placeholder="YYYY-MM-DD">
</div>
<div class="collection-field amount-field">
<label>수금금액</label>
<input class="inline-input collection-amount amount-input formatted-amount-input" type="text" inputmode="numeric" name="collection_amount_row[]" value="{{ "{:,.0f}".format(row.amount or 0) if row.amount else '' }}">
</div>
<div class="collection-field balance-field">
<label>수금잔액</label>
<div class="readonly-output collection-balance-cell"></div>
</div>
</div>
<div class="actions" style="justify-content: flex-end;">
<button type="button" class="button-link danger-lite mini-button button-icon row-remove-button" data-remove-row title="행 삭제" aria-label="행 삭제">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 12h12"></path>
</svg>
<span class="sr-only">행 삭제</span>
</button>
</div>
</div>
{% endfor %}
</div>
<div class="collection-footer">
<div class="summary-chip">
<span>계약금액</span>
<strong id="collectionContractDisplay">{{ "{:,.0f}".format(project_edit.contract_amount or 0) }}원</strong>
</div>
<div class="summary-chip">
<span>청구금액 합계</span>
<strong id="collectionBilledTotalDisplay">0원</strong>
</div>
<div class="summary-chip">
<span>수금금액 합계</span>
<strong id="collectionTotalDisplay">{{ "{:,.0f}".format(project_edit.collection_amount or 0) }}원</strong>
</div>
<div class="summary-chip">
<span>수금잔액</span>
<strong id="collectionBalanceTotalDisplay">0원</strong>
</div>
</div>
</section>
<section class="form-section-block">
<div class="form-section-head">
<h3>과업수행계획</h3>
</div>
<div class="table-total">계획 합계: <span id="taskPlanTotalDisplay">0</span></div>
<div class="section-columns">
<section class="mini-sector">
<div class="table-tools">
<h4>부서별배분</h4>
<button type="button" class="button-link button-secondary mini-button 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 rate in [0, 2, 5, 10] %}
<option value="{{ rate }}" {% if project_edit.expected_as_rate|string == rate|string %}selected{% endif %}>{{ rate }}%</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="expected_as_cost_display">예상 A/S비 금액</label>
<div id="expected_as_cost_display" class="readonly-output">{{ "{:,.0f}".format(project_edit.expected_as_cost or 0) }}원</div>
</div>
<div class="field">
<label for="expected_sga_rate">예상 판관비 비율</label>
<select id="expected_sga_rate" name="expected_sga_rate">
{% for rate in [13, 20, 25] %}
<option value="{{ rate }}" {% if project_edit.expected_sga_rate|string == rate|string %}selected{% endif %}>{{ rate }}%</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="expected_sga_budget_display">예상 판관비 금액</label>
<div id="expected_sga_budget_display" class="readonly-output">{{ "{:,.0f}".format(project_edit.expected_sga_budget or 0) }}원</div>
</div>
</div>
</section>
<section class="form-section-block">
<div class="form-section-head">
<h3>비고</h3>
</div>
<div class="field field-full">
<label for="notes">메모</label>
<textarea id="notes" name="notes">{{ project_edit.notes }}</textarea>
</div>
</section>
</form>
</div>
</div>
<div class="modal-backdrop" id="laborRateModal">
<div class="related-modal-card" style="max-width:720px;">
<div class="related-modal-head">
<h3 class="related-modal-title">기준인건비</h3>
<button type="button" class="button-secondary button-icon" id="closeLaborRateModal" title="닫기" aria-label="닫기">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 6l12 12"></path>
<path d="M18 6L6 18"></path>
</svg>
<span class="sr-only">닫기</span>
</button>
</div>
<div class="related-modal-body">
<div class="row-table-wrap">
<table class="row-table compact">
<colgroup>
<col style="width:40%">
<col style="width:60%">
</colgroup>
<thead>
<tr>
<th>직급</th>
<th>기준인건비</th>
</tr>
</thead>
<tbody id="laborRateRows"></tbody>
</table>
</div>
</div>
</div>
</div>
<div class="modal-backdrop" id="relatedProjectModal">
<div class="related-modal-card">
<div class="related-modal-head">
<h3 class="related-modal-title">연관 프로젝트 선택</h3>
<button type="button" class="button-secondary button-icon" id="closeRelatedProjectModal" title="닫기" aria-label="닫기">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 6l12 12"></path>
<path d="M18 6L6 18"></path>
</svg>
<span class="sr-only">닫기</span>
</button>
</div>
<div class="related-modal-body">
<input id="relatedProjectSearchInput" type="text" placeholder="지원부서코드 또는 사업명을 검색하세요." autocomplete="off">
<div class="related-search-results" id="relatedProjectSearchResults"></div>
<div class="project-search-empty" id="relatedProjectSearchEmpty"></div>
</div>
</div>
</div>
{% endblock %}
{% block script %}
<script>
const selectedYear = {{ selected_year or "null" }};
const yearFilter = document.getElementById("year");
const supportDepartmentOptions = {{ support_department_options | tojson }};
const costDepartmentOptions = {{ cost_department_options | tojson }};
const costAccountOptions = {{ cost_account_options | tojson }};
const laborGradeOptions = ["사장", "부사장", "전무", "상무", "이사", "부장", "차장", "과장", "대리", "사원", "수석", "책임", "선임", "연구원"];
let projectEdit = {{ project_edit | tojson }};
const projectEditCache = new Map();
let modalOriginalEdit = null;
const revenueMix = {{ project_revenue_mix | tojson }};
const revenueMixMode = {{ ("monthly" if selected_year else "yearly") | tojson }};
const projectCostRows = {{ project_cost_by_year | tojson }};
const projectStatusRows = {{ project_status_rows | tojson }};
const persistedProjectPageState = {{ project_page_state | tojson }};
const projectStatusMap = Object.fromEntries(projectStatusRows.map((item) => [item.support_dept_code, item]));
const projectAccountBreakdowns = {{ project_account_breakdowns | tojson }};
const projectCostMap = Object.fromEntries(projectCostRows.map((item) => [item.support_dept_code, item]));
const currencyFormatter = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 0 });
let currentSelectedProjectCode = projectEdit.support_dept_code || "";
const supportDeptCodeLookup = document.getElementById("supportDeptCodeLookup");
const supportDeptCodeInput = document.getElementById("support_dept_code");
const supportDeptNameInput = document.getElementById("support_dept_name");
const projectTypeInput = document.getElementById("project_type");
const completionStatusInput = document.getElementById("completion_status");
const changeRoundInput = document.getElementById("change_round");
const projectStartDateInput = document.getElementById("project_start_date");
const projectEndDateInput = document.getElementById("project_end_date");
const notesInput = document.getElementById("notes");
const supportDeptCodeResults = document.getElementById("supportDeptCodeResults");
const supportDeptNameResults = document.getElementById("supportDeptNameResults");
const contractAmountInput = document.getElementById("contract_amount");
const expectedAsRateInput = document.getElementById("expected_as_rate");
const expectedSgaRateInput = document.getElementById("expected_sga_rate");
const projectStatusForm = document.getElementById("projectStatusForm");
const editRevisionInput = projectStatusForm?.querySelector('input[name="edit_revision"]');
const collectionRows = document.getElementById("collectionRows");
const taskPlanDepartmentRows = document.getElementById("taskPlanDepartmentRows");
const taskPlanOutsourceRows = document.getElementById("taskPlanOutsourceRows");
const taskPlanJointRows = document.getElementById("taskPlanJointRows");
const execLaborRows = document.getElementById("execLaborRows");
const execOutsourceRows = document.getElementById("execOutsourceRows");
const execCostPlanRows = document.getElementById("execCostPlanRows");
const 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");
let currentLaborRates = {};
yearFilter?.addEventListener("change", (event) => {
const nextYear = event.target.value;
const nextUrl = nextYear ? `/projects?year=${encodeURIComponent(nextYear)}` : "/projects";
window.location.href = nextUrl;
});
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function formatNumber(value) {
return currencyFormatter.format(value || 0);
}
function formatDisplayAmount(value) {
const numeric = Number(value || 0);
if (!numeric) return "0";
if (Math.abs(numeric) >= 100000000) {
return `${(numeric / 100000000).toFixed(1).replace(/\.0$/, "")}억`;
}
if (Math.abs(numeric) >= 1000000) {
return `${(numeric / 1000000).toFixed(1).replace(/\.0$/, "")}백만`;
}
if (Math.abs(numeric) >= 1000) {
return `${(numeric / 1000).toFixed(1).replace(/\.0$/, "")}천`;
}
return formatNumber(numeric);
}
function formatCompactCurrency(value) {
const numeric = Number(value || 0);
if (!numeric) return "0원";
if (Math.abs(numeric) >= 100000000) {
return `${(numeric / 100000000).toFixed(1).replace(/\.0$/, "")}억원`;
}
if (Math.abs(numeric) >= 1000000) {
return `${(numeric / 1000000).toFixed(1).replace(/\.0$/, "")}백만원`;
}
return `${formatNumber(numeric)}원`;
}
function formatAxisLabel(value) {
const numeric = Number(value || 0);
if (!numeric) return "0";
if (numeric >= 100000000) {
return `${numeric / 100000000}억`;
}
if (numeric >= 1000000) {
return `${numeric / 1000000}백만`;
}
if (numeric >= 1000) {
return `${numeric / 1000}천`;
}
return formatNumber(numeric);
}
function pickTickStep(maxValue) {
const baseUnit = maxValue < 1000000 ? 1000 : 1000000;
const units = [1, 2, 5];
const raw = Math.max(maxValue / 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)));
}
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(2)}%`;
const taskPlanTotal = getAllTaskPlanAmountInputs()
.reduce((sum, input) => sum + parseAmount(input.value), 0);
const execBudgetTotal = getAllExecBudgetAmountInputs()
.reduce((sum, input) => sum + parseAmount(input.value), 0);
const actualInputTotal = 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);
const expectedAsCost = contractAmount * parseAmount(expectedAsRateInput?.value) / 100;
const expectedSgaBudget = contractAmount * parseAmount(expectedSgaRateInput?.value) / 100;
document.getElementById("expected_as_cost_display").textContent = `${formatNumber(expectedAsCost)}원`;
document.getElementById("expected_sga_budget_display").textContent = `${formatNumber(expectedSgaBudget)}원`;
}
function createCollectionRow(row = {}) {
const billingRound = row.billing_round || getNextRoundValue("collection_billing_round[]");
const collectionRound = row.round || getNextRoundValue("collection_round[]");
return `
<div class="collection-entry-card" data-collection-row>
<div class="collection-entry-grid">
<div class="collection-field">
<label>기성구분</label>
<input class="inline-input" type="text" name="collection_progress_type[]" value="${escapeHtml(row.progress_type || "")}">
</div>
<div class="collection-field">
<label>기성차수</label>
<input class="inline-input round-input" type="number" min="1" step="1" inputmode="numeric" name="collection_billing_round[]" value="${escapeHtml(billingRound)}">
</div>
<div class="collection-field">
<label>청구구분</label>
<input class="inline-input" type="text" name="collection_billing_type[]" value="${escapeHtml(row.billing_type || "")}">
</div>
<div class="collection-field">
<label>청구일자</label>
<input class="inline-input date-text-input" type="text" inputmode="numeric" maxlength="10" name="collection_billing_date[]" value="${escapeHtml(row.billing_date || "")}" placeholder="YYYY-MM-DD">
</div>
<div class="collection-field amount-field">
<label>청구금액</label>
<input class="inline-input collection-billed-amount amount-input formatted-amount-input" data-billed-amount-input type="text" inputmode="numeric" name="collection_billed_amount[]" value="${escapeHtml(formatAmountInputValue(row.billed_amount || ""))}">
</div>
<div class="collection-field">
<label>수금차수</label>
<input class="inline-input round-input" type="number" min="1" step="1" inputmode="numeric" name="collection_round[]" value="${escapeHtml(collectionRound)}">
</div>
<div class="collection-field">
<label>수금일자</label>
<input class="inline-input date-text-input" type="text" inputmode="numeric" maxlength="10" name="collection_date[]" value="${escapeHtml(row.date || "")}" placeholder="YYYY-MM-DD">
</div>
<div class="collection-field amount-field">
<label>수금금액</label>
<input class="inline-input collection-amount amount-input formatted-amount-input" data-amount-input type="text" inputmode="numeric" name="collection_amount_row[]" value="${escapeHtml(formatAmountInputValue(row.amount || ""))}">
</div>
<div class="collection-field balance-field">
<label>수금잔액</label>
<div class="readonly-output collection-balance-cell"></div>
</div>
</div>
<div class="actions" style="justify-content: flex-end;">
<button type="button" class="button-link danger-lite mini-button button-icon row-remove-button" data-remove-row title="행 삭제" aria-label="행 삭제">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 12h12"></path>
</svg>
<span class="sr-only">행 삭제</span>
</button>
</div>
</div>
`;
}
function createTaskPlanRow(group, row = {}) {
const fieldMap = {
department: ["task_plan_department_dept[]", "task_plan_department_work[]", "task_plan_department_amount[]"],
outsource: ["task_plan_outsource_dept[]", "task_plan_outsource_work[]", "task_plan_outsource_amount[]"],
joint: ["task_plan_joint_dept[]", "task_plan_joint_work[]", "task_plan_joint_amount[]"],
};
const [deptName, workName, amountName] = fieldMap[group];
const deptValue = row.dept_name || row.label || "";
const workValue = row.work_name || row.note || "";
const deptField = `
<div class="lookup-wrap">
<input class="inline-input cost-dept-lookup" type="text" name="${deptName}" value="${escapeHtml(deptValue)}" autocomplete="off">
<div class="lookup-results cost-dept-results"></div>
</div>
`;
const workField = `<input class="inline-input" type="text" name="${workName}" value="${escapeHtml(workValue)}">`;
return `
<tr data-group="${group}">
<td>${deptField}</td>
<td>${workField}</td>
<td><input class="inline-input amount-input formatted-amount-input" data-amount-input type="text" inputmode="numeric" name="${amountName}" value="${escapeHtml(formatAmountInputValue(row.amount || ""))}"></td>
<td><button type="button" class="button-link danger-lite mini-button button-icon row-remove-button" data-remove-row title="행 삭제" aria-label="행 삭제">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 12h12"></path>
</svg>
<span class="sr-only">행 삭제</span>
</button></td>
</tr>
`;
}
function createExecBudgetRow(group, row = {}) {
if (group === "labor") {
const selectedGrade = row.grade || row.dept_name || "";
const inputHours = row.hours || row.work_name || "";
const amountValue = row.amount || calculateLaborAmount(selectedGrade, inputHours);
return `
<tr data-group="${group}">
<td>
<select class="inline-input exec-labor-grade" name="exec_labor_grade[]">
<option value="">직급 선택</option>
${laborGradeOptions.map((grade) => `<option value="${escapeHtml(grade)}" ${selectedGrade === grade ? "selected" : ""}>${escapeHtml(grade)}</option>`).join("")}
</select>
</td>
<td><input class="inline-input exec-labor-hours" type="text" inputmode="decimal" name="exec_labor_hours[]" value="${escapeHtml(formatHourInputValue(inputHours))}"></td>
<td><input class="inline-input amount-input formatted-amount-input exec-labor-amount" data-amount-input type="text" inputmode="numeric" name="exec_labor_amount[]" value="${escapeHtml(formatAmountInputValue(amountValue))}" readonly></td>
<td><button type="button" class="button-link danger-lite mini-button button-icon row-remove-button" data-remove-row title="행 삭제" aria-label="행 삭제">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 12h12"></path>
</svg>
<span class="sr-only">행 삭제</span>
</button></td>
</tr>
`;
}
const fieldMap = {
outsource: ["exec_outsource_dept[]", "exec_outsource_work[]", "exec_outsource_amount[]"],
cost_plan: ["exec_cost_plan_code[]", "exec_cost_plan_name[]", "exec_cost_plan_amount[]"],
};
const [firstName, secondName, amountName] = fieldMap[group];
const firstValue = row.dept_name || row.account_code || row.label || "";
const secondValue = row.work_name || row.account_name || row.note || "";
return `
<tr data-group="${group}">
<td>
${group === "cost_plan" ? `
<div class="lookup-wrap">
<input class="inline-input cost-account-code-lookup" type="text" name="${firstName}" value="${escapeHtml(firstValue)}" autocomplete="off">
<div class="lookup-results cost-account-results"></div>
</div>
` : `
<div class="lookup-wrap">
<input class="inline-input cost-dept-lookup" type="text" name="${firstName}" value="${escapeHtml(firstValue)}" autocomplete="off">
<div class="lookup-results cost-dept-results"></div>
</div>
`}
</td>
<td>
${group === "cost_plan" ? `
<div class="lookup-wrap">
<input class="inline-input cost-account-name-lookup" type="text" name="${secondName}" value="${escapeHtml(secondValue)}" autocomplete="off">
<div class="lookup-results cost-account-results"></div>
</div>
` : `<input class="inline-input" type="text" name="${secondName}" value="${escapeHtml(secondValue)}">`}
</td>
<td><input class="inline-input amount-input formatted-amount-input" data-amount-input type="text" inputmode="numeric" name="${amountName}" value="${escapeHtml(formatAmountInputValue(row.amount || ""))}"></td>
<td><button type="button" class="button-link danger-lite mini-button button-icon row-remove-button" data-remove-row title="행 삭제" aria-label="행 삭제">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 12h12"></path>
</svg>
<span class="sr-only">행 삭제</span>
</button></td>
</tr>
`;
}
function 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 || "";
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 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 === 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: "0",
expected_sga_rate: "13",
expected_as_cost: "",
expected_sga_budget: "",
project_start_date: "",
project_end_date: "",
completion_status: "",
notes: "",
updated_at: "",
};
}
function cloneProjectEditData(data) {
return JSON.parse(JSON.stringify(data || createEmptyProjectEdit()));
}
function clearDynamicRows() {
collectionRows.innerHTML = "";
taskPlanDepartmentRows.innerHTML = "";
taskPlanOutsourceRows.innerHTML = "";
taskPlanJointRows.innerHTML = "";
execLaborRows.innerHTML = "";
execOutsourceRows.innerHTML = "";
execCostPlanRows.innerHTML = "";
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 || "";
expectedAsRateInput.value = String(normalized.expected_as_rate || "0");
expectedSgaRateInput.value = String(normalized.expected_sga_rate || "13");
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") addRow("actual-as", row);
else if (row.group === "sga") addRow("actual-sga", row);
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");
if (!actualSgaRows.children.length) addRow("actual-sga");
bindDateTextInputs(document);
recalculateExecLaborAmounts();
recalculateActualLaborAmounts();
updateComputedFields();
}
async function openProjectModalForCode(code = "") {
const normalizedCode = String(code || "").trim();
if (!normalizedCode) {
openProjectModalForFreshEntry(currentSelectedProjectCode || "");
return;
}
if (projectEditCache.has(normalizedCode)) {
const cachedData = cloneProjectEditData(projectEditCache.get(normalizedCode));
modalOriginalEdit = cloneProjectEditData(cachedData);
applyProjectEditData(cachedData);
modal?.classList.add("open");
return;
}
try {
const response = await fetch(`/projects/edit-data?code=${encodeURIComponent(normalizedCode)}`, {
credentials: "same-origin",
cache: "no-store",
headers: { "Accept": "application/json" },
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = await response.json();
if (payload?.error) {
throw new Error(payload.error);
}
modalOriginalEdit = cloneProjectEditData(payload);
applyProjectEditData(payload);
modal?.classList.add("open");
} catch (error) {
console.error(error);
window.alert("사업현황 데이터를 불러오지 못했습니다.");
}
}
function openProjectModalForFreshEntry(code = "") {
const normalizedCode = String(code || "").trim();
const selectedItem = supportDepartmentOptions.find((item) => item.support_dept_code === normalizedCode);
const emptyData = createEmptyProjectEdit(normalizedCode, selectedItem?.support_dept_name || "");
modalOriginalEdit = cloneProjectEditData(emptyData);
applyProjectEditData(emptyData);
modal?.classList.add("open");
}
bindSupportLookup(supportDeptCodeLookup, supportDeptCodeResults);
bindSupportLookup(supportDeptNameInput, supportDeptNameResults);
bindDateTextInputs(document);
projectStatusForm?.addEventListener("input", (event) => {
const lookupInput = event.target.closest(".cost-dept-lookup");
if (lookupInput) {
const resultBox = lookupInput.parentElement?.querySelector(".cost-dept-results");
renderCostDeptResults(lookupInput.value || "", resultBox, lookupInput);
return;
}
const accountCodeInput = event.target.closest(".cost-account-code-lookup");
if (accountCodeInput) {
const row = accountCodeInput.closest("tr");
const resultBox = accountCodeInput.parentElement?.querySelector(".cost-account-results");
renderCostAccountResults(accountCodeInput.value || "", resultBox, row, "code");
return;
}
const accountNameInput = event.target.closest(".cost-account-name-lookup");
if (accountNameInput) {
const row = accountNameInput.closest("tr");
const resultBox = accountNameInput.parentElement?.querySelector(".cost-account-results");
renderCostAccountResults(accountNameInput.value || "", resultBox, row, "name");
}
});
projectStatusForm?.addEventListener("focusout", (event) => {
const lookupInput = event.target.closest(".cost-dept-lookup");
if (lookupInput) {
const resultBox = lookupInput.parentElement?.querySelector(".cost-dept-results");
setTimeout(() => resultBox?.classList.remove("open"), 120);
return;
}
const accountLookup = event.target.closest(".cost-account-code-lookup, .cost-account-name-lookup");
if (accountLookup) {
const resultBox = accountLookup.parentElement?.querySelector(".cost-account-results");
setTimeout(() => resultBox?.classList.remove("open"), 120);
}
});
openLaborRateModalButton?.addEventListener("click", openLaborRateModal);
closeLaborRateModalButton?.addEventListener("click", closeLaborRateModal);
laborRateModal?.addEventListener("click", (event) => {
if (event.target === laborRateModal) {
closeLaborRateModal();
}
});
contractAmountInput?.addEventListener("input", updateComputedFields);
expectedAsRateInput?.addEventListener("change", updateComputedFields);
expectedSgaRateInput?.addEventListener("change", updateComputedFields);
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);
document.getElementById("projectStatusForm")?.addEventListener("submit", () => {
resolveSupportDepartmentFromLookup();
});
function renderRevenueLegend(items) {
const legend = document.getElementById("revenueLegend");
if (!legend) return;
legend.innerHTML = items.map((item) => `
<span class="legend-item">
<span class="legend-swatch" style="background:${item.color}"></span>
${item.label}
</span>
`).join("");
}
function renderRevenueChart() {
const svg = document.getElementById("revenueMixChart");
if (!svg) return;
const width = 1200;
const height = 390;
const margin = { top: 70, right: 10, bottom: 64, left: 64 };
const isMonthlyView = revenueMixMode === "monthly";
const items = [
{ key: "design_revenue", label: "설계", color: "#4f7cff" },
{ key: "design_other_revenue", label: "설계 외", color: "#67c7c9" },
{ key: "supervision_revenue", label: "감리", color: "#233a5a" },
{ key: "inspection_revenue", label: "점검", color: "#ffb54a" },
];
renderRevenueLegend(items);
const rows = revenueMix || [];
const values = rows.flatMap((row) => items.map((item) => Number(row[item.key] || 0)));
const maxValue = Math.max(...values, 1);
const { tickStep, tickMax } = buildPositiveAxis(maxValue, 4);
const plotWidth = width - margin.left - margin.right;
const plotHeight = height - margin.top - margin.bottom;
const groupWidth = plotWidth / Math.max(rows.length, 1);
const barWidth = Math.min(groupWidth / Math.max(items.length + (isMonthlyView ? 0.9 : 0.7), 1), isMonthlyView ? 24 : 34);
const axisBaseY = margin.top + plotHeight;
let markup = `
<defs>
<linearGradient id="chartPanelBg" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.88" />
<stop offset="100%" stop-color="#f2f7fa" stop-opacity="0.96" />
</linearGradient>
<filter id="barShadow" x="-20%" y="-20%" width="140%" height="160%">
<feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="rgba(44, 68, 89, 0.14)" />
</filter>
</defs>
<rect x="0" y="0" width="${width}" height="${height}" rx="8" fill="url(#chartPanelBg)"></rect>
<rect x="${margin.left}" y="${margin.top}" width="${plotWidth}" height="${plotHeight}" rx="4" fill="rgba(255,255,255,0.66)"></rect>
`;
for (let value = 0; value <= tickMax; value += tickStep) {
const y = margin.top + plotHeight - (value / tickMax) * plotHeight;
markup += `<line x1="${margin.left}" y1="${y}" x2="${width - margin.right}" y2="${y}" stroke="#d6e2e8" stroke-dasharray="4 7"></line>`;
markup += `<text x="${margin.left - 10}" y="${y + 5}" text-anchor="end" fill="#62717f" font-size="12">${formatAxisLabel(value)}</text>`;
}
markup += `<line x1="${margin.left}" y1="${axisBaseY}" x2="${width - margin.right}" y2="${axisBaseY}" stroke="#8ba0ae" stroke-width="1.2"></line>`;
rows.forEach((row, rowIndex) => {
const baseX = margin.left + rowIndex * groupWidth;
items.forEach((item, itemIndex) => {
const value = Number(row[item.key] || 0);
const drawWidth = Math.max(barWidth - 5, 12);
const x = baseX + 8 + itemIndex * barWidth;
const barHeight = tickMax ? (value / tickMax) * plotHeight : 0;
const y = margin.top + plotHeight - barHeight;
markup += `<rect x="${x}" y="${y}" width="${drawWidth}" height="${barHeight}" rx="3" fill="${item.color}" filter="url(#barShadow)"></rect>`;
if (!isMonthlyView || barHeight > 26) {
markup += `<text x="${x + drawWidth / 2}" y="${Math.max(y - 8, 18)}" text-anchor="middle" fill="#344b5e" font-size="${isMonthlyView ? 9.5 : 10.5}" font-weight="800">${formatDisplayAmount(value)}</text>`;
}
});
markup += `<text x="${baseX + groupWidth / 2}" y="${height - 26}" text-anchor="middle" fill="#425464" font-size="${isMonthlyView ? 12 : 13}" font-weight="700">${row.label || row.year}</text>`;
});
svg.innerHTML = markup;
}
function buildSearchResults(keyword, dataset, renderItem) {
if (!keyword) return [];
const normalized = keyword.trim().toLowerCase();
if (!normalized) return [];
return dataset
.filter((item) => `${item.support_dept_code} ${item.support_dept_name}`.toLowerCase().includes(normalized))
.slice(0, 8)
.map(renderItem)
.join("");
}
function buildProjectCostDataset(keyword, yearFilterValue) {
const normalized = (keyword || "").trim().toLowerCase();
const baseDataset = getAggregatedProjectCostDataset(yearFilterValue);
if (!normalized) {
return baseDataset;
}
return baseDataset.filter((item) =>
`${item.support_dept_code} ${item.support_dept_name}`.toLowerCase().includes(normalized)
);
}
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 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 renderLaborActualDetail(detail) {
return `
<div class="comparison-detail-split">
<section class="comparison-detail-group">
<h5>인건비(ERP)</h5>
<div class="comparison-detail-list">
${renderComparisonDetailList(detail.actual || [], "연결된 실제 인건비가 없습니다.")}
</div>
</section>
<section class="comparison-detail-group">
<h5>인건비(합사)</h5>
<div class="comparison-detail-list">
${renderComparisonDetailList(detail.actual_joint || [], "연결된 합사 인건비가 없습니다.")}
</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-title-row">
<div class="analysis-title-block">
<div class="analysis-title">
<h3>${item.support_dept_name}</h3>
<p>${item.support_dept_code} · ${item.year_range} · 최신 반영 ${detailValue(item.latest_year, "-")}${detailValue(item.latest_month, "-")}월</p>
<div class="analysis-stat-grid">
<div class="analysis-stat">
<span>진행율</span>
<strong>${Number(item.progress_rate || 0).toFixed(2)}%</strong>
</div>
<div class="analysis-stat">
<span>종료여부</span>
<strong>${detailValue(item.completion_status)}</strong>
</div>
<div class="analysis-stat">
<span>시작일</span>
<strong>${detailValue(item.project_start_date)}</strong>
</div>
<div class="analysis-stat">
<span>종료일</span>
<strong>${detailValue(item.project_end_date)}</strong>
</div>
</div>
</div>
</div>
<div class="actions">
<a class="button-link button-secondary button-icon" href="${editUrl}" data-edit-code="${item.support_dept_code}" title="수정" aria-label="수정">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 20h4l10-10-4-4L4 16v4z"></path>
<path d="M13 7l4 4"></path>
</svg>
<span class="sr-only">수정</span>
</a>
</div>
</div>
</div>
`;
}
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 =
sumEntryAmounts(details.labor.actual)
+ sumEntryAmounts(details.labor.actual_joint)
+ sumEntryAmounts(details.outsource.actual)
+ sumEntryAmounts(details.overhead.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 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>
</tr>
</thead>
<tbody>
${rows.map(([label, planned, actual, key]) => {
const detail = key ? details[key] : null;
const hasDetail = Boolean(detail);
return `
<tr class="comparison-main-row${hasDetail ? "" : " no-detail"}" ${hasDetail ? `data-detail-target="${key}"` : ""}>
<td>
${hasDetail ? `
<span class="comparison-toggle">
<svg class="comparison-chevron" viewBox="0 0 24 24" aria-hidden="true">
<path d="M9 6l6 6-6 6"></path>
</svg>
${label}
</span>
` : label}
</td>
<td>${formatComparisonCell(planned)}</td>
<td>${formatComparisonCell(actual)}</td>
<td>${formatComparisonDiff(planned, actual)}</td>
</tr>
${hasDetail ? `
<tr class="comparison-detail-row" data-detail-row="${key}">
<td colspan="4" class="comparison-detail-cell">
<div class="comparison-detail-box">
<div class="comparison-detail-grid">
<section class="comparison-detail-group">
<h5>계획/입력 세부</h5>
<div class="comparison-detail-list">
${renderComparisonDetailList(detail.planned, "등록된 계획 세부 항목이 없습니다.")}
</div>
</section>
<section class="comparison-detail-group">
<h5>실제 집행 세부</h5>
${key === "labor"
? renderLaborActualDetail(detail)
: `<div class="comparison-detail-list">
${renderComparisonDetailList(detail.actual, "연결된 실제 계정이 없습니다.")}
</div>`}
</section>
</div>
</div>
</td>
</tr>` : ""}
`;
}).join("")}
</tbody>
</table>
`;
}
function renderAnalysisNotes(item) {
return `
<h4>프로젝트 세부 입력 정보</h4>
<div class="analysis-note-grid">
<div class="detail-block">
<h4>기본 정보</h4>
<div class="detail-grid">
<div class="detail-item"><span>사업 종류</span><strong>${detailValue(item.project_type)}</strong></div>
<div class="detail-item"><span>차수변경</span><strong>${detailValue(item.change_round)}</strong></div>
<div class="detail-item"><span>예상 A/S비</span><strong>${formatNumber(item.expected_as_cost || 0)}</strong></div>
<div class="detail-item"><span>예상 판관비</span><strong>${formatNumber(item.expected_sga_budget || 0)}</strong></div>
</div>
</div>
<div class="detail-block">
<h4>메모 및 실행 참고</h4>
<div class="detail-grid">
<div class="detail-item"><span>실집행 인건비</span><strong>${formatNumber((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>${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);
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)),
);
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(2)}% · 수금 ${formatDisplayAmount(item.collection_amount)}</span>
</div>
<button type="button" class="button-icon button-secondary" data-add-code="${item.support_dept_code}" title="불러오기" aria-label="불러오기">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 5v14"></path>
<path d="M5 12h14"></path>
</svg>
<span class="sr-only">불러오기</span>
</button>
</div>
`;
}
function renderDetailCard(item) {
const periodLabel = item.project_duration_days !== null && item.project_duration_days !== undefined
? `${item.project_duration_days}일`
: "-";
const editUrl = selectedYear ? `/projects?edit_code=${encodeURIComponent(item.support_dept_code)}&year=${selectedYear}` : `/projects?edit_code=${encodeURIComponent(item.support_dept_code)}`;
return `
<article class="compare-card" data-card-code="${item.support_dept_code}">
<div class="compare-head">
<div>
<h3>${item.support_dept_name}</h3>
<p>${item.support_dept_code} · 최신 반영 ${detailValue(item.latest_year, "-")}${detailValue(item.latest_month, "-")}월</p>
</div>
<button type="button" class="remove-button" data-remove-code="${item.support_dept_code}" title="닫기" aria-label="닫기">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 6l12 12"></path>
<path d="M18 6L6 18"></path>
</svg>
<span class="sr-only">닫기</span>
</button>
</div>
<div class="info-metrics">
<div class="info-chip">
<div class="label">진행율</div>
<div class="value">${Number(item.progress_rate || 0).toFixed(2)}%</div>
</div>
<div class="info-chip">
<div class="label">수금액</div>
<div class="value">${formatDisplayAmount(item.collection_amount)}</div>
</div>
<div class="info-chip">
<div class="label">종료여부</div>
<div class="value">${detailValue(item.completion_status)}</div>
</div>
</div>
<div class="detail-stack">
<div class="detail-block">
<h4>기본 정보</h4>
<div class="detail-grid">
<div class="detail-item"><span>시작일</span><strong>${detailValue(item.project_start_date)}</strong></div>
<div class="detail-item"><span>종료일</span><strong>${detailValue(item.project_end_date)}</strong></div>
<div class="detail-item"><span>진행 기간</span><strong>${periodLabel}</strong></div>
<div class="detail-item"><span>차수변경</span><strong>${detailValue(item.change_round)}</strong></div>
</div>
</div>
<div class="detail-block">
<h4>과업수행계획</h4>
<div class="detail-grid">
<div class="detail-item"><span>부서별 배분액</span><strong>${formatNumber(item.task_plan_department_budget)}</strong></div>
<div class="detail-item"><span>외주비</span><strong>${formatNumber(item.task_plan_outsource_budget)}</strong></div>
<div class="detail-item"><span>합사운영비</span><strong>${formatNumber(item.task_plan_joint_operating_cost)}</strong></div>
<div class="detail-item"><span>외주별 배분액</span><strong>${detailValue(item.task_plan_outsource_detail)}</strong></div>
</div>
</div>
<div class="detail-block">
<h4>실행예산</h4>
<div class="detail-grid">
<div class="detail-item"><span>직급별 인건비</span><strong>${formatNumber(item.exec_budget_labor_by_grade)}</strong></div>
<div class="detail-item"><span>외주비</span><strong>${formatNumber(item.exec_budget_outsource)}</strong></div>
<div class="detail-item"><span>원가계정별 비용계획</span><strong>${formatNumber(item.exec_budget_cost_plan)}</strong></div>
<div class="detail-item"><span>예상 A/S비</span><strong>${formatNumber(item.expected_as_cost)}</strong></div>
<div class="detail-item"><span>예상 판관비</span><strong>${formatNumber(item.expected_sga_budget)}</strong></div>
<div class="detail-item"><span>항목별투입액</span><strong>${formatNumber(item.item_investment)}</strong></div>
</div>
</div>
<div class="detail-block">
<h4>실제 집행</h4>
<div class="detail-grid">
<div class="detail-item"><span>실집행 수익</span><strong>${formatNumber(item.total_revenue)}</strong></div>
<div class="detail-item"><span>실집행 원가</span><strong>${formatNumber(item.total_cost)}</strong></div>
<div class="detail-item"><span>실집행 판관비</span><strong>${formatNumber(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 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 relatedToolbarButton = document.getElementById("openRelatedProjectToolbar");
const relatedBarBox = document.getElementById("projectRelatedBar");
const heroBox = document.getElementById("projectAnalysisHero");
const metricsBox = document.getElementById("projectAnalysisMetrics");
const comparisonBox = document.getElementById("projectAnalysisComparison");
const notesBox = document.getElementById("projectAnalysisNotes");
const relatedModal = document.getElementById("relatedProjectModal");
const relatedModalCloseButton = document.getElementById("closeRelatedProjectModal");
const relatedSearchInput = document.getElementById("relatedProjectSearchInput");
const relatedSearchResults = document.getElementById("relatedProjectSearchResults");
const relatedSearchEmpty = document.getElementById("relatedProjectSearchEmpty");
const RELATED_PROJECT_STORAGE_KEY = "project-related-selections-v1";
let selectedCode = "";
let analysisOpen = false;
const relatedProjectSelections = loadRelatedProjectSelections();
function loadRelatedProjectSelections() {
const serverState = persistedProjectPageState?.related_project_selections;
if (serverState && typeof serverState === "object") {
return new Map(
Object.entries(serverState).map(([code, values]) => [
code,
new Set(Array.isArray(values) ? values.filter((value) => typeof value === "string" && value.trim()) : []),
]),
);
}
try {
const raw = window.localStorage.getItem(RELATED_PROJECT_STORAGE_KEY);
if (!raw) return new Map();
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object") return new Map();
return new Map(
Object.entries(parsed).map(([code, values]) => [
code,
new Set(Array.isArray(values) ? values.filter((value) => typeof value === "string" && value.trim()) : []),
]),
);
} catch (error) {
console.warn("Failed to load related project selections", error);
return new Map();
}
}
function persistRelatedProjectSelections() {
try {
const payload = Object.fromEntries(
[...relatedProjectSelections.entries()].map(([code, values]) => [code, [...values]]),
);
window.localStorage.setItem(RELATED_PROJECT_STORAGE_KEY, JSON.stringify(payload));
} catch (error) {
console.warn("Failed to persist related project selections", error);
}
}
async function saveProjectPageStateToServer(showFeedback = false) {
try {
const payload = {
selected_code: selectedCode,
selected_year: yearSelect?.value || "",
analysis_open: analysisOpen,
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에 저장하지 못했습니다.");
}
}
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>
</div>
</div>
<div class="explorer-tags">
<span class="mini-tag">수익 ${formatDisplayAmount(item.revenue_amount)}</span>
<span class="mini-tag">지출 ${formatDisplayAmount(item.expense_amount)}</span>
<span class="mini-tag">범위 ${formatYearRange(item)}</span>
</div>
</article>
`).join("");
listBox.querySelectorAll(".explorer-item").forEach((itemEl) => {
itemEl.addEventListener("click", () => {
selectedCode = itemEl.dataset.code || "";
currentSelectedProjectCode = selectedCode;
input.value = itemEl.querySelector("strong")?.textContent || input.value;
dropdownBox?.classList.remove("open");
setAnalysisVisibility(true);
renderSuggestionList(dataset, "");
renderAnalysis(getAnalysisItem(selectedCode));
});
});
emptyBox.textContent = 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 [...(relatedProjectSelections.get(baseCode) || new Set())];
}
function getRelatedItems(baseCode) {
return getRelatedCodes(baseCode)
.map((code) => getAnalysisItem(code))
.filter(Boolean);
}
function openRelatedProjectPicker() {
if (!selectedCode) return;
relatedModal?.classList.add("open");
relatedSearchInput.value = "";
refreshRelatedProjectResults();
relatedSearchInput?.focus();
}
function closeRelatedProjectPicker() {
relatedModal?.classList.remove("open");
relatedSearchInput.value = "";
relatedSearchResults.innerHTML = "";
relatedSearchEmpty.textContent = "";
}
function addRelatedProject(code) {
if (!selectedCode || !code || code === selectedCode) return;
const set = new Set(relatedProjectSelections.get(selectedCode) || []);
set.add(code);
relatedProjectSelections.set(selectedCode, set);
persistRelatedProjectSelections();
closeRelatedProjectPicker();
renderAnalysis(getAnalysisItem(selectedCode));
}
function removeRelatedProject(code) {
if (!selectedCode) return;
const set = new Set(relatedProjectSelections.get(selectedCode) || []);
set.delete(code);
if (set.size) {
relatedProjectSelections.set(selectedCode, set);
} else {
relatedProjectSelections.delete(selectedCode);
}
persistRelatedProjectSelections();
renderAnalysis(getAnalysisItem(selectedCode));
}
function refreshRelatedProjectResults() {
if (!selectedCode) {
relatedSearchResults.innerHTML = "";
relatedSearchEmpty.textContent = "먼저 프로젝트를 선택하세요.";
return;
}
const keyword = (relatedSearchInput?.value || "").trim().toLowerCase();
const alreadySelected = new Set(getRelatedCodes(selectedCode));
const baseDataset = buildProjectCostDataset("", yearSelect?.value || "");
const candidates = baseDataset.filter((item) => {
if (item.support_dept_code === selectedCode) return false;
if (alreadySelected.has(item.support_dept_code)) return false;
if (!keyword) return true;
return `${item.support_dept_code} ${item.support_dept_name}`.toLowerCase().includes(keyword);
}).slice(0, 20);
if (!candidates.length) {
relatedSearchResults.innerHTML = "";
relatedSearchEmpty.textContent = keyword ? "검색 결과가 없습니다." : "추가 가능한 연관 프로젝트가 없습니다.";
return;
}
relatedSearchResults.innerHTML = candidates.map((item) => `
<div class="related-search-item">
<div class="related-search-copy">
<strong>${escapeHtml(item.support_dept_name)}</strong>
<span>${escapeHtml(item.support_dept_code)} · ${escapeHtml(formatYearRange(item))} · 수익 ${escapeHtml(formatDisplayAmount(item.revenue_amount))} · 지출 ${escapeHtml(formatDisplayAmount(item.expense_amount))}</span>
</div>
<button type="button" class="button-icon button-secondary" data-add-related="${escapeHtml(item.support_dept_code)}" title="연관 프로젝트 추가" aria-label="연관 프로젝트 추가">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 5v14"></path>
<path d="M5 12h14"></path>
</svg>
<span class="sr-only">연관 프로젝트 추가</span>
</button>
</div>
`).join("");
relatedSearchEmpty.textContent = "";
relatedSearchResults.querySelectorAll("[data-add-related]").forEach((button) => {
button.addEventListener("click", () => addRelatedProject(button.dataset.addRelated || ""));
});
}
function renderAnalysis(item) {
if (!item) {
relatedBarBox.innerHTML = "";
heroBox.innerHTML = "";
metricsBox.innerHTML = "";
comparisonBox.innerHTML = "";
notesBox.innerHTML = "";
return;
}
const relatedItems = getRelatedItems(item.support_dept_code);
const aggregateItem = aggregateAnalysisItem(item, relatedItems);
relatedBarBox.innerHTML = renderRelatedProjectBar(item, relatedItems);
heroBox.innerHTML = renderAnalysisHero(aggregateItem);
metricsBox.innerHTML = renderAnalysisMetrics(aggregateItem);
comparisonBox.innerHTML = renderAnalysisComparison(aggregateItem);
notesBox.innerHTML = renderAnalysisNotes(aggregateItem);
relatedBarBox.querySelectorAll("[data-remove-related]").forEach((button) => {
button.addEventListener("click", () => removeRelatedProject(button.dataset.removeRelated || ""));
});
comparisonBox.querySelectorAll(".comparison-main-row[data-detail-target]").forEach((row) => {
row.addEventListener("click", () => {
const key = row.dataset.detailTarget;
const detailRow = comparisonBox.querySelector(`[data-detail-row="${key}"]`);
const isOpen = detailRow?.classList.contains("is-open");
row.classList.toggle("is-open", !isOpen);
detailRow?.classList.toggle("is-open", !isOpen);
});
});
}
function refresh(options = {}) {
const { renderDetail = false } = options;
const keyword = input?.value || "";
const dataset = buildProjectCostDataset(keyword, yearSelect?.value || "");
syncSelection(dataset, {
renderDetail: renderDetail && !keyword.trim(),
});
renderSuggestionList(dataset, keyword);
}
input?.addEventListener("input", () => refresh({ renderDetail: false }));
yearSelect?.addEventListener("change", () => refresh({ renderDetail: true }));
input?.addEventListener("focus", () => refresh({ renderDetail: false }));
relatedSearchInput?.addEventListener("input", refreshRelatedProjectResults);
relatedModalCloseButton?.addEventListener("click", closeRelatedProjectPicker);
relatedModal?.addEventListener("click", (event) => {
if (event.target === relatedModal) {
closeRelatedProjectPicker();
}
});
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");
}
});
// Always start from a clean first view: keep the latest project as the
// internal default selection, but hide the detail workspace until the
// user explicitly opens it or selects a search result.
setAnalysisVisibility(false);
refresh({ renderDetail: false });
})();
const modal = document.getElementById("projectModal");
const openModalButton = document.getElementById("openProjectModal");
const closeModalButton = document.getElementById("closeProjectModal");
if (openModalButton) {
openModalButton.addEventListener("click", async () => {
openProjectModalForFreshEntry("");
});
}
document.addEventListener("click", async (event) => {
const editButton = event.target.closest("[data-edit-code]");
if (!editButton) return;
event.preventDefault();
await openProjectModalForCode(editButton.dataset.editCode || "");
});
if (modal?.classList.contains("open") && projectEdit.support_dept_code) {
modalOriginalEdit = cloneProjectEditData(projectEdit);
applyProjectEditData(projectEdit);
}
function closeProjectModal() {
if (modalOriginalEdit) {
applyProjectEditData(cloneProjectEditData(modalOriginalEdit));
}
modal?.classList.remove("open");
supportDeptCodeResults.classList.remove("open");
supportDeptNameResults.classList.remove("open");
document.activeElement?.blur?.();
}
if (closeModalButton) {
closeModalButton.addEventListener("click", () => {
closeProjectModal();
});
}
modal?.addEventListener("click", (event) => {
if (event.target === modal) {
closeProjectModal();
}
});
renderRevenueChart();
</script>
{% endblock %}