This commit is contained in:
2026-03-23 16:45:11 +09:00
16 changed files with 1185 additions and 805 deletions
+142 -109
View File
@@ -1,20 +1,24 @@
"use client";
import { useEffect, useCallback, useMemo } from "react";
import { useCallback, useEffect, useMemo } from "react";
import { usePathname } from "next/navigation";
import { useQueryClient, useQuery } from "@tanstack/react-query";
import type { MenuItem } from "@/entities/menu";
import { useMenuStore, useAlertStore } from "@/shared/lib";
import { useTabStore, getTabStoreState } from "@/store/tab-store";
import { useTabStore as gridTabStore } from "@rsone/components";
import { getConfig } from "@common/config";
import { getCookie } from "@/shared/lib";
import { getMenuList } from "../../../../packages/common/components/src/api";
import {
getTabStoreState as getGridTabStoreState,
useTabStore as gridTabStore,
} from "@rsone/components";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import { BaseLayout } from "@common/components";
import { getCookie, useAlertStore, useMenuStore } from "@/shared/lib";
import { StatisticsPageLayout } from "@/shared/ui/statistics-page-layout";
import type { MenuItem } from "@/entities/menu";
import { BaseLayout } from "@common/components";
import { getConfig } from "@common/config";
import { getMenuList } from "../../../../packages/common/components/src/api";
import { getTabStoreState, useTabStore } from "@/store/tab-store";
const appConfig = getConfig();
const userId = getCookie(appConfig.AUTH_USER_ID ?? "");
@@ -22,118 +26,147 @@ const appSiteId = getCookie("site-id-key") ?? "";
const isLoggedIn = !!userId;
export default function WithBaseLayout({
children,
children,
}: {
children: React.ReactNode;
children: React.ReactNode;
}) {
const pathname = usePathname();
const queryClient = useQueryClient();
const pathname = usePathname();
const queryClient = useQueryClient();
const setCurrentMenuInfo = useMenuStore((state) => state.setCurrentMenuInfo);
const currentMenuInfo = useMenuStore((state) => state.currentMenuInfo);
const setCurrentMenuInfo = useMenuStore(
(state) => state.setCurrentMenuInfo,
);
const currentMenuInfo = useMenuStore((state) => state.currentMenuInfo);
const addTab = useTabStore((state) => state.addTab);
const setActiveTab = useTabStore((state) => state.setActiveTab);
const setMenuList = useTabStore((state) => state.setMenuList);
const activeTabId = useTabStore((state) => state.activeTabId);
const menuList = useTabStore((state) => state.menuList);
const tabs = useTabStore((state) => state.tabs);
const addTabForGrid = gridTabStore((state) => state.addTab);
const setActiveTabForGrid = gridTabStore((state) => state.setActiveTab);
const setMenuListForGrid = gridTabStore((state) => state.setMenuList);
const alert = useAlertStore((state) => state.setMessage);
const addTab = useTabStore((state) => state.addTab);
const setActiveTab = useTabStore((state) => state.setActiveTab);
const setMenuList = useTabStore((state) => state.setMenuList);
const activeTabId = useTabStore((state) => state.activeTabId);
const menuList = useTabStore((state) => state.menuList);
const tabs = useTabStore((state) => state.tabs);
const addTabForGrid = gridTabStore((state) => state.addTab);
const setActiveTabForGrid = gridTabStore((state) => state.setActiveTab);
const setMenuListForGrid = gridTabStore((state) => state.setMenuList);
const alert = useAlertStore((state) => state.setMessage);
const { data: menuListData } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.MENU_ROLES],
queryFn: () => getMenuList(appSiteId),
enabled: isLoggedIn,
});
const { data: menuListData } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.MENU_ROLES],
queryFn: () => getMenuList(appSiteId),
enabled: isLoggedIn,
});
useEffect(() => {
if (menuListData) {
setMenuList(menuListData);
setMenuListForGrid(menuListData);
}
}, [menuListData, setMenuList, setMenuListForGrid]);
useEffect(() => {
if (!pathname) return;
// pathname이 변경될 때만 실행되도록 tabs를 직접 가져옴
const currentTabs = getTabStoreState().tabs;
const tab = currentTabs.find((t) => t.path === pathname);
if (tab) {
setActiveTab(tab.id);
setActiveTabForGrid(tab.id);
}
}, [pathname, setActiveTab, setActiveTabForGrid]);
// 활성 탭의 메뉴를 찾아서 urlPath를 가져옴. 탭이 전부 닫히면 ''를 넘겨 LNB에서 메뉴 강조 제거
const currentPath = useMemo(() => {
if (tabs.length === 0) return "";
if (activeTabId && menuList) {
// menuList에서 활성 탭의 메뉴 찾기
const findMenuByEngNm = (items: MenuItem[]): MenuItem | null => {
for (const item of items) {
if (item.urlPath === activeTabId) {
return item;
}
if (item.children) {
const found = findMenuByEngNm(item.children);
if (found) return found;
}
useEffect(() => {
if (menuListData) {
setMenuList(menuListData);
setMenuListForGrid(menuListData);
}
return null;
};
}, [menuListData, setMenuList, setMenuListForGrid]);
const activeMenu = findMenuByEngNm(menuList);
// 새로고침 후 persist로 앱 탭만 복원된 경우 rsone 그리드 스토어에 탭을 맞춤
useEffect(() => {
if (!menuList) return;
const appTabs = getTabStoreState().tabs;
if (appTabs.length === 0) return;
if (getGridTabStoreState().tabs.length > 0) return;
if (activeMenu?.urlPath) {
return activeMenu.urlPath;
}
}
for (const tab of appTabs) {
getGridTabStoreState().addTab(tab.menuItem);
}
// 탭에서 직접 찾기
if (activeTabId) {
const currentTabs = getTabStoreState().tabs;
const activeTab = currentTabs.find((t) => t.id === activeTabId);
if (activeTab?.menuItem?.urlPath) {
return activeTab.menuItem.urlPath;
}
}
const activeId = getTabStoreState().activeTabId;
if (activeId) {
const activeTab = appTabs.find((t) => t.id === activeId);
const gridActiveId = activeTab?.menuItem.menuEngNm;
if (gridActiveId) {
getGridTabStoreState().setActiveTab(gridActiveId);
}
}
}, [menuList, tabs]);
// currentMenuInfo에서 가져오기
if (currentMenuInfo?.urlPath) {
return currentMenuInfo.urlPath;
}
useEffect(() => {
if (!pathname) return;
// 기본값으로 pathname 사용
return pathname ?? "";
}, [tabs.length, activeTabId, menuList, currentMenuInfo, pathname]);
// pathname이 변경될 때만 실행되도록 tabs를 직접 가져옴
const currentTabs = getTabStoreState().tabs;
const tab = currentTabs.find((t) => t.path === pathname);
if (tab) {
setActiveTab(tab.id);
const gridActiveId = tab.menuItem.menuEngNm;
if (gridActiveId) {
setActiveTabForGrid(gridActiveId);
}
}
}, [pathname, setActiveTab, setActiveTabForGrid]);
const handleMenuClick = useCallback((item: MenuItem) => {
setCurrentMenuInfo(item);
const addTabResult: boolean = addTab(item);
// packages/rsone의 스토어에도 동기화
if (addTabResult) {
addTabForGrid(item);
}
// 활성 탭의 메뉴를 찾아서 urlPath를 가져옴. 탭이 전부 닫히면 ''를 넘겨 LNB에서 메뉴 강조 제거
const currentPath = useMemo(() => {
if (tabs.length === 0) return "";
if (!addTabResult) {
alert(`최대 추가 할 수 있는 화면 수를 초과 하였습니다.
if (activeTabId && menuList) {
// menuList에서 활성 탭의 메뉴 찾기
const findMenuByEngNm = (items: MenuItem[]): MenuItem | null => {
for (const item of items) {
if (item.urlPath === activeTabId) {
return item;
}
if (item.children) {
const found = findMenuByEngNm(item.children);
if (found) return found;
}
}
return null;
};
const activeMenu = findMenuByEngNm(menuList);
if (activeMenu?.urlPath) {
return activeMenu.urlPath;
}
}
// 탭에서 직접 찾기
if (activeTabId) {
const currentTabs = getTabStoreState().tabs;
const activeTab = currentTabs.find((t) => t.id === activeTabId);
if (activeTab?.menuItem?.urlPath) {
return activeTab.menuItem.urlPath;
}
}
// currentMenuInfo에서 가져오기
if (currentMenuInfo?.urlPath) {
return currentMenuInfo.urlPath;
}
// 기본값으로 pathname 사용
return pathname ?? "";
}, [tabs.length, activeTabId, menuList, currentMenuInfo, pathname]);
const handleMenuClick = useCallback(
(item: MenuItem) => {
setCurrentMenuInfo(item);
const addTabResult: boolean = addTab(item);
// packages/rsone의 스토어에도 동기화
if (addTabResult) {
addTabForGrid(item);
}
if (!addTabResult) {
alert(`최대 추가 할 수 있는 화면 수를 초과 하였습니다.
기존 화면을 닫은 후 이용해주세요.`);
}
}, [setCurrentMenuInfo, addTab, addTabForGrid, alert]);
}
},
[setCurrentMenuInfo, addTab, addTabForGrid, alert],
);
return (
<BaseLayout
queryClient={queryClient}
currentPath={currentPath}
handleMenuClick={handleMenuClick}
>
<StatisticsPageLayout>{children}</StatisticsPageLayout>
</BaseLayout>
);
return (
<BaseLayout
queryClient={queryClient}
currentPath={currentPath}
handleMenuClick={handleMenuClick}
>
<StatisticsPageLayout>{children}</StatisticsPageLayout>
</BaseLayout>
);
}
@@ -1,10 +1,9 @@
import { useState } from "react";
import { SeweragePentrationRateTotColumn } from "@/components/util/column/total/SeweragePentrationRateTotColumn";
import SearchForm from "@/components/module/SearchForm";
import DataGrid from "@/components/module/DataGrid";
import SubmitButton from "@/components/button/SumitButton";
import SaveButton from "@/components/button/SaveButton";
import LastYearDataButton from "@/components/button/LastYearDataButton";
import SubmitExceptionButton from "@/components/button/SubmitExceptionButton";
import { useState } from "react";
const SeweragePenetrationRateTot = () => {
const columns = [
@@ -103,38 +102,12 @@ const SeweragePenetrationRateTot = () => {
'rmk',
];
const submitFunc = () => {
console.log('submit');
// 제출 로직
};
const saveFunc = () => {
console.log('submit');
// 저장 로직
};
const getLastYearDataFunc = () => {
console.log('get last year data');
// 지난연도 데이터 가져오기 로직
};
const SubmitExceptionFunc = () => {
console.log('submit exception');
// 예외 제출 로직
};
const [rowData, setRowData] = useState<Record<string, string | number>[]>([]);
return (
<>
<SearchForm />
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
<SubmitButton submitFunc={submitFunc} />
<SaveButton saveFunc={saveFunc} />
<LastYearDataButton getLastYearDataFunc={getLastYearDataFunc} />
<SubmitExceptionButton SubmitExceptionFunc={SubmitExceptionFunc} />
</div>
<DataGrid columnInfo={{columns, columnLayout}} rowStartNum={2} isExcelImport={true} fixedColumns={1} rowData={rowData} setRowData={setRowData} />
<DataGrid columnInfo={SeweragePentrationRateTotColumn} rowStartNum={2} isExcelImport={false} fixedColumns={1} rowData={rowData} setRowData={setRowData} />
</>
);
};
@@ -24,7 +24,7 @@ import type {
} from "@/entities/statistics";
import { verificationHistoryColumns } from "@/entities/statistics";
import { analysisApi } from "@/entities/statistics/api/analysis-api";
import { analysisHistoryApi } from "@/entities/statistics/api/analysis-history-api";
import { stAnlsMstApi } from "@/entities/statistics/api/statistics-analysis-master-api";
import { verificationTargetManagementApi } from "@/entities/statistics/api/verification-target-management-service";
import type { VerificationTargetCodeResponse } from "@/entities/statistics/model/verification-target-management-interface";
import {
@@ -202,7 +202,7 @@ const HbStatisticsVerification = () => {
params.verifyDtEnd = dayjs(to).format("YYYY-MM-DD");
}
return analysisHistoryApi.getAnalysisHistoryList(params);
return stAnlsMstApi.getAnalysisHistoryList(params);
},
refetchInterval: (query) => {
const data = query.state.data;
@@ -242,7 +242,7 @@ const HbStatisticsVerification = () => {
],
queryFn: async () => {
if (!outlierDialog.anlsSn) return [];
const response = await analysisHistoryApi.getOutlierList(
const response = await stAnlsMstApi.getOutlierList(
outlierDialog.anlsSn,
);
return response;
@@ -33,7 +33,7 @@ import {
imrDetailGridColumnInfo,
imrVerificationHistoryColumns,
} from "@/entities/statistics";
import { analysisHistoryApi } from "@/entities/statistics/api/analysis-history-api";
import { stAnlsMstApi } from "@/entities/statistics/api/statistics-analysis-master-api";
import { imrApi } from "@/entities/statistics/api/imr-api";
import { stAnlsRsltApi } from "@/entities/statistics/api/statistics-analysis-result-api";
import {
@@ -90,6 +90,7 @@ const ImrVerificationHistory = () => {
useState<StAnlsMstResponse | null>(null);
const [selectedDetail, setSelectedDetail] =
useState<StAnlsRsltResponse | null>(null);
const [isDownloading, setIsDownloading] = useState<boolean>(false);
// Area B Grid State
const [gridRowData, setGridRowData] = useState<
@@ -130,7 +131,7 @@ const ImrVerificationHistory = () => {
historyPagination,
],
queryFn: () =>
analysisHistoryApi.getAnalysisHistoryList({
stAnlsMstApi.getAnalysisHistoryList({
...appliedSearchParams,
page: historyPagination.pageIndex,
size: historyPagination.pageSize,
@@ -230,6 +231,29 @@ const ImrVerificationHistory = () => {
setDateRange(defaultRange);
};
const handleDownload = async () => {
if (!selectedHistory || isDownloading) return;
setIsDownloading(true);
try {
const blob = await stAnlsRsltApi.downloadExcelImr(
selectedHistory.anlsSn,
);
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `IMR_분석결과.xlsx`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (error) {
console.error("Failed to download IMR excel", error);
} finally {
setIsDownloading(false);
}
};
const chartOptions: ChartOptions<"line"> = useMemo(
() => ({
responsive: true,
@@ -442,16 +466,11 @@ const ImrVerificationHistory = () => {
<Button
color="green"
disabled={
!detailData || detailData.content.length === 0
!detailData ||
detailData.content.length === 0 ||
isDownloading
}
onClick={() => {
if (selectedHistory) {
stAnlsRsltApi.downloadExcel(
selectedHistory.anlsSn,
0,
);
}
}}
onClick={handleDownload}
>
다운로드
</Button>
@@ -1,11 +1,14 @@
import React, { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import { CodeApi } from "@/entities/code/api/code-service";
import type { StAnlsRsltResponse } from "@/entities/statistics";
import { useStAnlsRsltList } from "@/entities/statistics/api/queries";
import type {
AdstZoneResponseDto,
StAnlsRsltResponse,
} from "@/entities/statistics";
import {
useStAnlsRsltList,
useStAnlsSidoCombo,
useStAnlsSigunguCombo,
} from "@/entities/statistics/api/queries";
import { stAnlsRsltApi } from "@/entities/statistics/api/statistics-analysis-result-api";
import { regionalGridColumnInfo as columnInfo } from "@/entities/statistics/model/regional-grid-columns";
import {
@@ -22,6 +25,8 @@ import type { AppliedFilter } from "./HbVerificationDetail";
import DataGrid from "@/components/module/DataGrid";
const EMPTY_ADST_LIST: AdstZoneResponseDto[] = [];
interface RegionalGridProps {
anlsSn: number;
vrfcTrgtSn: string | null;
@@ -52,27 +57,10 @@ const RegionalGrid: React.FC<RegionalGridProps> = ({
Record<string, string | number>[]
>([]);
const { data: regionCodes } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.CODE, "LAESECD"],
queryFn: () => CodeApi.getCode("LAESECD"),
});
const sidoOptions = useMemo(() => {
if (!regionCodes) return [];
return regionCodes.filter(
(code) => code.lwpoCd.startsWith("G3") && code.lwpoCd.length === 4,
);
}, [regionCodes]);
const gugunOptions = useMemo(() => {
if (!regionCodes || !selectedSido) return [];
const sidoPrefix = selectedSido;
return regionCodes.filter(
(code) =>
code.lwpoCd.startsWith(sidoPrefix) && code.lwpoCd.length === 7,
);
}, [regionCodes, selectedSido]);
const { data: sidoData } = useStAnlsSidoCombo();
const { data: gugunData } = useStAnlsSigunguCombo(selectedSido);
const sidoOptions = sidoData ?? EMPTY_ADST_LIST;
const gugunOptions = gugunData ?? EMPTY_ADST_LIST;
const { data: resultData, isLoading } = useStAnlsRsltList(
anlsSn,
@@ -90,22 +78,22 @@ const RegionalGrid: React.FC<RegionalGridProps> = ({
data = showAllOnEmptyFilter ? data : [];
} else {
if (appliedFilter.sido) {
const sidoName = sidoOptions.find(
(opt) => opt.lwpoCd === appliedFilter.sido,
)?.lwpoCdNm;
if (sidoName) {
data = data.filter((item) => item.sido === sidoName);
const sidoCd = sidoOptions.find(
(opt) => opt.adstCd === appliedFilter.sido,
)?.adstCd;
if (sidoCd) {
data = data.filter((item) =>
item.admdstCd.startsWith(sidoCd.slice(0, 2)),
);
}
}
if (appliedFilter.gugun) {
const gugunName = gugunOptions.find(
(opt) => opt.lwpoCd === appliedFilter.gugun,
)?.lwpoCdNm;
if (gugunName) {
data = data.filter((item) =>
gugunName.includes(item.gugun),
);
const gugunCd = gugunOptions.find(
(opt) => opt.adstCd === appliedFilter.gugun,
)?.adstCd;
if (gugunCd) {
data = data.filter((item) => item.admdstCd === gugunCd);
}
}
@@ -135,9 +123,6 @@ const RegionalGrid: React.FC<RegionalGridProps> = ({
otlrYn: mapOtlrToLabel(item.otlrYnCd),
}));
setGridRowData(
mappedData as unknown as Record<string, string | number>[],
);
return mappedData;
}, [
resultData,
@@ -198,6 +183,10 @@ const RegionalGrid: React.FC<RegionalGridProps> = ({
}
};
useEffect(() => {
setGridRowData(rowData as Record<string, string | number>[]);
}, [rowData]);
useEffect(() => {
handleReset();
}, [vrfcTrgtSn]);
@@ -215,8 +204,8 @@ const RegionalGrid: React.FC<RegionalGridProps> = ({
</SelectTrigger>
<SelectContent>
{sidoOptions.map((opt) => (
<SelectItem key={opt.lwpoCd} value={opt.lwpoCd}>
{opt.lwpoCdNm}
<SelectItem key={opt.adstCd} value={opt.adstCd}>
{opt.adstNm}
</SelectItem>
))}
</SelectContent>
@@ -231,8 +220,8 @@ const RegionalGrid: React.FC<RegionalGridProps> = ({
</SelectTrigger>
<SelectContent>
{gugunOptions.map((opt) => (
<SelectItem key={opt.lwpoCd} value={opt.lwpoCd}>
{opt.lwpoCdNm}
<SelectItem key={opt.adstCd} value={opt.adstCd}>
{opt.adstNm}
</SelectItem>
))}
</SelectContent>
@@ -1,7 +1,6 @@
import React, { useMemo } from "react";
import { Scatter } from "react-chartjs-2";
import { useQuery } from "@tanstack/react-query";
import {
Chart as ChartJS,
type ChartData,
@@ -17,8 +16,6 @@ import {
} from "chart.js";
import zoomPlugin from "chartjs-plugin-zoom";
import { ENDPOINT } from "@/shared/config";
import { CodeApi } from "@/entities/code/api/code-service";
import type { StAnlsRsltResponse } from "@/entities/statistics";
import {
useStAnlsChartData,
@@ -55,29 +52,28 @@ interface DataPoint extends LinePoint {
item: StAnlsRsltResponse;
}
function matchesAdmdstFilter(
admdstCd: string,
sidoCd: string | null | undefined,
gugunCd: string | undefined,
): boolean {
const gugun = (gugunCd ?? "").trim();
if (gugun) {
return admdstCd === gugun;
}
const sido = (sidoCd ?? "").trim();
if (!sido) {
return true;
}
return admdstCd.startsWith(sido.slice(0, 2));
}
const ScatterChart: React.FC<ScatterChartProps> = ({
anlsSn,
vrfcTrgtSn,
appliedFilter,
onPointClick,
}) => {
const { data: regionCodes } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.CODE, "LAESECD"],
queryFn: () => CodeApi.getCode("LAESECD"),
});
const filterNames = useMemo(() => {
if (!regionCodes) return { sido: null, gugun: null };
return {
sido:
regionCodes.find((c) => c.lwpoCd === appliedFilter.sido)
?.lwpoCdNm || null,
gugun:
regionCodes.find((c) => c.lwpoCd === appliedFilter.gugun)
?.lwpoCdNm || null,
};
}, [regionCodes, appliedFilter.sido, appliedFilter.gugun]);
const { data: resultData, isLoading: isLoadingGrid } = useStAnlsRsltList(
anlsSn,
vrfcTrgtSn,
@@ -113,12 +109,13 @@ const ScatterChart: React.FC<ScatterChartProps> = ({
const outlierPoints: DataPoint[] = [];
rawPoints.forEach((item) => {
// Apply Filters (Sido, Gugun, Status)
if (filterNames.sido && item.sido !== filterNames.sido) {
return;
}
if (filterNames.gugun && !filterNames.gugun.includes(item.gugun)) {
if (
!matchesAdmdstFilter(
item.admdstCd,
appliedFilter.sido,
appliedFilter.gugun,
)
) {
return;
}
@@ -249,7 +246,13 @@ const ScatterChart: React.FC<ScatterChartProps> = ({
};
return { data, options };
}, [resultData, chartData, filterNames, appliedFilter.status]);
}, [
resultData,
chartData,
appliedFilter.sido,
appliedFilter.gugun,
appliedFilter.status,
]);
return (
<div className="relative flex-1 p-4">
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,215 @@
import { ValueType } from "realgrid";
/**
* 하수도 통계 관리 > 01. 하수도 보급률
* 단일 Tab
*/
export const SeweragePentrationRateTotColumn = {
columns: [
// 행정구역
{ name: 'ctpvNm', fieldName: 'ctpvNm', header: { text: '시도' }, editable: false, },
{ name: 'sggNm', fieldName: 'sggNm', header: { text: '시군구' }, editable: false, },
{ name: 'admdstNm', fieldName: 'admdstNm', header: { text: '행정구역명' }, editable: false, },
// 수계 및 권역
{ name: 'rvbsn', fieldName: 'rvbsn', header: { text: '수계' }, editable: false, },
{ name: 'trbtrNm', fieldName: 'trbtrNm', header: { text: '지류' } },
// 수계영향권별분류
{ name: 'mdbsn', fieldName: 'mdbsn', header: { text: '세부단위유역' }, editable: false, },
{ name: 'lgbsn', fieldName: 'lgbsn', header: { text: '중권역' }, editable: false, },
{ name: 'mdmbsn', fieldName: 'mdmbsn', header: { text: '소권역' }, editable: false, },
// 특별대책지역
{ name: 'spcCtpnRgnCn', fieldName: 'spcCtpnRgnCn', header: { text: '특별대책지역' } },
// 행정참고자료인구
{ name: 'pbadmsWholPpltnCnt', fieldName: 'pbadmsWholPpltnCnt', header: { text: '총인구' }, editable: false, valueType: ValueType.NUMBER, dataFormat: '#,##0', },
{ name: 'domplWholPpltnCnt', fieldName: 'domplWholPpltnCnt', header: { text: '내국인' }, editable: false, valueType: ValueType.NUMBER, dataFormat: '#,##0', },
{ name: 'frgnrWholPpltnCnt', fieldName: 'frgnrWholPpltnCnt', header: { text: '외국인' }, editable: false, valueType: ValueType.NUMBER, dataFormat: '#,##0', },
// 행정참고자료면적
{ name: 'refArea', fieldName: 'refArea', header: { text: '행정참고자료면적(㎢)' }, editable: false, valueType: ValueType.NUMBER, dataFormat: '#,##0.000', },
// 총인구
{
name: 'wholPpltnCnt',
fieldName: 'wholPpltnCnt',
header: { text: '총인구(명)' },
editable: false,
valueType: ValueType.NUMBER,
dataFormat: '#,##0',
},
// 총면적
{
name: 'wholArea',
fieldName: 'wholArea',
header: { text: '총면적(㎢)' },
valueType: ValueType.NUMBER,
dataFormat: '#,##0.000',
},
// 하수처리구역 내 > 합계
{
name: 'wholSwtrmtPpltnCnt',
fieldName: 'wholSwtrmtPpltnCnt',
header: { text: '합계' },
editable: false,
valueType: ValueType.NUMBER,
dataFormat: '#,##0',
},
// 하수처리구역 내 > 공공하수처리인구
{
name: 'wholPblSwtrmtPpltnCnt',
fieldName: 'wholPblSwtrmtPpltnCnt',
header: { text: '소계' },
editable: false,
valueType: ValueType.NUMBER,
dataFormat: '#,##0',
},
{ name: 'ts1SwtrmtPpltnCnt', fieldName: 'ts1SwtrmtPpltnCnt', header: { text: '물리적처리' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'ts2SwtrmtPpltnCnt', fieldName: 'ts2SwtrmtPpltnCnt', header: { text: '생물학적처리' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'ts3SwtrmtPpltnCnt', fieldName: 'ts3SwtrmtPpltnCnt', header: { text: '고도처리' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
// 하수처리구역 내 > 폐수처리인구
{
name: 'wholWwtPrcsPpltnCnt',
fieldName: 'wholWwtPrcsPpltnCnt',
header: { text: '소계' },
editable: false,
valueType: ValueType.NUMBER,
dataFormat: '#,##0',
},
{ name: 'ts1WwtPrcsPpltnCnt', fieldName: 'ts1WwtPrcsPpltnCnt', header: { text: '물리적처리' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'ts2WwtPrcsPpltnCnt', fieldName: 'ts2WwtPrcsPpltnCnt', header: { text: '생물학적처리' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'ts3WwtPrcsPpltnCnt', fieldName: 'ts3WwtPrcsPpltnCnt', header: { text: '고도처리' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
// 하수처리구역 내 > 미접속인구
{ name: 'yetCntnPpltnCnt', fieldName: 'yetCntnPpltnCnt', header: { text: '미접속인구' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
// 하수처리구역 내 > 면적
{ name: 'staaArea', fieldName: 'staaArea', header: { text: '면적(㎢)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0.000' },
// 하수처리구역 외
{
name: 'wholStaaExcpPpltnCnt',
fieldName: 'wholStaaExcpPpltnCnt',
header: { text: '합계' },
editable: false,
valueType: ValueType.NUMBER,
dataFormat: '#,##0',
},
{ name: 'unprcsPpltnCnt', fieldName: 'unprcsPpltnCnt', header: { text: '미처리인구' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'drtwPrcsPpltnCnt', fieldName: 'drtwPrcsPpltnCnt', header: { text: '오수처리인구' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'spctnkPpltnCnt', fieldName: 'spctnkPpltnCnt', header: { text: '정화조인구' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'staaExcpArea', fieldName: 'staaExcpArea', header: { text: '면적(㎢)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0.000' },
// 공공하수처리구역 인구보급률 (하수처리구역 내 인구 / 전체 인구 * 100)
{
name: 'staaPsplyRt',
fieldName: 'staaPsplyRt',
header: { text: '공공하수처리구역인구보급률(%)' },
editable: false,
valueType: ValueType.NUMBER,
dataFormat: '#,##0.0',
},
// 고도처리 인구보급률 (고도처리 인구 / 전체 인구 * 100)
{
name: 'altdPrcsPsplyRt',
fieldName: 'altdPrcsPsplyRt',
header: { text: '고도처리인구보급률(%)' },
editable: false,
valueType: ValueType.NUMBER,
dataFormat: '#,##0.0',
},
// 하수도 설치율
{
name: 'sewerInstlRt',
fieldName: 'sewerInstlRt',
header: { text: '하수도설치율(%)' },
editable: false,
valueType: ValueType.NUMBER,
dataFormat: '#,##0.0',
},
// 비고
{ name: 'rmrk', fieldName: 'rmrk', header: { text: '비고' } },
{ name: 'swagePplPsplyRt', fieldName: 'swagePplPsplyRt', header: { text: '하수관로보급률(%)' }, visible: false },
{ name: 'admdstCd', fieldName: 'admdstCd', header: { text: '행정코드' }, visible: false },
],
columnLayout: [
{
name: 'groupA',
hideChildHeaders: false,
header: { text: '행정구역' },
items: [
'ctpvNm',
'sggNm',
'admdstNm',
],
},
'rvbsn',
'trbtrNm',
{
name: 'groupB',
hideChildHeaders: false,
header: { text: '수계영향권별분류' },
items: ['mdbsn', 'lgbsn', 'mdmbsn'],
},
'spcCtpnRgnCn',
{
name: 'groupC',
hideChildHeaders: false,
header: { text: '행정참고자료인구' },
items: ['pbadmsWholPpltnCnt', 'domplWholPpltnCnt', 'frgnrWholPpltnCnt'],
},
'refArea',
'wholPpltnCnt',
'wholArea',
{
name: 'groupD',
hideChildHeaders: false,
header: { text: '하수처리구역내' },
items: [
'wholSwtrmtPpltnCnt',
{
name: 'pblswrgGroup',
hideChildHeaders: false,
header: { text: '공공하수처리인구(명)' },
items: ['wholPblSwtrmtPpltnCnt', 'ts1SwtrmtPpltnCnt', 'ts2SwtrmtPpltnCnt', 'ts3SwtrmtPpltnCnt'],
},
{
name: 'wstrGroup',
hideChildHeaders: false,
header: { text: '폐수처리인구(명)' },
items: ['wholWwtPrcsPpltnCnt', 'ts1WwtPrcsPpltnCnt', 'ts2WwtPrcsPpltnCnt', 'ts3WwtPrcsPpltnCnt'],
},
'yetCntnPpltnCnt',
'staaArea'
],
},
{
name: 'groupE',
hideChildHeaders: false,
header: { text: '하수처리구역외' },
items: [
'wholStaaExcpPpltnCnt',
'unprcsPpltnCnt',
'drtwPrcsPpltnCnt',
'spctnkPpltnCnt',
'staaExcpArea'
],
},
'staaPsplyRt',
'altdPrcsPsplyRt',
'sewerInstlRt',
'rmrk',
'swagePplPsplyRt',
'admdstCd',
]
};
+13 -3
View File
@@ -1,13 +1,18 @@
import { AxiosError } from "axios";
import {
ENDPOINT,
JWT_EXPIRATION_TIME,
ACCESS_TOKEN,
AUTH_USER_ID,
CLAIM_NAME,
ENDPOINT,
JWT_EXPIRATION_TIME,
} from "@/shared/config";
import type { LoginService, ILogin } from "../model/login-interface";
import { getCookie } from "@/shared/lib";
import { tokenManager } from "../lib/token-manager";
import type { ILogin,LoginService } from "../model/login-interface";
import { clearTabStorePersist } from "@/store/tab-store";
export class LoginApiService implements LoginService {
private static instance: LoginApiService;
@@ -39,6 +44,11 @@ export class LoginApiService implements LoginService {
throw new Error("Missing required authentication tokens");
}
const prevTokenId = getCookie(AUTH_USER_ID);
if (prevTokenId !== undefined && prevTokenId !== tokenId) {
clearTabStorePersist();
}
tokenManager.setToken(CLAIM_NAME, accessToken);
tokenManager.setToken(AUTH_USER_ID, tokenId);
tokenManager.setupRefreshTimer(
@@ -1,5 +1,7 @@
import { AUTH_USER_ID, CLAIM_NAME } from "@/shared/config";
import { getCookie, setCookie, removeCookie } from "@/shared/lib";
import { getCookie, removeCookie,setCookie } from "@/shared/lib";
import { clearTabStorePersist } from "@/store/tab-store";
export class TokenManager {
private static instance: TokenManager;
@@ -74,6 +76,7 @@ export class TokenManager {
this.refreshTimer = null;
}
clearTabStorePersist();
this.removeToken(CLAIM_NAME);
this.removeToken(AUTH_USER_ID);
}
+18 -1
View File
@@ -2,8 +2,24 @@ import { useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import { stAnlsMstApi } from "./statistics-analysis-master-api";
import { stAnlsRsltApi } from "./statistics-analysis-result-api";
export const useStAnlsSidoCombo = () => {
return useQuery({
queryKey: [ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_MST, "sido"],
queryFn: () => stAnlsMstApi.getSidoCombo(),
});
};
export const useStAnlsSigunguCombo = (sidoCd: string) => {
return useQuery({
queryKey: [ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_MST, "sigungu", sidoCd],
queryFn: () => stAnlsMstApi.getSigunguCombo(sidoCd),
enabled: Boolean(sidoCd),
});
};
export const useStAnlsRsltList = (
anlsSn: number,
vrfcTrgtSn: string | null,
@@ -20,7 +36,8 @@ export const useStAnlsRsltList = (
anlsSn,
vrfcTrgtSn: Number(vrfcTrgtSn),
page: 0,
size: 1000,
size: 15000,
sort: "admdstCd,asc",
}),
enabled: !!anlsSn && !!vrfcTrgtSn,
});
@@ -2,12 +2,13 @@ import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
AdstZoneResponseDto,
StAnlsMstListApiResponse,
StAnlsMstSearchParams,
StAnlsOutlierListResponse,
} from "../model/statistics-analysis/statistics-analysis-master-interface";
export class AnalysisHistoryApiService {
export class StAnlsMstApiService {
public async getAnalysisHistoryList(
params: StAnlsMstSearchParams,
): Promise<StAnlsMstListApiResponse> {
@@ -30,6 +31,23 @@ export class AnalysisHistoryApiService {
return response.data;
}
public async getSidoCombo(): Promise<AdstZoneResponseDto[]> {
const response = await client.get<AdstZoneResponseDto[]>(
`${ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_MST}/sido`,
);
return response.data;
}
public async getSigunguCombo(sidoCd: string): Promise<AdstZoneResponseDto[]> {
const response = await client.get<AdstZoneResponseDto[]>(
`${ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_MST}/sigungu`,
{ params: { sidoCd } },
);
return response.data;
}
}
export const analysisHistoryApi = new AnalysisHistoryApiService();
export const stAnlsMstApi = new StAnlsMstApiService();
@@ -60,6 +60,17 @@ export class StAnlsRsltApiService {
return response.data;
}
public async downloadExcelImr(anlsSn: number): Promise<Blob> {
const response = await client.get<Blob>(
`${ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_RSLT}/excel-down/imr/${anlsSn}`,
{
responseType: "blob",
},
);
return response.data;
}
}
export const stAnlsRsltApi = new StAnlsRsltApiService();
+1
View File
@@ -27,6 +27,7 @@ export {
type StAnlsDtlSearchParams,
} from "./model/statistics-analysis/statistics-analysis-detail-interface";
export {
type AdstZoneResponseDto,
type StAnlsMstListApiResponse,
type StAnlsMstResponse,
type StAnlsMstResponseDto,
@@ -1,5 +1,15 @@
import type { PageableList, PageableParams } from "@/shared/model";
/**
* 행정구역(시도·시군구) 콤보 응답 DTO
*/
export interface AdstZoneResponseDto {
/** 행정구역 코드 */
adstCd: string;
/** 행정구역 명 */
adstNm: string;
}
/**
* 분석 현황 마스터 응답 DTO
*/
+153 -83
View File
@@ -1,8 +1,10 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import type { MenuItem } from "@/entities/menu";
export interface Tab {
id: string; // 고유 ID (menuEngNm)
id: string; // 고유 ID (urlPath)
label: string; // 탭에 표시될 이름
path: string; // 메뉴 경로
urlPath: string; // url 경로
@@ -22,96 +24,164 @@ interface TabStore {
closeOtherTabs: (tabId: string) => void;
}
const useTabStoreHook = create<TabStore>((set, get) => ({
tabs: [],
activeTabId: null,
menuList: null,
addTab: (menuItem: MenuItem) => {
const { tabs, menuList } = get();
const tabId = menuItem.urlPath || '';
export const TAB_STORE_PERSIST_KEY = "st-tab-store";
const TAB_STORE_VERSION = 1;
const MAX_TABS = 10;
// 이미 존재하는 탭인지 확인
const existingTab = tabs.find((tab) => tab.id === tabId);
function tabForPersist(tab: Tab): Tab {
return {
...tab,
menuItem: { ...tab.menuItem, children: null },
};
}
if (existingTab) {
// 이미 존재하면 활성화만
set({ activeTabId: tabId });
return true;
}
function sanitizePersistedTabsAndActive(
tabs: Tab[],
activeTabId: string | null,
): { tabs: Tab[]; activeTabId: string | null } {
let nextTabs = Array.isArray(tabs) ? tabs : [];
if (nextTabs.length > MAX_TABS) nextTabs = nextTabs.slice(0, MAX_TABS);
if (tabs.length < 10) {
const newTab: Tab = {
id: tabId,
label: menuItem.menuKornNm,
path: menuItem.menuPath,
urlPath: menuItem.urlPath,
parentEngNm: menuList?.find((m) => m.menuCd === menuItem.parentId)?.menuEngNm || '',
menuItem,
};
set({
tabs: [...tabs, newTab],
activeTabId: tabId,
});
if (nextTabs.length === 0) {
return { tabs: [], activeTabId: null };
}
return true;
} else {
return false;
}
let nextActive = activeTabId;
if (nextActive && !nextTabs.some((t) => t.id === nextActive)) {
nextActive = nextTabs[0]?.id ?? null;
}
if (nextActive == null && nextTabs.length > 0) {
nextActive = nextTabs[0]?.id ?? null;
}
// 새 탭 추가
},
removeTab: (tabId: string): void => {
const { tabs, activeTabId } = get();
const newTabs = tabs.filter((tab) => tab.id !== tabId);
return { tabs: nextTabs, activeTabId: nextActive };
}
// 닫는 탭이 현재 활성 탭이면 다른 탭으로 전환
let newActiveTabId = activeTabId;
if (activeTabId === tabId) {
const closedTabIndex = tabs.findIndex((tab) => tab.id === tabId);
if (newTabs.length > 0) {
// 닫힌 탭의 이전 탭을 활성화, 없으면 다음 탭
if (closedTabIndex > 0) {
const prevTab = tabs[closedTabIndex - 1];
newActiveTabId = prevTab ? prevTab.id : null;
} else {
const firstTab = newTabs[0];
newActiveTabId = firstTab ? firstTab.id : null;
}
} else {
newActiveTabId = null;
}
}
set({
tabs: newTabs,
activeTabId: newActiveTabId,
});
},
setActiveTab: (tabId: string) => {
set({ activeTabId: tabId });
},
setMenuList: (menuList: MenuItem[] | null) => {
set({ menuList });
},
closeAllTabs: () => {
set({
const useTabStoreHook = create<TabStore>()(
persist(
(set, get) => ({
tabs: [],
activeTabId: null,
});
},
closeOtherTabs: (tabId: string) => {
const { tabs } = get();
const tabToKeep = tabs.find((tab) => tab.id === tabId);
menuList: null,
addTab: (menuItem: MenuItem) => {
const { tabs, menuList } = get();
const tabId = menuItem.urlPath || "";
if (tabToKeep) {
set({
tabs: [tabToKeep],
activeTabId: tabId,
});
}
},
}));
const existingTab = tabs.find((tab) => tab.id === tabId);
if (existingTab) {
set({ activeTabId: tabId });
return true;
}
if (tabs.length < MAX_TABS) {
const newTab: Tab = {
id: tabId,
label: menuItem.menuKornNm,
path: menuItem.menuPath,
urlPath: menuItem.urlPath,
parentEngNm:
menuList?.find(
(m) => m.menuCd === menuItem.parentId,
)?.menuEngNm || "",
menuItem,
};
set({
tabs: [...tabs, newTab],
activeTabId: tabId,
});
return true;
}
return false;
},
removeTab: (tabId: string): void => {
const { tabs, activeTabId } = get();
const newTabs = tabs.filter((tab) => tab.id !== tabId);
let newActiveTabId = activeTabId;
if (activeTabId === tabId) {
const closedTabIndex = tabs.findIndex(
(tab) => tab.id === tabId,
);
if (newTabs.length > 0) {
if (closedTabIndex > 0) {
const prevTab = tabs[closedTabIndex - 1];
newActiveTabId = prevTab ? prevTab.id : null;
} else {
const firstTab = newTabs[0];
newActiveTabId = firstTab ? firstTab.id : null;
}
} else {
newActiveTabId = null;
}
}
set({
tabs: newTabs,
activeTabId: newActiveTabId,
});
},
setActiveTab: (tabId: string) => {
set({ activeTabId: tabId });
},
setMenuList: (menuList: MenuItem[] | null) => {
set({ menuList });
},
closeAllTabs: () => {
set({
tabs: [],
activeTabId: null,
});
},
closeOtherTabs: (tabId: string) => {
const { tabs } = get();
const tabToKeep = tabs.find((tab) => tab.id === tabId);
if (tabToKeep) {
set({
tabs: [tabToKeep],
activeTabId: tabId,
});
}
},
}),
{
name: TAB_STORE_PERSIST_KEY,
version: TAB_STORE_VERSION,
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
tabs: state.tabs.map(tabForPersist),
activeTabId: state.activeTabId,
}),
merge: (persistedState, currentState) => {
const p = persistedState as Partial<
Pick<TabStore, "tabs" | "activeTabId">
> | null;
const rawTabs = p?.tabs;
const rawActive = p?.activeTabId;
const { tabs, activeTabId } = sanitizePersistedTabsAndActive(
(rawTabs as Tab[]) ?? [],
typeof rawActive === "string" || rawActive === null
? rawActive
: null,
);
return {
...currentState,
tabs,
activeTabId,
};
},
},
),
);
export const useTabStore = useTabStoreHook;
export const getTabStoreState = () => useTabStoreHook.getState();
export const getTabStoreState = () => useTabStoreHook.getState();
// 로그아웃 또는 사용자 전환 시 로컬 탭 스냅샷 제거 및 메모리 상태 초기화
export function clearTabStorePersist(): void {
useTabStoreHook.getState().closeAllTabs();
useTabStoreHook.persist.clearStorage();
}