Files
hikim2 b5e121136f feat: 엑셀 원본 파일 선택 기능 및 프론트엔드/백엔드 최적화
- PTC(2023-2026.02).xlsx 최신화
- PTC/index.html: 에러 핸들링, 동적 API 베이스, 예산 계산 로직 개선 및 UI 최적화
- server/ptc_api_server.py: 4000 포트에서 프론트엔드 직접 서빙, 원본 엑셀 경로 설정 기능, DB 인덱스 추가 및 성능 최적화
- windows/: 원본 파일 선택을 위한 set_ptc_source.bat 추가 및 기존 스크립트 수정
2026-03-24 13:21:20 +09:00

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>