5131 lines
293 KiB
HTML
5131 lines
293 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;
|
|
--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: var(--soft);
|
|
border: 1px solid rgba(216,226,236,0.95);
|
|
border-radius: 26px;
|
|
box-shadow: 0 18px 42px rgba(15, 28, 46, 0.07);
|
|
backdrop-filter: blur(10px);
|
|
}
|
|
.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 var(--line);
|
|
border-radius: 18px;
|
|
background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(244,248,252,0.98));
|
|
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 var(--line);
|
|
border-radius: 18px;
|
|
background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(244,248,252,0.98));
|
|
padding: 16px;
|
|
}
|
|
.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: 10px;
|
|
max-height: calc(100vh - 220px);
|
|
overflow: auto;
|
|
padding-right: 4px;
|
|
}
|
|
.project-item {
|
|
border: 1px solid var(--line);
|
|
border-radius: 18px;
|
|
padding: 12px 13px;
|
|
background: white;
|
|
cursor: pointer;
|
|
transition: 0.18s ease;
|
|
}
|
|
.project-item:hover { transform: translateY(-1px); border-color: #bfd3e5; }
|
|
.project-item.active {
|
|
border-color: var(--blue);
|
|
box-shadow: inset 0 0 0 1px var(--blue);
|
|
background: linear-gradient(180deg, #f7fbff, #eef5fb);
|
|
}
|
|
.project-item.selected {
|
|
box-shadow: inset 0 0 0 2px rgba(31,122,140,0.35);
|
|
}
|
|
.vendor-item {
|
|
border: 1px solid var(--line);
|
|
border-radius: 18px;
|
|
padding: 14px;
|
|
background: white;
|
|
cursor: pointer;
|
|
transition: 0.18s ease;
|
|
}
|
|
.vendor-item:hover { transform: translateY(-1px); border-color: #bfd3e5; }
|
|
.vendor-item.active {
|
|
border-color: var(--blue);
|
|
box-shadow: inset 0 0 0 1px var(--blue);
|
|
background: linear-gradient(180deg, #f7fbff, #eef5fb);
|
|
}
|
|
.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 #e6eef6;
|
|
border-radius: 14px;
|
|
padding: 10px 12px;
|
|
background: rgba(255,255,255,0.88);
|
|
}
|
|
button.dashboard-status-legend-item {
|
|
width: 100%;
|
|
text-align: left;
|
|
cursor: pointer;
|
|
transition: 0.18s ease;
|
|
}
|
|
button.dashboard-status-legend-item:hover {
|
|
transform: translateY(-1px);
|
|
border-color: #bfd3e5;
|
|
}
|
|
button.dashboard-status-legend-item.active {
|
|
border-color: var(--blue);
|
|
box-shadow: inset 0 0 0 1px var(--blue);
|
|
background: linear-gradient(180deg, #f7fbff, #eef5fb);
|
|
}
|
|
.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 var(--line);
|
|
border-radius: 18px;
|
|
background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(244,248,252,0.98));
|
|
padding: 14px 16px;
|
|
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: translateY(-1px);
|
|
border-color: #bfd3e5;
|
|
}
|
|
.dashboard-family-chart-row.active {
|
|
border-color: var(--blue);
|
|
box-shadow: inset 0 0 0 1px var(--blue);
|
|
background: linear-gradient(180deg, #f7fbff, #eef5fb);
|
|
}
|
|
.dashboard-family-row-main {
|
|
display: grid;
|
|
gap: 8px;
|
|
}
|
|
.dashboard-method-list {
|
|
display: grid;
|
|
gap: 12px;
|
|
margin-top: 16px;
|
|
}
|
|
.dashboard-method-row {
|
|
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: 14px 16px;
|
|
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: translateY(-1px);
|
|
border-color: #bfd3e5;
|
|
}
|
|
.dashboard-method-row.active {
|
|
border-color: var(--blue);
|
|
box-shadow: inset 0 0 0 1px var(--blue);
|
|
background: linear-gradient(180deg, #f7fbff, #eef5fb);
|
|
}
|
|
.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 var(--line);
|
|
border-radius: 18px;
|
|
background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(244,248,252,0.98));
|
|
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: 10px;
|
|
margin-top: 14px;
|
|
}
|
|
.dashboard-selection-kpis-wide {
|
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
|
}
|
|
.dashboard-selection-kpi {
|
|
border: 1px solid #e6eef6;
|
|
border-radius: 14px;
|
|
padding: 10px 12px;
|
|
background: rgba(255,255,255,0.88);
|
|
}
|
|
.dashboard-family-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
|
gap: 12px;
|
|
}
|
|
.dashboard-family-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;
|
|
cursor: pointer;
|
|
transition: 0.18s ease;
|
|
}
|
|
.dashboard-family-card:hover {
|
|
transform: translateY(-1px);
|
|
border-color: #bfd3e5;
|
|
}
|
|
.dashboard-family-card.active {
|
|
border-color: var(--blue);
|
|
box-shadow: inset 0 0 0 1px var(--blue);
|
|
background: linear-gradient(180deg, #f7fbff, #eef5fb);
|
|
}
|
|
.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);
|
|
}
|
|
.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;
|
|
font-size: 19px;
|
|
font-weight: 700;
|
|
line-height: 1.35;
|
|
letter-spacing: -0.02em;
|
|
white-space: normal;
|
|
}
|
|
.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;
|
|
}
|
|
.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 { useDeferredValue, useEffect, useMemo, 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.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 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 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 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", "vendor"].includes(initialTabParam) ? initialTabParam : "dashboard"
|
|
);
|
|
const [projectKeyword, setProjectKeyword] = useState("");
|
|
const [projectType, setProjectType] = useState("전체");
|
|
const [projectMethodFamily, setProjectMethodFamily] = useState("전체");
|
|
const [projectMethod, setProjectMethod] = useState("전체");
|
|
const [projectSort, setProjectSort] = useState("code_desc");
|
|
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 [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 [projectEditModalOpen, setProjectEditModalOpen] = useState(false);
|
|
const [detail, setDetail] = useState(null);
|
|
const [overallSummary, setOverallSummary] = useState(null);
|
|
const [dashboardData, setDashboardData] = useState(null);
|
|
const [dashboardLoading, setDashboardLoading] = useState(false);
|
|
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 [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: ""
|
|
});
|
|
const deferredProjectKeyword = useDeferredValue(projectKeyword);
|
|
const deferredVendorKeyword = useDeferredValue(vendorKeyword);
|
|
const debouncedProjectKeyword = useDebouncedValue(deferredProjectKeyword, 250);
|
|
const debouncedVendorKeyword = useDebouncedValue(deferredVendorKeyword, 250);
|
|
|
|
const projectQuery = useMemo(() => {
|
|
const params = new URLSearchParams();
|
|
if (debouncedProjectKeyword.trim()) params.set("keyword", debouncedProjectKeyword.trim());
|
|
if (projectType) params.set("project_type", projectType);
|
|
return params.toString();
|
|
}, [debouncedProjectKeyword, projectType]);
|
|
|
|
const methodOptionsByFamily = useMemo(() => {
|
|
if (projectMethodFamily === "전체") return methodOptions;
|
|
return methodOptions.filter((item) => methodFamilyMap[item] === projectMethodFamily);
|
|
}, [methodOptions, methodFamilyMap, projectMethodFamily]);
|
|
|
|
const filteredProjects = useMemo(() => {
|
|
let items = [...projects];
|
|
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) => {
|
|
if (projectSort === "method_desc") {
|
|
const aMethod = a.construction_method || "";
|
|
const bMethod = b.construction_method || "";
|
|
const aIndex = methodOptions.indexOf(aMethod);
|
|
const bIndex = methodOptions.indexOf(bMethod);
|
|
const methodCompare = (bIndex === -1 ? -1 : bIndex) - (aIndex === -1 ? -1 : aIndex);
|
|
if (methodCompare !== 0) return methodCompare;
|
|
return (b.supply_sum || 0) - (a.supply_sum || 0);
|
|
}
|
|
if (projectSort === "name_desc") {
|
|
return (b.project_name || "").localeCompare(a.project_name || "", "ko");
|
|
}
|
|
if (projectSort === "code_desc") {
|
|
return (b.project_code || "").localeCompare(a.project_code || "", "ko");
|
|
}
|
|
return (b.supply_sum || 0) - (a.supply_sum || 0);
|
|
});
|
|
return items;
|
|
}, [projects, projectMethodFamily, projectMethod, projectSort]);
|
|
|
|
useEffect(() => {
|
|
if (filteredProjects.length === 1 && filteredProjects[0]?.project_code && selectedProjectCode !== filteredProjects[0].project_code) {
|
|
setSelectedProjectCode(filteredProjects[0].project_code);
|
|
}
|
|
}, [filteredProjects, 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());
|
|
return params.toString();
|
|
}, [debouncedVendorKeyword]);
|
|
|
|
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] = await Promise.all([
|
|
fetch(`${API_BASE}/api/project-types`),
|
|
fetch(`${API_BASE}/api/project-master-options`),
|
|
fetch(`${API_BASE}/api/projects?${projectQuery}`)
|
|
]);
|
|
if (!typeRes.ok || !optionsRes.ok || !projectRes.ok) throw new Error("project load failed");
|
|
const typeData = await typeRes.json();
|
|
const optionsData = await optionsRes.json();
|
|
const projectData = await projectRes.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 || []);
|
|
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);
|
|
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]);
|
|
|
|
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 || ""
|
|
});
|
|
}
|
|
} 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);
|
|
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);
|
|
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]);
|
|
|
|
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);
|
|
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]);
|
|
|
|
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();
|
|
return (
|
|
projectCode.includes("-시공-") &&
|
|
!projectCode.includes("-관리-") &&
|
|
!projectCode.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 overallDashboardScope = useMemo(() => {
|
|
const items = dashboardFamilyItems.filter((item) => item.family !== "전체");
|
|
const income = items.reduce((sum, item) => sum + Number(item.income_supply || 0), 0);
|
|
const expense = items.reduce((sum, item) => sum + Number(item.expense_supply || 0), 0);
|
|
const profit = 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),
|
|
income,
|
|
expense,
|
|
profit,
|
|
marginRate,
|
|
statusCounts,
|
|
};
|
|
}, [dashboardFamilyItems]);
|
|
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 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/`);
|
|
nextUrl.searchParams.set("popup", "status_projects");
|
|
nextUrl.searchParams.set("dashboard_family", selectedDashboardFamily || "전체");
|
|
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/" &&
|
|
popupWindow.location.search.includes("popup=status_projects");
|
|
|
|
if (sameOriginReady) {
|
|
popupWindow.postMessage({
|
|
type: "ptc-open-status-popup",
|
|
dashboardFamily: selectedDashboardFamily || "전체",
|
|
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} 대분류 범위`} · {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;
|
|
|
|
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]));
|
|
});
|
|
}
|
|
|
|
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
|
|
})
|
|
});
|
|
if (!res.ok) throw new Error("save failed");
|
|
const saved = await res.json();
|
|
setDetail((prev) => prev ? {
|
|
...prev,
|
|
summary: {
|
|
...prev.summary,
|
|
project_name: saved.item?.project_name || prev.summary.project_name,
|
|
project_type: saved.item?.project_type || prev.summary.project_type,
|
|
construction_family: saved.item?.construction_family || "",
|
|
construction_method: saved.item?.construction_method || "",
|
|
start_date: saved.item?.start_date || "",
|
|
end_date: saved.item?.end_date || "",
|
|
note: saved.item?.note || ""
|
|
},
|
|
project_master: saved.item
|
|
} : prev);
|
|
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" ? "대시보드 시안" : "프로젝트 관리"}
|
|
</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 === "vendor" ? "active" : ""}`} onClick={() => setCurrentTab("vendor")}>
|
|
거래내역확인
|
|
</button>
|
|
</div>
|
|
</section>
|
|
{error && <div style={{ marginBottom: 12 }} className="badge badge-warn">{error}</div>}
|
|
{currentTab === "dashboard" && (
|
|
<section className="dashboard-grid">
|
|
<div className="panel" style={{ padding: 22 }}>
|
|
<div style={{ display: "flex", justifyContent: "space-between", gap: 16, alignItems: "flex-start" }}>
|
|
<div />
|
|
<div />
|
|
</div>
|
|
<div className="dashboard-selection-kpis" style={{ marginTop: 16 }}>
|
|
<div className="dashboard-selection-kpi">
|
|
<div className="subtle">프로젝트 수</div>
|
|
<div style={{ marginTop: 8, fontSize: 24, fontWeight: 600 }}>{fmt(overallDashboardScope.projectCount || 0)}개</div>
|
|
</div>
|
|
<div className="dashboard-selection-kpi">
|
|
<div className="subtle">수입</div>
|
|
<div style={{ marginTop: 8, fontSize: 24, fontWeight: 600 }}>{fmtEok(overallDashboardScope.income || 0)}</div>
|
|
</div>
|
|
<div className="dashboard-selection-kpi">
|
|
<div className="subtle">지출</div>
|
|
<div style={{ marginTop: 8, fontSize: 24, fontWeight: 600 }}>{fmtEok(overallDashboardScope.expense || 0)}</div>
|
|
</div>
|
|
<div className="dashboard-selection-kpi">
|
|
<div className="subtle">수익</div>
|
|
<div style={{ marginTop: 8, fontSize: 24, fontWeight: 600, color: (overallDashboardScope.profit || 0) < 0 ? "#d14343" : "var(--good)" }}>
|
|
{fmtEok(overallDashboardScope.profit || 0)}
|
|
</div>
|
|
</div>
|
|
<div className="dashboard-selection-kpi">
|
|
<div className="subtle">수익률</div>
|
|
<div style={{ marginTop: 8, fontSize: 24, fontWeight: 600, color: (overallDashboardScope.marginRate || 0) < 0 ? "#d14343" : "var(--good)" }}>
|
|
{(overallDashboardScope.marginRate || 0).toFixed(1)}%
|
|
</div>
|
|
</div>
|
|
<div className="dashboard-selection-kpi">
|
|
<div className="subtle">원가위험</div>
|
|
<div style={{ marginTop: 8, fontSize: 24, fontWeight: 600 }}>{fmt(overallDashboardScope.statusCounts?.risk || 0)}개</div>
|
|
</div>
|
|
<div className="dashboard-selection-kpi">
|
|
<div className="subtle">선투입</div>
|
|
<div style={{ marginTop: 8, fontSize: 24, fontWeight: 600 }}>{fmt(overallDashboardScope.statusCounts?.upfront || 0)}개</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={selectedDashboardFamily !== "전체" ? "dashboard-top-split" : ""}>
|
|
<div className="panel" style={{ padding: 20 }}>
|
|
{dashboardLoading ? (
|
|
<div className="mini-card" style={{ marginTop: 16 }}>대시보드 데이터를 불러오는 중입니다.</div>
|
|
) : (
|
|
<>
|
|
<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>
|
|
|
|
{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>
|
|
{dashboardLoading ? (
|
|
<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>
|
|
)}
|
|
{currentTab === "project" && (
|
|
<section className="layout">
|
|
<aside className="panel" style={{ padding: 18 }}>
|
|
<div style={{ fontSize: 18, fontWeight: 700 }}>프로젝트 목록</div>
|
|
<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="프로젝트코드, 프로젝트명 검색"
|
|
/>
|
|
<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 }}>
|
|
<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="toolbar" style={{ gridTemplateColumns: "1fr", marginTop: 10 }}>
|
|
<select className="select" value={projectSort} onChange={(e) => setProjectSort(e.target.value)}>
|
|
<option value="supply_desc">공급가액 순</option>
|
|
<option value="method_desc">상세 공법 내림차순</option>
|
|
<option value="name_desc">프로젝트명 내림차순</option>
|
|
<option value="code_desc">프로젝트코드 내림차순</option>
|
|
</select>
|
|
</div>
|
|
<div className="project-list" style={{ marginTop: 14 }}>
|
|
{filteredProjects.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" }}>
|
|
<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)}
|
|
/>
|
|
<div style={{ fontWeight: 700 }}>{item.project_code}</div>
|
|
</label>
|
|
<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>
|
|
<div className="subtle" style={{ marginTop: 4 }}>
|
|
{item.min_date || "-"} {item.max_date ? `~ ${item.max_date}` : ""}
|
|
</div>
|
|
</div>
|
|
))}
|
|
{!filteredProjects.length && !loading && (
|
|
<div className="empty">조건에 맞는 프로젝트가 없습니다.</div>
|
|
)}
|
|
</div>
|
|
</aside>
|
|
|
|
<main style={{ display: "flex", flexDirection: "column", gap: 18 }}>
|
|
{!selectedProjectCode && overallSummary && (
|
|
<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>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<button className="project-inline-edit" onClick={() => 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>
|
|
|
|
<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="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 style={{ color: "var(--muted)", fontWeight: 600 }}>~</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 style={{ color: "var(--muted)", fontWeight: 600 }}>~</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>
|
|
)}
|
|
|
|
{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" ? "상태 구분" : "공법 표기 기준"}
|
|
</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>
|
|
)}
|
|
</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="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>
|