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);
|
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>
|
||||||
|
|||||||
@@ -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
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 공유 설정, 방화벽 허용, 공유 주소 복사까지 처리합니다.
|
- 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 : 현재 공유 상태 확인
|
||||||
|
|||||||
@@ -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 }"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user