- PTC(2023-2026.02).xlsx 최신화 - PTC/index.html: 에러 핸들링, 동적 API 베이스, 예산 계산 로직 개선 및 UI 최적화 - server/ptc_api_server.py: 4000 포트에서 프론트엔드 직접 서빙, 원본 엑셀 경로 설정 기능, DB 인덱스 추가 및 성능 최적화 - windows/: 원본 파일 선택을 위한 set_ptc_source.bat 추가 및 기존 스크립트 수정
3598 lines
214 KiB
HTML
3598 lines
214 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;
|
|
}
|
|
}
|
|
</script>
|
|
<script src="https://unpkg.com/react@18/umd/react.production.min.js" onerror="ptcBootFail('React CDN을 불러오지 못했습니다. 네트워크 또는 사내망 차단 여부를 확인해 주세요.')"></script>
|
|
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" onerror="ptcBootFail('ReactDOM CDN을 불러오지 못했습니다. 네트워크 또는 사내망 차단 여부를 확인해 주세요.')"></script>
|
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js" onerror="ptcBootFail('Babel CDN을 불러오지 못했습니다. 네트워크 또는 사내망 차단 여부를 확인해 주세요.')"></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;
|
|
}
|
|
.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: 50;
|
|
}
|
|
.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; }
|
|
}
|
|
</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 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 [loading, setLoading] = useState(true);
|
|
const [detailLoading, setDetailLoading] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState("");
|
|
const [currentTab, setCurrentTab] = useState("project");
|
|
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("");
|
|
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 [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 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(() => {
|
|
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 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;
|
|
});
|
|
}
|
|
|
|
return (
|
|
<div className="page">
|
|
<section className="page-head">
|
|
<div>
|
|
<div style={{ fontSize: 34, fontWeight: 700, letterSpacing: "-0.03em" }}>
|
|
{currentTab === "vendor" ? "거래내역확인" : "프로젝트 관리"}
|
|
</div>
|
|
</div>
|
|
<div className="tab-row">
|
|
<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 === "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">{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">
|
|
{detail?.summary?.min_date || "-"} {detail?.summary?.max_date ? `~ ${detail?.summary?.max_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>
|
|
)}
|
|
|
|
{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>
|
|
<input
|
|
className="field"
|
|
value={editor.note}
|
|
onChange={(e) => setEditor(prev => ({ ...prev, note: e.target.value }))}
|
|
placeholder="프로젝트 설명 또는 관리 메모"
|
|
/>
|
|
</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>
|
|
);
|
|
}
|
|
|
|
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
|
|
</script>
|
|
</body>
|
|
</html>
|