feat: update manage dashboard flows

This commit is contained in:
2026-04-17 10:32:12 +09:00
parent 01a6e197ce
commit 6e8f606591
6 changed files with 626 additions and 193 deletions

View File

@@ -102,11 +102,11 @@
box-shadow: 0 10px 24px rgba(17,63,103,0.18); box-shadow: 0 10px 24px rgba(17,63,103,0.18);
} }
.panel { .panel {
background: var(--soft); background: rgba(248, 251, 254, 0.82);
border: 1px solid rgba(216,226,236,0.95); border: 1px solid rgba(232, 239, 246, 0.95);
border-radius: 26px; border-radius: 18px;
box-shadow: 0 18px 42px rgba(15, 28, 46, 0.07); box-shadow: 0 3px 10px rgba(15, 28, 46, 0.025);
backdrop-filter: blur(10px); backdrop-filter: blur(4px);
} }
.layout { .layout {
display: grid; display: grid;
@@ -219,9 +219,9 @@
gap: 12px; gap: 12px;
} }
.summary-card { .summary-card {
border: 1px solid var(--line); border: 1px solid #eef3f8;
border-radius: 18px; border-radius: 16px;
background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(244,248,252,0.98)); background: rgba(255,255,255,0.9);
padding: 16px; padding: 16px;
} }
.summary-card-grid { .summary-card-grid {
@@ -243,10 +243,11 @@
text-overflow: clip; text-overflow: clip;
} }
.metric, .mini-card { .metric, .mini-card {
border: 1px solid var(--line); border: 1px solid rgba(233, 240, 247, 0.68);
border-radius: 18px; border-radius: 12px;
background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(244,248,252,0.98)); background: rgba(255,255,255,0.7);
padding: 16px; box-shadow: none;
padding: 14px;
} }
.metric-value { .metric-value {
margin-top: 8px; margin-top: 8px;
@@ -280,41 +281,41 @@
.project-list { .project-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 4px;
max-height: calc(100vh - 220px); max-height: calc(100vh - 220px);
overflow: auto; overflow: auto;
padding-right: 4px; padding-right: 4px;
} }
.project-item { .project-item {
border: 1px solid var(--line); border: none;
border-radius: 18px; border-bottom: 1px solid rgba(233, 240, 247, 0.78);
padding: 12px 13px; border-radius: 0;
background: white; padding: 12px 8px 12px 6px;
background: transparent;
cursor: pointer; cursor: pointer;
transition: 0.18s ease; transition: 0.18s ease;
} }
.project-item:hover { transform: translateY(-1px); border-color: #bfd3e5; } .project-item:hover { transform: none; background: rgba(248,251,254,0.68); }
.project-item.active { .project-item.active {
border-color: var(--blue); box-shadow: inset 3px 0 0 var(--blue);
box-shadow: inset 0 0 0 1px var(--blue); background: #f8fbfe;
background: linear-gradient(180deg, #f7fbff, #eef5fb);
} }
.project-item.selected { .project-item.selected {
box-shadow: inset 0 0 0 2px rgba(31,122,140,0.35); box-shadow: inset 0 0 0 2px rgba(31,122,140,0.35);
} }
.vendor-item { .vendor-item {
border: 1px solid var(--line); border: none;
border-radius: 18px; border-bottom: 1px solid rgba(233, 240, 247, 0.78);
padding: 14px; border-radius: 0;
background: white; padding: 12px 8px 12px 6px;
background: transparent;
cursor: pointer; cursor: pointer;
transition: 0.18s ease; transition: 0.18s ease;
} }
.vendor-item:hover { transform: translateY(-1px); border-color: #bfd3e5; } .vendor-item:hover { transform: none; background: rgba(248,251,254,0.68); }
.vendor-item.active { .vendor-item.active {
border-color: var(--blue); box-shadow: inset 3px 0 0 var(--blue);
box-shadow: inset 0 0 0 1px var(--blue); background: #f8fbfe;
background: linear-gradient(180deg, #f7fbff, #eef5fb);
} }
.list-mode-toggle { .list-mode-toggle {
display: grid; display: grid;
@@ -535,10 +536,10 @@
width: 100%; width: 100%;
} }
.dashboard-status-legend-item { .dashboard-status-legend-item {
border: 1px solid #e6eef6; border: 1px solid rgba(233, 240, 247, 0.5);
border-radius: 14px; border-radius: 10px;
padding: 10px 12px; padding: 8px 10px;
background: rgba(255,255,255,0.88); background: rgba(255,255,255,0.46);
} }
button.dashboard-status-legend-item { button.dashboard-status-legend-item {
width: 100%; width: 100%;
@@ -547,13 +548,14 @@
transition: 0.18s ease; transition: 0.18s ease;
} }
button.dashboard-status-legend-item:hover { button.dashboard-status-legend-item:hover {
transform: translateY(-1px); transform: none;
border-color: #bfd3e5; border-color: #d2deea;
background: rgba(248,251,254,0.8);
} }
button.dashboard-status-legend-item.active { button.dashboard-status-legend-item.active {
border-color: var(--blue); border-color: #d8e4ef;
box-shadow: inset 0 0 0 1px var(--blue); box-shadow: inset 3px 0 0 var(--blue);
background: linear-gradient(180deg, #f7fbff, #eef5fb); background: #f8fbfe;
} }
.dashboard-status-legend-item.dashboard-status-inline-item { .dashboard-status-legend-item.dashboard-status-inline-item {
display: flex; display: flex;
@@ -630,10 +632,10 @@
margin-top: 16px; margin-top: 16px;
} }
.dashboard-family-chart-row { .dashboard-family-chart-row {
border: 1px solid var(--line); border: 1px solid rgba(233, 240, 247, 0.62);
border-radius: 18px; border-radius: 12px;
background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(244,248,252,0.98)); background: rgba(255,255,255,0.5);
padding: 14px 16px; padding: 12px 14px;
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 120px; grid-template-columns: minmax(0, 1fr) 120px;
gap: 14px; gap: 14px;
@@ -642,13 +644,14 @@
transition: 0.18s ease; transition: 0.18s ease;
} }
.dashboard-family-chart-row:hover { .dashboard-family-chart-row:hover {
transform: translateY(-1px); transform: none;
border-color: #bfd3e5; border-color: #d8e4ef;
background: rgba(248,251,254,0.78);
} }
.dashboard-family-chart-row.active { .dashboard-family-chart-row.active {
border-color: var(--blue); border-color: #d8e4ef;
box-shadow: inset 0 0 0 1px var(--blue); box-shadow: inset 3px 0 0 var(--blue);
background: linear-gradient(180deg, #f7fbff, #eef5fb); background: #f8fbfe;
} }
.dashboard-family-row-main { .dashboard-family-row-main {
display: grid; display: grid;
@@ -660,10 +663,10 @@
margin-top: 16px; margin-top: 16px;
} }
.dashboard-method-row { .dashboard-method-row {
border: 1px solid var(--line); border: 1px solid rgba(233, 240, 247, 0.62);
border-radius: 18px; border-radius: 12px;
background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(244,248,252,0.98)); background: rgba(255,255,255,0.5);
padding: 14px 16px; padding: 12px 14px;
display: grid; display: grid;
grid-template-columns: 92px minmax(0, 1fr) 180px; grid-template-columns: 92px minmax(0, 1fr) 180px;
gap: 16px; gap: 16px;
@@ -672,13 +675,14 @@
transition: 0.18s ease; transition: 0.18s ease;
} }
.dashboard-method-row:hover { .dashboard-method-row:hover {
transform: translateY(-1px); transform: none;
border-color: #bfd3e5; border-color: #d8e4ef;
background: rgba(248,251,254,0.78);
} }
.dashboard-method-row.active { .dashboard-method-row.active {
border-color: var(--blue); border-color: #d8e4ef;
box-shadow: inset 0 0 0 1px var(--blue); box-shadow: inset 3px 0 0 var(--blue);
background: linear-gradient(180deg, #f7fbff, #eef5fb); background: #f8fbfe;
} }
.dashboard-method-row-main { .dashboard-method-row-main {
display: grid; display: grid;
@@ -696,9 +700,9 @@
margin-top: 14px; margin-top: 14px;
} }
.dashboard-selection-card { .dashboard-selection-card {
border: 1px solid var(--line); border: 1px solid rgba(233, 240, 247, 0.62);
border-radius: 18px; border-radius: 14px;
background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(244,248,252,0.98)); background: rgba(255,255,255,0.54);
padding: 16px; padding: 16px;
} }
.dashboard-finance-bars { .dashboard-finance-bars {
@@ -726,17 +730,25 @@
.dashboard-selection-kpis { .dashboard-selection-kpis {
display: grid; display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr)); grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 10px; gap: 0;
margin-top: 14px; margin-top: 14px;
border: 1px solid rgba(233, 240, 247, 0.72);
border-radius: 16px;
background: rgba(255,255,255,0.58);
overflow: hidden;
} }
.dashboard-selection-kpis-wide { .dashboard-selection-kpis-wide {
grid-template-columns: repeat(5, minmax(0, 1fr)); grid-template-columns: repeat(5, minmax(0, 1fr));
} }
.dashboard-selection-kpi { .dashboard-selection-kpi {
border: 1px solid #e6eef6; border: none;
border-radius: 14px; border-radius: 0;
padding: 10px 12px; padding: 10px 12px;
background: rgba(255,255,255,0.88); background: transparent;
box-shadow: none;
}
.dashboard-selection-kpis > *:not(:last-child) {
border-right: 1px solid rgba(233, 240, 247, 0.72);
} }
.dashboard-family-grid { .dashboard-family-grid {
display: grid; display: grid;
@@ -744,21 +756,22 @@
gap: 12px; gap: 12px;
} }
.dashboard-family-card { .dashboard-family-card {
border: 1px solid var(--line); border: 1px solid rgba(233, 240, 247, 0.62);
border-radius: 18px; border-radius: 12px;
background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(244,248,252,0.98)); background: rgba(255,255,255,0.52);
padding: 16px; padding: 14px;
cursor: pointer; cursor: pointer;
transition: 0.18s ease; transition: 0.18s ease;
} }
.dashboard-family-card:hover { .dashboard-family-card:hover {
transform: translateY(-1px); transform: none;
border-color: #bfd3e5; border-color: #d8e4ef;
background: rgba(248,251,254,0.78);
} }
.dashboard-family-card.active { .dashboard-family-card.active {
border-color: var(--blue); border-color: #d8e4ef;
box-shadow: inset 0 0 0 1px var(--blue); box-shadow: inset 3px 0 0 var(--blue);
background: linear-gradient(180deg, #f7fbff, #eef5fb); background: #f8fbfe;
} }
.dashboard-family-mini-track { .dashboard-family-mini-track {
display: flex; display: flex;
@@ -1841,8 +1854,11 @@
const [selectedManagementYear, setSelectedManagementYear] = useState(""); const [selectedManagementYear, setSelectedManagementYear] = useState("");
const [selectedManagementCategory, setSelectedManagementCategory] = useState(""); const [selectedManagementCategory, setSelectedManagementCategory] = useState("");
const [selectedManagementExcludedYear, setSelectedManagementExcludedYear] = useState(""); const [selectedManagementExcludedYear, setSelectedManagementExcludedYear] = useState("");
const [managementYearWindowStart, setManagementYearWindowStart] = useState(0);
const MANAGEMENT_YEAR_WINDOW_SIZE = 4;
const [managementOverviewAccounts, setManagementOverviewAccounts] = useState([]); const [managementOverviewAccounts, setManagementOverviewAccounts] = useState([]);
const [managementOverviewAccountsLoading, setManagementOverviewAccountsLoading] = useState(false); const [managementOverviewAccountsLoading, setManagementOverviewAccountsLoading] = useState(false);
const [managementYearDetailModalOpen, setManagementYearDetailModalOpen] = useState(false);
const [managementAccountModal, setManagementAccountModal] = useState(null); const [managementAccountModal, setManagementAccountModal] = useState(null);
const [managementAccountModalLoading, setManagementAccountModalLoading] = useState(false); const [managementAccountModalLoading, setManagementAccountModalLoading] = useState(false);
const [managementAccountModalView, setManagementAccountModalView] = useState("all"); const [managementAccountModalView, setManagementAccountModalView] = useState("all");
@@ -1877,6 +1893,7 @@
: null : null
); );
const [dashboardOngoingProjectModalOpen, setDashboardOngoingProjectModalOpen] = useState(false); const [dashboardOngoingProjectModalOpen, setDashboardOngoingProjectModalOpen] = useState(false);
const [dashboardYearDetailModalOpen, setDashboardYearDetailModalOpen] = useState(false);
const [dashboardMarginGradeFilter, setDashboardMarginGradeFilter] = useState( const [dashboardMarginGradeFilter, setDashboardMarginGradeFilter] = useState(
["all", "deficit", "caution", "good", "excellent"].includes(initialGradeParam) ? initialGradeParam : "all" ["all", "deficit", "caution", "good", "excellent"].includes(initialGradeParam) ? initialGradeParam : "all"
); );
@@ -1964,6 +1981,26 @@
return params.toString(); return params.toString();
}, [selectedManagementYear, selectedManagementCategory, managementDateFrom, managementDateTo]); }, [selectedManagementYear, selectedManagementCategory, managementDateFrom, managementDateTo]);
const sortedManagementOverviewItems = useMemo(
() =>
[...(managementOverview.items || [])].sort(
(a, b) => Number(a.year || 0) - Number(b.year || 0)
),
[managementOverview.items]
);
const visibleManagementOverviewItems = useMemo(
() => sortedManagementOverviewItems.slice(managementYearWindowStart, managementYearWindowStart + MANAGEMENT_YEAR_WINDOW_SIZE),
[sortedManagementOverviewItems, managementYearWindowStart, MANAGEMENT_YEAR_WINDOW_SIZE]
);
const visibleManagementProfitItems = useMemo(() => {
const visibleYears = new Set(visibleManagementOverviewItems.map((item) => String(item.year)));
return [...(managementOverview.yearly_profit_items || [])]
.sort((a, b) => Number(a.year || 0) - Number(b.year || 0))
.filter((item) => visibleYears.has(String(item.year)));
}, [managementOverview.yearly_profit_items, visibleManagementOverviewItems]);
const overallSummaryQuery = useMemo(() => { const overallSummaryQuery = useMemo(() => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (projectType && projectType !== "전체") params.set("project_type", projectType); if (projectType && projectType !== "전체") params.set("project_type", projectType);
@@ -2335,6 +2372,21 @@
} }
}, [managementOverview, selectedManagementYear, selectedManagementCategory]); }, [managementOverview, selectedManagementYear, selectedManagementCategory]);
useEffect(() => {
const total = sortedManagementOverviewItems.length;
if (!total) {
setManagementYearWindowStart(0);
return;
}
const maxStart = Math.max(0, total - MANAGEMENT_YEAR_WINDOW_SIZE);
setManagementYearWindowStart((prev) => {
if (prev > maxStart) return maxStart;
if (prev < 0) return 0;
if (prev === 0 && total > MANAGEMENT_YEAR_WINDOW_SIZE) return maxStart;
return prev;
});
}, [sortedManagementOverviewItems.length, MANAGEMENT_YEAR_WINDOW_SIZE]);
useEffect(() => { useEffect(() => {
const hasExcludedSelection = (managementOverview.items || []).some( const hasExcludedSelection = (managementOverview.items || []).some(
(item) => item.year === selectedManagementExcludedYear && Number(item.excluded_total || 0) !== 0 (item) => item.year === selectedManagementExcludedYear && Number(item.excluded_total || 0) !== 0
@@ -2374,6 +2426,12 @@
}; };
}, [filteredManagementAccountTransactions]); }, [filteredManagementAccountTransactions]);
function getManagementCategoryAmount(yearItem, categoryName) {
return Number(
((yearItem?.categories || []).find((category) => category.name === categoryName)?.amount) || 0
);
}
useEffect(() => { useEffect(() => {
if (selectedManagementYear && selectedManagementCategory && managementCategorySectionRef.current) { if (selectedManagementYear && selectedManagementCategory && managementCategorySectionRef.current) {
managementCategorySectionRef.current.scrollIntoView({ behavior: "smooth", block: "start" }); managementCategorySectionRef.current.scrollIntoView({ behavior: "smooth", block: "start" });
@@ -2798,17 +2856,37 @@
const companyGraphMaxValue = useMemo(() => { const companyGraphMaxValue = useMemo(() => {
return companyGraphRows.reduce((max, row) => Math.max(max, Number(row.income_supply || 0), Number(row.expense_supply || 0)), 1); return companyGraphRows.reduce((max, row) => Math.max(max, Number(row.income_supply || 0), Number(row.expense_supply || 0)), 1);
}, [companyGraphRows]); }, [companyGraphRows]);
const companyGraphLayout = useMemo(() => {
const chartLeft = 90;
const chartRight = 980;
const chartWidth = chartRight - chartLeft;
const pointCount = Math.max(companyGraphRows.length, 1);
const centerStartX = chartLeft + 60;
const centerEndX = chartRight - 60;
const availableCenterWidth = Math.max(centerEndX - centerStartX, 0);
const stepX = pointCount > 1 ? availableCenterWidth / (pointCount - 1) : 0;
const barWidth = pointCount > 1
? Math.max(22, Math.min(72, stepX * 0.68))
: 94;
return {
chartLeft,
chartRight,
chartWidth,
centerStartX,
centerEndX,
stepX,
barWidth,
chartHeight: 250,
baseY: 320,
};
}, [companyGraphRows.length]);
const companyGraphLinePoints = useMemo(() => { const companyGraphLinePoints = useMemo(() => {
const startX = 150;
const stepX = companyGraphRows.length > 1 ? 260 : 0;
const chartHeight = 250;
const baseY = 320;
return companyGraphRows.map((row, index) => { return companyGraphRows.map((row, index) => {
const x = startX + index * stepX; const x = companyGraphLayout.centerStartX + index * companyGraphLayout.stepX;
const y = baseY - (Number(row.income_supply || 0) / companyGraphMaxValue) * chartHeight; const y = companyGraphLayout.baseY - (Number(row.income_supply || 0) / companyGraphMaxValue) * companyGraphLayout.chartHeight;
return `${x},${y}`; return `${x},${y}`;
}).join(" "); }).join(" ");
}, [companyGraphRows, companyGraphMaxValue]); }, [companyGraphRows, companyGraphMaxValue, companyGraphLayout]);
const visibleDashboardProjects = useMemo(() => { const visibleDashboardProjects = useMemo(() => {
const filtered = selectedDashboardFamily === "전체" const filtered = selectedDashboardFamily === "전체"
? dashboardProjectsBase ? dashboardProjectsBase
@@ -3901,9 +3979,9 @@
minHeight: 96, minHeight: 96,
appearance: "none", appearance: "none",
WebkitAppearance: "none", WebkitAppearance: "none",
border: "1px solid #e6eef6", border: "none",
borderRadius: 14, borderRadius: 0,
background: "rgba(255,255,255,0.88)", background: "transparent",
width: "100%", width: "100%",
textAlign: "left", textAlign: "left",
boxSizing: "border-box", boxSizing: "border-box",
@@ -3977,25 +4055,20 @@
</div> </div>
{!!(managementOverview.yearly_construction_margin_items || []).length && ( {!!(managementOverview.yearly_construction_margin_items || []).length && (
<div className="panel" style={{ padding: 20, gridColumn: "1 / -1" }}> <div className="panel" style={{ padding: 20, gridColumn: "1 / -1" }}>
<div style={{ display: "grid", gridTemplateColumns: `repeat(${managementOverview.yearly_construction_margin_items.length}, minmax(0, 1fr))`, gap: 12 }}> <div className="mini-card" style={{ padding: 16, display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
{(managementOverview.yearly_construction_margin_items || []).map((yearItem) => ( <div>
<div key={`construction-margin-${yearItem.year}`} className="mini-card" style={{ padding: 14 }}> <div style={{ fontSize: 16, fontWeight: 700 }}>년도별 시공 상세</div>
<div className="subtle">{yearItem.year} 시공 수익률</div> <div className="subtle" style={{ marginTop: 4 }}>
<div 연도별 시공 수익률과 수익 금액을 표로 비교해서 봅니다.
style={{ </div>
marginTop: 8, </div>
fontSize: 24, <button
fontWeight: 700, type="button"
color: (Number(yearItem.margin_rate || 0) || 0) < 0 ? "#d14343" : "var(--good)", className="button-primary"
}} onClick={() => setDashboardYearDetailModalOpen(true)}
> >
{(Number(yearItem.margin_rate || 0) || 0).toFixed(1)}% 년도별 상세 보기
</div> </button>
<div className="subtle" style={{ marginTop: 6 }}>
수익 {fmtEokManagement(yearItem.profit_supply || 0)}
</div>
</div>
))}
</div> </div>
</div> </div>
)} )}
@@ -4214,6 +4287,69 @@
{dashboardOngoingProjectModalContent} {dashboardOngoingProjectModalContent}
</div> </div>
)} )}
{dashboardYearDetailModalOpen && (
<div className="modal-backdrop" onClick={() => setDashboardYearDetailModalOpen(false)}>
<div className="modal-panel modal-panel-wide" onClick={(e) => e.stopPropagation()}>
<div className="modal-head">
<div>
<div style={{ fontSize: 24, fontWeight: 700 }}>년도별 시공 상세</div>
<div className="subtle" style={{ marginTop: 6 }}>
연도별 시공 수익률과 수익을 표로 비교합니다.
</div>
</div>
<button className="button-muted" onClick={() => setDashboardYearDetailModalOpen(false)}>닫기</button>
</div>
<div style={{ marginTop: 18, overflowX: "auto" }}>
<table style={{ width: "100%", borderCollapse: "separate", borderSpacing: 0 }}>
<thead>
<tr>
{["연도", "시공 수익률", "수익", "수입", "지출"].map((label, index) => (
<th
key={label}
style={{
textAlign: index === 0 ? "left" : "right",
padding: "12px 14px",
fontSize: 13,
fontWeight: 700,
color: "var(--subtle)",
borderBottom: "1px solid var(--line)",
background: "rgba(246, 249, 253, 0.82)",
position: "sticky",
top: 0,
zIndex: 1,
}}
>
{label}
</th>
))}
</tr>
</thead>
<tbody>
{(managementOverview.yearly_construction_margin_items || []).map((yearItem) => (
<tr key={`dashboard-year-detail-${yearItem.year}`}>
<td style={{ padding: "14px", borderBottom: "1px solid var(--line)", fontWeight: 700 }}>
{yearItem.year}
</td>
<td style={{ padding: "14px", borderBottom: "1px solid var(--line)", textAlign: "right", fontWeight: 700, color: Number(yearItem.margin_rate || 0) < 0 ? "#d14343" : "var(--good)" }}>
{(Number(yearItem.margin_rate || 0) || 0).toFixed(1)}%
</td>
<td style={{ padding: "14px", borderBottom: "1px solid var(--line)", textAlign: "right", fontWeight: 700, color: Number(yearItem.profit_supply || 0) < 0 ? "#d14343" : "var(--text)" }}>
{fmtEokManagement(yearItem.profit_supply || 0)}
</td>
<td style={{ padding: "14px", borderBottom: "1px solid var(--line)", textAlign: "right" }}>
{fmtEokManagement(yearItem.income_supply || 0)}
</td>
<td style={{ padding: "14px", borderBottom: "1px solid var(--line)", textAlign: "right" }}>
{fmtEokManagement(yearItem.expense_supply || 0)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
{currentTab === "project" && ( {currentTab === "project" && (
<section className="layout"> <section className="layout">
<aside className="panel" style={{ padding: 18 }}> <aside className="panel" style={{ padding: 18 }}>
@@ -5130,24 +5266,66 @@
<div style={{ fontSize: 22, fontWeight: 700 }}>년도별 관리 사용금액</div> <div style={{ fontSize: 22, fontWeight: 700 }}>년도별 관리 사용금액</div>
<div className="subtle" style={{ marginTop: 6 }}>관리 5 항목 기준으로 출금 금액을 연도별로 봅니다.</div> <div className="subtle" style={{ marginTop: 6 }}>관리 5 항목 기준으로 출금 금액을 연도별로 봅니다.</div>
</div> </div>
{!!sortedManagementOverviewItems.length && (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<button
type="button"
className="button-muted"
onClick={() => setManagementYearWindowStart((prev) => Math.max(0, prev - 1))}
disabled={managementYearWindowStart <= 0}
aria-label="이전 연도 보기"
style={{ minWidth: 40, padding: "8px 10px" }}
>
</button>
<div className="subtle" style={{ minWidth: 120, textAlign: "center" }}>
{visibleManagementOverviewItems[0]?.year || "-"} ~ {visibleManagementOverviewItems[visibleManagementOverviewItems.length - 1]?.year || "-"}
</div> </div>
{!!(managementOverview.yearly_profit_items || []).length && ( <button
type="button"
className="button-muted"
onClick={() =>
setManagementYearWindowStart((prev) =>
Math.min(Math.max(0, sortedManagementOverviewItems.length - MANAGEMENT_YEAR_WINDOW_SIZE), prev + 1)
)
}
disabled={managementYearWindowStart >= Math.max(0, sortedManagementOverviewItems.length - MANAGEMENT_YEAR_WINDOW_SIZE)}
aria-label="다음 연도 보기"
style={{ minWidth: 40, padding: "8px 10px" }}
>
</button>
</div>
)}
</div>
{!!visibleManagementProfitItems.length && (
<div <div
style={{ style={{
display: "grid", display: "grid",
gridTemplateColumns: `repeat(${managementOverview.yearly_profit_items.length}, minmax(0, 1fr))`, gridTemplateColumns: `repeat(${visibleManagementProfitItems.length}, minmax(0, 1fr))`,
gap: 12, gap: 0,
marginTop: 16, marginTop: 14,
marginBottom: 16, marginBottom: 14,
border: "1px solid rgba(233, 240, 247, 0.72)",
borderRadius: 16,
background: "rgba(255,255,255,0.56)",
overflow: "hidden",
}} }}
> >
{(managementOverview.yearly_profit_items || []).map((yearItem) => ( {visibleManagementProfitItems.map((yearItem, index) => (
<div key={`profit-${yearItem.year}`} className="mini-card" style={{ padding: 14 }}> <div
<div className="subtle">{yearItem.year} 수익</div> key={`profit-${yearItem.year}`}
style={{
padding: "10px 14px 12px",
borderLeft: index === 0 ? "none" : "1px solid rgba(233, 240, 247, 0.72)",
background: "transparent",
}}
>
<div className="subtle" style={{ fontSize: 12 }}>{yearItem.year} 수익</div>
<div <div
style={{ style={{
marginTop: 8, marginTop: 6,
fontSize: 24, fontSize: 18,
fontWeight: 700, fontWeight: 700,
color: (Number(yearItem.profit_supply || 0) || 0) < 0 ? "#d14343" : "var(--good)", color: (Number(yearItem.profit_supply || 0) || 0) < 0 ? "#d14343" : "var(--good)",
}} }}
@@ -5158,9 +5336,28 @@
))} ))}
</div> </div>
)} )}
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))", gap: 14, marginTop: 16 }}> <div
{(managementOverview.items || []).map((yearItem) => ( style={{
<div key={yearItem.year} className="mini-card" style={{ padding: 16, minHeight: 430 }}> display: "grid",
gridTemplateColumns: `repeat(${Math.max(1, visibleManagementOverviewItems.length)}, minmax(0, 1fr))`,
gap: 0,
marginTop: 16,
border: "1px solid rgba(233, 240, 247, 0.72)",
borderRadius: 18,
background: "rgba(255,255,255,0.56)",
overflow: "hidden",
}}
>
{visibleManagementOverviewItems.map((yearItem) => (
<div
key={yearItem.year}
style={{
padding: 16,
minHeight: 430,
borderLeft: visibleManagementOverviewItems[0]?.year === yearItem.year ? "none" : "1px solid rgba(233, 240, 247, 0.72)",
background: "rgba(255,255,255,0.08)",
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 12 }}> <div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 12 }}>
<div style={{ fontSize: 18, fontWeight: 700 }}>{yearItem.year}</div> <div style={{ fontSize: 18, fontWeight: 700 }}>{yearItem.year}</div>
<div style={{ fontSize: 22, fontWeight: 700 }}>{fmtEokManagement(yearItem.total_expense || 0)}</div> <div style={{ fontSize: 22, fontWeight: 700 }}>{fmtEokManagement(yearItem.total_expense || 0)}</div>
@@ -5183,14 +5380,14 @@
alignItems: "baseline", alignItems: "baseline",
width: "100%", width: "100%",
height: "auto", height: "auto",
minHeight: 44, minHeight: 40,
boxSizing: "border-box", boxSizing: "border-box",
padding: "10px 12px", padding: "8px 10px",
borderColor: borderColor:
selectedManagementYear === yearItem.year && selectedManagementYear === yearItem.year &&
selectedManagementCategory === category.name selectedManagementCategory === category.name
? "var(--blue-700)" ? "#d8e4ef"
: "var(--line)", : "transparent",
color: color:
selectedManagementYear === yearItem.year && selectedManagementYear === yearItem.year &&
selectedManagementCategory === category.name selectedManagementCategory === category.name
@@ -5199,8 +5396,13 @@
background: background:
selectedManagementYear === yearItem.year && selectedManagementYear === yearItem.year &&
selectedManagementCategory === category.name selectedManagementCategory === category.name
? "rgba(45, 106, 176, 0.08)" ? "#f8fbfe"
: "white", : "transparent",
boxShadow:
selectedManagementYear === yearItem.year &&
selectedManagementCategory === category.name
? "inset 3px 0 0 var(--blue)"
: "none",
}} }}
> >
<span className="subtle" style={{ color: "inherit" }}>{category.name}</span> <span className="subtle" style={{ color: "inherit" }}>{category.name}</span>
@@ -5209,9 +5411,9 @@
))} ))}
<div <div
style={{ style={{
marginTop: 2, marginTop: 4,
paddingTop: 10, paddingTop: 10,
borderTop: "1px dashed var(--line)", borderTop: "1px dashed #dbe5ef",
}} }}
> >
<button <button
@@ -5224,45 +5426,49 @@
}} }}
style={{ style={{
display: "grid", display: "grid",
gridTemplateColumns: "repeat(4, minmax(0, 1fr))", gridTemplateColumns: "1.35fr 1fr 1fr 1fr",
alignItems: "center", alignItems: "center",
columnGap: 0, columnGap: 12,
width: "100%", width: "100%",
height: "auto", height: "auto",
minHeight: 64, minHeight: 68,
boxSizing: "border-box", boxSizing: "border-box",
padding: "8px 12px", padding: "8px 10px",
borderColor: borderColor:
selectedManagementExcludedYear === yearItem.year selectedManagementExcludedYear === yearItem.year
? "rgba(214, 67, 67, 0.35)" ? "rgba(214, 67, 67, 0.25)"
: "var(--line)", : "transparent",
color: color:
selectedManagementExcludedYear === yearItem.year selectedManagementExcludedYear === yearItem.year
? "#b23a3a" ? "#b23a3a"
: "var(--text)", : "var(--text)",
background: background:
selectedManagementExcludedYear === yearItem.year selectedManagementExcludedYear === yearItem.year
? "rgba(214, 67, 67, 0.06)" ? "rgba(214, 67, 67, 0.05)"
: "rgba(246, 249, 253, 0.72)", : "transparent",
boxShadow:
selectedManagementExcludedYear === yearItem.year
? "inset 3px 0 0 #d14343"
: "none",
}} }}
> >
<div style={{ minWidth: 0, textAlign: "left", justifySelf: "center" }}> <div style={{ minWidth: 0, textAlign: "left" }}>
<div style={{ fontSize: 13, fontWeight: 700, color: "inherit", lineHeight: 1.2 }}>기타 수지/자산</div> <div style={{ fontSize: 13, fontWeight: 700, color: "inherit", lineHeight: 1.2 }}>기타 수지/자산</div>
<div className="subtle" style={{ color: "inherit", opacity: 0.75, fontSize: 11, marginTop: 2 }}>집계 제외 계정</div> <div className="subtle" style={{ color: "inherit", opacity: 0.75, fontSize: 11, marginTop: 2 }}>집계 제외 계정</div>
</div> </div>
<div style={{ textAlign: "center", justifySelf: "center", minWidth: 0 }}> <div style={{ textAlign: "center", minWidth: 0 }}>
<div className="subtle" style={{ color: "inherit", opacity: 0.75, fontSize: 11, marginBottom: 2 }}>출금</div> <div className="subtle" style={{ color: "inherit", opacity: 0.75, fontSize: 11, marginBottom: 2 }}>출금</div>
<strong style={{ fontSize: 18, lineHeight: 1.1, fontWeight: 700 }}> <strong style={{ fontSize: 18, lineHeight: 1.1, fontWeight: 700 }}>
{fmtEokManagement(yearItem.excluded_expense_total || 0)} {fmtEokManagement(yearItem.excluded_expense_total || 0)}
</strong> </strong>
</div> </div>
<div style={{ textAlign: "center", justifySelf: "center", minWidth: 0 }}> <div style={{ textAlign: "center", minWidth: 0 }}>
<div className="subtle" style={{ color: "inherit", opacity: 0.75, fontSize: 11, marginBottom: 2 }}>입금</div> <div className="subtle" style={{ color: "inherit", opacity: 0.75, fontSize: 11, marginBottom: 2 }}>입금</div>
<strong style={{ fontSize: 18, lineHeight: 1.1, fontWeight: 700 }}> <strong style={{ fontSize: 18, lineHeight: 1.1, fontWeight: 700 }}>
{fmtEokManagement(yearItem.excluded_income_total || 0)} {fmtEokManagement(yearItem.excluded_income_total || 0)}
</strong> </strong>
</div> </div>
<div style={{ textAlign: "center", justifySelf: "center", minWidth: 0 }}> <div style={{ textAlign: "center", minWidth: 0 }}>
<div className="subtle" style={{ color: "inherit", opacity: 0.75, fontSize: 11, marginBottom: 2 }}>차액</div> <div className="subtle" style={{ color: "inherit", opacity: 0.75, fontSize: 11, marginBottom: 2 }}>차액</div>
<strong style={{ fontSize: 18, lineHeight: 1.1 }}> <strong style={{ fontSize: 18, lineHeight: 1.1 }}>
{fmtEokManagement((Number(yearItem.excluded_expense_total || 0) - Number(yearItem.excluded_income_total || 0)))} {fmtEokManagement((Number(yearItem.excluded_expense_total || 0) - Number(yearItem.excluded_income_total || 0)))}
@@ -5273,7 +5479,7 @@
</div> </div>
</div> </div>
))} ))}
{!managementOverviewLoading && !(managementOverview.items || []).length && ( {!managementOverviewLoading && !visibleManagementOverviewItems.length && (
<div className="empty">표시할 연도별 관리 금액이 없습니다.</div> <div className="empty">표시할 연도별 관리 금액이 없습니다.</div>
)} )}
</div> </div>
@@ -5403,6 +5609,120 @@
</section> </section>
)} )}
{managementYearDetailModalOpen && (
<div className="modal-backdrop" onClick={() => setManagementYearDetailModalOpen(false)}>
<div className="modal-panel modal-panel-wide" onClick={(e) => e.stopPropagation()}>
<div className="modal-head">
<div>
<div style={{ fontSize: 24, fontWeight: 700 }}>년도별 상세</div>
<div className="subtle" style={{ marginTop: 6 }}>
관리 5 항목과 기타 수지/자산을 연도별 표로 비교합니다.
</div>
</div>
<button className="button-muted" onClick={() => setManagementYearDetailModalOpen(false)}>닫기</button>
</div>
{sortedManagementOverviewItems.length ? (
<div style={{ marginTop: 18, overflowX: "auto" }}>
<table style={{ width: "100%", borderCollapse: "separate", borderSpacing: 0 }}>
<thead>
<tr>
{[
"연도",
"총 지출",
"일반운영비",
"법정,의무",
"외부전문,전략",
"안전관리비",
"인건비",
"기타 수지/자산",
].map((label, index) => (
<th
key={label}
style={{
textAlign: index === 0 ? "left" : "right",
padding: "12px 14px",
fontSize: 13,
fontWeight: 700,
color: "var(--subtle)",
borderBottom: "1px solid var(--line)",
background: "rgba(246, 249, 253, 0.82)",
position: "sticky",
top: 0,
zIndex: 1,
}}
>
{label}
</th>
))}
</tr>
</thead>
<tbody>
{sortedManagementOverviewItems.map((yearItem) => {
const excludedDiff =
Number(yearItem.excluded_expense_total || 0) -
Number(yearItem.excluded_income_total || 0);
return (
<tr key={`management-year-detail-${yearItem.year}`}>
<td style={{ padding: "14px", borderBottom: "1px solid var(--line)", fontWeight: 700 }}>
{yearItem.year}
</td>
<td style={{ padding: "14px", borderBottom: "1px solid var(--line)", textAlign: "right", fontWeight: 700 }}>
{fmtEokManagement(yearItem.total_expense || 0)}
</td>
{[
"일반운영비",
"법정,의무",
"외부전문,전략",
"안전관리비",
"인건비",
].map((categoryName) => (
<td key={`${yearItem.year}-${categoryName}`} style={{ padding: "14px", borderBottom: "1px solid var(--line)", textAlign: "right" }}>
<button
type="button"
className="button-link"
onClick={() => {
setSelectedManagementYear(yearItem.year);
setSelectedManagementCategory(categoryName);
setSelectedManagementExcludedYear("");
setManagementYearDetailModalOpen(false);
}}
style={{ fontWeight: 700 }}
>
{fmtEokManagement(getManagementCategoryAmount(yearItem, categoryName))}
</button>
</td>
))}
<td style={{ padding: "14px", borderBottom: "1px solid var(--line)", textAlign: "right" }}>
<button
type="button"
className="button-link"
onClick={() => {
setSelectedManagementYear("");
setSelectedManagementCategory("");
setSelectedManagementExcludedYear(yearItem.year);
setManagementYearDetailModalOpen(false);
}}
style={{ fontWeight: 700, color: excludedDiff < 0 ? "#d14343" : "var(--text)" }}
>
{fmtEokManagement(excludedDiff)}
</button>
<div className="subtle" style={{ marginTop: 4, fontSize: 11 }}>
출금 {fmtEokManagement(yearItem.excluded_expense_total || 0)} / 입금 {fmtEokManagement(yearItem.excluded_income_total || 0)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<div className="empty-state">표시할 연도별 관리 금액이 없습니다.</div>
)}
</div>
</div>
)}
{managementAccountModal && ( {managementAccountModal && (
<div className="modal-backdrop" onClick={() => setManagementAccountModal(null)}> <div className="modal-backdrop" onClick={() => setManagementAccountModal(null)}>
<div className="modal-panel modal-panel-wide" onClick={(e) => e.stopPropagation()}> <div className="modal-panel modal-panel-wide" onClick={(e) => e.stopPropagation()}>
@@ -5674,10 +5994,10 @@
<g> <g>
<line x1="90" y1="320" x2="980" y2="320" stroke="#d7e4f0" strokeWidth="1.5" /> <line x1="90" y1="320" x2="980" y2="320" stroke="#d7e4f0" strokeWidth="1.5" />
{[0.25, 0.5, 0.75, 1].map((ratio) => { {[0.25, 0.5, 0.75, 1].map((ratio) => {
const y = 320 - ratio * 250; const y = companyGraphLayout.baseY - ratio * companyGraphLayout.chartHeight;
return ( return (
<g key={`company-grid-${ratio}`}> <g key={`company-grid-${ratio}`}>
<line x1="90" y1={y} x2="980" y2={y} stroke="#eef4fa" strokeWidth="1" /> <line x1={companyGraphLayout.chartLeft} y1={y} x2={companyGraphLayout.chartRight} y2={y} stroke="#eef4fa" strokeWidth="1" />
<text x="24" y={y + 4} style={{ fontSize: 12, fill: "#90a0b4" }}> <text x="24" y={y + 4} style={{ fontSize: 12, fill: "#90a0b4" }}>
{fmtEokManagement(companyGraphMaxValue * ratio)} {fmtEokManagement(companyGraphMaxValue * ratio)}
</text> </text>
@@ -5685,10 +6005,10 @@
); );
})} })}
{companyGraphRows.map((row, index) => { {companyGraphRows.map((row, index) => {
const centerX = 150 + index * (companyGraphRows.length > 1 ? 260 : 0); const centerX = companyGraphLayout.centerStartX + index * companyGraphLayout.stepX;
const barWidth = 94; const barWidth = companyGraphLayout.barWidth;
const stackBaseY = 320; const stackBaseY = companyGraphLayout.baseY;
const chartHeight = 250; const chartHeight = companyGraphLayout.chartHeight;
const stackItems = [...row.typeItems].sort((a, b) => { const stackItems = [...row.typeItems].sort((a, b) => {
const stackOrder = { "관리": 0, "시공": 999 }; const stackOrder = { "관리": 0, "시공": 999 };
const aFixed = Object.prototype.hasOwnProperty.call(stackOrder, a.project_type || "") ? stackOrder[a.project_type] : 100; const aFixed = Object.prototype.hasOwnProperty.call(stackOrder, a.project_type || "") ? stackOrder[a.project_type] : 100;
@@ -5720,11 +6040,14 @@
</rect> </rect>
); );
})} })}
<text x={centerX} y="352" textAnchor="middle" style={{ fontSize: 18, fontWeight: 700, fill: "#243447" }}> <text x={centerX} y="354" textAnchor="middle" style={{ fontSize: 17, fontWeight: 700, fill: "#243447" }}>
{row.year} {row.year}
</text> </text>
<text x={centerX} y="372" textAnchor="middle" style={{ fontSize: 12, fill: "#6b7a90" }}> <text x={centerX} y="384" textAnchor="middle" style={{ fontSize: 10, fontWeight: 700, fill: "#90a0b4" }}>
지출 {fmtEokManagement(row.expense_supply || 0)} 지출
</text>
<text x={centerX} y="398" textAnchor="middle" style={{ fontSize: 11, fill: "#6b7a90" }}>
{fmtEokManagement(row.expense_supply || 0)}
</text> </text>
</g> </g>
); );
@@ -5739,13 +6062,26 @@
filter="url(#companyLineShadow)" filter="url(#companyLineShadow)"
/> />
{companyGraphRows.map((row, index) => { {companyGraphRows.map((row, index) => {
const x = 150 + index * (companyGraphRows.length > 1 ? 260 : 0); const x = companyGraphLayout.centerStartX + index * companyGraphLayout.stepX;
const y = 320 - (Number(row.income_supply || 0) / companyGraphMaxValue) * 250; const y = companyGraphLayout.baseY - (Number(row.income_supply || 0) / companyGraphMaxValue) * companyGraphLayout.chartHeight;
return ( return (
<g key={`point-${row.year}`}> <g key={`point-${row.year}`}>
<circle cx={x} cy={y} r="7" fill="#143f67" /> <circle cx={x} cy={y} r="7" fill="#143f67" />
<circle cx={x} cy={y} r="3" fill="#ffffff" /> <circle cx={x} cy={y} r="3" fill="#ffffff" />
<text x={x} y={y - 14} textAnchor="middle" style={{ fontSize: 12, fontWeight: 700, fill: "#143f67" }}> <text
x={x}
y={y - 14}
textAnchor="middle"
style={{
fontSize: 12,
fontWeight: 700,
fill: "#143f67",
stroke: "#ffffff",
strokeWidth: 4,
paintOrder: "stroke",
strokeLinejoin: "round",
}}
>
{fmtEokManagement(row.income_supply || 0)} {fmtEokManagement(row.income_supply || 0)}
</text> </text>
</g> </g>

View File

@@ -14,7 +14,7 @@ from zipfile import ZipFile
BASE_DIR = Path("/home/hyein/project") BASE_DIR = Path("/home/hyein/project")
DEFAULT_XLSX_PATH = BASE_DIR / "PTC(2023-2026.02).xlsx" DEFAULT_XLSX_PATH = BASE_DIR / "PTC 입출금내역(2015~).xlsx"
XLSX_SOURCE_CONFIG_PATH = BASE_DIR / "server" / "ptc_source_path.txt" XLSX_SOURCE_CONFIG_PATH = BASE_DIR / "server" / "ptc_source_path.txt"
METHOD_XLSX_PATH = BASE_DIR / "PTC공법.xlsx" METHOD_XLSX_PATH = BASE_DIR / "PTC공법.xlsx"
DB_PATH = BASE_DIR / "db" / "ptc_local.sqlite3" DB_PATH = BASE_DIR / "db" / "ptc_local.sqlite3"
@@ -375,6 +375,24 @@ def normalize_transaction_type(in_out: str, account_name: str) -> str:
return "unknown" return "unknown"
def correct_transaction_date(
transaction_date: str,
raw_value: str,
project_code: str,
account_code: str,
description: str,
) -> str:
if (
transaction_date == "2106-10-31"
and raw_value == "75545"
and project_code == "15-시공-25"
and account_code == "711"
and (description or "").strip() == "강관말뚝"
):
return "2016-10-31"
return transaction_date
def read_xlsx_rows(path: Path) -> list[dict]: def read_xlsx_rows(path: Path) -> list[dict]:
with ZipFile(path) as book: with ZipFile(path) as book:
shared_strings = [] shared_strings = []
@@ -405,36 +423,72 @@ def read_xlsx_rows(path: Path) -> list[dict]:
if not rows: if not rows:
return [] return []
headers = rows[0] header_row_index = 0
data_rows = rows[1:] headers = rows[0] if rows else []
for idx, candidate in enumerate(rows):
normalized = [str(cell).strip() for cell in candidate if str(cell).strip()]
if "거래일" in normalized and "입/출금" in normalized and "계정코드" in normalized:
header_row_index = idx
headers = candidate
break
data_rows = rows[header_row_index + 1 :]
width = len(headers) width = len(headers)
items = [] items = []
for source_row_no, row in enumerate(data_rows, start=2):
def payload_get(payload: dict, *keys: str) -> str:
for key in keys:
if key in payload and payload.get(key) not in (None, ""):
return payload.get(key, "")
return ""
for source_row_no, row in enumerate(data_rows, start=header_row_index + 2):
current = row + [""] * (width - len(row)) if len(row) < width else row[:width] current = row + [""] * (width - len(row)) if len(row) < width else row[:width]
payload = dict(zip(headers, current)) payload = dict(zip(headers, current))
transaction_date_raw = payload_get(payload, "거래일")
in_out = payload_get(payload, "입/출금")
account_code = payload_get(payload, "계정코드")
account_name = payload_get(payload, "구분", "계정과목")
department_name = payload_get(payload, "부서")
vendor_name = payload_get(payload, "거래처")
project_code = payload_get(payload, "프로젝트코드")
raw_project_type = payload_get(payload, "프로젝트 구분(안)")
project_name = payload_get(payload, "프로젝트명")
description = payload_get(payload, "적요")
supply_amount_raw = payload_get(payload, "공급가액")
vat_amount_raw = payload_get(payload, "부가세")
total_amount_raw = payload_get(payload, "합계금액")
remarks = payload_get(payload, "비고")
transaction_date = correct_transaction_date(
excel_serial_to_date(transaction_date_raw),
transaction_date_raw,
project_code,
account_code,
description,
)
items.append( items.append(
{ {
"source_row_no": source_row_no, "source_row_no": source_row_no,
"transaction_date_raw": payload.get("거래일", ""), "transaction_date_raw": transaction_date_raw,
"transaction_date": excel_serial_to_date(payload.get("거래일", "")), "transaction_date": transaction_date,
"in_out": payload.get("입/출금", ""), "in_out": in_out,
"account_code": payload.get("계정코드", ""), "account_code": account_code,
"account_name": payload.get("구분", ""), "account_name": account_name,
"department_name": payload.get("부서", ""), "department_name": department_name,
"vendor_name": payload.get("거래처", ""), "vendor_name": vendor_name,
"project_code": payload.get("프로젝트코드", ""), "project_code": project_code,
"project_type": payload.get("프로젝트 구분(안)", ""), "project_type": raw_project_type or infer_project_type_from_code(project_code),
"project_name": payload.get("프로젝트명", ""), "project_name": project_name,
"description": payload.get("적요", ""), "description": description,
"supply_amount_raw": payload.get("공급가액", ""), "supply_amount_raw": supply_amount_raw,
"vat_amount_raw": payload.get("부가세", ""), "vat_amount_raw": vat_amount_raw,
"total_amount_raw": payload.get("합계금액", ""), "total_amount_raw": total_amount_raw,
"remarks": payload.get("비고", ""), "remarks": remarks,
"supply_amount": parse_amount(payload.get("공급가액", "")), "supply_amount": parse_amount(supply_amount_raw),
"vat_amount": parse_amount(payload.get("부가세", "")), "vat_amount": parse_amount(vat_amount_raw),
"total_amount": parse_amount(payload.get("합계금액", "")), "total_amount": parse_amount(total_amount_raw),
"normalized_type": normalize_transaction_type( "normalized_type": normalize_transaction_type(
payload.get("입/출금", ""), payload.get("구분", "") in_out, account_name
), ),
} }
) )

View File

@@ -1 +1 @@
/home/hyein/project/PTC(2023-2026.02).xlsx /home/hyein/project/PTC 입출금내역(2015~).xlsx

View File

@@ -1,5 +1,7 @@
사용 파일 사용 파일
- start_ptc_share.bat : 공유용 실행 파일. 관리자 권한으로 다시 실행되어 WSL 서버 시작, IP 공유 설정, 방화벽 허용, 공유 주소 복사까지 처리합니다. - start_ptc_share.bat : 공유용 실행 파일. 관리자 권한으로 다시 실행되어 WSL 서버 시작, IP 공유 설정, 방화벽 허용, 공유 주소 복사까지 처리합니다.
- install_ptc_share_autostart.bat : Windows 로그인 시 자동으로 공유가 시작되도록 작업 스케줄러에 등록합니다.
- remove_ptc_share_autostart.bat : 자동 실행 등록을 해제합니다.
- set_ptc_source.bat : 사용할 PTC 원본 `.xlsx` 파일을 선택하고 저장한 뒤 서버를 다시 시작합니다. - set_ptc_source.bat : 사용할 PTC 원본 `.xlsx` 파일을 선택하고 저장한 뒤 서버를 다시 시작합니다.
- stop_ptc_share.bat : 공유 중지 - stop_ptc_share.bat : 공유 중지
- check_ptc_share.bat : 현재 공유 상태 확인 - check_ptc_share.bat : 현재 공유 상태 확인

View File

@@ -1,12 +1,6 @@
@echo off @echo off
setlocal EnableExtensions setlocal EnableExtensions
set "HOST_IP=" set "HOST_IP=172.16.40.36"
for /f "usebackq delims=" %%i in (`powershell -NoProfile -Command "$ip = Get-NetIPAddress -AddressFamily IPv4 ^| Where-Object { $_.IPAddress -notlike '127.*' -and $_.IPAddress -notlike '169.254.*' -and $_.PrefixOrigin -ne 'WellKnown' } ^| Sort-Object InterfaceMetric, SkipAsSource ^| Select-Object -ExpandProperty IPAddress -First 1; if ($ip) { $ip }"`) do (
set "HOST_IP=%%i"
)
if "%HOST_IP%"=="" set "HOST_IP=localhost"
echo [Windows portproxy] echo [Windows portproxy]
netsh interface portproxy show v4tov4 netsh interface portproxy show v4tov4
@@ -15,10 +9,13 @@ echo [WSL api]
wsl.exe bash -lc "curl -s http://127.0.0.1:4000/api/health" wsl.exe bash -lc "curl -s http://127.0.0.1:4000/api/health"
echo. echo.
echo [WSL web] echo [WSL web]
wsl.exe bash -lc "curl -I -s http://127.0.0.1:4000/PTC/ | head -n 1" wsl.exe bash -lc "curl -I -s http://127.0.0.1:4000/PTC-lab/ | head -n 1"
echo.
echo [WSL manage web]
wsl.exe bash -lc "curl -I -s http://127.0.0.1:4000/PTC-lab-manage/ | head -n 1"
echo. echo.
echo [Office LAN web] echo [Office LAN web]
powershell -NoProfile -Command "try { (Invoke-WebRequest -Uri 'http://%HOST_IP%:4000/PTC/' -UseBasicParsing -TimeoutSec 5).StatusCode } catch { $_.Exception.Message }" powershell -NoProfile -Command "try { (Invoke-WebRequest -Uri 'http://%HOST_IP%:4000/PTC-lab-manage/' -UseBasicParsing -TimeoutSec 5).StatusCode } catch { $_.Exception.Message }"
echo. echo.
echo [Office LAN api] echo [Office LAN api]
powershell -NoProfile -Command "try { (Invoke-WebRequest -Uri 'http://%HOST_IP%:4000/api/health' -UseBasicParsing -TimeoutSec 5).Content } catch { $_.Exception.Message }" powershell -NoProfile -Command "try { (Invoke-WebRequest -Uri 'http://%HOST_IP%:4000/api/health' -UseBasicParsing -TimeoutSec 5).Content } catch { $_.Exception.Message }"

View File

@@ -1,3 +1,8 @@
param(
[switch]$NoBrowser,
[switch]$NoPause
)
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
function Test-IsAdmin { function Test-IsAdmin {
@@ -7,17 +12,22 @@ function Test-IsAdmin {
} }
if (-not (Test-IsAdmin)) { if (-not (Test-IsAdmin)) {
Start-Process -FilePath "powershell.exe" -Verb RunAs -ArgumentList @( $childArgs = @(
"-NoProfile", "-NoProfile",
"-ExecutionPolicy", "Bypass", "-ExecutionPolicy", "Bypass",
"-File", "`"$PSCommandPath`"" "-File", "`"$PSCommandPath`""
) )
if ($NoBrowser) { $childArgs += "-NoBrowser" }
if ($NoPause) { $childArgs += "-NoPause" }
Start-Process -FilePath "powershell.exe" -Verb RunAs -ArgumentList @(
$childArgs
)
exit 0 exit 0
} }
$projectDir = "/home/hyein/project" $projectDir = "/home/hyein/project"
$apiPort = 4000 $apiPort = 4000
$localUrl = "http://localhost:$apiPort/PTC/" $localUrl = "http://localhost:$apiPort/PTC-lab-manage/"
$preferredLanIp = "172.16.40.36" $preferredLanIp = "172.16.40.36"
$defaultSource = "/home/hyein/project/PTC(2023-2026.02).xlsx" $defaultSource = "/home/hyein/project/PTC(2023-2026.02).xlsx"
$sourceConfigPath = "/home/hyein/project/server/ptc_source_path.txt" $sourceConfigPath = "/home/hyein/project/server/ptc_source_path.txt"
@@ -43,30 +53,39 @@ Write-Host "Source file: $currentSource"
$startResult = Invoke-WslBash "pkill -f '/home/hyein/project/server/ptc_api_server.py' >/dev/null 2>&1 || true; nohup python3 /home/hyein/project/server/ptc_api_server.py >/tmp/ptc_api.log 2>&1 & sleep 3" $startResult = Invoke-WslBash "pkill -f '/home/hyein/project/server/ptc_api_server.py' >/dev/null 2>&1 || true; nohup python3 /home/hyein/project/server/ptc_api_server.py >/tmp/ptc_api.log 2>&1 & sleep 3"
if ($startResult.ExitCode -ne 0) { if ($startResult.ExitCode -ne 0) {
Write-Host "Failed to start the server in WSL." -ForegroundColor Red Write-Host "Failed to start the server in WSL." -ForegroundColor Red
if (-not $NoPause) {
Read-Host "Press Enter to exit" Read-Host "Press Enter to exit"
}
exit 1 exit 1
} }
$healthResult = Invoke-WslBash "curl -fsS http://127.0.0.1:$apiPort/api/health >/tmp/ptc_api_health.json && curl -fsSI http://127.0.0.1:$apiPort/PTC/ >/tmp/ptc_web_health.txt" $healthResult = Invoke-WslBash "curl -fsS http://127.0.0.1:$apiPort/api/health >/tmp/ptc_api_health.json && curl -fsSI http://127.0.0.1:$apiPort/PTC-lab/ >/tmp/ptc_web_health.txt && curl -fsSI http://127.0.0.1:$apiPort/PTC-lab-manage/ >/tmp/ptc_manage_web_health.txt"
if ($healthResult.ExitCode -ne 0) { if ($healthResult.ExitCode -ne 0) {
Write-Host "Local server check failed. Recent server log:" -ForegroundColor Red Write-Host "Local server check failed. Recent server log:" -ForegroundColor Red
$logResult = Invoke-WslBash "tail -n 80 /tmp/ptc_api.log" $logResult = Invoke-WslBash "tail -n 80 /tmp/ptc_api.log"
$logResult.Output | ForEach-Object { Write-Host $_ } $logResult.Output | ForEach-Object { Write-Host $_ }
if (-not $NoPause) {
Read-Host "Press Enter to exit" Read-Host "Press Enter to exit"
}
exit 1 exit 1
} }
Start-Process $localUrl if (-not $NoBrowser) {
Start-Process $localUrl
}
Write-Host "" Write-Host ""
Write-Host "Local URL: $localUrl" Write-Host "Local URL: $localUrl"
$lanIp = Get-NetIPAddress -AddressFamily IPv4 | $lanIps = @(Get-NetIPAddress -AddressFamily IPv4 |
Where-Object { Where-Object {
$_.IPAddress -notlike '127.*' -and $_.IPAddress -notlike '127.*' -and
$_.IPAddress -notlike '169.254.*' -and $_.IPAddress -notlike '169.254.*' -and
$_.PrefixOrigin -ne 'WellKnown' $_.PrefixOrigin -ne 'WellKnown'
} | } |
Select-Object -ExpandProperty IPAddress -First 1 Sort-Object InterfaceMetric, SkipAsSource |
Select-Object -ExpandProperty IPAddress)
$lanIp = $lanIps | Select-Object -First 1
$wslIpResult = Invoke-WslBash "hostname -I | awk '{print \$1}'" $wslIpResult = Invoke-WslBash "hostname -I | awk '{print \$1}'"
$wslIp = ($wslIpResult.Output | Select-Object -First 1).Trim() $wslIp = ($wslIpResult.Output | Select-Object -First 1).Trim()
@@ -78,14 +97,39 @@ if (-not [string]::IsNullOrWhiteSpace($lanIp) -and -not [string]::IsNullOrWhiteS
& netsh advfirewall firewall delete rule name="PTC 4000" | Out-Null & netsh advfirewall firewall delete rule name="PTC 4000" | Out-Null
& netsh advfirewall firewall add rule name="PTC 4000" dir=in action=allow protocol=TCP localport=$apiPort | Out-Null & netsh advfirewall firewall add rule name="PTC 4000" dir=in action=allow protocol=TCP localport=$apiPort | Out-Null
$shareIp = if ([string]::IsNullOrWhiteSpace($preferredLanIp)) { $lanIp } else { $preferredLanIp } if (-not ($lanIps -contains $preferredLanIp)) {
$shareUrl = "http://$shareIp:$apiPort/PTC/" Write-Host "Preferred share IP $preferredLanIp is not assigned on this PC." -ForegroundColor Red
Write-Host "Detected Windows IPs: $($lanIps -join ', ')" -ForegroundColor Yellow
if (-not $NoPause) {
Read-Host "Press Enter to exit"
}
exit 1
}
$shareIp = $preferredLanIp
$shareUrl = "http://$shareIp:$apiPort/PTC-lab-manage/"
Write-Host "Windows IP: $lanIp"
Write-Host "Preferred share IP in use: $shareIp"
Write-Host "WSL IP: $wslIp"
Write-Host "LAN URL: $shareUrl" Write-Host "LAN URL: $shareUrl"
Set-Clipboard -Value $shareUrl Set-Clipboard -Value $shareUrl
Write-Host "The share URL has been copied to the clipboard." Write-Host "The share URL has been copied to the clipboard."
try {
$lanWebStatus = (Invoke-WebRequest -Uri $shareUrl -UseBasicParsing -TimeoutSec 5).StatusCode
$lanApiStatus = (Invoke-WebRequest -Uri "http://$shareIp:$apiPort/api/health" -UseBasicParsing -TimeoutSec 5).StatusCode
Write-Host "LAN web check: $lanWebStatus"
Write-Host "LAN api check: $lanApiStatus"
}
catch {
Write-Host "LAN check failed for $shareIp:$apiPort" -ForegroundColor Red
Write-Host $_.Exception.Message -ForegroundColor Yellow
}
} }
else { else {
Write-Host "LAN sharing was skipped because Windows IP or WSL IP could not be detected." Write-Host "LAN sharing was skipped because Windows IP or WSL IP could not be detected."
} }
Read-Host "Press Enter to close" if (-not $NoPause) {
Read-Host "Press Enter to close"
}