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);
}
.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>

View File

@@ -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
),
}
)

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 공유 설정, 방화벽 허용, 공유 주소 복사까지 처리합니다.
- 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 : 현재 공유 상태 확인

View File

@@ -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 }"

View File

@@ -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"
}