8549 lines
553 KiB
HTML
8549 lines
553 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ko">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>PTC 관리 계정 시안</title>
|
||
<script>
|
||
window.__ptcBootStatus = { failed: false, reason: "" };
|
||
function ptcBootFail(reason) {
|
||
window.__ptcBootStatus = { failed: true, reason: reason || "필수 스크립트를 불러오지 못했습니다." };
|
||
const fallback = document.getElementById("ptc-boot-fallback");
|
||
if (fallback) {
|
||
fallback.style.display = "block";
|
||
const detail = document.getElementById("ptc-boot-detail");
|
||
if (detail) detail.textContent = window.__ptcBootStatus.reason;
|
||
}
|
||
}
|
||
window.addEventListener("error", function (event) {
|
||
const parts = [];
|
||
if (event?.message) parts.push(event.message);
|
||
if (event?.filename) parts.push(event.filename);
|
||
if (event?.lineno) parts.push(`line ${event.lineno}`);
|
||
if (event?.colno) parts.push(`col ${event.colno}`);
|
||
ptcBootFail(`화면 실행 중 오류: ${parts.filter(Boolean).join(" / ") || "알 수 없는 오류"}`);
|
||
});
|
||
window.addEventListener("unhandledrejection", function (event) {
|
||
const reason = event?.reason;
|
||
const message = typeof reason === "string" ? reason : reason?.message;
|
||
ptcBootFail(`화면 실행 중 오류: ${message || "알 수 없는 오류"}`);
|
||
});
|
||
</script>
|
||
<script
|
||
crossorigin="anonymous"
|
||
src="https://unpkg.com/react@18/umd/react.production.min.js"
|
||
onerror="if(this.dataset.retry!=='1'){this.dataset.retry='1';this.src='https://cdn.jsdelivr.net/npm/react@18/umd/react.production.min.js';}else{ptcBootFail('React 스크립트를 불러오지 못했습니다.');}"
|
||
></script>
|
||
<script
|
||
crossorigin="anonymous"
|
||
src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"
|
||
onerror="if(this.dataset.retry!=='1'){this.dataset.retry='1';this.src='https://cdn.jsdelivr.net/npm/react-dom@18/umd/react-dom.production.min.js';}else{ptcBootFail('ReactDOM 스크립트를 불러오지 못했습니다.');}"
|
||
></script>
|
||
<script
|
||
crossorigin="anonymous"
|
||
src="https://unpkg.com/@babel/standalone/babel.min.js"
|
||
onerror="if(this.dataset.retry!=='1'){this.dataset.retry='1';this.src='https://cdn.jsdelivr.net/npm/@babel/standalone/babel.min.js';}else{ptcBootFail('Babel 스크립트를 불러오지 못했습니다.');}"
|
||
></script>
|
||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+KR:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||
<style>
|
||
:root {
|
||
--ink: #0f1c2e;
|
||
--muted: #66788f;
|
||
--muted-strong: #4b5d73;
|
||
--line: #d8e2ec;
|
||
--blue: #113f67;
|
||
--cyan: #1f7a8c;
|
||
--mist: #eff5fb;
|
||
--soft: rgba(255,255,255,0.9);
|
||
--good: #1b7f5a;
|
||
--warn: #b85c38;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0;
|
||
font-family: 'IBM Plex Sans KR', sans-serif;
|
||
color: var(--ink);
|
||
background:
|
||
linear-gradient(125deg, rgba(17,63,103,0.10), transparent 30%),
|
||
radial-gradient(circle at 85% 10%, rgba(31,122,140,0.12), transparent 22%),
|
||
linear-gradient(180deg, #f8fbff 0%, #edf2f7 100%);
|
||
}
|
||
.page { width: min(1820px, calc(100vw - 8px)); margin: 0 auto; padding: 18px 0 56px; }
|
||
.page-head {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
justify-content: space-between;
|
||
gap: 18px;
|
||
margin-bottom: 16px;
|
||
}
|
||
.tab-row {
|
||
display: inline-flex;
|
||
gap: 8px;
|
||
padding: 6px;
|
||
border: 1px solid rgba(216,226,236,0.95);
|
||
border-radius: 999px;
|
||
background: rgba(255,255,255,0.8);
|
||
box-shadow: 0 12px 28px rgba(15, 28, 46, 0.05);
|
||
}
|
||
.tab-button {
|
||
height: 42px;
|
||
padding: 0 18px;
|
||
border: none;
|
||
border-radius: 999px;
|
||
background: transparent;
|
||
color: var(--muted);
|
||
font: inherit;
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
}
|
||
.tab-button.active {
|
||
background: linear-gradient(135deg, var(--blue), #1c577f);
|
||
color: white;
|
||
box-shadow: 0 10px 24px rgba(17,63,103,0.18);
|
||
}
|
||
.panel {
|
||
background: rgba(248, 251, 254, 0.82);
|
||
border: 1px solid rgba(232, 239, 246, 0.95);
|
||
border-radius: 18px;
|
||
box-shadow: 0 3px 10px rgba(15, 28, 46, 0.025);
|
||
backdrop-filter: blur(4px);
|
||
}
|
||
.layout {
|
||
display: grid;
|
||
grid-template-columns: 320px minmax(0, 1fr);
|
||
gap: 18px;
|
||
align-items: start;
|
||
}
|
||
.metrics {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
gap: 12px;
|
||
}
|
||
.split {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 18px;
|
||
margin-top: 18px;
|
||
}
|
||
.budget-split {
|
||
display: grid;
|
||
grid-template-columns: 1fr;
|
||
gap: 18px;
|
||
margin-top: 18px;
|
||
align-items: start;
|
||
}
|
||
.master-inline-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 12px;
|
||
align-items: start;
|
||
margin-top: 16px;
|
||
}
|
||
.project-head-grid {
|
||
display: grid;
|
||
grid-template-columns: minmax(260px, 0.72fr) minmax(760px, 1.28fr);
|
||
gap: 18px;
|
||
align-items: start;
|
||
}
|
||
.project-title-row {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
}
|
||
.project-title-main {
|
||
min-width: 0;
|
||
flex: 1;
|
||
}
|
||
.project-inline-meta {
|
||
margin-top: 10px;
|
||
display: grid;
|
||
gap: 6px;
|
||
}
|
||
.project-inline-meta-line {
|
||
color: var(--muted);
|
||
font-size: 14px;
|
||
line-height: 1.45;
|
||
word-break: break-word;
|
||
}
|
||
.project-inline-meta-line strong {
|
||
color: var(--text);
|
||
font-weight: 700;
|
||
}
|
||
.project-inline-edit {
|
||
flex-shrink: 0;
|
||
min-width: 92px;
|
||
height: 42px;
|
||
border: none;
|
||
border-radius: 12px;
|
||
padding: 0 16px;
|
||
background: var(--blue);
|
||
color: white;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
}
|
||
.project-editor-card {
|
||
border: 1px solid var(--line);
|
||
border-radius: 22px;
|
||
background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(244,248,252,0.98));
|
||
padding: 14px 16px;
|
||
}
|
||
.project-meta-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 10px 18px;
|
||
}
|
||
.project-meta-item {
|
||
min-width: 0;
|
||
}
|
||
.project-meta-label {
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
margin-bottom: 4px;
|
||
}
|
||
.project-meta-value {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
line-height: 1.45;
|
||
word-break: break-word;
|
||
}
|
||
.project-editor-actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
margin-top: 10px;
|
||
}
|
||
.summary-stack {
|
||
display: grid;
|
||
gap: 12px;
|
||
}
|
||
.summary-card {
|
||
border: 1px solid #eef3f8;
|
||
border-radius: 16px;
|
||
background: rgba(255,255,255,0.9);
|
||
padding: 16px;
|
||
}
|
||
.summary-card-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 14px 22px;
|
||
}
|
||
.summary-card-grid.compact {
|
||
gap: 14px 18px;
|
||
}
|
||
.summary-value {
|
||
margin-top: 6px;
|
||
font-size: 19px;
|
||
font-weight: 700;
|
||
line-height: 1.25;
|
||
letter-spacing: -0.02em;
|
||
white-space: nowrap;
|
||
overflow: visible;
|
||
text-overflow: clip;
|
||
}
|
||
.metric, .mini-card {
|
||
border: 1px solid rgba(233, 240, 247, 0.68);
|
||
border-radius: 12px;
|
||
background: rgba(255,255,255,0.7);
|
||
box-shadow: none;
|
||
padding: 14px;
|
||
}
|
||
.metric-value {
|
||
margin-top: 8px;
|
||
font-size: 25px;
|
||
font-weight: 700;
|
||
line-height: 1.2;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
letter-spacing: -0.02em;
|
||
}
|
||
.subtle { color: var(--muted); font-size: 14px; line-height: 1.65; }
|
||
.field, .select {
|
||
width: 100%;
|
||
height: 42px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 12px;
|
||
background: white;
|
||
padding: 0 12px;
|
||
font-size: 15px;
|
||
color: var(--ink);
|
||
outline: none;
|
||
}
|
||
.field:focus, .select:focus { border-color: var(--cyan); box-shadow: 0 0 0 3px rgba(31,122,140,0.12); }
|
||
.toolbar {
|
||
display: grid;
|
||
grid-template-columns: 1fr 140px;
|
||
gap: 10px;
|
||
margin-top: 16px;
|
||
}
|
||
.project-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
max-height: calc(100vh - 220px);
|
||
overflow: auto;
|
||
padding-right: 4px;
|
||
}
|
||
.project-item {
|
||
border: none;
|
||
border-bottom: 1px solid rgba(233, 240, 247, 0.78);
|
||
border-radius: 0;
|
||
padding: 12px 8px 12px 6px;
|
||
background: transparent;
|
||
cursor: pointer;
|
||
transition: 0.18s ease;
|
||
}
|
||
.project-item:hover { transform: none; background: rgba(248,251,254,0.68); }
|
||
.project-item.active {
|
||
box-shadow: inset 3px 0 0 var(--blue);
|
||
background: #f8fbfe;
|
||
}
|
||
.project-item.selected {
|
||
box-shadow: inset 0 0 0 2px rgba(31,122,140,0.35);
|
||
}
|
||
.vendor-item {
|
||
border: none;
|
||
border-bottom: 1px solid rgba(233, 240, 247, 0.78);
|
||
border-radius: 0;
|
||
padding: 12px 8px 12px 6px;
|
||
background: transparent;
|
||
cursor: pointer;
|
||
transition: 0.18s ease;
|
||
}
|
||
.vendor-item:hover { transform: none; background: rgba(248,251,254,0.68); }
|
||
.vendor-item.active {
|
||
box-shadow: inset 3px 0 0 var(--blue);
|
||
background: #f8fbfe;
|
||
}
|
||
.list-mode-toggle {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 10px;
|
||
margin-top: 14px;
|
||
}
|
||
.list-mode-toggle .mode-chip {
|
||
height: 42px;
|
||
border-radius: 12px;
|
||
font-size: 14px;
|
||
}
|
||
.vendor-search-field {
|
||
height: 42px;
|
||
border-radius: 12px;
|
||
font-size: 15px;
|
||
padding: 0 12px;
|
||
}
|
||
.vendor-search-toolbar {
|
||
grid-template-columns: 1fr;
|
||
margin-top: 14px;
|
||
}
|
||
.badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 6px 10px;
|
||
border-radius: 999px;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
}
|
||
.badge-blue { background: #eaf3fb; color: var(--blue); }
|
||
.badge-good { background: #e8f8f0; color: var(--good); }
|
||
.badge-warn { background: #fff0e8; color: var(--warn); }
|
||
.table-wrap { overflow: auto; border: 1px solid var(--line); border-radius: 18px; background: white; }
|
||
.bar-track {
|
||
height: 14px;
|
||
border-radius: 999px;
|
||
background: #e6eef6;
|
||
overflow: hidden;
|
||
}
|
||
.bar-fill {
|
||
height: 100%;
|
||
border-radius: 999px;
|
||
background: linear-gradient(90deg, var(--cyan), var(--blue));
|
||
}
|
||
.account-label-list {
|
||
display: grid;
|
||
gap: 4px;
|
||
white-space: normal;
|
||
min-width: 320px;
|
||
}
|
||
.account-label-item {
|
||
font-size: 13px;
|
||
color: var(--ink);
|
||
line-height: 1.45;
|
||
}
|
||
.progress-compare-row {
|
||
display: grid;
|
||
grid-template-columns: 180px minmax(260px, 1fr) 110px;
|
||
gap: 14px;
|
||
align-items: center;
|
||
padding: 10px 0;
|
||
border-bottom: 1px solid #edf2f7;
|
||
}
|
||
.progress-compare-row:last-child {
|
||
border-bottom: none;
|
||
}
|
||
.dashboard-grid {
|
||
display: grid;
|
||
gap: 18px;
|
||
}
|
||
.dashboard-top-split {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
|
||
gap: 18px;
|
||
align-items: start;
|
||
}
|
||
.dashboard-side-stack {
|
||
display: grid;
|
||
gap: 18px;
|
||
align-content: start;
|
||
}
|
||
.dashboard-kpi-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
gap: 12px;
|
||
}
|
||
.dashboard-visual-grid {
|
||
display: grid;
|
||
grid-template-columns: 1.1fr 0.9fr;
|
||
gap: 14px;
|
||
margin-top: 16px;
|
||
}
|
||
.dashboard-visual-card {
|
||
border: 1px solid var(--line);
|
||
border-radius: 18px;
|
||
background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(244,248,252,0.98));
|
||
padding: 16px;
|
||
}
|
||
.dashboard-donut-row {
|
||
display: grid;
|
||
grid-template-columns: 200px minmax(0, 1fr);
|
||
gap: 18px;
|
||
align-items: center;
|
||
margin-top: 14px;
|
||
}
|
||
.dashboard-donut-row.dashboard-donut-row-stacked {
|
||
grid-template-columns: 1fr;
|
||
justify-items: center;
|
||
}
|
||
.dashboard-donut {
|
||
width: 180px;
|
||
height: 180px;
|
||
border-radius: 999px;
|
||
position: relative;
|
||
margin: 0 auto;
|
||
}
|
||
.dashboard-donut::after {
|
||
content: "";
|
||
position: absolute;
|
||
inset: 24px;
|
||
border-radius: 999px;
|
||
background: white;
|
||
box-shadow: inset 0 0 0 1px #e6eef6;
|
||
}
|
||
.dashboard-donut-center {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: grid;
|
||
place-items: center;
|
||
z-index: 1;
|
||
text-align: center;
|
||
}
|
||
.dashboard-donut-center strong {
|
||
font-size: 28px;
|
||
line-height: 1;
|
||
}
|
||
.dashboard-compact-donut-row {
|
||
display: grid;
|
||
grid-template-columns: 180px minmax(0, 1fr);
|
||
gap: 18px;
|
||
align-items: center;
|
||
margin-top: 14px;
|
||
}
|
||
.dashboard-compact-donut {
|
||
width: 168px;
|
||
height: 168px;
|
||
border-radius: 999px;
|
||
position: relative;
|
||
margin: 0 auto;
|
||
}
|
||
.dashboard-compact-donut::after {
|
||
content: "";
|
||
position: absolute;
|
||
inset: 24px;
|
||
border-radius: 999px;
|
||
background: white;
|
||
box-shadow: inset 0 0 0 1px #e6eef6;
|
||
}
|
||
.dashboard-compact-donut .dashboard-donut-center strong {
|
||
font-size: 32px;
|
||
}
|
||
.dashboard-compact-legend {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 8px;
|
||
}
|
||
.dashboard-compact-legend .dashboard-status-legend-item {
|
||
padding: 12px 14px;
|
||
}
|
||
.dashboard-compact-summary {
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
.dashboard-legend-metrics {
|
||
display: flex;
|
||
align-items: baseline;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
margin-top: 10px;
|
||
}
|
||
.dashboard-legend-income {
|
||
margin-top: 10px;
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: var(--ink);
|
||
}
|
||
.dashboard-legend-status-line {
|
||
margin-top: 12px;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--ink);
|
||
line-height: 1.6;
|
||
white-space: normal;
|
||
}
|
||
.dashboard-status-track {
|
||
display: flex;
|
||
overflow: hidden;
|
||
height: 20px;
|
||
border-radius: 999px;
|
||
background: #e6eef6;
|
||
margin-top: 14px;
|
||
}
|
||
.dashboard-status-segment {
|
||
height: 100%;
|
||
min-width: 2px;
|
||
}
|
||
.dashboard-status-legend {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
gap: 10px;
|
||
margin-top: 14px;
|
||
}
|
||
.dashboard-status-legend.dashboard-status-legend-inline {
|
||
grid-template-columns: repeat(4, minmax(120px, 1fr));
|
||
align-items: stretch;
|
||
width: 100%;
|
||
}
|
||
.dashboard-status-legend-item {
|
||
border: 1px solid rgba(233, 240, 247, 0.5);
|
||
border-radius: 10px;
|
||
padding: 8px 10px;
|
||
background: rgba(255,255,255,0.46);
|
||
}
|
||
button.dashboard-status-legend-item {
|
||
width: 100%;
|
||
text-align: left;
|
||
cursor: pointer;
|
||
transition: 0.18s ease;
|
||
}
|
||
button.dashboard-status-legend-item:hover {
|
||
transform: none;
|
||
border-color: #d2deea;
|
||
background: rgba(248,251,254,0.8);
|
||
}
|
||
button.dashboard-status-legend-item.active {
|
||
border-color: #d8e4ef;
|
||
box-shadow: inset 3px 0 0 var(--blue);
|
||
background: #f8fbfe;
|
||
}
|
||
.dashboard-status-legend-item.dashboard-status-inline-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 10px 12px;
|
||
}
|
||
.dashboard-status-inline-label {
|
||
min-width: 0;
|
||
flex: 1 1 auto;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: var(--ink);
|
||
line-height: 1.25;
|
||
}
|
||
.dashboard-status-inline-value {
|
||
font-size: 22px;
|
||
font-weight: 700;
|
||
line-height: 1;
|
||
color: var(--ink);
|
||
}
|
||
.dashboard-status-inline-rate {
|
||
margin-top: 2px;
|
||
font-size: 13px;
|
||
color: var(--muted);
|
||
}
|
||
.dashboard-family-bars {
|
||
display: grid;
|
||
gap: 10px;
|
||
margin-top: 12px;
|
||
}
|
||
.dashboard-family-bar-row {
|
||
display: grid;
|
||
grid-template-columns: 110px minmax(0, 1fr) 84px;
|
||
gap: 12px;
|
||
align-items: center;
|
||
}
|
||
.dashboard-family-bar-label {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--ink);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.dashboard-family-bar-track {
|
||
height: 14px;
|
||
border-radius: 999px;
|
||
background: #e6eef6;
|
||
overflow: hidden;
|
||
position: relative;
|
||
}
|
||
.dashboard-family-bar-fill {
|
||
height: 100%;
|
||
border-radius: 999px;
|
||
background: linear-gradient(90deg, #77c4df, #1e5e95);
|
||
}
|
||
.dashboard-family-bar-value {
|
||
text-align: right;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--ink);
|
||
}
|
||
.dashboard-family-chart-list {
|
||
display: grid;
|
||
gap: 10px;
|
||
margin-top: 14px;
|
||
}
|
||
.dashboard-family-top {
|
||
display: grid;
|
||
grid-template-columns: 420px minmax(0, 1fr);
|
||
gap: 16px;
|
||
align-items: stretch;
|
||
margin-top: 16px;
|
||
}
|
||
.dashboard-family-chart-row {
|
||
border: 1px solid rgba(233, 240, 247, 0.62);
|
||
border-radius: 12px;
|
||
background: rgba(255,255,255,0.5);
|
||
padding: 12px 14px;
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) 120px;
|
||
gap: 14px;
|
||
align-items: center;
|
||
cursor: pointer;
|
||
transition: 0.18s ease;
|
||
}
|
||
.dashboard-family-chart-row:hover {
|
||
transform: none;
|
||
border-color: #d8e4ef;
|
||
background: rgba(248,251,254,0.78);
|
||
}
|
||
.dashboard-family-chart-row.active {
|
||
border-color: #d8e4ef;
|
||
box-shadow: inset 3px 0 0 var(--blue);
|
||
background: #f8fbfe;
|
||
}
|
||
.dashboard-family-row-main {
|
||
display: grid;
|
||
gap: 8px;
|
||
}
|
||
.dashboard-method-list {
|
||
display: grid;
|
||
gap: 12px;
|
||
margin-top: 16px;
|
||
}
|
||
.dashboard-method-row {
|
||
border: 1px solid rgba(233, 240, 247, 0.62);
|
||
border-radius: 12px;
|
||
background: rgba(255,255,255,0.5);
|
||
padding: 12px 14px;
|
||
display: grid;
|
||
grid-template-columns: 92px minmax(0, 1fr) 180px;
|
||
gap: 16px;
|
||
align-items: center;
|
||
cursor: pointer;
|
||
transition: 0.18s ease;
|
||
}
|
||
.dashboard-method-row:hover {
|
||
transform: none;
|
||
border-color: #d8e4ef;
|
||
background: rgba(248,251,254,0.78);
|
||
}
|
||
.dashboard-method-row.active {
|
||
border-color: #d8e4ef;
|
||
box-shadow: inset 3px 0 0 var(--blue);
|
||
background: #f8fbfe;
|
||
}
|
||
.dashboard-method-row-main {
|
||
display: grid;
|
||
gap: 8px;
|
||
}
|
||
.dashboard-method-metrics {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
.dashboard-selection-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr;
|
||
gap: 14px;
|
||
margin-top: 14px;
|
||
}
|
||
.dashboard-selection-card {
|
||
border: 1px solid rgba(233, 240, 247, 0.62);
|
||
border-radius: 14px;
|
||
background: rgba(255,255,255,0.54);
|
||
padding: 16px;
|
||
}
|
||
.dashboard-finance-bars {
|
||
display: grid;
|
||
gap: 12px;
|
||
margin-top: 14px;
|
||
}
|
||
.dashboard-finance-row {
|
||
display: grid;
|
||
grid-template-columns: 52px minmax(0, 1fr) 96px;
|
||
gap: 12px;
|
||
align-items: center;
|
||
}
|
||
.dashboard-finance-label {
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
color: var(--ink);
|
||
}
|
||
.dashboard-finance-value {
|
||
text-align: right;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
color: var(--ink);
|
||
}
|
||
.dashboard-selection-kpis {
|
||
display: grid;
|
||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||
gap: 0;
|
||
margin-top: 14px;
|
||
border: 1px solid rgba(233, 240, 247, 0.72);
|
||
border-radius: 16px;
|
||
background: rgba(255,255,255,0.58);
|
||
overflow: hidden;
|
||
}
|
||
.dashboard-selection-kpis-wide {
|
||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||
}
|
||
.dashboard-selection-kpi {
|
||
border: none;
|
||
border-radius: 0;
|
||
padding: 10px 12px;
|
||
background: transparent;
|
||
box-shadow: none;
|
||
}
|
||
.dashboard-selection-kpis > *:not(:last-child) {
|
||
border-right: 1px solid rgba(233, 240, 247, 0.72);
|
||
}
|
||
.dashboard-family-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||
gap: 12px;
|
||
}
|
||
.dashboard-family-card {
|
||
border: 1px solid rgba(233, 240, 247, 0.62);
|
||
border-radius: 12px;
|
||
background: rgba(255,255,255,0.52);
|
||
padding: 14px;
|
||
cursor: pointer;
|
||
transition: 0.18s ease;
|
||
}
|
||
.dashboard-family-card:hover {
|
||
transform: none;
|
||
border-color: #d8e4ef;
|
||
background: rgba(248,251,254,0.78);
|
||
}
|
||
.dashboard-family-card.active {
|
||
border-color: #d8e4ef;
|
||
box-shadow: inset 3px 0 0 var(--blue);
|
||
background: #f8fbfe;
|
||
}
|
||
.dashboard-family-mini-track {
|
||
display: flex;
|
||
overflow: hidden;
|
||
height: 8px;
|
||
border-radius: 999px;
|
||
background: #e6eef6;
|
||
margin-top: 12px;
|
||
}
|
||
.dashboard-family-mini-segment {
|
||
height: 100%;
|
||
min-width: 2px;
|
||
}
|
||
.dashboard-info-actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
justify-content: flex-end;
|
||
}
|
||
.dashboard-guide-grid {
|
||
display: grid;
|
||
grid-template-columns: 1.15fr 0.85fr;
|
||
gap: 14px;
|
||
}
|
||
.dashboard-matrix {
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
.dashboard-matrix-head,
|
||
.dashboard-matrix-row {
|
||
display: grid;
|
||
grid-template-columns: 160px repeat(4, minmax(0, 1fr));
|
||
gap: 10px;
|
||
align-items: stretch;
|
||
}
|
||
.dashboard-matrix-head {
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
}
|
||
.dashboard-method-cell,
|
||
.dashboard-bucket-cell {
|
||
border: 1px solid var(--line);
|
||
border-radius: 16px;
|
||
background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(244,248,252,0.98));
|
||
padding: 14px;
|
||
}
|
||
.dashboard-bucket-cell {
|
||
display: grid;
|
||
gap: 12px;
|
||
min-height: 150px;
|
||
cursor: pointer;
|
||
transition: 0.18s ease;
|
||
}
|
||
.dashboard-bucket-cell:hover {
|
||
transform: translateY(-1px);
|
||
border-color: #bfd3e5;
|
||
}
|
||
.dashboard-bucket-cell.active {
|
||
border-color: var(--blue);
|
||
box-shadow: inset 0 0 0 1px var(--blue);
|
||
background: linear-gradient(180deg, #f7fbff, #eef5fb);
|
||
}
|
||
.dashboard-cell-top {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
align-items: flex-start;
|
||
}
|
||
.dashboard-cell-count {
|
||
font-size: 24px;
|
||
font-weight: 700;
|
||
line-height: 1;
|
||
}
|
||
.dashboard-dominant-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-width: 76px;
|
||
height: 30px;
|
||
padding: 0 10px;
|
||
border-radius: 999px;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
color: white;
|
||
}
|
||
.dashboard-band-chip-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
.dashboard-band-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
height: 30px;
|
||
padding: 0 10px;
|
||
border-radius: 999px;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
background: #eef3f9;
|
||
color: var(--ink);
|
||
}
|
||
.company-filter-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-width: 54px;
|
||
height: 34px;
|
||
padding: 0 14px;
|
||
border-radius: 999px;
|
||
border: 1px solid #d7e2ee;
|
||
background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(244,248,252,0.98));
|
||
color: #38506b;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
letter-spacing: -0.01em;
|
||
box-shadow: 0 4px 10px rgba(15, 28, 46, 0.04);
|
||
cursor: pointer;
|
||
transition: all 0.16s ease;
|
||
}
|
||
.company-filter-chip:hover {
|
||
border-color: #9db7d0;
|
||
color: var(--blue);
|
||
transform: translateY(-1px);
|
||
}
|
||
.company-filter-chip.active {
|
||
border-color: rgba(17,63,103,0.22);
|
||
background: linear-gradient(135deg, rgba(17,63,103,0.12), rgba(74,167,209,0.12));
|
||
color: var(--blue);
|
||
box-shadow: 0 8px 18px rgba(17,63,103,0.08);
|
||
}
|
||
.dashboard-band-dot {
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 999px;
|
||
flex-shrink: 0;
|
||
}
|
||
.dashboard-project-snippet {
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
line-height: 1.55;
|
||
}
|
||
.dashboard-project-table td,
|
||
.dashboard-project-table th {
|
||
white-space: nowrap;
|
||
}
|
||
.dashboard-project-table td:first-child,
|
||
.dashboard-project-table td:nth-child(2) {
|
||
white-space: normal;
|
||
}
|
||
.dashboard-project-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr;
|
||
gap: 12px;
|
||
}
|
||
.dashboard-project-card {
|
||
border: 1px solid var(--line);
|
||
border-radius: 18px;
|
||
background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(244,248,252,0.98));
|
||
padding: 12px 16px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 18px;
|
||
width: 100%;
|
||
text-align: left;
|
||
cursor: pointer;
|
||
transition: 0.18s ease;
|
||
}
|
||
.dashboard-project-card:hover {
|
||
transform: translateY(-1px);
|
||
border-color: #bfd3e5;
|
||
}
|
||
.dashboard-project-meta {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
.dashboard-project-main {
|
||
min-width: 0;
|
||
flex: 1;
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) auto;
|
||
align-items: center;
|
||
gap: 16px;
|
||
}
|
||
.dashboard-project-summary {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 14px;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
flex-shrink: 0;
|
||
}
|
||
.dashboard-project-summary strong {
|
||
color: var(--ink);
|
||
font-size: 15px;
|
||
margin-left: 4px;
|
||
}
|
||
.dashboard-project-header {
|
||
min-width: 0;
|
||
}
|
||
.dashboard-project-chip-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
justify-content: flex-end;
|
||
}
|
||
@media (max-width: 1280px) {
|
||
.dashboard-kpi-grid,
|
||
.dashboard-visual-grid,
|
||
.dashboard-donut-row,
|
||
.dashboard-compact-donut-row,
|
||
.dashboard-family-top,
|
||
.dashboard-selection-grid,
|
||
.dashboard-selection-kpis,
|
||
.dashboard-family-grid,
|
||
.dashboard-guide-grid,
|
||
.dashboard-matrix-head,
|
||
.dashboard-matrix-row,
|
||
.dashboard-method-row,
|
||
.dashboard-project-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.dashboard-family-chart-row {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.dashboard-status-legend {
|
||
grid-template-columns: 1fr 1fr;
|
||
}
|
||
.dashboard-project-card,
|
||
.dashboard-project-main,
|
||
.dashboard-project-summary {
|
||
display: grid;
|
||
justify-content: stretch;
|
||
}
|
||
.dashboard-project-chip-row {
|
||
justify-content: flex-start;
|
||
}
|
||
}
|
||
.category-button {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
border: none;
|
||
background: transparent;
|
||
padding: 0;
|
||
font: inherit;
|
||
color: var(--blue);
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
}
|
||
.category-button:hover {
|
||
text-decoration: underline;
|
||
}
|
||
.account-expand-button {
|
||
border: none;
|
||
background: transparent;
|
||
padding: 0;
|
||
font: inherit;
|
||
color: var(--ink);
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
text-align: left;
|
||
}
|
||
.account-expand-button:hover {
|
||
color: var(--blue);
|
||
text-decoration: underline;
|
||
}
|
||
.amount-button {
|
||
border: none;
|
||
background: transparent;
|
||
padding: 0;
|
||
font: inherit;
|
||
color: var(--ink);
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
}
|
||
.vendor-head-grid {
|
||
display: grid;
|
||
grid-template-columns: minmax(260px, 0.72fr) minmax(760px, 1.28fr);
|
||
gap: 18px;
|
||
align-items: start;
|
||
}
|
||
.vendor-summary-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
gap: 18px;
|
||
}
|
||
.vendor-summary-card {
|
||
justify-self: stretch;
|
||
}
|
||
.vendor-summary-subline {
|
||
margin-top: 4px;
|
||
white-space: nowrap;
|
||
}
|
||
.vendor-period-value {
|
||
margin-top: 6px;
|
||
display: inline-grid;
|
||
justify-items: start;
|
||
width: max-content;
|
||
font-size: 19px;
|
||
font-weight: 700;
|
||
line-height: 1.25;
|
||
letter-spacing: -0.02em;
|
||
white-space: normal;
|
||
}
|
||
.vendor-period-tilde {
|
||
width: 100%;
|
||
text-align: center;
|
||
color: var(--muted);
|
||
font-weight: 600;
|
||
}
|
||
.vendor-project-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 14px;
|
||
}
|
||
.vendor-overview-split {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||
gap: 14px;
|
||
}
|
||
.overview-table {
|
||
min-width: 0;
|
||
table-layout: fixed;
|
||
}
|
||
.overview-table col.col-name { width: 34%; }
|
||
.overview-table col.col-in { width: 18%; }
|
||
.overview-table col.col-out { width: 18%; }
|
||
.overview-table col.col-total { width: 30%; }
|
||
.overview-table td,
|
||
.overview-table th {
|
||
padding-left: 10px;
|
||
padding-right: 10px;
|
||
font-size: 14px;
|
||
}
|
||
.overview-table td:nth-child(2),
|
||
.overview-table td:nth-child(3),
|
||
.overview-table td:nth-child(4),
|
||
.overview-table th:nth-child(2),
|
||
.overview-table th:nth-child(3),
|
||
.overview-table th:nth-child(4) {
|
||
white-space: nowrap;
|
||
}
|
||
.overview-table td:nth-child(1) {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.overview-table td:nth-child(2),
|
||
.overview-table td:nth-child(3),
|
||
.overview-table td:nth-child(4),
|
||
.overview-table th:nth-child(2),
|
||
.overview-table th:nth-child(3),
|
||
.overview-table th:nth-child(4) {
|
||
text-align: right;
|
||
}
|
||
.overview-table .subtle {
|
||
font-size: 14px;
|
||
line-height: 1.35;
|
||
}
|
||
.vendor-detail-split {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
|
||
gap: 14px;
|
||
align-items: start;
|
||
}
|
||
.vendor-project-card {
|
||
border: 1px solid var(--line);
|
||
border-radius: 18px;
|
||
background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(244,248,252,0.98));
|
||
padding: 16px;
|
||
}
|
||
.selectable-row {
|
||
cursor: pointer;
|
||
}
|
||
.selectable-row.active td {
|
||
background: linear-gradient(180deg, #f7fbff, #eef5fb);
|
||
}
|
||
.text-ellipsis {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.mode-chip {
|
||
height: 40px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 12px;
|
||
background: white;
|
||
color: var(--muted);
|
||
font: inherit;
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
}
|
||
.mode-chip.active {
|
||
background: linear-gradient(135deg, var(--blue), #1c577f);
|
||
border-color: var(--blue);
|
||
color: white;
|
||
}
|
||
.amount-button:hover {
|
||
color: var(--blue);
|
||
text-decoration: underline;
|
||
}
|
||
.lifecycle-breakdown-trigger {
|
||
transition: background-color 0.16s ease, color 0.16s ease;
|
||
outline: none;
|
||
}
|
||
.lifecycle-breakdown-trigger:hover,
|
||
.lifecycle-breakdown-trigger:focus-visible {
|
||
background: rgba(18, 76, 124, 0.06) !important;
|
||
}
|
||
.lifecycle-breakdown-trigger:focus-visible {
|
||
box-shadow: inset 0 0 0 1px rgba(18, 76, 124, 0.34);
|
||
}
|
||
.lifecycle-flow-project-block {
|
||
min-height: 60px;
|
||
display: grid;
|
||
align-content: start;
|
||
gap: 2px;
|
||
overflow: hidden;
|
||
}
|
||
.lifecycle-flow-head {
|
||
min-height: 22px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
.lifecycle-flow-project-code {
|
||
font-weight: 800;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.lifecycle-flow-project-name {
|
||
font-weight: 700;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.lifecycle-flow-amount-row {
|
||
min-height: 56px;
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 8px;
|
||
align-items: start;
|
||
}
|
||
.link-button {
|
||
border: none;
|
||
background: transparent;
|
||
padding: 0;
|
||
font: inherit;
|
||
color: var(--blue);
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
}
|
||
.link-button:hover {
|
||
text-decoration: underline;
|
||
}
|
||
.warning-cell {
|
||
background: #fff1f1;
|
||
color: #b42318;
|
||
font-weight: 700;
|
||
}
|
||
.warning-text {
|
||
color: #b42318;
|
||
font-weight: 700;
|
||
}
|
||
.warning-panel-title {
|
||
color: #b42318;
|
||
}
|
||
.warning-panel-bg {
|
||
background: #fff7f7 !important;
|
||
}
|
||
.modal-backdrop {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(15, 28, 46, 0.42);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 24px;
|
||
z-index: 9999;
|
||
}
|
||
.modal-panel {
|
||
width: min(1460px, calc(100vw - 28px));
|
||
max-height: calc(100vh - 48px);
|
||
overflow: auto;
|
||
background: #fff;
|
||
border: 1px solid rgba(216,226,236,0.95);
|
||
border-radius: 24px;
|
||
box-shadow: 0 24px 60px rgba(15, 28, 46, 0.22);
|
||
padding: 22px;
|
||
}
|
||
.modal-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
align-items: flex-start;
|
||
margin-bottom: 16px;
|
||
}
|
||
.modal-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 120px 160px 160px;
|
||
gap: 12px;
|
||
align-items: center;
|
||
}
|
||
.modal-grid.head {
|
||
padding: 10px 0;
|
||
border-bottom: 1px solid var(--line);
|
||
color: #38506b;
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
}
|
||
.modal-grid.row {
|
||
padding: 12px 0;
|
||
border-bottom: 1px solid #edf2f7;
|
||
}
|
||
.budget-detail-grid {
|
||
grid-template-columns: minmax(260px, 1.4fr) minmax(180px, 200px) minmax(150px, 170px) minmax(180px, 220px);
|
||
}
|
||
.modal-panel-wide {
|
||
width: min(1560px, calc(100vw - 24px));
|
||
}
|
||
.nowrap {
|
||
white-space: nowrap;
|
||
}
|
||
.inline-detail-row td {
|
||
background: #f8fbff;
|
||
padding: 0;
|
||
}
|
||
.inline-detail-wrap {
|
||
padding: 14px 16px 16px;
|
||
}
|
||
.inline-detail-meta {
|
||
display: flex;
|
||
gap: 18px;
|
||
flex-wrap: wrap;
|
||
margin-bottom: 10px;
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
}
|
||
.inline-detail-table {
|
||
width: 100%;
|
||
min-width: 0;
|
||
}
|
||
.inline-detail-table th,
|
||
.inline-detail-table td {
|
||
font-size: 14px;
|
||
padding: 9px 10px;
|
||
white-space: nowrap;
|
||
}
|
||
.modal-actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 10px;
|
||
margin-top: 18px;
|
||
}
|
||
.button-muted, .button-primary {
|
||
height: 42px;
|
||
border: none;
|
||
border-radius: 12px;
|
||
padding: 0 16px;
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
}
|
||
.button-muted {
|
||
background: #eef4fa;
|
||
color: var(--blue);
|
||
}
|
||
.button-primary {
|
||
background: var(--blue);
|
||
color: #fff;
|
||
}
|
||
table { width: 100%; border-collapse: collapse; min-width: 720px; }
|
||
.budget-table {
|
||
table-layout: fixed !important;
|
||
min-width: 0 !important;
|
||
}
|
||
.budget-table col.col-item { width: 40%; }
|
||
.budget-table col.col-budget { width: 13%; }
|
||
.budget-table col.col-actual { width: 13%; }
|
||
.budget-table col.col-diff { width: 20%; }
|
||
.budget-table col.col-rate { width: 14%; }
|
||
th {
|
||
position: sticky;
|
||
top: 0;
|
||
background: var(--mist);
|
||
color: #38506b;
|
||
text-align: left;
|
||
padding: 12px 14px;
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
border-bottom: 1px solid var(--line);
|
||
}
|
||
td {
|
||
padding: 11px 14px;
|
||
font-size: 15px;
|
||
border-bottom: 1px solid #edf2f7;
|
||
vertical-align: top;
|
||
}
|
||
.budget-table td:nth-child(n+2),
|
||
.budget-table th:nth-child(n+2) {
|
||
white-space: nowrap;
|
||
}
|
||
.budget-table th:nth-child(1),
|
||
.budget-table td:nth-child(1) { width: 40%; }
|
||
.budget-table th:nth-child(2),
|
||
.budget-table td:nth-child(2) { width: 13%; }
|
||
.budget-table th:nth-child(3),
|
||
.budget-table td:nth-child(3) { width: 13%; }
|
||
.budget-table th:nth-child(4),
|
||
.budget-table td:nth-child(4) { width: 20%; }
|
||
.budget-table th:nth-child(5),
|
||
.budget-table td:nth-child(5) { width: 14%; }
|
||
tr:hover td { background: #f9fbfd; }
|
||
.empty {
|
||
border: 1px dashed var(--line);
|
||
border-radius: 20px;
|
||
padding: 28px;
|
||
text-align: center;
|
||
color: var(--muted);
|
||
background: rgba(255,255,255,0.75);
|
||
}
|
||
@media (max-width: 1400px) {
|
||
.project-head-grid { grid-template-columns: 1fr; }
|
||
}
|
||
@media (max-width: 1180px) {
|
||
.hero, .layout, .split, .budget-split, .metrics, .toolbar { grid-template-columns: 1fr; }
|
||
.master-inline-grid { grid-template-columns: 1fr; }
|
||
.summary-card-grid { grid-template-columns: 1fr; }
|
||
.project-list { max-height: none; }
|
||
.dashboard-top-split { grid-template-columns: 1fr; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="root">
|
||
<div id="ptc-boot-fallback" style="display:block;max-width:760px;margin:48px auto;padding:24px;border:1px solid #d8e2ec;border-radius:20px;background:#ffffff;color:#0f1c2e;font-family:'IBM Plex Sans KR',sans-serif;box-shadow:0 18px 42px rgba(15, 28, 46, 0.07);">
|
||
<div style="font-size:28px;font-weight:700;line-height:1.25;">PTC 화면을 준비하는 중입니다.</div>
|
||
<div style="margin-top:12px;color:#66788f;font-size:15px;line-height:1.7;">
|
||
이 메시지가 계속 보이면 브라우저에서 필수 스크립트나 API 서버 연결이 막힌 상태입니다.
|
||
</div>
|
||
<div id="ptc-boot-detail" style="margin-top:16px;padding:12px 14px;border-radius:14px;background:#eef5fb;color:#113f67;font-size:14px;line-height:1.6;">
|
||
초기 스크립트를 불러오는 중입니다.
|
||
</div>
|
||
<div style="margin-top:16px;color:#66788f;font-size:14px;line-height:1.7;">
|
||
접속 주소 예시: `http://localhost:4000/PTC/` 또는 `PTC/index.html?apiBase=http://localhost:4000`
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<script type="text/babel">
|
||
if (window.__ptcBootStatus.failed || !window.React || !window.ReactDOM) {
|
||
ptcBootFail(window.__ptcBootStatus.reason || "React 실행 환경을 준비하지 못했습니다.");
|
||
throw new Error(window.__ptcBootStatus.reason || "PTC boot failed");
|
||
}
|
||
const { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } = React;
|
||
function resolveApiBase() {
|
||
const params = new URLSearchParams(window.location.search);
|
||
const override = (params.get("apiBase") || "").trim();
|
||
if (override) {
|
||
return override.replace(/\/$/, "");
|
||
}
|
||
|
||
const { protocol, hostname, port } = window.location;
|
||
if (protocol === "file:") {
|
||
return "http://127.0.0.1:4000";
|
||
}
|
||
|
||
if (!hostname) {
|
||
return "http://127.0.0.1:4000";
|
||
}
|
||
|
||
if (port === "4000") {
|
||
return `${protocol}//${hostname}:4000`;
|
||
}
|
||
|
||
return `${protocol}//${hostname}:4000`;
|
||
}
|
||
|
||
const API_BASE = resolveApiBase();
|
||
const fmt = (value) => new Intl.NumberFormat("ko-KR").format(Math.round(value || 0));
|
||
const summarizeInOutTransactions = (rows) => {
|
||
const items = rows || [];
|
||
return items.reduce((acc, row) => {
|
||
const amount = Number(row?.supply_amount || 0);
|
||
if (row?.in_out === "입금") {
|
||
acc.income_count += 1;
|
||
acc.income_sum += amount;
|
||
} else if (row?.in_out === "출금") {
|
||
acc.expense_count += 1;
|
||
acc.expense_sum += amount;
|
||
}
|
||
return acc;
|
||
}, { income_count: 0, income_sum: 0, expense_count: 0, expense_sum: 0 });
|
||
};
|
||
const DATE_YEAR_OPTIONS = Array.from({ length: 11 }, (_, index) => String(2021 + index));
|
||
const DATE_MONTH_OPTIONS = Array.from({ length: 12 }, (_, index) => String(index + 1).padStart(2, "0"));
|
||
function daysInMonth(year, month) {
|
||
const y = Number(year);
|
||
const m = Number(month);
|
||
if (!y || !m) return 31;
|
||
return new Date(y, m, 0).getDate();
|
||
}
|
||
function splitDateParts(value) {
|
||
const match = String(value || "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||
if (!match) return { year: "", month: "", day: "" };
|
||
return { year: match[1], month: match[2], day: match[3] };
|
||
}
|
||
function joinDateParts(year, month, day) {
|
||
if (!year || !month || !day) return "";
|
||
return `${year}-${month}-${day}`;
|
||
}
|
||
function decoratePileProgressRow(row = {}) {
|
||
return {
|
||
...row,
|
||
start_date: row.start_date || "",
|
||
end_date: row.end_date || "",
|
||
start_date_parts: row.start_date_parts || splitDateParts(row.start_date),
|
||
end_date_parts: row.end_date_parts || splitDateParts(row.end_date),
|
||
};
|
||
}
|
||
function normalizePileProgressRows(rows = []) {
|
||
return rows.map((row) => decoratePileProgressRow(row));
|
||
}
|
||
|
||
function buildBudgetSections(rows) {
|
||
const sectionOrder = [];
|
||
const sections = {};
|
||
|
||
rows.forEach((item) => {
|
||
if (!sections[item.section]) {
|
||
sections[item.section] = { section: item.section, groups: {} };
|
||
sectionOrder.push(item.section);
|
||
}
|
||
if (!sections[item.section].groups[item.group]) {
|
||
sections[item.section].groups[item.group] = [];
|
||
}
|
||
sections[item.section].groups[item.group].push(item);
|
||
});
|
||
|
||
return sectionOrder.map((sectionName) => {
|
||
const section = sections[sectionName];
|
||
const groups = Object.entries(section.groups).map(([groupName, items]) => {
|
||
const budgetTotal = items.reduce((sum, item) => sum + (Number(item.budget_amount) || 0), 0);
|
||
const actualTotal = items.reduce((sum, item) => sum + (Number(item.actual_amount) || 0), 0);
|
||
return {
|
||
groupName,
|
||
items,
|
||
budgetTotal,
|
||
actualTotal,
|
||
diffTotal: budgetTotal - actualTotal,
|
||
rateTotal: budgetTotal > 0 ? (actualTotal / budgetTotal) * 100 : 0,
|
||
};
|
||
});
|
||
|
||
const budgetTotal = groups.reduce((sum, group) => sum + group.budgetTotal, 0);
|
||
const actualTotal = groups.reduce((sum, group) => sum + group.actualTotal, 0);
|
||
return {
|
||
section: sectionName,
|
||
groups,
|
||
budgetTotal,
|
||
actualTotal,
|
||
diffTotal: budgetTotal - actualTotal,
|
||
rateTotal: budgetTotal > 0 ? (actualTotal / budgetTotal) * 100 : 0,
|
||
};
|
||
});
|
||
}
|
||
|
||
function buildBudgetGroups(rows, projectType = "") {
|
||
const order = [];
|
||
const groups = {};
|
||
|
||
rows.forEach((item) => {
|
||
const key = `${item.section}__${item.group}`;
|
||
if (!groups[key]) {
|
||
groups[key] = {
|
||
key,
|
||
section: item.section,
|
||
groupName: item.group,
|
||
items: [],
|
||
};
|
||
order.push(key);
|
||
}
|
||
groups[key].items.push(item);
|
||
});
|
||
|
||
const items = order.map((key) => {
|
||
const group = groups[key];
|
||
const budgetTotal = group.items.reduce((sum, item) => sum + (Number(item.budget_amount) || 0), 0);
|
||
const actualTotal = group.items.reduce((sum, item) => sum + (Number(item.actual_amount) || 0), 0);
|
||
return {
|
||
...group,
|
||
budgetTotal,
|
||
actualTotal,
|
||
diffTotal: budgetTotal - actualTotal,
|
||
rateTotal: budgetTotal > 0 ? (actualTotal / budgetTotal) * 100 : 0,
|
||
};
|
||
});
|
||
|
||
if (projectType === "시공") {
|
||
return items.sort((a, b) => {
|
||
if (a.groupName === "관리") return 1;
|
||
if (b.groupName === "관리") return -1;
|
||
return 0;
|
||
});
|
||
}
|
||
|
||
if (projectType === "관리") {
|
||
return items.sort((a, b) => {
|
||
if (a.groupName === "시공") return 1;
|
||
if (b.groupName === "시공") return -1;
|
||
return 0;
|
||
});
|
||
}
|
||
|
||
return items;
|
||
}
|
||
|
||
function filterProjectBudgetRows(rows) {
|
||
return (rows || []).filter((item) => (
|
||
item.section === "수입" ||
|
||
item.section === "기타" ||
|
||
(item.section === "지출" && (item.group === "시공" || item.group === "관리"))
|
||
));
|
||
}
|
||
|
||
function isRevenueBudgetItem(item) {
|
||
return item?.section === "수입";
|
||
}
|
||
|
||
function calculateBudgetDiff(item) {
|
||
const budgetAmount = Number(item?.budget_amount) || 0;
|
||
const actualAmount = Number(item?.actual_amount) || 0;
|
||
return isRevenueBudgetItem(item) ? actualAmount - budgetAmount : budgetAmount - actualAmount;
|
||
}
|
||
|
||
function getBudgetDiffColor(item, diff) {
|
||
if (isRevenueBudgetItem(item)) {
|
||
return diff > 0 ? "var(--good)" : diff < 0 ? "#b42318" : "var(--ink)";
|
||
}
|
||
return diff < 0 ? "#b42318" : "var(--ink)";
|
||
}
|
||
|
||
function getBudgetRateColor(item, rate) {
|
||
if (isRevenueBudgetItem(item)) {
|
||
return rate > 100 ? "var(--good)" : "var(--blue)";
|
||
}
|
||
return rate > 100 ? "#b42318" : "var(--blue)";
|
||
}
|
||
|
||
function useDebouncedValue(value, delay) {
|
||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||
|
||
useEffect(() => {
|
||
const timeoutId = window.setTimeout(() => {
|
||
setDebouncedValue(value);
|
||
}, delay);
|
||
return () => window.clearTimeout(timeoutId);
|
||
}, [value, delay]);
|
||
|
||
return debouncedValue;
|
||
}
|
||
|
||
function getStatusBandColor(key) {
|
||
if (key === "normal") return "#1b7f5a";
|
||
if (key === "upfront") return "#2563eb";
|
||
if (key === "delay") return "#f59e0b";
|
||
return "#d14343";
|
||
}
|
||
|
||
function getStatusBandLabel(statusBands, key) {
|
||
return (statusBands || []).find((band) => band.key === key)?.label || key;
|
||
}
|
||
function getMarginGradeKey(marginRate) {
|
||
const rate = Number(marginRate || 0);
|
||
if (rate < 0) return "deficit";
|
||
if (rate < 10) return "caution";
|
||
if (rate < 20) return "good";
|
||
return "excellent";
|
||
}
|
||
function getMarginGradeLabel(key) {
|
||
if (key === "deficit") return "적자";
|
||
if (key === "caution") return "주의";
|
||
if (key === "good") return "양호";
|
||
return "우수";
|
||
}
|
||
function getMarginGradeColor(key) {
|
||
if (key === "deficit") return "#d14343";
|
||
if (key === "caution") return "#f59e0b";
|
||
if (key === "good") return "#1b7f5a";
|
||
return "#0f8f66";
|
||
}
|
||
|
||
function getFamilyPaletteColor(index) {
|
||
const colors = ["#1e5e95", "#4aa7d1", "#7bc96f", "#f59e0b", "#d14343", "#7c5cff", "#00a3a3", "#9b59b6"];
|
||
return colors[index % colors.length];
|
||
}
|
||
|
||
function getCompanyTypeColor(projectType, index) {
|
||
const colorMap = {
|
||
"시공": "#1e5e95",
|
||
"영업": "#4aa7d1",
|
||
"설계": "#7bc96f",
|
||
"관리": "#f59e0b",
|
||
"교휴": "#d14343",
|
||
"개발": "#7c5cff",
|
||
"기술": "#00a3a3",
|
||
"미지정": "#94a3b8",
|
||
};
|
||
return colorMap[projectType] || getFamilyPaletteColor(index);
|
||
}
|
||
|
||
function getDominantStatusKey(counts = {}) {
|
||
return Object.entries(counts).sort((a, b) => (b[1] || 0) - (a[1] || 0))[0]?.[0] || "normal";
|
||
}
|
||
|
||
function buildDonutBackground(items, getColor) {
|
||
const validItems = (items || []).filter((item) => Number(item?.ratio || 0) > 0);
|
||
if (!validItems.length) return "#e6eef6";
|
||
let cursor = 0;
|
||
const segments = validItems.map((item) => {
|
||
const start = cursor;
|
||
cursor += Number(item.ratio || 0);
|
||
return `${getColor(item.key)} ${start}% ${cursor}%`;
|
||
});
|
||
return `conic-gradient(${segments.join(", ")})`;
|
||
}
|
||
|
||
function fmtEok(value) {
|
||
const amount = Number(value) || 0;
|
||
return `${(amount / 100000000).toFixed(1)}억`;
|
||
}
|
||
|
||
function fmtEokManagement(value) {
|
||
const amount = Number(value) || 0;
|
||
return `${(amount / 100000000).toFixed(2)}억`;
|
||
}
|
||
|
||
const MANAGEMENT_EXCLUDED_ACCOUNT_NOTE = "※ 집계 제외 계정: 110 받을어음, 124 매도가능증권, 135 매입부가세, 191 출자금, 192 임차보증금, 194 전도금, 195 보증금, 196 대여금, 206 기계장치, 208 차량운반구, 212 비품, 219 시설장치, 258 매출부가세, 259 선수금, 260 단기차입금, 294 임대보증금, 901 이자수입, 902 국고보조금, 903 잡이익, 904 배당수익, 961 이자비용, 962 잡손실, 999 법인세등";
|
||
|
||
function buildBudgetCompareGroups(rows) {
|
||
const groups = [
|
||
{ key: "수입", label: "수입", matcher: (item) => item.section === "수입" },
|
||
{ key: "시공", label: "시공", matcher: (item) => item.section === "지출" && item.group === "시공" },
|
||
{ key: "관리", label: "관리", matcher: (item) => item.section === "지출" && item.group === "관리" },
|
||
];
|
||
|
||
return groups.map((group) => {
|
||
const items = (rows || []).filter(group.matcher);
|
||
const budgetTotal = items.reduce((sum, item) => sum + (Number(item.budget_amount) || 0), 0);
|
||
const actualTotal = items.reduce((sum, item) => sum + (Number(item.actual_amount) || 0), 0);
|
||
return {
|
||
key: group.key,
|
||
label: group.label,
|
||
budgetTotal,
|
||
actualTotal,
|
||
rateTotal: budgetTotal > 0 ? (actualTotal / budgetTotal) * 100 : 0,
|
||
};
|
||
});
|
||
}
|
||
|
||
function buildConstructionCompareGroups(rows) {
|
||
const labelMap = {
|
||
자재비: "자재비",
|
||
외주비: "외주비",
|
||
현장운영비: "현장운영비",
|
||
보증료: "보증료",
|
||
기타: "기타",
|
||
};
|
||
const groups = [
|
||
{ key: "revenue", label: "수입", matcher: (item) => item.section === "수입" },
|
||
{ key: "direct_material", label: "자재비", matcher: (item) => item.section === "지출" && item.group === "시공" && labelMap[item.category] === "자재비" },
|
||
{ key: "outsource", label: "외주비", matcher: (item) => item.section === "지출" && item.group === "시공" && labelMap[item.category] === "외주비" },
|
||
{ key: "site_ops", label: "현장운영비", matcher: (item) => item.section === "지출" && item.group === "시공" && labelMap[item.category] === "현장운영비" },
|
||
{ key: "guarantee", label: "보증료", matcher: (item) => item.section === "지출" && item.group === "시공" && labelMap[item.category] === "보증료" },
|
||
{ key: "other", label: "기타", matcher: (item) => item.section === "지출" && item.group === "시공" && !labelMap[item.category] },
|
||
];
|
||
|
||
return groups.map((group) => {
|
||
const items = (rows || []).filter(group.matcher);
|
||
const budgetTotal = items.reduce((sum, item) => sum + (Number(item.budget_amount) || 0), 0);
|
||
const actualTotal = items.reduce((sum, item) => sum + (Number(item.actual_amount) || 0), 0);
|
||
return {
|
||
key: group.key,
|
||
label: group.label,
|
||
budgetTotal,
|
||
actualTotal,
|
||
rateTotal: budgetTotal > 0 ? (actualTotal / budgetTotal) * 100 : 0,
|
||
};
|
||
});
|
||
}
|
||
|
||
function buildGroupedRows(rows) {
|
||
const sectionCounts = {};
|
||
const groupCounts = {};
|
||
|
||
rows.forEach((item) => {
|
||
sectionCounts[item.section] = (sectionCounts[item.section] || 0) + 1;
|
||
const groupKey = `${item.section}__${item.group}`;
|
||
groupCounts[groupKey] = (groupCounts[groupKey] || 0) + 1;
|
||
});
|
||
|
||
const sectionSeen = {};
|
||
const groupSeen = {};
|
||
|
||
return rows.map((item) => {
|
||
const groupKey = `${item.section}__${item.group}`;
|
||
const showSection = !sectionSeen[item.section];
|
||
const showGroup = !groupSeen[groupKey];
|
||
sectionSeen[item.section] = true;
|
||
groupSeen[groupKey] = true;
|
||
return {
|
||
...item,
|
||
showSection,
|
||
showGroup,
|
||
sectionRowSpan: sectionCounts[item.section],
|
||
groupRowSpan: groupCounts[groupKey],
|
||
};
|
||
});
|
||
}
|
||
|
||
function App() {
|
||
const initialPageParams = new URLSearchParams(window.location.search);
|
||
const initialTabParam = (initialPageParams.get("tab") || "").trim();
|
||
const initialProjectCodeParam = (initialPageParams.get("project_code") || "").trim();
|
||
const initialPopupModeParam = (initialPageParams.get("popup") || "").trim();
|
||
const initialDashboardFamilyParam = (initialPageParams.get("dashboard_family") || "").trim();
|
||
const initialDashboardMethodParam = (initialPageParams.get("dashboard_method") || "").trim();
|
||
const initialStatusKeyParam = (initialPageParams.get("status_key") || "").trim();
|
||
const initialStatusLabelParam = (initialPageParams.get("status_label") || "").trim();
|
||
const initialGradeParam = (initialPageParams.get("grade") || "").trim();
|
||
const initialDashboardYearParam = (initialPageParams.get("dashboard_year") || "").trim();
|
||
const isStatusPopupWindow = initialPopupModeParam === "status_projects";
|
||
const STATUS_POPUP_WINDOW_NAME = "ptc_status_projects_popup";
|
||
const [loading, setLoading] = useState(true);
|
||
const [detailLoading, setDetailLoading] = useState(false);
|
||
const [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState("");
|
||
const [currentTab, setCurrentTab] = useState(
|
||
initialPopupModeParam === "status_projects"
|
||
? "dashboard"
|
||
: ["dashboard", "project", "lifecycle", "vendor", "management", "company"].includes(initialTabParam) ? initialTabParam : "company"
|
||
);
|
||
const [projectKeyword, setProjectKeyword] = useState("");
|
||
const [projectType, setProjectType] = useState("전체");
|
||
const [projectMethodFamily, setProjectMethodFamily] = useState("전체");
|
||
const [projectMethod, setProjectMethod] = useState("전체");
|
||
const [vendorKeyword, setVendorKeyword] = useState("");
|
||
const [detailKeyword, setDetailKeyword] = useState("");
|
||
const [detailInOut, setDetailInOut] = useState("전체");
|
||
const [projectTypes, setProjectTypes] = useState([]);
|
||
const [projectTypeOptions, setProjectTypeOptions] = useState([]);
|
||
const [methodFamilyOptions, setMethodFamilyOptions] = useState([]);
|
||
const [methodOptions, setMethodOptions] = useState([]);
|
||
const [methodFamilyMap, setMethodFamilyMap] = useState({});
|
||
const [accountMaster, setAccountMaster] = useState({});
|
||
const [allowedAccountCodesByProjectType, setAllowedAccountCodesByProjectType] = useState({});
|
||
const [projects, setProjects] = useState([]);
|
||
const [relationProjects, setRelationProjects] = useState([]);
|
||
const [selectedProjectCodes, setSelectedProjectCodes] = useState([]);
|
||
const [batchMethod, setBatchMethod] = useState("");
|
||
const [batchSaving, setBatchSaving] = useState(false);
|
||
const [issueSelections, setIssueSelections] = useState({});
|
||
const [remapSavingCode, setRemapSavingCode] = useState("");
|
||
const [budgetRows, setBudgetRows] = useState([]);
|
||
const [progressRate, setProgressRate] = useState("0");
|
||
const [contractPileCount, setContractPileCount] = useState("0");
|
||
const [constructedPileCount, setConstructedPileCount] = useState("0");
|
||
const [budgetSaving, setBudgetSaving] = useState(false);
|
||
const [pileProgressModalOpen, setPileProgressModalOpen] = useState(false);
|
||
const [pileProgressRows, setPileProgressRows] = useState([]);
|
||
const [pileProgressSaving, setPileProgressSaving] = useState(false);
|
||
const [budgetModalItem, setBudgetModalItem] = useState(null);
|
||
const [budgetModalAccounts, setBudgetModalAccounts] = useState([]);
|
||
const [budgetModalTotalBudget, setBudgetModalTotalBudget] = useState(0);
|
||
const [budgetAccountExpandedCode, setBudgetAccountExpandedCode] = useState("");
|
||
const [budgetAccountDetailLoading, setBudgetAccountDetailLoading] = useState(false);
|
||
const [budgetAccountDetailMap, setBudgetAccountDetailMap] = useState({});
|
||
const [actualModalItem, setActualModalItem] = useState(null);
|
||
const [actualModalLoading, setActualModalLoading] = useState(false);
|
||
const [actualModalDetail, setActualModalDetail] = useState(null);
|
||
const [issueDetailModal, setIssueDetailModal] = useState(null);
|
||
const [issueDetailLoading, setIssueDetailLoading] = useState(false);
|
||
const [issueRowSelections, setIssueRowSelections] = useState({});
|
||
const [issueRowSaving, setIssueRowSaving] = useState(false);
|
||
const [issueCheckedRows, setIssueCheckedRows] = useState([]);
|
||
const [issueBulkTargetCode, setIssueBulkTargetCode] = useState("");
|
||
const [selectedProjectCode, setSelectedProjectCode] = useState(initialProjectCodeParam);
|
||
const [vendors, setVendors] = useState([]);
|
||
const [vendorLoading, setVendorLoading] = useState(false);
|
||
const [vendorDetailLoading, setVendorDetailLoading] = useState(false);
|
||
const [vendorListMode, setVendorListMode] = useState("vendor");
|
||
const [selectedVendorName, setSelectedVendorName] = useState("");
|
||
const [selectedVendorProjectCode, setSelectedVendorProjectCode] = useState("");
|
||
const [accounts, setAccounts] = useState([]);
|
||
const [accountLoading, setAccountLoading] = useState(false);
|
||
const [selectedAccountCode, setSelectedAccountCode] = useState("");
|
||
const [selectedAccountProjectCode, setSelectedAccountProjectCode] = useState("");
|
||
const [accountDetail, setAccountDetail] = useState(null);
|
||
const [accountDetailLoading, setAccountDetailLoading] = useState(false);
|
||
const [vendorDetail, setVendorDetail] = useState(null);
|
||
const [vendorAccountModal, setVendorAccountModal] = useState(null);
|
||
const [vendorAccountModalLoading, setVendorAccountModalLoading] = useState(false);
|
||
const [accountVendorModal, setAccountVendorModal] = useState(null);
|
||
const [accountVendorModalLoading, setAccountVendorModalLoading] = useState(false);
|
||
const [vendorAccountDateFrom, setVendorAccountDateFrom] = useState("");
|
||
const [vendorAccountDateTo, setVendorAccountDateTo] = useState("");
|
||
const [accountVendorDateFrom, setAccountVendorDateFrom] = useState("");
|
||
const [accountVendorDateTo, setAccountVendorDateTo] = useState("");
|
||
const [vendorDateFrom, setVendorDateFrom] = useState("");
|
||
const [vendorDateTo, setVendorDateTo] = useState("");
|
||
const [managementDateFrom, setManagementDateFrom] = useState("");
|
||
const [managementDateTo, setManagementDateTo] = useState("");
|
||
const [managementOverview, setManagementOverview] = useState({ items: [], category_order: [] });
|
||
const [managementOverviewLoading, setManagementOverviewLoading] = useState(false);
|
||
const [selectedManagementYear, setSelectedManagementYear] = useState("");
|
||
const [selectedManagementCategory, setSelectedManagementCategory] = useState("");
|
||
const [selectedManagementExcludedYear, setSelectedManagementExcludedYear] = useState("");
|
||
const [managementYearWindowStart, setManagementYearWindowStart] = useState(0);
|
||
const MANAGEMENT_YEAR_WINDOW_SIZE = 4;
|
||
const [managementOverviewAccounts, setManagementOverviewAccounts] = useState([]);
|
||
const [managementOverviewAccountsLoading, setManagementOverviewAccountsLoading] = useState(false);
|
||
const [managementYearDetailModalOpen, setManagementYearDetailModalOpen] = useState(false);
|
||
const [managementAccountModal, setManagementAccountModal] = useState(null);
|
||
const [managementAccountModalLoading, setManagementAccountModalLoading] = useState(false);
|
||
const [managementAccountModalView, setManagementAccountModalView] = useState("all");
|
||
const [companyOverview, setCompanyOverview] = useState({ items: [], project_type_order: [] });
|
||
const managementCategorySectionRef = useRef(null);
|
||
const managementExcludedSectionRef = useRef(null);
|
||
const [companyOverviewLoading, setCompanyOverviewLoading] = useState(false);
|
||
const [companyGraphModalOpen, setCompanyGraphModalOpen] = useState(false);
|
||
const [companyAccountModal, setCompanyAccountModal] = useState(null);
|
||
const [companyAccountModalLoading, setCompanyAccountModalLoading] = useState(false);
|
||
const [companyAccountModalView, setCompanyAccountModalView] = useState("all");
|
||
const [companyAccountDetailModal, setCompanyAccountDetailModal] = useState(null);
|
||
const [companyAccountDetailModalLoading, setCompanyAccountDetailModalLoading] = useState(false);
|
||
const [lifecycleBreakdownModal, setLifecycleBreakdownModal] = useState(null);
|
||
const [lifecycleProjectTotalModal, setLifecycleProjectTotalModal] = useState(null);
|
||
const [lifecycleProjectTotalGroup, setLifecycleProjectTotalGroup] = useState("공통배분분");
|
||
const [lifecycleAccountDetailModal, setLifecycleAccountDetailModal] = useState(null);
|
||
const [lifecycleAccountDetailModalLoading, setLifecycleAccountDetailModalLoading] = useState(false);
|
||
const [lifecycleAllocationModal, setLifecycleAllocationModal] = useState(null);
|
||
const [lifecycleAllocationSaving, setLifecycleAllocationSaving] = useState(false);
|
||
const [lifecycleCommonAllocationSaving, setLifecycleCommonAllocationSaving] = useState(false);
|
||
const [lifecycleCommonAllocationDraft, setLifecycleCommonAllocationDraft] = useState("");
|
||
const [projectEditModalOpen, setProjectEditModalOpen] = useState(false);
|
||
const [relatedProjectSearch, setRelatedProjectSearch] = useState("");
|
||
const [projectTxnDateFrom, setProjectTxnDateFrom] = useState("");
|
||
const [projectTxnDateTo, setProjectTxnDateTo] = useState("");
|
||
const [detail, setDetail] = useState(null);
|
||
const [overallSummary, setOverallSummary] = useState(null);
|
||
const [dashboardData, setDashboardData] = useState(null);
|
||
const [dashboardLoading, setDashboardLoading] = useState(false);
|
||
const [selectedDashboardYear, setSelectedDashboardYear] = useState(initialDashboardYearParam);
|
||
const [selectedDashboardFamily, setSelectedDashboardFamily] = useState(initialDashboardFamilyParam || "전체");
|
||
const [selectedDashboardMethod, setSelectedDashboardMethod] = useState(initialDashboardMethodParam || "");
|
||
const [dashboardInfoModal, setDashboardInfoModal] = useState("");
|
||
const [dashboardStatusProjectModal, setDashboardStatusProjectModal] = useState(
|
||
initialPopupModeParam === "status_projects" && initialStatusKeyParam
|
||
? {
|
||
statusKey: initialStatusKeyParam,
|
||
statusLabel: initialStatusLabelParam || initialStatusKeyParam,
|
||
openedAt: Date.now(),
|
||
}
|
||
: null
|
||
);
|
||
const [dashboardOngoingProjectModalOpen, setDashboardOngoingProjectModalOpen] = useState(false);
|
||
const [dashboardYearDetailModalOpen, setDashboardYearDetailModalOpen] = useState(false);
|
||
const [dashboardMarginGradeFilter, setDashboardMarginGradeFilter] = useState(
|
||
["all", "deficit", "caution", "good", "excellent"].includes(initialGradeParam) ? initialGradeParam : "all"
|
||
);
|
||
const [editor, setEditor] = useState({
|
||
project_name: "",
|
||
project_type: "",
|
||
construction_family: "",
|
||
construction_method: "",
|
||
start_date: "",
|
||
end_date: "",
|
||
note: "",
|
||
related_project_codes: ""
|
||
});
|
||
const deferredProjectKeyword = useDeferredValue(projectKeyword);
|
||
const deferredVendorKeyword = useDeferredValue(vendorKeyword);
|
||
const effectiveCommonAllocationMode = lifecycleCommonAllocationDraft
|
||
|| detail?.lifecycle_cost?.summary?.common_allocation_mode
|
||
|| "expense_ratio";
|
||
|
||
useEffect(() => {
|
||
setLifecycleCommonAllocationDraft("");
|
||
}, [selectedProjectCode]);
|
||
const debouncedProjectKeyword = useDebouncedValue(deferredProjectKeyword, 250);
|
||
const debouncedVendorKeyword = useDebouncedValue(deferredVendorKeyword, 250);
|
||
const deferredRelatedProjectSearch = useDeferredValue(relatedProjectSearch);
|
||
|
||
const projectQuery = useMemo(() => {
|
||
const params = new URLSearchParams();
|
||
if (projectType) params.set("project_type", projectType);
|
||
if (projectTxnDateFrom) params.set("date_from", projectTxnDateFrom);
|
||
if (projectTxnDateTo) params.set("date_to", projectTxnDateTo);
|
||
return params.toString();
|
||
}, [projectType, projectTxnDateFrom, projectTxnDateTo]);
|
||
|
||
const methodOptionsByFamily = useMemo(() => {
|
||
if (projectMethodFamily === "전체") return methodOptions;
|
||
return methodOptions.filter((item) => methodFamilyMap[item] === projectMethodFamily);
|
||
}, [methodOptions, methodFamilyMap, projectMethodFamily]);
|
||
|
||
const filteredProjects = useMemo(() => {
|
||
let items = [...projects];
|
||
const keyword = debouncedProjectKeyword.trim().toLowerCase();
|
||
if (keyword) {
|
||
items = items.filter((item) => (item.related_search_text || "").includes(keyword));
|
||
}
|
||
if (projectType !== "전체") {
|
||
items = items.filter((item) => (item.project_type || "") === projectType);
|
||
}
|
||
if (projectMethodFamily !== "전체") {
|
||
items = items.filter((item) => (item.construction_family || "") === projectMethodFamily);
|
||
}
|
||
if (projectMethod !== "전체") {
|
||
items = items.filter((item) => (item.construction_method || "") === projectMethod);
|
||
}
|
||
|
||
items.sort((a, b) => (b.project_code || "").localeCompare(a.project_code || "", "ko"));
|
||
return items;
|
||
}, [projects, projectMethodFamily, projectMethod, debouncedProjectKeyword, projectType]);
|
||
|
||
const isConstructionProject = useCallback((item) => {
|
||
const code = String(item?.project_code || "");
|
||
const type = String(item?.project_type || "");
|
||
const name = String(item?.project_name || "");
|
||
return (code.includes("-시공-") || type === "시공") && !name.includes("시공관리");
|
||
}, []);
|
||
|
||
const getLifecycleProjectType = useCallback((item) => {
|
||
const code = String(item?.project_code || "");
|
||
const type = String(item?.project_type || "");
|
||
if (code.includes("-영업-")) return "영업";
|
||
if (code.includes("-설계-")) return "설계";
|
||
if (code.includes("-시공-")) return "시공";
|
||
return type;
|
||
}, []);
|
||
|
||
const filteredLifecycleProjects = useMemo(
|
||
() => filteredProjects.filter((item) => isConstructionProject(item)),
|
||
[filteredProjects, isConstructionProject]
|
||
);
|
||
|
||
const selectedRelatedProjectCodes = useMemo(
|
||
() => String(editor.related_project_codes || "")
|
||
.split(/[\s,]+/)
|
||
.map((item) => item.trim())
|
||
.filter(Boolean),
|
||
[editor.related_project_codes]
|
||
);
|
||
|
||
useEffect(() => {
|
||
window.__lifecycleBreakdownState = lifecycleBreakdownModal ? (lifecycleBreakdownModal.label || "__open__") : "";
|
||
}, [lifecycleBreakdownModal]);
|
||
|
||
useEffect(() => {
|
||
setLifecycleBreakdownModal(null);
|
||
setLifecycleAccountDetailModal(null);
|
||
}, [selectedProjectCode, currentTab]);
|
||
|
||
const currentEditableProjectType = useMemo(
|
||
() => getLifecycleProjectType({
|
||
project_code: selectedProjectCode || detail?.summary?.project_code || "",
|
||
project_type: editor.project_type || detail?.summary?.project_type || "",
|
||
}),
|
||
[editor.project_type, detail?.summary?.project_code, detail?.summary?.project_type, getLifecycleProjectType, selectedProjectCode]
|
||
);
|
||
|
||
const relatedProjectTargetTypes = useMemo(() => {
|
||
const lifecycleTypes = ["영업", "설계", "시공"];
|
||
if (lifecycleTypes.includes(currentEditableProjectType)) {
|
||
return lifecycleTypes.filter((item) => item !== currentEditableProjectType);
|
||
}
|
||
return lifecycleTypes;
|
||
}, [currentEditableProjectType]);
|
||
|
||
const normalizedSelectedRelatedProjectItems = useMemo(() => (
|
||
selectedRelatedProjectCodes
|
||
.map((code) => relationProjects.find((item) => item.project_code === code) || { project_code: code, project_name: "", project_type: "" })
|
||
.filter((item) => {
|
||
const derivedType = getLifecycleProjectType(item);
|
||
return !derivedType || relatedProjectTargetTypes.includes(derivedType);
|
||
})
|
||
), [getLifecycleProjectType, relationProjects, selectedRelatedProjectCodes, relatedProjectTargetTypes]);
|
||
|
||
const normalizedSelectedRelatedProjectCodes = useMemo(
|
||
() => normalizedSelectedRelatedProjectItems.map((item) => item.project_code),
|
||
[normalizedSelectedRelatedProjectItems]
|
||
);
|
||
|
||
const filteredRelatedProjectCandidates = useMemo(() => {
|
||
const keyword = String(deferredRelatedProjectSearch || "").trim().toLowerCase();
|
||
const excluded = new Set([selectedProjectCode, ...normalizedSelectedRelatedProjectCodes].filter(Boolean));
|
||
let items = [...relationProjects].filter((item) => !excluded.has(item.project_code));
|
||
items = items.filter((item) => relatedProjectTargetTypes.includes(getLifecycleProjectType(item) || ""));
|
||
if (keyword) {
|
||
items = items.filter((item) =>
|
||
[
|
||
item.project_code || "",
|
||
item.project_name || "",
|
||
getLifecycleProjectType(item) || "",
|
||
item.construction_family || "",
|
||
item.construction_method || "",
|
||
].join(" ").toLowerCase().includes(keyword)
|
||
);
|
||
}
|
||
items.sort((a, b) => (b.project_code || "").localeCompare(a.project_code || "", "ko"));
|
||
return items.slice(0, 12);
|
||
}, [deferredRelatedProjectSearch, getLifecycleProjectType, normalizedSelectedRelatedProjectCodes, relationProjects, relatedProjectTargetTypes, selectedProjectCode]);
|
||
|
||
const groupedSelectedRelatedProjects = useMemo(() => {
|
||
return relatedProjectTargetTypes.map((type) => ({
|
||
type,
|
||
items: normalizedSelectedRelatedProjectItems.filter((item) => getLifecycleProjectType(item) === type),
|
||
}));
|
||
}, [getLifecycleProjectType, normalizedSelectedRelatedProjectItems, relatedProjectTargetTypes]);
|
||
|
||
const groupedRelatedProjectCandidates = useMemo(() => (
|
||
relatedProjectTargetTypes.map((type) => ({
|
||
type,
|
||
items: filteredRelatedProjectCandidates.filter((item) => getLifecycleProjectType(item) === type),
|
||
}))
|
||
), [filteredRelatedProjectCandidates, getLifecycleProjectType, relatedProjectTargetTypes]);
|
||
|
||
const isLifecycleTab = currentTab === "lifecycle";
|
||
const lifecycleRatioMap = useMemo(() => {
|
||
const map = {};
|
||
(detail?.lifecycle_cost?.rows || []).forEach((row) => {
|
||
const code = row?.project_code || "";
|
||
if (!code) return;
|
||
const ratio = Number(row?.allocation_ratio);
|
||
map[code] = Number.isFinite(ratio) && ratio >= 0 ? ratio : 1;
|
||
});
|
||
return map;
|
||
}, [detail?.lifecycle_cost?.rows]);
|
||
const lifecycleFlowCards = useMemo(() => {
|
||
const related = detail?.related_projects || [];
|
||
const roleOrder = ["영업", "설계", "시공"];
|
||
const cards = [];
|
||
const selectedSummaryCode = detail?.summary?.project_code || selectedProjectCode || "";
|
||
const selectedSummaryName = detail?.summary?.project_name || "";
|
||
const selectedProgressRate = Number(detail?.budget_analysis?.progress_rate || 0);
|
||
|
||
for (const role of roleOrder) {
|
||
let item = null;
|
||
if (role === "시공") {
|
||
item = related.find((row) => (row.project_code || "") === selectedSummaryCode) || null;
|
||
}
|
||
if (!item) {
|
||
item = related.find((row) => (row.project_type || "") === role) || null;
|
||
}
|
||
if (!item && role === "시공" && selectedSummaryCode) {
|
||
item = {
|
||
project_code: selectedSummaryCode,
|
||
project_name: selectedSummaryName,
|
||
project_type: "시공",
|
||
progress_rate: selectedProgressRate,
|
||
};
|
||
}
|
||
|
||
const ratio = role === "영업" || role === "설계"
|
||
? (item?.project_code ? (lifecycleRatioMap[item.project_code] ?? 1) : 1)
|
||
: 1;
|
||
const income = Number(item?.income_supply || 0);
|
||
const expense = Number(item?.expense_supply || 0);
|
||
const rowAllocation = (detail?.lifecycle_cost?.rows || []).find((row) => row.project_code === item?.project_code);
|
||
cards.push({
|
||
role,
|
||
has_project: !!item?.project_code,
|
||
project_code: item?.project_code || "",
|
||
project_name: item?.project_name || "",
|
||
note: item?.note || "",
|
||
income,
|
||
expense,
|
||
reflected_income: income * ratio,
|
||
reflected_expense: expense * ratio,
|
||
numerator: Number(rowAllocation?.allocation_numerator || 1),
|
||
denominator: Number(rowAllocation?.allocation_denominator || 1),
|
||
progress_rate: role === "시공"
|
||
? selectedProgressRate
|
||
: Number(item?.progress_rate || 0),
|
||
});
|
||
}
|
||
|
||
return cards;
|
||
}, [detail?.related_projects, detail?.lifecycle_cost?.rows, detail?.summary?.project_code, detail?.summary?.project_name, detail?.budget_analysis?.progress_rate, lifecycleRatioMap, selectedProjectCode]);
|
||
const lifecycleMarginRate = useMemo(() => {
|
||
const income = Number(detail?.lifecycle_cost?.summary?.income_supply || 0);
|
||
const profit = Number(detail?.lifecycle_cost?.summary?.profit_supply || 0);
|
||
if (!income) return 0;
|
||
return (profit / income) * 100;
|
||
}, [detail?.lifecycle_cost?.summary?.income_supply, detail?.lifecycle_cost?.summary?.profit_supply]);
|
||
const visibleProjectList = isLifecycleTab ? filteredLifecycleProjects : filteredProjects;
|
||
|
||
useEffect(() => {
|
||
if (filteredProjects.length === 1 && filteredProjects[0]?.project_code && selectedProjectCode !== filteredProjects[0].project_code) {
|
||
setSelectedProjectCode(filteredProjects[0].project_code);
|
||
}
|
||
}, [filteredProjects, selectedProjectCode]);
|
||
|
||
useEffect(() => {
|
||
if (currentTab !== "lifecycle") return;
|
||
if (!filteredLifecycleProjects.length) return;
|
||
if (!filteredLifecycleProjects.some((item) => item.project_code === selectedProjectCode)) {
|
||
setSelectedProjectCode(filteredLifecycleProjects[0].project_code);
|
||
}
|
||
}, [currentTab, filteredLifecycleProjects, selectedProjectCode]);
|
||
|
||
const detailQuery = useMemo(() => {
|
||
if (!selectedProjectCode) return "";
|
||
const params = new URLSearchParams();
|
||
params.set("project_code", selectedProjectCode);
|
||
if (detailKeyword.trim()) params.set("keyword", detailKeyword.trim());
|
||
if (detailInOut) params.set("in_out", detailInOut);
|
||
return params.toString();
|
||
}, [selectedProjectCode, detailKeyword, detailInOut]);
|
||
|
||
const vendorQuery = useMemo(() => {
|
||
const params = new URLSearchParams();
|
||
if (debouncedVendorKeyword.trim()) params.set("keyword", debouncedVendorKeyword.trim());
|
||
if (vendorDateFrom) params.set("date_from", vendorDateFrom);
|
||
if (vendorDateTo) params.set("date_to", vendorDateTo);
|
||
return params.toString();
|
||
}, [debouncedVendorKeyword, vendorDateFrom, vendorDateTo]);
|
||
|
||
const managementOverviewQuery = useMemo(() => {
|
||
const params = new URLSearchParams();
|
||
if (managementDateFrom) params.set("date_from", managementDateFrom);
|
||
if (managementDateTo) params.set("date_to", managementDateTo);
|
||
return params.toString();
|
||
}, [managementDateFrom, managementDateTo]);
|
||
|
||
const managementOverviewAccountsQuery = useMemo(() => {
|
||
if (!selectedManagementYear || !selectedManagementCategory) return "";
|
||
const params = new URLSearchParams();
|
||
params.set("year", selectedManagementYear);
|
||
params.set("category", selectedManagementCategory);
|
||
if (managementDateFrom) params.set("date_from", managementDateFrom);
|
||
if (managementDateTo) params.set("date_to", managementDateTo);
|
||
return params.toString();
|
||
}, [selectedManagementYear, selectedManagementCategory, managementDateFrom, managementDateTo]);
|
||
|
||
const sortedManagementOverviewItems = useMemo(
|
||
() =>
|
||
[...(managementOverview.items || [])].sort(
|
||
(a, b) => Number(a.year || 0) - Number(b.year || 0)
|
||
),
|
||
[managementOverview.items]
|
||
);
|
||
|
||
const visibleManagementOverviewItems = useMemo(
|
||
() => sortedManagementOverviewItems.slice(managementYearWindowStart, managementYearWindowStart + MANAGEMENT_YEAR_WINDOW_SIZE),
|
||
[sortedManagementOverviewItems, managementYearWindowStart, MANAGEMENT_YEAR_WINDOW_SIZE]
|
||
);
|
||
|
||
const visibleManagementProfitItems = useMemo(() => {
|
||
const visibleYears = new Set(visibleManagementOverviewItems.map((item) => String(item.year)));
|
||
return [...(managementOverview.yearly_profit_items || [])]
|
||
.sort((a, b) => Number(a.year || 0) - Number(b.year || 0))
|
||
.filter((item) => visibleYears.has(String(item.year)));
|
||
}, [managementOverview.yearly_profit_items, visibleManagementOverviewItems]);
|
||
|
||
const overallSummaryQuery = useMemo(() => {
|
||
const params = new URLSearchParams();
|
||
if (projectType && projectType !== "전체") params.set("project_type", projectType);
|
||
return params.toString();
|
||
}, [projectType]);
|
||
|
||
useEffect(() => {
|
||
let ignore = false;
|
||
async function loadProjects() {
|
||
setLoading(true);
|
||
setError("");
|
||
try {
|
||
const [typeRes, optionsRes, projectRes, relationProjectRes] = await Promise.all([
|
||
fetch(`${API_BASE}/api/project-types`),
|
||
fetch(`${API_BASE}/api/project-master-options`),
|
||
fetch(`${API_BASE}/api/projects?${projectQuery}`),
|
||
fetch(`${API_BASE}/api/projects`)
|
||
]);
|
||
if (!typeRes.ok || !optionsRes.ok || !projectRes.ok || !relationProjectRes.ok) throw new Error("project load failed");
|
||
const typeData = await typeRes.json();
|
||
const optionsData = await optionsRes.json();
|
||
const projectData = await projectRes.json();
|
||
const relationProjectData = await relationProjectRes.json();
|
||
if (ignore) return;
|
||
setProjectTypes(typeData.items || []);
|
||
setProjectTypeOptions(optionsData.project_type_options || []);
|
||
setMethodFamilyOptions(optionsData.method_family_options || []);
|
||
setMethodOptions(optionsData.method_options || []);
|
||
setMethodFamilyMap(optionsData.method_family_map || {});
|
||
setAccountMaster(optionsData.account_master || {});
|
||
setAllowedAccountCodesByProjectType(optionsData.allowed_account_codes_by_project_type || {});
|
||
setProjects(projectData.items || []);
|
||
setRelationProjects(relationProjectData.items || []);
|
||
if (selectedProjectCode && !(projectData.items || []).some(item => item.project_code === selectedProjectCode)) {
|
||
setSelectedProjectCode("");
|
||
}
|
||
} catch (err) {
|
||
if (!ignore) setError("프로젝트 목록을 불러오지 못했습니다. API 서버를 확인해 주세요.");
|
||
} finally {
|
||
if (!ignore) setLoading(false);
|
||
}
|
||
}
|
||
loadProjects();
|
||
return () => { ignore = true; };
|
||
}, [projectQuery]);
|
||
|
||
useEffect(() => {
|
||
let ignore = false;
|
||
async function loadOverallSummary() {
|
||
try {
|
||
const querySuffix = overallSummaryQuery ? `?${overallSummaryQuery}` : "";
|
||
const res = await fetch(`${API_BASE}/api/summary${querySuffix}`);
|
||
if (!res.ok) throw new Error("overall summary load failed");
|
||
const data = await res.json();
|
||
if (!ignore) setOverallSummary(data);
|
||
} catch (err) {
|
||
if (!ignore) setOverallSummary(null);
|
||
}
|
||
}
|
||
loadOverallSummary();
|
||
return () => { ignore = true; };
|
||
}, [overallSummaryQuery]);
|
||
|
||
useEffect(() => {
|
||
let ignore = false;
|
||
async function loadDashboard() {
|
||
if (currentTab !== "dashboard") return;
|
||
setDashboardLoading(true);
|
||
try {
|
||
const params = new URLSearchParams();
|
||
if (projectType && projectType !== "전체") params.set("project_type", projectType);
|
||
params.set("exclude_asset_accounts", "1");
|
||
if (selectedDashboardYear) {
|
||
params.set("date_from", `${selectedDashboardYear}-01-01`);
|
||
params.set("date_to", `${selectedDashboardYear}-12-31`);
|
||
}
|
||
const suffix = params.toString() ? `?${params.toString()}` : "";
|
||
const res = await fetch(`${API_BASE}/api/dashboard-prototype${suffix}`);
|
||
if (!res.ok) throw new Error("dashboard load failed");
|
||
const data = await res.json();
|
||
if (!ignore) setDashboardData(data);
|
||
} catch (err) {
|
||
if (!ignore) setError("대시보드 시안을 불러오지 못했습니다.");
|
||
} finally {
|
||
if (!ignore) setDashboardLoading(false);
|
||
}
|
||
}
|
||
loadDashboard();
|
||
return () => { ignore = true; };
|
||
}, [currentTab, projectType, selectedDashboardYear]);
|
||
|
||
useEffect(() => {
|
||
if (isStatusPopupWindow && initialDashboardFamilyParam) return;
|
||
setSelectedDashboardFamily("전체");
|
||
}, [projectType, dashboardData?.projects?.length, isStatusPopupWindow, initialDashboardFamilyParam]);
|
||
|
||
useEffect(() => {
|
||
let ignore = false;
|
||
async function loadDetail() {
|
||
if (!selectedProjectCode) {
|
||
setDetail(null);
|
||
return;
|
||
}
|
||
setDetailLoading(true);
|
||
try {
|
||
const res = await fetch(`${API_BASE}/api/project-detail?${detailQuery}`);
|
||
if (!res.ok) throw new Error("detail load failed");
|
||
const data = await res.json();
|
||
if (!ignore) {
|
||
setDetail(data);
|
||
const nextIssueSelections = {};
|
||
(data?.account_issues || []).forEach((item) => {
|
||
nextIssueSelections[item.account_code] = item.suggested_code || "";
|
||
});
|
||
setIssueSelections(nextIssueSelections);
|
||
setBudgetRows(data?.budget_analysis?.rows || []);
|
||
setProgressRate(String(data?.budget_analysis?.progress_rate ?? 0));
|
||
setContractPileCount(String(data?.budget_analysis?.contract_pile_count ?? 0));
|
||
setConstructedPileCount(String(data?.budget_analysis?.constructed_pile_count ?? 0));
|
||
setPileProgressRows(normalizePileProgressRows(data?.budget_analysis?.pile_progress_entries || []));
|
||
setBudgetModalItem(null);
|
||
setBudgetModalAccounts([]);
|
||
setActualModalItem(null);
|
||
setIssueDetailModal(null);
|
||
setIssueRowSelections({});
|
||
setIssueCheckedRows([]);
|
||
setIssueBulkTargetCode("");
|
||
setEditor({
|
||
project_name: data?.summary?.project_name || "",
|
||
project_type: data?.summary?.project_type || "",
|
||
construction_family: data?.summary?.construction_family || "",
|
||
construction_method: data?.summary?.construction_method || "",
|
||
start_date: data?.summary?.start_date || "",
|
||
end_date: data?.summary?.end_date || "",
|
||
note: data?.summary?.note || "",
|
||
related_project_codes: (data?.related_projects || [])
|
||
.filter((item) => item.project_code !== selectedProjectCode)
|
||
.filter((item) => {
|
||
const currentType = data?.summary?.project_type || "";
|
||
const lifecycleTypes = ["영업", "설계", "시공"];
|
||
if (!lifecycleTypes.includes(currentType)) return true;
|
||
return (item.project_type || "") !== currentType;
|
||
})
|
||
.map((item) => item.project_code)
|
||
.join(", ")
|
||
});
|
||
}
|
||
} catch (err) {
|
||
if (!ignore) setError("선택 프로젝트 상세를 불러오지 못했습니다.");
|
||
} finally {
|
||
if (!ignore) setDetailLoading(false);
|
||
}
|
||
}
|
||
loadDetail();
|
||
return () => { ignore = true; };
|
||
}, [detailQuery, selectedProjectCode]);
|
||
|
||
useEffect(() => {
|
||
function handleProjectOpenMessage(event) {
|
||
if (event.origin !== window.location.origin) return;
|
||
const payload = event.data || {};
|
||
if (payload?.type !== "ptc-open-project" || !payload?.projectCode) return;
|
||
setProjectKeyword("");
|
||
setProjectType("전체");
|
||
setProjectMethodFamily("전체");
|
||
setProjectMethod("전체");
|
||
setCurrentTab("project");
|
||
setSelectedProjectCode(payload.projectCode);
|
||
}
|
||
window.addEventListener("message", handleProjectOpenMessage);
|
||
return () => window.removeEventListener("message", handleProjectOpenMessage);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
function handleStatusPopupMessage(event) {
|
||
if (event.origin !== window.location.origin) return;
|
||
const payload = event.data || {};
|
||
if (payload?.type !== "ptc-open-status-popup") return;
|
||
|
||
if (payload.dashboardFamily) setSelectedDashboardFamily(payload.dashboardFamily);
|
||
setSelectedDashboardYear(payload.dashboardYear || "");
|
||
setSelectedDashboardMethod(payload.dashboardMethod || "");
|
||
setDashboardMarginGradeFilter(payload.grade || "all");
|
||
|
||
if (payload.statusKey) {
|
||
setDashboardStatusProjectModal({
|
||
statusKey: payload.statusKey,
|
||
statusLabel: payload.statusLabel || payload.statusKey,
|
||
openedAt: Date.now(),
|
||
});
|
||
} else {
|
||
setDashboardStatusProjectModal(null);
|
||
}
|
||
}
|
||
|
||
window.addEventListener("message", handleStatusPopupMessage);
|
||
return () => window.removeEventListener("message", handleStatusPopupMessage);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
let ignore = false;
|
||
async function loadVendors() {
|
||
if (currentTab !== "vendor") return;
|
||
setVendorLoading(true);
|
||
try {
|
||
const res = await fetch(`${API_BASE}/api/vendors?${vendorQuery}`);
|
||
if (!res.ok) throw new Error("vendor load failed");
|
||
const data = await res.json();
|
||
if (ignore) return;
|
||
const nextItems = data.items || [];
|
||
setVendors(nextItems);
|
||
if (!selectedVendorName || !nextItems.some((item) => item.vendor_name === selectedVendorName)) {
|
||
setSelectedVendorName(nextItems[0]?.vendor_name || "");
|
||
}
|
||
} catch (err) {
|
||
if (!ignore) setError("거래처 목록을 불러오지 못했습니다.");
|
||
} finally {
|
||
if (!ignore) setVendorLoading(false);
|
||
}
|
||
}
|
||
loadVendors();
|
||
return () => { ignore = true; };
|
||
}, [currentTab, vendorQuery]);
|
||
|
||
useEffect(() => {
|
||
let ignore = false;
|
||
async function loadAccounts() {
|
||
if (currentTab !== "vendor") return;
|
||
setAccountLoading(true);
|
||
try {
|
||
const res = await fetch(`${API_BASE}/api/accounts?${vendorQuery}`);
|
||
if (!res.ok) throw new Error("account load failed");
|
||
const data = await res.json();
|
||
if (ignore) return;
|
||
const nextItems = data.items || [];
|
||
setAccounts(nextItems);
|
||
if (!selectedAccountCode || !nextItems.some((item) => item.account_code === selectedAccountCode)) {
|
||
setSelectedAccountCode(nextItems[0]?.account_code || "");
|
||
}
|
||
} catch (err) {
|
||
if (!ignore) setError("계정 목록을 불러오지 못했습니다.");
|
||
} finally {
|
||
if (!ignore) setAccountLoading(false);
|
||
}
|
||
}
|
||
loadAccounts();
|
||
return () => { ignore = true; };
|
||
}, [currentTab, vendorQuery]);
|
||
|
||
useEffect(() => {
|
||
let ignore = false;
|
||
async function loadVendorDetail() {
|
||
if (currentTab !== "vendor") return;
|
||
if (!selectedVendorName) {
|
||
setVendorDetail(null);
|
||
return;
|
||
}
|
||
setVendorDetailLoading(true);
|
||
try {
|
||
const params = new URLSearchParams();
|
||
params.set("vendor_name", selectedVendorName);
|
||
if (selectedVendorProjectCode) params.set("project_code", selectedVendorProjectCode);
|
||
if (vendorDateFrom) params.set("date_from", vendorDateFrom);
|
||
if (vendorDateTo) params.set("date_to", vendorDateTo);
|
||
const res = await fetch(`${API_BASE}/api/vendor-detail?${params.toString()}`);
|
||
if (!res.ok) throw new Error("vendor detail load failed");
|
||
const data = await res.json();
|
||
if (!ignore) setVendorDetail(data);
|
||
} catch (err) {
|
||
if (!ignore) setError("거래처 상세를 불러오지 못했습니다.");
|
||
} finally {
|
||
if (!ignore) setVendorDetailLoading(false);
|
||
}
|
||
}
|
||
loadVendorDetail();
|
||
return () => { ignore = true; };
|
||
}, [currentTab, selectedVendorName, selectedVendorProjectCode, vendorDateFrom, vendorDateTo]);
|
||
|
||
useEffect(() => {
|
||
let ignore = false;
|
||
async function loadAccountDetail() {
|
||
if (currentTab !== "vendor") return;
|
||
if (!selectedAccountCode) {
|
||
setAccountDetail(null);
|
||
return;
|
||
}
|
||
setAccountDetailLoading(true);
|
||
try {
|
||
const params = new URLSearchParams();
|
||
params.set("account_code", selectedAccountCode);
|
||
if (selectedAccountProjectCode) params.set("project_code", selectedAccountProjectCode);
|
||
if (vendorDateFrom) params.set("date_from", vendorDateFrom);
|
||
if (vendorDateTo) params.set("date_to", vendorDateTo);
|
||
const res = await fetch(`${API_BASE}/api/account-detail?${params.toString()}`);
|
||
if (!res.ok) throw new Error("account detail failed");
|
||
const data = await res.json();
|
||
if (!ignore) setAccountDetail(data);
|
||
} catch (err) {
|
||
if (!ignore) setError("계정 상세를 불러오지 못했습니다.");
|
||
} finally {
|
||
if (!ignore) setAccountDetailLoading(false);
|
||
}
|
||
}
|
||
loadAccountDetail();
|
||
return () => { ignore = true; };
|
||
}, [currentTab, selectedAccountCode, selectedAccountProjectCode, vendorDateFrom, vendorDateTo]);
|
||
|
||
useEffect(() => {
|
||
let ignore = false;
|
||
async function loadManagementOverview() {
|
||
if (currentTab !== "management" && currentTab !== "dashboard") return;
|
||
setManagementOverviewLoading(true);
|
||
try {
|
||
const res = await fetch(`${API_BASE}/api/management-overview?${managementOverviewQuery}`);
|
||
if (!res.ok) throw new Error("management overview failed");
|
||
const data = await res.json();
|
||
if (!ignore) setManagementOverview(data || { items: [], category_order: [] });
|
||
} catch (err) {
|
||
if (!ignore) setError("관리 연도별 금액을 불러오지 못했습니다.");
|
||
} finally {
|
||
if (!ignore) setManagementOverviewLoading(false);
|
||
}
|
||
}
|
||
loadManagementOverview();
|
||
return () => { ignore = true; };
|
||
}, [currentTab, managementOverviewQuery]);
|
||
|
||
useEffect(() => {
|
||
let ignore = false;
|
||
async function loadCompanyOverview() {
|
||
if (currentTab !== "company") return;
|
||
setCompanyOverviewLoading(true);
|
||
try {
|
||
const res = await fetch(`${API_BASE}/api/management-company-overview`);
|
||
if (!res.ok) throw new Error("company overview load failed");
|
||
const data = await res.json();
|
||
if (!ignore) setCompanyOverview(data);
|
||
} catch (err) {
|
||
if (!ignore) setError("전체 연도별 현황을 불러오지 못했습니다.");
|
||
} finally {
|
||
if (!ignore) setCompanyOverviewLoading(false);
|
||
}
|
||
}
|
||
loadCompanyOverview();
|
||
return () => { ignore = true; };
|
||
}, [currentTab]);
|
||
|
||
useEffect(() => {
|
||
let ignore = false;
|
||
async function loadCompanyAccountModal() {
|
||
if (!companyAccountModal?.year || !companyAccountModal?.project_type) return;
|
||
setCompanyAccountModalLoading(true);
|
||
try {
|
||
const params = new URLSearchParams();
|
||
params.set("year", companyAccountModal.year);
|
||
params.set("project_type", companyAccountModal.project_type);
|
||
const res = await fetch(`${API_BASE}/api/management-company-accounts?${params.toString()}`);
|
||
if (!res.ok) throw new Error("management company accounts failed");
|
||
const data = await res.json();
|
||
if (!ignore) {
|
||
setCompanyAccountModal((prev) => (prev ? { ...prev, items: data.items || [] } : prev));
|
||
}
|
||
} catch (err) {
|
||
if (!ignore) setError("전체 연도별 계정 금액을 불러오지 못했습니다.");
|
||
} finally {
|
||
if (!ignore) setCompanyAccountModalLoading(false);
|
||
}
|
||
}
|
||
loadCompanyAccountModal();
|
||
return () => { ignore = true; };
|
||
}, [companyAccountModal?.year, companyAccountModal?.project_type]);
|
||
|
||
useEffect(() => {
|
||
const hasSelection = (managementOverview.items || []).some(
|
||
(item) =>
|
||
item.year === selectedManagementYear &&
|
||
(item.categories || []).some((category) => category.name === selectedManagementCategory)
|
||
);
|
||
if (!hasSelection) {
|
||
setSelectedManagementYear("");
|
||
setSelectedManagementCategory("");
|
||
}
|
||
}, [managementOverview, selectedManagementYear, selectedManagementCategory]);
|
||
|
||
useEffect(() => {
|
||
const total = sortedManagementOverviewItems.length;
|
||
if (!total) {
|
||
setManagementYearWindowStart(0);
|
||
return;
|
||
}
|
||
const maxStart = Math.max(0, total - MANAGEMENT_YEAR_WINDOW_SIZE);
|
||
setManagementYearWindowStart((prev) => {
|
||
if (prev > maxStart) return maxStart;
|
||
if (prev < 0) return 0;
|
||
if (prev === 0 && total > MANAGEMENT_YEAR_WINDOW_SIZE) return maxStart;
|
||
return prev;
|
||
});
|
||
}, [sortedManagementOverviewItems.length, MANAGEMENT_YEAR_WINDOW_SIZE]);
|
||
|
||
useEffect(() => {
|
||
const hasExcludedSelection = (managementOverview.items || []).some(
|
||
(item) => item.year === selectedManagementExcludedYear && Number(item.excluded_total || 0) !== 0
|
||
);
|
||
if (!hasExcludedSelection) {
|
||
setSelectedManagementExcludedYear("");
|
||
}
|
||
}, [managementOverview, selectedManagementExcludedYear]);
|
||
|
||
const selectedManagementExcludedItem = useMemo(
|
||
() => (managementOverview.items || []).find((item) => item.year === selectedManagementExcludedYear) || null,
|
||
[managementOverview, selectedManagementExcludedYear]
|
||
);
|
||
const filteredManagementAccountTransactions = useMemo(() => {
|
||
const rows = managementAccountModal?.detail?.transactions || [];
|
||
if (managementAccountModalView === "income") {
|
||
return rows.filter((row) => (row.in_out || "") === "입금");
|
||
}
|
||
if (managementAccountModalView === "expense") {
|
||
return rows.filter((row) => (row.in_out || "") === "출금");
|
||
}
|
||
return rows;
|
||
}, [managementAccountModal?.detail?.transactions, managementAccountModalView]);
|
||
|
||
const filteredManagementAccountSummary = useMemo(() => {
|
||
const rows = filteredManagementAccountTransactions;
|
||
const income = rows.reduce((sum, row) => sum + Number(((row.in_out || "") === "입금" ? row.supply_amount : 0) || 0), 0);
|
||
const expense = rows.reduce((sum, row) => sum + Number(((row.in_out || "") === "출금" ? row.supply_amount : 0) || 0), 0);
|
||
const txnCount = rows.length;
|
||
const dates = rows.map((row) => row.transaction_date || "").filter(Boolean).sort();
|
||
return {
|
||
income_supply_sum: income,
|
||
expense_supply_sum: expense,
|
||
txn_count: txnCount,
|
||
min_date: dates[0] || "-",
|
||
max_date: dates[dates.length - 1] || "-",
|
||
};
|
||
}, [filteredManagementAccountTransactions]);
|
||
|
||
function getManagementCategoryAmount(yearItem, categoryName) {
|
||
return Number(
|
||
((yearItem?.categories || []).find((category) => category.name === categoryName)?.amount) || 0
|
||
);
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (selectedManagementYear && selectedManagementCategory && managementCategorySectionRef.current) {
|
||
managementCategorySectionRef.current.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
}
|
||
}, [selectedManagementYear, selectedManagementCategory]);
|
||
|
||
useEffect(() => {
|
||
let ignore = false;
|
||
async function loadManagementOverviewAccounts() {
|
||
if (currentTab !== "management" || !managementOverviewAccountsQuery) {
|
||
setManagementOverviewAccounts([]);
|
||
return;
|
||
}
|
||
setManagementOverviewAccountsLoading(true);
|
||
try {
|
||
const res = await fetch(`${API_BASE}/api/management-overview-accounts?${managementOverviewAccountsQuery}`);
|
||
if (!res.ok) throw new Error("management overview accounts failed");
|
||
const data = await res.json();
|
||
if (!ignore) setManagementOverviewAccounts(data.items || []);
|
||
} catch (err) {
|
||
if (!ignore) setError("관리 연도별 계정 금액을 불러오지 못했습니다.");
|
||
} finally {
|
||
if (!ignore) setManagementOverviewAccountsLoading(false);
|
||
}
|
||
}
|
||
loadManagementOverviewAccounts();
|
||
return () => { ignore = true; };
|
||
}, [currentTab, managementOverviewAccountsQuery]);
|
||
|
||
useEffect(() => {
|
||
let ignore = false;
|
||
async function loadManagementAccountModal() {
|
||
if (!managementAccountModal?.account_code) return;
|
||
setManagementAccountModalLoading(true);
|
||
try {
|
||
const params = new URLSearchParams();
|
||
params.set("account_code", managementAccountModal.account_code);
|
||
if (managementAccountModal?.date_from) {
|
||
params.set("date_from", managementAccountModal.date_from);
|
||
} else if (managementDateFrom) {
|
||
params.set("date_from", managementDateFrom);
|
||
}
|
||
if (managementAccountModal?.date_to) {
|
||
params.set("date_to", managementAccountModal.date_to);
|
||
} else if (managementDateTo) {
|
||
params.set("date_to", managementDateTo);
|
||
}
|
||
const detailPath =
|
||
managementAccountModal?.category === "기타 수지/자산 합계"
|
||
? "/api/management-excluded-account-detail"
|
||
: "/api/management-account-detail";
|
||
const res = await fetch(`${API_BASE}${detailPath}?${params.toString()}`);
|
||
if (!res.ok) throw new Error("management account modal failed");
|
||
const data = await res.json();
|
||
if (!ignore) {
|
||
setManagementAccountModal((prev) => (prev ? { ...prev, detail: data } : prev));
|
||
}
|
||
} catch (err) {
|
||
if (!ignore) setError("관리 계정 상세를 불러오지 못했습니다.");
|
||
} finally {
|
||
if (!ignore) setManagementAccountModalLoading(false);
|
||
}
|
||
}
|
||
loadManagementAccountModal();
|
||
return () => { ignore = true; };
|
||
}, [managementAccountModal?.account_code, managementAccountModal?.date_from, managementAccountModal?.date_to, managementDateFrom, managementDateTo]);
|
||
|
||
useEffect(() => {
|
||
let ignore = false;
|
||
async function loadCompanyAccountDetailModal() {
|
||
if (!companyAccountDetailModal?.year || !companyAccountDetailModal?.project_type || !companyAccountDetailModal?.account_code) return;
|
||
setCompanyAccountDetailModalLoading(true);
|
||
try {
|
||
const params = new URLSearchParams();
|
||
params.set("year", companyAccountDetailModal.year);
|
||
params.set("project_type", companyAccountDetailModal.project_type);
|
||
params.set("account_code", companyAccountDetailModal.account_code);
|
||
const res = await fetch(`${API_BASE}/api/company-account-detail?${params.toString()}`);
|
||
if (!res.ok) throw new Error("company account detail failed");
|
||
const data = await res.json();
|
||
if (!ignore) {
|
||
setCompanyAccountDetailModal((prev) => (prev ? { ...prev, detail: data } : prev));
|
||
}
|
||
} catch (err) {
|
||
if (!ignore) setError("전체 계정 상세를 불러오지 못했습니다.");
|
||
} finally {
|
||
if (!ignore) setCompanyAccountDetailModalLoading(false);
|
||
}
|
||
}
|
||
loadCompanyAccountDetailModal();
|
||
return () => { ignore = true; };
|
||
}, [companyAccountDetailModal?.year, companyAccountDetailModal?.project_type, companyAccountDetailModal?.account_code]);
|
||
|
||
useEffect(() => {
|
||
let ignore = false;
|
||
async function loadLifecycleAccountDetailModal() {
|
||
if (!lifecycleAccountDetailModal?.project_code || !lifecycleAccountDetailModal?.bucket_label || !lifecycleAccountDetailModal?.account_code) return;
|
||
setLifecycleAccountDetailModalLoading(true);
|
||
try {
|
||
const params = new URLSearchParams();
|
||
params.set("project_code", lifecycleAccountDetailModal.project_code);
|
||
params.set("bucket_label", lifecycleAccountDetailModal.bucket_label);
|
||
params.set("account_code", lifecycleAccountDetailModal.account_code);
|
||
const res = await fetch(`${API_BASE}/api/lifecycle-account-detail?${params.toString()}`);
|
||
if (!res.ok) throw new Error("lifecycle account detail failed");
|
||
const data = await res.json();
|
||
if (!ignore) {
|
||
setLifecycleAccountDetailModal((prev) => (prev ? { ...prev, detail: data } : prev));
|
||
}
|
||
} catch (err) {
|
||
if (!ignore) setError("생애주기 계정 상세를 불러오지 못했습니다.");
|
||
} finally {
|
||
if (!ignore) setLifecycleAccountDetailModalLoading(false);
|
||
}
|
||
}
|
||
loadLifecycleAccountDetailModal();
|
||
return () => { ignore = true; };
|
||
}, [lifecycleAccountDetailModal?.project_code, lifecycleAccountDetailModal?.bucket_label, lifecycleAccountDetailModal?.account_code]);
|
||
|
||
useEffect(() => {
|
||
setSelectedAccountProjectCode("");
|
||
setAccountVendorModal(null);
|
||
}, [selectedAccountCode]);
|
||
|
||
useEffect(() => {
|
||
setSelectedVendorProjectCode("");
|
||
setVendorAccountModal(null);
|
||
}, [selectedVendorName]);
|
||
|
||
useEffect(() => {
|
||
if (projectMethodFamily === "전체") return;
|
||
if (projectMethod !== "전체" && !methodOptionsByFamily.includes(projectMethod)) {
|
||
setProjectMethod("전체");
|
||
}
|
||
}, [projectMethodFamily, projectMethod, methodOptionsByFamily]);
|
||
|
||
const selectedProject = useMemo(() => (
|
||
projects.find(item => item.project_code === selectedProjectCode) || null
|
||
), [projects, selectedProjectCode]);
|
||
const isPileProject = useMemo(() => {
|
||
const family = detail?.summary?.construction_family || editor.construction_family || "";
|
||
const method = detail?.summary?.construction_method || editor.construction_method || "";
|
||
return family === "복합말뚝" || ["HCP", "CFT", "DDH", "PHC"].includes(method);
|
||
}, [detail?.summary?.construction_family, detail?.summary?.construction_method, editor.construction_family, editor.construction_method]);
|
||
const effectiveProgressRate = useMemo(() => {
|
||
if (isPileProject) {
|
||
const contractCount = Number(contractPileCount) || 0;
|
||
const currentCount = Number(constructedPileCount) || 0;
|
||
return contractCount > 0 ? (currentCount / contractCount) * 100 : 0;
|
||
}
|
||
return Number(progressRate) || 0;
|
||
}, [isPileProject, contractPileCount, constructedPileCount, progressRate]);
|
||
|
||
const selectedVendor = useMemo(() => (
|
||
vendors.find((item) => item.vendor_name === selectedVendorName) || null
|
||
), [vendors, selectedVendorName]);
|
||
|
||
const projectBudgetRows = useMemo(() => filterProjectBudgetRows(budgetRows), [budgetRows]);
|
||
const budgetSections = useMemo(() => buildBudgetSections(projectBudgetRows), [projectBudgetRows]);
|
||
const budgetGroups = useMemo(
|
||
() => buildBudgetGroups(projectBudgetRows, detail?.summary?.project_type || ""),
|
||
[projectBudgetRows, detail?.summary?.project_type]
|
||
);
|
||
const budgetCompareGroups = useMemo(
|
||
() => (
|
||
detail?.summary?.project_type === "시공"
|
||
? buildConstructionCompareGroups(projectBudgetRows)
|
||
: buildBudgetCompareGroups(projectBudgetRows)
|
||
),
|
||
[projectBudgetRows, detail?.summary?.project_type]
|
||
);
|
||
const budgetCompareRows = useMemo(() => (
|
||
budgetCompareGroups.map((group) => ({
|
||
key: group.key,
|
||
label: group.label,
|
||
progressRate: effectiveProgressRate,
|
||
executionRate: group.rateTotal || 0,
|
||
gapRate: (group.rateTotal || 0) - effectiveProgressRate,
|
||
}))
|
||
), [budgetCompareGroups, effectiveProgressRate]);
|
||
const profitSummary = useMemo(() => {
|
||
const revenue = Number(detail?.budget_analysis?.revenue_actual_total || 0);
|
||
const expense = Number(detail?.budget_analysis?.expense_actual_total || 0);
|
||
const profit = revenue - expense;
|
||
const marginRate = revenue > 0 ? (profit / revenue) * 100 : 0;
|
||
return {
|
||
revenue,
|
||
expense,
|
||
profit,
|
||
marginRate,
|
||
};
|
||
}, [detail?.budget_analysis]);
|
||
const dashboardProjectsBase = useMemo(() => (
|
||
(dashboardData?.projects || []).filter((item) => {
|
||
const projectType = String(item?.project_type || "").trim();
|
||
const projectCode = String(item?.project_code || "").trim();
|
||
const projectName = String(item?.project_name || "").trim();
|
||
return (
|
||
projectCode.includes("-시공-") &&
|
||
!projectCode.includes("-관리-") &&
|
||
!projectCode.includes("-설계-") &&
|
||
!projectName.includes("시공관리") &&
|
||
projectType !== "관리" &&
|
||
projectType !== "설계"
|
||
);
|
||
})
|
||
), [dashboardData]);
|
||
const dashboardMethodBaseItems = useMemo(() => {
|
||
const groups = {};
|
||
dashboardProjectsBase.forEach((project) => {
|
||
const method = (project.construction_method || "").trim() || "공법미지정";
|
||
const family = (project.construction_family || "").trim() || "기타/미지정";
|
||
if (!groups[method]) {
|
||
groups[method] = {
|
||
method,
|
||
family,
|
||
project_count: 0,
|
||
income_supply: 0,
|
||
expense_supply: 0,
|
||
profit_supply: 0,
|
||
status_counts: { normal: 0, upfront: 0, delay: 0, risk: 0 },
|
||
margin_grade_counts: { deficit: 0, caution: 0, good: 0, excellent: 0 },
|
||
};
|
||
}
|
||
groups[method].project_count += 1;
|
||
groups[method].income_supply += Number(project.income_supply || 0);
|
||
groups[method].expense_supply += Number(project.expense_supply || 0);
|
||
groups[method].profit_supply += Number(project.profit_supply || 0);
|
||
const statusKey = project.status_key || "normal";
|
||
if (Object.prototype.hasOwnProperty.call(groups[method].status_counts, statusKey)) {
|
||
groups[method].status_counts[statusKey] += 1;
|
||
}
|
||
const gradeKey = getMarginGradeKey(project.margin_rate);
|
||
groups[method].margin_grade_counts[gradeKey] += 1;
|
||
});
|
||
return Object.values(groups)
|
||
.map((item) => ({
|
||
...item,
|
||
margin_rate: item.income_supply > 0 ? (item.profit_supply / item.income_supply) * 100 : 0,
|
||
}))
|
||
.sort((a, b) => b.income_supply - a.income_supply);
|
||
}, [dashboardProjectsBase]);
|
||
const dashboardFamilyMethodStatusMap = useMemo(() => {
|
||
const groups = {};
|
||
dashboardMethodBaseItems.forEach((method) => {
|
||
const family = (method.family || "").trim() || "기타/미지정";
|
||
if (!groups[family]) {
|
||
groups[family] = {
|
||
normal: 0,
|
||
upfront: 0,
|
||
delay: 0,
|
||
risk: 0,
|
||
};
|
||
}
|
||
groups[family].normal += Number(method.status_counts?.normal || 0);
|
||
groups[family].upfront += Number(method.status_counts?.upfront || 0);
|
||
groups[family].delay += Number(method.status_counts?.delay || 0);
|
||
groups[family].risk += Number(method.status_counts?.risk || 0);
|
||
});
|
||
return groups;
|
||
}, [dashboardMethodBaseItems]);
|
||
const dashboardFamilyItems = useMemo(() => {
|
||
const groups = {};
|
||
dashboardProjectsBase.forEach((project) => {
|
||
const family = (project.construction_family || "").trim() || "기타/미지정";
|
||
if (!groups[family]) {
|
||
groups[family] = {
|
||
family,
|
||
project_count: 0,
|
||
income_supply: 0,
|
||
expense_supply: 0,
|
||
profit_supply: 0,
|
||
status_counts: { normal: 0, upfront: 0, delay: 0, risk: 0 },
|
||
margin_grade_counts: { deficit: 0, caution: 0, good: 0, excellent: 0 },
|
||
};
|
||
}
|
||
groups[family].project_count += 1;
|
||
groups[family].income_supply += Number(project.income_supply || 0);
|
||
groups[family].expense_supply += Number(project.expense_supply || 0);
|
||
groups[family].profit_supply += Number(project.profit_supply || 0);
|
||
const statusKey = project.status_key || "normal";
|
||
if (Object.prototype.hasOwnProperty.call(groups[family].status_counts, statusKey)) {
|
||
groups[family].status_counts[statusKey] += 1;
|
||
}
|
||
const gradeKey = getMarginGradeKey(project.margin_rate);
|
||
groups[family].margin_grade_counts[gradeKey] += 1;
|
||
});
|
||
|
||
const items = Object.values(groups).map((item) => {
|
||
const methodStatusCounts = dashboardFamilyMethodStatusMap[item.family];
|
||
const status_counts = methodStatusCounts
|
||
? {
|
||
normal: Number(methodStatusCounts.normal || 0),
|
||
upfront: Number(methodStatusCounts.upfront || 0),
|
||
delay: Number(methodStatusCounts.delay || 0),
|
||
risk: Number(methodStatusCounts.risk || 0),
|
||
}
|
||
: item.status_counts;
|
||
return {
|
||
...item,
|
||
status_counts,
|
||
margin_grade_counts: item.margin_grade_counts || { deficit: 0, caution: 0, good: 0, excellent: 0 },
|
||
margin_rate: item.income_supply > 0 ? (item.profit_supply / item.income_supply) * 100 : 0,
|
||
};
|
||
}).sort((a, b) => b.income_supply - a.income_supply);
|
||
|
||
const total = {
|
||
family: "전체",
|
||
project_count: items.reduce((sum, item) => sum + item.project_count, 0),
|
||
income_supply: items.reduce((sum, item) => sum + item.income_supply, 0),
|
||
expense_supply: items.reduce((sum, item) => sum + item.expense_supply, 0),
|
||
profit_supply: items.reduce((sum, item) => sum + item.profit_supply, 0),
|
||
status_counts: items.reduce((acc, item) => {
|
||
Object.keys(acc).forEach((key) => {
|
||
acc[key] += Number(item.status_counts?.[key] || 0);
|
||
});
|
||
return acc;
|
||
}, { normal: 0, upfront: 0, delay: 0, risk: 0 }),
|
||
margin_grade_counts: items.reduce((acc, item) => {
|
||
Object.keys(acc).forEach((key) => {
|
||
acc[key] += Number(item.margin_grade_counts?.[key] || 0);
|
||
});
|
||
return acc;
|
||
}, { deficit: 0, caution: 0, good: 0, excellent: 0 }),
|
||
};
|
||
total.margin_rate = total.income_supply > 0 ? (total.profit_supply / total.income_supply) * 100 : 0;
|
||
return [total, ...items];
|
||
}, [dashboardProjectsBase, dashboardFamilyMethodStatusMap]);
|
||
const dashboardFamilyLegend = useMemo(() => {
|
||
const items = dashboardFamilyItems.filter((item) => item.family !== "전체");
|
||
const total = items.reduce((sum, item) => sum + Number(item.project_count || 0), 0);
|
||
return items.map((item, index) => ({
|
||
key: item.family,
|
||
label: item.family,
|
||
count: Number(item.project_count || 0),
|
||
ratio: total > 0 ? (Number(item.project_count || 0) / total) * 100 : 0,
|
||
color: getFamilyPaletteColor(index),
|
||
income_supply: Number(item.income_supply || 0),
|
||
margin_rate: Number(item.margin_rate || 0),
|
||
status_counts: item.status_counts || { normal: 0, upfront: 0, delay: 0, risk: 0 },
|
||
}));
|
||
}, [dashboardFamilyItems]);
|
||
const dashboardFamilyMap = useMemo(() => {
|
||
const map = {};
|
||
dashboardFamilyItems.forEach((item) => {
|
||
map[item.family] = item;
|
||
});
|
||
return map;
|
||
}, [dashboardFamilyItems]);
|
||
const dashboardFamilyColorMap = useMemo(() => {
|
||
const map = {};
|
||
dashboardFamilyLegend.forEach((item) => {
|
||
map[item.key] = item.color;
|
||
});
|
||
return map;
|
||
}, [dashboardFamilyLegend]);
|
||
const dashboardFamilyBarItems = useMemo(() => {
|
||
const items = dashboardFamilyItems.filter((item) => item.family !== "전체");
|
||
const maxIncome = items.reduce((max, item) => Math.max(max, Number(item.income_supply || 0)), 0);
|
||
return items.map((item) => ({
|
||
...item,
|
||
width: maxIncome > 0 ? (Number(item.income_supply || 0) / maxIncome) * 100 : 0,
|
||
}));
|
||
}, [dashboardFamilyItems]);
|
||
const visibleDashboardMethods = useMemo(() => {
|
||
if (selectedDashboardFamily === "전체") return dashboardMethodBaseItems;
|
||
return dashboardMethodBaseItems.filter((method) => ((method.family || "").trim() || "기타/미지정") === selectedDashboardFamily);
|
||
}, [dashboardMethodBaseItems, selectedDashboardFamily]);
|
||
const dashboardYearOptions = useMemo(() => {
|
||
return (managementOverview?.yearly_construction_margin_items || [])
|
||
.map((item) => String(item.year || "").trim())
|
||
.filter(Boolean);
|
||
}, [managementOverview]);
|
||
const overallDashboardScope = useMemo(() => {
|
||
const items = dashboardFamilyItems.filter((item) => item.family !== "전체");
|
||
const yearlyConstructionItems = (managementOverview?.yearly_construction_margin_items || []).filter((item) => {
|
||
return !selectedDashboardYear || String(item.year || "") === selectedDashboardYear;
|
||
});
|
||
const income = yearlyConstructionItems.length
|
||
? yearlyConstructionItems.reduce((sum, item) => sum + Number(item.income_supply || 0), 0)
|
||
: items.reduce((sum, item) => sum + Number(item.income_supply || 0), 0);
|
||
const expense = yearlyConstructionItems.length
|
||
? yearlyConstructionItems.reduce((sum, item) => sum + Number(item.expense_supply || 0), 0)
|
||
: items.reduce((sum, item) => sum + Number(item.expense_supply || 0), 0);
|
||
const profit = yearlyConstructionItems.length
|
||
? yearlyConstructionItems.reduce((sum, item) => sum + Number(item.profit_supply || 0), 0)
|
||
: items.reduce((sum, item) => sum + Number(item.profit_supply || 0), 0);
|
||
const marginRate = income > 0 ? (profit / income) * 100 : 0;
|
||
const statusCounts = items.reduce((acc, item) => {
|
||
Object.keys(acc).forEach((key) => {
|
||
acc[key] += Number(item.status_counts?.[key] || 0);
|
||
});
|
||
return acc;
|
||
}, { normal: 0, upfront: 0, delay: 0, risk: 0 });
|
||
return {
|
||
projectCount: items.reduce((sum, item) => sum + Number(item.project_count || 0), 0),
|
||
ongoingProjectCount: Number(dashboardData?.ongoing_project_count || 0),
|
||
income,
|
||
expense,
|
||
profit,
|
||
marginRate,
|
||
statusCounts,
|
||
};
|
||
}, [dashboardFamilyItems, managementOverview, selectedDashboardYear, dashboardData]);
|
||
const dashboardStatusLegend = useMemo(() => {
|
||
const counts = overallDashboardScope.statusCounts || {};
|
||
const total = Object.values(counts).reduce((sum, value) => sum + Number(value || 0), 0);
|
||
return (dashboardData?.status_bands || []).map((band) => {
|
||
const count = Number(counts?.[band.key] || 0);
|
||
return {
|
||
...band,
|
||
count,
|
||
ratio: total > 0 ? (count / total) * 100 : 0,
|
||
};
|
||
});
|
||
}, [dashboardData, overallDashboardScope]);
|
||
const dashboardOngoingProjectCount = useMemo(() => {
|
||
return Number(dashboardData?.ongoing_project_count || 0);
|
||
}, [dashboardData]);
|
||
const dashboardOngoingProjectItems = useMemo(() => {
|
||
const ongoingSet = new Set((dashboardData?.ongoing_project_codes || []).map((code) => String(code || "").trim()).filter(Boolean));
|
||
return [...dashboardProjectsBase]
|
||
.filter((item) => ongoingSet.has(String(item.project_code || "").trim()))
|
||
.sort((a, b) => (b.project_code || "").localeCompare(a.project_code || "", "ko"));
|
||
}, [dashboardProjectsBase, dashboardData]);
|
||
const companyGraphRows = useMemo(() => {
|
||
const fixedOrder = { "시공": 0, "관리": 999 };
|
||
return (companyOverview.items || []).map((yearItem) => {
|
||
const typeItems = [...(yearItem.types || [])]
|
||
.filter((typeItem) => Number(typeItem.expense_supply || 0) > 0 || Number(typeItem.income_supply || 0) > 0)
|
||
.sort((a, b) => {
|
||
const aFixed = Object.prototype.hasOwnProperty.call(fixedOrder, a.project_type || "") ? fixedOrder[a.project_type] : 100;
|
||
const bFixed = Object.prototype.hasOwnProperty.call(fixedOrder, b.project_type || "") ? fixedOrder[b.project_type] : 100;
|
||
if (aFixed !== bFixed) return aFixed - bFixed;
|
||
const aWeight = Math.max(Number(a.income_ratio || 0) || 0, Number(a.expense_ratio || 0) || 0);
|
||
const bWeight = Math.max(Number(b.income_ratio || 0) || 0, Number(b.expense_ratio || 0) || 0);
|
||
if (bWeight !== aWeight) return bWeight - aWeight;
|
||
return (a.project_type || "").localeCompare(b.project_type || "", "ko");
|
||
});
|
||
return {
|
||
year: yearItem.year,
|
||
income_supply: Number(yearItem.income_supply || 0),
|
||
expense_supply: Number(yearItem.expense_supply || 0),
|
||
typeItems,
|
||
};
|
||
});
|
||
}, [companyOverview]);
|
||
const companyGraphMaxValue = useMemo(() => {
|
||
return companyGraphRows.reduce((max, row) => Math.max(max, Number(row.income_supply || 0), Number(row.expense_supply || 0)), 1);
|
||
}, [companyGraphRows]);
|
||
const companyGraphLayout = useMemo(() => {
|
||
const chartLeft = 90;
|
||
const chartRight = 980;
|
||
const chartWidth = chartRight - chartLeft;
|
||
const pointCount = Math.max(companyGraphRows.length, 1);
|
||
const centerStartX = chartLeft + 60;
|
||
const centerEndX = chartRight - 60;
|
||
const availableCenterWidth = Math.max(centerEndX - centerStartX, 0);
|
||
const stepX = pointCount > 1 ? availableCenterWidth / (pointCount - 1) : 0;
|
||
const barWidth = pointCount > 1
|
||
? Math.max(22, Math.min(72, stepX * 0.68))
|
||
: 94;
|
||
return {
|
||
chartLeft,
|
||
chartRight,
|
||
chartWidth,
|
||
centerStartX,
|
||
centerEndX,
|
||
stepX,
|
||
barWidth,
|
||
chartHeight: 250,
|
||
baseY: 320,
|
||
};
|
||
}, [companyGraphRows.length]);
|
||
const companyGraphLinePoints = useMemo(() => {
|
||
return companyGraphRows.map((row, index) => {
|
||
const x = companyGraphLayout.centerStartX + index * companyGraphLayout.stepX;
|
||
const y = companyGraphLayout.baseY - (Number(row.income_supply || 0) / companyGraphMaxValue) * companyGraphLayout.chartHeight;
|
||
return `${x},${y}`;
|
||
}).join(" ");
|
||
}, [companyGraphRows, companyGraphMaxValue, companyGraphLayout]);
|
||
const visibleDashboardProjects = useMemo(() => {
|
||
const filtered = selectedDashboardFamily === "전체"
|
||
? dashboardProjectsBase
|
||
: dashboardProjectsBase.filter((item) => ((item.construction_family || "").trim() || "기타/미지정") === selectedDashboardFamily);
|
||
return [...filtered].sort((a, b) => (b.income_supply || 0) - (a.income_supply || 0));
|
||
}, [dashboardProjectsBase, selectedDashboardFamily]);
|
||
const dashboardProjectMap = useMemo(() => {
|
||
const map = {};
|
||
dashboardProjectsBase.forEach((item) => {
|
||
if (item?.project_code) map[item.project_code] = item;
|
||
});
|
||
return map;
|
||
}, [dashboardProjectsBase]);
|
||
const selectedDashboardProjects = useMemo(() => {
|
||
if (!selectedDashboardMethod) return visibleDashboardProjects;
|
||
return visibleDashboardProjects
|
||
.filter((item) => ((item.construction_method || "").trim() || "공법미지정") === selectedDashboardMethod)
|
||
.sort((a, b) => (b.income_supply || 0) - (a.income_supply || 0));
|
||
}, [selectedDashboardMethod, visibleDashboardProjects]);
|
||
const dashboardMethodItems = useMemo(() => {
|
||
return visibleDashboardMethods.map((method) => ({
|
||
...method,
|
||
donutItems: (dashboardData?.status_bands || []).map((band) => {
|
||
const count = Number(method.status_counts?.[band.key] || 0);
|
||
const total = Number(method.project_count || 0);
|
||
return {
|
||
key: band.key,
|
||
ratio: total > 0 ? (count / total) * 100 : 0,
|
||
};
|
||
}),
|
||
}));
|
||
}, [visibleDashboardMethods, dashboardData]);
|
||
const selectedDashboardScope = useMemo(() => {
|
||
const projects = selectedDashboardProjects || [];
|
||
const income = projects.reduce((sum, item) => sum + Number(item.income_supply || 0), 0);
|
||
const expense = projects.reduce((sum, item) => sum + Number(item.expense_supply || 0), 0);
|
||
const profit = income - expense;
|
||
const marginRate = income > 0 ? (profit / income) * 100 : 0;
|
||
const statusCounts = { normal: 0, upfront: 0, delay: 0, risk: 0 };
|
||
projects.forEach((item) => {
|
||
const key = item.status_key || "normal";
|
||
if (Object.prototype.hasOwnProperty.call(statusCounts, key)) statusCounts[key] += 1;
|
||
});
|
||
const total = projects.length;
|
||
const statusLegend = (dashboardData?.status_bands || []).map((band) => {
|
||
const count = Number(statusCounts?.[band.key] || 0);
|
||
return {
|
||
...band,
|
||
count,
|
||
ratio: total > 0 ? (count / total) * 100 : 0,
|
||
};
|
||
});
|
||
const compareBase = Math.max(income, expense, Math.abs(profit), 1);
|
||
return {
|
||
projectCount: total,
|
||
income,
|
||
expense,
|
||
profit,
|
||
marginRate,
|
||
statusLegend,
|
||
compareBars: [
|
||
{ key: "income", label: "수입", value: income, width: (income / compareBase) * 100, color: "#1e5e95" },
|
||
{ key: "expense", label: "지출", value: expense, width: (expense / compareBase) * 100, color: "#77c4df" },
|
||
{ key: "profit", label: "수익", value: Math.abs(profit), displayValue: profit, width: (Math.abs(profit) / compareBase) * 100, color: profit < 0 ? "#d14343" : "#1ca64b" },
|
||
],
|
||
};
|
||
}, [selectedDashboardProjects, dashboardData]);
|
||
const dashboardStatusProjectModalItems = useMemo(() => {
|
||
if (!dashboardStatusProjectModal?.statusKey) return [];
|
||
return [...selectedDashboardProjects]
|
||
.filter((item) => (item.status_key || "normal") === dashboardStatusProjectModal.statusKey)
|
||
.filter((item) => dashboardMarginGradeFilter === "all" ? true : getMarginGradeKey(item.margin_rate) === dashboardMarginGradeFilter)
|
||
.sort((a, b) => (b.project_code || "").localeCompare(a.project_code || "", "ko"));
|
||
}, [dashboardStatusProjectModal, selectedDashboardProjects, dashboardMarginGradeFilter]);
|
||
function closeDashboardStatusProjectModal() {
|
||
if (initialPopupModeParam === "status_projects") {
|
||
window.close();
|
||
return;
|
||
}
|
||
setDashboardStatusProjectModal(null);
|
||
}
|
||
function openDashboardStatusProjectModal(statusKey, statusLabel, count) {
|
||
if (!count) return;
|
||
const nextUrl = new URL(`${window.location.origin}/PTC-lab-manage/`);
|
||
nextUrl.searchParams.set("popup", "status_projects");
|
||
nextUrl.searchParams.set("dashboard_family", selectedDashboardFamily || "전체");
|
||
if (selectedDashboardYear) nextUrl.searchParams.set("dashboard_year", selectedDashboardYear);
|
||
if (selectedDashboardMethod) nextUrl.searchParams.set("dashboard_method", selectedDashboardMethod);
|
||
nextUrl.searchParams.set("status_key", statusKey);
|
||
nextUrl.searchParams.set("status_label", statusLabel);
|
||
const popupWindow = window.open(
|
||
"",
|
||
STATUS_POPUP_WINDOW_NAME,
|
||
"popup=yes,width=1320,height=920,scrollbars=yes,resizable=yes"
|
||
);
|
||
if (popupWindow) {
|
||
try {
|
||
const sameOriginReady =
|
||
popupWindow.location &&
|
||
popupWindow.location.origin === window.location.origin &&
|
||
popupWindow.location.pathname === "/PTC-lab-manage/" &&
|
||
popupWindow.location.search.includes("popup=status_projects");
|
||
|
||
if (sameOriginReady) {
|
||
popupWindow.postMessage({
|
||
type: "ptc-open-status-popup",
|
||
dashboardFamily: selectedDashboardFamily || "전체",
|
||
dashboardYear: selectedDashboardYear || "",
|
||
dashboardMethod: selectedDashboardMethod || "",
|
||
statusKey,
|
||
statusLabel,
|
||
grade: "all",
|
||
}, window.location.origin);
|
||
} else {
|
||
popupWindow.location.href = nextUrl.toString();
|
||
}
|
||
} catch (err) {
|
||
popupWindow.location.href = nextUrl.toString();
|
||
}
|
||
popupWindow.focus();
|
||
return;
|
||
}
|
||
setDashboardMarginGradeFilter("all");
|
||
setDashboardStatusProjectModal({
|
||
statusKey,
|
||
statusLabel,
|
||
openedAt: Date.now(),
|
||
});
|
||
}
|
||
|
||
const dashboardStatusProjectModalContent = dashboardStatusProjectModal ? (
|
||
<div className={isStatusPopupWindow ? "panel" : "modal-panel modal-panel-wide"} onClick={isStatusPopupWindow ? undefined : (e) => e.stopPropagation()} style={isStatusPopupWindow ? { padding: 20 } : undefined}>
|
||
<div className="modal-head">
|
||
<div>
|
||
<div style={{ fontSize: 22, fontWeight: 700 }}>
|
||
{dashboardStatusProjectModal.statusLabel} 프로젝트
|
||
</div>
|
||
<div className="subtle" style={{ marginTop: 6 }}>
|
||
{selectedDashboardMethod
|
||
? `${selectedDashboardFamily} · ${selectedDashboardMethod} 범위`
|
||
: `${selectedDashboardFamily} 대분류 범위`}{selectedDashboardYear ? ` · ${selectedDashboardYear}년` : ""} · {fmt(dashboardStatusProjectModalItems.length)}개
|
||
</div>
|
||
</div>
|
||
<button className="button-muted" onClick={closeDashboardStatusProjectModal}>닫기</button>
|
||
</div>
|
||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginTop: 4, marginBottom: 14 }}>
|
||
{[
|
||
{ key: "all", label: "전체" },
|
||
{ key: "deficit", label: "적자" },
|
||
{ key: "caution", label: "주의" },
|
||
{ key: "good", label: "양호" },
|
||
{ key: "excellent", label: "우수" },
|
||
].map((grade) => (
|
||
<button
|
||
key={grade.key}
|
||
type="button"
|
||
className="button-muted"
|
||
onClick={() => setDashboardMarginGradeFilter(grade.key)}
|
||
style={{
|
||
borderColor: dashboardMarginGradeFilter === grade.key ? getMarginGradeColor(grade.key === "all" ? "good" : grade.key) : undefined,
|
||
color: dashboardMarginGradeFilter === grade.key ? getMarginGradeColor(grade.key === "all" ? "good" : grade.key) : undefined,
|
||
fontWeight: dashboardMarginGradeFilter === grade.key ? 700 : 500,
|
||
}}
|
||
>
|
||
{grade.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div style={{ display: "grid", gap: 10, marginTop: 14 }}>
|
||
{dashboardStatusProjectModalItems.map((item) => (
|
||
<button
|
||
key={item.project_code}
|
||
type="button"
|
||
className="mini-card"
|
||
style={{ padding: 14, width: "100%", textAlign: "left", cursor: "pointer" }}
|
||
onClick={() => {
|
||
if (isStatusPopupWindow && window.opener && !window.opener.closed) {
|
||
window.opener.postMessage({
|
||
type: "ptc-open-project",
|
||
projectCode: item.project_code || "",
|
||
}, window.location.origin);
|
||
window.opener.focus();
|
||
return;
|
||
}
|
||
|
||
setDashboardStatusProjectModal(null);
|
||
setSelectedProjectCode(item.project_code || "");
|
||
setCurrentTab("project");
|
||
}}
|
||
>
|
||
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, alignItems: "flex-start" }}>
|
||
<div>
|
||
<div style={{ fontSize: 17, fontWeight: 700 }}>{item.project_name || "(이름없음)"}</div>
|
||
<div className="subtle" style={{ marginTop: 4 }}>{item.project_code}</div>
|
||
</div>
|
||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", justifyContent: "flex-end" }}>
|
||
<span className="badge badge-blue">{item.project_type || "미지정"}</span>
|
||
<span className="badge badge-blue">{item.construction_method || "공법미지정"}</span>
|
||
<span className="badge" style={{ background: "rgba(255,255,255,0.92)", color: getMarginGradeColor(getMarginGradeKey(item.margin_rate)), border: `1px solid ${getMarginGradeColor(getMarginGradeKey(item.margin_rate))}33` }}>
|
||
{getMarginGradeLabel(getMarginGradeKey(item.margin_rate))}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: 10, marginTop: 12 }}>
|
||
<div>
|
||
<div className="subtle">수입</div>
|
||
<div style={{ marginTop: 4, fontWeight: 700 }}>{fmtEok(item.income_supply || 0)}</div>
|
||
</div>
|
||
<div>
|
||
<div className="subtle">지출</div>
|
||
<div style={{ marginTop: 4, fontWeight: 700 }}>{fmtEok(item.expense_supply || 0)}</div>
|
||
</div>
|
||
<div>
|
||
<div className="subtle">수익률</div>
|
||
<div style={{ marginTop: 4, fontWeight: 700, color: Number(item.margin_rate || 0) < 0 ? "#d14343" : "var(--good)" }}>
|
||
{Number(item.margin_rate || 0).toFixed(1)}%
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
))}
|
||
{!dashboardStatusProjectModalItems.length && (
|
||
<div className="mini-card">해당 상태의 프로젝트가 없습니다.</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : null;
|
||
const dashboardOngoingProjectModalContent = dashboardOngoingProjectModalOpen ? (
|
||
<div className="modal-panel modal-panel-wide" onClick={(e) => e.stopPropagation()}>
|
||
<div className="modal-head">
|
||
<div>
|
||
<div style={{ fontSize: 22, fontWeight: 700 }}>진행중 프로젝트</div>
|
||
<div className="subtle" style={{ marginTop: 6 }}>
|
||
{selectedDashboardYear ? `${selectedDashboardYear}년에 거래가 있었고 현재 기준 최근 6개월 안에도 입출금이 이어진 시공 프로젝트` : "현재 기준 최근 6개월 안에 입출금 내역이 있는 시공 프로젝트"} · {fmt(dashboardOngoingProjectItems.length)}개
|
||
</div>
|
||
</div>
|
||
<button className="button-muted" onClick={() => setDashboardOngoingProjectModalOpen(false)}>닫기</button>
|
||
</div>
|
||
<div style={{ display: "grid", gap: 10, marginTop: 14 }}>
|
||
{dashboardOngoingProjectItems.map((item) => (
|
||
<button
|
||
key={`ongoing-${item.project_code}`}
|
||
type="button"
|
||
className="mini-card"
|
||
style={{ padding: 14, width: "100%", textAlign: "left", cursor: "pointer" }}
|
||
onClick={() => {
|
||
setDashboardOngoingProjectModalOpen(false);
|
||
setSelectedProjectCode(item.project_code || "");
|
||
setCurrentTab("project");
|
||
}}
|
||
>
|
||
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, alignItems: "flex-start" }}>
|
||
<div>
|
||
<div style={{ fontSize: 17, fontWeight: 700 }}>{item.project_name || "(이름없음)"}</div>
|
||
<div className="subtle" style={{ marginTop: 4 }}>{item.project_code}</div>
|
||
</div>
|
||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", justifyContent: "flex-end" }}>
|
||
<span className="badge badge-blue">{item.project_type || "미지정"}</span>
|
||
<span className="badge badge-blue">{item.construction_method || "공법미지정"}</span>
|
||
</div>
|
||
</div>
|
||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: 10, marginTop: 12 }}>
|
||
<div>
|
||
<div className="subtle">수입</div>
|
||
<div style={{ marginTop: 4, fontWeight: 700 }}>{fmtEok(item.income_supply || 0)}</div>
|
||
</div>
|
||
<div>
|
||
<div className="subtle">지출</div>
|
||
<div style={{ marginTop: 4, fontWeight: 700 }}>{fmtEok(item.expense_supply || 0)}</div>
|
||
</div>
|
||
<div>
|
||
<div className="subtle">수익률</div>
|
||
<div style={{ marginTop: 4, fontWeight: 700, color: Number(item.margin_rate || 0) < 0 ? "#d14343" : "var(--good)" }}>
|
||
{Number(item.margin_rate || 0).toFixed(1)}%
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
))}
|
||
{!dashboardOngoingProjectItems.length && (
|
||
<div className="mini-card">해당 조건의 진행중 프로젝트가 없습니다.</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : null;
|
||
|
||
useEffect(() => {
|
||
if (!selectedDashboardMethod) return;
|
||
if (!visibleDashboardMethods.some((method) => method.method === selectedDashboardMethod)) {
|
||
setSelectedDashboardMethod("");
|
||
}
|
||
}, [selectedDashboardFamily, selectedDashboardMethod, visibleDashboardMethods]);
|
||
|
||
const allVisibleSelected = useMemo(() => (
|
||
filteredProjects.length > 0 && filteredProjects.every((item) => selectedProjectCodes.includes(item.project_code))
|
||
), [filteredProjects, selectedProjectCodes]);
|
||
|
||
function toggleProjectSelection(projectCode) {
|
||
setSelectedProjectCodes((prev) => (
|
||
prev.includes(projectCode)
|
||
? prev.filter((code) => code !== projectCode)
|
||
: [...prev, projectCode]
|
||
));
|
||
}
|
||
|
||
function toggleSelectAllVisible() {
|
||
const visibleCodes = filteredProjects.map((item) => item.project_code);
|
||
setSelectedProjectCodes((prev) => {
|
||
if (visibleCodes.every((code) => prev.includes(code))) {
|
||
return prev.filter((code) => !visibleCodes.includes(code));
|
||
}
|
||
return Array.from(new Set([...prev, ...visibleCodes]));
|
||
});
|
||
}
|
||
|
||
function openLifecycleAllocationModal(projectItem) {
|
||
if (!selectedProjectCode || !projectItem) return;
|
||
const projectType = projectItem.project_type || "";
|
||
if (!["영업", "설계"].includes(projectType)) return;
|
||
const sourceProjectCode = projectItem.project_code || "";
|
||
const lifecycleRow = ((detail?.lifecycle_cost?.rows || []).find(
|
||
(row) => (row.project_code || "") === sourceProjectCode
|
||
) || null);
|
||
const numerator = Number(
|
||
lifecycleRow?.allocation_numerator ?? projectItem.allocation_numerator ?? 1
|
||
);
|
||
const denominator = Number(
|
||
lifecycleRow?.allocation_denominator ?? projectItem.allocation_denominator ?? 1
|
||
);
|
||
setLifecycleAllocationModal({
|
||
base_project_code: selectedProjectCode,
|
||
source_project_code: sourceProjectCode,
|
||
project_name: projectItem.project_name || "",
|
||
project_type: projectType,
|
||
allocation_numerator: Number.isFinite(numerator) && numerator >= 0 ? numerator : 1,
|
||
allocation_denominator: Number.isFinite(denominator) && denominator > 0 ? denominator : 1,
|
||
});
|
||
}
|
||
|
||
async function saveLifecycleAllocation() {
|
||
if (!lifecycleAllocationModal?.base_project_code || !lifecycleAllocationModal?.source_project_code) return;
|
||
const numerator = Math.max(0, Math.floor(Number(lifecycleAllocationModal.allocation_numerator || 0)));
|
||
const denominator = Math.max(1, Math.floor(Number(lifecycleAllocationModal.allocation_denominator || 1)));
|
||
if (numerator > denominator) {
|
||
setError("해당프로젝트 수는 총프로젝트 수보다 클 수 없습니다.");
|
||
return;
|
||
}
|
||
setLifecycleAllocationSaving(true);
|
||
setError("");
|
||
try {
|
||
const res = await fetch(`${API_BASE}/api/lifecycle-allocation/upsert`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
base_project_code: lifecycleAllocationModal.base_project_code,
|
||
source_project_code: lifecycleAllocationModal.source_project_code,
|
||
allocation_numerator: numerator,
|
||
allocation_denominator: denominator,
|
||
}),
|
||
});
|
||
if (!res.ok) throw new Error("allocation save failed");
|
||
await res.json();
|
||
|
||
const detailRes = await fetch(`${API_BASE}/api/project-detail?${detailQuery}`);
|
||
if (detailRes.ok) {
|
||
const nextDetail = await detailRes.json();
|
||
setDetail(nextDetail);
|
||
if (lifecycleBreakdownModal?.label) {
|
||
const refreshed = (nextDetail?.lifecycle_cost?.breakdown || []).find(
|
||
(item) => item.label === lifecycleBreakdownModal.label
|
||
);
|
||
if (refreshed) {
|
||
setLifecycleBreakdownModal({
|
||
label: refreshed.label || "",
|
||
expense_supply: Number(refreshed.expense_supply || 0),
|
||
direct_expense_supply: Number(refreshed.direct_expense_supply || 0),
|
||
shared_expense_supply: Number(refreshed.shared_expense_supply || 0),
|
||
projects: Array.isArray(refreshed.projects) ? refreshed.projects.map((project) => ({ ...project })) : [],
|
||
accounts: Array.isArray(refreshed.accounts) ? refreshed.accounts.map((account) => ({ ...account })) : [],
|
||
opened_at: Date.now(),
|
||
});
|
||
} else {
|
||
setLifecycleBreakdownModal(null);
|
||
}
|
||
}
|
||
}
|
||
setLifecycleAllocationModal(null);
|
||
} catch (err) {
|
||
setError("생애주기 배분 비율 저장에 실패했습니다.");
|
||
} finally {
|
||
setLifecycleAllocationSaving(false);
|
||
}
|
||
}
|
||
|
||
async function deleteLifecycleAllocation() {
|
||
if (!lifecycleAllocationModal?.base_project_code || !lifecycleAllocationModal?.source_project_code) return;
|
||
setLifecycleAllocationSaving(true);
|
||
setError("");
|
||
try {
|
||
const res = await fetch(`${API_BASE}/api/lifecycle-allocation/delete`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
base_project_code: lifecycleAllocationModal.base_project_code,
|
||
source_project_code: lifecycleAllocationModal.source_project_code,
|
||
}),
|
||
});
|
||
if (!res.ok) throw new Error("allocation delete failed");
|
||
await res.json();
|
||
|
||
const detailRes = await fetch(`${API_BASE}/api/project-detail?${detailQuery}`);
|
||
if (detailRes.ok) {
|
||
const nextDetail = await detailRes.json();
|
||
setDetail(nextDetail);
|
||
if (lifecycleBreakdownModal?.label) {
|
||
const refreshed = (nextDetail?.lifecycle_cost?.breakdown || []).find(
|
||
(item) => item.label === lifecycleBreakdownModal.label
|
||
);
|
||
if (refreshed) {
|
||
setLifecycleBreakdownModal({
|
||
label: refreshed.label || "",
|
||
expense_supply: Number(refreshed.expense_supply || 0),
|
||
direct_expense_supply: Number(refreshed.direct_expense_supply || 0),
|
||
shared_expense_supply: Number(refreshed.shared_expense_supply || 0),
|
||
projects: Array.isArray(refreshed.projects) ? refreshed.projects.map((project) => ({ ...project })) : [],
|
||
accounts: Array.isArray(refreshed.accounts) ? refreshed.accounts.map((account) => ({ ...account })) : [],
|
||
opened_at: Date.now(),
|
||
});
|
||
} else {
|
||
setLifecycleBreakdownModal(null);
|
||
}
|
||
}
|
||
}
|
||
setLifecycleAllocationModal(null);
|
||
} catch (err) {
|
||
setError("생애주기 배분 비율 삭제에 실패했습니다.");
|
||
} finally {
|
||
setLifecycleAllocationSaving(false);
|
||
}
|
||
}
|
||
|
||
async function saveLifecycleCommonAllocationMode(nextMode) {
|
||
if (!selectedProjectCode) return;
|
||
if (!["expense_ratio", "income_ratio"].includes(nextMode || "")) return;
|
||
if (effectiveCommonAllocationMode === nextMode) return;
|
||
setLifecycleCommonAllocationDraft(nextMode);
|
||
setLifecycleCommonAllocationSaving(true);
|
||
setError("");
|
||
try {
|
||
const res = await fetch(`${API_BASE}/api/lifecycle-common-allocation/upsert`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
base_project_code: selectedProjectCode,
|
||
allocation_mode: nextMode,
|
||
}),
|
||
});
|
||
if (!res.ok) throw new Error("common allocation save failed");
|
||
await res.json();
|
||
const detailRes = await fetch(`${API_BASE}/api/project-detail?${detailQuery}`);
|
||
if (detailRes.ok) {
|
||
const nextDetail = await detailRes.json();
|
||
setDetail(nextDetail);
|
||
if (lifecycleBreakdownModal?.label) {
|
||
const refreshed = (nextDetail?.lifecycle_cost?.breakdown || []).find(
|
||
(item) => item.label === lifecycleBreakdownModal.label
|
||
);
|
||
if (refreshed) {
|
||
setLifecycleBreakdownModal({
|
||
label: refreshed.label || "",
|
||
expense_supply: Number(refreshed.expense_supply || 0),
|
||
direct_expense_supply: Number(refreshed.direct_expense_supply || 0),
|
||
shared_expense_supply: Number(refreshed.shared_expense_supply || 0),
|
||
projects: Array.isArray(refreshed.projects) ? refreshed.projects.map((project) => ({ ...project })) : [],
|
||
accounts: Array.isArray(refreshed.accounts) ? refreshed.accounts.map((account) => ({ ...account })) : [],
|
||
opened_at: Date.now(),
|
||
});
|
||
} else {
|
||
setLifecycleBreakdownModal(null);
|
||
}
|
||
}
|
||
}
|
||
} catch (err) {
|
||
setLifecycleCommonAllocationDraft("");
|
||
setError("공통배분 기준 저장에 실패했습니다.");
|
||
} finally {
|
||
setLifecycleCommonAllocationSaving(false);
|
||
}
|
||
}
|
||
|
||
async function saveProjectMaster() {
|
||
if (!selectedProjectCode) return;
|
||
setSaving(true);
|
||
setError("");
|
||
try {
|
||
const res = await fetch(`${API_BASE}/api/project-master/upsert`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
project_code: selectedProjectCode,
|
||
project_name: editor.project_name,
|
||
project_type: editor.project_type,
|
||
construction_family: editor.construction_family,
|
||
construction_method: editor.construction_method,
|
||
start_date: editor.start_date,
|
||
end_date: editor.end_date,
|
||
note: editor.note,
|
||
related_project_codes: normalizedSelectedRelatedProjectCodes
|
||
})
|
||
});
|
||
if (!res.ok) throw new Error("save failed");
|
||
await res.json();
|
||
const detailRes = await fetch(`${API_BASE}/api/project-detail?project_code=${encodeURIComponent(selectedProjectCode)}`);
|
||
if (detailRes.ok) {
|
||
const data = await detailRes.json();
|
||
setDetail(data);
|
||
}
|
||
const projectRes = await fetch(`${API_BASE}/api/projects?${projectQuery}`);
|
||
if (projectRes.ok) {
|
||
const projectData = await projectRes.json();
|
||
setProjects(projectData.items || []);
|
||
}
|
||
} catch (err) {
|
||
setError("프로젝트 마스터 저장에 실패했습니다.");
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
async function persistProjectBudget(nextRows, nextProgressRate) {
|
||
if (!selectedProjectCode) return false;
|
||
setBudgetSaving(true);
|
||
setError("");
|
||
try {
|
||
const res = await fetch(`${API_BASE}/api/project-budget/upsert`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
project_code: selectedProjectCode,
|
||
progress_rate: Number(nextProgressRate) || 0,
|
||
contract_pile_count: Number(contractPileCount) || 0,
|
||
constructed_pile_count: Number(constructedPileCount) || 0,
|
||
item_rows: nextRows.map((item) => ({
|
||
section: item.section,
|
||
group: item.group,
|
||
category: item.category,
|
||
budget_amount: Number(item.budget_amount) || 0,
|
||
})),
|
||
account_rows: nextRows.flatMap((item) =>
|
||
(item.account_items || []).map((account) => ({
|
||
section: item.section,
|
||
group: item.group,
|
||
category: item.category,
|
||
account_code: account.account_code,
|
||
account_name: account.account_name,
|
||
budget_amount: Number(account.budget_amount) || 0,
|
||
}))
|
||
)
|
||
})
|
||
});
|
||
if (!res.ok) throw new Error("budget save failed");
|
||
const detailRes = await fetch(`${API_BASE}/api/project-detail?${detailQuery}`);
|
||
if (detailRes.ok) {
|
||
const data = await detailRes.json();
|
||
setDetail(data);
|
||
setBudgetRows(data?.budget_analysis?.rows || []);
|
||
setProgressRate(String(data?.budget_analysis?.progress_rate ?? 0));
|
||
setContractPileCount(String(data?.budget_analysis?.contract_pile_count ?? 0));
|
||
setConstructedPileCount(String(data?.budget_analysis?.constructed_pile_count ?? 0));
|
||
setPileProgressRows(normalizePileProgressRows(data?.budget_analysis?.pile_progress_entries || []));
|
||
}
|
||
return true;
|
||
} catch (err) {
|
||
setError("실행예산 저장에 실패했습니다.");
|
||
return false;
|
||
} finally {
|
||
setBudgetSaving(false);
|
||
}
|
||
}
|
||
|
||
async function saveProjectBudget() {
|
||
await persistProjectBudget(budgetRows, effectiveProgressRate);
|
||
}
|
||
|
||
function openBudgetModal(item) {
|
||
setBudgetModalItem(item);
|
||
setBudgetModalAccounts((item.account_items || []).map((account) => ({ ...account })));
|
||
setBudgetModalTotalBudget(Number(item?.budget_amount) || 0);
|
||
setBudgetAccountExpandedCode("");
|
||
setBudgetAccountDetailLoading(false);
|
||
}
|
||
|
||
async function openActualModal(item) {
|
||
if (!selectedProjectCode) return;
|
||
setActualModalItem(item);
|
||
setActualModalDetail(null);
|
||
setActualModalLoading(true);
|
||
try {
|
||
const params = new URLSearchParams({
|
||
project_code: selectedProjectCode,
|
||
section: item.section || "",
|
||
group_name: item.group || "",
|
||
category: item.category || ""
|
||
});
|
||
const res = await fetch(`${API_BASE}/api/project-budget-actual-detail?${params.toString()}`);
|
||
const data = await res.json();
|
||
if (!res.ok) throw new Error(data?.message || "집행 상세내역을 불러오지 못했습니다.");
|
||
setActualModalDetail(data);
|
||
} catch (err) {
|
||
setError(err.message || "집행 상세내역을 불러오지 못했습니다.");
|
||
setActualModalDetail({
|
||
summary: null,
|
||
accounts: [],
|
||
transactions: [],
|
||
error_message: err.message || "집행 상세내역을 불러오지 못했습니다."
|
||
});
|
||
} finally {
|
||
setActualModalLoading(false);
|
||
}
|
||
}
|
||
|
||
function openPileProgressModal() {
|
||
setPileProgressModalOpen(true);
|
||
setPileProgressRows((prev) => (
|
||
prev.length ? normalizePileProgressRows(prev) : normalizePileProgressRows([{ start_date: "", end_date: "", pile_count: 0, note: "" }])
|
||
));
|
||
}
|
||
|
||
function addPileProgressRow() {
|
||
setPileProgressRows((prev) => [...prev, decoratePileProgressRow({ start_date: "", end_date: "", pile_count: 0, note: "" })]);
|
||
}
|
||
|
||
function updatePileProgressRow(index, field, value) {
|
||
setPileProgressRows((prev) => prev.map((row, rowIndex) => (
|
||
rowIndex === index
|
||
? { ...row, [field]: field === "pile_count" ? (Number(value) || 0) : value }
|
||
: row
|
||
)));
|
||
}
|
||
|
||
function updatePileProgressDatePart(index, field, part, value) {
|
||
setPileProgressRows((prev) => prev.map((row, rowIndex) => {
|
||
if (rowIndex !== index) return row;
|
||
const partsKey = `${field}_parts`;
|
||
const current = row[partsKey] || splitDateParts(row[field]);
|
||
const next = { ...current, [part]: value };
|
||
const maxDay = daysInMonth(next.year, next.month);
|
||
if (Number(next.day || 0) > maxDay) {
|
||
next.day = String(maxDay).padStart(2, "0");
|
||
}
|
||
return { ...row, [partsKey]: next, [field]: joinDateParts(next.year, next.month, next.day) };
|
||
}));
|
||
}
|
||
|
||
function removePileProgressRow(index) {
|
||
setPileProgressRows((prev) => prev.filter((_, rowIndex) => rowIndex !== index));
|
||
}
|
||
|
||
async function savePileProgress() {
|
||
if (!selectedProjectCode) return;
|
||
setPileProgressSaving(true);
|
||
setError("");
|
||
try {
|
||
const entries = pileProgressRows
|
||
.map((row) => ({
|
||
start_date: String(row.start_date || "").trim(),
|
||
end_date: String(row.end_date || "").trim(),
|
||
pile_count: Number(row.pile_count) || 0,
|
||
note: String(row.note || "").trim(),
|
||
}))
|
||
.filter((row) => row.start_date);
|
||
const res = await fetch(`${API_BASE}/api/project-pile-progress/upsert`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
project_code: selectedProjectCode,
|
||
contract_pile_count: Number(contractPileCount) || 0,
|
||
entries,
|
||
})
|
||
});
|
||
if (!res.ok) throw new Error("pile progress save failed");
|
||
const detailRes = await fetch(`${API_BASE}/api/project-detail?${detailQuery}`);
|
||
if (detailRes.ok) {
|
||
const data = await detailRes.json();
|
||
setDetail(data);
|
||
setBudgetRows(data?.budget_analysis?.rows || []);
|
||
setProgressRate(String(data?.budget_analysis?.progress_rate ?? 0));
|
||
setContractPileCount(String(data?.budget_analysis?.contract_pile_count ?? 0));
|
||
setConstructedPileCount(String(data?.budget_analysis?.constructed_pile_count ?? 0));
|
||
setPileProgressRows(normalizePileProgressRows(data?.budget_analysis?.pile_progress_entries || []));
|
||
}
|
||
setPileProgressModalOpen(false);
|
||
} catch (err) {
|
||
setError("시공실적 저장에 실패했습니다.");
|
||
} finally {
|
||
setPileProgressSaving(false);
|
||
}
|
||
}
|
||
|
||
async function toggleBudgetAccountDetail(account) {
|
||
const code = account?.account_code || "";
|
||
if (!code || !selectedProjectCode) return;
|
||
if (budgetAccountExpandedCode === code) {
|
||
setBudgetAccountExpandedCode("");
|
||
return;
|
||
}
|
||
setBudgetAccountExpandedCode(code);
|
||
if (budgetAccountDetailMap[code]) return;
|
||
setBudgetAccountDetailLoading(true);
|
||
try {
|
||
const params = new URLSearchParams({
|
||
project_code: selectedProjectCode,
|
||
account_code: code,
|
||
});
|
||
const res = await fetch(`${API_BASE}/api/project-account-issue-detail?${params.toString()}`);
|
||
if (!res.ok) throw new Error("budget account detail failed");
|
||
const data = await res.json();
|
||
setBudgetAccountDetailMap((prev) => ({ ...prev, [code]: data }));
|
||
} catch (err) {
|
||
setError("계정 상세내역을 불러오지 못했습니다.");
|
||
} finally {
|
||
setBudgetAccountDetailLoading(false);
|
||
}
|
||
}
|
||
|
||
function updateBudgetModalAccount(index, value) {
|
||
setBudgetModalAccounts((prev) => prev.map((account, accountIndex) => (
|
||
accountIndex === index
|
||
? { ...account, budget_amount: Number(value) || 0 }
|
||
: account
|
||
)));
|
||
}
|
||
|
||
function updateBudgetModalTotalBudget(value) {
|
||
setBudgetModalTotalBudget(Number(value) || 0);
|
||
}
|
||
|
||
async function saveBudgetModal() {
|
||
if (!budgetModalItem) return;
|
||
const nextRows = budgetRows.map((row) => {
|
||
if (row.section === budgetModalItem.section && row.group === budgetModalItem.group && row.category === budgetModalItem.category) {
|
||
return {
|
||
...row,
|
||
account_items: budgetModalAccounts,
|
||
budget_amount: Number(budgetModalTotalBudget) || 0,
|
||
};
|
||
}
|
||
return row;
|
||
});
|
||
const ok = await persistProjectBudget(nextRows, effectiveProgressRate);
|
||
if (ok) {
|
||
setBudgetModalItem(null);
|
||
setBudgetModalAccounts([]);
|
||
setBudgetModalTotalBudget(0);
|
||
setBudgetAccountExpandedCode("");
|
||
}
|
||
}
|
||
|
||
async function remapProjectAccount(fromAccountCode) {
|
||
const toAccountCode = issueSelections[fromAccountCode];
|
||
if (!selectedProjectCode || !fromAccountCode || !toAccountCode) return;
|
||
setRemapSavingCode(fromAccountCode);
|
||
setError("");
|
||
try {
|
||
const res = await fetch(`${API_BASE}/api/project-account-remap`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
project_code: selectedProjectCode,
|
||
from_account_code: fromAccountCode,
|
||
to_account_code: toAccountCode
|
||
})
|
||
});
|
||
if (!res.ok) throw new Error("remap failed");
|
||
const detailRes = await fetch(`${API_BASE}/api/project-detail?${detailQuery}`);
|
||
if (detailRes.ok) {
|
||
const data = await detailRes.json();
|
||
setDetail(data);
|
||
const nextIssueSelections = {};
|
||
(data?.account_issues || []).forEach((item) => {
|
||
nextIssueSelections[item.account_code] = item.suggested_code || "";
|
||
});
|
||
setIssueSelections(nextIssueSelections);
|
||
}
|
||
} catch (err) {
|
||
setError("계정 변경에 실패했습니다.");
|
||
} finally {
|
||
setRemapSavingCode("");
|
||
}
|
||
}
|
||
|
||
async function openIssueDetail(item) {
|
||
if (!selectedProjectCode || !item?.account_code) return;
|
||
setIssueDetailLoading(true);
|
||
try {
|
||
const params = new URLSearchParams({
|
||
project_code: selectedProjectCode,
|
||
account_code: item.account_code
|
||
});
|
||
const res = await fetch(`${API_BASE}/api/project-account-issue-detail?${params.toString()}`);
|
||
if (!res.ok) throw new Error("issue detail failed");
|
||
const data = await res.json();
|
||
setIssueDetailModal(data);
|
||
setIssueRowSelections({});
|
||
setIssueCheckedRows([]);
|
||
setIssueBulkTargetCode("");
|
||
} catch (err) {
|
||
setError("계정 상세를 불러오지 못했습니다.");
|
||
} finally {
|
||
setIssueDetailLoading(false);
|
||
}
|
||
}
|
||
|
||
function toggleIssueCheckedRow(sourceRowNo) {
|
||
setIssueCheckedRows((prev) => (
|
||
prev.includes(sourceRowNo)
|
||
? prev.filter((id) => id !== sourceRowNo)
|
||
: [...prev, sourceRowNo]
|
||
));
|
||
}
|
||
|
||
function toggleIssueAllChecked() {
|
||
const allIds = (issueDetailModal?.items || []).map((row) => row.source_row_no);
|
||
setIssueCheckedRows((prev) => (
|
||
allIds.length && allIds.every((id) => prev.includes(id)) ? [] : allIds
|
||
));
|
||
}
|
||
|
||
function applyIssueBulkTarget() {
|
||
if (!issueBulkTargetCode || !issueCheckedRows.length) return;
|
||
setIssueRowSelections((prev) => {
|
||
const next = { ...prev };
|
||
issueCheckedRows.forEach((id) => {
|
||
next[id] = issueBulkTargetCode;
|
||
});
|
||
return next;
|
||
});
|
||
}
|
||
|
||
async function saveIssueRowRemap() {
|
||
if (!selectedProjectCode || !issueDetailModal) return;
|
||
const rows = (issueDetailModal.items || [])
|
||
.map((item) => ({
|
||
source_row_no: item.source_row_no,
|
||
to_account_code: issueRowSelections[item.source_row_no] || "",
|
||
}))
|
||
.filter((item) => item.to_account_code);
|
||
if (!rows.length) {
|
||
setIssueDetailModal(null);
|
||
return;
|
||
}
|
||
|
||
setIssueRowSaving(true);
|
||
try {
|
||
const res = await fetch(`${API_BASE}/api/project-account-remap-rows`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
project_code: selectedProjectCode,
|
||
rows,
|
||
})
|
||
});
|
||
if (!res.ok) throw new Error("row remap failed");
|
||
|
||
const detailRes = await fetch(`${API_BASE}/api/project-detail?${detailQuery}`);
|
||
if (detailRes.ok) {
|
||
const data = await detailRes.json();
|
||
setDetail(data);
|
||
const nextIssueSelections = {};
|
||
(data?.account_issues || []).forEach((item) => {
|
||
nextIssueSelections[item.account_code] = item.suggested_code || "";
|
||
});
|
||
setIssueSelections(nextIssueSelections);
|
||
setBudgetRows(data?.budget_analysis?.rows || []);
|
||
}
|
||
setIssueDetailModal(null);
|
||
setIssueRowSelections({});
|
||
} catch (err) {
|
||
setError("상세 거래 계정 변경에 실패했습니다.");
|
||
} finally {
|
||
setIssueRowSaving(false);
|
||
}
|
||
}
|
||
|
||
async function saveBatchMethod() {
|
||
if (!selectedProjectCodes.length || !batchMethod) return;
|
||
setBatchSaving(true);
|
||
setError("");
|
||
try {
|
||
const res = await fetch(`${API_BASE}/api/project-master/batch-update-method`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
project_codes: selectedProjectCodes,
|
||
construction_method: batchMethod === "__UNSET__" ? "" : batchMethod
|
||
})
|
||
});
|
||
if (!res.ok) throw new Error("batch save failed");
|
||
const projectRes = await fetch(`${API_BASE}/api/projects?${projectQuery}`);
|
||
if (projectRes.ok) {
|
||
const projectData = await projectRes.json();
|
||
setProjects(projectData.items || []);
|
||
}
|
||
if (selectedProjectCode) {
|
||
const detailRes = await fetch(`${API_BASE}/api/project-detail?${detailQuery}`);
|
||
if (detailRes.ok) {
|
||
const data = await detailRes.json();
|
||
setDetail(data);
|
||
setEditor({
|
||
project_name: data?.summary?.project_name || "",
|
||
project_type: data?.summary?.project_type || "",
|
||
construction_family: data?.summary?.construction_family || "",
|
||
construction_method: data?.summary?.construction_method || "",
|
||
start_date: data?.summary?.start_date || "",
|
||
end_date: data?.summary?.end_date || "",
|
||
note: data?.summary?.note || ""
|
||
});
|
||
}
|
||
}
|
||
setSelectedProjectCodes([]);
|
||
setBatchMethod("");
|
||
} catch (err) {
|
||
setError("공법 일괄 변경에 실패했습니다.");
|
||
} finally {
|
||
setBatchSaving(false);
|
||
}
|
||
}
|
||
|
||
async function openVendorAccountModal(account) {
|
||
if (!selectedVendorName || !account?.account_code) return;
|
||
setVendorAccountModalLoading(true);
|
||
setVendorAccountDateFrom("");
|
||
setVendorAccountDateTo("");
|
||
try {
|
||
const params = new URLSearchParams({
|
||
vendor_name: selectedVendorName,
|
||
account_code: account.account_code,
|
||
});
|
||
if (selectedVendorProjectCode) params.set("project_code", selectedVendorProjectCode);
|
||
const res = await fetch(`${API_BASE}/api/vendor-detail?${params.toString()}`);
|
||
if (!res.ok) throw new Error("vendor account detail failed");
|
||
const data = await res.json();
|
||
setVendorAccountModal({
|
||
project_code: selectedVendorProjectCode || "전체 프로젝트",
|
||
account_code: account.account_code,
|
||
account_name: account.account_name || "",
|
||
transactions: data?.transactions || [],
|
||
summary: data?.summary || null,
|
||
});
|
||
} catch (err) {
|
||
setError("계정별 거래내역을 불러오지 못했습니다.");
|
||
} finally {
|
||
setVendorAccountModalLoading(false);
|
||
}
|
||
}
|
||
|
||
async function openAccountVendorModal(vendor) {
|
||
if (!selectedAccountCode || !vendor) return;
|
||
setAccountVendorDateFrom("");
|
||
setAccountVendorDateTo("");
|
||
if (!vendor.vendor_name) {
|
||
setAccountVendorModal({
|
||
vendor_name: "전체 거래처",
|
||
account_code: selectedAccountCode,
|
||
project_code: selectedAccountProjectCode || "전체 프로젝트",
|
||
account_name: accountDetail?.summary?.account_name || "",
|
||
transactions: accountDetail?.transactions || [],
|
||
});
|
||
return;
|
||
}
|
||
setAccountVendorModalLoading(true);
|
||
try {
|
||
const params = new URLSearchParams({
|
||
vendor_name: vendor.vendor_name,
|
||
account_code: selectedAccountCode,
|
||
});
|
||
if (selectedAccountProjectCode) params.set("project_code", selectedAccountProjectCode);
|
||
const res = await fetch(`${API_BASE}/api/vendor-detail?${params.toString()}`);
|
||
if (!res.ok) throw new Error("account vendor detail failed");
|
||
const data = await res.json();
|
||
setAccountVendorModal({
|
||
vendor_name: vendor.vendor_name,
|
||
account_code: selectedAccountCode,
|
||
project_code: selectedAccountProjectCode || "전체 프로젝트",
|
||
account_name: accountDetail?.summary?.account_name || "",
|
||
transactions: data?.transactions || [],
|
||
});
|
||
} catch (err) {
|
||
setError("거래처별 거래내역을 불러오지 못했습니다.");
|
||
} finally {
|
||
setAccountVendorModalLoading(false);
|
||
}
|
||
}
|
||
|
||
function filterTransactionsByDateRange(rows, dateFrom, dateTo) {
|
||
return (rows || []).filter((row) => {
|
||
const date = String(row?.transaction_date || "");
|
||
if (dateFrom && date < dateFrom) return false;
|
||
if (dateTo && date > dateTo) return false;
|
||
return true;
|
||
});
|
||
}
|
||
|
||
if (isStatusPopupWindow) {
|
||
return (
|
||
<div className="page" style={{ width: "min(1400px, calc(100vw - 24px))", padding: "12px 0 20px" }}>
|
||
{dashboardStatusProjectModalContent}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="page">
|
||
<section className="page-head">
|
||
<div>
|
||
<div style={{ fontSize: 34, fontWeight: 700, letterSpacing: "-0.03em" }}>
|
||
{currentTab === "vendor"
|
||
? "거래내역확인"
|
||
: currentTab === "dashboard"
|
||
? "대시보드 시안"
|
||
: currentTab === "management"
|
||
? "관리 계정 보기"
|
||
: currentTab === "company"
|
||
? "전체 연도별 현황"
|
||
: currentTab === "lifecycle"
|
||
? "프로젝트 생애주기 원가"
|
||
: "프로젝트 관리"}
|
||
</div>
|
||
</div>
|
||
<div className="tab-row">
|
||
<button className={`tab-button ${currentTab === "dashboard" ? "active" : ""}`} onClick={() => setCurrentTab("dashboard")}>
|
||
대시보드
|
||
</button>
|
||
<button className={`tab-button ${currentTab === "project" ? "active" : ""}`} onClick={() => setCurrentTab("project")}>
|
||
프로젝트 관리
|
||
</button>
|
||
<button className={`tab-button ${currentTab === "lifecycle" ? "active" : ""}`} onClick={() => setCurrentTab("lifecycle")}>
|
||
프로젝트 생애주기 원가
|
||
</button>
|
||
<button className={`tab-button ${currentTab === "vendor" ? "active" : ""}`} onClick={() => setCurrentTab("vendor")}>
|
||
거래내역확인
|
||
</button>
|
||
<button className={`tab-button ${currentTab === "management" ? "active" : ""}`} onClick={() => setCurrentTab("management")}>
|
||
관리 계정
|
||
</button>
|
||
<button className={`tab-button ${currentTab === "company" ? "active" : ""}`} onClick={() => setCurrentTab("company")}>
|
||
전체
|
||
</button>
|
||
</div>
|
||
</section>
|
||
{error && <div style={{ marginBottom: 12 }} className="badge badge-warn">{error}</div>}
|
||
{["dashboard", "management", "company"].includes(currentTab) && (
|
||
<div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 12 }}>
|
||
<button
|
||
type="button"
|
||
onClick={() => setDashboardInfoModal(
|
||
currentTab === "dashboard"
|
||
? "dashboard_scope"
|
||
: currentTab === "management"
|
||
? "management_scope"
|
||
: "company_scope"
|
||
)}
|
||
title="집계 제외 계정 참고"
|
||
style={{
|
||
width: 32,
|
||
height: 32,
|
||
borderRadius: 999,
|
||
border: "1px solid var(--line)",
|
||
background: "rgba(255,255,255,0.9)",
|
||
color: "var(--muted)",
|
||
fontSize: 16,
|
||
fontWeight: 700,
|
||
cursor: "pointer",
|
||
boxShadow: "0 6px 14px rgba(15, 28, 46, 0.06)",
|
||
}}
|
||
>
|
||
!
|
||
</button>
|
||
</div>
|
||
)}
|
||
{currentTab === "company" && (
|
||
<section className="panel" style={{ padding: 20, marginBottom: 18 }}>
|
||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12 }}>
|
||
<div style={{ fontSize: 22, fontWeight: 700 }}>년도별 전체 입금·지출 현황</div>
|
||
<button
|
||
type="button"
|
||
className="button-muted"
|
||
onClick={() => setCompanyGraphModalOpen(true)}
|
||
title="연도별 입금 대비 지출 그래프 보기"
|
||
style={{
|
||
width: 36,
|
||
height: 36,
|
||
borderRadius: 999,
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
fontSize: 16,
|
||
fontWeight: 700,
|
||
padding: 0,
|
||
}}
|
||
>
|
||
<svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true">
|
||
<rect x="2" y="9" width="3" height="6" rx="1.2" fill="#7bc96f" />
|
||
<rect x="7.5" y="6" width="3" height="9" rx="1.2" fill="#4aa7d1" />
|
||
<rect x="13" y="3" width="3" height="12" rx="1.2" fill="#1e5e95" />
|
||
<polyline
|
||
points="2,11 6,8 10,9 15,4"
|
||
fill="none"
|
||
stroke="#143f67"
|
||
strokeWidth="1.7"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div style={{ marginTop: 16, display: "grid", gap: 14 }}>
|
||
{companyOverviewLoading ? (
|
||
<div className="mini-card">전체 연도별 현황을 불러오는 중입니다.</div>
|
||
) : !(companyOverview.items || []).length ? (
|
||
<div className="mini-card">표시할 연도별 현황이 없습니다.</div>
|
||
) : (
|
||
(companyOverview.items || []).map((yearItem) => {
|
||
const typeItems = [...(yearItem.types || [])]
|
||
.filter((typeItem) => (
|
||
Number(typeItem.income_supply || 0) > 0 || Number(typeItem.expense_supply || 0) > 0
|
||
))
|
||
.sort((a, b) => {
|
||
const fixedOrder = { "시공": 0, "관리": 1 };
|
||
const aFixed = Object.prototype.hasOwnProperty.call(fixedOrder, a.project_type || "") ? fixedOrder[a.project_type] : 99;
|
||
const bFixed = Object.prototype.hasOwnProperty.call(fixedOrder, b.project_type || "") ? fixedOrder[b.project_type] : 99;
|
||
if (aFixed !== bFixed) return aFixed - bFixed;
|
||
const aWeight = Math.max(Number(a.income_ratio || 0) || 0, Number(a.expense_ratio || 0) || 0);
|
||
const bWeight = Math.max(Number(b.income_ratio || 0) || 0, Number(b.expense_ratio || 0) || 0);
|
||
if (bWeight !== aWeight) return bWeight - aWeight;
|
||
return (b.project_type || "").localeCompare(a.project_type || "", "ko");
|
||
});
|
||
return (
|
||
<div key={`company-${yearItem.year}`} className="mini-card" style={{ padding: 18 }}>
|
||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 12, flexWrap: "wrap" }}>
|
||
<div style={{ fontSize: 20, fontWeight: 700 }}>{yearItem.year}년</div>
|
||
<div className="subtle" style={{ fontSize: 14 }}>
|
||
입금 {fmtEokManagement(yearItem.income_supply || 0)} · 지출 {fmtEokManagement(yearItem.expense_supply || 0)} · 수익 {fmtEokManagement(yearItem.profit_supply || 0)}
|
||
</div>
|
||
</div>
|
||
<div style={{ display: "grid", gap: 12, marginTop: 14 }}>
|
||
<div>
|
||
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, marginBottom: 6 }}>
|
||
<strong style={{ fontSize: 14 }}>입금 구성</strong>
|
||
<span className="subtle">연도 전체 입금 대비 비중</span>
|
||
</div>
|
||
<div style={{ height: 18, borderRadius: 999, overflow: "hidden", background: "#e6eef6", display: "flex" }}>
|
||
{typeItems.map((typeItem, index) => (
|
||
<div
|
||
key={`income-${yearItem.year}-${typeItem.project_type}`}
|
||
title={`${typeItem.project_type} · 입금 ${fmtEokManagement(typeItem.income_supply || 0)} · ${(Number(typeItem.income_ratio || 0) || 0).toFixed(1)}%`}
|
||
style={{
|
||
width: `${Math.max(Number(typeItem.income_ratio || 0) || 0, 0)}%`,
|
||
minWidth: Number(typeItem.income_ratio || 0) > 0 ? 6 : 0,
|
||
background: getCompanyTypeColor(typeItem.project_type, index),
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, marginBottom: 6 }}>
|
||
<strong style={{ fontSize: 14 }}>지출 구성</strong>
|
||
<span className="subtle">연도 전체 지출 대비 비중</span>
|
||
</div>
|
||
<div style={{ height: 18, borderRadius: 999, overflow: "hidden", background: "#e6eef6", display: "flex" }}>
|
||
{typeItems.map((typeItem, index) => (
|
||
<div
|
||
key={`expense-${yearItem.year}-${typeItem.project_type}`}
|
||
title={`${typeItem.project_type} · 지출 ${fmtEokManagement(typeItem.expense_supply || 0)} · ${(Number(typeItem.expense_ratio || 0) || 0).toFixed(1)}%`}
|
||
style={{
|
||
width: `${Math.max(Number(typeItem.expense_ratio || 0) || 0, 0)}%`,
|
||
minWidth: Number(typeItem.expense_ratio || 0) > 0 ? 6 : 0,
|
||
background: getCompanyTypeColor(typeItem.project_type, index),
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", gap: 10, marginTop: 14 }}>
|
||
{typeItems.map((typeItem, index) => (
|
||
<button
|
||
key={`legend-${yearItem.year}-${typeItem.project_type}`}
|
||
type="button"
|
||
className="mini-card"
|
||
onClick={() => {
|
||
setCompanyAccountModalView("all");
|
||
setCompanyAccountModal({
|
||
year: yearItem.year,
|
||
project_type: typeItem.project_type,
|
||
items: [],
|
||
});
|
||
}}
|
||
style={{
|
||
padding: "10px 12px",
|
||
borderRadius: 14,
|
||
textAlign: "left",
|
||
cursor: "pointer",
|
||
background: "linear-gradient(180deg, rgba(255,255,255,0.98), rgba(244,248,252,0.98))",
|
||
}}
|
||
title={`${yearItem.year}년 ${typeItem.project_type} 계정별 금액 보기`}
|
||
>
|
||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||
<span style={{ width: 10, height: 10, borderRadius: 999, background: getCompanyTypeColor(typeItem.project_type, index), flexShrink: 0 }} />
|
||
<strong style={{ fontSize: 14 }}>{typeItem.project_type}</strong>
|
||
</div>
|
||
<div className="subtle" style={{ marginTop: 6 }}>
|
||
입금 {fmtEokManagement(typeItem.income_supply || 0)} · {(Number(typeItem.income_ratio || 0) || 0).toFixed(1)}%
|
||
</div>
|
||
<div className="subtle">
|
||
지출 {fmtEokManagement(typeItem.expense_supply || 0)} · {(Number(typeItem.expense_ratio || 0) || 0).toFixed(1)}%
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
</section>
|
||
)}
|
||
{currentTab === "dashboard" && (
|
||
<section className="dashboard-grid" style={{ transition: "opacity 180ms ease", opacity: dashboardLoading ? 0.82 : 1 }}>
|
||
<div className="panel" style={{ padding: 22 }}>
|
||
<div style={{ display: "flex", justifyContent: "space-between", gap: 16, alignItems: "flex-start", flexWrap: "wrap" }}>
|
||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||
<button
|
||
type="button"
|
||
className={`company-filter-chip ${!selectedDashboardYear ? "active" : ""}`}
|
||
onClick={() => React.startTransition(() => setSelectedDashboardYear(""))}
|
||
>
|
||
전체
|
||
</button>
|
||
{dashboardYearOptions.map((year) => (
|
||
<button
|
||
key={`dashboard-year-${year}`}
|
||
type="button"
|
||
className={`company-filter-chip ${selectedDashboardYear === year ? "active" : ""}`}
|
||
onClick={() => React.startTransition(() => setSelectedDashboardYear(year))}
|
||
>
|
||
{year}년
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="subtle">
|
||
{selectedDashboardYear ? `${selectedDashboardYear}년 기준` : "전체 기준"}{dashboardLoading ? " · 갱신 중" : ""}
|
||
</div>
|
||
</div>
|
||
<div className="dashboard-selection-kpis" style={{ marginTop: 16, gridTemplateColumns: "repeat(8, minmax(0, 1fr))", gap: 10 }}>
|
||
<div
|
||
className="dashboard-selection-kpi"
|
||
style={{ padding: 14, minHeight: 96 }}
|
||
title="현재 대시보드 기준으로 집계된 시공 프로젝트 수입니다."
|
||
>
|
||
<div className="subtle">시공 프로젝트 수</div>
|
||
<div style={{ marginTop: 8, fontSize: 18, fontWeight: 600 }}>{fmt(overallDashboardScope.projectCount || 0)}개</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="dashboard-selection-kpi"
|
||
style={{
|
||
padding: 14,
|
||
minHeight: 96,
|
||
appearance: "none",
|
||
WebkitAppearance: "none",
|
||
border: "none",
|
||
borderRadius: 0,
|
||
background: "transparent",
|
||
width: "100%",
|
||
textAlign: "left",
|
||
boxSizing: "border-box",
|
||
font: "inherit",
|
||
color: "inherit",
|
||
cursor: dashboardOngoingProjectCount > 0 ? "pointer" : "default",
|
||
}}
|
||
title={selectedDashboardYear ? `${selectedDashboardYear}년에 거래가 있었고 현재 기준 최근 6개월 안에도 입출금 내역이 있는 시공 프로젝트 수입니다.` : "현재 달 기준 최근 6개월 안에 입출금 내역이 있는 시공 프로젝트 수입니다."}
|
||
onClick={() => dashboardOngoingProjectCount > 0 && setDashboardOngoingProjectModalOpen(true)}
|
||
>
|
||
<div className="subtle">진행중 프로젝트</div>
|
||
<div style={{ marginTop: 8, fontSize: 18, fontWeight: 600 }}>{fmt(dashboardOngoingProjectCount || 0)}개</div>
|
||
<div className="subtle" style={{ marginTop: 6 }}>{selectedDashboardYear ? `${selectedDashboardYear}년 거래 기준` : "최근 6개월 입출금 기준"}</div>
|
||
</button>
|
||
<div
|
||
className="dashboard-selection-kpi"
|
||
style={{ padding: 14, minHeight: 96 }}
|
||
title="시공 프로젝트의 입금 공급가액 합계입니다."
|
||
>
|
||
<div className="subtle">수입</div>
|
||
<div style={{ marginTop: 8, fontSize: 18, fontWeight: 600 }}>{fmtEok(overallDashboardScope.income || 0)}</div>
|
||
</div>
|
||
<div
|
||
className="dashboard-selection-kpi"
|
||
style={{ padding: 14, minHeight: 96 }}
|
||
title="시공 프로젝트의 출금 공급가액 합계입니다."
|
||
>
|
||
<div className="subtle">지출</div>
|
||
<div style={{ marginTop: 8, fontSize: 18, fontWeight: 600 }}>{fmtEok(overallDashboardScope.expense || 0)}</div>
|
||
</div>
|
||
<div
|
||
className="dashboard-selection-kpi"
|
||
style={{ padding: 14, minHeight: 96 }}
|
||
title="수입에서 지출을 뺀 금액입니다."
|
||
>
|
||
<div className="subtle">수익</div>
|
||
<div style={{ marginTop: 8, fontSize: 18, fontWeight: 600, color: (overallDashboardScope.profit || 0) < 0 ? "#d14343" : "var(--good)" }}>
|
||
{fmtEok(overallDashboardScope.profit || 0)}
|
||
</div>
|
||
</div>
|
||
<div
|
||
className="dashboard-selection-kpi"
|
||
style={{ padding: 14, minHeight: 96 }}
|
||
title="수익을 수입으로 나눈 비율입니다."
|
||
>
|
||
<div className="subtle">수익률</div>
|
||
<div style={{ marginTop: 8, fontSize: 18, fontWeight: 600, color: (overallDashboardScope.marginRate || 0) < 0 ? "#d14343" : "var(--good)" }}>
|
||
{(overallDashboardScope.marginRate || 0).toFixed(1)}%
|
||
</div>
|
||
</div>
|
||
<div
|
||
className="dashboard-selection-kpi"
|
||
style={{ padding: 14, minHeight: 96 }}
|
||
title="입금이 있더라도 원가 부담이 크다고 분류된 프로젝트 수입니다."
|
||
>
|
||
<div className="subtle">원가위험</div>
|
||
<div style={{ marginTop: 8, fontSize: 18, fontWeight: 600, color: "#d14343" }}>{fmt(overallDashboardScope.statusCounts?.risk || 0)}개</div>
|
||
</div>
|
||
<div
|
||
className="dashboard-selection-kpi"
|
||
style={{ padding: 14, minHeight: 96 }}
|
||
title="입금액 없이 지출액만 있는 것으로 분류된 프로젝트 수입니다."
|
||
>
|
||
<div className="subtle">선투입</div>
|
||
<div style={{ marginTop: 8, fontSize: 18, fontWeight: 600, color: "#d14343" }}>{fmt(overallDashboardScope.statusCounts?.upfront || 0)}개</div>
|
||
</div>
|
||
</div>
|
||
<div className="subtle" style={{ marginTop: 12 }}>
|
||
수입은 시공 프로젝트의 입금 공급가액 합계, 지출은 시공 프로젝트의 출금 공급가액 합계입니다.
|
||
</div>
|
||
</div>
|
||
{!!(managementOverview.yearly_construction_margin_items || []).length && (
|
||
<div className="panel" style={{ padding: 20, gridColumn: "1 / -1" }}>
|
||
<div className="mini-card" style={{ padding: 16, display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
|
||
<div>
|
||
<div style={{ fontSize: 16, fontWeight: 700 }}>년도별 시공 상세</div>
|
||
<div className="subtle" style={{ marginTop: 4 }}>
|
||
연도별 시공 수익률과 수익 금액을 표로 비교해서 봅니다.
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="button-primary"
|
||
onClick={() => setDashboardYearDetailModalOpen(true)}
|
||
>
|
||
년도별 상세 보기
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className={selectedDashboardFamily !== "전체" ? "dashboard-top-split" : ""}>
|
||
<div className="panel" style={{ padding: 20 }}>
|
||
{dashboardData ? (
|
||
<>
|
||
<div className="dashboard-compact-donut-row" style={{ marginTop: 0, marginBottom: 16 }}>
|
||
<div className="dashboard-compact-donut" style={{ background: buildDonutBackground(dashboardFamilyLegend, (key) => dashboardFamilyLegend.find((item) => item.key === key)?.color || "#e6eef6") }}>
|
||
<div className="dashboard-donut-center">
|
||
<div>
|
||
<div className="subtle">전체</div>
|
||
<strong>{fmt(overallDashboardScope.projectCount || 0)}</strong>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="dashboard-compact-summary">
|
||
<div className="dashboard-status-track" style={{ marginTop: 0 }}>
|
||
{dashboardFamilyLegend.map((band) => (
|
||
<div
|
||
key={band.key}
|
||
className="dashboard-status-segment"
|
||
style={{ width: `${band.ratio}%`, background: band.color }}
|
||
title={`${band.label} ${band.count}개`}
|
||
/>
|
||
))}
|
||
</div>
|
||
<div className="dashboard-compact-legend">
|
||
{dashboardFamilyItems.filter((item) => item.family !== "전체").map((band) => (
|
||
<button
|
||
key={band.family}
|
||
type="button"
|
||
className={`dashboard-status-legend-item ${selectedDashboardFamily === band.family ? "active" : ""}`}
|
||
onClick={() => {
|
||
setSelectedDashboardFamily(band.family);
|
||
setSelectedDashboardMethod("");
|
||
}}
|
||
>
|
||
<div className="dashboard-band-chip" style={{ background: "transparent", padding: 0, height: "auto" }}>
|
||
<span className="dashboard-band-dot" style={{ background: dashboardFamilyColorMap[band.family] || "#1e5e95" }} />
|
||
<span style={{ fontSize: 17, fontWeight: 700 }}>{band.family}</span>
|
||
</div>
|
||
<div className="dashboard-legend-metrics">
|
||
<div style={{ fontSize: 22, fontWeight: 700, lineHeight: 1.1 }}>{fmt((dashboardFamilyMap[band.family] || band).project_count)}개</div>
|
||
<div style={{ fontSize: 16, fontWeight: 700, color: ((dashboardFamilyMap[band.family] || band).margin_rate || 0) < 0 ? "#d14343" : "var(--good)" }}>
|
||
{(((dashboardFamilyMap[band.family] || band).margin_rate) || 0).toFixed(1)}%
|
||
</div>
|
||
</div>
|
||
<div className="subtle" style={{ marginTop: 4, fontSize: 15 }}>{(((dashboardFamilyLegend.find((item) => item.key === band.family)?.ratio) || 0)).toFixed(1)}%</div>
|
||
<div className="dashboard-legend-income">{fmtEok((dashboardFamilyMap[band.family] || band).income_supply)}</div>
|
||
<div className="dashboard-legend-status-line" style={{ display: "flex", flexWrap: "wrap", gap: 10 }}>
|
||
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, color: getStatusBandColor("normal") }}>
|
||
<span className="dashboard-band-dot" style={{ background: getStatusBandColor("normal") }} />
|
||
정상 {(dashboardFamilyMap[band.family] || band).status_counts?.normal || 0}개
|
||
</span>
|
||
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, color: getStatusBandColor("upfront") }}>
|
||
<span className="dashboard-band-dot" style={{ background: getStatusBandColor("upfront") }} />
|
||
선투입 {(dashboardFamilyMap[band.family] || band).status_counts?.upfront || 0}개
|
||
</span>
|
||
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, color: getStatusBandColor("delay") }}>
|
||
<span className="dashboard-band-dot" style={{ background: getStatusBandColor("delay") }} />
|
||
회수지연 {(dashboardFamilyMap[band.family] || band).status_counts?.delay || 0}개
|
||
</span>
|
||
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, color: getStatusBandColor("risk") }}>
|
||
<span className="dashboard-band-dot" style={{ background: getStatusBandColor("risk") }} />
|
||
원가위험 {(dashboardFamilyMap[band.family] || band).status_counts?.risk || 0}개
|
||
</span>
|
||
</div>
|
||
<div className="dashboard-legend-status-line" style={{ color: "#5d7088" }}>
|
||
적자 {(dashboardFamilyMap[band.family] || band).margin_grade_counts?.deficit || 0}개 · 주의 {(dashboardFamilyMap[band.family] || band).margin_grade_counts?.caution || 0}개 · 양호 {(dashboardFamilyMap[band.family] || band).margin_grade_counts?.good || 0}개 · 우수 {(dashboardFamilyMap[band.family] || band).margin_grade_counts?.excellent || 0}개
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className="mini-card" style={{ marginTop: 16 }}>대시보드 데이터를 불러오는 중입니다.</div>
|
||
)}
|
||
</div>
|
||
|
||
{selectedDashboardFamily !== "전체" && (
|
||
<div className={selectedDashboardFamily !== "전체" ? "dashboard-side-stack" : ""}>
|
||
<div className="panel" style={{ padding: 20 }}>
|
||
<div style={{ fontSize: 18, fontWeight: 600 }}>세부 공법 상태 분포</div>
|
||
<div className="subtle" style={{ marginTop: 6 }}>
|
||
<strong>{selectedDashboardFamily}</strong> 안에 포함된 공법별로 상태 프로젝트 수를 봅니다.
|
||
</div>
|
||
{!dashboardData ? (
|
||
<div className="mini-card" style={{ marginTop: 16 }}>대시보드 데이터를 불러오는 중입니다.</div>
|
||
) : selectedDashboardFamily === "전체" ? (
|
||
<div className="mini-card" style={{ marginTop: 16 }}>먼저 위에서 대분류를 선택해 주세요.</div>
|
||
) : (
|
||
<div className="dashboard-method-list">
|
||
{dashboardMethodItems.map((method) => (
|
||
<button
|
||
key={method.method}
|
||
type="button"
|
||
className={`dashboard-method-row ${selectedDashboardMethod === method.method ? "active" : ""}`}
|
||
onClick={() => setSelectedDashboardMethod((prev) => (prev === method.method ? "" : method.method))}
|
||
>
|
||
<div className="dashboard-family-row-side">
|
||
<div className="dashboard-family-donut" style={{ background: buildDonutBackground(method.donutItems, getStatusBandColor) }}>
|
||
<div className="dashboard-family-donut-center">{fmt(method.project_count)}개</div>
|
||
</div>
|
||
</div>
|
||
<div className="dashboard-method-row-main">
|
||
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, alignItems: "baseline" }}>
|
||
<div style={{ fontSize: 18, fontWeight: 600 }}>{method.method}</div>
|
||
<div style={{ fontSize: 13, fontWeight: 600, color: method.margin_rate < 0 ? "#d14343" : "var(--good)" }}>
|
||
{method.margin_rate.toFixed(1)}%
|
||
</div>
|
||
</div>
|
||
<div className="dashboard-method-metrics">
|
||
{(dashboardData?.status_bands || []).map((band) => (
|
||
<span key={band.key} className="dashboard-band-chip">
|
||
<span className="dashboard-band-dot" style={{ background: getStatusBandColor(band.key) }} />
|
||
{band.label} {method.status_counts?.[band.key] || 0}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="dashboard-family-bar-value">{fmtEok(method.income_supply)}</div>
|
||
</button>
|
||
))}
|
||
{!dashboardMethodItems.length && (
|
||
<div className="mini-card">선택한 대분류에 해당하는 세부 공법이 없습니다.</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="panel" style={{ padding: 20 }}>
|
||
<div style={{ fontSize: 18, fontWeight: 600 }}>선택 조건 그래프</div>
|
||
<div className="subtle" style={{ marginTop: 6 }}>
|
||
{selectedDashboardMethod
|
||
? `${selectedDashboardFamily} · ${selectedDashboardMethod} 선택 결과입니다. 프로젝트 상세는 프로젝트 관리에서 확인하면 됩니다.`
|
||
: `${selectedDashboardFamily} 대분류 기준 요약입니다. 아래 세부 공법 행을 누르면 해당 공법 기준으로 그래프가 바뀝니다.`}
|
||
</div>
|
||
<div className="dashboard-selection-kpis dashboard-selection-kpis-wide">
|
||
<div className="dashboard-selection-kpi">
|
||
<div className="subtle">시공 프로젝트 수</div>
|
||
<div style={{ marginTop: 8, fontSize: 24, fontWeight: 600 }}>{fmt(selectedDashboardScope.projectCount)}개</div>
|
||
</div>
|
||
<div className="dashboard-selection-kpi">
|
||
<div className="subtle">수입</div>
|
||
<div style={{ marginTop: 8, fontSize: 24, fontWeight: 600 }}>{fmtEok(selectedDashboardScope.income)}</div>
|
||
</div>
|
||
<div className="dashboard-selection-kpi">
|
||
<div className="subtle">지출</div>
|
||
<div style={{ marginTop: 8, fontSize: 24, fontWeight: 600 }}>{fmtEok(selectedDashboardScope.expense)}</div>
|
||
</div>
|
||
<div className="dashboard-selection-kpi">
|
||
<div className="subtle">수익</div>
|
||
<div style={{ marginTop: 8, fontSize: 24, fontWeight: 600, color: selectedDashboardScope.profit < 0 ? "#d14343" : "var(--good)" }}>
|
||
{fmtEok(selectedDashboardScope.profit)}
|
||
</div>
|
||
</div>
|
||
<div className="dashboard-selection-kpi">
|
||
<div className="subtle">수익률</div>
|
||
<div style={{ marginTop: 8, fontSize: 24, fontWeight: 600, color: selectedDashboardScope.marginRate < 0 ? "#d14343" : "var(--good)" }}>
|
||
{selectedDashboardScope.marginRate.toFixed(1)}%
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="dashboard-selection-grid">
|
||
<div className="dashboard-selection-card">
|
||
<div style={{ fontSize: 16, fontWeight: 600 }}>선택 범위 상태 분포</div>
|
||
<div className="dashboard-donut-row dashboard-donut-row-stacked">
|
||
<div className="dashboard-donut" style={{ background: buildDonutBackground(selectedDashboardScope.statusLegend, getStatusBandColor) }}>
|
||
<div className="dashboard-donut-center">
|
||
<div>
|
||
<div className="subtle">선택</div>
|
||
<strong>{fmt(selectedDashboardScope.projectCount)}</strong>
|
||
<div className="subtle" style={{ marginTop: 6 }}>프로젝트</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="dashboard-status-legend dashboard-status-legend-inline">
|
||
{selectedDashboardScope.statusLegend.map((band) => (
|
||
<button
|
||
key={band.key}
|
||
type="button"
|
||
className="dashboard-status-legend-item dashboard-status-inline-item"
|
||
onClick={() => openDashboardStatusProjectModal(band.key, band.label, band.count)}
|
||
style={{ cursor: band.count ? "pointer" : "default" }}
|
||
title={band.count ? `${band.label} 프로젝트 보기` : `${band.label} 프로젝트 없음`}
|
||
>
|
||
<span className="dashboard-band-dot" style={{ background: getStatusBandColor(band.key) }} />
|
||
<div className="dashboard-status-inline-label">
|
||
<div>{band.label}</div>
|
||
<div className="dashboard-status-inline-rate">{band.ratio.toFixed(1)}%</div>
|
||
</div>
|
||
<div className="dashboard-status-inline-value">{fmt(band.count)}개</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
)}
|
||
{dashboardStatusProjectModal && (
|
||
<div className="modal-backdrop" onClick={closeDashboardStatusProjectModal}>
|
||
{dashboardStatusProjectModalContent}
|
||
</div>
|
||
)}
|
||
|
||
{dashboardOngoingProjectModalOpen && (
|
||
<div className="modal-backdrop" onClick={() => setDashboardOngoingProjectModalOpen(false)}>
|
||
{dashboardOngoingProjectModalContent}
|
||
</div>
|
||
)}
|
||
{dashboardYearDetailModalOpen && (
|
||
<div className="modal-backdrop" onClick={() => setDashboardYearDetailModalOpen(false)}>
|
||
<div className="modal-panel modal-panel-wide" onClick={(e) => e.stopPropagation()}>
|
||
<div className="modal-head">
|
||
<div>
|
||
<div style={{ fontSize: 24, fontWeight: 700 }}>년도별 시공 상세</div>
|
||
<div className="subtle" style={{ marginTop: 6 }}>
|
||
연도별 시공 수익률과 수익을 표로 비교합니다.
|
||
</div>
|
||
</div>
|
||
<button className="button-muted" onClick={() => setDashboardYearDetailModalOpen(false)}>닫기</button>
|
||
</div>
|
||
<div style={{ marginTop: 18, overflowX: "auto" }}>
|
||
<table style={{ width: "100%", borderCollapse: "separate", borderSpacing: 0 }}>
|
||
<thead>
|
||
<tr>
|
||
{["연도", "시공 수익률", "수익", "수입", "지출"].map((label, index) => (
|
||
<th
|
||
key={label}
|
||
style={{
|
||
textAlign: index === 0 ? "left" : "right",
|
||
padding: "12px 14px",
|
||
fontSize: 13,
|
||
fontWeight: 700,
|
||
color: "var(--subtle)",
|
||
borderBottom: "1px solid var(--line)",
|
||
background: "rgba(246, 249, 253, 0.82)",
|
||
position: "sticky",
|
||
top: 0,
|
||
zIndex: 1,
|
||
}}
|
||
>
|
||
{label}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{(managementOverview.yearly_construction_margin_items || []).map((yearItem) => (
|
||
<tr key={`dashboard-year-detail-${yearItem.year}`}>
|
||
<td style={{ padding: "14px", borderBottom: "1px solid var(--line)", fontWeight: 700 }}>
|
||
{yearItem.year}년
|
||
</td>
|
||
<td style={{ padding: "14px", borderBottom: "1px solid var(--line)", textAlign: "right", fontWeight: 700, color: Number(yearItem.margin_rate || 0) < 0 ? "#d14343" : "var(--good)" }}>
|
||
{(Number(yearItem.margin_rate || 0) || 0).toFixed(1)}%
|
||
</td>
|
||
<td style={{ padding: "14px", borderBottom: "1px solid var(--line)", textAlign: "right", fontWeight: 700, color: Number(yearItem.profit_supply || 0) < 0 ? "#d14343" : "var(--text)" }}>
|
||
{fmtEokManagement(yearItem.profit_supply || 0)}
|
||
</td>
|
||
<td style={{ padding: "14px", borderBottom: "1px solid var(--line)", textAlign: "right" }}>
|
||
{fmtEokManagement(yearItem.income_supply || 0)}
|
||
</td>
|
||
<td style={{ padding: "14px", borderBottom: "1px solid var(--line)", textAlign: "right" }}>
|
||
{fmtEokManagement(yearItem.expense_supply || 0)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{(currentTab === "project" || currentTab === "lifecycle") && (
|
||
<section className="layout">
|
||
<aside className="panel" style={{ padding: 18 }}>
|
||
<div style={{ fontSize: 18, fontWeight: 700 }}>{isLifecycleTab ? "시공 프로젝트 목록" : "프로젝트 목록"}</div>
|
||
{!isLifecycleTab && (
|
||
<div className="mini-card" style={{ marginTop: 14 }}>
|
||
<div style={{ display: "flex", justifyContent: "space-between", gap: 10, alignItems: "center" }}>
|
||
<div>
|
||
<div style={{ fontSize: 12, color: "var(--muted)", fontWeight: 700 }}>일괄 공법 변경</div>
|
||
<div style={{ fontSize: 18, fontWeight: 700, marginTop: 6 }}>{fmt(selectedProjectCodes.length)}개 선택</div>
|
||
</div>
|
||
<label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12, color: "var(--muted)" }}>
|
||
<input type="checkbox" checked={allVisibleSelected} onChange={toggleSelectAllVisible} />
|
||
현재 목록 전체
|
||
</label>
|
||
</div>
|
||
<div style={{ display: "grid", gridTemplateColumns: "1fr auto", gap: 10, marginTop: 12 }}>
|
||
<select className="select" value={batchMethod} onChange={(e) => setBatchMethod(e.target.value)}>
|
||
<option value="">상세 공법 선택</option>
|
||
<option value="__UNSET__">종류미지정</option>
|
||
{methodOptions.map((item) => <option key={item} value={item}>{item}</option>)}
|
||
</select>
|
||
<button
|
||
onClick={saveBatchMethod}
|
||
disabled={batchSaving || !selectedProjectCodes.length || !batchMethod}
|
||
style={{
|
||
height: 42,
|
||
border: "none",
|
||
borderRadius: 12,
|
||
padding: "0 16px",
|
||
background: "var(--cyan)",
|
||
color: "white",
|
||
fontSize: 13,
|
||
fontWeight: 700,
|
||
cursor: "pointer",
|
||
opacity: batchSaving || !selectedProjectCodes.length || !batchMethod ? 0.55 : 1
|
||
}}
|
||
>
|
||
{batchSaving ? "변경 중..." : "일괄 변경"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className="toolbar" style={{ gridTemplateColumns: "1.3fr 0.8fr" }}>
|
||
<input
|
||
className="field"
|
||
value={projectKeyword}
|
||
onChange={(e) => setProjectKeyword(e.target.value)}
|
||
placeholder={isLifecycleTab ? "시공 프로젝트코드, 프로젝트명 검색" : "프로젝트코드, 프로젝트명 검색"}
|
||
/>
|
||
<select className="select" value={projectType} onChange={(e) => setProjectType(e.target.value)}>
|
||
<option value="전체">전체</option>
|
||
{projectTypes.map((item) => <option key={item} value={item}>{item}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="toolbar" style={{ gridTemplateColumns: "1fr 1fr", marginTop: 10 }}>
|
||
<input
|
||
className="field"
|
||
type="date"
|
||
value={projectTxnDateFrom}
|
||
onChange={(e) => setProjectTxnDateFrom(e.target.value)}
|
||
aria-label="거래 시작일"
|
||
/>
|
||
<input
|
||
className="field"
|
||
type="date"
|
||
value={projectTxnDateTo}
|
||
onChange={(e) => setProjectTxnDateTo(e.target.value)}
|
||
aria-label="거래 종료일"
|
||
/>
|
||
</div>
|
||
<div className="toolbar" style={{ gridTemplateColumns: "1fr", marginTop: 10 }}>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setProjectTxnDateFrom("");
|
||
setProjectTxnDateTo("");
|
||
}}
|
||
style={{
|
||
height: 42,
|
||
border: "1px solid var(--line)",
|
||
borderRadius: 12,
|
||
padding: "0 12px",
|
||
background: "white",
|
||
color: "var(--text)",
|
||
fontSize: 13,
|
||
fontWeight: 700,
|
||
cursor: "pointer"
|
||
}}
|
||
>
|
||
기간 초기화
|
||
</button>
|
||
</div>
|
||
<div className="toolbar" style={{ gridTemplateColumns: "1fr 1fr", marginTop: 10 }}>
|
||
<select className="select" value={projectMethodFamily} onChange={(e) => setProjectMethodFamily(e.target.value)}>
|
||
<option value="전체">공법 종류 전체</option>
|
||
{methodFamilyOptions.map((item) => <option key={item} value={item}>{item}</option>)}
|
||
</select>
|
||
<select className="select" value={projectMethod} onChange={(e) => setProjectMethod(e.target.value)}>
|
||
<option value="전체">상세 공법 전체</option>
|
||
{methodOptionsByFamily.map((item) => <option key={item} value={item}>{item}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="project-list" style={{ marginTop: 14 }}>
|
||
{visibleProjectList.map((item) => (
|
||
<div
|
||
key={item.project_code}
|
||
className={`project-item ${selectedProjectCode === item.project_code ? "active" : ""} ${selectedProjectCodes.includes(item.project_code) ? "selected" : ""}`}
|
||
onClick={() => setSelectedProjectCode(item.project_code)}
|
||
>
|
||
<div style={{ display: "flex", justifyContent: "space-between", gap: 8, alignItems: "flex-start" }}>
|
||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||
{!isLifecycleTab && (
|
||
<label
|
||
style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer" }}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedProjectCodes.includes(item.project_code)}
|
||
onChange={() => toggleProjectSelection(item.project_code)}
|
||
/>
|
||
</label>
|
||
)}
|
||
<div style={{ fontWeight: 700 }}>{item.project_code}</div>
|
||
</div>
|
||
<span className="badge badge-blue">{item.project_type || "미지정"}</span>
|
||
</div>
|
||
<div style={{ marginTop: 8, fontSize: 15, fontWeight: 600 }}>{item.project_name || "(이름없음)"}</div>
|
||
<div style={{ marginTop: 6, display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||
<span className="badge badge-blue">{item.construction_method || "공법미지정"}</span>
|
||
<span className="badge badge-blue">{item.construction_family || "종류미지정"}</span>
|
||
</div>
|
||
{!!(item.related_projects || []).filter((rel) => rel.project_code !== item.project_code).length && (
|
||
<div className="subtle" style={{ marginTop: 6 }}>
|
||
연결: {(item.related_projects || [])
|
||
.filter((rel) => rel.project_code !== item.project_code)
|
||
.map((rel) => `${rel.project_type} ${rel.project_code}`)
|
||
.join(" · ")}
|
||
</div>
|
||
)}
|
||
<div className="subtle" style={{ marginTop: 4 }}>
|
||
{item.min_date || "-"} {item.max_date ? `~ ${item.max_date}` : ""}
|
||
</div>
|
||
</div>
|
||
))}
|
||
{!visibleProjectList.length && !loading && (
|
||
<div className="empty">{isLifecycleTab ? "조건에 맞는 시공 프로젝트가 없습니다." : "조건에 맞는 프로젝트가 없습니다."}</div>
|
||
)}
|
||
</div>
|
||
</aside>
|
||
|
||
<main style={{ display: "flex", flexDirection: "column", gap: 18 }}>
|
||
{!selectedProjectCode && overallSummary && !isLifecycleTab && (
|
||
<section className="panel" style={{ padding: 22 }}>
|
||
<div style={{ display: "flex", justifyContent: "space-between", gap: 18, alignItems: "flex-start" }}>
|
||
<div>
|
||
<div style={{ fontSize: 28, fontWeight: 700, lineHeight: 1.25 }}>전체 현황</div>
|
||
<div className="project-inline-meta" style={{ marginTop: 8 }}>
|
||
<div className="project-inline-meta-line">
|
||
{projectType && projectType !== "전체" ? `${projectType} 프로젝트 기준` : "전체 프로젝트 기준"}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="summary-card" style={{ minWidth: "min(720px, 100%)" }}>
|
||
<div className="summary-card-grid compact">
|
||
<div>
|
||
<div className="subtle">기간</div>
|
||
<div className="summary-value nowrap">
|
||
{overallSummary?.min_date || "-"} {overallSummary?.max_date ? `~ ${overallSummary?.max_date}` : ""}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="subtle">입금 / 출금</div>
|
||
<div className="summary-value">
|
||
{fmt(overallSummary?.income_count || 0)} / {fmt(overallSummary?.expense_count || 0)}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="subtle">공급가액 합계</div>
|
||
<div className="summary-value">{fmt(overallSummary?.supply_sum || 0)}원</div>
|
||
</div>
|
||
<div>
|
||
<div className="subtle">합계금액</div>
|
||
<div className="summary-value">{fmt(overallSummary?.total_sum || 0)}원</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{!selectedProject && !loading && (
|
||
<div className="panel empty">선택된 프로젝트가 없습니다.</div>
|
||
)}
|
||
|
||
{selectedProject && (
|
||
<>
|
||
<section className="panel" style={{ padding: 22 }}>
|
||
<div className="project-head-grid">
|
||
<div>
|
||
<div className="project-title-row">
|
||
<div className="project-title-main">
|
||
<h2 style={{ margin: 0, fontSize: 30, lineHeight: 1.25 }}>{selectedProject.project_name || "(이름없음)"}</h2>
|
||
<div className="project-inline-meta">
|
||
<div className="project-inline-meta-line">
|
||
<strong>{detail?.summary?.project_code || "-"}</strong>
|
||
{" · "}
|
||
{editor.project_type || "구분미지정"}
|
||
{" · "}
|
||
{editor.construction_family || "종류미지정"}
|
||
{" · "}
|
||
{editor.construction_method || "공법미지정"}
|
||
</div>
|
||
{!!editor.note && (
|
||
<div className="project-inline-meta-line" style={{ whiteSpace: "pre-wrap" }}>{editor.note}</div>
|
||
)}
|
||
{!!(detail?.related_projects || []).filter((item) => item.project_code !== selectedProjectCode).length && (
|
||
<div className="project-inline-meta-line">
|
||
연결 코드: {(detail?.related_projects || [])
|
||
.filter((item) => item.project_code !== selectedProjectCode)
|
||
.map((item) => `${item.project_type} ${item.project_code}`)
|
||
.join(" · ")}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<button className="project-inline-edit" onClick={() => {
|
||
setRelatedProjectSearch("");
|
||
setProjectEditModalOpen(true);
|
||
}}>
|
||
수정
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="summary-card">
|
||
<div className="summary-card-grid compact">
|
||
<div>
|
||
<div className="subtle">계약기간</div>
|
||
<div className="summary-value nowrap">
|
||
{(editor.start_date || detail?.summary?.start_date || "-")} {(editor.end_date || detail?.summary?.end_date) ? `~ ${editor.end_date || detail?.summary?.end_date}` : ""}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="subtle">입금 / 출금</div>
|
||
<div className="summary-value">
|
||
{fmt(detail?.summary?.income_count || 0)} / {fmt(detail?.summary?.expense_count || 0)}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="subtle">공급가액 합계</div>
|
||
<div className="summary-value">{fmt(detail?.summary?.supply_sum || 0)}원</div>
|
||
</div>
|
||
<div>
|
||
<div className="subtle">합계금액</div>
|
||
<div className="summary-value">{fmt(detail?.summary?.total_sum || 0)}원</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{currentTab === "project" && !!(detail?.related_projects || []).filter((item) => item.project_code !== selectedProjectCode).length && (
|
||
<section className="panel" style={{ padding: 20 }}>
|
||
<div style={{ fontSize: 18, fontWeight: 700 }}>관련 프로젝트</div>
|
||
<div className="subtle" style={{ marginTop: 6 }}>수정 화면에서 연결한 영업·설계·시공 코드를 함께 봅니다.</div>
|
||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: 12, marginTop: 14 }}>
|
||
{(detail.related_projects || []).map((item) => {
|
||
const isCurrent = item.project_code === selectedProjectCode;
|
||
const ratio = isLifecycleTab ? (lifecycleRatioMap[item.project_code] ?? 1) : 1;
|
||
const displayIncome = Number(item.income_supply || 0) * ratio;
|
||
const displayExpense = Number(item.expense_supply || 0) * ratio;
|
||
const displayProfit = displayIncome - displayExpense;
|
||
return (
|
||
<button
|
||
key={item.project_code}
|
||
type="button"
|
||
onClick={() => {
|
||
if (currentTab === "lifecycle" && ["영업", "설계"].includes(item.project_type || "")) {
|
||
openLifecycleAllocationModal(item);
|
||
return;
|
||
}
|
||
setSelectedProjectCode(item.project_code);
|
||
}}
|
||
style={{
|
||
textAlign: "left",
|
||
border: isCurrent ? "1.5px solid var(--blue)" : "1px solid var(--line)",
|
||
borderRadius: 16,
|
||
background: isCurrent ? "rgba(47, 108, 164, 0.06)" : "white",
|
||
padding: "14px 16px",
|
||
display: "grid",
|
||
gap: 8,
|
||
cursor: "pointer"
|
||
}}
|
||
>
|
||
<div style={{ display: "flex", justifyContent: "space-between", gap: 10, alignItems: "center" }}>
|
||
<strong>{item.project_code}</strong>
|
||
<span className="badge badge-blue">{item.project_type || "미지정"}</span>
|
||
</div>
|
||
<div style={{ fontSize: 15, fontWeight: 700 }}>{item.project_name || "(이름없음)"}</div>
|
||
<div className="subtle">{item.construction_family || "종류미지정"} · {item.construction_method || "공법미지정"}</div>
|
||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: 8 }}>
|
||
<div>
|
||
<div className="subtle">입금</div>
|
||
<div style={{ fontWeight: 700 }}>{fmt(displayIncome)}원</div>
|
||
</div>
|
||
<div>
|
||
<div className="subtle">지출</div>
|
||
<div style={{ fontWeight: 700 }}>{fmt(displayExpense)}원</div>
|
||
</div>
|
||
<div>
|
||
<div className="subtle">수익</div>
|
||
<div style={{ fontWeight: 700, color: displayProfit < 0 ? "#b42318" : "var(--good)" }}>
|
||
{fmt(displayProfit)}원
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{currentTab === "lifecycle" && (detail?.lifecycle_cost?.rows || []).length > 0 && (
|
||
<section className="panel" style={{ padding: 20 }}>
|
||
<section style={{ marginBottom: 14, paddingBottom: 10, borderBottom: "1px solid var(--line)" }}>
|
||
<div style={{ fontSize: 16, fontWeight: 700 }}>관련 프로젝트 흐름</div>
|
||
<div className="subtle" style={{ marginTop: 6 }}>영업/설계 카드를 누르면 배분 비율(해당프로젝트/총프로젝트)을 입력할 수 있습니다.</div>
|
||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: 0, marginTop: 10, borderTop: "1px solid var(--line)" }}>
|
||
{lifecycleFlowCards.map((card) => (
|
||
<div
|
||
key={`lifecycle-flow-${card.role}-${card.project_code}`}
|
||
role={card.role !== "시공" && card.has_project ? "button" : undefined}
|
||
tabIndex={card.role !== "시공" && card.has_project ? 0 : undefined}
|
||
onClick={() => {
|
||
if (card.role === "시공" || !card.has_project) return;
|
||
openLifecycleAllocationModal({
|
||
project_code: card.project_code,
|
||
project_name: card.project_name,
|
||
project_type: card.role,
|
||
allocation_numerator: card.numerator,
|
||
allocation_denominator: card.denominator,
|
||
});
|
||
}}
|
||
onKeyDown={(event) => {
|
||
if (card.role === "시공" || !card.has_project) return;
|
||
if (event.key !== "Enter" && event.key !== " ") return;
|
||
event.preventDefault();
|
||
openLifecycleAllocationModal({
|
||
project_code: card.project_code,
|
||
project_name: card.project_name,
|
||
project_type: card.role,
|
||
allocation_numerator: card.numerator,
|
||
allocation_denominator: card.denominator,
|
||
});
|
||
}}
|
||
style={{
|
||
border: "none",
|
||
borderRight: card.role !== "시공" ? "1px solid var(--line)" : "none",
|
||
borderRadius: 0,
|
||
background: card.has_project ? "transparent" : "rgba(15, 28, 46, 0.03)",
|
||
padding: `12px 14px 10px ${card.role === "영업" ? 0 : 14}px`,
|
||
display: "grid",
|
||
alignContent: "start",
|
||
gap: 8,
|
||
cursor: card.role !== "시공" && card.has_project ? "pointer" : "default",
|
||
color: card.has_project ? "var(--ink)" : "var(--muted-strong)",
|
||
}}
|
||
>
|
||
<div className="lifecycle-flow-head" style={{ opacity: card.has_project ? 1 : 0.9 }}>
|
||
<div style={{ fontSize: 14, fontWeight: 800 }}>{card.role}</div>
|
||
{card.role !== "시공" && (
|
||
<div className="subtle" style={{ color: card.has_project ? undefined : "var(--muted-strong)" }}>{fmt(card.numerator)} / {fmt(card.denominator)}</div>
|
||
)}
|
||
{card.role === "시공" && (
|
||
<div className="subtle">
|
||
{card.has_project ? <>공정률 <strong style={{ color: "var(--ink)" }}>{Number(card.progress_rate || 0).toFixed(1)}%</strong></> : ""}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="lifecycle-flow-project-block">
|
||
<div className="lifecycle-flow-project-code" style={{ color: card.has_project ? "var(--ink)" : "var(--muted-strong)" }}>
|
||
{card.project_code || "-"}
|
||
</div>
|
||
<div className="lifecycle-flow-project-name" style={{ color: card.has_project ? "var(--ink)" : "var(--muted-strong)" }}>
|
||
{card.project_name || "연결 프로젝트 없음"}
|
||
</div>
|
||
</div>
|
||
<div className="lifecycle-flow-amount-row" style={{ marginTop: 2 }}>
|
||
<div>
|
||
<div className="subtle" style={{ color: card.has_project ? undefined : "var(--muted-strong)" }}>실제 매출</div>
|
||
<div style={{ fontWeight: 700, color: card.has_project ? "var(--ink)" : "var(--muted-strong)" }}>{fmt(card.income)}원</div>
|
||
</div>
|
||
<div>
|
||
<div className="subtle" style={{ color: card.has_project ? undefined : "var(--muted-strong)" }}>실제 매입</div>
|
||
<div style={{ fontWeight: 700, color: card.has_project ? "var(--ink)" : "var(--muted-strong)" }}>{fmt(card.expense)}원</div>
|
||
</div>
|
||
</div>
|
||
{card.role !== "시공" && (
|
||
<div className="lifecycle-flow-amount-row" style={{ borderTop: "1px solid var(--line)", paddingTop: 8, minHeight: 54 }}>
|
||
<div>
|
||
<div className="subtle" style={{ color: card.has_project ? undefined : "var(--muted-strong)" }}>반영 매출</div>
|
||
<div style={{ fontWeight: 800, color: card.has_project ? "var(--good)" : "var(--muted-strong)" }}>{fmt(card.reflected_income)}원</div>
|
||
</div>
|
||
<div>
|
||
<div className="subtle" style={{ color: card.has_project ? undefined : "var(--muted-strong)" }}>반영 매입</div>
|
||
<div style={{ fontWeight: 800, color: card.has_project ? "var(--ink)" : "var(--muted-strong)" }}>{fmt(card.reflected_expense)}원</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
{!lifecycleFlowCards.length && (
|
||
<div className="subtle">표시할 연결 프로젝트가 없습니다.</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
<div style={{ display: "flex", justifyContent: "space-between", gap: 16, alignItems: "flex-start" }}>
|
||
<div>
|
||
<div style={{ fontSize: 18, fontWeight: 700 }}>프로젝트 생애주기 원가</div>
|
||
<div className="subtle" style={{ marginTop: 6 }}>현재 시공 프로젝트를 포함해 연결된 전체 비용을 시공비, 인건비, 관리비로 나눠 봅니다.</div>
|
||
<div style={{ display: "flex", alignItems: "center", gap: 14, marginTop: 10, minWidth: 0 }}>
|
||
<span className="subtle" style={{ whiteSpace: "nowrap", flex: "0 0 auto" }}>공통배분 기준</span>
|
||
<div style={{ display: "flex", gap: 6 }}>
|
||
<button
|
||
type="button"
|
||
className={`mode-chip ${effectiveCommonAllocationMode === "expense_ratio" ? "active" : ""}`}
|
||
onClick={() => saveLifecycleCommonAllocationMode("expense_ratio")}
|
||
disabled={lifecycleCommonAllocationSaving}
|
||
style={{ minWidth: 116, height: 34, fontSize: 13, borderRadius: 10, padding: "0 10px" }}
|
||
>
|
||
지출기준
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`mode-chip ${effectiveCommonAllocationMode === "income_ratio" ? "active" : ""}`}
|
||
onClick={() => saveLifecycleCommonAllocationMode("income_ratio")}
|
||
disabled={lifecycleCommonAllocationSaving}
|
||
style={{ minWidth: 116, height: 34, fontSize: 13, borderRadius: 10, padding: "0 10px" }}
|
||
>
|
||
수입기준
|
||
</button>
|
||
</div>
|
||
<div className="subtle" style={{ marginLeft: 12, lineHeight: 1.45, minWidth: 0 }}>
|
||
<div style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||
본사관리비 배부원천 x (프로젝트 기준값 / 전체 기준값)
|
||
</div>
|
||
<div style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||
기준값: {effectiveCommonAllocationMode === "income_ratio" ? "프로젝트 입금 / 전체입금" : "프로젝트 지출 / 전체지출"}
|
||
</div>
|
||
</div>
|
||
<span className="subtle">{lifecycleCommonAllocationSaving ? "저장 중..." : ""}</span>
|
||
</div>
|
||
</div>
|
||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, minmax(120px, 1fr))", gap: 24, minWidth: 560 }}>
|
||
<div>
|
||
<div className="subtle">총 입금</div>
|
||
<div className="summary-value" style={{ fontSize: 20 }}>{fmt(detail.lifecycle_cost.summary?.income_supply || 0)}원</div>
|
||
</div>
|
||
<div>
|
||
<div className="subtle">총 지출</div>
|
||
<div className="summary-value" style={{ fontSize: 20 }}>{fmt(detail.lifecycle_cost.summary?.expense_supply || 0)}원</div>
|
||
</div>
|
||
<div>
|
||
<div className="subtle">총 수익</div>
|
||
<div className="summary-value" style={{ fontSize: 20, color: (detail.lifecycle_cost.summary?.profit_supply || 0) < 0 ? "#b42318" : "var(--good)" }}>
|
||
{fmt(detail.lifecycle_cost.summary?.profit_supply || 0)}원
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="subtle">수익률</div>
|
||
<div className="summary-value" style={{ fontSize: 20, color: lifecycleMarginRate < 0 ? "#b42318" : "var(--good)" }}>
|
||
{lifecycleMarginRate.toFixed(1)}%
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: 0, marginTop: 14, borderTop: "1px solid var(--line)" }}>
|
||
{(detail.lifecycle_cost.breakdown || []).map((item, index, arr) => (
|
||
<div
|
||
key={`lifecycle-breakdown-${item.label}`}
|
||
className="lifecycle-breakdown-trigger"
|
||
role="button"
|
||
tabIndex={0}
|
||
title={`${item.label} 상세 보기`}
|
||
onClick={() => {
|
||
window.__lifecycleBreakdownClicked = item.label || "";
|
||
setLifecycleBreakdownModal({
|
||
label: item.label || "",
|
||
expense_supply: Number(item.expense_supply || 0),
|
||
direct_expense_supply: Number(item.direct_expense_supply || 0),
|
||
shared_expense_supply: Number(item.shared_expense_supply || 0),
|
||
projects: Array.isArray(item.projects) ? item.projects.map((project) => ({ ...project })) : [],
|
||
accounts: Array.isArray(item.accounts) ? item.accounts.map((account) => ({ ...account })) : [],
|
||
opened_at: Date.now(),
|
||
});
|
||
}}
|
||
onKeyDown={(event) => {
|
||
if (event.key !== "Enter" && event.key !== " ") return;
|
||
event.preventDefault();
|
||
window.__lifecycleBreakdownClicked = item.label || "";
|
||
setLifecycleBreakdownModal({
|
||
label: item.label || "",
|
||
expense_supply: Number(item.expense_supply || 0),
|
||
direct_expense_supply: Number(item.direct_expense_supply || 0),
|
||
shared_expense_supply: Number(item.shared_expense_supply || 0),
|
||
projects: Array.isArray(item.projects) ? item.projects.map((project) => ({ ...project })) : [],
|
||
accounts: Array.isArray(item.accounts) ? item.accounts.map((account) => ({ ...account })) : [],
|
||
opened_at: Date.now(),
|
||
});
|
||
}}
|
||
style={{
|
||
border: "none",
|
||
borderRight: index < arr.length - 1 ? "1px solid var(--line)" : "none",
|
||
borderRadius: 0,
|
||
background: "transparent",
|
||
padding: `10px 14px 9px ${index === 0 ? 0 : 14}px`,
|
||
display: "grid",
|
||
gap: 4,
|
||
textAlign: "left",
|
||
cursor: "pointer",
|
||
}}
|
||
>
|
||
<div className="subtle">{item.label}</div>
|
||
<div style={{ fontSize: 16, fontWeight: 800, lineHeight: 1.1 }}>
|
||
{fmt(item.expense_supply || 0)}원
|
||
</div>
|
||
{(item.label === "인건비" || item.label === "관리비") && (
|
||
<div className="subtle" style={{ marginTop: 2, fontSize: 12 }}>
|
||
직접분 {fmt(item.direct_expense_supply || 0)}원 · 공통배분분 {fmt(item.shared_expense_supply || 0)}원
|
||
</div>
|
||
)}
|
||
<div style={{ fontSize: 12, color: "var(--blue)", fontWeight: 700 }}>상세보기 ></div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{lifecycleBreakdownModal && (
|
||
<section className="mini-card" style={{ marginTop: 14, padding: 16 }}>
|
||
<div style={{ display: "flex", justifyContent: "space-between", gap: 16, alignItems: "flex-start" }}>
|
||
<div>
|
||
<div style={{ fontSize: 18, fontWeight: 700 }}>{lifecycleBreakdownModal.label} 상세</div>
|
||
<div className="subtle" style={{ marginTop: 6 }}>연결된 전체 프로젝트 기준으로 묶은 지출 상세입니다.</div>
|
||
</div>
|
||
<button className="button-muted" onClick={() => setLifecycleBreakdownModal(null)}>닫기</button>
|
||
</div>
|
||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: 12, marginTop: 14 }}>
|
||
<div className="mini-card">
|
||
<div className="subtle">합계 지출</div>
|
||
<div className="summary-value" style={{ marginTop: 6 }}>{fmt(lifecycleBreakdownModal.expense_supply || 0)}원</div>
|
||
</div>
|
||
{(lifecycleBreakdownModal.label === "인건비" || lifecycleBreakdownModal.label === "관리비") && (
|
||
<div className="mini-card">
|
||
<div className="subtle">직접분 / 공통배분분</div>
|
||
<div className="summary-value" style={{ marginTop: 6, fontSize: 20 }}>
|
||
{fmt(lifecycleBreakdownModal.direct_expense_supply || 0)}원 / {fmt(lifecycleBreakdownModal.shared_expense_supply || 0)}원
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className="mini-card">
|
||
<div className="subtle">프로젝트 수</div>
|
||
<div className="summary-value" style={{ marginTop: 6 }}>{fmt((lifecycleBreakdownModal.projects || []).length)}개</div>
|
||
</div>
|
||
<div className="mini-card">
|
||
<div className="subtle">계정 수</div>
|
||
<div className="summary-value" style={{ marginTop: 6 }}>{fmt((lifecycleBreakdownModal.accounts || []).length)}개</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: "grid", gap: 18, marginTop: 18 }}>
|
||
<section className="panel" style={{ padding: 16 }}>
|
||
<div style={{ fontSize: 16, fontWeight: 700 }}>프로젝트별 금액</div>
|
||
<div className="subtle" style={{ marginTop: 6 }}>연결된 프로젝트들 중 이 항목에 반영된 지출 금액입니다.</div>
|
||
<div style={{ display: "grid", gap: 10, marginTop: 12 }}>
|
||
{(lifecycleBreakdownModal.projects || []).length ? (
|
||
(() => {
|
||
const projects = Array.isArray(lifecycleBreakdownModal.projects) ? lifecycleBreakdownModal.projects : [];
|
||
const roleAmount = {
|
||
영업: 0,
|
||
설계: 0,
|
||
시공: 0,
|
||
};
|
||
const roleCodes = {
|
||
영업: new Set(),
|
||
설계: new Set(),
|
||
시공: new Set(),
|
||
};
|
||
let sharedAmount = 0;
|
||
projects.forEach((project) => {
|
||
const role = (project.project_type || "").trim();
|
||
const direct = Number(project.direct_expense_supply || 0);
|
||
const shared = Number(project.shared_expense_supply || 0);
|
||
const projectCode = (project.project_code || "").trim();
|
||
if (roleAmount[role] !== undefined) {
|
||
roleAmount[role] += direct;
|
||
if (projectCode) roleCodes[role].add(projectCode);
|
||
}
|
||
sharedAmount += shared;
|
||
});
|
||
const groupedRows = [
|
||
{ key: "영업", label: "영업", amount: roleAmount.영업, showWhenZero: false, codes: Array.from(roleCodes.영업) },
|
||
{ key: "설계", label: "설계", amount: roleAmount.설계, showWhenZero: false, codes: Array.from(roleCodes.설계) },
|
||
{ key: "시공", label: "시공", amount: roleAmount.시공, showWhenZero: true, codes: Array.from(roleCodes.시공) },
|
||
{ key: "공통배분분", label: "공통배분분", amount: sharedAmount, showWhenZero: true, codes: [] },
|
||
].filter((row) => row.showWhenZero || row.amount > 0);
|
||
return groupedRows.map((item) => (
|
||
<button
|
||
key={`lifecycle-inline-project-group-${item.key}`}
|
||
type="button"
|
||
onClick={() => setLifecycleProjectTotalGroup(item.key)}
|
||
style={{
|
||
textAlign: "left",
|
||
border: "1px solid var(--line)",
|
||
borderRadius: 14,
|
||
background: lifecycleProjectTotalGroup === item.key ? "#f3f8ff" : "white",
|
||
padding: "12px 14px",
|
||
display: "grid",
|
||
gridTemplateColumns: "minmax(220px, 1fr) minmax(140px, 0.4fr)",
|
||
gap: 12,
|
||
alignItems: "center",
|
||
cursor: "pointer",
|
||
}}
|
||
>
|
||
<div>
|
||
<div style={{ fontWeight: 700 }}>{item.label}</div>
|
||
<div className="subtle" style={{ marginTop: 4 }}>
|
||
{(item.codes || []).length ? item.codes.join(", ") : "-"}
|
||
</div>
|
||
</div>
|
||
<div style={{ textAlign: "right", fontWeight: 700 }}>
|
||
{fmt(item.amount || 0)}원
|
||
</div>
|
||
</button>
|
||
));
|
||
})()
|
||
) : (
|
||
<div className="subtle">표시할 프로젝트가 없습니다.</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="panel" style={{ padding: 16 }}>
|
||
<div style={{ fontSize: 16, fontWeight: 700 }}>계정별 금액</div>
|
||
<div className="subtle" style={{ marginTop: 6 }}>
|
||
{lifecycleProjectTotalGroup === "설계" || lifecycleProjectTotalGroup === "시공"
|
||
? `${lifecycleProjectTotalGroup} 직접분 계정 기준 합계입니다.`
|
||
: lifecycleProjectTotalGroup === "공통배분분"
|
||
? "공통배분분 계정 기준 합계입니다."
|
||
: "이 항목에 포함된 계정 기준 합계입니다."}
|
||
</div>
|
||
<div style={{ display: "grid", gap: 10, marginTop: 12 }}>
|
||
{(lifecycleBreakdownModal.accounts || []).length ? (
|
||
(() => {
|
||
const roleField =
|
||
lifecycleProjectTotalGroup === "설계" ? "direct_design_expense_supply"
|
||
: lifecycleProjectTotalGroup === "시공" ? "direct_construction_expense_supply"
|
||
: lifecycleProjectTotalGroup === "영업" ? "direct_sales_expense_supply"
|
||
: "shared_expense_supply";
|
||
const filtered = (lifecycleBreakdownModal.accounts || [])
|
||
.map((item) => ({ ...item, __role_amount: Number(item?.[roleField] || 0) }))
|
||
.filter((item) => item.__role_amount > 0);
|
||
if (!filtered.length) {
|
||
return <div className="subtle">표시할 계정이 없습니다.</div>;
|
||
}
|
||
return filtered.map((item) => (
|
||
<button
|
||
key={`lifecycle-inline-account-${item.account_code}`}
|
||
type="button"
|
||
data-testid="lifecycle-inline-account"
|
||
onClick={() => {
|
||
if (!selectedProjectCode) return;
|
||
setLifecycleAccountDetailModal({
|
||
project_code: selectedProjectCode,
|
||
bucket_label: lifecycleBreakdownModal.label,
|
||
account_code: item.account_code || "",
|
||
account_name: item.account_name || "",
|
||
allocation_source_amount: Number(item.allocation_source_amount || 0),
|
||
allocation_project_basis_amount: Number(item.allocation_project_basis_amount || 0),
|
||
allocation_total_basis_amount: Number(item.allocation_total_basis_amount || 0),
|
||
allocation_mode: item.allocation_mode || "",
|
||
allocation_result_amount: Number(item.__role_amount || 0),
|
||
allocation_details: Array.isArray(item.allocation_details) ? item.allocation_details.map((row) => ({ ...row })) : [],
|
||
allocation_group: lifecycleProjectTotalGroup,
|
||
show_allocation_formula: lifecycleProjectTotalGroup === "공통배분분",
|
||
detail: null,
|
||
});
|
||
}}
|
||
style={{
|
||
textAlign: "left",
|
||
border: "none",
|
||
borderBottom: "1px solid var(--line)",
|
||
borderRadius: 0,
|
||
background: "transparent",
|
||
padding: "10px 2px",
|
||
display: "grid",
|
||
gridTemplateColumns: "minmax(220px, 1fr) minmax(140px, 0.4fr)",
|
||
gap: 12,
|
||
alignItems: "center",
|
||
cursor: item.account_code ? "pointer" : "default",
|
||
}}
|
||
>
|
||
<div>
|
||
<div style={{ fontWeight: 700 }}>{item.account_name || "미지정 계정"}</div>
|
||
<div className="subtle" style={{ marginTop: 4 }}>{item.account_code || "-"}</div>
|
||
{((item.account_code || "").startsWith("SHARED_")) && (
|
||
<div className="subtle" style={{ marginTop: 4 }}>
|
||
본사관리비 배부원천 x (프로젝트 기준값 / 전체 기준값)
|
||
<br />
|
||
기준값: {item.allocation_mode === "income_ratio" ? "프로젝트 입금 / 전체입금" : "프로젝트 지출 / 전체지출"}
|
||
<br />
|
||
{(Array.isArray(item.allocation_details) && item.allocation_details.length
|
||
? item.allocation_details
|
||
: [{
|
||
source_amount: item.allocation_source_amount || 0,
|
||
project_basis_amount: item.allocation_project_basis_amount || 0,
|
||
total_basis_amount: item.allocation_total_basis_amount || 0,
|
||
allocated_amount: item.expense_supply || 0,
|
||
}]
|
||
).map((row, idx) => {
|
||
const sourceAmount = Number(row.source_amount || 0);
|
||
const allocatedAmount = Number(row.allocated_amount || 0);
|
||
const projectBasisAmount = Number(
|
||
row.display_project_basis_amount ?? row.project_basis_amount ?? 0
|
||
);
|
||
const totalBasisAmount = Number(
|
||
row.display_total_basis_amount ?? row.total_basis_amount ?? 0
|
||
);
|
||
return (
|
||
<span key={`alloc-line-${item.account_code}-${idx}`} style={{ display: "block" }}>
|
||
{((row.year_month || "").slice(0, 4) || "-")}년 · {fmt(sourceAmount)}원 x ({fmt(projectBasisAmount)}원 / {fmt(totalBasisAmount)}원) = {fmt(allocatedAmount)}원
|
||
</span>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div style={{ textAlign: "right", fontWeight: 700 }}>{fmt(item.__role_amount || 0)}원</div>
|
||
</button>
|
||
));
|
||
})()
|
||
) : (
|
||
<div className="subtle">표시할 계정이 없습니다.</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</section>
|
||
)}
|
||
</section>
|
||
)}
|
||
|
||
{currentTab === "lifecycle" && !((detail?.lifecycle_cost?.rows || []).length > 0) && (
|
||
<section className="panel" style={{ padding: 20 }}>
|
||
<div style={{ fontSize: 18, fontWeight: 700 }}>프로젝트 생애주기 원가</div>
|
||
<div className="subtle" style={{ marginTop: 6 }}>
|
||
현재 프로젝트 금액을 불러올 수 없습니다. 프로젝트 유형과 연결 코드를 확인해 주세요.
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{currentTab === "project" && (
|
||
<>
|
||
<section className="budget-split">
|
||
<div className="panel" style={{ padding: 20 }}>
|
||
<div style={{ fontSize: 18, fontWeight: 700 }}>집행률 / 공정률 그래프</div>
|
||
<div className="subtle" style={{ marginTop: 6 }}>공정률과 집행률 차이를 먼저 보고, 아래 상세표에서 실행계획을 바로 조정할 수 있습니다.</div>
|
||
<div className="mini-card" style={{ marginTop: 14 }}>
|
||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, minmax(0, 1fr))", gap: 14 }}>
|
||
<div>
|
||
<div className="subtle">총수입</div>
|
||
<div className="summary-value nowrap">{fmt(profitSummary.revenue)}원</div>
|
||
</div>
|
||
<div>
|
||
<div className="subtle">총지출</div>
|
||
<div className="summary-value nowrap">{fmt(profitSummary.expense)}원</div>
|
||
</div>
|
||
<div>
|
||
<div className="subtle">수익</div>
|
||
<div className="summary-value nowrap" style={{ color: profitSummary.profit < 0 ? "#b42318" : "var(--good)" }}>
|
||
{fmt(profitSummary.profit)}원
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="subtle">수익률</div>
|
||
<div className="summary-value nowrap" style={{ color: profitSummary.marginRate < 0 ? "#b42318" : "var(--good)" }}>
|
||
{profitSummary.marginRate.toFixed(1)}%
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="mini-card" style={{ marginTop: 14 }}>
|
||
{isPileProject ? (
|
||
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 180px) minmax(0, 160px) minmax(0, 160px) minmax(0, 160px) 150px 120px", gap: 12, alignItems: "end", justifyContent: "start" }}>
|
||
<label style={{ display: "grid", gap: 6 }}>
|
||
<div className="subtle">계약본수</div>
|
||
<input
|
||
className="field"
|
||
type="number"
|
||
min="0"
|
||
value={contractPileCount}
|
||
onChange={(e) => setContractPileCount(e.target.value)}
|
||
placeholder="계약본수 입력"
|
||
/>
|
||
</label>
|
||
<div className="mini-card" style={{ padding: "10px 14px" }}>
|
||
<div className="subtle">누적 시공본수</div>
|
||
<div className="summary-value">{fmt(constructedPileCount)}본</div>
|
||
</div>
|
||
<div className="mini-card" style={{ padding: "10px 14px" }}>
|
||
<div className="subtle">잔여수량</div>
|
||
<div className="summary-value">
|
||
{fmt(Math.max((Number(contractPileCount) || 0) - (Number(constructedPileCount) || 0), 0))}본
|
||
</div>
|
||
</div>
|
||
<div className="mini-card" style={{ padding: "10px 14px" }}>
|
||
<div className="subtle">공정률</div>
|
||
<div className="summary-value">{effectiveProgressRate.toFixed(1)}%</div>
|
||
</div>
|
||
<button
|
||
onClick={openPileProgressModal}
|
||
className="button-muted"
|
||
style={{ width: "150px", height: 42, justifySelf: "start" }}
|
||
>
|
||
시공실적 입력
|
||
</button>
|
||
<button
|
||
onClick={saveProjectBudget}
|
||
disabled={budgetSaving}
|
||
style={{
|
||
height: 42,
|
||
border: "none",
|
||
borderRadius: 12,
|
||
padding: "0 18px",
|
||
background: "var(--blue)",
|
||
color: "white",
|
||
fontSize: 13,
|
||
fontWeight: 700,
|
||
cursor: "pointer",
|
||
opacity: budgetSaving ? 0.6 : 1,
|
||
width: "120px",
|
||
justifySelf: "start"
|
||
}}
|
||
>
|
||
{budgetSaving ? "저장 중..." : "저장"}
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 180px) minmax(0, 160px) 160px", gap: 12, alignItems: "end", justifyContent: "start" }}>
|
||
<label style={{ display: "grid", gap: 6 }}>
|
||
<div className="subtle">공정률</div>
|
||
<input
|
||
className="field"
|
||
type="number"
|
||
min="0"
|
||
max="100"
|
||
value={progressRate}
|
||
onChange={(e) => setProgressRate(e.target.value)}
|
||
placeholder="공정률 입력"
|
||
/>
|
||
</label>
|
||
<div className="mini-card" style={{ padding: "10px 14px" }}>
|
||
<div className="subtle">공정률</div>
|
||
<div className="summary-value">{effectiveProgressRate.toFixed(1)}%</div>
|
||
</div>
|
||
<button
|
||
onClick={saveProjectBudget}
|
||
disabled={budgetSaving}
|
||
style={{
|
||
height: 42,
|
||
border: "none",
|
||
borderRadius: 12,
|
||
padding: "0 18px",
|
||
background: "var(--blue)",
|
||
color: "white",
|
||
fontSize: 13,
|
||
fontWeight: 700,
|
||
cursor: "pointer",
|
||
opacity: budgetSaving ? 0.6 : 1,
|
||
width: "160px",
|
||
justifySelf: "start"
|
||
}}
|
||
>
|
||
{budgetSaving ? "저장 중..." : "공정 저장"}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="mini-card" style={{ marginTop: 14 }}>
|
||
<div style={{ display: "grid", gridTemplateColumns: "180px minmax(0, 1fr) 110px", gap: 14, alignItems: "center", marginBottom: 14, paddingBottom: 14, borderBottom: "1px solid var(--line)" }}>
|
||
<div style={{ fontSize: 16, fontWeight: 700 }}>기준 공정률</div>
|
||
<div className="bar-track">
|
||
<div className="bar-fill" style={{ width: `${Math.min(effectiveProgressRate, 100)}%`, background: "linear-gradient(90deg, #18a34a, #29b65a)" }} />
|
||
</div>
|
||
<div style={{ textAlign: "right", fontWeight: 700, color: "#1ca64b" }}>
|
||
{effectiveProgressRate.toFixed(1)}%
|
||
</div>
|
||
</div>
|
||
<div style={{ display: "grid", gap: 6 }}>
|
||
{budgetCompareRows.map((item) => (
|
||
<div key={item.key} className="progress-compare-row">
|
||
<div style={{ fontSize: 16, fontWeight: 700 }}>{item.label}</div>
|
||
<div>
|
||
<div className="bar-track">
|
||
<div className="bar-fill" style={{ width: `${Math.min(item.executionRate, 100)}%`, background: "linear-gradient(90deg, #1c54d8, #325fe8)" }} />
|
||
</div>
|
||
</div>
|
||
<div style={{ textAlign: "right", fontWeight: 700, color: "var(--blue)" }}>
|
||
{item.executionRate.toFixed(1)}%
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div style={{ marginTop: 16, textAlign: "right", fontSize: 13, fontWeight: 700 }}>
|
||
[집행 총 합계] {fmt(detail?.budget_analysis?.expense_actual_total || 0)}원
|
||
{" "}|{" "}
|
||
[집행 총 합계 / 실행계획] {((detail?.budget_analysis?.execution_rate_total) || 0).toFixed(1)}%
|
||
{" "}(기준 공정률 {effectiveProgressRate.toFixed(1)}%)
|
||
</div>
|
||
</div>
|
||
|
||
<div className="panel" style={{ padding: 20 }}>
|
||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}>
|
||
<div>
|
||
<div style={{ fontSize: 18, fontWeight: 700 }}>실행예산 입력</div>
|
||
<div className="subtle">메인 표에서는 항목 합계만 보고, 항목명을 클릭해서 팝업 안에서 계정별 실행계획을 입력합니다.</div>
|
||
</div>
|
||
<div style={{ display: "flex", gap: 10, alignItems: "center" }}>
|
||
<button
|
||
onClick={saveProjectBudget}
|
||
disabled={budgetSaving}
|
||
style={{
|
||
height: 42,
|
||
border: "none",
|
||
borderRadius: 12,
|
||
padding: "0 16px",
|
||
background: "var(--blue)",
|
||
color: "white",
|
||
fontSize: 13,
|
||
fontWeight: 700,
|
||
cursor: "pointer",
|
||
opacity: budgetSaving ? 0.6 : 1
|
||
}}
|
||
>
|
||
{budgetSaving ? "저장 중..." : "예산 저장"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div style={{ display: "grid", gap: 16 }}>
|
||
{budgetGroups.map((group) => {
|
||
const isCrossTypeWarning =
|
||
group.section === "지출" &&
|
||
(
|
||
(detail?.summary?.project_type === "시공" && group.groupName === "관리") ||
|
||
(detail?.summary?.project_type === "관리" && group.groupName === "시공")
|
||
);
|
||
const warningMessage =
|
||
detail?.summary?.project_type === "시공"
|
||
? "시공 프로젝트에서는 관리가 비워져야 합니다"
|
||
: detail?.summary?.project_type === "관리"
|
||
? "관리 프로젝트에서는 시공이 비워져야 합니다"
|
||
: "";
|
||
const hasCrossTypeWarningValue = isCrossTypeWarning && group.items.some((entry) => Number(entry.actual_amount || 0) > 0 || Number(entry.budget_amount || 0) > 0);
|
||
|
||
return (
|
||
<div key={group.key} className="table-wrap">
|
||
<div
|
||
style={{ padding: "14px 16px 10px", fontSize: 24, fontWeight: 700, borderBottom: "1px solid var(--line)", background: "rgba(255,255,255,0.88)" }}
|
||
className={`${isCrossTypeWarning ? "warning-panel-bg warning-panel-title" : ""} ${hasCrossTypeWarningValue ? "warning-cell" : ""}`.trim()}
|
||
>
|
||
{group.groupName}
|
||
{isCrossTypeWarning && (
|
||
<div style={{ fontSize: 12, marginTop: 6 }} className="warning-text">
|
||
{warningMessage}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="budget-table-wrap">
|
||
<table className="budget-table">
|
||
<colgroup>
|
||
<col className="col-item" />
|
||
<col className="col-budget" />
|
||
<col className="col-actual" />
|
||
<col className="col-diff" />
|
||
<col className="col-rate" />
|
||
</colgroup>
|
||
<thead>
|
||
<tr>
|
||
<th>항목명 (계정수)</th>
|
||
<th>실행계획</th>
|
||
<th>집행금액</th>
|
||
<th>차이</th>
|
||
<th>집행률</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{group.items.map((item, idx) => {
|
||
const diff = calculateBudgetDiff(item);
|
||
const rate = (Number(item.budget_amount) || 0) > 0 ? (Number(item.actual_amount) || 0) / (Number(item.budget_amount) || 0) * 100 : 0;
|
||
return (
|
||
<tr key={idx}>
|
||
<td>
|
||
<button className="account-expand-button" onClick={() => openBudgetModal(item)}>
|
||
{item.category} ({item.account_items?.length || 0})
|
||
</button>
|
||
</td>
|
||
<td style={{ fontWeight: 700 }}>{fmt(item.budget_amount || 0)}원</td>
|
||
<td>
|
||
<button className="amount-button" onClick={() => openActualModal(item)}>
|
||
{fmt(item.actual_amount || 0)}원
|
||
</button>
|
||
</td>
|
||
<td style={{ fontWeight: 700, color: getBudgetDiffColor(item, diff) }}>{fmt(diff)}원</td>
|
||
<td style={{ fontWeight: 700, color: getBudgetRateColor(item, rate) }}>{rate.toFixed(1)}%</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="panel" style={{ padding: 20 }}>
|
||
<div style={{ fontSize: 18, fontWeight: 700 }}>계정 매핑 이슈 ({detail?.account_issues?.length || 0}건)</div>
|
||
<div className="subtle" style={{ marginTop: 6 }}>PTC 프로젝트 성격에 맞지 않는 계정으로 분류된 거래들입니다. 적절한 계정으로 일괄 또는 개별 변경할 수 있습니다.</div>
|
||
<div className="table-wrap" style={{ marginTop: 14 }}>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>현재 계정</th>
|
||
<th>거래건수</th>
|
||
<th>합계금액</th>
|
||
<th>변경 추천 계정</th>
|
||
<th>작업</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{(detail?.account_issues || []).map((item) => (
|
||
<tr key={item.account_code}>
|
||
<td>
|
||
<button className="link-button" onClick={() => openIssueDetail(item)}>
|
||
{item.account_code} {item.account_name}
|
||
</button>
|
||
</td>
|
||
<td>{fmt(item.txn_count)}건</td>
|
||
<td style={{ fontWeight: 700 }}>{fmt(item.total_sum)}원</td>
|
||
<td>
|
||
<select
|
||
className="select"
|
||
value={issueSelections[item.account_code] || ""}
|
||
onChange={(e) => setIssueSelections((prev) => ({ ...prev, [item.account_code]: e.target.value }))}
|
||
>
|
||
<option value="">계정 선택</option>
|
||
{(allowedAccountCodesByProjectType[detail?.summary?.project_type] || []).map((code) => (
|
||
<option key={code} value={code}>
|
||
{code} · {accountMaster[code]?.name || ""}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</td>
|
||
<td>
|
||
<button
|
||
className="button-muted"
|
||
onClick={() => remapProjectAccount(item.account_code)}
|
||
disabled={remapSavingCode === item.account_code || !issueSelections[item.account_code]}
|
||
>
|
||
{remapSavingCode === item.account_code ? "변경 중" : "일괄 변경"}
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
{!(detail?.account_issues || []).length && (
|
||
<tr><td colSpan="5">매핑 이슈가 없습니다.</td></tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
</main>
|
||
</section>
|
||
)}
|
||
|
||
{currentTab === "vendor" && (
|
||
<section className="layout">
|
||
<aside className="panel" style={{ padding: 18 }}>
|
||
<div className="list-mode-toggle">
|
||
<button className={`mode-chip ${vendorListMode === "vendor" ? "active" : ""}`} onClick={() => setVendorListMode("vendor")}>거래처별</button>
|
||
<button className={`mode-chip ${vendorListMode === "account" ? "active" : ""}`} onClick={() => setVendorListMode("account")}>계정별</button>
|
||
</div>
|
||
<div className="toolbar vendor-search-toolbar">
|
||
<input
|
||
className="field vendor-search-field"
|
||
value={vendorKeyword}
|
||
onChange={(e) => setVendorKeyword(e.target.value)}
|
||
placeholder={vendorListMode === "vendor" ? "거래처명 검색" : "계정코드/명 검색"}
|
||
/>
|
||
</div>
|
||
<div className="toolbar" style={{ gridTemplateColumns: "1fr 1fr", marginTop: 10 }}>
|
||
<input
|
||
className="field"
|
||
type="date"
|
||
value={vendorDateFrom}
|
||
onChange={(e) => setVendorDateFrom(e.target.value)}
|
||
aria-label="거래 시작일"
|
||
/>
|
||
<input
|
||
className="field"
|
||
type="date"
|
||
value={vendorDateTo}
|
||
onChange={(e) => setVendorDateTo(e.target.value)}
|
||
aria-label="거래 종료일"
|
||
/>
|
||
</div>
|
||
<div className="toolbar" style={{ gridTemplateColumns: "1fr", marginTop: 10 }}>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setVendorDateFrom("");
|
||
setVendorDateTo("");
|
||
}}
|
||
style={{
|
||
height: 42,
|
||
border: "1px solid var(--line)",
|
||
borderRadius: 12,
|
||
padding: "0 12px",
|
||
background: "white",
|
||
color: "var(--text)",
|
||
fontSize: 13,
|
||
fontWeight: 700,
|
||
cursor: "pointer"
|
||
}}
|
||
>
|
||
기간 초기화
|
||
</button>
|
||
</div>
|
||
<div className="project-list" style={{ marginTop: 14 }}>
|
||
{vendorListMode === "vendor" && vendors.map((item) => (
|
||
<div
|
||
key={item.vendor_name}
|
||
className={`vendor-item ${selectedVendorName === item.vendor_name ? "active" : ""}`}
|
||
onClick={() => setSelectedVendorName(item.vendor_name)}
|
||
>
|
||
<div style={{ fontWeight: 700, fontSize: 15 }}>{item.vendor_name}</div>
|
||
<div style={{ marginTop: 6, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||
<div className="subtle">{fmt(item.txn_count)}건의 거래</div>
|
||
<div style={{ fontWeight: 700, fontSize: 14 }}>{fmt(item.supply_sum)}원</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
{vendorListMode === "account" && accounts.map((item) => (
|
||
<div
|
||
key={item.account_code}
|
||
className={`vendor-item ${selectedAccountCode === item.account_code ? "active" : ""}`}
|
||
onClick={() => setSelectedAccountCode(item.account_code)}
|
||
>
|
||
<div style={{ fontWeight: 700, fontSize: 15 }}>{item.account_code} · {item.account_name}</div>
|
||
<div style={{ marginTop: 6, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||
<div className="subtle">{fmt(item.txn_count)}건의 거래</div>
|
||
<div style={{ fontWeight: 700, fontSize: 14 }}>{fmt(item.supply_sum)}원</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
{vendorListMode === "vendor" && !vendors.length && !vendorLoading && (
|
||
<div className="empty">거래처가 없습니다.</div>
|
||
)}
|
||
{vendorListMode === "account" && !accounts.length && !accountLoading && (
|
||
<div className="empty">계정이 없습니다.</div>
|
||
)}
|
||
</div>
|
||
</aside>
|
||
|
||
<main style={{ display: "flex", flexDirection: "column", gap: 18 }}>
|
||
{vendorListMode === "vendor" && selectedVendor && (
|
||
<>
|
||
<section className="panel" style={{ padding: 22 }}>
|
||
<div className="vendor-head-grid">
|
||
<div>
|
||
<h2 style={{ margin: 0, fontSize: 30, lineHeight: 1.25 }}>{selectedVendor.vendor_name}</h2>
|
||
</div>
|
||
<div className="summary-card vendor-summary-card">
|
||
<div className="vendor-summary-grid">
|
||
<div>
|
||
<div className="subtle">기간</div>
|
||
<div className="vendor-period-value">
|
||
<div>{vendorDetail?.summary?.min_date || "-"}</div>
|
||
<div className="vendor-period-tilde">~</div>
|
||
<div>{vendorDetail?.summary?.max_date || "-"}</div>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="subtle">거래 건수</div>
|
||
<div className="summary-value">{fmt(vendorDetail?.summary?.txn_count || 0)}건</div>
|
||
<div className="subtle" style={{ marginTop: 4 }}>
|
||
<span className="vendor-summary-subline">입금 {fmt(vendorDetail?.summary?.income_count || 0)} / 출금 {fmt(vendorDetail?.summary?.expense_count || 0)}</span>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="subtle">공급가액 합계</div>
|
||
<div className="summary-value">{fmt(vendorDetail?.summary?.supply_sum || 0)}원</div>
|
||
<div className="subtle" style={{ marginTop: 4, display: "grid", gap: 2 }}>
|
||
<div>입금 {fmt(vendorDetail?.summary?.income_supply_sum || 0)}원</div>
|
||
<div>출금 {fmt(vendorDetail?.summary?.expense_supply_sum || 0)}원</div>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="subtle">연결 프로젝트</div>
|
||
<div className="summary-value">{fmt(vendorDetail?.projects?.length || 0)}개</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="panel" style={{ padding: 20 }}>
|
||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}>
|
||
<div>
|
||
<div style={{ fontSize: 18, fontWeight: 700 }}>프로젝트별 / 계정별 요약</div>
|
||
</div>
|
||
</div>
|
||
<div className="vendor-detail-split">
|
||
<div className="table-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>프로젝트</th>
|
||
<th>입금</th>
|
||
<th>출금</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr
|
||
className={`selectable-row ${selectedVendorProjectCode === "" ? "active" : ""}`}
|
||
onClick={() => setSelectedVendorProjectCode("")}
|
||
>
|
||
<td style={{ fontWeight: 700 }}>전체 프로젝트</td>
|
||
<td style={{ color: "var(--good)", fontWeight: 700 }}>{fmt(vendorDetail?.summary?.income_count || 0)}건</td>
|
||
<td style={{ color: "#b42318", fontWeight: 700 }}>{fmt(vendorDetail?.summary?.expense_count || 0)}건</td>
|
||
</tr>
|
||
{(vendorDetail?.projects || []).map((item) => (
|
||
<tr
|
||
key={`${item.project_code}-${item.project_name}`}
|
||
className={`selectable-row ${selectedVendorProjectCode === item.project_code ? "active" : ""}`}
|
||
onClick={() => setSelectedVendorProjectCode(item.project_code || "")}
|
||
>
|
||
<td>
|
||
<div style={{ display: "grid", gap: 2 }}>
|
||
<div style={{ fontWeight: 700 }}>{item.project_code || "코드없음"}</div>
|
||
<div className="subtle">{item.project_name || "(이름없음)"}</div>
|
||
</div>
|
||
</td>
|
||
<td style={{ color: "var(--good)", fontWeight: 700 }}>{fmt(item.income_count || 0)}건</td>
|
||
<td style={{ color: "#b42318", fontWeight: 700 }}>{fmt(item.expense_count || 0)}건</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div className="table-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>계정</th>
|
||
<th>거래건수</th>
|
||
<th>공급가액</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{(vendorDetail?.accounts || []).map((item) => (
|
||
<tr key={`${item.account_code}-${item.account_name}`}>
|
||
<td>
|
||
<button className="link-button" onClick={() => openVendorAccountModal(item)}>
|
||
{item.account_code || "-"} {item.account_name ? `· ${item.account_name}` : ""}
|
||
</button>
|
||
</td>
|
||
<td>{fmt(item.txn_count || 0)}건</td>
|
||
<td style={{ fontWeight: 700 }}>{fmt(item.supply_sum || 0)}원</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</>
|
||
)}
|
||
|
||
{vendorListMode === "account" && accountDetail?.summary && (
|
||
<>
|
||
<section className="panel" style={{ padding: 22 }}>
|
||
<div className="vendor-head-grid">
|
||
<div>
|
||
<h2 style={{ margin: 0, fontSize: 30, lineHeight: 1.25 }}>
|
||
{accountDetail.summary.account_code} {accountDetail.summary.account_name ? `· ${accountDetail.summary.account_name}` : ""}
|
||
</h2>
|
||
</div>
|
||
<div className="summary-card vendor-summary-card">
|
||
<div className="vendor-summary-grid">
|
||
<div>
|
||
<div className="subtle">기간</div>
|
||
<div className="vendor-period-value">
|
||
<div>{accountDetail?.summary?.min_date || "-"}</div>
|
||
<div className="vendor-period-tilde">~</div>
|
||
<div>{accountDetail?.summary?.max_date || "-"}</div>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="subtle">거래 건수</div>
|
||
<div className="summary-value">{fmt(accountDetail?.summary?.txn_count || 0)}건</div>
|
||
<div className="subtle" style={{ marginTop: 4 }}>
|
||
<span className="vendor-summary-subline">입금 {fmt(accountDetail?.summary?.income_count || 0)} / 출금 {fmt(accountDetail?.summary?.expense_count || 0)}</span>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="subtle">공급가액 합계</div>
|
||
<div className="summary-value">{fmt(accountDetail?.summary?.supply_sum || 0)}원</div>
|
||
<div className="subtle" style={{ marginTop: 4, display: "grid", gap: 2 }}>
|
||
<div>입금 {fmt(accountDetail?.summary?.income_supply_sum || 0)}원</div>
|
||
<div>출금 {fmt(accountDetail?.summary?.expense_supply_sum || 0)}원</div>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="subtle">연결 거래처</div>
|
||
<div className="summary-value">{fmt(accountDetail?.vendors?.length || 0)}개</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="panel" style={{ padding: 20 }}>
|
||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}>
|
||
<div>
|
||
<div style={{ fontSize: 18, fontWeight: 700 }}>프로젝트별 / 거래처별 요약</div>
|
||
</div>
|
||
</div>
|
||
<div className="vendor-overview-split">
|
||
<div className="table-wrap">
|
||
<table className="overview-table">
|
||
<thead>
|
||
<tr>
|
||
<th>프로젝트</th>
|
||
<th>입금</th>
|
||
<th>출금</th>
|
||
<th>공급가액</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr
|
||
className={`selectable-row ${selectedAccountProjectCode === "" ? "active" : ""}`}
|
||
onClick={() => setSelectedAccountProjectCode("")}
|
||
>
|
||
<td>
|
||
<div style={{ display: "grid", gap: 2 }}>
|
||
<div style={{ fontWeight: 700 }}>전체 프로젝트</div>
|
||
</div>
|
||
</td>
|
||
<td>{fmt(accountDetail?.summary?.income_count || 0)}건</td>
|
||
<td>{fmt(accountDetail?.summary?.expense_count || 0)}건</td>
|
||
<td style={{ fontWeight: 700 }}>{fmt(accountDetail?.summary?.supply_sum || 0)}원</td>
|
||
</tr>
|
||
{(accountDetail?.projects || []).map((item) => (
|
||
<tr
|
||
key={`${item.project_code}-${item.project_name}`}
|
||
className={`selectable-row ${selectedAccountProjectCode === item.project_code ? "active" : ""}`}
|
||
onClick={() => setSelectedAccountProjectCode(item.project_code || "")}
|
||
>
|
||
<td>
|
||
<div style={{ display: "grid", gap: 2 }}>
|
||
<div style={{ fontWeight: 700 }}>{item.project_code || "코드없음"}</div>
|
||
<div className="subtle">{item.project_name || "(이름없음)"}</div>
|
||
</div>
|
||
</td>
|
||
<td>{fmt(item.income_count || 0)}건</td>
|
||
<td>{fmt(item.expense_count || 0)}건</td>
|
||
<td style={{ fontWeight: 700 }}>{fmt(item.supply_sum || 0)}원</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div className="table-wrap">
|
||
<table className="overview-table">
|
||
<thead>
|
||
<tr>
|
||
<th>거래처</th>
|
||
<th>입금</th>
|
||
<th>출금</th>
|
||
<th>공급가액</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr>
|
||
<td>
|
||
<button className="link-button" onClick={() => openAccountVendorModal({ vendor_name: "" })}>
|
||
전체 거래처
|
||
</button>
|
||
</td>
|
||
<td>{fmt(accountDetail?.summary?.income_count || 0)}건</td>
|
||
<td>{fmt(accountDetail?.summary?.expense_count || 0)}건</td>
|
||
<td style={{ fontWeight: 700 }}>{fmt(accountDetail?.summary?.supply_sum || 0)}원</td>
|
||
</tr>
|
||
{(accountDetail?.vendors || []).map((item) => (
|
||
<tr key={item.vendor_name || "blank-vendor"}>
|
||
<td>
|
||
<button className="link-button" onClick={() => openAccountVendorModal(item)}>
|
||
{item.vendor_name || "-"}
|
||
</button>
|
||
</td>
|
||
<td>{fmt(item.income_count || 0)}건</td>
|
||
<td>{fmt(item.expense_count || 0)}건</td>
|
||
<td style={{ fontWeight: 700 }}>{fmt(item.supply_sum || 0)}원</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</>
|
||
)}
|
||
</main>
|
||
</section>
|
||
)}
|
||
|
||
{currentTab === "management" && (
|
||
<>
|
||
<section className="panel" style={{ padding: 20, marginBottom: 18 }}>
|
||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12 }}>
|
||
<div>
|
||
<div style={{ fontSize: 22, fontWeight: 700 }}>년도별 관리 사용금액</div>
|
||
<div className="subtle" style={{ marginTop: 6 }}>관리 5개 항목 기준으로 출금 금액을 연도별로 봅니다.</div>
|
||
</div>
|
||
{!!sortedManagementOverviewItems.length && (
|
||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||
<button
|
||
type="button"
|
||
className="button-muted"
|
||
onClick={() => setManagementYearWindowStart((prev) => Math.max(0, prev - 1))}
|
||
disabled={managementYearWindowStart <= 0}
|
||
aria-label="이전 연도 보기"
|
||
style={{ minWidth: 40, padding: "8px 10px" }}
|
||
>
|
||
←
|
||
</button>
|
||
<div className="subtle" style={{ minWidth: 120, textAlign: "center" }}>
|
||
{visibleManagementOverviewItems[0]?.year || "-"}년 ~ {visibleManagementOverviewItems[visibleManagementOverviewItems.length - 1]?.year || "-"}년
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="button-muted"
|
||
onClick={() =>
|
||
setManagementYearWindowStart((prev) =>
|
||
Math.min(Math.max(0, sortedManagementOverviewItems.length - MANAGEMENT_YEAR_WINDOW_SIZE), prev + 1)
|
||
)
|
||
}
|
||
disabled={managementYearWindowStart >= Math.max(0, sortedManagementOverviewItems.length - MANAGEMENT_YEAR_WINDOW_SIZE)}
|
||
aria-label="다음 연도 보기"
|
||
style={{ minWidth: 40, padding: "8px 10px" }}
|
||
>
|
||
→
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{!!visibleManagementProfitItems.length && (
|
||
<div
|
||
style={{
|
||
display: "grid",
|
||
gridTemplateColumns: `repeat(${visibleManagementProfitItems.length}, minmax(0, 1fr))`,
|
||
gap: 0,
|
||
marginTop: 14,
|
||
marginBottom: 14,
|
||
border: "1px solid rgba(233, 240, 247, 0.72)",
|
||
borderRadius: 16,
|
||
background: "rgba(255,255,255,0.56)",
|
||
overflow: "hidden",
|
||
}}
|
||
>
|
||
{visibleManagementProfitItems.map((yearItem, index) => (
|
||
<div
|
||
key={`profit-${yearItem.year}`}
|
||
style={{
|
||
padding: "10px 14px 12px",
|
||
borderLeft: index === 0 ? "none" : "1px solid rgba(233, 240, 247, 0.72)",
|
||
background: "transparent",
|
||
}}
|
||
>
|
||
<div className="subtle" style={{ fontSize: 12 }}>{yearItem.year}년 수익</div>
|
||
<div
|
||
style={{
|
||
marginTop: 6,
|
||
fontSize: 18,
|
||
fontWeight: 700,
|
||
color: (Number(yearItem.profit_supply || 0) || 0) < 0 ? "#d14343" : "var(--good)",
|
||
}}
|
||
>
|
||
{fmtEokManagement(yearItem.profit_supply || 0)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
<div
|
||
style={{
|
||
display: "grid",
|
||
gridTemplateColumns: `repeat(${Math.max(1, visibleManagementOverviewItems.length)}, minmax(0, 1fr))`,
|
||
gap: 0,
|
||
marginTop: 16,
|
||
border: "1px solid rgba(233, 240, 247, 0.72)",
|
||
borderRadius: 18,
|
||
background: "rgba(255,255,255,0.56)",
|
||
overflow: "hidden",
|
||
}}
|
||
>
|
||
{visibleManagementOverviewItems.map((yearItem) => (
|
||
<div
|
||
key={yearItem.year}
|
||
style={{
|
||
padding: 16,
|
||
minHeight: 430,
|
||
borderLeft: visibleManagementOverviewItems[0]?.year === yearItem.year ? "none" : "1px solid rgba(233, 240, 247, 0.72)",
|
||
background: "rgba(255,255,255,0.08)",
|
||
}}
|
||
>
|
||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 12 }}>
|
||
<div style={{ fontSize: 18, fontWeight: 700 }}>{yearItem.year}년</div>
|
||
<div style={{ fontSize: 22, fontWeight: 700 }}>{fmtEokManagement(yearItem.total_expense || 0)}</div>
|
||
</div>
|
||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8, marginTop: 8 }}>
|
||
<div style={{ padding: "8px 10px", border: "1px solid rgba(233, 240, 247, 0.9)", borderRadius: 10 }}>
|
||
<div className="subtle" style={{ fontSize: 11 }}>프로젝트 적용 관리비</div>
|
||
<div style={{ marginTop: 4, fontWeight: 700 }}>{fmtEokManagement(yearItem.project_applied_admin_expense || 0)}</div>
|
||
</div>
|
||
<div style={{ padding: "8px 10px", border: "1px solid rgba(233, 240, 247, 0.9)", borderRadius: 10 }}>
|
||
<div className="subtle" style={{ fontSize: 11 }}>공통관리비</div>
|
||
<div style={{ marginTop: 4, fontWeight: 700 }}>{fmtEokManagement(yearItem.common_admin_expense || 0)}</div>
|
||
</div>
|
||
</div>
|
||
<div style={{ display: "grid", gap: 8, marginTop: 14 }}>
|
||
{(yearItem.categories || []).map((category) => (
|
||
<button
|
||
key={`${yearItem.year}-${category.name}`}
|
||
type="button"
|
||
className="button-muted"
|
||
onClick={() => {
|
||
setSelectedManagementYear(yearItem.year);
|
||
setSelectedManagementCategory(category.name);
|
||
setSelectedManagementExcludedYear("");
|
||
}}
|
||
style={{
|
||
display: "flex",
|
||
justifyContent: "space-between",
|
||
gap: 12,
|
||
alignItems: "baseline",
|
||
width: "100%",
|
||
height: "auto",
|
||
minHeight: 40,
|
||
boxSizing: "border-box",
|
||
padding: "8px 10px",
|
||
borderColor:
|
||
selectedManagementYear === yearItem.year &&
|
||
selectedManagementCategory === category.name
|
||
? "#d8e4ef"
|
||
: "transparent",
|
||
color:
|
||
selectedManagementYear === yearItem.year &&
|
||
selectedManagementCategory === category.name
|
||
? "var(--blue-700)"
|
||
: "var(--text)",
|
||
background:
|
||
selectedManagementYear === yearItem.year &&
|
||
selectedManagementCategory === category.name
|
||
? "#f8fbfe"
|
||
: "transparent",
|
||
boxShadow:
|
||
selectedManagementYear === yearItem.year &&
|
||
selectedManagementCategory === category.name
|
||
? "inset 3px 0 0 var(--blue)"
|
||
: "none",
|
||
}}
|
||
>
|
||
<span className="subtle" style={{ color: "inherit" }}>{category.name}</span>
|
||
<strong>{fmtEokManagement(category.amount || 0)}</strong>
|
||
</button>
|
||
))}
|
||
<div
|
||
style={{
|
||
marginTop: 4,
|
||
paddingTop: 10,
|
||
borderTop: "1px dashed #dbe5ef",
|
||
}}
|
||
>
|
||
<button
|
||
type="button"
|
||
className="button-muted"
|
||
onClick={() => {
|
||
setSelectedManagementYear("");
|
||
setSelectedManagementCategory("");
|
||
setSelectedManagementExcludedYear(yearItem.year);
|
||
}}
|
||
style={{
|
||
display: "grid",
|
||
gridTemplateColumns: "1.35fr 1fr 1fr 1fr",
|
||
alignItems: "center",
|
||
columnGap: 12,
|
||
width: "100%",
|
||
height: "auto",
|
||
minHeight: 68,
|
||
boxSizing: "border-box",
|
||
padding: "8px 10px",
|
||
borderColor:
|
||
selectedManagementExcludedYear === yearItem.year
|
||
? "rgba(214, 67, 67, 0.25)"
|
||
: "transparent",
|
||
color:
|
||
selectedManagementExcludedYear === yearItem.year
|
||
? "#b23a3a"
|
||
: "var(--text)",
|
||
background:
|
||
selectedManagementExcludedYear === yearItem.year
|
||
? "rgba(214, 67, 67, 0.05)"
|
||
: "transparent",
|
||
boxShadow:
|
||
selectedManagementExcludedYear === yearItem.year
|
||
? "inset 3px 0 0 #d14343"
|
||
: "none",
|
||
}}
|
||
>
|
||
<div style={{ minWidth: 0, textAlign: "left" }}>
|
||
<div style={{ fontSize: 13, fontWeight: 700, color: "inherit", lineHeight: 1.2 }}>기타 수지/자산</div>
|
||
<div className="subtle" style={{ color: "inherit", opacity: 0.75, fontSize: 11, marginTop: 2 }}>집계 제외 계정</div>
|
||
</div>
|
||
<div style={{ textAlign: "center", minWidth: 0 }}>
|
||
<div className="subtle" style={{ color: "inherit", opacity: 0.75, fontSize: 11, marginBottom: 2 }}>출금</div>
|
||
<strong style={{ fontSize: 18, lineHeight: 1.1, fontWeight: 700 }}>
|
||
{fmtEokManagement(yearItem.excluded_expense_total || 0)}
|
||
</strong>
|
||
</div>
|
||
<div style={{ textAlign: "center", minWidth: 0 }}>
|
||
<div className="subtle" style={{ color: "inherit", opacity: 0.75, fontSize: 11, marginBottom: 2 }}>입금</div>
|
||
<strong style={{ fontSize: 18, lineHeight: 1.1, fontWeight: 700 }}>
|
||
{fmtEokManagement(yearItem.excluded_income_total || 0)}
|
||
</strong>
|
||
</div>
|
||
<div style={{ textAlign: "center", minWidth: 0 }}>
|
||
<div className="subtle" style={{ color: "inherit", opacity: 0.75, fontSize: 11, marginBottom: 2 }}>차액</div>
|
||
<strong style={{ fontSize: 18, lineHeight: 1.1 }}>
|
||
{fmtEokManagement((Number(yearItem.excluded_expense_total || 0) - Number(yearItem.excluded_income_total || 0)))}
|
||
</strong>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
{!managementOverviewLoading && !visibleManagementOverviewItems.length && (
|
||
<div className="empty">표시할 연도별 관리 금액이 없습니다.</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
{selectedManagementYear && selectedManagementCategory && (
|
||
<section className="panel" style={{ padding: 20 }} ref={managementCategorySectionRef}>
|
||
<div className="section-heading">
|
||
<div>
|
||
<h3>{selectedManagementYear}년 · {selectedManagementCategory}</h3>
|
||
<p>해당 항목에 포함된 계정별 지출 금액입니다.</p>
|
||
</div>
|
||
</div>
|
||
{managementOverviewAccountsLoading ? (
|
||
<div className="empty-state">계정별 금액을 불러오는 중입니다.</div>
|
||
) : managementOverviewAccounts.length ? (
|
||
<div style={{ display: "grid", gap: 10 }}>
|
||
{managementOverviewAccounts.map((item) => (
|
||
<button
|
||
key={`${item.account_code}-${item.account_name}`}
|
||
type="button"
|
||
className="mini-card"
|
||
onClick={() =>
|
||
{
|
||
setManagementAccountModalView("all");
|
||
setManagementAccountModal({
|
||
account_code: item.account_code || "",
|
||
account_name: item.account_name || "",
|
||
year: selectedManagementYear,
|
||
category: selectedManagementCategory,
|
||
date_from: `${selectedManagementYear}-01-01`,
|
||
date_to: `${selectedManagementYear}-12-31`,
|
||
detail: null,
|
||
});
|
||
}
|
||
}
|
||
style={{
|
||
padding: "12px 14px",
|
||
width: "100%",
|
||
textAlign: "left",
|
||
cursor: "pointer",
|
||
display: "grid",
|
||
gridTemplateColumns: "1.4fr auto auto",
|
||
alignItems: "center",
|
||
gap: 16,
|
||
}}
|
||
>
|
||
<div>
|
||
<div style={{ fontSize: 16, fontWeight: 700 }}>{item.account_name || "(계정명없음)"}</div>
|
||
<div className="subtle" style={{ marginTop: 3 }}>{item.account_code || "-"}</div>
|
||
</div>
|
||
<div className="subtle" style={{ textAlign: "right" }}>
|
||
거래 {fmt(item.transaction_count || 0)}건
|
||
</div>
|
||
<div style={{ fontSize: 18, fontWeight: 700, textAlign: "right" }}>{fmtEokManagement(item.expense_amount || 0)}</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="empty-state">표시할 계정이 없습니다.</div>
|
||
)}
|
||
</section>
|
||
)}
|
||
{selectedManagementExcludedItem && (
|
||
<section className="panel" style={{ padding: 20 }} ref={managementExcludedSectionRef}>
|
||
<div className="section-heading">
|
||
<div>
|
||
<h3>{selectedManagementExcludedItem.year}년 · 기타 수지/자산 합계</h3>
|
||
<p>집계 제외 계정 기준으로 분리한 계정별 금액입니다.</p>
|
||
</div>
|
||
</div>
|
||
{(selectedManagementExcludedItem.excluded_accounts || []).length ? (
|
||
<div style={{ display: "grid", gap: 10 }}>
|
||
{(selectedManagementExcludedItem.excluded_accounts || []).map((item) => (
|
||
<button
|
||
key={`${selectedManagementExcludedItem.year}-${item.account_code}`}
|
||
className="mini-card"
|
||
type="button"
|
||
onClick={() => {
|
||
setManagementAccountModalView("all");
|
||
setManagementAccountModal({
|
||
account_code: item.account_code || "",
|
||
account_name: item.account_name || "",
|
||
year: selectedManagementExcludedItem.year,
|
||
category: "기타 수지/자산 합계",
|
||
date_from: `${selectedManagementExcludedItem.year}-01-01`,
|
||
date_to: `${selectedManagementExcludedItem.year}-12-31`,
|
||
detail: null,
|
||
});
|
||
}}
|
||
style={{
|
||
padding: "12px 14px",
|
||
display: "grid",
|
||
gridTemplateColumns: "minmax(180px, 1.5fr) repeat(4, minmax(80px, 1fr))",
|
||
gap: 12,
|
||
alignItems: "center",
|
||
width: "100%",
|
||
textAlign: "left",
|
||
cursor: "pointer",
|
||
}}
|
||
>
|
||
<div>
|
||
<div style={{ fontWeight: 700 }}>{item.account_name || "-"}</div>
|
||
<div className="subtle">{item.account_code || "-"}</div>
|
||
</div>
|
||
<div>
|
||
<div className="subtle">입금</div>
|
||
<strong style={{ color: "var(--good)" }}>{fmtEokManagement(item.income_supply || 0)}</strong>
|
||
</div>
|
||
<div>
|
||
<div className="subtle">지출</div>
|
||
<strong>{fmtEokManagement(item.expense_supply || 0)}</strong>
|
||
</div>
|
||
<div>
|
||
<div className="subtle">차액</div>
|
||
<strong>{fmtEokManagement((Number(item.expense_supply || 0) - Number(item.income_supply || 0)))}</strong>
|
||
</div>
|
||
<div style={{ textAlign: "right" }}>
|
||
<div className="subtle">거래</div>
|
||
<strong>{fmt(item.txn_count || item.transaction_count || 0)}건</strong>
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="empty-state">표시할 제외 계정 금액이 없습니다.</div>
|
||
)}
|
||
</section>
|
||
)}
|
||
|
||
{managementYearDetailModalOpen && (
|
||
<div className="modal-backdrop" onClick={() => setManagementYearDetailModalOpen(false)}>
|
||
<div className="modal-panel modal-panel-wide" onClick={(e) => e.stopPropagation()}>
|
||
<div className="modal-head">
|
||
<div>
|
||
<div style={{ fontSize: 24, fontWeight: 700 }}>년도별 상세</div>
|
||
<div className="subtle" style={{ marginTop: 6 }}>
|
||
관리 5개 항목과 기타 수지/자산을 연도별 표로 비교합니다.
|
||
</div>
|
||
</div>
|
||
<button className="button-muted" onClick={() => setManagementYearDetailModalOpen(false)}>닫기</button>
|
||
</div>
|
||
{sortedManagementOverviewItems.length ? (
|
||
<div style={{ marginTop: 18, overflowX: "auto" }}>
|
||
<table style={{ width: "100%", borderCollapse: "separate", borderSpacing: 0 }}>
|
||
<thead>
|
||
<tr>
|
||
{[
|
||
"연도",
|
||
"총 지출",
|
||
"프로젝트 적용 관리비",
|
||
"공통관리비",
|
||
"일반운영비",
|
||
"법정,의무",
|
||
"외부전문,전략",
|
||
"안전관리비",
|
||
"인건비",
|
||
"기타 수지/자산",
|
||
].map((label, index) => (
|
||
<th
|
||
key={label}
|
||
style={{
|
||
textAlign: index === 0 ? "left" : "right",
|
||
padding: "12px 14px",
|
||
fontSize: 13,
|
||
fontWeight: 700,
|
||
color: "var(--subtle)",
|
||
borderBottom: "1px solid var(--line)",
|
||
background: "rgba(246, 249, 253, 0.82)",
|
||
position: "sticky",
|
||
top: 0,
|
||
zIndex: 1,
|
||
}}
|
||
>
|
||
{label}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{sortedManagementOverviewItems.map((yearItem) => {
|
||
const excludedDiff =
|
||
Number(yearItem.excluded_expense_total || 0) -
|
||
Number(yearItem.excluded_income_total || 0);
|
||
return (
|
||
<tr key={`management-year-detail-${yearItem.year}`}>
|
||
<td style={{ padding: "14px", borderBottom: "1px solid var(--line)", fontWeight: 700 }}>
|
||
{yearItem.year}년
|
||
</td>
|
||
<td style={{ padding: "14px", borderBottom: "1px solid var(--line)", textAlign: "right", fontWeight: 700 }}>
|
||
{fmtEokManagement(yearItem.total_expense || 0)}
|
||
</td>
|
||
<td style={{ padding: "14px", borderBottom: "1px solid var(--line)", textAlign: "right", fontWeight: 700 }}>
|
||
{fmtEokManagement(yearItem.project_applied_admin_expense || 0)}
|
||
</td>
|
||
<td style={{ padding: "14px", borderBottom: "1px solid var(--line)", textAlign: "right", fontWeight: 700 }}>
|
||
{fmtEokManagement(yearItem.common_admin_expense || 0)}
|
||
</td>
|
||
{[
|
||
"일반운영비",
|
||
"법정,의무",
|
||
"외부전문,전략",
|
||
"안전관리비",
|
||
"인건비",
|
||
].map((categoryName) => (
|
||
<td key={`${yearItem.year}-${categoryName}`} style={{ padding: "14px", borderBottom: "1px solid var(--line)", textAlign: "right" }}>
|
||
<button
|
||
type="button"
|
||
className="button-link"
|
||
onClick={() => {
|
||
setSelectedManagementYear(yearItem.year);
|
||
setSelectedManagementCategory(categoryName);
|
||
setSelectedManagementExcludedYear("");
|
||
setManagementYearDetailModalOpen(false);
|
||
}}
|
||
style={{ fontWeight: 700 }}
|
||
>
|
||
{fmtEokManagement(getManagementCategoryAmount(yearItem, categoryName))}
|
||
</button>
|
||
</td>
|
||
))}
|
||
<td style={{ padding: "14px", borderBottom: "1px solid var(--line)", textAlign: "right" }}>
|
||
<button
|
||
type="button"
|
||
className="button-link"
|
||
onClick={() => {
|
||
setSelectedManagementYear("");
|
||
setSelectedManagementCategory("");
|
||
setSelectedManagementExcludedYear(yearItem.year);
|
||
setManagementYearDetailModalOpen(false);
|
||
}}
|
||
style={{ fontWeight: 700, color: excludedDiff < 0 ? "#d14343" : "var(--text)" }}
|
||
>
|
||
{fmtEokManagement(excludedDiff)}
|
||
</button>
|
||
<div className="subtle" style={{ marginTop: 4, fontSize: 11 }}>
|
||
출금 {fmtEokManagement(yearItem.excluded_expense_total || 0)} / 입금 {fmtEokManagement(yearItem.excluded_income_total || 0)}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
) : (
|
||
<div className="empty-state">표시할 연도별 관리 금액이 없습니다.</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{managementAccountModal && (
|
||
<div className="modal-backdrop" onClick={() => setManagementAccountModal(null)}>
|
||
<div className="modal-panel modal-panel-wide" onClick={(e) => e.stopPropagation()}>
|
||
<div className="modal-head">
|
||
<div>
|
||
<div style={{ fontSize: 24, fontWeight: 700 }}>
|
||
{managementAccountModal.account_name || "(계정명없음)"}
|
||
</div>
|
||
<div className="subtle" style={{ marginTop: 6 }}>
|
||
{managementAccountModal.account_code || "-"} · {managementAccountModal.year}년 · {managementAccountModal.category}
|
||
</div>
|
||
</div>
|
||
<button className="button-muted" onClick={() => {
|
||
setManagementAccountModal(null);
|
||
setManagementAccountModalView("all");
|
||
}}>닫기</button>
|
||
</div>
|
||
{managementAccountModalLoading ? (
|
||
<div className="empty-state">계정 상세를 불러오는 중입니다.</div>
|
||
) : managementAccountModal.detail?.summary ? (
|
||
<>
|
||
<div style={{ display: "flex", gap: 8, marginTop: 16, marginBottom: 2, flexWrap: "wrap" }}>
|
||
<button
|
||
type="button"
|
||
className="button-muted"
|
||
onClick={() => setManagementAccountModalView("all")}
|
||
style={managementAccountModalView === "all" ? { borderColor: "var(--blue-700)", color: "var(--blue-700)", background: "rgba(45, 106, 176, 0.08)" } : {}}
|
||
>
|
||
전체
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="button-muted"
|
||
onClick={() => setManagementAccountModalView("income")}
|
||
style={managementAccountModalView === "income" ? { borderColor: "var(--blue-700)", color: "var(--blue-700)", background: "rgba(45, 106, 176, 0.08)" } : {}}
|
||
>
|
||
입금
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="button-muted"
|
||
onClick={() => setManagementAccountModalView("expense")}
|
||
style={managementAccountModalView === "expense" ? { borderColor: "var(--blue-700)", color: "var(--blue-700)", background: "rgba(45, 106, 176, 0.08)" } : {}}
|
||
>
|
||
지출
|
||
</button>
|
||
</div>
|
||
<div className="summary-grid" style={{ marginTop: 18 }}>
|
||
<div className="summary-item">
|
||
<span>지출 합계</span>
|
||
<strong>{fmt(filteredManagementAccountSummary.expense_supply_sum || 0)}원</strong>
|
||
</div>
|
||
<div className="summary-item">
|
||
<span>입금 합계</span>
|
||
<strong>{fmt(filteredManagementAccountSummary.income_supply_sum || 0)}원</strong>
|
||
</div>
|
||
<div className="summary-item">
|
||
<span>거래 건수</span>
|
||
<strong>{fmt(filteredManagementAccountSummary.txn_count || 0)}건</strong>
|
||
</div>
|
||
<div className="summary-item">
|
||
<span>기간</span>
|
||
<strong>
|
||
{(filteredManagementAccountSummary.min_date || "-")}
|
||
{" ~ "}
|
||
{(filteredManagementAccountSummary.max_date || "-")}
|
||
</strong>
|
||
</div>
|
||
</div>
|
||
<section className="panel" style={{ marginTop: 16, padding: 16 }}>
|
||
<div className="section-heading">
|
||
<div>
|
||
<h3>거래내역</h3>
|
||
<p>해당 계정에 포함된 거래를 날짜와 프로젝트 기준으로 확인합니다.</p>
|
||
</div>
|
||
</div>
|
||
{filteredManagementAccountTransactions.length ? (
|
||
<div className="table-wrap">
|
||
<table className="data-table">
|
||
<thead>
|
||
<tr>
|
||
<th>거래일</th>
|
||
<th>프로젝트</th>
|
||
<th>거래처</th>
|
||
<th>적요</th>
|
||
<th>입/출금</th>
|
||
<th>공급가액</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{filteredManagementAccountTransactions.map((row, index) => (
|
||
<tr key={`${row.transaction_date}-${row.project_code}-${index}`}>
|
||
<td>{row.transaction_date || "-"}</td>
|
||
<td>
|
||
<div>{row.project_name || "프로젝트명 없음"}</div>
|
||
<div className="table-sub">{row.project_code || "-"}</div>
|
||
</td>
|
||
<td>{row.vendor_name || "-"}</td>
|
||
<td>{row.description || "-"}</td>
|
||
<td>{row.in_out || "-"}</td>
|
||
<td>{fmt(row.supply_amount || 0)}원</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
) : (
|
||
<div className="empty-state">표시할 거래내역이 없습니다.</div>
|
||
)}
|
||
</section>
|
||
</>
|
||
) : (
|
||
<div className="empty-state">계정 상세를 불러오지 못했습니다.</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
</>
|
||
)}
|
||
|
||
{lifecycleAccountDetailModal && (
|
||
<div className="modal-backdrop" data-testid="lifecycle-account-detail-modal" onClick={() => setLifecycleAccountDetailModal(null)}>
|
||
<div
|
||
className="modal-panel modal-panel-wide"
|
||
onClick={(e) => e.stopPropagation()}
|
||
style={{ width: "min(1080px, calc(100vw - 32px))", maxHeight: "min(760px, calc(100vh - 32px))", overflow: "auto" }}
|
||
>
|
||
<div style={{ display: "flex", justifyContent: "space-between", gap: 16, alignItems: "flex-start" }}>
|
||
<div>
|
||
<div style={{ fontSize: 18, fontWeight: 700 }}>
|
||
{lifecycleAccountDetailModal.account_name || lifecycleAccountDetailModal.account_code || "(계정명없음)"}
|
||
</div>
|
||
<div className="subtle" style={{ marginTop: 6 }}>
|
||
{lifecycleAccountDetailModal.account_code || "-"} · {lifecycleAccountDetailModal.bucket_label}
|
||
</div>
|
||
</div>
|
||
<button className="button-muted" onClick={() => setLifecycleAccountDetailModal(null)}>닫기</button>
|
||
</div>
|
||
{lifecycleAccountDetailModalLoading ? (
|
||
<div className="empty-state">계정 상세를 불러오는 중입니다.</div>
|
||
) : lifecycleAccountDetailModal.detail?.summary ? (
|
||
<>
|
||
{(() => {
|
||
const summaryAmount = Number(lifecycleAccountDetailModal.detail?.summary?.expense_supply_sum || 0);
|
||
const selectedAmount = Number(lifecycleAccountDetailModal.allocation_result_amount || 0);
|
||
const effectiveAccountAmount = selectedAmount > 0 ? selectedAmount : summaryAmount;
|
||
const effectiveTxnCount = Number(lifecycleAccountDetailModal.detail?.summary?.txn_count || 0);
|
||
return (
|
||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: 12, marginTop: 16 }}>
|
||
<div className="mini-card">
|
||
<div className="subtle">지출 합계</div>
|
||
<div className="summary-value" style={{ marginTop: 6 }}>
|
||
{fmt(effectiveAccountAmount)}원
|
||
</div>
|
||
</div>
|
||
<div className="mini-card">
|
||
<div className="subtle">거래 건수</div>
|
||
<div className="summary-value" style={{ marginTop: 6 }}>{fmt(effectiveTxnCount)}건</div>
|
||
</div>
|
||
<div className="mini-card">
|
||
<div className="subtle">기간</div>
|
||
<div className="summary-value" style={{ marginTop: 6, fontSize: 15 }}>
|
||
{(lifecycleAccountDetailModal.detail.summary.min_date || "-")}
|
||
{" ~ "}
|
||
{(lifecycleAccountDetailModal.detail.summary.max_date || "-")}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
<div style={{ display: "grid", gap: 18, marginTop: 18 }}>
|
||
{Boolean(lifecycleAccountDetailModal.show_allocation_formula) && (
|
||
<section className="mini-card">
|
||
<div style={{ fontSize: 16, fontWeight: 700 }}>배부 계산식</div>
|
||
<div className="subtle" style={{ marginTop: 6 }}>
|
||
본사관리비 배부원천 x (프로젝트 기준값 / 전체 기준값)
|
||
</div>
|
||
<div className="subtle" style={{ marginTop: 4 }}>
|
||
기준값: {(lifecycleAccountDetailModal.detail?.allocation_mode || lifecycleAccountDetailModal.allocation_mode) === "income_ratio"
|
||
? "프로젝트 입금 / 전체입금"
|
||
: "프로젝트 지출 / 전체지출"}
|
||
</div>
|
||
<div className="subtle" style={{ marginTop: 8, lineHeight: 1.55 }}>
|
||
{(() => {
|
||
const rows = (Array.isArray(lifecycleAccountDetailModal.detail?.allocation_details) && lifecycleAccountDetailModal.detail.allocation_details.length)
|
||
? lifecycleAccountDetailModal.detail.allocation_details
|
||
: (Array.isArray(lifecycleAccountDetailModal.allocation_details) ? lifecycleAccountDetailModal.allocation_details : []);
|
||
const fallback = [{
|
||
year_month: "",
|
||
source_amount: lifecycleAccountDetailModal.detail?.allocation_source_amount ?? lifecycleAccountDetailModal.allocation_source_amount ?? 0,
|
||
project_basis_amount: lifecycleAccountDetailModal.detail?.allocation_project_basis_amount ?? lifecycleAccountDetailModal.allocation_project_basis_amount ?? 0,
|
||
total_basis_amount: lifecycleAccountDetailModal.detail?.allocation_total_basis_amount ?? lifecycleAccountDetailModal.allocation_total_basis_amount ?? 0,
|
||
allocated_amount: lifecycleAccountDetailModal.allocation_result_amount ?? 0,
|
||
}];
|
||
const list = rows.length ? rows : fallback;
|
||
if (!list.length || (list.length === 1 && Number(list[0].source_amount || 0) === 0 && Number(list[0].allocated_amount || 0) === 0)) {
|
||
return <div key="alloc-empty">해당 계정의 배부 원천 금액이 없어 계산식이 없습니다.</div>;
|
||
}
|
||
return list.map((row, idx) => {
|
||
const sourceAmount = Number(row.source_amount || 0);
|
||
const allocatedAmount = Number(row.allocated_amount || 0);
|
||
const projectBasisAmount = Number(row.display_project_basis_amount ?? row.project_basis_amount ?? 0);
|
||
const totalBasisAmount = Number(row.display_total_basis_amount ?? row.total_basis_amount ?? 0);
|
||
return (
|
||
<div key={`alloc-restore-${idx}`}>
|
||
{((row.year_month || "").slice(0, 4) || "-")}년 · {fmt(sourceAmount)}원 x ({fmt(projectBasisAmount)}원 / {fmt(totalBasisAmount)}원) = {fmt(allocatedAmount)}원
|
||
</div>
|
||
);
|
||
});
|
||
})()}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
<section className="mini-card">
|
||
<div style={{ fontSize: 16, fontWeight: 700 }}>프로젝트별 금액</div>
|
||
<div className="subtle" style={{ marginTop: 6 }}>연결된 프로젝트들 중 이 계정에 포함된 지출입니다.</div>
|
||
{(lifecycleAccountDetailModal.detail.projects || []).length ? (
|
||
<div style={{ display: "grid", gap: 10, marginTop: 12 }}>
|
||
{(lifecycleAccountDetailModal.detail.projects || []).map((item) => (
|
||
<button
|
||
key={`lifecycle-account-project-${item.project_code}`}
|
||
type="button"
|
||
onClick={() => {
|
||
setSelectedProjectCode(item.project_code);
|
||
setLifecycleAccountDetailModal(null);
|
||
}}
|
||
style={{
|
||
textAlign: "left",
|
||
border: "1px solid var(--line)",
|
||
borderRadius: 14,
|
||
background: "white",
|
||
padding: "12px 14px",
|
||
display: "grid",
|
||
gridTemplateColumns: "minmax(220px, 1fr) minmax(140px, 0.4fr)",
|
||
gap: 12,
|
||
alignItems: "center",
|
||
cursor: "pointer",
|
||
}}
|
||
>
|
||
<div>
|
||
<div style={{ fontWeight: 700 }}>{item.project_name || "(이름없음)"}</div>
|
||
<div className="subtle" style={{ marginTop: 4 }}>
|
||
{item.project_code || "-"} · {item.project_type || "미지정"}
|
||
</div>
|
||
</div>
|
||
<div style={{ textAlign: "right", fontWeight: 700 }}>{fmt(item.expense_supply_sum || 0)}원</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="empty-state">표시할 프로젝트가 없습니다.</div>
|
||
)}
|
||
</section>
|
||
|
||
<section className="mini-card">
|
||
<div style={{ fontSize: 16, fontWeight: 700 }}>거래내역</div>
|
||
<div className="subtle" style={{ marginTop: 6 }}>이 계정에 포함된 출금 거래를 확인합니다.</div>
|
||
{(lifecycleAccountDetailModal.detail.transactions || []).length ? (
|
||
<div className="table-wrap" style={{ marginTop: 12 }}>
|
||
<table className="data-table">
|
||
<thead>
|
||
<tr>
|
||
<th>거래일</th>
|
||
<th>프로젝트</th>
|
||
<th>거래처</th>
|
||
<th>적요</th>
|
||
<th>공급가액</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{(lifecycleAccountDetailModal.detail.transactions || []).map((row, index) => (
|
||
<tr key={`lifecycle-account-transaction-${index}`}>
|
||
<td>{row.transaction_date || "-"}</td>
|
||
<td>
|
||
<div>{row.project_name || "프로젝트명 없음"}</div>
|
||
<div className="table-sub">{row.project_code || "-"}</div>
|
||
</td>
|
||
<td>{row.vendor_name || "-"}</td>
|
||
<td>{row.description || "-"}</td>
|
||
<td>{fmt(row.allocated_supply_amount ?? row.supply_amount ?? 0)}원</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
) : (
|
||
<div className="empty-state">표시할 거래내역이 없습니다.</div>
|
||
)}
|
||
</section>
|
||
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className="empty-state">계정 상세를 불러오지 못했습니다.</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{lifecycleProjectTotalModal && (
|
||
<div className="modal-backdrop" onClick={() => setLifecycleProjectTotalModal(null)}>
|
||
<div
|
||
className="modal-panel modal-panel-wide"
|
||
onClick={(e) => e.stopPropagation()}
|
||
style={{ width: "min(980px, calc(100vw - 32px))", maxHeight: "min(760px, calc(100vh - 32px))", overflow: "auto" }}
|
||
>
|
||
<div style={{ display: "flex", justifyContent: "space-between", gap: 16, alignItems: "flex-start" }}>
|
||
<div>
|
||
<div style={{ fontSize: 20, fontWeight: 700 }}>{lifecycleProjectTotalModal.label || "전체"} 프로젝트별 상세</div>
|
||
<div className="subtle" style={{ marginTop: 6 }}>연결 프로젝트 기준 공통배분분 내역입니다.</div>
|
||
</div>
|
||
<button className="button-muted" onClick={() => { setLifecycleProjectTotalModal(null); setLifecycleProjectTotalGroup("공통배분분"); }}>닫기</button>
|
||
</div>
|
||
<div className="mini-card" style={{ marginTop: 14 }}>
|
||
<div className="subtle">합계 금액</div>
|
||
<div className="summary-value" style={{ marginTop: 6 }}>{fmt(lifecycleProjectTotalModal.total_amount || 0)}원</div>
|
||
<div className="subtle" style={{ marginTop: 6 }}>
|
||
직접분 {fmt(lifecycleProjectTotalModal.direct_total || 0)}원 · 공통배분분 {fmt(lifecycleProjectTotalModal.shared_total || 0)}원
|
||
</div>
|
||
</div>
|
||
<section className="mini-card" style={{ marginTop: 12 }}>
|
||
<div style={{ fontSize: 16, fontWeight: 700 }}>배부 계산식</div>
|
||
<div className="subtle" style={{ marginTop: 6 }}>본사관리비 배부원천 x (프로젝트 기준값 / 전체 기준값)</div>
|
||
<div className="subtle" style={{ marginTop: 2 }}>
|
||
기준값: {(lifecycleProjectTotalModal.allocation_mode || "") === "income_ratio" ? "프로젝트 입금 / 전체입금" : "프로젝트 지출 / 전체지출"}
|
||
</div>
|
||
<div className="subtle" style={{ marginTop: 8, lineHeight: 1.55 }}>
|
||
{(Array.isArray(lifecycleProjectTotalModal.allocation_details) && lifecycleProjectTotalModal.allocation_details.length
|
||
? lifecycleProjectTotalModal.allocation_details
|
||
: []
|
||
).map((row, idx) => {
|
||
const sourceAmount = Number(row.source_amount || 0);
|
||
const allocatedAmount = Number(row.allocated_amount || 0);
|
||
const projectBasisAmount = Number(row.display_project_basis_amount ?? row.project_basis_amount ?? 0);
|
||
const totalBasisAmount = Number(row.display_total_basis_amount ?? row.total_basis_amount ?? 0);
|
||
return (
|
||
<div key={`lifecycle-total-alloc-${idx}`}>
|
||
{((row.year_month || "").slice(0, 4) || "-")}년 · {fmt(sourceAmount)}원 x ({fmt(projectBasisAmount)}원 / {fmt(totalBasisAmount)}원) = {fmt(allocatedAmount)}원
|
||
</div>
|
||
);
|
||
})}
|
||
{!((lifecycleProjectTotalModal.allocation_details || []).length) && (
|
||
<div>표시할 배부 계산식이 없습니다.</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
<div style={{ display: "grid", gap: 10, marginTop: 14 }}>
|
||
{(lifecycleProjectTotalModal.projects || []).length ? (
|
||
(lifecycleProjectTotalModal.projects || []).map((item) => (
|
||
<div
|
||
key={`lifecycle-total-project-${item.project_code}`}
|
||
style={{
|
||
textAlign: "left",
|
||
border: "1px solid var(--line)",
|
||
borderRadius: 14,
|
||
background: "white",
|
||
padding: "12px 14px",
|
||
display: "grid",
|
||
gridTemplateColumns: "minmax(220px, 1fr) minmax(160px, 0.45fr)",
|
||
gap: 12,
|
||
alignItems: "center",
|
||
}}
|
||
>
|
||
<div>
|
||
<div style={{ fontWeight: 700 }}>{item.project_name || "(이름없음)"}</div>
|
||
<div className="subtle" style={{ marginTop: 4 }}>
|
||
{item.project_code || "-"} · {item.project_type || "미지정"} · {item.construction_family || "종류미지정"} · {item.construction_method || "공법미지정"}
|
||
</div>
|
||
</div>
|
||
<div style={{ textAlign: "right", fontWeight: 700 }}>
|
||
<div>{fmt(item.expense_supply || 0)}원</div>
|
||
<div className="subtle" style={{ marginTop: 4 }}>
|
||
직접분 {fmt(item.direct_expense_supply || 0)}원 · 공통배분분 {fmt(item.shared_expense_supply || 0)}원
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))
|
||
) : (
|
||
<div className="empty-state">표시할 공통배분 프로젝트가 없습니다.</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{lifecycleAllocationModal && (
|
||
<div className="modal-backdrop" onClick={() => setLifecycleAllocationModal(null)}>
|
||
<div className="modal-panel" onClick={(e) => e.stopPropagation()} style={{ width: "min(560px, calc(100vw - 24px))" }}>
|
||
<div className="modal-head">
|
||
<div>
|
||
<div style={{ fontSize: 22, fontWeight: 700 }}>생애주기 배분 비율</div>
|
||
<div className="subtle" style={{ marginTop: 6 }}>
|
||
{lifecycleAllocationModal.project_type || "-"} · {lifecycleAllocationModal.source_project_code || "-"}
|
||
</div>
|
||
</div>
|
||
<button className="button-muted" onClick={() => setLifecycleAllocationModal(null)}>닫기</button>
|
||
</div>
|
||
<div className="mini-card" style={{ marginTop: 14 }}>
|
||
<div style={{ fontSize: 15, fontWeight: 700 }}>{lifecycleAllocationModal.project_name || "(이름없음)"}</div>
|
||
<div className="subtle" style={{ marginTop: 6 }}>
|
||
해당 프로젝트에 반영할 비율을 입력합니다. 예: 1 / 3
|
||
</div>
|
||
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr) 30px minmax(0, 1fr)", gap: 10, marginTop: 14, alignItems: "end" }}>
|
||
<label style={{ display: "grid", gap: 6 }}>
|
||
<div className="subtle">해당프로젝트 수</div>
|
||
<input
|
||
className="field"
|
||
type="number"
|
||
min="0"
|
||
value={lifecycleAllocationModal.allocation_numerator}
|
||
onChange={(e) => setLifecycleAllocationModal((prev) => (
|
||
prev ? { ...prev, allocation_numerator: e.target.value } : prev
|
||
))}
|
||
/>
|
||
</label>
|
||
<div style={{ textAlign: "center", paddingBottom: 10, fontSize: 20, fontWeight: 700 }}>/</div>
|
||
<label style={{ display: "grid", gap: 6 }}>
|
||
<div className="subtle">총프로젝트 수</div>
|
||
<input
|
||
className="field"
|
||
type="number"
|
||
min="1"
|
||
value={lifecycleAllocationModal.allocation_denominator}
|
||
onChange={(e) => setLifecycleAllocationModal((prev) => (
|
||
prev ? { ...prev, allocation_denominator: e.target.value } : prev
|
||
))}
|
||
/>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div className="modal-actions">
|
||
<button className="button-muted" onClick={() => setLifecycleAllocationModal(null)}>취소</button>
|
||
{Number(lifecycleAllocationModal?.allocation_numerator ?? 1) !== 1 || Number(lifecycleAllocationModal?.allocation_denominator ?? 1) !== 1 ? (
|
||
<button className="button-muted" onClick={deleteLifecycleAllocation} disabled={lifecycleAllocationSaving}>
|
||
{lifecycleAllocationSaving ? "삭제 중..." : "삭제"}
|
||
</button>
|
||
) : null}
|
||
<button className="button-primary" onClick={saveLifecycleAllocation} disabled={lifecycleAllocationSaving}>
|
||
{lifecycleAllocationSaving ? "저장 중..." : "배분 저장"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{companyAccountModal && (
|
||
<div className="modal-backdrop" onClick={() => setCompanyAccountModal(null)}>
|
||
<div
|
||
className="modal-panel modal-panel-wide"
|
||
onClick={(e) => e.stopPropagation()}
|
||
style={{
|
||
width: "min(1320px, calc(100vw - 24px))",
|
||
height: "min(760px, calc(100vh - 48px))",
|
||
overflow: "auto",
|
||
}}
|
||
>
|
||
<div className="modal-head">
|
||
<div>
|
||
<div style={{ fontSize: 24, fontWeight: 700 }}>
|
||
{companyAccountModal.year}년 · {companyAccountModal.project_type}
|
||
</div>
|
||
<div className="subtle" style={{ marginTop: 6 }}>계정별 금액 구성</div>
|
||
</div>
|
||
<button className="button-muted" onClick={() => setCompanyAccountModal(null)}>닫기</button>
|
||
</div>
|
||
<div className="dashboard-band-chip-row" style={{ marginTop: 16 }}>
|
||
{[
|
||
{ key: "all", label: "전체" },
|
||
{ key: "income", label: "입금" },
|
||
{ key: "expense", label: "지출" },
|
||
].map((option) => (
|
||
<button
|
||
key={option.key}
|
||
type="button"
|
||
className={`company-filter-chip ${companyAccountModalView === option.key ? "active" : ""}`}
|
||
onClick={() => setCompanyAccountModalView(option.key)}
|
||
>
|
||
{option.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{companyAccountModalLoading ? (
|
||
<div className="empty-state">계정 금액을 불러오는 중입니다.</div>
|
||
) : (companyAccountModal.items || []).filter((item) => {
|
||
if (companyAccountModalView === "income") return Number(item.income_supply || 0) > 0;
|
||
if (companyAccountModalView === "expense") return Number(item.expense_supply || 0) > 0;
|
||
return true;
|
||
}).length ? (
|
||
<div style={{ display: "grid", gap: 10, marginTop: 16 }}>
|
||
{(companyAccountModal.items || [])
|
||
.filter((item) => {
|
||
if (companyAccountModalView === "income") return Number(item.income_supply || 0) > 0;
|
||
if (companyAccountModalView === "expense") return Number(item.expense_supply || 0) > 0;
|
||
return true;
|
||
})
|
||
.map((item) => (
|
||
<button
|
||
key={`company-account-${item.account_code}`}
|
||
type="button"
|
||
className="mini-card"
|
||
onClick={() =>
|
||
setCompanyAccountDetailModal({
|
||
year: companyAccountModal.year,
|
||
project_type: companyAccountModal.project_type,
|
||
account_code: item.account_code || "",
|
||
account_name: item.account_name || "",
|
||
detail: null,
|
||
})
|
||
}
|
||
style={{ padding: "10px 14px", width: "100%", textAlign: "left", cursor: "pointer" }}
|
||
title={`${item.account_name || item.account_code} 상세 보기`}
|
||
>
|
||
<div style={{ display: "grid", gridTemplateColumns: "minmax(240px, 1.4fr) repeat(4, minmax(96px, 0.6fr))", gap: 12, alignItems: "center" }}>
|
||
<div>
|
||
<div style={{ fontSize: 16, fontWeight: 700 }}>{item.account_name || item.account_code}</div>
|
||
<div className="subtle" style={{ marginTop: 4 }}>{item.account_code || "-"}</div>
|
||
</div>
|
||
<div>
|
||
<div className="subtle">입금</div>
|
||
<div style={{ marginTop: 2, fontWeight: 700, color: "var(--good)" }}>{fmtEokManagement(item.income_supply || 0)}</div>
|
||
</div>
|
||
<div>
|
||
<div className="subtle">지출</div>
|
||
<div style={{ marginTop: 2, fontWeight: 700 }}>{fmtEokManagement(item.expense_supply || 0)}</div>
|
||
</div>
|
||
<div>
|
||
<div className="subtle">합계</div>
|
||
<div style={{ marginTop: 2, fontWeight: 700 }}>{fmtEokManagement(Number(item.income_supply || 0) + Number(item.expense_supply || 0))}</div>
|
||
</div>
|
||
<div style={{ textAlign: "right" }}>
|
||
<div className="subtle">거래</div>
|
||
<div style={{ marginTop: 2, fontWeight: 700 }}>{fmt(item.txn_count || 0)}건</div>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div
|
||
className="empty-state"
|
||
style={{
|
||
minHeight: 220,
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
marginTop: 16,
|
||
textAlign: "center",
|
||
}}
|
||
>
|
||
표시할 {companyAccountModalView === "income" ? "입금" : companyAccountModalView === "expense" ? "지출" : "계정"} 금액이 없습니다.
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{companyGraphModalOpen && (
|
||
<div className="modal-backdrop" onClick={() => setCompanyGraphModalOpen(false)}>
|
||
<div
|
||
className="modal-panel modal-panel-wide"
|
||
onClick={(e) => e.stopPropagation()}
|
||
style={{
|
||
width: "min(1240px, calc(100vw - 24px))",
|
||
height: "min(820px, calc(100vh - 48px))",
|
||
overflow: "auto",
|
||
}}
|
||
>
|
||
<div className="modal-head">
|
||
<div>
|
||
<div style={{ fontSize: 24, fontWeight: 700 }}>연도별 입금 대비 지출 그래프</div>
|
||
<div className="subtle" style={{ marginTop: 6 }}>
|
||
연도를 가로축으로 두고, 입금은 선으로, 지출은 프로젝트 구분별 세로 누적 막대로 봅니다. 관리는 아래, 시공은 위에 고정됩니다.
|
||
</div>
|
||
</div>
|
||
<button className="button-muted" onClick={() => setCompanyGraphModalOpen(false)}>닫기</button>
|
||
</div>
|
||
{!companyGraphRows.length ? (
|
||
<div className="empty-state">표시할 연도별 그래프 데이터가 없습니다.</div>
|
||
) : (
|
||
<>
|
||
<div className="mini-card" style={{ marginTop: 16, padding: 18 }}>
|
||
<svg
|
||
width="100%"
|
||
viewBox="0 0 1080 420"
|
||
role="img"
|
||
aria-label="연도별 입금 대비 지출 그래프"
|
||
>
|
||
<defs>
|
||
<filter id="companyLineShadow" x="-20%" y="-20%" width="140%" height="140%">
|
||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodColor="rgba(30,94,149,0.18)" />
|
||
</filter>
|
||
</defs>
|
||
<g>
|
||
<line x1="90" y1="320" x2="980" y2="320" stroke="#d7e4f0" strokeWidth="1.5" />
|
||
{[0.25, 0.5, 0.75, 1].map((ratio) => {
|
||
const y = companyGraphLayout.baseY - ratio * companyGraphLayout.chartHeight;
|
||
return (
|
||
<g key={`company-grid-${ratio}`}>
|
||
<line x1={companyGraphLayout.chartLeft} y1={y} x2={companyGraphLayout.chartRight} y2={y} stroke="#eef4fa" strokeWidth="1" />
|
||
<text x="24" y={y + 4} style={{ fontSize: 12, fill: "#90a0b4" }}>
|
||
{fmtEokManagement(companyGraphMaxValue * ratio)}
|
||
</text>
|
||
</g>
|
||
);
|
||
})}
|
||
{companyGraphRows.map((row, index) => {
|
||
const centerX = companyGraphLayout.centerStartX + index * companyGraphLayout.stepX;
|
||
const barWidth = companyGraphLayout.barWidth;
|
||
const stackBaseY = companyGraphLayout.baseY;
|
||
const chartHeight = companyGraphLayout.chartHeight;
|
||
const stackItems = [...row.typeItems].sort((a, b) => {
|
||
const stackOrder = { "관리": 0, "시공": 999 };
|
||
const aFixed = Object.prototype.hasOwnProperty.call(stackOrder, a.project_type || "") ? stackOrder[a.project_type] : 100;
|
||
const bFixed = Object.prototype.hasOwnProperty.call(stackOrder, b.project_type || "") ? stackOrder[b.project_type] : 100;
|
||
if (aFixed !== bFixed) return aFixed - bFixed;
|
||
const aWeight = Number(a.expense_supply || 0);
|
||
const bWeight = Number(b.expense_supply || 0);
|
||
if (bWeight !== aWeight) return bWeight - aWeight;
|
||
return (a.project_type || "").localeCompare(b.project_type || "", "ko");
|
||
});
|
||
let cursorY = stackBaseY;
|
||
return (
|
||
<g key={`company-graph-row-${row.year}`}>
|
||
{stackItems.map((typeItem, itemIndex) => {
|
||
const height = (Number(typeItem.expense_supply || 0) / companyGraphMaxValue) * chartHeight;
|
||
if (height <= 0) return null;
|
||
const visibleHeight = Math.max(height, 2);
|
||
cursorY -= visibleHeight;
|
||
return (
|
||
<rect
|
||
key={`bar-${row.year}-${typeItem.project_type}`}
|
||
x={centerX - barWidth / 2}
|
||
y={cursorY}
|
||
width={barWidth}
|
||
height={visibleHeight}
|
||
fill={getCompanyTypeColor(typeItem.project_type, itemIndex)}
|
||
>
|
||
<title>{`${row.year}년 ${typeItem.project_type} 지출 ${fmtEokManagement(typeItem.expense_supply || 0)}`}</title>
|
||
</rect>
|
||
);
|
||
})}
|
||
<text x={centerX} y="354" textAnchor="middle" style={{ fontSize: 17, fontWeight: 700, fill: "#243447" }}>
|
||
{row.year}
|
||
</text>
|
||
<text x={centerX} y="384" textAnchor="middle" style={{ fontSize: 10, fontWeight: 700, fill: "#90a0b4" }}>
|
||
지출
|
||
</text>
|
||
<text x={centerX} y="398" textAnchor="middle" style={{ fontSize: 11, fill: "#6b7a90" }}>
|
||
{fmtEokManagement(row.expense_supply || 0)}
|
||
</text>
|
||
</g>
|
||
);
|
||
})}
|
||
<polyline
|
||
points={companyGraphLinePoints}
|
||
fill="none"
|
||
stroke="#143f67"
|
||
strokeWidth="4"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
filter="url(#companyLineShadow)"
|
||
/>
|
||
{companyGraphRows.map((row, index) => {
|
||
const x = companyGraphLayout.centerStartX + index * companyGraphLayout.stepX;
|
||
const y = companyGraphLayout.baseY - (Number(row.income_supply || 0) / companyGraphMaxValue) * companyGraphLayout.chartHeight;
|
||
return (
|
||
<g key={`point-${row.year}`}>
|
||
<circle cx={x} cy={y} r="7" fill="#143f67" />
|
||
<circle cx={x} cy={y} r="3" fill="#ffffff" />
|
||
<text
|
||
x={x}
|
||
y={y - 14}
|
||
textAnchor="middle"
|
||
style={{
|
||
fontSize: 12,
|
||
fontWeight: 700,
|
||
fill: "#143f67",
|
||
stroke: "#ffffff",
|
||
strokeWidth: 4,
|
||
paintOrder: "stroke",
|
||
strokeLinejoin: "round",
|
||
}}
|
||
>
|
||
{fmtEokManagement(row.income_supply || 0)}
|
||
</text>
|
||
</g>
|
||
);
|
||
})}
|
||
</g>
|
||
</svg>
|
||
</div>
|
||
<div style={{ display: "flex", gap: 14, flexWrap: "wrap", marginTop: 14 }}>
|
||
<div className="dashboard-band-chip" style={{ background: "rgba(20,63,103,0.08)" }}>
|
||
<span className="dashboard-band-dot" style={{ background: "#143f67" }} />
|
||
입금 선그래프
|
||
</div>
|
||
{(companyOverview.project_type_order || [])
|
||
.filter((projectType) => companyGraphRows.some((row) => row.typeItems.some((item) => item.project_type === projectType && Number(item.expense_supply || 0) > 0)))
|
||
.map((projectType, index) => (
|
||
<div key={`company-graph-legend-${projectType}`} className="dashboard-band-chip">
|
||
<span className="dashboard-band-dot" style={{ background: getCompanyTypeColor(projectType, index) }} />
|
||
{projectType} 지출
|
||
</div>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{companyAccountDetailModal && (
|
||
<div className="modal-backdrop" onClick={() => setCompanyAccountDetailModal(null)}>
|
||
<div
|
||
className="modal-panel modal-panel-wide"
|
||
onClick={(e) => e.stopPropagation()}
|
||
style={{
|
||
width: "min(1320px, calc(100vw - 24px))",
|
||
height: "min(760px, calc(100vh - 48px))",
|
||
overflow: "auto",
|
||
}}
|
||
>
|
||
<div className="modal-head">
|
||
<div>
|
||
<div style={{ fontSize: 24, fontWeight: 700 }}>
|
||
{companyAccountDetailModal.account_name || companyAccountDetailModal.account_code || "(계정명없음)"}
|
||
</div>
|
||
<div className="subtle" style={{ marginTop: 6 }}>
|
||
{companyAccountDetailModal.account_code || "-"} · {companyAccountDetailModal.year}년 · {companyAccountDetailModal.project_type}
|
||
</div>
|
||
</div>
|
||
<button className="button-muted" onClick={() => setCompanyAccountDetailModal(null)}>닫기</button>
|
||
</div>
|
||
{companyAccountDetailModalLoading ? (
|
||
<div className="empty-state">계정 상세를 불러오는 중입니다.</div>
|
||
) : companyAccountDetailModal.detail?.summary ? (
|
||
<>
|
||
<div className="summary-grid" style={{ marginTop: 18 }}>
|
||
<div className="summary-item">
|
||
<span>입금 합계</span>
|
||
<strong>{fmt(companyAccountDetailModal.detail.summary.income_supply_sum || 0)}원</strong>
|
||
</div>
|
||
<div className="summary-item">
|
||
<span>지출 합계</span>
|
||
<strong>{fmt(companyAccountDetailModal.detail.summary.expense_supply_sum || 0)}원</strong>
|
||
</div>
|
||
<div className="summary-item">
|
||
<span>거래 건수</span>
|
||
<strong>{fmt(companyAccountDetailModal.detail.summary.txn_count || 0)}건</strong>
|
||
</div>
|
||
<div className="summary-item">
|
||
<span>기간</span>
|
||
<strong>
|
||
{(companyAccountDetailModal.detail.summary.min_date || "-")}
|
||
{" ~ "}
|
||
{(companyAccountDetailModal.detail.summary.max_date || "-")}
|
||
</strong>
|
||
</div>
|
||
</div>
|
||
<section className="panel" style={{ marginTop: 16, padding: 16 }}>
|
||
<div className="section-heading">
|
||
<div>
|
||
<h3>프로젝트별 금액</h3>
|
||
<p>해당 계정이 잡힌 프로젝트를 확인하고, 눌러서 프로젝트 상세로 이동할 수 있습니다.</p>
|
||
</div>
|
||
</div>
|
||
{companyAccountDetailModal.detail?.project_allocation?.enabled && (
|
||
<div className="mini-card" style={{ marginBottom: 12 }}>
|
||
<div style={{ fontWeight: 700 }}>
|
||
균등 배분 적용
|
||
</div>
|
||
<div className="subtle" style={{ marginTop: 6 }}>
|
||
{companyAccountDetailModal.detail.project_allocation.source_project_type || "-"} 금액을 연결 프로젝트 수 기준으로 균등 배분했습니다.
|
||
{" "}
|
||
원본
|
||
{" "}
|
||
{fmt(companyAccountDetailModal.detail.project_allocation.source_project_count || 0)}
|
||
{"개 프로젝트 → "}
|
||
{fmt(companyAccountDetailModal.detail.project_allocation.target_project_count || 0)}
|
||
{"개 프로젝트"}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{(companyAccountDetailModal.detail.projects || []).length ? (
|
||
<div style={{ display: "grid", gap: 10 }}>
|
||
{(companyAccountDetailModal.detail.projects || []).map((row) => (
|
||
<button
|
||
key={`company-project-${row.project_code}`}
|
||
type="button"
|
||
className="mini-card"
|
||
onClick={() => {
|
||
setCompanyAccountDetailModal(null);
|
||
setCompanyAccountModal(null);
|
||
setSelectedProjectCode(row.project_code || "");
|
||
setCurrentTab("project");
|
||
}}
|
||
style={{ padding: "12px 14px", width: "100%", textAlign: "left", cursor: "pointer" }}
|
||
>
|
||
<div style={{ display: "grid", gridTemplateColumns: "minmax(220px, 1.4fr) repeat(4, minmax(88px, 0.65fr))", gap: 12, alignItems: "center" }}>
|
||
<div>
|
||
<div style={{ fontSize: 16, fontWeight: 700 }}>{row.project_name || "프로젝트명 없음"}</div>
|
||
<div className="subtle" style={{ marginTop: 4 }}>{row.project_code || "-"}</div>
|
||
</div>
|
||
<div>
|
||
<div className="subtle">입금</div>
|
||
<div style={{ marginTop: 2, fontWeight: 700, color: "var(--good)" }}>{fmtEokManagement(row.income_supply_sum || 0)}</div>
|
||
</div>
|
||
<div>
|
||
<div className="subtle">지출</div>
|
||
<div style={{ marginTop: 2, fontWeight: 700 }}>{fmtEokManagement(row.expense_supply_sum || 0)}</div>
|
||
</div>
|
||
<div>
|
||
<div className="subtle">합계</div>
|
||
<div style={{ marginTop: 2, fontWeight: 700 }}>{fmtEokManagement(row.supply_sum || 0)}</div>
|
||
</div>
|
||
<div style={{ textAlign: "right" }}>
|
||
<div className="subtle">거래</div>
|
||
<div style={{ marginTop: 2, fontWeight: 700 }}>{fmt(row.txn_count || 0)}건</div>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="empty-state">표시할 프로젝트가 없습니다.</div>
|
||
)}
|
||
</section>
|
||
<section className="panel" style={{ marginTop: 16, padding: 16 }}>
|
||
<div className="section-heading">
|
||
<div>
|
||
<h3>거래내역</h3>
|
||
<p>해당 연도와 구분 기준으로 포함된 거래를 확인합니다.</p>
|
||
</div>
|
||
</div>
|
||
{(companyAccountDetailModal.detail.transactions || []).length ? (
|
||
<div className="table-wrap">
|
||
<table className="data-table">
|
||
<thead>
|
||
<tr>
|
||
<th>거래일</th>
|
||
<th>프로젝트</th>
|
||
<th>거래처</th>
|
||
<th>적요</th>
|
||
<th>입/출금</th>
|
||
<th>공급가액</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{(companyAccountDetailModal.detail.transactions || []).map((row, index) => (
|
||
<tr key={`${row.transaction_date}-${row.project_code}-${index}`}>
|
||
<td>{row.transaction_date || "-"}</td>
|
||
<td>
|
||
<div>{row.project_name || "프로젝트명 없음"}</div>
|
||
<div className="table-sub">{row.project_code || "-"}</div>
|
||
</td>
|
||
<td>{row.vendor_name || "-"}</td>
|
||
<td>{row.description || "-"}</td>
|
||
<td>{row.in_out || "-"}</td>
|
||
<td>{fmt(row.supply_amount || 0)}원</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
) : (
|
||
<div className="empty-state">표시할 거래내역이 없습니다.</div>
|
||
)}
|
||
</section>
|
||
</>
|
||
) : (
|
||
<div className="empty-state">계정 상세를 불러오지 못했습니다.</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{dashboardInfoModal && (
|
||
<div className="modal-backdrop" onClick={() => setDashboardInfoModal("")}>
|
||
<div className="modal-panel" onClick={(e) => e.stopPropagation()}>
|
||
<div className="modal-head">
|
||
<div style={{ fontSize: 22, fontWeight: 700 }}>
|
||
{dashboardInfoModal === "guide"
|
||
? "읽는 방법"
|
||
: dashboardInfoModal === "status"
|
||
? "상태 구분"
|
||
: ["dashboard_scope", "management_scope", "company_scope"].includes(dashboardInfoModal)
|
||
? "참고"
|
||
: "공법 표기 기준"}
|
||
</div>
|
||
<button className="button-muted" onClick={() => setDashboardInfoModal("")}>닫기</button>
|
||
</div>
|
||
{dashboardInfoModal === "guide" && (
|
||
<div className="subtle" style={{ marginTop: 16, lineHeight: 1.8 }}>
|
||
먼저 공법 대분류 카드에서 어떤 사업군이 많은지 보고, 아래에서 선택된 대분류 안의 세부 공법과 계약금 구간을 비교합니다.
|
||
<br />
|
||
카드 아래 프로젝트 목록이 뜨면 해당 프로젝트를 눌러 기존 프로젝트 관리 화면으로 바로 이동할 수 있습니다.
|
||
</div>
|
||
)}
|
||
{dashboardInfoModal === "status" && (
|
||
<div style={{ marginTop: 16 }}>
|
||
<div className="dashboard-band-chip-row">
|
||
{(dashboardData?.status_bands || []).map((band) => (
|
||
<div key={band.key} className="dashboard-band-chip">
|
||
<span className="dashboard-band-dot" style={{ background: getStatusBandColor(band.key) }} />
|
||
{band.label}
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="subtle" style={{ marginTop: 14, lineHeight: 1.8 }}>
|
||
정상: 입금과 집행 흐름이 현재 기준으로 크게 무리 없는 상태
|
||
<br />
|
||
선투입: 수입이 아직 0원인데 자재비나 외주비가 먼저 나간 상태
|
||
<br />
|
||
회수지연: 수입은 일부 들어왔지만 집행이 더 앞서 있고, 공정이나 기성 흐름상 회수 지연으로 보는 상태
|
||
<br />
|
||
원가위험: 선투입이나 회수지연으로 보기보다 원가 부담이 큰 상태
|
||
</div>
|
||
</div>
|
||
)}
|
||
{dashboardInfoModal === "method" && (
|
||
<div className="subtle" style={{ marginTop: 16, lineHeight: 1.8 }}>
|
||
원본 데이터에 문자열 `NULL` 로 들어온 값과 빈값은 모두 미지정으로 묶어 표시합니다.
|
||
<br />
|
||
이 대시보드에서는 `NULL` 과 `공법미지정`을 따로 보지 않고 같은 의미로 취급합니다.
|
||
</div>
|
||
)}
|
||
{dashboardInfoModal === "dashboard_scope" && (
|
||
<div className="subtle" style={{ marginTop: 16, lineHeight: 1.8 }}>
|
||
대시보드 탭은 시공 프로젝트만 반영합니다.
|
||
<br />
|
||
시공관리, 관리/설계 프로젝트는 제외하고 집계합니다.
|
||
<br />
|
||
아래 계정들은 참고용으로 집계에서 제외합니다.
|
||
<br />
|
||
{MANAGEMENT_EXCLUDED_ACCOUNT_NOTE.replace("※ 집계 제외 계정: ", "")}
|
||
</div>
|
||
)}
|
||
{dashboardInfoModal === "management_scope" && (
|
||
<div className="subtle" style={{ marginTop: 16, lineHeight: 1.8 }}>
|
||
관리 계정 탭은 관리 프로젝트만 반영합니다.
|
||
<br />
|
||
관리 5개 항목 기준으로 지출 금액을 연도별로 집계합니다.
|
||
<br />
|
||
아래 계정들은 참고용으로 집계에서 제외합니다.
|
||
<br />
|
||
{MANAGEMENT_EXCLUDED_ACCOUNT_NOTE.replace("※ 집계 제외 계정: ", "")}
|
||
</div>
|
||
)}
|
||
{dashboardInfoModal === "company_scope" && (
|
||
<div className="subtle" style={{ marginTop: 16, lineHeight: 1.8 }}>
|
||
전체 탭은 전체 프로젝트를 반영합니다.
|
||
<br />
|
||
연도별로 시공, 영업, 설계, 관리 등의 입금/지출 구성을 보여줍니다.
|
||
<br />
|
||
아래 계정들은 참고용으로 집계에서 제외합니다.
|
||
<br />
|
||
{MANAGEMENT_EXCLUDED_ACCOUNT_NOTE.replace("※ 집계 제외 계정: ", "")}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{pileProgressModalOpen && (
|
||
<div className="modal-backdrop" onClick={() => setPileProgressModalOpen(false)}>
|
||
<div className="modal-panel modal-panel-wide" onClick={(e) => e.stopPropagation()}>
|
||
<div className="modal-head">
|
||
<div>
|
||
<div style={{ fontSize: 26, fontWeight: 700 }}>시공실적 입력</div>
|
||
<div className="subtle" style={{ marginTop: 6 }}>
|
||
시작일과 종료일 기준으로 시공본수를 입력하면 누적 시공본수와 공정률이 자동 계산됩니다.
|
||
</div>
|
||
</div>
|
||
<button className="button-muted" onClick={() => setPileProgressModalOpen(false)}>닫기</button>
|
||
</div>
|
||
|
||
<div className="mini-card" style={{ marginBottom: 14 }}>
|
||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: 12 }}>
|
||
<div>
|
||
<div className="subtle">계약본수</div>
|
||
<div style={{ fontSize: 24, fontWeight: 700, marginTop: 6 }}>{fmt(contractPileCount)}본</div>
|
||
</div>
|
||
<div>
|
||
<div className="subtle">누적 시공본수</div>
|
||
<div style={{ fontSize: 24, fontWeight: 700, marginTop: 6 }}>
|
||
{fmt(pileProgressRows.reduce((sum, row) => sum + (Number(row.pile_count) || 0), 0))}본
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="subtle">자동 공정률</div>
|
||
<div style={{ fontSize: 24, fontWeight: 700, marginTop: 6, color: "#1ca64b" }}>
|
||
{((Number(contractPileCount) || 0) > 0
|
||
? (pileProgressRows.reduce((sum, row) => sum + (Number(row.pile_count) || 0), 0) / (Number(contractPileCount) || 0)) * 100
|
||
: 0
|
||
).toFixed(1)}%
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}>
|
||
<div className="subtle">기간별 시공실적을 관리합니다.</div>
|
||
<button className="button-muted" onClick={addPileProgressRow}>행 추가</button>
|
||
</div>
|
||
|
||
<div className="table-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>시작일</th>
|
||
<th>종료일</th>
|
||
<th>시공본수</th>
|
||
<th>메모</th>
|
||
<th>삭제</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{pileProgressRows.map((row, index) => (
|
||
<tr key={`${row.start_date || "row"}-${index}`}>
|
||
<td>
|
||
{(() => {
|
||
const parts = row.start_date_parts || splitDateParts(row.start_date);
|
||
const dayOptions = Array.from({ length: daysInMonth(parts.year, parts.month) }, (_, dayIndex) => (
|
||
String(dayIndex + 1).padStart(2, "0")
|
||
));
|
||
return (
|
||
<div style={{ display: "grid", gridTemplateColumns: "1.1fr 0.9fr 0.9fr", gap: 6 }}>
|
||
<select className="select" value={parts.year} onChange={(e) => updatePileProgressDatePart(index, "start_date", "year", e.target.value)}>
|
||
<option value="">년</option>
|
||
{DATE_YEAR_OPTIONS.map((year) => <option key={year} value={year}>{year}</option>)}
|
||
</select>
|
||
<select className="select" value={parts.month} onChange={(e) => updatePileProgressDatePart(index, "start_date", "month", e.target.value)}>
|
||
<option value="">월</option>
|
||
{DATE_MONTH_OPTIONS.map((month) => <option key={month} value={month}>{month}</option>)}
|
||
</select>
|
||
<select className="select" value={parts.day} onChange={(e) => updatePileProgressDatePart(index, "start_date", "day", e.target.value)}>
|
||
<option value="">일</option>
|
||
{dayOptions.map((day) => <option key={day} value={day}>{day}</option>)}
|
||
</select>
|
||
</div>
|
||
);
|
||
})()}
|
||
</td>
|
||
<td>
|
||
{(() => {
|
||
const parts = row.end_date_parts || splitDateParts(row.end_date);
|
||
const dayOptions = Array.from({ length: daysInMonth(parts.year, parts.month) }, (_, dayIndex) => (
|
||
String(dayIndex + 1).padStart(2, "0")
|
||
));
|
||
return (
|
||
<div style={{ display: "grid", gridTemplateColumns: "1.1fr 0.9fr 0.9fr", gap: 6 }}>
|
||
<select className="select" value={parts.year} onChange={(e) => updatePileProgressDatePart(index, "end_date", "year", e.target.value)}>
|
||
<option value="">년</option>
|
||
{DATE_YEAR_OPTIONS.map((year) => <option key={year} value={year}>{year}</option>)}
|
||
</select>
|
||
<select className="select" value={parts.month} onChange={(e) => updatePileProgressDatePart(index, "end_date", "month", e.target.value)}>
|
||
<option value="">월</option>
|
||
{DATE_MONTH_OPTIONS.map((month) => <option key={month} value={month}>{month}</option>)}
|
||
</select>
|
||
<select className="select" value={parts.day} onChange={(e) => updatePileProgressDatePart(index, "end_date", "day", e.target.value)}>
|
||
<option value="">일</option>
|
||
{dayOptions.map((day) => <option key={day} value={day}>{day}</option>)}
|
||
</select>
|
||
</div>
|
||
);
|
||
})()}
|
||
</td>
|
||
<td>
|
||
<input
|
||
className="field"
|
||
type="number"
|
||
min="0"
|
||
value={row.pile_count || 0}
|
||
onChange={(e) => updatePileProgressRow(index, "pile_count", e.target.value)}
|
||
/>
|
||
</td>
|
||
<td>
|
||
<input
|
||
className="field"
|
||
value={row.note || ""}
|
||
onChange={(e) => updatePileProgressRow(index, "note", e.target.value)}
|
||
placeholder="메모"
|
||
/>
|
||
</td>
|
||
<td>
|
||
<button className="button-muted" onClick={() => removePileProgressRow(index)}>삭제</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
{!pileProgressRows.length && (
|
||
<tr><td colSpan="5">입력된 시공실적이 없습니다.</td></tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div className="modal-actions">
|
||
<button className="button-muted" onClick={() => setPileProgressModalOpen(false)}>취소</button>
|
||
<button className="button-primary" onClick={savePileProgress} disabled={pileProgressSaving}>
|
||
{pileProgressSaving ? "저장 중..." : "시공실적 저장"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{projectEditModalOpen && (
|
||
<div className="modal-backdrop" onClick={() => setProjectEditModalOpen(false)}>
|
||
<div className="modal-panel" onClick={(e) => e.stopPropagation()}>
|
||
<div className="modal-head">
|
||
<div>
|
||
<div style={{ fontSize: 26, fontWeight: 700 }}>프로젝트 정보 수정</div>
|
||
</div>
|
||
<button className="button-muted" onClick={() => setProjectEditModalOpen(false)}>닫기</button>
|
||
</div>
|
||
<div className="master-inline-grid" style={{ marginTop: 8 }}>
|
||
<div>
|
||
<div className="project-meta-label">프로젝트 코드</div>
|
||
<div className="field" style={{ display: "flex", alignItems: "center", fontSize: 14, fontWeight: 600, height: 42, background: "#f8fbff" }}>
|
||
{detail?.summary?.project_code || "-"}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="project-meta-label">프로젝트명</div>
|
||
<input
|
||
className="field"
|
||
value={editor.project_name}
|
||
onChange={(e) => setEditor(prev => ({ ...prev, project_name: e.target.value }))}
|
||
placeholder="프로젝트명"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<div className="project-meta-label">프로젝트 구분</div>
|
||
<select
|
||
className="select"
|
||
value={editor.project_type}
|
||
onChange={(e) => setEditor(prev => ({ ...prev, project_type: e.target.value }))}
|
||
>
|
||
<option value="">프로젝트 구분</option>
|
||
{projectTypeOptions.map((item) => <option key={item} value={item}>{item}</option>)}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<div className="project-meta-label">공법 종류</div>
|
||
<input
|
||
className="field"
|
||
value={editor.construction_family}
|
||
readOnly
|
||
placeholder="공법 종류"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<div className="project-meta-label">공법</div>
|
||
<select
|
||
className="select"
|
||
value={editor.construction_method}
|
||
onChange={(e) => setEditor(prev => ({
|
||
...prev,
|
||
construction_method: e.target.value,
|
||
construction_family: methodFamilyMap[e.target.value] || ""
|
||
}))}
|
||
>
|
||
<option value="">공법 선택</option>
|
||
{methodOptions.map((item) => <option key={item} value={item}>{item}</option>)}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<div className="project-meta-label">계약 시작일</div>
|
||
<input
|
||
className="field"
|
||
type="date"
|
||
value={editor.start_date}
|
||
onChange={(e) => setEditor(prev => ({ ...prev, start_date: e.target.value }))}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<div className="project-meta-label">계약 종료일</div>
|
||
<input
|
||
className="field"
|
||
type="date"
|
||
value={editor.end_date}
|
||
onChange={(e) => setEditor(prev => ({ ...prev, end_date: e.target.value }))}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<div className="project-meta-label">메모</div>
|
||
<textarea
|
||
className="field"
|
||
value={editor.note}
|
||
onChange={(e) => setEditor(prev => ({ ...prev, note: e.target.value }))}
|
||
placeholder="프로젝트 설명 또는 관리 메모"
|
||
rows={3}
|
||
style={{ minHeight: 96, resize: "vertical", paddingTop: 10, paddingBottom: 10 }}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<div className="project-meta-label">관련 프로젝트 코드</div>
|
||
<div style={{ display: "grid", gap: 10 }}>
|
||
<input
|
||
className="field"
|
||
value={relatedProjectSearch}
|
||
onChange={(e) => setRelatedProjectSearch(e.target.value)}
|
||
placeholder={`${relatedProjectTargetTypes.join(" / ")} 프로젝트 검색해서 연결`}
|
||
/>
|
||
<div style={{ display: "grid", gridTemplateColumns: `repeat(${Math.max(relatedProjectTargetTypes.length, 1)}, minmax(0, 1fr))`, gap: 12 }}>
|
||
{groupedSelectedRelatedProjects.map((group) => (
|
||
<div key={`selected-${group.type}`} style={{ display: "grid", gap: 8 }}>
|
||
<div className="project-meta-label" style={{ marginBottom: 0 }}>{group.type} 연결</div>
|
||
{group.items.length ? (
|
||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||
{group.items.map((item) => (
|
||
<button
|
||
key={item.project_code}
|
||
type="button"
|
||
onClick={() => setEditor((prev) => ({
|
||
...prev,
|
||
related_project_codes: normalizedSelectedRelatedProjectCodes.filter((code) => code !== item.project_code).join(", ")
|
||
}))}
|
||
style={{
|
||
border: "1px solid var(--line)",
|
||
background: "#f8fbff",
|
||
borderRadius: 999,
|
||
padding: "7px 12px",
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
gap: 8,
|
||
cursor: "pointer",
|
||
fontSize: 13,
|
||
fontWeight: 700
|
||
}}
|
||
>
|
||
<span>{item.project_code}</span>
|
||
<span style={{ color: "var(--muted)", fontWeight: 600 }}>
|
||
{item.project_name || item.project_type || "연결됨"}
|
||
</span>
|
||
<span style={{ color: "#b42318" }}>×</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="subtle">선택된 {group.type} 코드가 없습니다.</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
{!!filteredRelatedProjectCandidates.length && (
|
||
<div style={{ display: "grid", gridTemplateColumns: `repeat(${Math.max(relatedProjectTargetTypes.length, 1)}, minmax(0, 1fr))`, gap: 12 }}>
|
||
{groupedRelatedProjectCandidates.map((group) => (
|
||
<div key={`candidate-${group.type}`} style={{ border: "1px solid var(--line)", borderRadius: 14, background: "white", maxHeight: 220, overflow: "auto" }}>
|
||
<div style={{ position: "sticky", top: 0, background: "white", borderBottom: "1px solid var(--line)", padding: "10px 12px", fontSize: 13, fontWeight: 800 }}>
|
||
{group.type} 후보
|
||
</div>
|
||
{group.items.length ? group.items.map((item, index) => (
|
||
<button
|
||
key={item.project_code}
|
||
type="button"
|
||
onClick={() => {
|
||
setEditor((prev) => ({
|
||
...prev,
|
||
related_project_codes: [...normalizedSelectedRelatedProjectCodes, item.project_code].join(", ")
|
||
}));
|
||
}}
|
||
style={{
|
||
width: "100%",
|
||
textAlign: "left",
|
||
border: "none",
|
||
borderBottom: index === group.items.length - 1 ? "none" : "1px solid var(--line)",
|
||
background: "white",
|
||
padding: "10px 12px",
|
||
cursor: "pointer",
|
||
display: "grid",
|
||
gap: 4
|
||
}}
|
||
>
|
||
<div style={{ display: "flex", justifyContent: "space-between", gap: 10 }}>
|
||
<strong>{item.project_code}</strong>
|
||
<span className="badge badge-blue">{item.project_type || "미지정"}</span>
|
||
</div>
|
||
<div style={{ fontSize: 14, fontWeight: 700 }}>{item.project_name || "(이름없음)"}</div>
|
||
<div className="subtle">{item.construction_family || "종류미지정"} · {item.construction_method || "공법미지정"}</div>
|
||
</button>
|
||
)) : (
|
||
<div className="subtle" style={{ padding: "12px 14px" }}>검색 결과가 없습니다.</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
{!filteredRelatedProjectCandidates.length && !!relatedProjectSearch.trim() && (
|
||
<div className="subtle">검색 결과가 없습니다.</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="modal-actions">
|
||
<button className="button-muted" onClick={() => setProjectEditModalOpen(false)}>취소</button>
|
||
<button
|
||
className="button-primary"
|
||
onClick={async () => {
|
||
await saveProjectMaster();
|
||
setProjectEditModalOpen(false);
|
||
}}
|
||
disabled={saving}
|
||
>
|
||
{saving ? "저장 중..." : "저장"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{vendorAccountModal && (
|
||
<div className="modal-backdrop" onClick={() => setVendorAccountModal(null)}>
|
||
<div className="modal-panel modal-panel-wide" onClick={(e) => e.stopPropagation()}>
|
||
<div className="modal-head">
|
||
<div>
|
||
<div style={{ fontSize: 26, fontWeight: 700 }}>
|
||
{vendorAccountModal.project_code} / {vendorAccountModal.account_code}{vendorAccountModal.account_name ? ` · ${vendorAccountModal.account_name}` : ""}
|
||
</div>
|
||
</div>
|
||
<button className="button-muted" onClick={() => setVendorAccountModal(null)}>닫기</button>
|
||
</div>
|
||
<div className="mini-card" style={{ marginBottom: 14 }}>
|
||
{(() => {
|
||
const filteredTransactions = filterTransactionsByDateRange(
|
||
vendorAccountModal.transactions || [],
|
||
vendorAccountDateFrom,
|
||
vendorAccountDateTo
|
||
);
|
||
const io = summarizeInOutTransactions(filteredTransactions);
|
||
return (
|
||
<div style={{ display: "grid", gap: 12 }}>
|
||
<div style={{ display: "grid", gridTemplateColumns: "180px 180px auto", gap: 10, alignItems: "end" }}>
|
||
<label style={{ display: "grid", gap: 6 }}>
|
||
<div className="subtle">시작일</div>
|
||
<input className="field" type="date" value={vendorAccountDateFrom} onChange={(e) => setVendorAccountDateFrom(e.target.value)} />
|
||
</label>
|
||
<label style={{ display: "grid", gap: 6 }}>
|
||
<div className="subtle">종료일</div>
|
||
<input className="field" type="date" value={vendorAccountDateTo} onChange={(e) => setVendorAccountDateTo(e.target.value)} />
|
||
</label>
|
||
<div className="subtle" style={{ paddingBottom: 8 }}>
|
||
기간별 조회 건수 {fmt(filteredTransactions.length)}건
|
||
</div>
|
||
</div>
|
||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, minmax(0, 1fr))", gap: 12 }}>
|
||
<div><div className="subtle">입금 건수</div><div style={{ fontSize: 24, fontWeight: 700, marginTop: 6 }}>{fmt(io.income_count)}건</div></div>
|
||
<div><div className="subtle">출금 건수</div><div style={{ fontSize: 24, fontWeight: 700, marginTop: 6 }}>{fmt(io.expense_count)}건</div></div>
|
||
<div><div className="subtle">입금액</div><div style={{ fontSize: 24, fontWeight: 700, marginTop: 6 }}>{fmt(io.income_sum)}원</div></div>
|
||
<div><div className="subtle">출금액</div><div style={{ fontSize: 24, fontWeight: 700, marginTop: 6 }}>{fmt(io.expense_sum)}원</div></div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
<div className="table-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>거래일</th>
|
||
<th>입/출금</th>
|
||
<th>프로젝트</th>
|
||
<th>부서</th>
|
||
<th>적요</th>
|
||
<th>공급가액</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{filterTransactionsByDateRange(
|
||
vendorAccountModal.transactions || [],
|
||
vendorAccountDateFrom,
|
||
vendorAccountDateTo
|
||
).map((row) => (
|
||
<tr key={`${row.source_row_no}-${row.transaction_date}-${row.description || ""}`}>
|
||
<td>{row.transaction_date || "-"}</td>
|
||
<td>{row.in_out || "-"}</td>
|
||
<td>{row.project_code || "-"}{row.project_name ? ` / ${row.project_name}` : ""}</td>
|
||
<td>{row.department_name || "-"}</td>
|
||
<td>{row.description || "-"}</td>
|
||
<td style={{ fontWeight: 700 }}>{fmt(row.supply_amount || 0)}원</td>
|
||
</tr>
|
||
))}
|
||
{!filterTransactionsByDateRange(
|
||
vendorAccountModal.transactions || [],
|
||
vendorAccountDateFrom,
|
||
vendorAccountDateTo
|
||
).length && (
|
||
<tr><td colSpan="6">표시할 거래내역이 없습니다.</td></tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{accountVendorModal && (
|
||
<div className="modal-backdrop" onClick={() => setAccountVendorModal(null)}>
|
||
<div className="modal-panel modal-panel-wide" onClick={(e) => e.stopPropagation()}>
|
||
<div className="modal-head">
|
||
<div>
|
||
<div style={{ fontSize: 26, fontWeight: 700 }}>
|
||
{accountVendorModal.project_code} / {accountVendorModal.account_code}{accountVendorModal.account_name ? ` · ${accountVendorModal.account_name}` : ""} / {accountVendorModal.vendor_name}
|
||
</div>
|
||
</div>
|
||
<button className="button-muted" onClick={() => setAccountVendorModal(null)}>닫기</button>
|
||
</div>
|
||
<div className="mini-card" style={{ marginBottom: 14 }}>
|
||
{(() => {
|
||
const filteredTransactions = filterTransactionsByDateRange(
|
||
accountVendorModal.transactions || [],
|
||
accountVendorDateFrom,
|
||
accountVendorDateTo
|
||
);
|
||
const io = summarizeInOutTransactions(filteredTransactions);
|
||
return (
|
||
<div style={{ display: "grid", gap: 12 }}>
|
||
<div style={{ display: "grid", gridTemplateColumns: "180px 180px auto", gap: 10, alignItems: "end" }}>
|
||
<label style={{ display: "grid", gap: 6 }}>
|
||
<div className="subtle">시작일</div>
|
||
<input className="field" type="date" value={accountVendorDateFrom} onChange={(e) => setAccountVendorDateFrom(e.target.value)} />
|
||
</label>
|
||
<label style={{ display: "grid", gap: 6 }}>
|
||
<div className="subtle">종료일</div>
|
||
<input className="field" type="date" value={accountVendorDateTo} onChange={(e) => setAccountVendorDateTo(e.target.value)} />
|
||
</label>
|
||
<div className="subtle" style={{ paddingBottom: 8 }}>
|
||
기간별 조회 건수 {fmt(filteredTransactions.length)}건
|
||
</div>
|
||
</div>
|
||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, minmax(0, 1fr))", gap: 12 }}>
|
||
<div><div className="subtle">입금 건수</div><div style={{ fontSize: 24, fontWeight: 700, marginTop: 6 }}>{fmt(io.income_count)}건</div></div>
|
||
<div><div className="subtle">출금 건수</div><div style={{ fontSize: 24, fontWeight: 700, marginTop: 6 }}>{fmt(io.expense_count)}건</div></div>
|
||
<div><div className="subtle">입금액</div><div style={{ fontSize: 24, fontWeight: 700, marginTop: 6 }}>{fmt(io.income_sum)}원</div></div>
|
||
<div><div className="subtle">출금액</div><div style={{ fontSize: 24, fontWeight: 700, marginTop: 6 }}>{fmt(io.expense_sum)}원</div></div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
<div className="table-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>거래일</th>
|
||
<th>입/출금</th>
|
||
<th>프로젝트</th>
|
||
<th>부서</th>
|
||
<th>적요</th>
|
||
<th>공급가액</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{filterTransactionsByDateRange(
|
||
accountVendorModal.transactions || [],
|
||
accountVendorDateFrom,
|
||
accountVendorDateTo
|
||
).map((row) => (
|
||
<tr key={`${row.source_row_no}-${row.transaction_date}-${row.description || ""}`}>
|
||
<td>{row.transaction_date || "-"}</td>
|
||
<td>{row.in_out || "-"}</td>
|
||
<td>{row.project_code || "-"}{row.project_name ? ` / ${row.project_name}` : ""}</td>
|
||
<td>{row.department_name || "-"}</td>
|
||
<td>{row.description || "-"}</td>
|
||
<td style={{ fontWeight: 700 }}>{fmt(row.supply_amount || 0)}원</td>
|
||
</tr>
|
||
))}
|
||
{!filterTransactionsByDateRange(
|
||
accountVendorModal.transactions || [],
|
||
accountVendorDateFrom,
|
||
accountVendorDateTo
|
||
).length && (
|
||
<tr><td colSpan="6">표시할 거래내역이 없습니다.</td></tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{actualModalItem && (
|
||
<div className="modal-backdrop" onClick={() => { setActualModalItem(null); setActualModalDetail(null); }}>
|
||
<div className="modal-panel modal-panel-wide" onClick={(e) => e.stopPropagation()}>
|
||
<div className="modal-head">
|
||
<div>
|
||
<div style={{ fontSize: 26, fontWeight: 700 }}>
|
||
{actualModalItem.section} / {actualModalItem.group} / {actualModalItem.category}
|
||
</div>
|
||
</div>
|
||
<button className="button-muted" onClick={() => { setActualModalItem(null); setActualModalDetail(null); }}>닫기</button>
|
||
</div>
|
||
<div className="mini-card" style={{ marginBottom: 14 }}>
|
||
{actualModalLoading ? (
|
||
<div className="subtle">집행 상세내역을 불러오는 중입니다.</div>
|
||
) : actualModalDetail?.error_message ? (
|
||
<div style={{ color: "#b42318", fontSize: 15, fontWeight: 700 }}>
|
||
{actualModalDetail.error_message}
|
||
</div>
|
||
) : (
|
||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, minmax(0, 1fr))", gap: 12 }}>
|
||
<div>
|
||
<div className="subtle">거래 건수</div>
|
||
<div style={{ fontSize: 24, fontWeight: 700, marginTop: 6 }}>
|
||
{fmt(actualModalDetail?.summary?.txn_count || 0)}건
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="subtle">입금 / 출금</div>
|
||
<div style={{ fontSize: 24, fontWeight: 700, marginTop: 6 }}>
|
||
{fmt(actualModalDetail?.summary?.income_count || 0)} / {fmt(actualModalDetail?.summary?.expense_count || 0)}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="subtle">입금액</div>
|
||
<div style={{ fontSize: 24, fontWeight: 700, marginTop: 6 }}>
|
||
{fmt(actualModalDetail?.summary?.income_sum || 0)}원
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="subtle">출금액</div>
|
||
<div style={{ fontSize: 24, fontWeight: 700, marginTop: 6 }}>
|
||
{fmt(actualModalDetail?.summary?.expense_sum || 0)}원
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{!actualModalLoading && (
|
||
<>
|
||
<div className="table-wrap" style={{ marginBottom: 14 }}>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>계정번호 / 계정명</th>
|
||
<th>거래건수</th>
|
||
<th>집행금액</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{(actualModalDetail?.accounts || []).map((account) => (
|
||
<tr key={account.account_code}>
|
||
<td>{account.account_code} {account.account_name ? `· ${account.account_name}` : ""}</td>
|
||
<td>{fmt(account.txn_count || 0)}건</td>
|
||
<td style={{ fontWeight: 700 }}>{fmt(account.supply_sum || 0)}원</td>
|
||
</tr>
|
||
))}
|
||
{!actualModalDetail?.accounts?.length && (
|
||
<tr><td colSpan="3">계정 집계가 없습니다.</td></tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div className="table-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>거래일</th>
|
||
<th>입/출금</th>
|
||
<th>계정</th>
|
||
<th>부서</th>
|
||
<th>거래처</th>
|
||
<th>적요</th>
|
||
<th>공급가액</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{(actualModalDetail?.transactions || []).map((row) => (
|
||
<tr key={`${row.source_row_no}-${row.transaction_date}-${row.account_code || ""}`}>
|
||
<td>{row.transaction_date || "-"}</td>
|
||
<td>{row.in_out || "-"}</td>
|
||
<td>{row.account_code || "-"}{row.account_name ? ` · ${row.account_name}` : ""}</td>
|
||
<td>{row.department_name || "-"}</td>
|
||
<td>{row.vendor_name || "-"}</td>
|
||
<td>{row.description || "-"}</td>
|
||
<td style={{ fontWeight: 700 }}>{fmt(row.supply_amount || 0)}원</td>
|
||
</tr>
|
||
))}
|
||
{!actualModalDetail?.transactions?.length && (
|
||
<tr><td colSpan="7">표시할 집행 상세내역이 없습니다.</td></tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{issueDetailModal && (
|
||
<div className="modal-backdrop" onClick={() => { setIssueDetailModal(null); setIssueRowSelections({}); setIssueCheckedRows([]); setIssueBulkTargetCode(""); }}>
|
||
<div className="modal-panel modal-panel-wide" onClick={(e) => e.stopPropagation()}>
|
||
<div className="modal-head">
|
||
<div>
|
||
<div style={{ fontSize: 26, fontWeight: 700 }}>
|
||
{issueDetailModal.account_code} {issueDetailModal.account_name ? `· ${issueDetailModal.account_name}` : ""}
|
||
</div>
|
||
<div className="subtle" style={{ marginTop: 6 }}>
|
||
어떤 거래인지 먼저 확인한 뒤, 필요한 행만 골라 다른 계정으로 변경할 수 있습니다.
|
||
</div>
|
||
</div>
|
||
<button className="button-muted" onClick={() => { setIssueDetailModal(null); setIssueRowSelections({}); setIssueCheckedRows([]); setIssueBulkTargetCode(""); }}>닫기</button>
|
||
</div>
|
||
<div className="mini-card" style={{ marginBottom: 14 }}>
|
||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: 12 }}>
|
||
<div>
|
||
<div className="subtle">거래 건수</div>
|
||
<div style={{ fontSize: 24, fontWeight: 700, marginTop: 6 }}>{fmt(issueDetailModal.summary?.txn_count || 0)}건</div>
|
||
</div>
|
||
<div>
|
||
<div className="subtle">공급가액 합계</div>
|
||
<div style={{ fontSize: 24, fontWeight: 700, marginTop: 6 }}>{fmt(issueDetailModal.summary?.supply_sum || 0)}원</div>
|
||
</div>
|
||
<div>
|
||
<div className="subtle">기간</div>
|
||
<div style={{ fontSize: 20, fontWeight: 700, marginTop: 6 }}>
|
||
{issueDetailModal.summary?.min_date || "-"} {issueDetailModal.summary?.max_date ? `~ ${issueDetailModal.summary.max_date}` : ""}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="mini-card" style={{ marginBottom: 14 }}>
|
||
<div style={{ display: "grid", gridTemplateColumns: "1fr auto auto", gap: 12, alignItems: "end" }}>
|
||
<label style={{ display: "grid", gap: 6 }}>
|
||
<div className="subtle">선택 행 일괄 변경 계정</div>
|
||
<select className="select" value={issueBulkTargetCode} onChange={(e) => setIssueBulkTargetCode(e.target.value)}>
|
||
<option value="">계정 선택</option>
|
||
{(allowedAccountCodesByProjectType[detail?.summary?.project_type] || []).map((code) => (
|
||
<option key={code} value={code}>
|
||
{code} · {accountMaster[code]?.name || ""}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<button className="button-muted" onClick={toggleIssueAllChecked}>
|
||
{(issueDetailModal.items || []).length && (issueDetailModal.items || []).every((row) => issueCheckedRows.includes(row.source_row_no)) ? "전체 해제" : "전체 선택"}
|
||
</button>
|
||
<button className="button-primary" onClick={applyIssueBulkTarget} disabled={!issueBulkTargetCode || !issueCheckedRows.length}>
|
||
선택 행 일괄 적용
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="table-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th style={{ width: 56 }}>선택</th>
|
||
<th>거래일</th>
|
||
<th>입/출금</th>
|
||
<th>부서</th>
|
||
<th>거래처</th>
|
||
<th>적요</th>
|
||
<th>공급가액</th>
|
||
<th>변경 계정</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{(issueDetailModal.items || []).map((row) => (
|
||
<tr key={row.source_row_no}>
|
||
<td>
|
||
<input
|
||
type="checkbox"
|
||
checked={issueCheckedRows.includes(row.source_row_no)}
|
||
onChange={() => toggleIssueCheckedRow(row.source_row_no)}
|
||
/>
|
||
</td>
|
||
<td>{row.transaction_date || "-"}</td>
|
||
<td>{row.in_out || "-"}</td>
|
||
<td>{row.department_name || "-"}</td>
|
||
<td>{row.vendor_name || "-"}</td>
|
||
<td>{row.description || "-"}</td>
|
||
<td style={{ fontWeight: 700 }}>{fmt(row.supply_amount || 0)}원</td>
|
||
<td>
|
||
<select
|
||
className="select"
|
||
value={issueRowSelections[row.source_row_no] || ""}
|
||
onChange={(e) => setIssueRowSelections((prev) => ({ ...prev, [row.source_row_no]: e.target.value }))}
|
||
>
|
||
<option value="">유지</option>
|
||
{(allowedAccountCodesByProjectType[detail?.summary?.project_type] || []).map((code) => (
|
||
<option key={code} value={code}>
|
||
{code} · {accountMaster[code]?.name || ""}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
{!issueDetailModal.items?.length && (
|
||
<tr><td colSpan="8">표시할 거래가 없습니다.</td></tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div className="modal-actions">
|
||
<button className="button-muted" onClick={() => { setIssueDetailModal(null); setIssueRowSelections({}); setIssueCheckedRows([]); setIssueBulkTargetCode(""); }}>취소</button>
|
||
<button className="button-primary" onClick={saveIssueRowRemap} disabled={issueRowSaving}>
|
||
{issueRowSaving ? "저장 중..." : "선택 행 계정 저장"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{budgetModalItem && (
|
||
<div className="modal-backdrop" onClick={() => { setBudgetModalItem(null); setBudgetModalAccounts([]); setBudgetModalTotalBudget(0); }}>
|
||
<div className="modal-panel" onClick={(e) => e.stopPropagation()}>
|
||
<div className="modal-head">
|
||
<div>
|
||
<div style={{ fontSize: 26, fontWeight: 700 }}>
|
||
{budgetModalItem.section} / {budgetModalItem.group} / {budgetModalItem.category}
|
||
</div>
|
||
</div>
|
||
<button className="button-muted" onClick={() => { setBudgetModalItem(null); setBudgetModalAccounts([]); setBudgetModalTotalBudget(0); }}>닫기</button>
|
||
</div>
|
||
<div className="mini-card" style={{ marginBottom: 14 }}>
|
||
<div style={{ display: "grid", gridTemplateColumns: "1.15fr repeat(3, minmax(0, 1fr))", gap: 12 }}>
|
||
<div>
|
||
<div className="subtle">항목 전체 실행계획</div>
|
||
<input
|
||
className="field"
|
||
type="number"
|
||
min="0"
|
||
value={budgetModalTotalBudget || 0}
|
||
onChange={(e) => updateBudgetModalTotalBudget(e.target.value)}
|
||
style={{ marginTop: 8 }}
|
||
/>
|
||
<div className="subtle" style={{ marginTop: 6 }}>
|
||
자재비처럼 항목 전체 금액을 먼저 잡을 수 있습니다.
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="subtle">계정별 입력 합계</div>
|
||
<div style={{ fontSize: 24, fontWeight: 700, marginTop: 6 }}>
|
||
{fmt(budgetModalAccounts.reduce((sum, account) => sum + (Number(account.budget_amount) || 0), 0))}원
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="subtle">현재 집행금액</div>
|
||
<div style={{ fontSize: 24, fontWeight: 700, marginTop: 6 }}>
|
||
{fmt(budgetModalAccounts.reduce((sum, account) => sum + (Number(account.actual_amount) || 0), 0))}원
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="subtle">미배분 금액</div>
|
||
<div style={{ fontSize: 24, fontWeight: 700, marginTop: 6, color: ((Number(budgetModalTotalBudget) || 0) - budgetModalAccounts.reduce((sum, account) => sum + (Number(account.budget_amount) || 0), 0)) < 0 ? "#b42318" : "var(--ink)" }}>
|
||
{fmt((Number(budgetModalTotalBudget) || 0) - budgetModalAccounts.reduce((sum, account) => sum + (Number(account.budget_amount) || 0), 0))}원
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="table-wrap" style={{ marginTop: 14 }}>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>계정번호 / 계정명</th>
|
||
<th>집행금액</th>
|
||
<th>실행계획</th>
|
||
<th>차이</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{budgetModalAccounts.map((account, index) => {
|
||
const diff = (Number(account.budget_amount) || 0) - (Number(account.actual_amount) || 0);
|
||
return (
|
||
<tr key={index}>
|
||
<td>{account.account_code} {account.account_name}</td>
|
||
<td style={{ fontWeight: 700 }}>{fmt(account.actual_amount || 0)}원</td>
|
||
<td>
|
||
<input
|
||
className="field"
|
||
type="number"
|
||
value={account.budget_amount || 0}
|
||
onChange={(e) => updateBudgetModalAccount(index, e.target.value)}
|
||
/>
|
||
</td>
|
||
<td style={{ fontWeight: 700, color: diff < 0 ? "#b42318" : "var(--ink)" }}>
|
||
{fmt(diff)}원
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div className="modal-actions">
|
||
<button className="button-muted" onClick={() => { setBudgetModalItem(null); setBudgetModalAccounts([]); setBudgetModalTotalBudget(0); }}>취소</button>
|
||
<button className="button-primary" onClick={saveBudgetModal}>예산 저장</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
try {
|
||
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
|
||
const fallback = document.getElementById("ptc-boot-fallback");
|
||
if (fallback) fallback.style.display = "none";
|
||
} catch (err) {
|
||
ptcBootFail(`화면 렌더링 실패: ${err?.message || err}`);
|
||
throw err;
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|