feat: 엑셀 원본 파일 선택 기능 및 프론트엔드/백엔드 최적화
- PTC(2023-2026.02).xlsx 최신화 - PTC/index.html: 에러 핸들링, 동적 API 베이스, 예산 계산 로직 개선 및 UI 최적화 - server/ptc_api_server.py: 4000 포트에서 프론트엔드 직접 서빙, 원본 엑셀 경로 설정 기능, DB 인덱스 추가 및 성능 최적화 - windows/: 원본 파일 선택을 위한 set_ptc_source.bat 추가 및 기존 스크립트 수정
This commit is contained in:
1 parent
f88d8e53cb
commit
b5e121136f
9 files changed
+748
-102
No files matched your search
+449
-25
@@ -4,9 +4,21 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PTC 프로젝트 관리</title>
|
||||
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||
<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 {
|
||||
@@ -337,7 +349,7 @@
|
||||
}
|
||||
.progress-compare-row {
|
||||
display: grid;
|
||||
grid-template-columns: 130px 90px minmax(260px, 1fr) 110px;
|
||||
grid-template-columns: 180px minmax(260px, 1fr) 110px;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
@@ -693,10 +705,50 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<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">
|
||||
const { useEffect, useMemo, useState } = React;
|
||||
const API_BASE = `${window.location.protocol}//${window.location.hostname}:4000`;
|
||||
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 || [];
|
||||
@@ -842,6 +894,43 @@
|
||||
));
|
||||
}
|
||||
|
||||
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 === "수입" },
|
||||
@@ -965,6 +1054,8 @@
|
||||
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({});
|
||||
@@ -995,6 +1086,7 @@
|
||||
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: "",
|
||||
@@ -1004,13 +1096,17 @@
|
||||
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 (projectKeyword.trim()) params.set("keyword", projectKeyword.trim());
|
||||
if (debouncedProjectKeyword.trim()) params.set("keyword", debouncedProjectKeyword.trim());
|
||||
if (projectType) params.set("project_type", projectType);
|
||||
return params.toString();
|
||||
}, [projectKeyword, projectType]);
|
||||
}, [debouncedProjectKeyword, projectType]);
|
||||
|
||||
const methodOptionsByFamily = useMemo(() => {
|
||||
if (projectMethodFamily === "전체") return methodOptions;
|
||||
@@ -1050,6 +1146,12 @@
|
||||
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();
|
||||
@@ -1061,9 +1163,15 @@
|
||||
|
||||
const vendorQuery = useMemo(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (vendorKeyword.trim()) params.set("keyword", vendorKeyword.trim());
|
||||
if (debouncedVendorKeyword.trim()) params.set("keyword", debouncedVendorKeyword.trim());
|
||||
return params.toString();
|
||||
}, [vendorKeyword]);
|
||||
}, [debouncedVendorKeyword]);
|
||||
|
||||
const overallSummaryQuery = useMemo(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (projectType && projectType !== "전체") params.set("project_type", projectType);
|
||||
return params.toString();
|
||||
}, [projectType]);
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
@@ -1089,8 +1197,8 @@
|
||||
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(projectData.items?.[0]?.project_code || "");
|
||||
if (selectedProjectCode && !(projectData.items || []).some(item => item.project_code === selectedProjectCode)) {
|
||||
setSelectedProjectCode("");
|
||||
}
|
||||
} catch (err) {
|
||||
if (!ignore) setError("프로젝트 목록을 불러오지 못했습니다. API 서버를 확인해 주세요.");
|
||||
@@ -1102,6 +1210,23 @@
|
||||
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() {
|
||||
@@ -1156,6 +1281,7 @@
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
async function loadVendors() {
|
||||
if (currentTab !== "vendor") return;
|
||||
setVendorLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/vendors?${vendorQuery}`);
|
||||
@@ -1175,11 +1301,12 @@
|
||||
}
|
||||
loadVendors();
|
||||
return () => { ignore = true; };
|
||||
}, [vendorQuery]);
|
||||
}, [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}`);
|
||||
@@ -1199,11 +1326,12 @@
|
||||
}
|
||||
loadAccounts();
|
||||
return () => { ignore = true; };
|
||||
}, [vendorQuery]);
|
||||
}, [currentTab, vendorQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
async function loadVendorDetail() {
|
||||
if (currentTab !== "vendor") return;
|
||||
if (!selectedVendorName) {
|
||||
setVendorDetail(null);
|
||||
return;
|
||||
@@ -1225,11 +1353,12 @@
|
||||
}
|
||||
loadVendorDetail();
|
||||
return () => { ignore = true; };
|
||||
}, [selectedVendorName, selectedVendorProjectCode]);
|
||||
}, [currentTab, selectedVendorName, selectedVendorProjectCode]);
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
async function loadAccountDetail() {
|
||||
if (currentTab !== "vendor") return;
|
||||
if (!selectedAccountCode) {
|
||||
setAccountDetail(null);
|
||||
return;
|
||||
@@ -1251,7 +1380,7 @@
|
||||
}
|
||||
loadAccountDetail();
|
||||
return () => { ignore = true; };
|
||||
}, [selectedAccountCode, selectedAccountProjectCode]);
|
||||
}, [currentTab, selectedAccountCode, selectedAccountProjectCode]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedAccountProjectCode("");
|
||||
@@ -1459,8 +1588,33 @@
|
||||
setBudgetAccountDetailLoading(false);
|
||||
}
|
||||
|
||||
function openActualModal(item) {
|
||||
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() {
|
||||
@@ -1972,6 +2126,46 @@
|
||||
</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>
|
||||
)}
|
||||
@@ -2062,7 +2256,7 @@
|
||||
</div>
|
||||
<div className="mini-card" style={{ marginTop: 14 }}>
|
||||
{isPileProject ? (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 180px) minmax(0, 160px) minmax(0, 160px) 150px 120px", gap: 12, alignItems: "end", justifyContent: "start" }}>
|
||||
<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
|
||||
@@ -2078,6 +2272,12 @@
|
||||
<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>
|
||||
@@ -2165,14 +2365,14 @@
|
||||
{budgetCompareRows.map((item) => (
|
||||
<div key={item.key} className="progress-compare-row">
|
||||
<div style={{ fontSize: 16, fontWeight: 700 }}>{item.label}</div>
|
||||
<div style={{ display: "grid", gap: 4 }}>
|
||||
<div style={{ fontSize: 12, color: "#2454d3", fontWeight: 700 }}>집행 {item.executionRate.toFixed(1)}%</div>
|
||||
</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>
|
||||
@@ -2261,7 +2461,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{group.items.map((item, idx) => {
|
||||
const diff = (Number(item.budget_amount) || 0) - (Number(item.actual_amount) || 0);
|
||||
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}>
|
||||
@@ -2276,8 +2476,8 @@
|
||||
{fmt(item.actual_amount || 0)}원
|
||||
</button>
|
||||
</td>
|
||||
<td style={{ fontWeight: 700, color: diff < 0 ? "#b42318" : "var(--ink)" }}>{fmt(diff)}원</td>
|
||||
<td style={{ fontWeight: 700, color: rate > 100 ? "#b42318" : "var(--blue)" }}>{rate.toFixed(1)}%</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>
|
||||
);
|
||||
})}
|
||||
@@ -3075,6 +3275,230 @@
|
||||
</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()}>
|
||||
|
||||
Reference in new issue
Block a user