1559 lines
61 KiB
HTML
1559 lines
61 KiB
HTML
{% extends "base.html" %}
|
||
|
||
{% block title %}프로세스 원가{% endblock %}
|
||
|
||
{% block head_extra %}
|
||
<style>
|
||
.pc-grid {
|
||
display: grid;
|
||
grid-template-columns: minmax(260px, 300px) minmax(0, 1fr) minmax(280px, 340px);
|
||
gap: 14px;
|
||
align-items: start;
|
||
}
|
||
|
||
.pc-shell {
|
||
display: grid;
|
||
gap: 14px;
|
||
}
|
||
|
||
.pc-toolbar {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
padding-bottom: 14px;
|
||
border-bottom: 1px solid var(--line);
|
||
}
|
||
|
||
.pc-source-tabs {
|
||
display: inline-flex;
|
||
gap: 6px;
|
||
background: rgba(245, 247, 249, 0.95);
|
||
border: 1px solid var(--line);
|
||
border-radius: 999px;
|
||
padding: 4px;
|
||
}
|
||
|
||
.pc-source-tabs a {
|
||
text-decoration: none;
|
||
color: var(--ink);
|
||
font-size: 13px;
|
||
font-weight: 800;
|
||
padding: 8px 12px;
|
||
border-radius: 999px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.pc-source-tabs a.active {
|
||
background: var(--accent);
|
||
color: #fff;
|
||
}
|
||
|
||
.pc-tab-row {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.pc-filter {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.pc-filter select {
|
||
min-width: 110px;
|
||
}
|
||
|
||
.pc-side-panel {
|
||
display: grid;
|
||
gap: 14px;
|
||
overflow: visible;
|
||
min-width: 0;
|
||
}
|
||
|
||
.pc-collapse {
|
||
border-top: 1px solid var(--line);
|
||
padding-top: 14px;
|
||
display: grid;
|
||
gap: 12px;
|
||
}
|
||
|
||
.pc-collapse.is-first {
|
||
border-top: 0;
|
||
padding-top: 0;
|
||
}
|
||
|
||
.pc-collapse-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.pc-collapse-head h3,
|
||
.pc-collapse-head h4 {
|
||
margin: 0;
|
||
font-size: 15px;
|
||
}
|
||
|
||
.pc-collapse-toggle {
|
||
width: 28px;
|
||
height: 28px;
|
||
min-width: 28px;
|
||
border: 0;
|
||
background: transparent;
|
||
box-shadow: none;
|
||
padding: 0;
|
||
color: var(--muted);
|
||
}
|
||
|
||
.pc-collapse-toggle:hover {
|
||
transform: none;
|
||
background: transparent;
|
||
color: var(--ink);
|
||
}
|
||
|
||
.pc-collapse-toggle svg {
|
||
width: 18px;
|
||
height: 18px;
|
||
stroke: currentColor;
|
||
fill: none;
|
||
stroke-width: 2;
|
||
transition: transform 0.18s ease;
|
||
}
|
||
|
||
.pc-collapse.is-collapsed .pc-collapse-toggle svg {
|
||
transform: rotate(180deg);
|
||
}
|
||
|
||
.pc-collapse-body {
|
||
display: grid;
|
||
gap: 12px;
|
||
}
|
||
|
||
.pc-collapse.is-collapsed .pc-collapse-body {
|
||
display: none;
|
||
}
|
||
|
||
.pc-quick-links {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.pc-quick-link,
|
||
.pc-related-link {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
max-width: 100%;
|
||
min-height: 32px;
|
||
padding: 5px 12px;
|
||
border: 1px solid rgba(217, 221, 227, 0.9);
|
||
border-radius: 999px;
|
||
background: rgba(255, 255, 255, 0.82);
|
||
color: var(--ink);
|
||
text-decoration: none;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
min-width: 0;
|
||
}
|
||
|
||
.pc-quick-link strong,
|
||
.pc-quick-link span,
|
||
.pc-related-link span {
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
white-space: nowrap;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.pc-quick-link strong {
|
||
flex: 0 0 auto;
|
||
overflow: visible;
|
||
text-overflow: clip;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.pc-quick-link {
|
||
width: 100%;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.pc-quick-link-main {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
min-width: 0;
|
||
flex: 1 1 auto;
|
||
color: inherit;
|
||
text-decoration: none;
|
||
}
|
||
|
||
.pc-bookmark-button {
|
||
width: 28px;
|
||
height: 28px;
|
||
min-width: 28px;
|
||
border: 1px solid rgba(217, 221, 227, 0.9);
|
||
border-radius: 999px;
|
||
background: rgba(255, 255, 255, 0.92);
|
||
color: var(--muted);
|
||
box-shadow: none;
|
||
padding: 0;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex: 0 0 auto;
|
||
}
|
||
|
||
.pc-bookmark-button:hover {
|
||
transform: none;
|
||
background: #f3f6f9;
|
||
color: var(--ink);
|
||
}
|
||
|
||
.pc-bookmark-button.is-active {
|
||
color: #d97706;
|
||
border-color: rgba(217, 221, 227, 0.9);
|
||
background: #fff7ed;
|
||
}
|
||
|
||
.pc-bookmark-button svg {
|
||
width: 14px;
|
||
height: 14px;
|
||
stroke: currentColor;
|
||
fill: none;
|
||
stroke-width: 1.9;
|
||
}
|
||
|
||
.pc-selector-grid {
|
||
display: grid;
|
||
gap: 10px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.pc-selector-field {
|
||
position: relative;
|
||
display: grid;
|
||
gap: 8px;
|
||
overflow: visible;
|
||
}
|
||
|
||
.pc-project-search {
|
||
width: 100%;
|
||
}
|
||
|
||
.pc-project-dropdown {
|
||
position: absolute;
|
||
top: calc(100% + 8px);
|
||
left: 0;
|
||
right: 0;
|
||
z-index: 40;
|
||
display: grid;
|
||
gap: 0;
|
||
max-height: 360px;
|
||
overflow: auto;
|
||
border: 1px solid var(--line);
|
||
border-radius: 14px;
|
||
background: rgba(255, 255, 255, 0.98);
|
||
box-shadow: 0 16px 28px rgba(15, 23, 42, 0.08);
|
||
}
|
||
|
||
.pc-project-dropdown[hidden] {
|
||
display: none;
|
||
}
|
||
|
||
.pc-project-item {
|
||
border: 0;
|
||
border-bottom: 1px solid rgba(217, 221, 227, 0.9);
|
||
border-radius: 0;
|
||
background: transparent;
|
||
padding: 10px 12px;
|
||
text-decoration: none;
|
||
color: var(--ink);
|
||
display: grid;
|
||
gap: 5px;
|
||
}
|
||
|
||
.pc-project-item[hidden] {
|
||
display: none;
|
||
}
|
||
|
||
.pc-project-item.active {
|
||
border-color: rgba(217, 221, 227, 0.9);
|
||
background: rgba(245, 247, 250, 0.72);
|
||
box-shadow: none;
|
||
}
|
||
|
||
.pc-project-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
align-items: baseline;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.pc-project-code {
|
||
font-weight: 800;
|
||
}
|
||
|
||
.pc-project-name {
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.pc-type-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 22px;
|
||
padding: 2px 8px;
|
||
border-radius: 999px;
|
||
background: #eef2f6;
|
||
color: #3a4654;
|
||
font-size: 11px;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.pc-selected-card {
|
||
display: grid;
|
||
gap: 6px;
|
||
padding: 10px 12px;
|
||
border: 1px solid rgba(217, 221, 227, 0.9);
|
||
border-radius: 14px;
|
||
background: rgba(248, 250, 252, 0.75);
|
||
min-width: 0;
|
||
}
|
||
|
||
.pc-selected-card[hidden] {
|
||
display: none;
|
||
}
|
||
|
||
.pc-selected-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.pc-selected-title {
|
||
font-size: 14px;
|
||
font-weight: 800;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.pc-selected-sub {
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
line-height: 1.55;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.pc-process-list {
|
||
display: grid;
|
||
gap: 8px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.pc-process-button {
|
||
width: 100%;
|
||
justify-content: flex-start;
|
||
padding: 10px 12px;
|
||
min-height: 40px;
|
||
border-radius: 12px;
|
||
background: rgba(255, 255, 255, 0.78);
|
||
color: var(--ink);
|
||
border: 1px solid rgba(217, 221, 227, 0.9);
|
||
box-shadow: none;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.pc-process-button:hover {
|
||
transform: none;
|
||
background: #f3f6f9;
|
||
}
|
||
|
||
.pc-metric-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
gap: 10px;
|
||
align-items: start;
|
||
}
|
||
|
||
.pc-card {
|
||
border: 0;
|
||
border-bottom: 1px solid rgba(217, 221, 227, 0.9);
|
||
border-radius: 0;
|
||
background: transparent;
|
||
padding: 0 0 12px;
|
||
display: grid;
|
||
gap: 6px;
|
||
align-content: start;
|
||
align-items: start;
|
||
min-height: 100px;
|
||
}
|
||
|
||
.pc-card .label {
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
font-weight: 700;
|
||
}
|
||
|
||
.pc-card .value {
|
||
font-size: 24px;
|
||
font-weight: 900;
|
||
letter-spacing: -0.03em;
|
||
line-height: 1.15;
|
||
}
|
||
|
||
.pc-card .meta {
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
}
|
||
|
||
.pc-note {
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.pc-related-box {
|
||
display: grid;
|
||
gap: 5px;
|
||
}
|
||
|
||
.pc-chip-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
|
||
.pc-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 999px;
|
||
background: #f8fafc;
|
||
padding: 6px 10px;
|
||
font-size: 12px;
|
||
min-height: 32px;
|
||
}
|
||
|
||
.pc-chip-link {
|
||
color: inherit;
|
||
text-decoration: none;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.pc-chip-link:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
.pc-chip button {
|
||
border: none;
|
||
background: transparent;
|
||
color: var(--muted);
|
||
padding: 0;
|
||
min-width: 16px;
|
||
min-height: 16px;
|
||
line-height: 1;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.pc-related-include {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 18px;
|
||
min-width: 18px;
|
||
height: 18px;
|
||
border-radius: 999px;
|
||
border: 1px solid rgba(217, 221, 227, 0.9);
|
||
background: #fff;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
font-weight: 800;
|
||
padding: 0;
|
||
line-height: 1;
|
||
}
|
||
|
||
.pc-related-include.is-active {
|
||
color: #1f6f3d;
|
||
background: #eefbf3;
|
||
}
|
||
|
||
.pc-related-include:disabled {
|
||
opacity: 0.42;
|
||
cursor: default;
|
||
}
|
||
|
||
.pc-related-form {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
|
||
.pc-related-list {
|
||
border: 1px solid var(--line);
|
||
border-radius: 10px;
|
||
background: #fff;
|
||
max-height: 200px;
|
||
overflow: auto;
|
||
display: none;
|
||
}
|
||
|
||
.pc-related-list.active {
|
||
display: block;
|
||
}
|
||
|
||
.pc-related-item {
|
||
width: 100%;
|
||
border: none;
|
||
background: transparent;
|
||
text-align: left;
|
||
padding: 9px 10px;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
color: var(--ink);
|
||
}
|
||
|
||
.pc-related-item:hover {
|
||
background: #f5f7fa;
|
||
}
|
||
|
||
.pc-block {
|
||
border: 0;
|
||
border-top: 1px solid var(--line);
|
||
border-radius: 0;
|
||
overflow: hidden;
|
||
background: transparent;
|
||
}
|
||
|
||
.pc-grid > .pc-block:first-child,
|
||
.pc-grid > .pc-shell > :first-child,
|
||
.pc-grid > .pc-side-stack > :first-child {
|
||
border-top: 0;
|
||
}
|
||
|
||
.pc-grid > section,
|
||
.pc-grid > aside,
|
||
.pc-grid > .pc-shell,
|
||
.pc-grid > .pc-side-stack {
|
||
min-width: 0;
|
||
}
|
||
|
||
.pc-block h3 {
|
||
font-size: 15px;
|
||
padding: 14px 0 10px;
|
||
border-bottom: 0;
|
||
background: transparent;
|
||
}
|
||
|
||
.pc-grid > .pc-block:first-child h3,
|
||
.pc-grid > .pc-shell > :first-child h3,
|
||
.pc-grid > .pc-side-stack > :first-child h3 {
|
||
padding-top: 0;
|
||
}
|
||
|
||
.pc-table-wrap {
|
||
overflow: auto;
|
||
max-height: 390px;
|
||
}
|
||
|
||
.pc-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 12px;
|
||
min-width: 620px;
|
||
}
|
||
|
||
.pc-table th,
|
||
.pc-table td {
|
||
border-bottom: 1px solid #eceff4;
|
||
padding: 9px 10px;
|
||
text-align: right;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.pc-table th:first-child,
|
||
.pc-table td:first-child {
|
||
text-align: left;
|
||
}
|
||
|
||
.pc-table thead th {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 1;
|
||
background: #f5f7fa;
|
||
font-weight: 800;
|
||
color: #353b45;
|
||
}
|
||
|
||
.pc-table tr:nth-child(even) td {
|
||
background: #fcfcfd;
|
||
}
|
||
|
||
.pc-table td.negative {
|
||
color: #b0272d;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.pc-table td.positive {
|
||
color: #1f6f3d;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.pc-side-stack {
|
||
display: grid;
|
||
gap: 12px;
|
||
}
|
||
|
||
.pc-kpi {
|
||
border: 0;
|
||
border-top: 1px solid var(--line);
|
||
border-radius: 0;
|
||
background: transparent;
|
||
padding: 14px 0 0;
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
|
||
.pc-kpi h3 {
|
||
font-size: 15px;
|
||
}
|
||
|
||
.pc-kpi-row {
|
||
display: grid;
|
||
gap: 6px;
|
||
}
|
||
|
||
.pc-kpi-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.pc-bar {
|
||
height: 8px;
|
||
border-radius: 999px;
|
||
background: #edf1f5;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.pc-bar > span {
|
||
display: block;
|
||
height: 100%;
|
||
border-radius: inherit;
|
||
background: linear-gradient(90deg, #445a7e, #67a2d4);
|
||
width: 0;
|
||
}
|
||
|
||
.pc-mini-chart {
|
||
border: 0;
|
||
border-top: 1px solid var(--line);
|
||
border-radius: 0;
|
||
background: transparent;
|
||
padding: 14px 0 0;
|
||
}
|
||
|
||
.pc-mini-chart h3 {
|
||
font-size: 15px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.pc-mini-chart svg {
|
||
width: 100%;
|
||
height: auto;
|
||
display: block;
|
||
min-height: 170px;
|
||
}
|
||
|
||
.pc-badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
border: 1px solid rgba(217, 221, 227, 0.9);
|
||
border-radius: 999px;
|
||
padding: 4px 8px;
|
||
font-size: 11px;
|
||
color: var(--muted);
|
||
background: rgba(255, 255, 255, 0.72);
|
||
}
|
||
|
||
body[data-view-mode="dual"] .pc-grid {
|
||
grid-template-columns: minmax(250px, 280px) minmax(0, 1fr) minmax(300px, 360px);
|
||
}
|
||
|
||
body[data-view-mode="dual"] .pc-project-dropdown {
|
||
max-height: calc(100vh - 250px);
|
||
}
|
||
|
||
@media (max-width: 1260px) {
|
||
.pc-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.pc-metric-grid {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
}
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
{% set detail = process_cost_detail or {} %}
|
||
{% set overview = detail.overview or {} %}
|
||
{% set diagnostics = detail.diagnostics or {} %}
|
||
{% set selected_project = process_cost_selected_project or {} %}
|
||
<section class="panel pc-shell">
|
||
<div class="pc-toolbar">
|
||
<div class="pc-tab-row">
|
||
<div class="pc-source-tabs">
|
||
<a href="/process-cost?source=hanmac{% if process_cost_selected_start_year %}&start_year={{ process_cost_selected_start_year }}{% endif %}{% if process_cost_selected_end_year %}&end_year={{ process_cost_selected_end_year }}{% endif %}{% if process_cost_selected_code %}&code={{ process_cost_selected_code }}{% endif %}{% if process_cost_include_related %}&include_related=1&active_related={% if process_cost_active_related_codes %}{{ process_cost_active_related_codes|join(',') }}{% elif process_cost_related_codes %}{{ process_cost_related_codes|join(',') }}{% else %}-{% endif %}{% endif %}" class="{% if process_cost_source == 'hanmac' %}active{% endif %}">Hanmac ERP</a>
|
||
<a href="/process-cost?source=wehago{% if process_cost_selected_start_year %}&start_year={{ process_cost_selected_start_year }}{% endif %}{% if process_cost_selected_end_year %}&end_year={{ process_cost_selected_end_year }}{% endif %}{% if process_cost_selected_code %}&code={{ process_cost_selected_code }}{% endif %}{% if process_cost_include_related %}&include_related=1&active_related={% if process_cost_active_related_codes %}{{ process_cost_active_related_codes|join(',') }}{% elif process_cost_related_codes %}{{ process_cost_related_codes|join(',') }}{% else %}-{% endif %}{% endif %}" class="{% if process_cost_source == 'wehago' %}active{% endif %}">WEHAGO</a>
|
||
</div>
|
||
<div class="pc-source-tabs">
|
||
<a href="/process-cost?source={{ process_cost_source }}{% if process_cost_selected_start_year %}&start_year={{ process_cost_selected_start_year }}{% endif %}{% if process_cost_selected_end_year %}&end_year={{ process_cost_selected_end_year }}{% endif %}{% if process_cost_selected_code %}&code={{ process_cost_selected_code }}{% endif %}" class="{% if not process_cost_include_related %}active{% endif %}">연계 미반영</a>
|
||
<a href="/process-cost?source={{ process_cost_source }}{% if process_cost_selected_start_year %}&start_year={{ process_cost_selected_start_year }}{% endif %}{% if process_cost_selected_end_year %}&end_year={{ process_cost_selected_end_year }}{% endif %}{% if process_cost_selected_code %}&code={{ process_cost_selected_code }}{% endif %}&include_related=1&active_related={% if process_cost_active_related_codes %}{{ process_cost_active_related_codes|join(',') }}{% elif process_cost_related_codes %}{{ process_cost_related_codes|join(',') }}{% else %}-{% endif %}" class="{% if process_cost_include_related %}active{% endif %}">연계 반영</a>
|
||
</div>
|
||
</div>
|
||
<form method="get" action="/process-cost" class="pc-filter">
|
||
<input type="hidden" name="source" value="{{ process_cost_source }}">
|
||
<input type="hidden" name="code" value="{{ process_cost_selected_code }}">
|
||
{% if process_cost_include_related %}
|
||
<input type="hidden" name="include_related" value="1">
|
||
<input type="hidden" name="active_related" value="{% if process_cost_active_related_codes %}{{ process_cost_active_related_codes|join(',') }}{% else %}-{% endif %}">
|
||
{% endif %}
|
||
<select name="start_year" aria-label="시작 연도 선택" id="processCostStartYear">
|
||
{% if not process_cost_years %}
|
||
<option value="">연도 없음</option>
|
||
{% else %}
|
||
{% for y in process_cost_years_desc %}
|
||
<option value="{{ y }}" {% if process_cost_selected_start_year == y %}selected{% endif %} {% if process_cost_selected_end_year and y > process_cost_selected_end_year %}disabled{% endif %}>{{ y }}</option>
|
||
{% endfor %}
|
||
{% endif %}
|
||
</select>
|
||
<span>~</span>
|
||
<select name="end_year" aria-label="종료 연도 선택" id="processCostEndYear">
|
||
{% if not process_cost_years %}
|
||
<option value="">연도 없음</option>
|
||
{% else %}
|
||
{% for y in process_cost_years_desc %}
|
||
<option value="{{ y }}" {% if process_cost_selected_end_year == y %}selected{% endif %} {% if process_cost_selected_start_year and y < process_cost_selected_start_year %}disabled{% endif %}>{{ y }}</option>
|
||
{% endfor %}
|
||
{% endif %}
|
||
</select>
|
||
<button type="submit" class="button-secondary">적용</button>
|
||
</form>
|
||
</div>
|
||
|
||
<div class="pc-grid">
|
||
<aside class="pc-side-panel">
|
||
<section class="pc-collapse is-first" id="pcQuickLinksSection">
|
||
<div class="pc-collapse-head">
|
||
<h3>바로가기</h3>
|
||
<button type="button" class="pc-collapse-toggle" data-collapse-target="pcQuickLinksSection" aria-label="프로젝트 바로가기 접기">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M6 15l6-6 6 6"></path>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div class="pc-collapse-body">
|
||
<div class="pc-quick-links" id="pcQuickLinks"></div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="pc-collapse" id="pcProjectSelectorSection">
|
||
<div class="pc-collapse-head">
|
||
<h3>프로젝트 선택</h3>
|
||
</div>
|
||
<div class="pc-collapse-body">
|
||
<div class="pc-selector-grid">
|
||
<div class="field">
|
||
<select id="projectTypeFilter" aria-label="프로젝트 종류 선택">
|
||
<option value="">전체</option>
|
||
<option value="x">사전사업</option>
|
||
<option value="y">설계</option>
|
||
<option value="z">감리</option>
|
||
</select>
|
||
</div>
|
||
<div class="pc-selector-field">
|
||
<input type="text" id="projectSearchInput" class="pc-project-search" placeholder="프로젝트 코드/명 검색" autocomplete="off">
|
||
<div class="pc-project-dropdown" id="projectList" hidden>
|
||
{% for item in process_cost_projects %}
|
||
<a
|
||
href="/process-cost?source={{ process_cost_source }}{% if process_cost_selected_start_year %}&start_year={{ process_cost_selected_start_year }}{% endif %}{% if process_cost_selected_end_year %}&end_year={{ process_cost_selected_end_year }}{% endif %}&code={{ item.support_dept_code }}{% if process_cost_include_related %}&include_related=1&active_related={% if process_cost_active_related_codes %}{{ process_cost_active_related_codes|join(',') }}{% else %}-{% endif %}{% endif %}"
|
||
class="pc-project-item {% if process_cost_selected_code == item.support_dept_code %}active{% endif %}"
|
||
data-project-search="{{ (item.support_dept_code ~ ' ' ~ item.support_dept_name ~ ' ' ~ (item.client_name or ''))|lower }}"
|
||
data-project-kind="{{ item.project_kind_code|lower }}"
|
||
title="{{ item.support_dept_code }} {{ item.support_dept_name }}"
|
||
>
|
||
<div class="pc-project-head">
|
||
<span class="pc-project-code">{{ item.support_dept_code }}</span>
|
||
<span class="pc-type-pill">{{ item.project_kind_label }}</span>
|
||
</div>
|
||
<div class="pc-project-name">{{ item.support_dept_name }}</div>
|
||
{% if item.client_name %}
|
||
<div class="pc-project-name">{{ item.client_name }}</div>
|
||
{% endif %}
|
||
</a>
|
||
{% endfor %}
|
||
<div class="pc-note" id="projectListEmpty" hidden style="padding: 12px;">검색 결과가 없습니다.</div>
|
||
</div>
|
||
</div>
|
||
{% if selected_project and (selected_project.support_dept_code not in (process_cost_quick_link_codes or [])) %}
|
||
<div class="pc-selected-card" id="selectedProjectCard">
|
||
<div class="pc-selected-head">
|
||
<div class="pc-selected-title">{{ selected_project.support_dept_code }}</div>
|
||
<button
|
||
type="button"
|
||
class="pc-bookmark-button"
|
||
id="selectedProjectBookmark"
|
||
data-bookmark-code="{{ selected_project.support_dept_code }}"
|
||
data-bookmark-name="{{ selected_project.support_dept_name }}"
|
||
data-bookmark-kind="{{ selected_project.project_kind_label }}"
|
||
aria-label="바로가기 저장"
|
||
title="바로가기 저장"
|
||
>
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M7 4h10a1 1 0 0 1 1 1v15l-6-3-6 3V5a1 1 0 0 1 1-1z"></path>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div class="pc-selected-sub">{{ selected_project.support_dept_name }}</div>
|
||
{% if selected_project.client_name %}
|
||
<div class="pc-selected-sub">{{ selected_project.client_name }}</div>
|
||
{% endif %}
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="pc-collapse" id="pcProcessSection">
|
||
<div class="pc-collapse-body">
|
||
<div class="pc-process-list">
|
||
<button type="button" class="pc-process-button" data-process-modal="contract">계약정보</button>
|
||
<button type="button" class="pc-process-button" data-process-modal="planning">계획수립</button>
|
||
<button type="button" class="pc-process-button" data-process-modal="collection">수금관리</button>
|
||
<button type="button" class="pc-process-button" data-process-modal="expense">경비/외주비 지출</button>
|
||
<button type="button" class="pc-process-button" data-process-modal="progress">진행률관리</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</aside>
|
||
|
||
<section class="pc-shell" style="gap: 12px;">
|
||
<div class="pc-metric-grid">
|
||
<article class="pc-card">
|
||
<span class="label">계약금액</span>
|
||
<strong class="value">{{ "{:,.0f}".format(overview.contract_amount|default(0)) }}원</strong>
|
||
</article>
|
||
<article class="pc-card">
|
||
<span class="label">누적 수익</span>
|
||
<strong class="value">{{ "{:,.0f}".format(overview.revenue_amount|default(0)) }}원</strong>
|
||
</article>
|
||
<article class="pc-card">
|
||
<span class="label">누적 비용</span>
|
||
<strong class="value">{{ "{:,.0f}".format(overview.expense_amount|default(0)) }}원</strong>
|
||
<span class="meta">목표원가 {{ "{:,.0f}".format(overview.target_cost_amount|default(0)) }}원</span>
|
||
</article>
|
||
<article class="pc-card">
|
||
<span class="label">영업수지</span>
|
||
<strong class="value">{{ "{:,.0f}".format(overview.profit_amount|default(0)) }}원</strong>
|
||
<span class="meta">마진율 {{ "{:.1f}".format(overview.profit_rate|default(0)) }}%</span>
|
||
</article>
|
||
</div>
|
||
|
||
<section class="pc-collapse is-first" id="pcRelatedSection">
|
||
<div class="pc-collapse-head">
|
||
<h3>연계 프로젝트</h3>
|
||
<button type="button" class="pc-collapse-toggle" data-collapse-target="pcRelatedSection" aria-label="연계 프로젝트 접기">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M6 15l6-6 6 6"></path>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div class="pc-collapse-body pc-related-box">
|
||
<div class="pc-chip-row" id="relatedChipRow">
|
||
{% if process_cost_selected_code %}
|
||
<span class="pc-chip">{{ process_cost_selected_code }} 기준</span>
|
||
{% endif %}
|
||
{% for related_code in process_cost_related_codes or [] %}
|
||
<span class="pc-chip" data-related-code="{{ related_code }}">
|
||
<span>{{ related_code }}</span>
|
||
<button type="button" class="pc-related-remove" data-related-remove="{{ related_code }}">x</button>
|
||
</span>
|
||
{% endfor %}
|
||
</div>
|
||
<div class="pc-related-form">
|
||
<input type="text" id="relatedSearchInput" placeholder="추가할 프로젝트 코드/명 검색" {% if not process_cost_selected_code %}disabled{% endif %}>
|
||
<button type="button" class="button-secondary" id="relatedSaveButton" {% if not process_cost_selected_code %}disabled{% endif %}>저장</button>
|
||
<button type="button" class="button-secondary" id="relatedResetButton" {% if not process_cost_selected_code %}disabled{% endif %}>초기화</button>
|
||
</div>
|
||
<div class="pc-related-list" id="relatedSuggestList"></div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="pc-block">
|
||
<h3>지출 내역</h3>
|
||
<div class="pc-table-wrap">
|
||
<table class="pc-table">
|
||
<thead>
|
||
<tr>
|
||
<th>구분</th>
|
||
<th>목표 원가</th>
|
||
<th>실지출</th>
|
||
<th>차이(목표-실제)</th>
|
||
<th>집행률</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for row in detail.phase_rows or [] %}
|
||
<tr>
|
||
<td>{{ row.phase }}</td>
|
||
<td>{{ "{:,.0f}".format(row.target_amount|default(0)) }}</td>
|
||
<td>{{ "{:,.0f}".format(row.actual_amount|default(0)) }}</td>
|
||
<td class="{% if (row.gap_amount|default(0)) < 0 %}negative{% elif (row.gap_amount|default(0)) > 0 %}positive{% endif %}">
|
||
{{ "{:,.0f}".format(row.gap_amount|default(0)) }}
|
||
</td>
|
||
<td>{{ "{:.1f}".format(row.progress_rate|default(0)) }}%</td>
|
||
</tr>
|
||
{% endfor %}
|
||
{% if not (detail.phase_rows or []) %}
|
||
<tr><td colspan="5" style="text-align:center; color: transparent;"> </td></tr>
|
||
{% endif %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="pc-block">
|
||
<h3>지출 상위 항목</h3>
|
||
<div class="pc-table-wrap">
|
||
<table class="pc-table">
|
||
<thead>
|
||
<tr>
|
||
<th>계정</th>
|
||
<th>실지출</th>
|
||
<th>비중</th>
|
||
<th>전표건수</th>
|
||
<th>최근 증빙일</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for row in detail.account_rows or [] %}
|
||
<tr>
|
||
<td title="{{ row.account_code }} {{ row.account_name }}">{{ row.account_code }} {{ row.account_name }}</td>
|
||
<td>{{ "{:,.0f}".format(row.amount|default(0)) }}</td>
|
||
<td>{{ "{:.1f}".format(row.share_rate|default(0)) }}%</td>
|
||
<td>{{ "{:,}".format(row.row_count|default(0)) }}</td>
|
||
<td>{{ row.last_posting_date or '-' }}</td>
|
||
</tr>
|
||
{% endfor %}
|
||
{% if not (detail.account_rows or []) %}
|
||
<tr><td colspan="5" style="text-align:center; color: transparent;"> </td></tr>
|
||
{% endif %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
</section>
|
||
|
||
<aside class="pc-side-stack">
|
||
<section class="pc-kpi">
|
||
<h3>원가 건전성 지표</h3>
|
||
<div class="pc-kpi-row">
|
||
<div class="pc-kpi-head"><span>비용/수익 비율</span><strong>{{ "{:.1f}".format(diagnostics.cost_to_revenue_ratio|default(0)) }}%</strong></div>
|
||
<div class="pc-bar"><span data-rate="{{ diagnostics.cost_to_revenue_ratio|default(0) }}"></span></div>
|
||
</div>
|
||
<div class="pc-kpi-row">
|
||
<div class="pc-kpi-head"><span>직접인건비 비중</span><strong>{{ "{:.1f}".format(diagnostics.labor_ratio|default(0)) }}%</strong></div>
|
||
<div class="pc-bar"><span data-rate="{{ diagnostics.labor_ratio|default(0) }}"></span></div>
|
||
</div>
|
||
<div class="pc-kpi-row">
|
||
<div class="pc-kpi-head"><span>외주비 비중</span><strong>{{ "{:.1f}".format(diagnostics.outsourcing_ratio|default(0)) }}%</strong></div>
|
||
<div class="pc-bar"><span data-rate="{{ diagnostics.outsourcing_ratio|default(0) }}"></span></div>
|
||
</div>
|
||
<div class="pc-kpi-row">
|
||
<div class="pc-kpi-head"><span>제경비 비중</span><strong>{{ "{:.1f}".format(diagnostics.design_cost_ratio|default(0)) }}%</strong></div>
|
||
<div class="pc-bar"><span data-rate="{{ diagnostics.design_cost_ratio|default(0) }}"></span></div>
|
||
</div>
|
||
<div class="pc-kpi-row">
|
||
<div class="pc-kpi-head"><span>판관비 비중</span><strong>{{ "{:.1f}".format(diagnostics.sga_ratio|default(0)) }}%</strong></div>
|
||
<div class="pc-bar"><span data-rate="{{ diagnostics.sga_ratio|default(0) }}"></span></div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="pc-kpi">
|
||
<h3>데이터 현황</h3>
|
||
<div class="pc-kpi-head"><span>선택 소스</span><strong>{% if process_cost_source == 'wehago' %}WEHAGO{% else %}Hanmac ERP{% endif %}</strong></div>
|
||
<div class="pc-kpi-head"><span>기간</span><strong>{% if process_cost_selected_start_year and process_cost_selected_end_year %}{{ process_cost_selected_start_year }} ~ {{ process_cost_selected_end_year }}{% else %}전체 기간{% endif %}</strong></div>
|
||
<div class="pc-kpi-head"><span>전표 건수</span><strong>{{ "{:,}".format(overview.voucher_count|default(0)) }}</strong></div>
|
||
<div class="pc-kpi-head"><span>행 건수</span><strong>{{ "{:,}".format(overview.row_count|default(0)) }}</strong></div>
|
||
<div class="pc-kpi-head"><span>실행률</span><strong>{{ "{:.1f}".format(overview.execution_rate|default(0)) }}%</strong></div>
|
||
<div class="pc-kpi-head"><span>연계 반영</span><strong>{% if process_cost_include_related %}반영{% else %}미반영{% endif %}</strong></div>
|
||
</section>
|
||
|
||
<section class="pc-mini-chart">
|
||
<h3>최근 월별 수익/비용 추이</h3>
|
||
<svg id="processMonthlyChart" viewBox="0 0 540 220" preserveAspectRatio="xMidYMid meet"></svg>
|
||
</section>
|
||
</aside>
|
||
</div>
|
||
</section>
|
||
|
||
<div class="modal-backdrop" id="processFlowModal">
|
||
<div class="related-modal-card" style="max-width:520px;">
|
||
<div class="related-modal-head related-modal-head--singleline">
|
||
<h3 class="related-modal-title" id="processFlowModalTitle"></h3>
|
||
<button type="button" class="button-icon button-secondary" id="processFlowModalClose" aria-label="닫기">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M7 7l10 10"></path>
|
||
<path d="M17 7L7 17"></path>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div class="pc-note" id="processFlowModalNote"></div>
|
||
</div>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block script %}
|
||
<script>
|
||
(() => {
|
||
const processCostProjects = {{ process_cost_projects | tojson }};
|
||
const processCostProjectMap = new Map(
|
||
processCostProjects
|
||
.filter((item) => item && item.support_dept_code)
|
||
.map((item) => [item.support_dept_code, item])
|
||
);
|
||
const selectedCode = {{ process_cost_selected_code | tojson }};
|
||
const selectedProject = {{ (process_cost_selected_project or {}) | tojson }};
|
||
const initialRelatedCodes = {{ (process_cost_related_codes or []) | tojson }};
|
||
const initialActiveRelatedCodes = {{ (process_cost_active_related_codes or []) | tojson }};
|
||
const initialQuickLinkCodes = {{ (process_cost_quick_link_codes or []) | tojson }};
|
||
const searchInput = document.getElementById("projectSearchInput");
|
||
const list = document.getElementById("projectList");
|
||
const projectListEmpty = document.getElementById("projectListEmpty");
|
||
const typeFilter = document.getElementById("projectTypeFilter");
|
||
const quickLinksContainer = document.getElementById("pcQuickLinks");
|
||
const selectedProjectBookmark = document.getElementById("selectedProjectBookmark");
|
||
const collapseButtons = document.querySelectorAll("[data-collapse-target]");
|
||
const processModal = document.getElementById("processFlowModal");
|
||
const processModalTitle = document.getElementById("processFlowModalTitle");
|
||
const processModalNote = document.getElementById("processFlowModalNote");
|
||
const processModalClose = document.getElementById("processFlowModalClose");
|
||
const processButtons = document.querySelectorAll("[data-process-modal]");
|
||
const includeRelatedEnabled = {{ 1 if process_cost_include_related else 0 }};
|
||
let bookmarkedCodes = Array.isArray(initialQuickLinkCodes) ? [...initialQuickLinkCodes] : [];
|
||
let activeRelatedCodes = Array.isArray(initialActiveRelatedCodes) ? [...initialActiveRelatedCodes] : [];
|
||
|
||
function buildProcessCostUrl(code, options = {}) {
|
||
const params = new URLSearchParams();
|
||
params.set("source", {{ process_cost_source | tojson }});
|
||
if ({{ process_cost_selected_start_year | tojson }}) {
|
||
params.set("start_year", {{ process_cost_selected_start_year | tojson }});
|
||
}
|
||
if ({{ process_cost_selected_end_year | tojson }}) {
|
||
params.set("end_year", {{ process_cost_selected_end_year | tojson }});
|
||
}
|
||
if (code) {
|
||
params.set("code", code);
|
||
}
|
||
const includeRelated = Object.prototype.hasOwnProperty.call(options, "includeRelated")
|
||
? Boolean(options.includeRelated)
|
||
: Boolean(includeRelatedEnabled);
|
||
const relatedCodes = Array.isArray(options.activeRelatedCodes)
|
||
? options.activeRelatedCodes
|
||
: activeRelatedCodes;
|
||
if (includeRelated) {
|
||
params.set("include_related", "1");
|
||
params.set("active_related", relatedCodes.length ? relatedCodes.join(",") : "-");
|
||
}
|
||
return `/process-cost?${params.toString()}`;
|
||
}
|
||
|
||
function getBookmarkedProjects() {
|
||
return bookmarkedCodes
|
||
.map((code) => processCostProjectMap.get(code) || { support_dept_code: code, support_dept_name: "", project_kind_label: "" })
|
||
.filter((item) => item && item.support_dept_code);
|
||
}
|
||
|
||
async function persistBookmarkedCodes(nextCodes) {
|
||
const response = await fetch("/process-cost/quick-links", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ codes: nextCodes }),
|
||
});
|
||
const payload = await response.json();
|
||
if (!response.ok || payload.error) {
|
||
throw new Error(payload.error || "바로가기를 저장하지 못했습니다.");
|
||
}
|
||
bookmarkedCodes = Array.isArray(payload.codes) ? payload.codes : nextCodes;
|
||
}
|
||
|
||
function isBookmarked(code) {
|
||
return bookmarkedCodes.includes(code);
|
||
}
|
||
|
||
function syncBookmarkButton() {
|
||
if (!selectedProjectBookmark) return;
|
||
const code = selectedProjectBookmark.dataset.bookmarkCode || "";
|
||
const active = Boolean(code) && isBookmarked(code);
|
||
selectedProjectBookmark.classList.toggle("is-active", active);
|
||
selectedProjectBookmark.setAttribute("aria-label", active ? "바로가기 해제" : "바로가기 저장");
|
||
selectedProjectBookmark.setAttribute("title", active ? "바로가기 해제" : "바로가기 저장");
|
||
}
|
||
|
||
function syncSelectedProjectCard() {
|
||
const selectedProjectCard = document.getElementById("selectedProjectCard");
|
||
if (!selectedProjectCard || !selectedProject || !selectedProject.support_dept_code) {
|
||
return;
|
||
}
|
||
selectedProjectCard.hidden = isBookmarked(selectedProject.support_dept_code);
|
||
}
|
||
|
||
function renderQuickLinks() {
|
||
if (!quickLinksContainer) return;
|
||
const items = getBookmarkedProjects();
|
||
if (!items.length) {
|
||
quickLinksContainer.innerHTML = "";
|
||
syncSelectedProjectCard();
|
||
return;
|
||
}
|
||
quickLinksContainer.innerHTML = items.map((item) => `
|
||
<div class="pc-quick-link" title="${item.support_dept_code} ${item.support_dept_name || ""}">
|
||
<a href="${buildProcessCostUrl(item.support_dept_code)}" class="pc-quick-link-main">
|
||
<strong>${item.support_dept_code}</strong>
|
||
<span>${item.support_dept_name || ""}</span>
|
||
</a>
|
||
<button type="button" class="pc-bookmark-button is-active" data-bookmark-toggle="${item.support_dept_code}" aria-label="바로가기 해제" title="바로가기 해제">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M7 4h10a1 1 0 0 1 1 1v15l-6-3-6 3V5a1 1 0 0 1 1-1z"></path>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
`).join("");
|
||
quickLinksContainer.querySelectorAll("[data-bookmark-toggle]").forEach((button) => {
|
||
button.addEventListener("click", (event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
const code = button.getAttribute("data-bookmark-toggle") || "";
|
||
const nextCodes = bookmarkedCodes.filter((item) => item !== code);
|
||
persistBookmarkedCodes(nextCodes)
|
||
.then(() => {
|
||
renderQuickLinks();
|
||
syncBookmarkButton();
|
||
syncSelectedProjectCard();
|
||
closeProjectList();
|
||
filterProjectList();
|
||
})
|
||
.catch((error) => {
|
||
window.alert(error.message || "바로가기를 저장하지 못했습니다.");
|
||
});
|
||
});
|
||
});
|
||
syncSelectedProjectCard();
|
||
}
|
||
|
||
function getVisibleProjectItems() {
|
||
return [...list.querySelectorAll("[data-project-search]")].filter((item) => item.style.display !== "none");
|
||
}
|
||
|
||
function filterProjectList() {
|
||
if (!searchInput || !list) return;
|
||
const keyword = (searchInput.value || "").trim().toLowerCase();
|
||
const typeValue = (typeFilter?.value || "").trim().toLowerCase();
|
||
const bookmarkedSet = new Set(bookmarkedCodes);
|
||
let visibleCount = 0;
|
||
list.querySelectorAll("[data-project-search]").forEach((item) => {
|
||
const text = (item.dataset.projectSearch || "").toLowerCase();
|
||
const kind = (item.dataset.projectKind || "").toLowerCase();
|
||
const matchesKeyword = !keyword || text.includes(keyword);
|
||
const matchesType = !typeValue || kind === typeValue;
|
||
const codeNode = item.querySelector(".pc-project-code");
|
||
const code = (codeNode?.textContent || "").trim();
|
||
const isVisible = matchesKeyword && matchesType && !bookmarkedSet.has(code);
|
||
item.hidden = !isVisible;
|
||
if (isVisible) {
|
||
visibleCount += 1;
|
||
}
|
||
});
|
||
if (projectListEmpty) {
|
||
projectListEmpty.hidden = visibleCount !== 0;
|
||
}
|
||
return visibleCount;
|
||
}
|
||
|
||
function openProjectList() {
|
||
if (!list) return;
|
||
const visibleCount = filterProjectList();
|
||
const visibleItems = getVisibleProjectItems();
|
||
list.hidden = false;
|
||
if (projectListEmpty) {
|
||
projectListEmpty.hidden = (visibleCount || visibleItems.length) !== 0;
|
||
}
|
||
}
|
||
|
||
function closeProjectList() {
|
||
if (list) {
|
||
list.hidden = true;
|
||
}
|
||
}
|
||
|
||
if (searchInput && list) {
|
||
searchInput.addEventListener("focus", () => {
|
||
openProjectList();
|
||
});
|
||
searchInput.addEventListener("input", openProjectList);
|
||
searchInput.addEventListener("click", openProjectList);
|
||
document.addEventListener("click", (event) => {
|
||
const target = event.target;
|
||
if (!(target instanceof Element)) return;
|
||
if (target.closest(".pc-selector-field")) return;
|
||
closeProjectList();
|
||
});
|
||
}
|
||
|
||
if (typeFilter) {
|
||
typeFilter.addEventListener("change", () => {
|
||
openProjectList();
|
||
});
|
||
}
|
||
|
||
collapseButtons.forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
const targetId = button.getAttribute("data-collapse-target");
|
||
if (!targetId) return;
|
||
const section = document.getElementById(targetId);
|
||
if (!section) return;
|
||
section.classList.toggle("is-collapsed");
|
||
});
|
||
});
|
||
|
||
processButtons.forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
const kind = button.getAttribute("data-process-modal") || "";
|
||
const titleMap = {
|
||
contract: "계약정보",
|
||
planning: "계획수립",
|
||
collection: "수금관리",
|
||
expense: "경비/외주비 지출",
|
||
progress: "진행률관리",
|
||
};
|
||
if (processModalTitle) {
|
||
processModalTitle.textContent = titleMap[kind] || "프로세스";
|
||
}
|
||
if (processModalNote) {
|
||
processModalNote.textContent = "";
|
||
}
|
||
if (processModal) {
|
||
processModal.classList.add("is-open");
|
||
}
|
||
});
|
||
});
|
||
|
||
function closeProcessModal() {
|
||
if (processModal) {
|
||
processModal.classList.remove("is-open");
|
||
}
|
||
}
|
||
|
||
if (processModalClose) {
|
||
processModalClose.addEventListener("click", closeProcessModal);
|
||
}
|
||
if (processModal) {
|
||
processModal.addEventListener("click", (event) => {
|
||
if (event.target === processModal) {
|
||
closeProcessModal();
|
||
}
|
||
});
|
||
}
|
||
|
||
renderQuickLinks();
|
||
syncBookmarkButton();
|
||
syncSelectedProjectCard();
|
||
filterProjectList();
|
||
|
||
if (selectedProjectBookmark) {
|
||
selectedProjectBookmark.addEventListener("click", async () => {
|
||
const code = selectedProjectBookmark.dataset.bookmarkCode || "";
|
||
if (!code) return;
|
||
const nextCodes = bookmarkedCodes.filter((item) => item !== code);
|
||
if (!isBookmarked(code)) {
|
||
nextCodes.unshift(code);
|
||
}
|
||
try {
|
||
await persistBookmarkedCodes(nextCodes);
|
||
renderQuickLinks();
|
||
syncBookmarkButton();
|
||
syncSelectedProjectCard();
|
||
closeProjectList();
|
||
filterProjectList();
|
||
} catch (error) {
|
||
window.alert(error.message || "바로가기를 저장하지 못했습니다.");
|
||
}
|
||
});
|
||
}
|
||
|
||
document.querySelectorAll(".pc-bar > span[data-rate]").forEach((bar) => {
|
||
const raw = Number(bar.dataset.rate || 0);
|
||
const width = Math.max(0, Math.min(raw, 100));
|
||
bar.style.width = `${width}%`;
|
||
});
|
||
|
||
const relatedSearchInput = document.getElementById("relatedSearchInput");
|
||
const relatedSuggestList = document.getElementById("relatedSuggestList");
|
||
const relatedChipRow = document.getElementById("relatedChipRow");
|
||
const relatedSaveButton = document.getElementById("relatedSaveButton");
|
||
const relatedResetButton = document.getElementById("relatedResetButton");
|
||
const relatedSet = new Set(initialRelatedCodes || []);
|
||
|
||
function renderRelatedChips() {
|
||
if (!relatedChipRow) return;
|
||
const fixedHead = selectedCode ? `<span class="pc-chip">${selectedCode} 기준</span>` : "";
|
||
const chips = [...relatedSet].sort().map((code) => {
|
||
const isActive = activeRelatedCodes.includes(code);
|
||
const nextActiveCodes = isActive
|
||
? activeRelatedCodes.filter((item) => item !== code)
|
||
: [...activeRelatedCodes, code];
|
||
const openActiveCodes = [...new Set(
|
||
[selectedCode, ...activeRelatedCodes]
|
||
.filter((item) => item && item !== code)
|
||
)];
|
||
return `
|
||
<span class="pc-chip" data-related-code="${code}">
|
||
<a
|
||
href="${buildProcessCostUrl(code, { activeRelatedCodes: openActiveCodes, includeRelated: includeRelatedEnabled })}"
|
||
class="pc-chip-link"
|
||
>${code}</a>
|
||
<button
|
||
type="button"
|
||
class="pc-related-include ${isActive ? "is-active" : ""}"
|
||
data-related-toggle="${code}"
|
||
data-related-active="${isActive ? "1" : "0"}"
|
||
aria-label="${isActive ? "합산 제외" : "합산 포함"}"
|
||
title="${isActive ? "합산 제외" : "합산 포함"}"
|
||
${includeRelatedEnabled ? "" : "disabled"}
|
||
>${isActive ? "−" : "+"}</button>
|
||
<button type="button" class="pc-related-remove" data-related-remove="${code}">x</button>
|
||
</span>
|
||
`;
|
||
}).join("");
|
||
const empty = "";
|
||
relatedChipRow.innerHTML = fixedHead + chips + empty;
|
||
relatedChipRow.querySelectorAll("[data-related-toggle]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
if (!includeRelatedEnabled) return;
|
||
const code = button.getAttribute("data-related-toggle") || "";
|
||
if (!code) return;
|
||
const currentlyActive = button.getAttribute("data-related-active") === "1";
|
||
const nextActiveCodes = currentlyActive
|
||
? activeRelatedCodes.filter((item) => item !== code)
|
||
: [...activeRelatedCodes, code];
|
||
window.location.href = buildProcessCostUrl(selectedCode, {
|
||
includeRelated: true,
|
||
activeRelatedCodes: [...new Set(nextActiveCodes)],
|
||
});
|
||
});
|
||
});
|
||
relatedChipRow.querySelectorAll("[data-related-remove]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
const code = button.dataset.relatedRemove || "";
|
||
if (code) {
|
||
relatedSet.delete(code);
|
||
activeRelatedCodes = activeRelatedCodes.filter((item) => item !== code);
|
||
renderRelatedChips();
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderRelatedSuggestions(keyword) {
|
||
if (!relatedSuggestList) return;
|
||
const normalized = (keyword || "").trim().toLowerCase();
|
||
if (!normalized || !selectedCode) {
|
||
relatedSuggestList.classList.remove("active");
|
||
relatedSuggestList.innerHTML = "";
|
||
return;
|
||
}
|
||
const rows = processCostProjects
|
||
.filter((item) => item.support_dept_code !== selectedCode)
|
||
.filter((item) => !relatedSet.has(item.support_dept_code))
|
||
.filter((item) => `${item.support_dept_code} ${item.support_dept_name || ""} ${item.client_name || ""}`.toLowerCase().includes(normalized))
|
||
.slice(0, 30);
|
||
if (!rows.length) {
|
||
relatedSuggestList.classList.remove("active");
|
||
relatedSuggestList.innerHTML = "";
|
||
return;
|
||
}
|
||
relatedSuggestList.innerHTML = rows.map((item) => `
|
||
<button type="button" class="pc-related-item" data-related-pick="${item.support_dept_code}">
|
||
${item.support_dept_code} ${item.support_dept_name || ""}
|
||
</button>
|
||
`).join("");
|
||
relatedSuggestList.classList.add("active");
|
||
relatedSuggestList.querySelectorAll("[data-related-pick]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
const code = button.dataset.relatedPick || "";
|
||
if (code) {
|
||
relatedSet.add(code);
|
||
if (relatedSearchInput) relatedSearchInput.value = "";
|
||
relatedSuggestList.classList.remove("active");
|
||
relatedSuggestList.innerHTML = "";
|
||
renderRelatedChips();
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
if (relatedSearchInput) {
|
||
relatedSearchInput.addEventListener("input", () => {
|
||
renderRelatedSuggestions(relatedSearchInput.value || "");
|
||
});
|
||
relatedSearchInput.addEventListener("focus", () => {
|
||
renderRelatedSuggestions(relatedSearchInput.value || "");
|
||
});
|
||
}
|
||
|
||
if (relatedResetButton) {
|
||
relatedResetButton.addEventListener("click", () => {
|
||
relatedSet.clear();
|
||
for (const code of initialRelatedCodes || []) {
|
||
relatedSet.add(code);
|
||
}
|
||
activeRelatedCodes = Array.isArray(initialActiveRelatedCodes) ? [...initialActiveRelatedCodes] : [];
|
||
if (relatedSearchInput) relatedSearchInput.value = "";
|
||
if (relatedSuggestList) {
|
||
relatedSuggestList.classList.remove("active");
|
||
relatedSuggestList.innerHTML = "";
|
||
}
|
||
renderRelatedChips();
|
||
});
|
||
}
|
||
|
||
if (relatedSaveButton) {
|
||
relatedSaveButton.addEventListener("click", async () => {
|
||
if (!selectedCode) return;
|
||
relatedSaveButton.disabled = true;
|
||
try {
|
||
const response = await fetch("/projects/related-links", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
base_code: selectedCode,
|
||
related_codes: [...relatedSet],
|
||
}),
|
||
});
|
||
const payload = await response.json();
|
||
if (!response.ok || payload.error) {
|
||
throw new Error(payload.error || "연계 프로젝트 저장에 실패했습니다.");
|
||
}
|
||
window.location.reload();
|
||
} catch (error) {
|
||
alert(error.message || "연계 프로젝트 저장에 실패했습니다.");
|
||
} finally {
|
||
relatedSaveButton.disabled = false;
|
||
}
|
||
});
|
||
}
|
||
|
||
renderRelatedChips();
|
||
|
||
const startYearSelect = document.getElementById("processCostStartYear");
|
||
const endYearSelect = document.getElementById("processCostEndYear");
|
||
const syncYearRangeOptions = () => {
|
||
if (!startYearSelect || !endYearSelect) return;
|
||
const startValue = Number(startYearSelect.value || 0);
|
||
const endValue = Number(endYearSelect.value || 0);
|
||
|
||
Array.from(startYearSelect.options).forEach((option) => {
|
||
const value = Number(option.value || 0);
|
||
option.disabled = Boolean(endValue && value > endValue);
|
||
});
|
||
Array.from(endYearSelect.options).forEach((option) => {
|
||
const value = Number(option.value || 0);
|
||
option.disabled = Boolean(startValue && value < startValue);
|
||
});
|
||
};
|
||
|
||
if (startYearSelect && endYearSelect) {
|
||
startYearSelect.addEventListener("change", () => {
|
||
const startValue = Number(startYearSelect.value || 0);
|
||
const endValue = Number(endYearSelect.value || 0);
|
||
if (startValue && endValue && startValue > endValue) {
|
||
endYearSelect.value = String(startValue);
|
||
}
|
||
syncYearRangeOptions();
|
||
});
|
||
endYearSelect.addEventListener("change", () => {
|
||
const startValue = Number(startYearSelect.value || 0);
|
||
const endValue = Number(endYearSelect.value || 0);
|
||
if (startValue && endValue && endValue < startValue) {
|
||
startYearSelect.value = String(endValue);
|
||
}
|
||
syncYearRangeOptions();
|
||
});
|
||
syncYearRangeOptions();
|
||
}
|
||
|
||
const monthlyRows = {{ (process_cost_detail.monthly_rows or []) | tojson }};
|
||
const svg = document.getElementById("processMonthlyChart");
|
||
if (!svg || !Array.isArray(monthlyRows) || !monthlyRows.length) {
|
||
return;
|
||
}
|
||
|
||
const width = 540;
|
||
const height = 220;
|
||
const pad = { top: 16, right: 14, bottom: 36, left: 42 };
|
||
const plotW = width - pad.left - pad.right;
|
||
const plotH = height - pad.top - pad.bottom;
|
||
const maxVal = Math.max(
|
||
...monthlyRows.map((row) => Number(row.revenue_amount || 0)),
|
||
...monthlyRows.map((row) => Number(row.expense_amount || 0)),
|
||
1
|
||
);
|
||
const stepX = monthlyRows.length > 1 ? plotW / (monthlyRows.length - 1) : 0;
|
||
const y = (v) => pad.top + (1 - Math.min(v / maxVal, 1)) * plotH;
|
||
const x = (i) => pad.left + stepX * i;
|
||
|
||
const revenuePoints = monthlyRows.map((row, i) => `${x(i)},${y(Number(row.revenue_amount || 0))}`).join(" ");
|
||
const expensePoints = monthlyRows.map((row, i) => `${x(i)},${y(Number(row.expense_amount || 0))}`).join(" ");
|
||
|
||
let labels = "";
|
||
monthlyRows.forEach((row, i) => {
|
||
labels += `<text x="${x(i)}" y="${height - 12}" text-anchor="middle" fill="#677182" font-size="10">${(row.month_label || '').slice(2)}</text>`;
|
||
});
|
||
|
||
svg.innerHTML = `
|
||
<line x1="${pad.left}" y1="${pad.top}" x2="${pad.left}" y2="${height - pad.bottom}" stroke="#d9dde3" />
|
||
<line x1="${pad.left}" y1="${height - pad.bottom}" x2="${width - pad.right}" y2="${height - pad.bottom}" stroke="#d9dde3" />
|
||
<polyline points="${revenuePoints}" fill="none" stroke="#2c5f96" stroke-width="2.3" />
|
||
<polyline points="${expensePoints}" fill="none" stroke="#d9822b" stroke-width="2.3" />
|
||
${labels}
|
||
<text x="${pad.left}" y="${pad.top - 3}" fill="#2c5f96" font-size="11">수익</text>
|
||
<text x="${pad.left + 36}" y="${pad.top - 3}" fill="#d9822b" font-size="11">비용</text>
|
||
`;
|
||
})();
|
||
</script>
|
||
{% endblock %}
|