Files
HM_project_Viewer_Board/templates/process_cost.html
T

1455 lines
55 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-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 button {
border: none;
background: transparent;
color: var(--muted);
padding: 0;
min-width: 16px;
min-height: 16px;
line-height: 1;
cursor: pointer;
}
.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{% 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{% 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" 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">
{% 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{% 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 %}
<div class="pc-selected-card">
<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;">&nbsp;</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;">&nbsp;</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 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]");
let bookmarkedCodes = Array.isArray(initialQuickLinkCodes) ? [...initialQuickLinkCodes] : [];
function buildProcessCostUrl(code) {
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);
}
if ({{ 1 if process_cost_include_related else 0 }}) {
params.set("include_related", "1");
}
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 renderQuickLinks() {
if (!quickLinksContainer) return;
const items = getBookmarkedProjects();
if (!items.length) {
quickLinksContainer.innerHTML = "";
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();
closeProjectList();
filterProjectList();
})
.catch((error) => {
window.alert(error.message || "바로가기를 저장하지 못했습니다.");
});
});
});
}
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();
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();
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) => `
<span class="pc-chip" data-related-code="${code}">
<span>${code}</span>
<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-remove]").forEach((button) => {
button.addEventListener("click", () => {
const code = button.dataset.relatedRemove || "";
if (code) {
relatedSet.delete(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);
}
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 %}