feat: update manage dashboard flows
This commit is contained in:
1 parent
01a6e197ce
commit
6e8f606591
6 files changed
+626
-193
No files matched your search
@@ -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)",
|
||||
}}
|
||||
>
|
||||
{(Number(yearItem.margin_rate || 0) || 0).toFixed(1)}%
|
||||
</div>
|
||||
<div className="subtle" style={{ marginTop: 6 }}>
|
||||
수익 {fmtEokManagement(yearItem.profit_supply || 0)}
|
||||
</div>
|
||||
<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)}
|
||||
>
|
||||
년도별 상세 보기
|
||||
</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>
|
||||
<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>
|
||||
{!!(managementOverview.yearly_profit_items || []).length && (
|
||||
{!!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>
|
||||
@@ -5208,11 +5410,11 @@
|
||||
</button>
|
||||
))}
|
||||
<div
|
||||
style={{
|
||||
marginTop: 2,
|
||||
paddingTop: 10,
|
||||
borderTop: "1px dashed var(--line)",
|
||||
}}
|
||||
style={{
|
||||
marginTop: 4,
|
||||
paddingTop: 10,
|
||||
borderTop: "1px dashed #dbe5ef",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="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>
|
||||
|
||||
Reference in new issue
Block a user