[st] feat: 검증 미통과 내역 - 조회 바로가기 기능

This commit is contained in:
2026-05-04 18:04:32 +09:00
parent 4a57b363eb
commit 9f9c6f30cb
11 changed files with 998 additions and 40 deletions
@@ -1,5 +1,16 @@
"use client";
import {
type Dispatch,
type MutableRefObject,
type SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import type { RealGridReact } from "realgrid-react";
import {
Button,
Dialog,
@@ -8,25 +19,477 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
LoadingWithAnimation,
} from "@common/components";
import DataGrid from "@/components/module/DataGrid";
import { VerificationResultHistoryPagination } from "@/components/module/VerificationResultHistoryPagination";
import type { CellEditedCallbackParams, ColumnInfo } from "@/components/type/DataGrid.type";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import type { VerificationResultListRequest, VerificationResultReportItem } from "@/entities/statistics/model/statistics-interface";
import { useAlertStore } from "@/shared/lib";
type VerificationResultGridRow = Record<string, string | number>;
export type VerificationExceptionSubmitItem = {
vrfcSn: number;
prcsOpnnExpln: string;
};
type GridViewWithCheckItem = {
checkItem?: (itemIndex: number, checked: boolean, exclusive?: boolean) => void;
};
function setRowCheckByItemIndex(
gridRef: MutableRefObject<RealGridReact | null>,
itemIndex: number,
checked: boolean,
) {
const gv = gridRef.current?.gridView as GridViewWithCheckItem | undefined;
try {
gv?.checkItem?.(itemIndex, checked, false);
} catch {
/* RealGrid 체크 API 미지원 등 */
}
}
const PRCS_YN_LABEL: Record<string, string> = {
"0": "미통과",
"1": "통과",
"2": "예외처리",
"3": "반려",
};
/** History와 동일 + 편집 가능한 처리의견(예외사유, API 필드명 `prcsOpnnExpln`과 동일) */
const verificationSubmitResultColumnInfo: ColumnInfo = {
columns: [
{ name: "ctpvNm", fieldName: "ctpvNm", header: { text: "시도" }, editable: false },
{ name: "sggNm", fieldName: "sggNm", header: { text: "시군구" }, editable: false },
{ name: "vrfcTrgtNm", fieldName: "vrfcTrgtNm", header: { text: "검증대상" }, editable: false },
{ name: "rsnExpln", fieldName: "rsnExpln", header: { text: "사유" }, editable: false },
{ name: "nowVlCn", fieldName: "nowVlCn", header: { text: "입력값" }, editable: false },
{ name: "compVlCn", fieldName: "compVlCn", header: { text: "기준값" }, editable: false },
{ name: "prcsYn", fieldName: "prcsYn", header: { text: "처리여부" }, editable: false },
{ name: "pbcpAprvYn", fieldName: "pbcpAprvYn", header: { text: "공단승인여부" }, editable: false },
{ name: "regDt", fieldName: "regDt", header: { text: "등록일시" }, editable: false },
{ name: "mdfcnDt", fieldName: "mdfcnDt", header: { text: "갱신일시" }, editable: false },
{
name: "prcsOpnnExpln",
fieldName: "prcsOpnnExpln",
header: { text: "예외사유" },
editable: true,
},
],
columnLayout: [
"ctpvNm",
"sggNm",
"vrfcTrgtNm",
"rsnExpln",
"nowVlCn",
"compVlCn",
"prcsYn",
"pbcpAprvYn",
"regDt",
"mdfcnDt",
"prcsOpnnExpln",
],
};
export interface VerificationResultDialogQueryContext {
sewerBzmnCd: string;
ctpvNm: string;
sggNm: string;
statsYr: string;
statsCd: string;
}
export interface VerificationResultDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
queryContext?: VerificationResultDialogQueryContext | null;
title?: string;
emptyMessage?: string;
}
export function VerificationResultDialog({
open,
onOpenChange,
queryContext,
title = "검증 결과",
emptyMessage = "표시할 검증 결과가 없습니다.",
}: VerificationResultDialogProps) {
/** 부모가 매 렌더마다 새 객체를 넘기면 queryContext 참조만 바뀌어 loadInitial이 재실행되므로 필드 값 기준으로 안정화 */
const stableQueryContext = useMemo((): VerificationResultDialogQueryContext | null => {
if (!queryContext) return null;
return queryContext;
}, [
queryContext?.sewerBzmnCd,
queryContext?.ctpvNm,
queryContext?.sggNm,
queryContext?.statsYr,
queryContext?.statsCd,
]);
const [normalPageIndex, setNormalPageIndex] = useState(0);
const [imrPageIndex, setImrPageIndex] = useState(0);
const [normalPageCount, setNormalPageCount] = useState(1);
const [imrPageCount, setImrPageCount] = useState(1);
const [normalLoading, setNormalLoading] = useState(false);
const [imrLoading, setImrLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [normalRows, setNormalRows] = useState<VerificationResultGridRow[]>([]);
const [imrRows, setImrRows] = useState<VerificationResultGridRow[]>([]);
const [normalCheckedIndices, setNormalCheckedIndices] = useState<Set<number>>(
() => new Set(),
);
const [imrCheckedIndices, setImrCheckedIndices] = useState<Set<number>>(() => new Set());
const normalGridRef = useRef<RealGridReact | null>(null);
const imrGridRef = useRef<RealGridReact | null>(null);
const alert = useAlertStore((state) => state.setMessage);
const onNormalPrcsOpnnExplnEdited = useCallback((params: CellEditedCallbackParams) => {
if (params.fieldName !== "prcsOpnnExpln") return;
const text = String(params.newValue ?? "");
const hasValue = text.trim().length > 0;
/** DataGrid는 onCellEdited 시 React rowData를 갱신하지 않아, 제출용 상태를 여기서 맞춤 */
setNormalRows((prev) => {
const next = [...prev];
const row = next[params.itemIndex];
if (!row) return prev;
next[params.itemIndex] = { ...row, prcsOpnnExpln: text };
return next;
});
setRowCheckByItemIndex(normalGridRef, params.itemIndex, hasValue);
setNormalCheckedIndices((prev) => {
const next = new Set(prev);
if (hasValue) next.add(params.itemIndex);
else next.delete(params.itemIndex);
return next;
});
}, []);
const onImrPrcsOpnnExplnEdited = useCallback((params: CellEditedCallbackParams) => {
if (params.fieldName !== "prcsOpnnExpln") return;
const text = String(params.newValue ?? "");
const hasValue = text.trim().length > 0;
setImrRows((prev) => {
const next = [...prev];
const row = next[params.itemIndex];
if (!row) return prev;
next[params.itemIndex] = { ...row, prcsOpnnExpln: text };
return next;
});
setRowCheckByItemIndex(imrGridRef, params.itemIndex, hasValue);
setImrCheckedIndices((prev) => {
const next = new Set(prev);
if (hasValue) next.add(params.itemIndex);
else next.delete(params.itemIndex);
return next;
});
}, []);
const resetState = useCallback(() => {
setNormalPageIndex(0);
setImrPageIndex(0);
setNormalPageCount(1);
setImrPageCount(1);
setNormalLoading(false);
setImrLoading(false);
setErrorMessage(null);
setNormalRows([]);
setImrRows([]);
setNormalCheckedIndices(new Set());
setImrCheckedIndices(new Set());
}, []);
const mapItemToRow = useCallback((item: VerificationResultReportItem): VerificationResultGridRow => {
return {
vrfcSn: item.vrfcSn,
ctpvNm: item.ctpvNm,
sggNm: item.sggNm,
vrfcTrgtNm: item.vrfcTrgtNm,
rsnExpln: item.rsnExpln,
nowVlCn: item.nowVlCn ?? "-",
compVlCn: item.compVlCn ?? "-",
prcsYn: PRCS_YN_LABEL[item.prcsYn] ?? item.prcsYn,
pbcpAprvYn: item.pbcpAprvYn,
regDt: (item.regDt ?? "").slice(0, 16),
mdfcnDt: (item.mdfcnDt ?? "").slice(0, 16),
prcsOpnnExpln: item.prcsOpnnExpln ?? "",
};
}, []);
const fetchVerificationResultPage = useCallback(
async (vrfcMthdCd: "NV" | "IMR", pageIndex: number) => {
if (!stableQueryContext) return null;
const param: VerificationResultListRequest = {
sewerBzmnCd: stableQueryContext.sewerBzmnCd,
ctpvNm: stableQueryContext.ctpvNm,
sggNm: stableQueryContext.sggNm,
statsYr: stableQueryContext.statsYr,
statsCd: stableQueryContext.statsCd,
pbcpAprvYn: "N",
vrfcMthdCd,
prcsYnList: "0",
page: pageIndex + 1,
size: 10000,
};
return statisticsApi.getVerificationResultList(param);
},
[stableQueryContext],
);
const loadInitial = useCallback(async () => {
if (!stableQueryContext) {
setErrorMessage("조회 조건이 없어 검증 결과를 불러올 수 없습니다.");
return;
}
setErrorMessage(null);
setNormalLoading(true);
setImrLoading(true);
setNormalPageIndex(0);
setImrPageIndex(0);
try {
const [nvRes, imrRes] = await Promise.all([
fetchVerificationResultPage("NV", 0),
fetchVerificationResultPage("IMR", 0),
]);
const nvList = nvRes?.data?.verifyResultList ?? [];
const imrList = imrRes?.data?.verifyResultList ?? [];
setNormalRows(nvList.map(mapItemToRow));
setImrRows(imrList.map(mapItemToRow));
setNormalPageCount(nvRes?.data?.pageInfo?.totalPages ?? 1);
setImrPageCount(imrRes?.data?.pageInfo?.totalPages ?? 1);
} catch {
setNormalRows([]);
setImrRows([]);
setNormalPageCount(1);
setImrPageCount(1);
setErrorMessage("검증 결과 조회 중 오류가 발생했습니다.");
} finally {
setNormalLoading(false);
setImrLoading(false);
setNormalCheckedIndices(new Set());
setImrCheckedIndices(new Set());
}
}, [fetchVerificationResultPage, mapItemToRow, stableQueryContext]);
const handleNormalPageChange = useCallback(
async (pageIndex: number) => {
if (pageIndex === normalPageIndex || !stableQueryContext) return;
setErrorMessage(null);
setNormalLoading(true);
try {
const response = await fetchVerificationResultPage("NV", pageIndex);
const list = response?.data?.verifyResultList ?? [];
setNormalRows(list.map(mapItemToRow));
setNormalPageCount(response?.data?.pageInfo?.totalPages ?? 1);
setNormalPageIndex(pageIndex);
} catch {
setErrorMessage("일반검증 내역 조회 중 오류가 발생했습니다.");
} finally {
setNormalLoading(false);
setNormalCheckedIndices(new Set());
}
},
[fetchVerificationResultPage, mapItemToRow, normalPageIndex, stableQueryContext],
);
const handleImrPageChange = useCallback(
async (pageIndex: number) => {
if (pageIndex === imrPageIndex || !stableQueryContext) return;
setErrorMessage(null);
setImrLoading(true);
try {
const response = await fetchVerificationResultPage("IMR", pageIndex);
const list = response?.data?.verifyResultList ?? [];
setImrRows(list.map(mapItemToRow));
setImrPageCount(response?.data?.pageInfo?.totalPages ?? 1);
setImrPageIndex(pageIndex);
} catch {
setErrorMessage("I-MR 검증내역 조회 중 오류가 발생했습니다.");
} finally {
setImrLoading(false);
setImrCheckedIndices(new Set());
}
},
[fetchVerificationResultPage, mapItemToRow, imrPageIndex, stableQueryContext],
);
useEffect(() => {
if (!open) {
resetState();
return;
}
void loadInitial();
}, [loadInitial, open, resetState]);
/** 조회된 행에 예외사유 텍스트가 있으면 체크바·체크 인덱스와 맞춤 */
useEffect(() => {
if (normalLoading) return;
const id = requestAnimationFrame(() => {
setNormalCheckedIndices((prev) => {
const next = new Set(prev);
normalRows.forEach((row, itemIndex) => {
const has = String(row.prcsOpnnExpln ?? "").trim().length > 0;
setRowCheckByItemIndex(normalGridRef, itemIndex, has);
if (has) next.add(itemIndex);
});
return next;
});
});
return () => cancelAnimationFrame(id);
}, [normalRows, normalLoading]);
useEffect(() => {
if (imrLoading) return;
const id = requestAnimationFrame(() => {
setImrCheckedIndices((prev) => {
const next = new Set(prev);
imrRows.forEach((row, itemIndex) => {
const has = String(row.prcsOpnnExpln ?? "").trim().length > 0;
setRowCheckByItemIndex(imrGridRef, itemIndex, has);
if (has) next.add(itemIndex);
});
return next;
});
});
return () => cancelAnimationFrame(id);
}, [imrRows, imrLoading]);
const handleExceptionSubmitClick = useCallback(() => {
const payload: VerificationExceptionSubmitItem[] = [];
const pushIfHasExpln = (row: VerificationResultGridRow | undefined) => {
if (!row) return;
const prcsOpnnExpln = String(row.prcsOpnnExpln ?? "").trim();
if (!prcsOpnnExpln) return;
payload.push({
vrfcSn: Number(row.vrfcSn),
prcsOpnnExpln,
});
};
normalCheckedIndices.forEach((idx) => pushIfHasExpln(normalRows[idx]));
imrCheckedIndices.forEach((idx) => pushIfHasExpln(imrRows[idx]));
if (payload.length === 0) {
alert("작성된 예외 제출 항목이 없습니다.");
return;
}
console.log(payload);
}, [alert, imrCheckedIndices, imrRows, normalCheckedIndices, normalRows]);
const renderGrid = (
rows: VerificationResultGridRow[],
setRows: Dispatch<SetStateAction<VerificationResultGridRow[]>>,
pageIndex: number,
pageCount: number,
onPageIndexChange: (page: number) => void,
loading: boolean,
gridRef: MutableRefObject<RealGridReact | null>,
onPrcsOpnnExplnCellEdited: (params: CellEditedCallbackParams) => void,
setCheckedIndices: Dispatch<SetStateAction<Set<number>>>,
) => {
if (!loading && rows.length === 0) {
return <p className="p-6 text-center text-[1.3rem] text-muted-foreground">{emptyMessage}</p>;
}
return (
<div className="relative space-y-2">
<DataGrid
columnInfo={verificationSubmitResultColumnInfo}
rowData={rows}
setRowData={setRows as Dispatch<SetStateAction<Record<string, string | number>[]>>}
isExcelImport={false}
isExcelExport={false}
showHeaderCheckbox
enableSort={false}
getGridRef={gridRef}
onCellEdited={onPrcsOpnnExplnCellEdited}
onItemChecked={(itemIndex, checked) => {
setCheckedIndices((prev) => {
const next = new Set(prev);
if (checked) next.add(itemIndex);
else next.delete(itemIndex);
return next;
});
}}
onItemAllChecked={(checked) => {
setCheckedIndices(
checked ? new Set(rows.map((_, i) => i)) : new Set(),
);
}}
/>
<VerificationResultHistoryPagination
pageCount={Math.max(pageCount, 1)}
pageIndex={Math.max(pageIndex, 0)}
onPageIndexChange={onPageIndexChange}
className="mx-2 mb-2"
/>
{loading && (
<div className="absolute inset-0 z-10 rounded-md [data-theme='black']:bg-transparent bg-white/40 flex items-center justify-center">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
)}
</div>
);
};
const titleSuffix = useMemo(() => {
if (!stableQueryContext) return "";
const sgg = stableQueryContext.sggNm?.trim() ? ` ${stableQueryContext.sggNm}` : "";
return ` - ${stableQueryContext.statsYr} ${stableQueryContext.ctpvNm}${sgg}`;
}, [stableQueryContext]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[640px]" aria-describedby={undefined}>
<DialogContent className="max-w-[80%]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>검증 결과</DialogTitle>
<DialogTitle>
{title}
{titleSuffix}
</DialogTitle>
</DialogHeader>
<div className="min-h-[120px]" />
<div className="max-h-[85vh] space-y-5 overflow-auto">
{errorMessage && (
<p className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-[1.2rem] text-destructive">
{errorMessage}
</p>
)}
<section className="rounded-md border border-border">
<h3 className="border-b border-border px-4 py-2 text-[1.35rem] font-semibold">일반검증 내역</h3>
{renderGrid(
normalRows,
setNormalRows,
normalPageIndex,
normalPageCount,
handleNormalPageChange,
normalLoading,
normalGridRef,
onNormalPrcsOpnnExplnEdited,
setNormalCheckedIndices,
)}
</section>
<section className="rounded-md border border-border">
<h3 className="border-b border-border px-4 py-2 text-[1.35rem] font-semibold">I-MR 검증내역</h3>
{renderGrid(
imrRows,
setImrRows,
imrPageIndex,
imrPageCount,
handleImrPageChange,
imrLoading,
imrGridRef,
onImrPrcsOpnnExplnEdited,
setImrCheckedIndices,
)}
</section>
</div>
<DialogFooter>
<Button type="button" color="primary" onClick={handleExceptionSubmitClick}>
예외 제출
</Button>
<DialogClose asChild>
<Button type="button" color="gray">
닫기
@@ -37,4 +500,3 @@ export function VerificationResultDialog({
</Dialog>
);
}
@@ -1,9 +1,19 @@
"use client";
import { useDashboardVrfcReport } from "@/entities/statistics";
import dayjs from "dayjs";
import { useCallback } from "react";
import { useQueries } from "@tanstack/react-query";
import { useTabStore as useGridTabStore } from "@rsone/components";
import { useUserStore } from "@/shared/lib";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import type { VerificationResultReportItem } from "@/entities/statistics/model/statistics-interface";
import { useAlertStore, useMenuStore } from "@/shared/lib";
import {
findMenuItemByUrlPath,
resolveInputPageNavigationFromStatsCd,
} from "@/shared/lib/stats-cd-input-navigation";
import { useStatisticsInputFocusStore } from "@/store/statistics-input-focus-store";
import { useTabStore } from "@/store/tab-store";
import dayjs from "dayjs";
import {
Badge,
@@ -28,20 +38,119 @@ type VerificationSelectionReportProps = {
const VerificationSelectionReport = ({
onClose,
}: VerificationSelectionReportProps) => {
const userInfo = useUserStore((state) => state.userInfo);
const menuList = useTabStore((s) => s.menuList);
const addTab = useTabStore((s) => s.addTab);
const setCurrentMenuInfo = useMenuStore((s) => s.setCurrentMenuInfo);
const addTabForGrid = useGridTabStore((s) => s.addTab);
const setPendingInputTab = useStatisticsInputFocusStore(
(s) => s.setPendingInputTab,
);
const alert = useAlertStore((s) => s.setMessage);
const param = {
statsYr: String(dayjs().year() - 1),
dtlInstCd: userInfo?.dtlInstCd || '',
enoCd: userInfo?.enoCd || '',
laeSeCd: userInfo?.laeSeCd || '',
const statsYr = String(dayjs().year() - 1);
const baseRequest = {
statsYr,
pbcpAprvYn: "N" as const,
page: 1,
size: 10000,
prcsYnList: "0",
};
const { data, isLoading, error } = useDashboardVrfcReport(param);
const imrList = data?.list.filter((item) => item.vrfcCd === "IMR") ?? [];
const nvList = data?.list.filter((item) => item.vrfcCd === "NV") ?? [];
const [nvQuery, imrQuery] = useQueries({
queries: [
{
queryKey: [
"verificationResultList",
statsYr,
"NV",
baseRequest.pbcpAprvYn,
baseRequest.page,
baseRequest.size,
baseRequest.prcsYnList,
],
queryFn: () =>
statisticsApi.getVerificationResultList({
...baseRequest,
vrfcMthdCd: "NV",
}),
},
{
queryKey: [
"verificationResultList",
statsYr,
"IMR",
baseRequest.pbcpAprvYn,
baseRequest.page,
baseRequest.size,
baseRequest.prcsYnList,
],
queryFn: () =>
statisticsApi.getVerificationResultList({
...baseRequest,
vrfcMthdCd: "IMR",
}),
},
],
});
const renderSection = (typeCode: "IMR" | "NV", items: typeof imrList) => (
const isLoading = nvQuery.isLoading || imrQuery.isLoading;
const error = nvQuery.error ?? imrQuery.error;
const nvList = nvQuery.data?.data?.verifyResultList ?? [];
const imrList = imrQuery.data?.data?.verifyResultList ?? [];
const openInputFromStatsCd = useCallback(
(item: VerificationResultReportItem) => {
const nav = resolveInputPageNavigationFromStatsCd(item.statsCd);
if (!nav) {
alert(
`입력 화면으로 연결되지 않는 통계코드입니다: ${item.statsCd}`,
);
return;
}
if (!menuList) {
alert(
"메뉴 정보를 불러오는 중입니다. 잠시 후 다시 시도해 주세요.",
);
return;
}
const menu = findMenuItemByUrlPath(menuList, nav.urlPath);
if (!menu) {
alert(
"해당 입력 메뉴를 찾을 수 없습니다. 메뉴 권한을 확인해 주세요.",
);
return;
}
setPendingInputTab(nav.urlPath, nav.tabKey, {
statsYr: item.statsYr,
sewerBzmnCd: item.sewerBzmnCd,
ctpvNm: item.ctpvNm,
sggNm: item.sggNm ?? "",
});
setCurrentMenuInfo(menu);
const ok = addTab(menu);
if (ok) {
addTabForGrid(menu);
} else {
alert(`최대 추가 할 수 있는 화면 수(10개)를 \n초과 하였습니다.
기존 화면을 닫은 후 이용해주세요.`);
}
},
[
menuList,
addTab,
addTabForGrid,
setCurrentMenuInfo,
setPendingInputTab,
alert,
],
);
const renderSection = (
typeCode: "IMR" | "NV",
items: VerificationResultReportItem[],
) => (
<section className="flex min-h-0 flex-1 flex-col rounded-sm border border-border h-[50rem]">
<div className="flex items-center justify-between border-b border-border px-3 py-2">
<Badge color={TYPE_CODE_COLOR_MAP[typeCode]} className="rounded-full px-2 py-1 text-lg">
@@ -55,20 +164,33 @@ const VerificationSelectionReport = ({
</div>
) : (
<div className="space-y-2 tracking-tight">
{items.map((item, index) => (
{items.map((item) => (
<div
key={`${typeCode}-${index}`}
key={`${typeCode}-${item.vrfcSn}`}
role="button"
tabIndex={0}
className="flex flex-col rounded-sm border border-border p-3 cursor-pointer hover:bg-gray-100"
onClick={() => openInputFromStatsCd(item)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
openInputFromStatsCd(item);
}
}}
>
<div className="mb-2 flex items-center justify-between">
<span className="font-medium">
{item.statsNm}
</span>
<Badge
color={TYPE_CODE_COLOR_MAP[item.vrfcCd]}
color={
TYPE_CODE_COLOR_MAP[item.vrfcMthdCd] ??
"gray"
}
className="text-sm"
>
{TYPE_CODE_NAME_MAP[item.vrfcCd]}
{TYPE_CODE_NAME_MAP[item.vrfcMthdCd] ??
item.vrfcMthdNm}
</Badge>
</div>
<p className="text-lg text-muted-foreground">
@@ -112,18 +234,18 @@ const VerificationSelectionReport = ({
</div>
<div className="card-body max-h-[calc(100%-3.25rem)] p-2.5">
{isLoading && (
<div className="flex h-full items-center justify-center">
<div className="absolute inset-0 z-50 [data-theme='black']:bg-transparent rounded-lg p-10 flex justify-center items-center">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
)}
{error && (
<div className="flex h-full items-center justify-center">
<div className="absolute inset-0 z-50 [data-theme='black']:bg-transparent rounded-lg p-10 flex justify-center items-center">
데이터를 불러올 수 없습니다.
</div>
)}
{data && (
{!isLoading && !error && (
<div className="grid grid-rows-2 gap-6">
{renderSection("IMR", imrList)}
{renderSection("NV", nvList)}
@@ -135,4 +257,4 @@ const VerificationSelectionReport = ({
);
};
export default VerificationSelectionReport;
export default VerificationSelectionReport;
@@ -1,4 +1,4 @@
import { useState, useMemo, useRef, useEffect } from "react";
import { useState, useMemo, useRef, useEffect, useLayoutEffect } from "react";
import SearchForm from "@/components/module/SearchForm";
import DataGrid from "@/components/module/DataGrid";
@@ -15,6 +15,10 @@ import { SewagePipeInpColumnInfoList } from "@/components/util/column/input/sewa
import type { ColumnInfo, ColumnInfoList, ColumnConfig } from "@/components/type/DataGrid.type";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
import { useAlertStore, useGlobalProgressStore } from "@/shared/lib";
import {
STATISTICS_INPUT_URL_PATH,
verificationSearchToFormValues,
} from "@/shared/lib/stats-cd-input-navigation";
import { useAdminStore } from "@/store/admin-store";
import { statisticsInputSewSupplyRateApi } from "@/entities/statistics/api/input/statistics-input-sew-supply-rate-service";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
@@ -39,6 +43,8 @@ import { LoadingWithAnimation, Tabs, TabsContent, TabsList, TabsTrigger } from "
import { getUserInfo } from "../../../../../../packages/common/components/src/api";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import type { cancelSubmitRequest } from "@/entities/statistics/model/statistics-interface";
import { useStatisticsInputFocusStore } from "@/store/statistics-input-focus-store";
import { useTabStore } from "@/store/tab-store";
/** 탭별 status + 입력기간으로 편집 가능 여부 */
const computeTabEnterable = (
@@ -164,6 +170,14 @@ const SewagePipeInp = () => {
/** 검색 완료 후(API 연결 전) 탭별 지연 로그용 — 첫 탭에 행이 없어도 동작하도록 보조 */
const searchDoneRef = useRef(false);
const lazyTabLoggedRef = useRef<Set<string>>(new Set());
const postVerificationSearchRef = useRef<{
values: SearchFormValues;
tabKey: string;
} | null>(null);
const [verificationSearchDefaults, setVerificationSearchDefaults] =
useState<SearchFormValues | null>(null);
const [searchFormRemountKey, setSearchFormRemountKey] = useState(0);
const firstTabKey = SewagePipeInpColumnInfoList[0]?.tabKey ?? "ST00020001";
const activeTabRef = useRef<string>(firstTabKey);
@@ -172,6 +186,25 @@ const SewagePipeInp = () => {
activeTabRef.current = activeTab;
}, [activeTab]);
const activeTabId = useTabStore((s) => s.activeTabId);
const inputFocusNonce = useStatisticsInputFocusStore((s) => s.nonce);
useLayoutEffect(() => {
if (activeTabId !== STATISTICS_INPUT_URL_PATH.SEWAGE_PIPE) return;
const pending = useStatisticsInputFocusStore
.getState()
.takePendingForUrl(STATISTICS_INPUT_URL_PATH.SEWAGE_PIPE);
if (!pending) return;
const { tabKey, search } = pending;
activeTabRef.current = tabKey;
setActiveTab(tabKey);
if (search) {
const values = verificationSearchToFormValues(search);
setVerificationSearchDefaults(values);
postVerificationSearchRef.current = { values, tabKey };
setSearchFormRemountKey((k) => k + 1);
}
}, [activeTabId, inputFocusNonce]);
const syncTopStatusIfStillOnTab = (loadedTabKey: string, nextStatsCd: string) => {
if (loadedTabKey === activeTabRef.current) {
setStatsCd(nextStatsCd);
@@ -195,6 +228,8 @@ const SewagePipeInp = () => {
setActiveTab(firstTabKey);
setStatsCd("");
setPeriodCheckList([]);
setVerificationSearchDefaults(null);
setSearchFormRemountKey((k) => k + 1);
};
const columnInfo = useMemo(() => {
@@ -424,7 +459,10 @@ const SewagePipeInp = () => {
return true;
};
const handleSearch = async (values: SearchFormValues) => {
const handleSearch = async (
values: SearchFormValues,
options?: { forcedTabKey?: string },
) => {
if (!values.sido || values.sido === 'ALL') {
alert('시도를 선택해주세요.');
return;
@@ -466,9 +504,17 @@ const SewagePipeInp = () => {
setHasSearched(true);
setLastSearchedYear(values.year ?? null);
const tabKeyToLoad = yearChangedFromLastSearch ? firstTabKey : activeTab;
const forcedTab = options?.forcedTabKey;
const tabKeyToLoad = forcedTab
? forcedTab
: yearChangedFromLastSearch
? firstTabKey
: activeTab;
if (yearChangedFromLastSearch) {
if (forcedTab) {
activeTabRef.current = forcedTab;
setActiveTab(forcedTab);
} else if (yearChangedFromLastSearch) {
activeTabRef.current = firstTabKey;
setActiveTab(firstTabKey);
} else {
@@ -482,6 +528,14 @@ const SewagePipeInp = () => {
}
};
useEffect(() => {
const job = postVerificationSearchRef.current;
if (!job) return;
postVerificationSearchRef.current = null;
void handleSearch(job.values, { forcedTabKey: job.tabKey });
// eslint-disable-next-line react-hooks/exhaustive-deps -- 검색폼 remount 직후 1회 자동 조회
}, [searchFormRemountKey]);
const handleActiveTabChange = async (tabKey: string) => {
activeTabRef.current = tabKey;
setActiveTab(tabKey);
@@ -826,7 +880,13 @@ const SewagePipeInp = () => {
return (
<>
<SearchForm onSubmit={handleSearch} onReset={handleReset} onValuesChange={setSearchValues} />
<SearchForm
key={searchFormRemountKey}
defaultValues={verificationSearchDefaults ?? undefined}
onSubmit={handleSearch}
onReset={handleReset}
onValuesChange={setSearchValues}
/>
{showActionToolbarCard && (
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] mx-[0.6rem]">
@@ -1,6 +1,10 @@
import { useRef, useState } from "react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { useAlertStore } from "@/shared/lib";
import {
STATISTICS_INPUT_URL_PATH,
verificationSearchToFormValues,
} from "@/shared/lib/stats-cd-input-navigation";
import { statisticsInputSewSupplyRateApi } from "@/entities/statistics/api/input/statistics-input-sew-supply-rate-service";
import { sewagePipeMaintainApiService } from "@/entities/statistics/api/input/statistics-input-sewage-pipe-maintain-service";
import type { SewagePipeMaintainRequestParams } from "@/entities/statistics/model/input/statistics-input-sewage-pipe-maintain-interface";
@@ -16,6 +20,8 @@ import type { SearchFormValues } from "@/components/type/SearchForm.type";
import { PipeDredgingStatusInpColumn } from "@/components/util/column/input/sewagePipeMaintain/PipeDredgingStatusInpColumn";
import { RepairStatusInpColumn } from "@/components/util/column/input/sewagePipeMaintain/RepairStatusInpColumn";
import { toGridRows } from "@/components/util/grid/gridFunction";
import { useStatisticsInputFocusStore } from "@/store/statistics-input-focus-store";
import { useTabStore } from "@/store/tab-store";
function buildSewagePipeMaintainSearchParam(
values: SearchFormValues,
@@ -40,6 +46,13 @@ const SewagePipeMaintainInp = () => {
const [lastSearchedYear, setLastSearchedYear] = useState<string | null>(null);
const [hasSearched, setHasSearched] = useState(false);
const searchParamRef = useRef<SewagePipeMaintainRequestParams | null>(null);
const postVerificationSearchRef = useRef<{
values: SearchFormValues;
tabKey: string;
} | null>(null);
const [verificationSearchDefaults, setVerificationSearchDefaults] =
useState<SearchFormValues | null>(null);
const [searchFormRemountKey, setSearchFormRemountKey] = useState(0);
// tab List
const SewagePipeMaintainInpTabList = [
@@ -47,6 +60,24 @@ const SewagePipeMaintainInp = () => {
PipeDredgingStatusInpColumn
];
const activeTabId = useTabStore((s) => s.activeTabId);
const inputFocusNonce = useStatisticsInputFocusStore((s) => s.nonce);
useLayoutEffect(() => {
if (activeTabId !== STATISTICS_INPUT_URL_PATH.SEWAGE_PIPE_MAINTAIN) return;
const pending = useStatisticsInputFocusStore
.getState()
.takePendingForUrl(STATISTICS_INPUT_URL_PATH.SEWAGE_PIPE_MAINTAIN);
if (!pending) return;
const { tabKey, search } = pending;
setActiveTab(tabKey);
if (search) {
const values = verificationSearchToFormValues(search);
setVerificationSearchDefaults(values);
postVerificationSearchRef.current = { values, tabKey };
setSearchFormRemountKey((k) => k + 1);
}
}, [activeTabId, inputFocusNonce]);
const SubmitExceptionFunc = () => {
console.log('submit exception');
// 예외 제출 로직
@@ -136,9 +167,14 @@ const SewagePipeMaintainInp = () => {
setLastSearchedYear(null);
setHasSearched(false);
searchParamRef.current = null;
setVerificationSearchDefaults(null);
setSearchFormRemountKey((k) => k + 1);
};
const handleSearch = async (values: SearchFormValues) => {
const handleSearch = async (
values: SearchFormValues,
opts?: { forcedTabKey?: string },
) => {
if (!values.sido || values.sido === "ALL") {
alert("시도를 선택해주세요.");
return;
@@ -151,13 +187,18 @@ const SewagePipeMaintainInp = () => {
return;
}
const tabForLoad = opts?.forcedTabKey ?? activeTab;
if (opts?.forcedTabKey) {
setActiveTab(opts.forcedTabKey);
}
setIsLoading(true);
const searchParam = buildSewagePipeMaintainSearchParam(values);
searchParamRef.current = searchParam;
setLastSearchedValues(values);
setLastSearchedYear(values.year ?? null);
setHasSearched(true);
await loadActiveTabData(activeTab, searchParam, false);
await loadActiveTabData(tabForLoad, searchParam, false);
} catch {
alert("데이터 조회 중 오류가 발생했습니다.");
} finally {
@@ -165,6 +206,14 @@ const SewagePipeMaintainInp = () => {
}
};
useEffect(() => {
const job = postVerificationSearchRef.current;
if (!job) return;
postVerificationSearchRef.current = null;
void handleSearch(job.values, { forcedTabKey: job.tabKey });
// eslint-disable-next-line react-hooks/exhaustive-deps -- 검색폼 remount 직후 1회 자동 조회
}, [searchFormRemountKey]);
const handleActiveTabChange = async (tabKey: string) => {
setActiveTab(tabKey);
const hasRows = (rowDataMap.get(tabKey) ?? []).length > 0;
@@ -183,7 +232,13 @@ const SewagePipeMaintainInp = () => {
return (
<>
<SearchForm onSubmit={handleSearch} onReset={handleReset} onValuesChange={setSearchValues} />
<SearchForm
key={searchFormRemountKey}
defaultValues={verificationSearchDefaults ?? undefined}
onSubmit={handleSearch}
onReset={handleReset}
onValuesChange={setSearchValues}
/>
{(hasRowData || hasSearched) && (
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
@@ -1,4 +1,4 @@
import { useState, useMemo, useRef } from "react";
import { useState, useMemo, useRef, useLayoutEffect, useEffect } from "react";
import SearchForm from "@/components/module/SearchForm";
// import { DataGrid } from "@rsone/components";
@@ -15,6 +15,10 @@ import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import SubmitDailog from "./SubmitDailog";
import { useAlertStore, useGlobalProgressStore } from "@/shared/lib";
import {
STATISTICS_INPUT_URL_PATH,
verificationSearchToFormValues,
} from "@/shared/lib/stats-cd-input-navigation";
import { useAdminStore } from "@/store/admin-store";
import { statisticsInputSewSupplyRateApi } from "@/entities/statistics/api/input/statistics-input-sew-supply-rate-service";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
@@ -43,6 +47,8 @@ import type {
import type { StatisticsInputSubmitInfoFormData } from "@/entities/statistics/model/statistics-input-submit-info-schema";
import { SeweragePenetrationRateInpColumn } from "@/components/util/column/input/SeweragePenetrationRateInpColumn";
import { doList } from "@/components/util/column/input/DoList";
import { useStatisticsInputFocusStore } from "@/store/statistics-input-focus-store";
import { useTabStore } from "@/store/tab-store";
type GridRowWithVerification = Record<string, string | number> & {
@@ -118,6 +124,27 @@ const SeweragePenetrationRateInp = () => {
const hideGlobalProgress = useGlobalProgressStore((state) => state.hide);
const globalProgressVisible = useGlobalProgressStore((state) => state.visible);
const postVerificationSearchRef = useRef<SearchFormValues | null>(null);
const [verificationSearchDefaults, setVerificationSearchDefaults] =
useState<SearchFormValues | null>(null);
const [searchFormRemountKey, setSearchFormRemountKey] = useState(0);
const activeTabId = useTabStore((s) => s.activeTabId);
const inputFocusNonce = useStatisticsInputFocusStore((s) => s.nonce);
useLayoutEffect(() => {
if (activeTabId !== STATISTICS_INPUT_URL_PATH.SEWERAGE_PENETRATION_RATE) {
return;
}
const pending = useStatisticsInputFocusStore
.getState()
.takePendingForUrl(STATISTICS_INPUT_URL_PATH.SEWERAGE_PENETRATION_RATE);
if (!pending?.search) return;
const values = verificationSearchToFormValues(pending.search);
setVerificationSearchDefaults(values);
postVerificationSearchRef.current = values;
setSearchFormRemountKey((k) => k + 1);
}, [activeTabId, inputFocusNonce]);
const { isAdminInfo } = useAdminStore();
const getGridDataRef = useRef<(() => Record<string, string | number>[]) | null>(null);
@@ -156,6 +183,8 @@ const SeweragePenetrationRateInp = () => {
setLastSearchedYear(null);
setLastSearchedValues(null);
setFooterFromApi(null);
setVerificationSearchDefaults(null);
setSearchFormRemountKey((k) => k + 1);
};
const fetchSewerageSupplyRate = async (params: SewerageSupplyRateRequestParams, isEnterable: boolean) => {
@@ -238,6 +267,14 @@ const SeweragePenetrationRateInp = () => {
}
};
useEffect(() => {
const values = postVerificationSearchRef.current;
if (!values) return;
postVerificationSearchRef.current = null;
void handleSearch(values);
// eslint-disable-next-line react-hooks/exhaustive-deps -- 검색폼 remount 직후 1회 자동 조회
}, [searchFormRemountKey]);
const SubmitExceptionFunc = async (submitInfo: StatisticsInputSubmitInfoFormData) => {
const verificationBlocked = getVerificationPeriodActionBlockedMessage(periodCheckList);
if (verificationBlocked) {
@@ -623,7 +660,13 @@ const SeweragePenetrationRateInp = () => {
return (
<>
<div className="relative">
<SearchForm onSubmit={handleSearch} onReset={handleReset} onValuesChange={setSearchValues} />
<SearchForm
key={searchFormRemountKey}
defaultValues={verificationSearchDefaults ?? undefined}
onSubmit={handleSearch}
onReset={handleReset}
onValuesChange={setSearchValues}
/>
{showActionToolbarCard && (
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] mx-[0.6rem]">
@@ -713,6 +756,20 @@ const SeweragePenetrationRateInp = () => {
<VerificationResultDialog
open={verificationResultDialogOpen}
onOpenChange={setVerificationResultDialogOpen}
queryContext={
searchValues?.year && searchValues?.sido && searchValues?.bsnmCd && statsCd
? {
sewerBzmnCd: searchValues.bsnmCd,
ctpvNm: searchValues.sido,
sggNm:
searchValues.gugun === "ALL"
? ""
: (searchValues.gugun ?? ""),
statsYr: searchValues.year,
statsCd,
}
: null
}
/>
</>
);
@@ -175,12 +175,12 @@ const SubmitDailog = ({
</TableBody>
</Table>
<DialogFooter className="mt-6">
<Button type="button" color="gray" onClick={handleCancel}>
취소
</Button>
<Button type="submit" color="primary">
{submitMode === 'normal' ? '제출' : '예외 제출'}
</Button>
<Button type="button" color="gray" onClick={handleCancel}>
취소
</Button>
</DialogFooter>
</form>
</DialogContent>
@@ -0,0 +1,33 @@
/**
* statistics/input 화면과 탭(·분할 그리드) 키 매칭용 상수.
* 각 컴포넌트에 실제로 쓰이는 `tabKey`를 모두 포함합니다.
*/
/** `SewagePipeInp.tsx` — 메인 탭 + `ConnectionStatusInpColumn` 좌·우 그리드 서브 키 */
export const SEWAGE_PIPE_INP_TAB_KEYS = [
"ST00020001",
"ST00020002",
"ST00020003",
"ST00020004",
"ST00020005",
"ST00020006",
"treatmentPlantConnectionInp",
"adminAreaConnectionInp",
] as const;
/** `SewagePipeMaintainInp.tsx` — 보수·준설 탭 */
export const SEWAGE_PIPE_MAINTAIN_INP_TAB_KEYS = ["ST00030001", "ST00030002"] as const;
/** `SeweragePenetrationRateInp.tsx` — 단일 그리드(통계코드) */
export const SEWERAGE_PENETRATION_RATE_INP_TAB_KEYS = ["ST00010001"] as const;
export type SewagePipeInpTabKey = (typeof SEWAGE_PIPE_INP_TAB_KEYS)[number];
export type SewagePipeMaintainInpTabKey = (typeof SEWAGE_PIPE_MAINTAIN_INP_TAB_KEYS)[number];
export type SeweragePenetrationRateInpTabKey = (typeof SEWERAGE_PENETRATION_RATE_INP_TAB_KEYS)[number];
/** 컴포넌트 파일명 기준 묶음 (메뉴·라우트 매칭 시 참고) */
export const STATISTICS_INPUT_TAB_KEYS_BY_SCREEN = {
SewagePipeInp: SEWAGE_PIPE_INP_TAB_KEYS,
SewagePipeMaintainInp: SEWAGE_PIPE_MAINTAIN_INP_TAB_KEYS,
SeweragePenetrationRateInp: SEWERAGE_PENETRATION_RATE_INP_TAB_KEYS,
} as const;
@@ -26,6 +26,8 @@ import {
type SubmitExceptionResponse,
type ImrResponse,
type ImrStatusResponse,
type SubmitVerifyExceptionRequest,
type SubmitVerifyExceptionResponse,
} from "../model/statistics-interface";
export class StatisticsApiService implements StatisticsService {
@@ -164,6 +166,14 @@ export class StatisticsApiService implements StatisticsService {
);
return response.data;
}
public async SubmitVerifyException(param: SubmitVerifyExceptionRequest): Promise<SubmitVerifyExceptionResponse> {
const response = await stClient.put<SubmitVerifyExceptionResponse>(
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/results/exception`,
param,
);
return response.data;
}
}
export const statisticsApi = new StatisticsApiService();
@@ -268,6 +268,24 @@ export interface ReviewSubmitResponse {
message?: string;
}
export interface SubmitVerifyExceptionRequest {
sewerBzmnCd: string;
statsYr: string;
statsCd: string;
userId: string;
exceptionList: [
{
vrfcSn: number;
prcsOpnnExpln: string;
}
];
}
export interface SubmitVerifyExceptionResponse {
status: string;
message?: string;
}
export interface StatisticsService {
getSidoList(params?: { instSeCd?: string }): Promise<ApiResponse<SearchCondition>>;
getSggList(sidoName: string, params?: { instSeCd?: string; gugunSkipYn?: "Y" | "N" }): Promise<ApiResponse<Sgg[]>>;
@@ -280,7 +298,8 @@ export interface StatisticsService {
saveReviewOpinion(param: ReviewOpinionSaveRequest): Promise<ReviewOpinionSaveResponse>;
getReviewOpinionDetail(param: getReviewOpinionDetailRequest): Promise<getReviewOpinionDetailResponse>;
saveReviewReturn(param: ReviewReturnSaveRequest): Promise<ReviewReturnSaveResponse>;
submitReview(param :ReviewSubmitRequest): Promise<ReviewSubmitResponse>;
submitReview(param: ReviewSubmitRequest): Promise<ReviewSubmitResponse>;
getVerificationResultList(param: VerificationResultListRequest): Promise<VerificationResultListResponse>;
getImrStatus(param: string): Promise<ImrStatusResponse>;
SubmitVerifyException(param: SubmitVerifyExceptionRequest): Promise<SubmitVerifyExceptionResponse>;
}
@@ -0,0 +1,99 @@
import type { MenuItem } from "@/entities/menu";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
import { doNameList } from "@/components/util/column/input/DoList";
import {
SEWAGE_PIPE_INP_TAB_KEYS,
SEWAGE_PIPE_MAINTAIN_INP_TAB_KEYS,
SEWERAGE_PENETRATION_RATE_INP_TAB_KEYS,
} from "@/components/util/MenuMatchList";
/** 검증 결과에서 입력 화면 자동 조회에 사용하는 조건 */
export type StatisticsInputPendingSearch = {
statsYr: string;
sewerBzmnCd: string;
ctpvNm: string;
sggNm: string;
};
export function verificationSearchToFormValues(
s: StatisticsInputPendingSearch,
): SearchFormValues {
const ctpvNmTrimmed = s.ctpvNm?.trim() ?? "";
const sggNmTrimmed = s.sggNm?.trim() ?? "";
/** 시군구 필수 도(`DoList.ts`의 `doNameList`)일 때만 API `sggNm`을 검색폼에 반영 */
const useSggNm =
sggNmTrimmed.length > 0 && doNameList.includes(ctpvNmTrimmed);
return {
year: s.statsYr,
bsnmCd: s.sewerBzmnCd,
sido: s.ctpvNm,
gugun: useSggNm ? sggNmTrimmed : "ALL",
};
}
/** `statistics-page` 동적 import 경로의 파일명(확장자 제외)과 일치해야 함 */
export const STATISTICS_INPUT_URL_PATH = {
SEWAGE_PIPE: "SewagePipeInp",
SEWAGE_PIPE_MAINTAIN: "SewagePipeMaintainInp",
SEWERAGE_PENETRATION_RATE: "SeweragePenetrationRateInp",
} as const;
function normalizeSewagePipeTabKey(tabKey: string): string {
if (
tabKey === "adminAreaConnectionInp" ||
tabKey === "treatmentPlantConnectionInp"
) {
return "ST00020006";
}
return tabKey;
}
const NAV_ROWS: { urlPath: string; tabKeys: readonly string[] }[] = [
{ urlPath: STATISTICS_INPUT_URL_PATH.SEWAGE_PIPE, tabKeys: SEWAGE_PIPE_INP_TAB_KEYS },
{
urlPath: STATISTICS_INPUT_URL_PATH.SEWAGE_PIPE_MAINTAIN,
tabKeys: SEWAGE_PIPE_MAINTAIN_INP_TAB_KEYS,
},
{
urlPath: STATISTICS_INPUT_URL_PATH.SEWERAGE_PENETRATION_RATE,
tabKeys: SEWERAGE_PENETRATION_RATE_INP_TAB_KEYS,
},
];
/**
* 검증 결과 등의 `statsCd`(또는 입력 화면 탭 키와 동일한 코드)로
* 열 입력 화면 `urlPath`와 활성 탭 키를 반환합니다.
*/
export function resolveInputPageNavigationFromStatsCd(statsCd: string): {
urlPath: string;
tabKey: string;
} | null {
const trimmed = statsCd.trim();
if (!trimmed) return null;
for (const row of NAV_ROWS) {
if (row.tabKeys.includes(trimmed)) {
const tabKey =
row.urlPath === STATISTICS_INPUT_URL_PATH.SEWAGE_PIPE
? normalizeSewagePipeTabKey(trimmed)
: trimmed;
return { urlPath: row.urlPath, tabKey };
}
}
return null;
}
export function findMenuItemByUrlPath(
menus: MenuItem[],
urlPath: string,
): MenuItem | null {
for (const item of menus) {
if (item.urlPath === urlPath) return item;
if (item.children?.length) {
const found = findMenuItemByUrlPath(item.children, urlPath);
if (found) return found;
}
}
return null;
}
@@ -0,0 +1,41 @@
import { create } from "zustand";
import type { StatisticsInputPendingSearch } from "@/shared/lib/stats-cd-input-navigation";
type Pending = {
urlPath: string;
tabKey: string;
search: StatisticsInputPendingSearch | null;
};
export interface StatisticsInputFocusStore {
pending: Pending | null;
/** `setPendingInputTab` 호출 시마다 증가 — 이미 해당 화면이 활성일 때도 탭 적용 effect가 돌도록 함 */
nonce: number;
setPendingInputTab: (
urlPath: string,
tabKey: string,
search?: StatisticsInputPendingSearch | null,
) => void;
takePendingForUrl: (
urlPath: string,
) => { tabKey: string; search: StatisticsInputPendingSearch | null } | undefined;
}
export const useStatisticsInputFocusStore = create<StatisticsInputFocusStore>(
(set, get) => ({
pending: null,
nonce: 0,
setPendingInputTab: (urlPath, tabKey, search = null) =>
set((state) => ({
pending: { urlPath, tabKey, search: search ?? null },
nonce: state.nonce + 1,
})),
takePendingForUrl: (urlPath) => {
const p = get().pending;
if (!p || p.urlPath !== urlPath) return undefined;
set({ pending: null });
return { tabKey: p.tabKey, search: p.search };
},
}),
);