feat: update manage dashboard flows
This commit is contained in:
@@ -102,11 +102,11 @@
|
||||
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);
|
||||
background: rgba(248, 251, 254, 0.82);
|
||||
border: 1px solid rgba(232, 239, 246, 0.95);
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 3px 10px rgba(15, 28, 46, 0.025);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.layout {
|
||||
display: grid;
|
||||
@@ -219,9 +219,9 @@
|
||||
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));
|
||||
border: 1px solid #eef3f8;
|
||||
border-radius: 16px;
|
||||
background: rgba(255,255,255,0.9);
|
||||
padding: 16px;
|
||||
}
|
||||
.summary-card-grid {
|
||||
@@ -243,10 +243,11 @@
|
||||
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;
|
||||
border: 1px solid rgba(233, 240, 247, 0.68);
|
||||
border-radius: 12px;
|
||||
background: rgba(255,255,255,0.7);
|
||||
box-shadow: none;
|
||||
padding: 14px;
|
||||
}
|
||||
.metric-value {
|
||||
margin-top: 8px;
|
||||
@@ -280,41 +281,41 @@
|
||||
.project-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
gap: 4px;
|
||||
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;
|
||||
border: none;
|
||||
border-bottom: 1px solid rgba(233, 240, 247, 0.78);
|
||||
border-radius: 0;
|
||||
padding: 12px 8px 12px 6px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
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 {
|
||||
border-color: var(--blue);
|
||||
box-shadow: inset 0 0 0 1px var(--blue);
|
||||
background: linear-gradient(180deg, #f7fbff, #eef5fb);
|
||||
box-shadow: inset 3px 0 0 var(--blue);
|
||||
background: #f8fbfe;
|
||||
}
|
||||
.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;
|
||||
border: none;
|
||||
border-bottom: 1px solid rgba(233, 240, 247, 0.78);
|
||||
border-radius: 0;
|
||||
padding: 12px 8px 12px 6px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
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 {
|
||||
border-color: var(--blue);
|
||||
box-shadow: inset 0 0 0 1px var(--blue);
|
||||
background: linear-gradient(180deg, #f7fbff, #eef5fb);
|
||||
box-shadow: inset 3px 0 0 var(--blue);
|
||||
background: #f8fbfe;
|
||||
}
|
||||
.list-mode-toggle {
|
||||
display: grid;
|
||||
@@ -535,10 +536,10 @@
|
||||
width: 100%;
|
||||
}
|
||||
.dashboard-status-legend-item {
|
||||
border: 1px solid #e6eef6;
|
||||
border-radius: 14px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(255,255,255,0.88);
|
||||
border: 1px solid rgba(233, 240, 247, 0.5);
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
background: rgba(255,255,255,0.46);
|
||||
}
|
||||
button.dashboard-status-legend-item {
|
||||
width: 100%;
|
||||
@@ -547,13 +548,14 @@
|
||||
transition: 0.18s ease;
|
||||
}
|
||||
button.dashboard-status-legend-item:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: #bfd3e5;
|
||||
transform: none;
|
||||
border-color: #d2deea;
|
||||
background: rgba(248,251,254,0.8);
|
||||
}
|
||||
button.dashboard-status-legend-item.active {
|
||||
border-color: var(--blue);
|
||||
box-shadow: inset 0 0 0 1px var(--blue);
|
||||
background: linear-gradient(180deg, #f7fbff, #eef5fb);
|
||||
border-color: #d8e4ef;
|
||||
box-shadow: inset 3px 0 0 var(--blue);
|
||||
background: #f8fbfe;
|
||||
}
|
||||
.dashboard-status-legend-item.dashboard-status-inline-item {
|
||||
display: flex;
|
||||
@@ -630,10 +632,10 @@
|
||||
margin-top: 16px;
|
||||
}
|
||||
.dashboard-family-chart-row {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(244,248,252,0.98));
|
||||
padding: 14px 16px;
|
||||
border: 1px solid rgba(233, 240, 247, 0.62);
|
||||
border-radius: 12px;
|
||||
background: rgba(255,255,255,0.5);
|
||||
padding: 12px 14px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 120px;
|
||||
gap: 14px;
|
||||
@@ -642,13 +644,14 @@
|
||||
transition: 0.18s ease;
|
||||
}
|
||||
.dashboard-family-chart-row:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: #bfd3e5;
|
||||
transform: none;
|
||||
border-color: #d8e4ef;
|
||||
background: rgba(248,251,254,0.78);
|
||||
}
|
||||
.dashboard-family-chart-row.active {
|
||||
border-color: var(--blue);
|
||||
box-shadow: inset 0 0 0 1px var(--blue);
|
||||
background: linear-gradient(180deg, #f7fbff, #eef5fb);
|
||||
border-color: #d8e4ef;
|
||||
box-shadow: inset 3px 0 0 var(--blue);
|
||||
background: #f8fbfe;
|
||||
}
|
||||
.dashboard-family-row-main {
|
||||
display: grid;
|
||||
@@ -660,10 +663,10 @@
|
||||
margin-top: 16px;
|
||||
}
|
||||
.dashboard-method-row {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(244,248,252,0.98));
|
||||
padding: 14px 16px;
|
||||
border: 1px solid rgba(233, 240, 247, 0.62);
|
||||
border-radius: 12px;
|
||||
background: rgba(255,255,255,0.5);
|
||||
padding: 12px 14px;
|
||||
display: grid;
|
||||
grid-template-columns: 92px minmax(0, 1fr) 180px;
|
||||
gap: 16px;
|
||||
@@ -672,13 +675,14 @@
|
||||
transition: 0.18s ease;
|
||||
}
|
||||
.dashboard-method-row:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: #bfd3e5;
|
||||
transform: none;
|
||||
border-color: #d8e4ef;
|
||||
background: rgba(248,251,254,0.78);
|
||||
}
|
||||
.dashboard-method-row.active {
|
||||
border-color: var(--blue);
|
||||
box-shadow: inset 0 0 0 1px var(--blue);
|
||||
background: linear-gradient(180deg, #f7fbff, #eef5fb);
|
||||
border-color: #d8e4ef;
|
||||
box-shadow: inset 3px 0 0 var(--blue);
|
||||
background: #f8fbfe;
|
||||
}
|
||||
.dashboard-method-row-main {
|
||||
display: grid;
|
||||
@@ -696,9 +700,9 @@
|
||||
margin-top: 14px;
|
||||
}
|
||||
.dashboard-selection-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(244,248,252,0.98));
|
||||
border: 1px solid rgba(233, 240, 247, 0.62);
|
||||
border-radius: 14px;
|
||||
background: rgba(255,255,255,0.54);
|
||||
padding: 16px;
|
||||
}
|
||||
.dashboard-finance-bars {
|
||||
@@ -726,17 +730,25 @@
|
||||
.dashboard-selection-kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
gap: 0;
|
||||
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 {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
}
|
||||
.dashboard-selection-kpi {
|
||||
border: 1px solid #e6eef6;
|
||||
border-radius: 14px;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
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 {
|
||||
display: grid;
|
||||
@@ -744,21 +756,22 @@
|
||||
gap: 12px;
|
||||
}
|
||||
.dashboard-family-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(244,248,252,0.98));
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(233, 240, 247, 0.62);
|
||||
border-radius: 12px;
|
||||
background: rgba(255,255,255,0.52);
|
||||
padding: 14px;
|
||||
cursor: pointer;
|
||||
transition: 0.18s ease;
|
||||
}
|
||||
.dashboard-family-card:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: #bfd3e5;
|
||||
transform: none;
|
||||
border-color: #d8e4ef;
|
||||
background: rgba(248,251,254,0.78);
|
||||
}
|
||||
.dashboard-family-card.active {
|
||||
border-color: var(--blue);
|
||||
box-shadow: inset 0 0 0 1px var(--blue);
|
||||
background: linear-gradient(180deg, #f7fbff, #eef5fb);
|
||||
border-color: #d8e4ef;
|
||||
box-shadow: inset 3px 0 0 var(--blue);
|
||||
background: #f8fbfe;
|
||||
}
|
||||
.dashboard-family-mini-track {
|
||||
display: flex;
|
||||
@@ -1841,8 +1854,11 @@
|
||||
const [selectedManagementYear, setSelectedManagementYear] = useState("");
|
||||
const [selectedManagementCategory, setSelectedManagementCategory] = useState("");
|
||||
const [selectedManagementExcludedYear, setSelectedManagementExcludedYear] = useState("");
|
||||
const [managementYearWindowStart, setManagementYearWindowStart] = useState(0);
|
||||
const MANAGEMENT_YEAR_WINDOW_SIZE = 4;
|
||||
const [managementOverviewAccounts, setManagementOverviewAccounts] = useState([]);
|
||||
const [managementOverviewAccountsLoading, setManagementOverviewAccountsLoading] = useState(false);
|
||||
const [managementYearDetailModalOpen, setManagementYearDetailModalOpen] = useState(false);
|
||||
const [managementAccountModal, setManagementAccountModal] = useState(null);
|
||||
const [managementAccountModalLoading, setManagementAccountModalLoading] = useState(false);
|
||||
const [managementAccountModalView, setManagementAccountModalView] = useState("all");
|
||||
@@ -1877,6 +1893,7 @@
|
||||
: null
|
||||
);
|
||||
const [dashboardOngoingProjectModalOpen, setDashboardOngoingProjectModalOpen] = useState(false);
|
||||
const [dashboardYearDetailModalOpen, setDashboardYearDetailModalOpen] = useState(false);
|
||||
const [dashboardMarginGradeFilter, setDashboardMarginGradeFilter] = useState(
|
||||
["all", "deficit", "caution", "good", "excellent"].includes(initialGradeParam) ? initialGradeParam : "all"
|
||||
);
|
||||
@@ -1964,6 +1981,26 @@
|
||||
return params.toString();
|
||||
}, [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 params = new URLSearchParams();
|
||||
if (projectType && projectType !== "전체") params.set("project_type", projectType);
|
||||
@@ -2335,6 +2372,21 @@
|
||||
}
|
||||
}, [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(() => {
|
||||
const hasExcludedSelection = (managementOverview.items || []).some(
|
||||
(item) => item.year === selectedManagementExcludedYear && Number(item.excluded_total || 0) !== 0
|
||||
@@ -2374,6 +2426,12 @@
|
||||
};
|
||||
}, [filteredManagementAccountTransactions]);
|
||||
|
||||
function getManagementCategoryAmount(yearItem, categoryName) {
|
||||
return Number(
|
||||
((yearItem?.categories || []).find((category) => category.name === categoryName)?.amount) || 0
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedManagementYear && selectedManagementCategory && managementCategorySectionRef.current) {
|
||||
managementCategorySectionRef.current.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
@@ -2798,17 +2856,37 @@
|
||||
const companyGraphMaxValue = useMemo(() => {
|
||||
return companyGraphRows.reduce((max, row) => Math.max(max, Number(row.income_supply || 0), Number(row.expense_supply || 0)), 1);
|
||||
}, [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 startX = 150;
|
||||
const stepX = companyGraphRows.length > 1 ? 260 : 0;
|
||||
const chartHeight = 250;
|
||||
const baseY = 320;
|
||||
return companyGraphRows.map((row, index) => {
|
||||
const x = startX + index * stepX;
|
||||
const y = baseY - (Number(row.income_supply || 0) / companyGraphMaxValue) * chartHeight;
|
||||
const x = companyGraphLayout.centerStartX + index * companyGraphLayout.stepX;
|
||||
const y = companyGraphLayout.baseY - (Number(row.income_supply || 0) / companyGraphMaxValue) * companyGraphLayout.chartHeight;
|
||||
return `${x},${y}`;
|
||||
}).join(" ");
|
||||
}, [companyGraphRows, companyGraphMaxValue]);
|
||||
}, [companyGraphRows, companyGraphMaxValue, companyGraphLayout]);
|
||||
const visibleDashboardProjects = useMemo(() => {
|
||||
const filtered = selectedDashboardFamily === "전체"
|
||||
? dashboardProjectsBase
|
||||
@@ -3901,9 +3979,9 @@
|
||||
minHeight: 96,
|
||||
appearance: "none",
|
||||
WebkitAppearance: "none",
|
||||
border: "1px solid #e6eef6",
|
||||
borderRadius: 14,
|
||||
background: "rgba(255,255,255,0.88)",
|
||||
border: "none",
|
||||
borderRadius: 0,
|
||||
background: "transparent",
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
boxSizing: "border-box",
|
||||
@@ -3977,25 +4055,20 @@
|
||||
</div>
|
||||
{!!(managementOverview.yearly_construction_margin_items || []).length && (
|
||||
<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 }}>
|
||||
{(managementOverview.yearly_construction_margin_items || []).map((yearItem) => (
|
||||
<div key={`construction-margin-${yearItem.year}`} className="mini-card" style={{ padding: 14 }}>
|
||||
<div className="subtle">{yearItem.year}년 시공 수익률</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
fontSize: 24,
|
||||
fontWeight: 700,
|
||||
color: (Number(yearItem.margin_rate || 0) || 0) < 0 ? "#d14343" : "var(--good)",
|
||||
}}
|
||||
<div className="mini-card" style={{ padding: 16, display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 16, fontWeight: 700 }}>년도별 시공 상세</div>
|
||||
<div className="subtle" style={{ marginTop: 4 }}>
|
||||
연도별 시공 수익률과 수익 금액을 표로 비교해서 봅니다.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="button-primary"
|
||||
onClick={() => setDashboardYearDetailModalOpen(true)}
|
||||
>
|
||||
{(Number(yearItem.margin_rate || 0) || 0).toFixed(1)}%
|
||||
</div>
|
||||
<div className="subtle" style={{ marginTop: 6 }}>
|
||||
수익 {fmtEokManagement(yearItem.profit_supply || 0)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
년도별 상세 보기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -4214,6 +4287,69 @@
|
||||
{dashboardOngoingProjectModalContent}
|
||||
</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" && (
|
||||
<section className="layout">
|
||||
<aside className="panel" style={{ padding: 18 }}>
|
||||
@@ -5130,24 +5266,66 @@
|
||||
<div style={{ fontSize: 22, fontWeight: 700 }}>년도별 관리 사용금액</div>
|
||||
<div className="subtle" style={{ marginTop: 6 }}>관리 5개 항목 기준으로 출금 금액을 연도별로 봅니다.</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>
|
||||
{!!(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
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${managementOverview.yearly_profit_items.length}, minmax(0, 1fr))`,
|
||||
gap: 12,
|
||||
marginTop: 16,
|
||||
marginBottom: 16,
|
||||
gridTemplateColumns: `repeat(${visibleManagementProfitItems.length}, minmax(0, 1fr))`,
|
||||
gap: 0,
|
||||
marginTop: 14,
|
||||
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) => (
|
||||
<div key={`profit-${yearItem.year}`} className="mini-card" style={{ padding: 14 }}>
|
||||
<div className="subtle">{yearItem.year}년 수익</div>
|
||||
{visibleManagementProfitItems.map((yearItem, index) => (
|
||||
<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
|
||||
style={{
|
||||
marginTop: 8,
|
||||
fontSize: 24,
|
||||
marginTop: 6,
|
||||
fontSize: 18,
|
||||
fontWeight: 700,
|
||||
color: (Number(yearItem.profit_supply || 0) || 0) < 0 ? "#d14343" : "var(--good)",
|
||||
}}
|
||||
@@ -5158,9 +5336,28 @@
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))", gap: 14, marginTop: 16 }}>
|
||||
{(managementOverview.items || []).map((yearItem) => (
|
||||
<div key={yearItem.year} className="mini-card" style={{ padding: 16, minHeight: 430 }}>
|
||||
<div
|
||||
style={{
|
||||
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={{ fontSize: 18, fontWeight: 700 }}>{yearItem.year}년</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 700 }}>{fmtEokManagement(yearItem.total_expense || 0)}</div>
|
||||
@@ -5183,14 +5380,14 @@
|
||||
alignItems: "baseline",
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
minHeight: 44,
|
||||
minHeight: 40,
|
||||
boxSizing: "border-box",
|
||||
padding: "10px 12px",
|
||||
padding: "8px 10px",
|
||||
borderColor:
|
||||
selectedManagementYear === yearItem.year &&
|
||||
selectedManagementCategory === category.name
|
||||
? "var(--blue-700)"
|
||||
: "var(--line)",
|
||||
? "#d8e4ef"
|
||||
: "transparent",
|
||||
color:
|
||||
selectedManagementYear === yearItem.year &&
|
||||
selectedManagementCategory === category.name
|
||||
@@ -5199,8 +5396,13 @@
|
||||
background:
|
||||
selectedManagementYear === yearItem.year &&
|
||||
selectedManagementCategory === category.name
|
||||
? "rgba(45, 106, 176, 0.08)"
|
||||
: "white",
|
||||
? "#f8fbfe"
|
||||
: "transparent",
|
||||
boxShadow:
|
||||
selectedManagementYear === yearItem.year &&
|
||||
selectedManagementCategory === category.name
|
||||
? "inset 3px 0 0 var(--blue)"
|
||||
: "none",
|
||||
}}
|
||||
>
|
||||
<span className="subtle" style={{ color: "inherit" }}>{category.name}</span>
|
||||
@@ -5209,9 +5411,9 @@
|
||||
))}
|
||||
<div
|
||||
style={{
|
||||
marginTop: 2,
|
||||
marginTop: 4,
|
||||
paddingTop: 10,
|
||||
borderTop: "1px dashed var(--line)",
|
||||
borderTop: "1px dashed #dbe5ef",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
@@ -5224,45 +5426,49 @@
|
||||
}}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(4, minmax(0, 1fr))",
|
||||
gridTemplateColumns: "1.35fr 1fr 1fr 1fr",
|
||||
alignItems: "center",
|
||||
columnGap: 0,
|
||||
columnGap: 12,
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
minHeight: 64,
|
||||
minHeight: 68,
|
||||
boxSizing: "border-box",
|
||||
padding: "8px 12px",
|
||||
padding: "8px 10px",
|
||||
borderColor:
|
||||
selectedManagementExcludedYear === yearItem.year
|
||||
? "rgba(214, 67, 67, 0.35)"
|
||||
: "var(--line)",
|
||||
? "rgba(214, 67, 67, 0.25)"
|
||||
: "transparent",
|
||||
color:
|
||||
selectedManagementExcludedYear === yearItem.year
|
||||
? "#b23a3a"
|
||||
: "var(--text)",
|
||||
background:
|
||||
selectedManagementExcludedYear === yearItem.year
|
||||
? "rgba(214, 67, 67, 0.06)"
|
||||
: "rgba(246, 249, 253, 0.72)",
|
||||
? "rgba(214, 67, 67, 0.05)"
|
||||
: "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 className="subtle" style={{ color: "inherit", opacity: 0.75, fontSize: 11, marginTop: 2 }}>집계 제외 계정</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>
|
||||
<strong style={{ fontSize: 18, lineHeight: 1.1, fontWeight: 700 }}>
|
||||
{fmtEokManagement(yearItem.excluded_expense_total || 0)}
|
||||
</strong>
|
||||
</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>
|
||||
<strong style={{ fontSize: 18, lineHeight: 1.1, fontWeight: 700 }}>
|
||||
{fmtEokManagement(yearItem.excluded_income_total || 0)}
|
||||
</strong>
|
||||
</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>
|
||||
<strong style={{ fontSize: 18, lineHeight: 1.1 }}>
|
||||
{fmtEokManagement((Number(yearItem.excluded_expense_total || 0) - Number(yearItem.excluded_income_total || 0)))}
|
||||
@@ -5273,7 +5479,7 @@
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!managementOverviewLoading && !(managementOverview.items || []).length && (
|
||||
{!managementOverviewLoading && !visibleManagementOverviewItems.length && (
|
||||
<div className="empty">표시할 연도별 관리 금액이 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -5403,6 +5609,120 @@
|
||||
</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 && (
|
||||
<div className="modal-backdrop" onClick={() => setManagementAccountModal(null)}>
|
||||
<div className="modal-panel modal-panel-wide" onClick={(e) => e.stopPropagation()}>
|
||||
@@ -5674,10 +5994,10 @@
|
||||
<g>
|
||||
<line x1="90" y1="320" x2="980" y2="320" stroke="#d7e4f0" strokeWidth="1.5" />
|
||||
{[0.25, 0.5, 0.75, 1].map((ratio) => {
|
||||
const y = 320 - ratio * 250;
|
||||
const y = companyGraphLayout.baseY - ratio * companyGraphLayout.chartHeight;
|
||||
return (
|
||||
<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" }}>
|
||||
{fmtEokManagement(companyGraphMaxValue * ratio)}
|
||||
</text>
|
||||
@@ -5685,10 +6005,10 @@
|
||||
);
|
||||
})}
|
||||
{companyGraphRows.map((row, index) => {
|
||||
const centerX = 150 + index * (companyGraphRows.length > 1 ? 260 : 0);
|
||||
const barWidth = 94;
|
||||
const stackBaseY = 320;
|
||||
const chartHeight = 250;
|
||||
const centerX = companyGraphLayout.centerStartX + index * companyGraphLayout.stepX;
|
||||
const barWidth = companyGraphLayout.barWidth;
|
||||
const stackBaseY = companyGraphLayout.baseY;
|
||||
const chartHeight = companyGraphLayout.chartHeight;
|
||||
const stackItems = [...row.typeItems].sort((a, b) => {
|
||||
const stackOrder = { "관리": 0, "시공": 999 };
|
||||
const aFixed = Object.prototype.hasOwnProperty.call(stackOrder, a.project_type || "") ? stackOrder[a.project_type] : 100;
|
||||
@@ -5720,11 +6040,14 @@
|
||||
</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}
|
||||
</text>
|
||||
<text x={centerX} y="372" textAnchor="middle" style={{ fontSize: 12, fill: "#6b7a90" }}>
|
||||
지출 {fmtEokManagement(row.expense_supply || 0)}
|
||||
<text x={centerX} y="384" textAnchor="middle" style={{ fontSize: 10, fontWeight: 700, fill: "#90a0b4" }}>
|
||||
지출
|
||||
</text>
|
||||
<text x={centerX} y="398" textAnchor="middle" style={{ fontSize: 11, fill: "#6b7a90" }}>
|
||||
{fmtEokManagement(row.expense_supply || 0)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
@@ -5739,13 +6062,26 @@
|
||||
filter="url(#companyLineShadow)"
|
||||
/>
|
||||
{companyGraphRows.map((row, index) => {
|
||||
const x = 150 + index * (companyGraphRows.length > 1 ? 260 : 0);
|
||||
const y = 320 - (Number(row.income_supply || 0) / companyGraphMaxValue) * 250;
|
||||
const x = companyGraphLayout.centerStartX + index * companyGraphLayout.stepX;
|
||||
const y = companyGraphLayout.baseY - (Number(row.income_supply || 0) / companyGraphMaxValue) * companyGraphLayout.chartHeight;
|
||||
return (
|
||||
<g key={`point-${row.year}`}>
|
||||
<circle cx={x} cy={y} r="7" fill="#143f67" />
|
||||
<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)}
|
||||
</text>
|
||||
</g>
|
||||
|
||||
@@ -14,7 +14,7 @@ from zipfile import ZipFile
|
||||
|
||||
|
||||
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"
|
||||
METHOD_XLSX_PATH = BASE_DIR / "PTC공법.xlsx"
|
||||
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"
|
||||
|
||||
|
||||
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]:
|
||||
with ZipFile(path) as book:
|
||||
shared_strings = []
|
||||
@@ -405,36 +423,72 @@ def read_xlsx_rows(path: Path) -> list[dict]:
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
headers = rows[0]
|
||||
data_rows = rows[1:]
|
||||
header_row_index = 0
|
||||
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)
|
||||
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]
|
||||
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(
|
||||
{
|
||||
"source_row_no": source_row_no,
|
||||
"transaction_date_raw": payload.get("거래일", ""),
|
||||
"transaction_date": excel_serial_to_date(payload.get("거래일", "")),
|
||||
"in_out": payload.get("입/출금", ""),
|
||||
"account_code": payload.get("계정코드", ""),
|
||||
"account_name": payload.get("구분", ""),
|
||||
"department_name": payload.get("부서", ""),
|
||||
"vendor_name": payload.get("거래처", ""),
|
||||
"project_code": payload.get("프로젝트코드", ""),
|
||||
"project_type": payload.get("프로젝트 구분(안)", ""),
|
||||
"project_name": payload.get("프로젝트명", ""),
|
||||
"description": payload.get("적요", ""),
|
||||
"supply_amount_raw": payload.get("공급가액", ""),
|
||||
"vat_amount_raw": payload.get("부가세", ""),
|
||||
"total_amount_raw": payload.get("합계금액", ""),
|
||||
"remarks": payload.get("비고", ""),
|
||||
"supply_amount": parse_amount(payload.get("공급가액", "")),
|
||||
"vat_amount": parse_amount(payload.get("부가세", "")),
|
||||
"total_amount": parse_amount(payload.get("합계금액", "")),
|
||||
"transaction_date_raw": transaction_date_raw,
|
||||
"transaction_date": transaction_date,
|
||||
"in_out": in_out,
|
||||
"account_code": account_code,
|
||||
"account_name": account_name,
|
||||
"department_name": department_name,
|
||||
"vendor_name": vendor_name,
|
||||
"project_code": project_code,
|
||||
"project_type": raw_project_type or infer_project_type_from_code(project_code),
|
||||
"project_name": project_name,
|
||||
"description": description,
|
||||
"supply_amount_raw": supply_amount_raw,
|
||||
"vat_amount_raw": vat_amount_raw,
|
||||
"total_amount_raw": total_amount_raw,
|
||||
"remarks": remarks,
|
||||
"supply_amount": parse_amount(supply_amount_raw),
|
||||
"vat_amount": parse_amount(vat_amount_raw),
|
||||
"total_amount": parse_amount(total_amount_raw),
|
||||
"normalized_type": normalize_transaction_type(
|
||||
payload.get("입/출금", ""), payload.get("구분", "")
|
||||
in_out, account_name
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1 +1 @@
|
||||
/home/hyein/project/PTC(2023-2026.02).xlsx
|
||||
/home/hyein/project/PTC 입출금내역(2015~).xlsx
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
사용 파일
|
||||
- start_ptc_share.bat : 공유용 실행 파일. 관리자 권한으로 다시 실행되어 WSL 서버 시작, IP 공유 설정, 방화벽 허용, 공유 주소 복사까지 처리합니다.
|
||||
- install_ptc_share_autostart.bat : Windows 로그인 시 자동으로 공유가 시작되도록 작업 스케줄러에 등록합니다.
|
||||
- remove_ptc_share_autostart.bat : 자동 실행 등록을 해제합니다.
|
||||
- set_ptc_source.bat : 사용할 PTC 원본 `.xlsx` 파일을 선택하고 저장한 뒤 서버를 다시 시작합니다.
|
||||
- stop_ptc_share.bat : 공유 중지
|
||||
- check_ptc_share.bat : 현재 공유 상태 확인
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions
|
||||
set "HOST_IP="
|
||||
|
||||
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"
|
||||
set "HOST_IP=172.16.40.36"
|
||||
|
||||
echo [Windows portproxy]
|
||||
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"
|
||||
echo.
|
||||
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 [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 [Office LAN api]
|
||||
powershell -NoProfile -Command "try { (Invoke-WebRequest -Uri 'http://%HOST_IP%:4000/api/health' -UseBasicParsing -TimeoutSec 5).Content } catch { $_.Exception.Message }"
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
param(
|
||||
[switch]$NoBrowser,
|
||||
[switch]$NoPause
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Test-IsAdmin {
|
||||
@@ -7,17 +12,22 @@ function Test-IsAdmin {
|
||||
}
|
||||
|
||||
if (-not (Test-IsAdmin)) {
|
||||
Start-Process -FilePath "powershell.exe" -Verb RunAs -ArgumentList @(
|
||||
$childArgs = @(
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy", "Bypass",
|
||||
"-File", "`"$PSCommandPath`""
|
||||
)
|
||||
if ($NoBrowser) { $childArgs += "-NoBrowser" }
|
||||
if ($NoPause) { $childArgs += "-NoPause" }
|
||||
Start-Process -FilePath "powershell.exe" -Verb RunAs -ArgumentList @(
|
||||
$childArgs
|
||||
)
|
||||
exit 0
|
||||
}
|
||||
|
||||
$projectDir = "/home/hyein/project"
|
||||
$apiPort = 4000
|
||||
$localUrl = "http://localhost:$apiPort/PTC/"
|
||||
$localUrl = "http://localhost:$apiPort/PTC-lab-manage/"
|
||||
$preferredLanIp = "172.16.40.36"
|
||||
$defaultSource = "/home/hyein/project/PTC(2023-2026.02).xlsx"
|
||||
$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"
|
||||
if ($startResult.ExitCode -ne 0) {
|
||||
Write-Host "Failed to start the server in WSL." -ForegroundColor Red
|
||||
if (-not $NoPause) {
|
||||
Read-Host "Press Enter to exit"
|
||||
}
|
||||
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) {
|
||||
Write-Host "Local server check failed. Recent server log:" -ForegroundColor Red
|
||||
$logResult = Invoke-WslBash "tail -n 80 /tmp/ptc_api.log"
|
||||
$logResult.Output | ForEach-Object { Write-Host $_ }
|
||||
if (-not $NoPause) {
|
||||
Read-Host "Press Enter to exit"
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
|
||||
Start-Process $localUrl
|
||||
if (-not $NoBrowser) {
|
||||
Start-Process $localUrl
|
||||
}
|
||||
Write-Host ""
|
||||
Write-Host "Local URL: $localUrl"
|
||||
|
||||
$lanIp = Get-NetIPAddress -AddressFamily IPv4 |
|
||||
$lanIps = @(Get-NetIPAddress -AddressFamily IPv4 |
|
||||
Where-Object {
|
||||
$_.IPAddress -notlike '127.*' -and
|
||||
$_.IPAddress -notlike '169.254.*' -and
|
||||
$_.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}'"
|
||||
$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 add rule name="PTC 4000" dir=in action=allow protocol=TCP localport=$apiPort | Out-Null
|
||||
|
||||
$shareIp = if ([string]::IsNullOrWhiteSpace($preferredLanIp)) { $lanIp } else { $preferredLanIp }
|
||||
$shareUrl = "http://$shareIp:$apiPort/PTC/"
|
||||
if (-not ($lanIps -contains $preferredLanIp)) {
|
||||
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"
|
||||
Set-Clipboard -Value $shareUrl
|
||||
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 {
|
||||
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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user