Files
HM_project_Viewer_Board/templates/annual_gap_analysis.html
T

1426 lines
58 KiB
HTML

{% extends "base.html" %}
{% block title %}수익/비용 차이분석{% endblock %}
{% block head_extra %}
<style>
.gap-page {
display: grid;
gap: 22px;
}
.gap-hero {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
align-items: end;
padding-bottom: 14px;
border-bottom: 1px solid var(--line);
}
.gap-hero h1 {
font-size: 28px;
line-height: 1.18;
}
.gap-help {
color: var(--muted);
font-size: 13px;
line-height: 1.55;
}
.gap-actions {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
}
.gap-action-stack {
display: grid;
gap: 8px;
justify-items: end;
}
.gap-scoreboard {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.gap-score {
display: grid;
gap: 7px;
padding-bottom: 14px;
border-bottom: 1px solid var(--line);
min-height: 112px;
}
.gap-score .label {
color: var(--muted);
font-size: 12px;
font-weight: 800;
}
.gap-score .value {
font-size: 25px;
font-weight: 900;
line-height: 1.12;
}
.gap-score .meta {
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.gap-year-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.year-card {
display: grid;
gap: 12px;
align-content: start;
min-height: 270px;
padding: 14px;
border: 1px solid var(--line);
border-radius: 8px;
background: #ffffff;
}
.year-card[data-severity="danger"] {
border-color: #efb0aa;
background: #fff8f7;
}
.year-card[data-severity="warning"] {
border-color: #efcf8f;
background: #fffaf0;
}
.year-card[data-severity="ok"] {
border-color: #b8ddce;
background: #f6fcf9;
}
.year-head {
display: flex;
align-items: start;
justify-content: space-between;
gap: 10px;
}
.year-head h2 {
font-size: 22px;
line-height: 1;
}
.severity-pill {
display: inline-flex;
align-items: center;
min-height: 26px;
padding: 4px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 900;
background: #eef2f6;
color: #344055;
white-space: nowrap;
}
.severity-pill.danger {
background: #ffe3df;
color: #9f2418;
}
.severity-pill.warning {
background: #fff1ce;
color: #835000;
}
.severity-pill.ok {
background: #e4f7ed;
color: #11633e;
}
.year-main {
display: grid;
gap: 6px;
}
.year-main strong {
font-size: 18px;
line-height: 1.25;
}
.gap-mini-grid {
display: grid;
gap: 8px;
}
.gap-mini-row {
display: grid;
grid-template-columns: 74px minmax(0, 1fr) 88px;
gap: 8px;
align-items: center;
font-size: 12px;
}
.gap-track {
height: 8px;
background: rgba(17, 24, 39, 0.09);
border-radius: 999px;
overflow: hidden;
}
.gap-fill {
height: 100%;
width: var(--w, 2%);
min-width: 2px;
background: #2563eb;
}
.gap-fill.warn {
background: #b45309;
}
.gap-fill.danger {
background: #b42318;
}
.gap-amount {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
font-weight: 800;
}
.gap-tags {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.gap-tag {
display: inline-flex;
min-height: 23px;
align-items: center;
padding: 3px 7px;
border: 1px solid #d7dee8;
border-radius: 6px;
background: rgba(255, 255, 255, 0.82);
font-size: 11px;
font-weight: 900;
color: #273241;
}
button.gap-tag {
cursor: pointer;
box-shadow: none;
min-height: 23px;
line-height: 1.15;
}
button.gap-tag:hover {
background: #eef2f6;
transform: none;
}
.detail-buttons {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-top: auto;
}
.detail-buttons button {
min-height: 34px;
padding: 7px 9px;
font-size: 12px;
border-radius: 8px;
box-shadow: none;
}
.gap-panel {
display: grid;
gap: 12px;
}
.gap-panel h2 {
font-size: 20px;
}
.rationale-outline {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin: 6px 0 8px;
}
.rationale-card {
display: grid;
gap: 6px;
min-height: 112px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: #f8fafc;
}
.rationale-card strong {
font-size: 13px;
line-height: 1.25;
}
.rationale-card span {
color: #4b5563;
font-size: 12px;
line-height: 1.55;
}
.bridge-table table {
width: 100%;
min-width: 1440px;
table-layout: fixed;
}
.bridge-table th,
.bridge-table td {
width: 104px;
box-sizing: border-box;
}
.bridge-table th:first-child,
.bridge-table td:first-child {
width: 64px;
}
.bridge-value-with-rate {
display: flex;
align-items: baseline;
justify-content: flex-end;
gap: 5px;
width: 100%;
white-space: nowrap;
}
.bridge-rate {
flex: 0 0 auto;
min-width: 42px;
color: inherit;
font-size: 10px;
font-weight: 800;
text-align: right;
opacity: 0.82;
}
.bridge-number {
appearance: none;
border: 0;
background: transparent;
color: #111827;
font: inherit;
font-weight: 900;
font-variant-numeric: tabular-nums;
text-align: right;
white-space: nowrap;
cursor: pointer;
padding: 0;
border-radius: 0;
box-shadow: none !important;
min-height: 0;
line-height: inherit;
}
.bridge-number:hover {
color: #111827;
background: transparent;
box-shadow: none;
transform: none;
}
.bridge-diff,
.bridge-diff .bridge-number {
font-weight: 900;
}
.bridge-positive,
.bridge-positive .bridge-number {
color: #1d4ed8;
}
.bridge-negative,
.bridge-negative .bridge-number {
color: #b42318;
}
.bridge-zero,
.bridge-zero .bridge-number {
color: #475569;
}
.bridge-positive .bridge-number:hover,
.bridge-negative .bridge-number:hover,
.bridge-zero .bridge-number:hover {
color: inherit;
}
.sign-plus {
color: #1d4ed8;
font-weight: 900;
}
.sign-minus {
color: #b42318;
font-weight: 900;
}
.bridge-note {
display: block;
margin-top: 4px;
color: #64748b;
font-size: 11px;
line-height: 1.35;
white-space: normal;
max-width: 170px;
margin-left: auto;
}
.substantive-table table {
min-width: 1180px;
}
.substantive-table tbody tr[data-section="수익"] {
background: #fbfdff;
}
.substantive-table tbody tr[data-section="영업비용"] {
background: #fffdf8;
}
.source-note {
color: #64748b;
font-size: 11px;
line-height: 1.45;
max-width: 270px;
}
.insight-cell {
min-width: 250px;
color: #334155;
font-size: 12px;
line-height: 1.5;
}
.number {
text-align: right;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.positive {
color: #0f766e;
font-weight: 900;
}
.negative {
color: #b42318;
font-weight: 900;
}
.modal-card.gap-modal-card {
width: min(1120px, calc(100vw - 32px));
max-height: min(84vh, 900px);
border-radius: 10px;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
padding: 0;
overflow: hidden;
}
.modal-card.gap-modal-card.wide {
width: min(1720px, calc(100vw - 20px));
max-height: min(90vh, 980px);
}
.gap-modal-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--line);
}
.gap-modal-head h2 {
font-size: 18px;
}
.gap-modal-body {
overflow: auto;
padding: 14px 16px 18px;
}
.gap-modal-body table {
min-width: 920px;
}
.gap-modal-body th {
position: sticky;
top: 0;
z-index: 2;
background: #f8fafc;
box-shadow: inset 0 -1px 0 var(--line);
}
.raw-account-comparison-wrap {
max-height: calc(90vh - 160px);
}
.raw-account-comparison-table {
min-width: 1640px;
table-layout: fixed;
}
.raw-account-comparison-table th,
.raw-account-comparison-table td {
vertical-align: top;
}
.raw-account-comparison-table th:nth-child(1),
.raw-account-comparison-table td:nth-child(1) {
position: sticky;
left: 0;
z-index: 1;
width: 62px;
background: #ffffff;
box-shadow: inset -1px 0 0 var(--line);
}
.raw-account-comparison-table th:nth-child(1) {
z-index: 4;
background: #f8fafc;
}
.raw-account-comparison-table th:nth-child(2),
.raw-account-comparison-table td:nth-child(2) {
width: 88px;
}
.raw-account-comparison-table th:nth-child(3),
.raw-account-comparison-table td:nth-child(3) {
width: 104px;
}
.raw-account-comparison-table th:nth-child(4),
.raw-account-comparison-table td:nth-child(4) {
width: 150px;
}
.raw-account-comparison-table th:nth-child(5),
.raw-account-comparison-table td:nth-child(5),
.raw-account-comparison-table th:nth-child(6),
.raw-account-comparison-table td:nth-child(6),
.raw-account-comparison-table th:nth-child(7),
.raw-account-comparison-table td:nth-child(7),
.raw-account-comparison-table th:nth-child(8),
.raw-account-comparison-table td:nth-child(8),
.raw-account-comparison-table th:nth-child(9),
.raw-account-comparison-table td:nth-child(9) {
width: 128px;
}
.raw-account-comparison-table th:nth-child(10),
.raw-account-comparison-table td:nth-child(10),
.raw-account-comparison-table th:nth-child(11),
.raw-account-comparison-table td:nth-child(11) {
width: 210px;
}
.raw-account-comparison-table th:nth-child(12),
.raw-account-comparison-table td:nth-child(12) {
width: 190px;
}
.account-list {
display: grid;
gap: 4px;
min-width: 0;
line-height: 1.35;
}
.account-chip {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 5px;
min-width: 0;
}
.account-code {
color: #475569;
font-weight: 900;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.account-name {
min-width: 0;
overflow-wrap: anywhere;
}
@media (min-width: 1500px) {
.raw-account-comparison-table {
min-width: 100%;
}
.raw-account-comparison-table th:nth-child(10),
.raw-account-comparison-table td:nth-child(10),
.raw-account-comparison-table th:nth-child(11),
.raw-account-comparison-table td:nth-child(11) {
width: 260px;
}
}
@media (max-width: 1180px) {
.raw-account-comparison-table {
min-width: 1480px;
}
.account-chip {
grid-template-columns: 1fr;
gap: 1px;
}
}
.modal-empty {
padding: 26px;
text-align: center;
color: var(--muted);
}
@media (max-width: 1180px) {
.gap-scoreboard,
.gap-year-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.gap-hero {
grid-template-columns: 1fr;
}
.gap-actions {
justify-content: start;
}
.gap-scoreboard,
.gap-year-grid,
.rationale-outline,
.detail-buttons {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}
{% macro money(value) -%}
{{ "{:,.0f}".format(value or 0) }}
{%- endmacro %}
{% macro eok(value) -%}
{{ "{:,.1f}".format((value or 0) / 100000000) }}억
{%- endmacro %}
{% macro signed_eok(value) -%}
{%- if (value or 0) > 0 -%}+{{ "{:,.1f}".format((value or 0) / 100000000) }}억{%- else -%}{{ "{:,.1f}".format((value or 0) / 100000000) }}억{%- endif -%}
{%- endmacro %}
{% macro signed_eok_sign(value) -%}
{%- if (value or 0) > 0 -%}<span class="bridge-positive">+{{ "{:,.1f}".format((value or 0) / 100000000) }}억</span>
{%- elif (value or 0) < 0 -%}<span class="bridge-negative">-{{ "{:,.1f}".format(((value or 0) | abs) / 100000000) }}억</span>
{%- else -%}<span class="bridge-zero">0.0억</span>{%- endif -%}
{%- endmacro %}
{% macro gap_with_rate(value, basis) -%}
{%- set rate = ((value or 0) / basis * 100) if basis else 0 -%}
<span class="bridge-value-with-rate">
{{ signed_eok_sign(value) }}
<span class="bridge-rate">({{ "{:+.1f}".format(rate) }}%)</span>
</span>
{%- endmacro %}
{% block content %}
{% set data = gap_analysis %}
{% set totals = data.totals %}
<div class="gap-page">
<section class="panel gap-hero">
<div>
<h1>수익/비용 차이분석</h1>
</div>
<div class="gap-action-stack">
<div class="gap-actions">
<a class="button-link button-secondary" href="/annual-summary">연도별 수익/비용</a>
<button type="button" class="button-secondary" data-modal-kind="assumptions" data-year="all">집계 기준</button>
</div>
<button type="button" class="button-secondary" data-modal-kind="raw-account-comparison" data-year="all">
WEHAGO·Hanmac ERP 계정 비교
</button>
</div>
</section>
<section class="panel gap-panel">
<div>
<h2>핵심 브릿지</h2>
</div>
<div class="table-wrap bridge-table">
<table>
<thead>
<tr>
<th>연도</th>
<th class="number">wehago 매출</th>
<th class="number">매출조정</th>
<th class="number">wehago 매출(조정전)</th>
<th class="number">프로젝트손익분석 매출</th>
<th class="number">수익 차이</th>
<th class="number">수익 차이(조정전)</th>
<th class="number">wehago 비용</th>
<th class="number">프로젝트손익분석 비용</th>
<th class="number">비용 차이</th>
<th class="number">wehago 손익</th>
<th class="number">프로젝트손익분석 손익</th>
<th class="number">손익 차이</th>
<th class="number">손익 차이(조정전)</th>
</tr>
</thead>
<tbody>
{% for row in data.yearly_rows %}
{% set project_bridge = data.project_profit_bridge_rows | selectattr("year", "equalto", row.year) | first %}
{% set pre_adjustment_wehago_revenue = row.wehago_revenue - row.adjustments.revenue %}
{% set project_revenue = project_bridge.project_revenue if project_bridge else 0 %}
{% set project_expense = project_bridge.project_expense if project_bridge else 0 %}
{% set project_profit = project_revenue - project_expense %}
{% set revenue_gap = row.wehago_revenue - project_revenue %}
{% set pre_adjustment_revenue_gap = pre_adjustment_wehago_revenue - project_revenue %}
{% set expense_gap = row.wehago_operating_expense - project_expense %}
{% set profit_gap = row.wehago_operating_profit - project_profit %}
{% set pre_adjustment_profit_gap = pre_adjustment_revenue_gap - expense_gap %}
<tr>
<td>{{ row.year }}년</td>
<td class="number">
<button type="button" class="bridge-number" data-modal-kind="revenue-amount" data-year="{{ row.year }}">{{ eok(row.wehago_revenue) }}</button>
</td>
<td class="number">
<button type="button" class="bridge-number" data-modal-kind="revenue-adjustment" data-year="{{ row.year }}">{{ signed_eok(row.adjustments.revenue) }}</button>
</td>
<td class="number">
<button type="button" class="bridge-number" data-modal-kind="revenue-pre-adjustment" data-year="{{ row.year }}">{{ eok(pre_adjustment_wehago_revenue) }}</button>
</td>
<td class="number">
{% if project_bridge %}
<button type="button" class="bridge-number" data-modal-kind="project-profit" data-year="{{ row.year }}">{{ eok(project_revenue) }}</button>
{% else %}-{% endif %}
</td>
<td class="number bridge-diff">
<button type="button" class="bridge-number" data-modal-kind="revenue-gap" data-year="{{ row.year }}">{{ signed_eok_sign(revenue_gap) }}</button>
</td>
<td class="number bridge-diff">
<button type="button" class="bridge-number" data-modal-kind="revenue-gap-pre" data-year="{{ row.year }}">{{ gap_with_rate(pre_adjustment_revenue_gap, row.wehago_revenue) }}</button>
</td>
<td class="number">
<button type="button" class="bridge-number" data-modal-kind="expense-amount" data-year="{{ row.year }}">{{ eok(row.wehago_operating_expense) }}</button>
</td>
<td class="number">
{% if project_bridge %}
<button type="button" class="bridge-number" data-modal-kind="project-profit" data-year="{{ row.year }}">{{ eok(project_expense) }}</button>
{% else %}-{% endif %}
</td>
<td class="number bridge-diff">
<button type="button" class="bridge-number" data-modal-kind="expense-gap" data-year="{{ row.year }}">{{ gap_with_rate(expense_gap, row.wehago_operating_expense) }}</button>
</td>
<td class="number">
<button type="button" class="bridge-number" data-modal-kind="accounts" data-year="{{ row.year }}">{{ signed_eok(row.wehago_operating_profit) }}</button>
</td>
<td class="number">
{% if project_bridge %}
<button type="button" class="bridge-number" data-modal-kind="project-profit" data-year="{{ row.year }}">{{ signed_eok(project_profit) }}</button>
{% else %}-{% endif %}
</td>
<td class="number bridge-diff">
<button type="button" class="bridge-number" data-modal-kind="profit-gap" data-year="{{ row.year }}">{{ signed_eok_sign(profit_gap) }}</button>
</td>
<td class="number bridge-diff">
<button type="button" class="bridge-number" data-modal-kind="profit-gap-pre" data-year="{{ row.year }}">{{ signed_eok_sign(pre_adjustment_profit_gap) }}</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
</div>
<div class="modal-backdrop" id="gapModal" aria-hidden="true">
<div class="modal-card gap-modal-card" role="dialog" aria-modal="true" aria-labelledby="gapModalTitle">
<div class="gap-modal-head">
<h2 id="gapModalTitle">상세</h2>
<button type="button" class="button-secondary" id="gapModalClose">닫기</button>
</div>
<div class="gap-modal-body" id="gapModalBody"></div>
</div>
</div>
<script>
const gapData = {{ data | tojson }};
function money(value) {
return Math.round(value || 0).toLocaleString("ko-KR");
}
function eok(value) {
return `${((value || 0) / 100000000).toLocaleString("ko-KR", { maximumFractionDigits: 1, minimumFractionDigits: 1 })}억`;
}
function signedEok(value) {
const prefix = (value || 0) > 0 ? "+" : "";
return `${prefix}${eok(value)}`;
}
function signedEokSign(value) {
const amount = Math.abs(value || 0);
if ((value || 0) > 0) return `<span class="bridge-positive">+${eok(amount)}</span>`;
if ((value || 0) < 0) return `<span class="bridge-negative">-${eok(amount)}</span>`;
return `<span class="bridge-zero">${eok(0)}</span>`;
}
function gapWithRate(value, basis) {
const rate = basis ? ((value || 0) / basis) * 100 : 0;
const rateText = `${rate >= 0 ? "+" : ""}${rate.toLocaleString("ko-KR", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})}%`;
return `<span class="bridge-value-with-rate">${signedEokSign(value)}<span class="bridge-rate">(${rateText})</span></span>`;
}
function esc(value) {
return String(value ?? "").replace(/[&<>"']/g, (char) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"\"": "&quot;",
"'": "&#39;",
}[char]));
}
function filteredRows(kind, year) {
const source = {
bridge: gapData.yearly_rows,
substantive: gapData.substantive_gap_rows,
adjustments: gapData.adjustment_rows,
labor: gapData.labor_rows,
accounts: gapData.account_detail_rows,
rationale: gapData.reason_items,
assumptions: gapData.assumptions,
"project-profit": gapData.project_profit_bridge_rows,
"raw-account-comparison": gapData.raw_account_comparison_rows,
}[kind] || [];
if (kind === "assumptions" || year === "all") return source;
return source.filter((row) => String(row.year) === String(year));
}
function projectBridgeFor(year) {
return (gapData.project_profit_bridge_rows || []).find((item) => String(item.year) === String(year)) || {};
}
function yearlyRowFor(year) {
return (gapData.yearly_rows || []).find((item) => String(item.year) === String(year)) || {};
}
function accountRowsFor(year, type) {
const allowedBuckets = type === "revenue"
? ["매출"]
: ["매출원가 총액", "판관비", "판관비 조정"];
return (gapData.account_detail_rows || [])
.filter((row) => String(row.year) === String(year) && allowedBuckets.includes(row.bucket))
.sort((a, b) => String(a.account_code).localeCompare(String(b.account_code)));
}
function adjustmentRowsFor(year, type) {
const allowedBuckets = type === "revenue" ? ["revenue"] : ["cogs", "sga"];
return (gapData.adjustment_rows || [])
.filter((row) => String(row.year) === String(year) && allowedBuckets.includes(row.bucket))
.sort((a, b) => Math.abs(b.delta || 0) - Math.abs(a.delta || 0));
}
function table(headers, rows, options = {}) {
if (!rows.length) return `<div class="modal-empty">표시할 상세 데이터가 없습니다.</div>`;
const wrapClass = ["table-wrap", options.wrapClass || ""].filter(Boolean).join(" ");
const tableClass = options.tableClass || "";
return `
<div class="${wrapClass}">
<table class="${tableClass}">
<thead><tr>${headers.map((header) => `<th class="${header.className || ""}">${esc(header.label)}</th>`).join("")}</tr></thead>
<tbody>
${rows.map((row) => `
<tr>
${headers.map((header) => `<td class="${header.className || ""}">${header.render ? header.render(row) : esc(row[header.key])}</td>`).join("")}
</tr>
`).join("")}
</tbody>
</table>
</div>
`;
}
function adjustmentSummary(type, year) {
const yearly = yearlyRowFor(year);
const adjustments = yearly.adjustments || {};
const amount = type === "revenue"
? (adjustments.revenue || 0)
: (adjustments.cogs || 0) + (adjustments.sga || 0);
if (!amount) return "";
const label = type === "revenue" ? "매출 조정값" : "비용 조정값";
return table([
{ label: "조정 항목", key: "label" },
{ label: "조정 금액", className: "number", render: (row) => signedEok(row.amount) },
], [{ label, amount }]);
}
function renderAmountDetail(year, type, preAdjustment = false) {
let rows = accountRowsFor(year, type);
if (type === "revenue" && preAdjustment) {
const adjustmentByAccount = adjustmentRowsFor(year, "revenue").reduce((result, row) => {
const code = String(row.account_code || "");
result[code] = (result[code] || 0) + Number(row.delta || 0);
return result;
}, {});
rows = rows.map((row) => ({
...row,
display_amount: Number(row.wehago_amount || 0) - Number(adjustmentByAccount[String(row.account_code || "")] || 0),
}));
}
return `
${adjustmentSummary(type, year)}
<h3>계정별 금액</h3>
${table([
{ label: "구분", key: "bucket" },
{ label: "계정코드", key: "account_code" },
{ label: "계정명", key: "account_name" },
{ label: "금액", className: "number", render: (row) => money(row.display_amount ?? row.wehago_amount) },
], rows)}
`;
}
function renderGapDetail(year, type) {
const yearly = yearlyRowFor(year);
const bridge = projectBridgeFor(year);
const projectRevenue = bridge.project_revenue || 0;
const projectExpense = bridge.project_expense || 0;
const projectProfit = projectRevenue - projectExpense;
const revenueAdjustment = ((yearly.adjustments || {}).revenue || 0);
const preAdjustmentRevenue = (yearly.wehago_revenue || 0) - revenueAdjustment;
const expenseGap = (yearly.wehago_operating_expense || 0) - projectExpense;
const preAdjustmentRevenueGap = preAdjustmentRevenue - projectRevenue;
const definitions = {
revenue: [
{ label: "WEHAGO 매출", amount: yearly.wehago_revenue || 0 },
{ label: "프로젝트손익분석 매출", amount: projectRevenue },
{ label: "수익 차이", amount: (yearly.wehago_revenue || 0) - projectRevenue },
],
expense: [
{ label: "WEHAGO 비용", amount: yearly.wehago_operating_expense || 0 },
{ label: "프로젝트손익분석 비용", amount: projectExpense },
{ label: "비용 차이", amount: (yearly.wehago_operating_expense || 0) - projectExpense },
],
profit: [
{ label: "WEHAGO 손익", amount: yearly.wehago_operating_profit || 0 },
{ label: "프로젝트손익분석 손익", amount: projectProfit },
{ label: "손익 차이", amount: (yearly.wehago_operating_profit || 0) - projectProfit },
],
"revenue-pre": [
{ label: "WEHAGO 매출(조정전)", amount: preAdjustmentRevenue },
{ label: "프로젝트손익분석 매출", amount: projectRevenue },
{ label: "수익 차이(조정전)", amount: preAdjustmentRevenueGap },
],
"profit-pre": [
{ label: "수익 차이(조정전)", amount: preAdjustmentRevenueGap },
{ label: "비용 차이", amount: expenseGap },
{ label: "손익 차이(조정전)", amount: preAdjustmentRevenueGap - expenseGap },
],
};
const rows = definitions[type] || [];
const adjustmentType = type === "revenue" ? "revenue" : type === "expense" ? "expense" : "";
return `
${adjustmentType ? adjustmentSummary(adjustmentType, year) : ""}
${table([
{ label: "구분", key: "label" },
{ label: "금액", className: "number", render: (row) => signedEok(row.amount) },
], rows)}
${type === "revenue" ? `
<h3>매출 조정 근거</h3>
${renderAdjustmentEvidence(adjustmentRowsFor(year, "revenue"))}
` : ""}
${type === "expense" ? `
<h3>비용 조정 근거</h3>
${renderAdjustmentEvidence(adjustmentRowsFor(year, "expense"))}
` : ""}
`;
}
function renderAdjustmentEvidence(rows) {
return table([
{ label: "일자", key: "ledger_date" },
{ label: "전표", key: "voucher_no" },
{ label: "구분", key: "bucket" },
{ label: "계정", render: (row) => `${esc(row.account_code)} ${esc(row.account_name)}` },
{ label: "적요", key: "description" },
{ label: "조정효과", className: "number", render: (row) => signedEok(row.delta) },
], rows);
}
function renderRevenueAdjustments(year) {
const rows = adjustmentRowsFor(year, "revenue");
const total = rows.reduce((sum, row) => sum + Number(row.delta || 0), 0);
return `
${table([
{ label: "구분", key: "label" },
{ label: "금액", className: "number", render: (row) => signedEok(row.amount) },
], [{ label: "매출조정 합계", amount: total }])}
<h3>매출조정 계정</h3>
${renderAdjustmentEvidence(rows)}
`;
}
function renderBridge(year) {
const rows = filteredRows("bridge", year);
return table([
{ label: "연도", render: (row) => `${esc(row.year)}년` },
{ label: "wehago 매출", className: "number", render: (row) => eok(row.wehago_revenue) },
{ label: "매출조정", className: "number", render: (row) => signedEok((row.adjustments || {}).revenue || 0) },
{ label: "wehago 매출(조정전)", className: "number", render: (row) => eok((row.wehago_revenue || 0) - ((row.adjustments || {}).revenue || 0)) },
{ label: "프로젝트손익분석 매출", className: "number", render: (row) => eok(projectBridgeFor(row.year).project_revenue || 0) },
{ label: "수익 차이", className: "number bridge-diff", render: (row) => signedEokSign((row.wehago_revenue || 0) - (projectBridgeFor(row.year).project_revenue || 0)) },
{ label: "수익 차이(조정전)", className: "number bridge-diff", render: (row) => gapWithRate(
(row.wehago_revenue || 0)
- ((row.adjustments || {}).revenue || 0)
- (projectBridgeFor(row.year).project_revenue || 0),
row.wehago_revenue || 0
) },
{ label: "wehago 비용", className: "number", render: (row) => eok(row.wehago_operating_expense) },
{ label: "프로젝트손익분석 비용", className: "number", render: (row) => eok(projectBridgeFor(row.year).project_expense || 0) },
{ label: "비용 차이", className: "number bridge-diff", render: (row) => gapWithRate(
(row.wehago_operating_expense || 0) - (projectBridgeFor(row.year).project_expense || 0),
row.wehago_operating_expense || 0
) },
{ label: "wehago 손익", className: "number", render: (row) => signedEok(row.wehago_operating_profit) },
{ label: "프로젝트손익분석 손익", className: "number", render: (row) => {
const bridge = projectBridgeFor(row.year);
return signedEok((bridge.project_revenue || 0) - (bridge.project_expense || 0));
} },
{ label: "손익 차이", className: "number bridge-diff", render: (row) => {
const bridge = projectBridgeFor(row.year);
return signedEokSign((row.wehago_operating_profit || 0) - ((bridge.project_revenue || 0) - (bridge.project_expense || 0)));
} },
{ label: "손익 차이(조정전)", className: "number bridge-diff", render: (row) => {
const bridge = projectBridgeFor(row.year);
const revenueGapBeforeAdjustment = (row.wehago_revenue || 0)
- ((row.adjustments || {}).revenue || 0)
- (bridge.project_revenue || 0);
const expenseGap = (row.wehago_operating_expense || 0) - (bridge.project_expense || 0);
return signedEokSign(revenueGapBeforeAdjustment - expenseGap);
} },
], rows);
}
function renderSubstantive(year) {
const rows = filteredRows("substantive", year);
return `
<div class="gap-help">
<p>금액은 같은 성격 항목끼리 맞춘 실질 비교입니다. 452 매출원가 총액은 6xx 원가 상세와 더하지 않고 별도 확인합니다.</p>
<p>남은 차이 = WEHAGO - Hanmac ERP - 식별 조정효과입니다.</p>
</div>
${table([
{ label: "연도", key: "year" },
{ label: "구분", key: "section" },
{ label: "항목", key: "item_label" },
{ label: "WEHAGO", className: "number", render: (row) => eok(row.wehago_amount) },
{ label: "Hanmac ERP", className: "number", render: (row) => eok(row.erp_amount) },
{ label: "차이", className: "number", render: (row) => signedEok(row.gap_amount) },
{ label: "조정효과", className: "number", render: (row) => signedEok(row.adjustment_amount) },
{ label: "남은 차이", className: "number", render: (row) => signedEok(row.residual_gap) },
{ label: "WEHAGO 계정", key: "wehago_accounts" },
{ label: "ERP 계정", key: "erp_accounts" },
{ label: "해석", key: "interpretation" },
], rows)}
`;
}
function renderAdjustments(year) {
const rows = filteredRows("adjustments", year);
return table([
{ label: "일자", key: "ledger_date" },
{ label: "전표", key: "voucher_no" },
{ label: "구분", key: "bucket" },
{ label: "계정", render: (row) => `${esc(row.account_code)} ${esc(row.account_name)}` },
{ label: "적요", key: "description" },
{ label: "거래처/구분", key: "vendor_name" },
{ label: "효과", className: "number", render: (row) => signedEok(row.delta) },
{ label: "차변", className: "number", render: (row) => money(row.debit) },
{ label: "대변", className: "number", render: (row) => money(row.credit) },
], rows);
}
function renderLabor(year) {
const rows = filteredRows("labor", year);
return table([
{ label: "연도", key: "year" },
{ label: "원가성 인건비", className: "number", render: (row) => eok(row.direct_labor) },
{ label: "판관 인건비", className: "number", render: (row) => eok(row.sga_labor) },
{ label: "WEHAGO 인건비 합계", className: "number", render: (row) => eok(row.wehago_labor_total) },
{ label: "프로젝트손익1 인건비", className: "number", render: (row) => eok(row.hanmac_labor_total) },
{ label: "프로젝트손익1 - WEHAGO 차이", className: "number", render: (row) => signedEok(row.hanmac_wehago_labor_gap) },
{ label: "R&D성", className: "number", render: (row) => eok(row.rnd_like) },
{ label: "총시간", className: "number", render: (row) => `${money(row.total_hours)}h` },
{ label: "역산 단가", className: "number", render: (row) => `${money(row.implied_hourly_without_rnd)}원/h` },
{ label: "검토", render: (row) => (row.tags || []).map((tag) => `<span class="gap-tag">${esc(tag)}</span>`).join(" ") },
], rows);
}
function renderProjectProfit(year) {
const rows = filteredRows("project-profit", year);
if (!rows.length) return `<div class="modal-empty">표시할 손익 차이 구성 데이터가 없습니다.</div>`;
const row = rows[0];
const yearly = (gapData.yearly_rows || []).find((item) => String(item.year) === String(year)) || {};
const yearlyAdjustments = yearly.adjustments || {};
const expenseAdjustment = (yearlyAdjustments.cogs || 0) + (yearlyAdjustments.sga || 0);
const projectRevenue = row.project_revenue || 0;
const projectExpense = row.project_expense || 0;
const projectProfit = projectRevenue - projectExpense;
const preAdjustmentWehagoRevenue = (yearly.wehago_revenue || 0) - (yearlyAdjustments.revenue || 0);
const revenueGap = (yearly.wehago_revenue || 0) - projectRevenue;
const adjustedRevenueGap = (yearly.wehago_revenue || 0) - ((row.erp_revenue || 0) + (row.revenue_adjustment_total || 0));
const expenseGap = (yearly.wehago_operating_expense || 0) - projectExpense;
const adjustedExpenseGap = (yearly.wehago_operating_expense || 0) - (projectExpense + expenseAdjustment);
const profitGap = (yearly.wehago_operating_profit || 0) - projectProfit;
const adjustedProjectProfit = ((row.erp_revenue || 0) + (row.revenue_adjustment_total || 0)) - (projectExpense + expenseAdjustment);
const adjustedProfitGap = (yearly.wehago_operating_profit || 0) - adjustedProjectProfit;
const projectProfitBasis = [
row.project_profit_financial_logic_version ? `재무 로직 ${row.project_profit_financial_logic_version}` : "",
row.project_profit_h_mapping_version ? `H코드 매핑 ${row.project_profit_h_mapping_version}` : "",
row.project_profit_generated_at ? `계산시각 ${String(row.project_profit_generated_at).replace("T", " ")}` : "",
].filter(Boolean).join(" · ");
const items = [
["wehago 매출", yearly.wehago_revenue || 0, "WEHAGO 계정별 원장 기준 매출액 전체"],
["wehago 매출(조정전)", preAdjustmentWehagoRevenue, "WEHAGO 매출에서 식별된 진행률·결산 매출 조정효과를 제외한 비교값"],
["프로젝트손익분석 매출", projectRevenue, "프로젝트 손익분석 페이지의 기간 ERP 매출 총합"],
["수익 차이", revenueGap, "WEHAGO 매출 - 프로젝트손익분석 매출"],
["진행률 조정 고려 후 수익 잔차", adjustedRevenueGap, "WEHAGO 매출 - (HANMAC ERP 매출 + 식별된 진행률·결산 매출 조정)"],
["HANMAC ERP 매출", row.erp_revenue, "HANMAC ERP 전표 4xx 매출 기준"],
["식별된 결산·진행률 매출 조정 순액", row.revenue_adjustment_total, `${money(row.revenue_adjustment_count)}개 전표의 순효과`],
["공통매출", row.common_revenue, `${money(row.common_revenue_row_count || 0)}개 ZZZZZZ 공통매출 행`],
["음수 청구금액", row.project_negative_billing, "청구 취소·차감 등 음수 청구의 합계"],
["ERP 매출-세금계산서 차이", row.project_revenue_billing_gap, "HANMAC ERP 매출 - 프로젝트손익분석 매출세금계산서 발행액"],
["wehago 비용", yearly.wehago_operating_expense || 0, "WEHAGO 계정별 원장 기준 원가 + 판관비"],
["프로젝트손익분석 비용", projectExpense, "프로젝트 손익분석 페이지의 기간 배부비용"],
["비용 차이", expenseGap, "WEHAGO 비용 - 프로젝트손익분석 비용"],
["회계감사 조정 고려 후 비용 잔차", adjustedExpenseGap, "WEHAGO 비용 - (프로젝트손익분석 비용 + 식별된 대체·상계·결산 비용 조정)"],
["식별된 비용 조정효과", expenseAdjustment, "WEHAGO 원장에서 감사·결산·대체 문구로 식별한 원가/판관비 조정 순효과"],
["wehago 손익", yearly.wehago_operating_profit || 0, "WEHAGO 매출 - WEHAGO 비용"],
["프로젝트손익분석 손익", projectProfit, "프로젝트손익분석 매출 - 프로젝트손익분석 비용"],
["손익 차이", profitGap, "WEHAGO 손익 - 프로젝트손익분석 손익"],
["조정 고려 후 손익 잔차", adjustedProfitGap, "진행률 매출 조정과 비용 조정을 함께 고려한 뒤의 손익 차이"],
["프로젝트손익1 인건비", row.hanmac_labor_total, "프로젝트 손익분석 페이지1의 인건비+조정인건비 합계"],
["중복 방지 제외 공통 급여", row.common_labor_excluded, "한맥 인건비와 중복되지 않도록 미배부"],
["부족근로 판관비", row.hanmac_missing_regular_sga_allocated, "한맥 인건비 구성요소이며 총비용에는 한 번만 반영"],
["공통 비인건비 배부", row.common_cost_allocated, "공통 5xx 중 급여성 비용 제외"],
["공통 판관비 배부", row.common_sga_allocated, "공통 6xx 판관비"],
["프로젝트 직접 비인건비", row.direct_project_nonlabor, "프로젝트 코드가 연결된 직접 비용"],
["손익 브릿지 검증 차이", row.profit_bridge_validation_gap, "수익 차이 - 비용 차이 - 손익 차이, 0원이면 산식 일치"],
["매출 브릿지 검증 차이", row.revenue_bridge_validation_gap, "WEHAGO→ERP 차이 + ERP→프로젝트 차이 검증, 0원이면 산식 일치"],
["ERP+조정매출 검증 차이", row.adjusted_revenue_bridge_validation_gap, "ERP 원매출 + 조정액 - ERP+조정매출, 0원이면 산식 일치"],
];
const summaryTable = table([
{ label: "구분", key: "label" },
{ label: "금액", className: "number", render: (item) => signedEok(item.amount) },
{ label: "해석", key: "note" },
], items.map(([label, amount, note]) => ({ label, amount, note })));
const adjustments = (gapData.adjustment_rows || [])
.filter((item) => String(item.year) === String(year) && item.bucket === "revenue")
.sort((a, b) => String(a.ledger_date).localeCompare(String(b.ledger_date)));
const adjustmentTable = table([
{ label: "일자", key: "ledger_date" },
{ label: "전표", key: "voucher_no" },
{ label: "계정", render: (item) => `${esc(item.account_code)} ${esc(item.account_name)}` },
{ label: "적요", key: "description" },
{ label: "구분/거래처", key: "vendor_name" },
{ label: "조정효과", className: "number", render: (item) => signedEok(item.delta) },
], adjustments);
const expenseAdjustments = (gapData.adjustment_rows || [])
.filter((item) => String(item.year) === String(year) && ["cogs", "sga"].includes(item.bucket))
.sort((a, b) => Math.abs(b.delta || 0) - Math.abs(a.delta || 0));
const expenseAdjustmentTable = table([
{ label: "일자", key: "ledger_date" },
{ label: "전표", key: "voucher_no" },
{ label: "구분", key: "bucket" },
{ label: "계정", render: (item) => `${esc(item.account_code)} ${esc(item.account_name)}` },
{ label: "적요", key: "description" },
{ label: "구분/거래처", key: "vendor_name" },
{ label: "조정효과", className: "number", render: (item) => signedEok(item.delta) },
], expenseAdjustments);
return `
<div class="gap-help">
<p><strong>핵심 브릿지의 프로젝트손익분석 매출은 기간 ERP 매출 총합, 비용은 기간 배부비용입니다.</strong></p>
${projectProfitBasis ? `<p>적용 기준: ${esc(projectProfitBasis)}</p>` : ""}
<p>수익 잔차는 진행률·결산 매출 조정을 고려한 뒤에도 남는 차이를, 비용 잔차는 대체·상계·결산 비용 조정을 고려한 뒤에도 남는 차이를 보여줍니다.</p>
</div>
${summaryTable}
<h3>식별된 매출 조정 전표 근거</h3>
${adjustmentTable}
<h3>식별된 비용 조정 전표 근거</h3>
${expenseAdjustmentTable}
`;
}
function renderAccounts(year) {
const rows = filteredRows("accounts", year);
return table([
{ label: "연도", key: "year" },
{ label: "구분", key: "bucket" },
{ label: "계정코드", key: "account_code" },
{ label: "계정명", key: "account_name" },
{ label: "WEHAGO 표시금액", className: "number", render: (row) => money(row.wehago_amount) },
], rows);
}
function renderAssumptions() {
return `<div class="gap-help">${gapData.assumptions.map((item) => `<p>${esc(item)}</p>`).join("")}</div>`;
}
function renderAccountList(value) {
const text = String(value || "").trim();
if (!text) return "";
const parts = text
.split(/\s*[,;]\s*/)
.map((part) => part.trim())
.filter(Boolean);
const items = parts.length ? parts : [text];
return `
<div class="account-list">
${items.map((item) => {
const match = item.match(/^([A-Za-z]?\d{2,}(?:-\d+)?)\s+(.+)$/);
if (!match) return `<span class="account-name">${esc(item)}</span>`;
return `
<span class="account-chip">
<span class="account-code">${esc(match[1])}</span>
<span class="account-name">${esc(match[2])}</span>
</span>
`;
}).join("")}
</div>
`;
}
function renderRawAccountComparison(year) {
const rows = filteredRows("raw-account-comparison", year);
const summary = `
<div class="gap-help">
<p><strong>WEHAGO 조정 전 환산액 = WEHAGO 현재액 - 식별된 감사·결산·대체 조정효과</strong></p>
<p>Hanmac ERP는 원천 전표의 5xx 원가·6xx 판관비 및 대응 유사 계정군 합계입니다. 452 매출원가 총액과 WEHAGO 6xx 원가 상세는 중복 합산하지 않습니다.</p>
</div>
`;
return summary + table([
{ label: "연도", key: "year" },
{ label: "구분", key: "section" },
{ label: "비교단위", key: "comparison_level" },
{ label: "계정/계정군", key: "item_label" },
{ label: "WEHAGO 현재액", className: "number", render: (row) => money(row.wehago_current_amount) },
{ label: "감사·대체 조정", className: "number", render: (row) => signedEok(row.audit_adjustment_amount) },
{ label: "WEHAGO 조정 전", className: "number", render: (row) => money(row.wehago_pre_adjustment_amount) },
{ label: "Hanmac ERP", className: "number", render: (row) => money(row.erp_amount) },
{ label: "조정 전 차이", className: "number", render: (row) => signedEok(row.pre_adjustment_gap) },
{ label: "WEHAGO 계정", render: (row) => renderAccountList(row.wehago_accounts) },
{ label: "Hanmac ERP 계정", render: (row) => renderAccountList(row.erp_accounts) },
{ label: "비고", key: "note" },
], rows, {
wrapClass: "raw-account-comparison-wrap",
tableClass: "raw-account-comparison-table",
});
}
function renderRationale(year, tag) {
const reason = (gapData.reason_items || []).find((item) => String(item.year) === String(year) && item.tag === tag);
const yearly = (gapData.yearly_rows || []).find((item) => String(item.year) === String(year)) || {};
if (!reason) {
return `<div class="modal-empty">해당 판단 근거를 찾지 못했습니다.</div>`;
}
const revenueAccounts = (gapData.account_detail_rows || [])
.filter((row) => String(row.year) === String(year) && row.bucket === "매출")
.sort((a, b) => Math.abs(b.wehago_amount || 0) - Math.abs(a.wehago_amount || 0))
.slice(0, 8);
const costAccounts = (gapData.account_detail_rows || [])
.filter((row) => String(row.year) === String(year) && ["매출원가 총액", "원가 상세", "판관비", "판관비 조정"].includes(row.bucket))
.sort((a, b) => Math.abs(b.wehago_amount || 0) - Math.abs(a.wehago_amount || 0))
.slice(0, 10);
const relatedAdjustments = (gapData.adjustment_rows || [])
.filter((row) => String(row.year) === String(year) && (
tag.includes("매출") ? row.bucket === "revenue" :
tag.includes("원가") || tag.includes("452") || tag.includes("6xx") ? ["cogs", "sga"].includes(row.bucket) :
true
))
.sort((a, b) => Math.abs(b.delta || 0) - Math.abs(a.delta || 0))
.slice(0, 8);
const classificationRows = (gapData.classification_review_rows || [])
.filter((row) => String(row.year) === String(year))
.filter((row) => tag.includes("원가/판관비") || tag.includes("6xx/8xx"))
.sort((a, b) => Math.abs((b.erp_sga_amount || 0) - (b.wehago_sga_amount || 0)) - Math.abs((a.erp_sga_amount || 0) - (a.wehago_sga_amount || 0)));
const basisTable = table([
{ label: "근거 항목", key: "label" },
{ label: "금액/값", className: "number", render: (row) => typeof row.value === "number" ? (Math.abs(row.value) >= 1000000 ? signedEok(row.value) : money(row.value)) : esc(row.value) },
], reason.basis_rows || []);
const outline = (reason.outline_rows || []).length
? `<div class="rationale-outline">${reason.outline_rows.map((item) => `
<div class="rationale-card">
<strong>${esc(item.title)}</strong>
<span>${esc(item.body)}</span>
</div>
`).join("")}</div>`
: "";
const nextChecks = (reason.next_checks || []).map((item) => `<li>${esc(item)}</li>`).join("");
const accountRows = tag.includes("매출") ? revenueAccounts : costAccounts;
const accountTitle = tag.includes("매출") ? "관련 매출 계정" : "관련 원가/판관비 계정";
return `
<div class="gap-panel">
<div>
<h2>${esc(reason.tag)}</h2>
<p class="gap-help">${esc(reason.summary)}</p>
</div>
${outline}
${basisTable}
<div>
<h2>다음 확인 항목</h2>
<ul class="gap-help">${nextChecks}</ul>
</div>
${classificationRows.length ? `
<div>
<h2>계정명·적요 검토 결과</h2>
${table([
{ label: "항목", key: "label" },
{ label: "판단", key: "finding" },
{ label: "ERP 판관비", className: "number", render: (row) => eok(row.erp_sga_amount) },
{ label: "WEHAGO 원가", className: "number", render: (row) => eok(row.wehago_cost_amount) },
{ label: "WEHAGO 판관비", className: "number", render: (row) => eok(row.wehago_sga_amount) },
{ label: "ERP 계정", key: "erp_account_names" },
{ label: "WEHAGO 계정", key: "wehago_account_names" },
], classificationRows)}
</div>
` : ""}
<div>
<h2>${accountTitle}</h2>
${table([
{ label: "구분", key: "bucket" },
{ label: "계정", render: (row) => `${esc(row.account_code)} ${esc(row.account_name)}` },
{ label: "금액", className: "number", render: (row) => eok(row.wehago_amount) },
], accountRows)}
</div>
<div>
<h2>관련 감사/결산 조정 전표</h2>
${table([
{ label: "일자", key: "ledger_date" },
{ label: "전표", key: "voucher_no" },
{ label: "구분", key: "bucket" },
{ label: "계정", render: (row) => `${esc(row.account_code)} ${esc(row.account_name)}` },
{ label: "적요", key: "description" },
{ label: "효과", className: "number", render: (row) => signedEok(row.delta) },
], relatedAdjustments)}
</div>
<div>
<h2>연도 요약</h2>
${table([
{ label: "항목", key: "label" },
{ label: "금액", className: "number", render: (row) => signedEok(row.value) },
], [
{ label: "조정 후 매출 차이", value: yearly.adjusted_revenue_gap || 0 },
{ label: "조정 후 원가 차이", value: yearly.adjusted_cost_gap || 0 },
{ label: "조정 후 판관비 차이", value: yearly.adjusted_sga_gap || 0 },
{ label: "조정 후 영업비용 차이", value: yearly.adjusted_operating_expense_gap || 0 },
])}
</div>
</div>
`;
}
function openModal(kind, year, tag = "") {
const modal = document.getElementById("gapModal");
const title = document.getElementById("gapModalTitle");
const body = document.getElementById("gapModalBody");
const card = modal.querySelector(".gap-modal-card");
const labels = {
bridge: `${year}년 차이 브릿지`,
"revenue-amount": `${year}년 WEHAGO 매출 계정별 금액`,
"revenue-adjustment": `${year}년 매출조정`,
"revenue-pre-adjustment": `${year}년 WEHAGO 매출(조정전) 계정별 금액`,
"expense-amount": `${year}년 WEHAGO 비용 계정별 금액`,
"revenue-gap": `${year}년 수익 차이`,
"revenue-gap-pre": `${year}년 수익 차이(조정전)`,
"expense-gap": `${year}년 비용 차이`,
"profit-gap": `${year}년 손익 차이`,
"profit-gap-pre": `${year}년 손익 차이(조정전)`,
substantive: year === "all" ? "주요 실질 차이 항목" : `${year}년 주요 실질 차이 항목`,
adjustments: `${year}년 감사/결산 조정 전표`,
labor: `${year}년 WEHAGO·한맥 인건비 비교`,
"project-profit": `${year}년 프로젝트손익·WEHAGO 손익 차이`,
accounts: `${year}년 계정 상세`,
rationale: `${year}년 판단 근거`,
assumptions: "집계 기준",
"raw-account-comparison": "WEHAGO 조정 전 · Hanmac ERP 계정 비교",
};
const renderers = {
bridge: renderBridge,
"revenue-amount": (selectedYear) => renderAmountDetail(selectedYear, "revenue", false),
"revenue-adjustment": renderRevenueAdjustments,
"revenue-pre-adjustment": (selectedYear) => renderAmountDetail(selectedYear, "revenue", true),
"expense-amount": (selectedYear) => renderAmountDetail(selectedYear, "expense", false),
"revenue-gap": (selectedYear) => renderGapDetail(selectedYear, "revenue"),
"revenue-gap-pre": (selectedYear) => renderGapDetail(selectedYear, "revenue-pre"),
"expense-gap": (selectedYear) => renderGapDetail(selectedYear, "expense"),
"profit-gap": (selectedYear) => renderGapDetail(selectedYear, "profit"),
"profit-gap-pre": (selectedYear) => renderGapDetail(selectedYear, "profit-pre"),
substantive: renderSubstantive,
adjustments: renderAdjustments,
labor: renderLabor,
"project-profit": renderProjectProfit,
accounts: renderAccounts,
rationale: (selectedYear) => renderRationale(selectedYear, tag),
assumptions: renderAssumptions,
"raw-account-comparison": renderRawAccountComparison,
};
title.textContent = kind === "rationale" ? `${year}${tag}` : (labels[kind] || "상세");
body.innerHTML = (renderers[kind] || renderBridge)(year);
card?.classList.toggle("wide", kind === "raw-account-comparison");
modal.classList.add("open");
modal.setAttribute("aria-hidden", "false");
}
function closeModal() {
const modal = document.getElementById("gapModal");
modal.classList.remove("open");
modal.setAttribute("aria-hidden", "true");
}
document.querySelectorAll("[data-modal-kind]").forEach((button) => {
button.addEventListener("click", () => openModal(button.dataset.modalKind, button.dataset.year, button.dataset.tag || ""));
});
document.getElementById("gapModalClose")?.addEventListener("click", closeModal);
document.getElementById("gapModal")?.addEventListener("click", (event) => {
if (event.target.id === "gapModal") closeModal();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeModal();
});
</script>
{% endblock %}