diff --git a/apps/st/src/components/module/DataGrid.tsx b/apps/st/src/components/module/DataGrid.tsx index 801a8711f..8c8e0d75c 100644 --- a/apps/st/src/components/module/DataGrid.tsx +++ b/apps/st/src/components/module/DataGrid.tsx @@ -319,8 +319,10 @@ const DataGrid = ({ rowData, setRowData, getGridDataRef, + getGridRef, cellStyleCallback, cellTooltipCallback, + onCellEdited, onRowClick, onItemChecked, onItemAllChecked, @@ -345,6 +347,7 @@ const DataGrid = ({ const cellTooltipCallbackRef = useRef( cellTooltipCallback, ); + const onCellEditedRef = useRef(onCellEdited); const onScrollToBottomRef = useRef<(() => void) | undefined>( onScrollToBottom, ); @@ -357,6 +360,7 @@ const DataGrid = ({ onScrollToBottomRef.current = onScrollToBottom; onItemCheckedRef.current = onItemChecked; onItemAllCheckedRef.current = onItemAllChecked; + onCellEditedRef.current = onCellEdited; // highlightedRowId가 변경될 때 해당 행으로 스크롤 및 선택 useEffect(() => { @@ -1149,6 +1153,33 @@ const DataGrid = ({ }; }, [onItemChecked, onItemAllChecked]); + useEffect(() => { + const gridView = gridRef.current?.gridView; + if (!gridView) return; + + gridView.onCellEdited = (grid, itemIndex, _row, field) => { + const fieldName = columns[field]?.fieldName; + if (!fieldName) return; + + const newValue = grid.getValue(itemIndex, fieldName); + const prevRow = rowDataRef.current[itemIndex]; + const oldValue = prevRow?.[fieldName] ?? ""; + + onCellEditedRef.current?.({ + itemIndex, + fieldName, + oldValue, + newValue, + }); + }; + + return () => { + if (gridView.onCellEdited) { + gridView.onCellEdited = undefined; + } + }; + }, [columns, setRowData]); + return (
{ if (!grid) return; ( @@ -1259,9 +1289,9 @@ const DataGrid = ({ buildFieldsFromColumns(columns), ); } - if (dataProvider?.setRows && rowData?.length) { + if (dataProvider?.setRows) dataProvider.setRows(rowData); - } + if (getGridRef) getGridRef.current = gridRef.current; const gridView = gridRef.current?.gridView ?? grid; if (!gridView?.columnByName) return; setTimeout(() => { diff --git a/apps/st/src/components/statistics/verification/StatisticsVerificationReport.tsx b/apps/st/src/components/statistics/verification/StatisticsVerificationReport.tsx index 3b6f86c4f..837433b8d 100644 --- a/apps/st/src/components/statistics/verification/StatisticsVerificationReport.tsx +++ b/apps/st/src/components/statistics/verification/StatisticsVerificationReport.tsx @@ -1,567 +1,713 @@ -"use client"; - -import type { Dispatch, SetStateAction } from "react"; -import { useCallback, useEffect, useMemo, useState } from "react"; - -import { useConfirm } from "@frontend-opensource/use-react-hooks"; -import dayjs from "dayjs"; - -import { useAlertStore, useMutationWrapper, useUserStore } from "@/shared/lib"; -import { CodeSelect } from "@/entities/code"; -import { - type ApprovalRequest, - statisticsApprovalApi, - statisticsVerificationDetailColumnInfo, - type StatisticsVerificationFilterState, - type StatisticsVerificationSubFilterState, - statisticsVerificationSummaryColumnInfo, - type StatsInputStatusList, - useStAnlsSidoCombo, - useStAnlsSigunguCombo, - useStatsVrfcRptData, - useStatsVrfcRsltData, - type VerifyResultItem, -} from "@/entities/statistics"; -import { - Button, - Combobox, - ComboboxChip, - ComboboxChips, - ComboboxChipsInput, - ComboboxContent, - ComboboxEmpty, - ComboboxItem, - ComboboxList, - ComboboxValue, - Label, - LoadingWithAnimation, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - useComboboxAnchor, -} from "@common/components"; - -import DataGrid from "@/components/module/DataGrid"; -import { toGridRows } from "@/components/util/grid/gridFunction"; - -const getYearOptions = () => { - const currentYear = dayjs().year(); - const yearOptionsRange = Array.from( - { length: currentYear - 2017 }, - (_, i) => { - const year = 2017 + i; - return { label: String(year), value: String(year) }; - }, - ); - return yearOptionsRange.map((year) => ({ - label: year.label, - value: year.value, - })); -}; - -function isSameStatsInputStatusRow( - a: StatsInputStatusList, - b: StatsInputStatusList, -): boolean { - return ( - a.ctpvNm === b.ctpvNm && a.sggNm === b.sggNm && a.statsCd === b.statsCd - ); -} - -function isSameVerifyResultRow( - a: VerifyResultItem, - b: VerifyResultItem, -): boolean { - if (a.vrfcSn != null && b.vrfcSn != null) return a.vrfcSn === b.vrfcSn; - return ( - a.statsYr === b.statsYr && - a.statsCd === b.statsCd && - a.ctpvNm === b.ctpvNm && - a.sggNm === b.sggNm && - a.vrfcTrgtNm === b.vrfcTrgtNm && - a.vrfcMthdCd === b.vrfcMthdCd - ); -} - -function useGridCheckboxSelection( - sourceRows: T[], - isSameRow: (a: T, b: T) => boolean, - setSelected: Dispatch>, -) { - const handleItemChecked = useCallback( - (itemIndex: number, checked: boolean) => { - const origin = sourceRows[itemIndex]; - if (origin === undefined) return; - setSelected((prev) => { - if (checked) { - if (prev.some((r) => isSameRow(r, origin))) return prev; - return [...prev, origin]; - } - return prev.filter((r) => !isSameRow(r, origin)); - }); - }, - [sourceRows, isSameRow, setSelected], - ); - - const handleAllChecked = useCallback( - (checked: boolean) => { - if (checked) setSelected([...sourceRows]); - else setSelected([]); - }, - [sourceRows, setSelected], - ); - - return { handleItemChecked, handleAllChecked }; -} - -const DEFAULT_FILTERS: StatisticsVerificationFilterState = { - ctpvNm: null, - sggNm: null, - statsYr: String(dayjs().year() - 1), - sewerBzmnCd: null, - statsCd: null, -}; - -const DEFAULT_SUB_FILTERS: StatisticsVerificationSubFilterState = { - ...DEFAULT_FILTERS, - prcsYnList: "1,2,3", - pbcpAprvYn: "N", -}; - -const STATUS_OPTIONS = [ - { label: "수정완료", value: "1" }, - { label: "예외통과", value: "2" }, - { label: "반려", value: "3" }, -]; - -const APPROVAL_STATUS_OPTIONS = [ - { label: "승인", value: "Y" }, - { label: "미승인", value: "N" }, -]; - -function StatisticsVerificationReport() { - const userInfo = useUserStore((state) => state.userInfo); - const { setMessage: alert } = useAlertStore((state) => state); - const { confirm } = useConfirm(); - const [filters, setFilters] = - useState(DEFAULT_FILTERS); - const [subFilters, setSubFilters] = - useState(DEFAULT_SUB_FILTERS); - const [submittedMainFilters, setSubmittedMainFilters] = - useState(DEFAULT_FILTERS); - const [submittedDetailParams, setSubmittedDetailParams] = useState<{ - main: StatisticsVerificationFilterState; - sub: StatisticsVerificationSubFilterState; - }>({ - main: DEFAULT_FILTERS, - sub: DEFAULT_SUB_FILTERS, - }); - const [selectedSummaryRows, setSelectedSummaryRows] = useState< - StatsInputStatusList[] - >([]); - const [selectedDetailRows, setSelectedDetailRows] = useState< - VerifyResultItem[] - >([]); - - const { data: sidoData } = useStAnlsSidoCombo(); - const { data: sigunguData } = useStAnlsSigunguCombo( - filters.sewerBzmnCd ?? "", - ); - - const statisticsVerificationReportQuery = - useStatsVrfcRptData(submittedMainFilters); - const statisticsVerificationResultQuery = useStatsVrfcRsltData( - submittedDetailParams, - ); - - const statisticsVerificationReportData = - statisticsVerificationReportQuery.data ?? []; - const statisticsVerificationReportGridRows = useMemo( - () => toGridRows(statisticsVerificationReportData), - [statisticsVerificationReportData], - ); - const statisticsVerificationResultData = useMemo( - () => - statisticsVerificationResultQuery.data?.pages.flatMap( - (page) => page.verifyResultList ?? [], - ) ?? [], - [statisticsVerificationResultQuery.data], - ); - const statisticsVerificationResultGridRows = useMemo( - () => toGridRows(statisticsVerificationResultData), - [statisticsVerificationResultData], - ); - - const isLoading = - statisticsVerificationReportQuery.isFetching || - statisticsVerificationResultQuery.isFetching; - const isAppendingDetail = - statisticsVerificationResultQuery.isFetchingNextPage; - const canLoadMoreDetail = Boolean( - statisticsVerificationResultQuery.hasNextPage, - ); - - const { - handleItemChecked: handleReportItemChecked, - handleAllChecked: handleReportAllChecked, - } = useGridCheckboxSelection( - statisticsVerificationReportData, - isSameStatsInputStatusRow, - setSelectedSummaryRows, - ); - - const { - handleItemChecked: handleResultItemChecked, - handleAllChecked: handleResultAllChecked, - } = useGridCheckboxSelection( - statisticsVerificationResultData, - isSameVerifyResultRow, - setSelectedDetailRows, - ); - - const { mutateAsync: onApproveStats } = useMutationWrapper({ - mutationFn: (approvalRequests: ApprovalRequest[]) => - statisticsApprovalApi.approveStats(approvalRequests), - onSuccess: (data) => { - alert(data.message ?? "승인이 완료되었습니다."); - }, - }); - - useEffect(() => { - if (!statisticsVerificationReportQuery.isError) return; - alert("통계검증리포트 조회 중 오류가 발생했습니다."); - setSelectedSummaryRows([]); - }, [ - alert, - statisticsVerificationReportQuery.errorUpdatedAt, - statisticsVerificationReportQuery.isError, - ]); - - function handleSearch( - nextFilters: StatisticsVerificationFilterState, - nextSubFilters: StatisticsVerificationSubFilterState, - ) { - setSelectedSummaryRows([]); - setSelectedDetailRows([]); - setSubmittedMainFilters({ ...nextFilters }); - setSubmittedDetailParams({ - main: { ...nextFilters }, - sub: { ...nextSubFilters }, - }); - } - - async function handleLoadMoreDetail() { - if (isLoading || isAppendingDetail || !canLoadMoreDetail) return; - - try { - await statisticsVerificationResultQuery.fetchNextPage(); - } catch { - alert("추가 데이터를 불러오는 중 오류가 발생했습니다."); - } - } - - function handleDetailSearchByAreaC() { - setSelectedDetailRows([]); - setSubmittedDetailParams({ - main: { ...filters }, - sub: { ...subFilters }, - }); - } - - function handleFilterChange< - K extends keyof StatisticsVerificationFilterState, - >(key: K) { - return ( - value?: StatisticsVerificationFilterState[K], - optionData?: { label: string; value: string }, - ) => { - if (key === "sewerBzmnCd") { - setFilters((prev) => ({ - ...prev, - ctpvNm: optionData?.label, - sewerBzmnCd: optionData?.value, - sggNm: "", - })); - return; - } - setFilters((prev) => ({ ...prev, [key]: value })); - }; - } - - function handleSubFilterChange< - K extends keyof StatisticsVerificationSubFilterState, - >(key: K) { - return (value?: StatisticsVerificationSubFilterState[K]) => { - setSubFilters((prev) => ({ ...prev, [key]: value })); - }; - } - - function handleReset() { - setFilters(DEFAULT_FILTERS); - setSubFilters(DEFAULT_SUB_FILTERS); - setSelectedSummaryRows([]); - setSelectedDetailRows([]); - handleSearch(DEFAULT_FILTERS, DEFAULT_SUB_FILTERS); - } - - async function handleApprove() { - const commonParam = { - statsYr: filters.statsYr, - sewerBzmnCd: filters.sewerBzmnCd ?? "", - statsCd: filters.statsCd ?? "", - mdfrId: userInfo?.userUnqId ?? "", - }; - const approvalRequests = selectedSummaryRows.map((row) => ({ - ...commonParam, - rjctOpnnCn: row.rjctOpnnCn ?? "", - lastCfmtnPicNm: row.laePicNm ?? "", - lastCfmtnPicTelNo: row.laePicTelNo ?? "", - lastCfmtnPicDeptNm: row.laeKbrdrDeptNm ?? "", - lastCfmtnPicJbgdNm: row.laeKbrdrJbgdNm ?? "", - })); - try { - if (await confirm("승인하시겠습니까?")) { - await onApproveStats(approvalRequests); - } - } catch { - alert("승인 중 오류가 발생했습니다."); - } - } - - function handleReject() {} - - return ( -
-
-
- ({ - label: item.adstNm, - value: item.adstCd, - })) ?? [] - } - /> - ({ - label: item.adstNm, - value: item.adstCd, - })) ?? [] - } - /> - -
- - -
-
-
- - - -
-
- - {}} - showHeaderCheckbox={true} - gridHeight={30} - /> - -

- 검증 결과 -

-
-
- - -
-
- - -
-
- -
- {}} - showHeaderCheckbox={true} - /> - - {(isLoading || isAppendingDetail) && ( -
-
- -
-
- )} -
-
- ); -} - -interface FilterSelectProps { - label: string; - onChange: ( - value?: string, - optionData?: { label: string; value: string }, - ) => void; - options: Array<{ label: string; value: string }>; - value?: string; -} - -function FilterCombobox({ - label, - onChange, - options, - value, -}: FilterSelectProps) { - const anchor = useComboboxAnchor(); - const items = useMemo( - () => options.map((option) => option.value), - [options], - ); - const selectedValues = useMemo(() => { - if (!value) return []; - return value.split(",").filter(Boolean); - }, [value]); - - return ( -
- - { - if (next == null) { - onChange(undefined); - return; - } - const list = Array.isArray(next) ? next : [next]; - onChange(list.length > 0 ? list.join(",") : undefined); - }} - > - - - {(values: string[]) => ( - <> - {values.map((v) => ( - - {options.find((o) => o.value === v) - ?.label ?? v} - - ))} - - - )} - - - - 검색 결과가 없습니다. - - {(item: string) => ( - - {options.find((o) => o.value === item)?.label ?? - item} - - )} - - - -
- ); -} - -function FilterSelect({ label, onChange, options, value }: FilterSelectProps) { - return ( -
- - -
- ); -} - -export default StatisticsVerificationReport; +"use client"; + +import type { Dispatch, SetStateAction } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import dayjs from "dayjs"; +import type { RealGridReact } from "realgrid-react"; + +import { useAlertStore, useMutationWrapper, useUserStore } from "@/shared/lib"; +import { CodeSelect } from "@/entities/code"; +import { + type ApproveRequest, + type RejectRequest, + statisticsApprovalApi, + type StatisticsVerificationFilterState, + statisticsVerificationReportColumnInfo, + statisticsVerificationResultColumnInfo, + type StatisticsVerificationSubFilterState, + type StatsInputStatusList, + useStAnlsSidoCombo, + useStAnlsSigunguCombo, + useStatsVrfcRptData, + useStatsVrfcRsltData, + type VerifyResultItem, +} from "@/entities/statistics"; +import { + Button, + Combobox, + ComboboxChip, + ComboboxChips, + ComboboxChipsInput, + ComboboxContent, + ComboboxEmpty, + ComboboxItem, + ComboboxList, + ComboboxValue, + Label, + LoadingWithAnimation, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + useComboboxAnchor, +} from "@common/components"; + +import DataGrid from "@/components/module/DataGrid"; +import type { CellEditedCallbackParams } from "@/components/type/DataGrid.type"; +import { toGridRows } from "@/components/util/grid/gridFunction"; + +const getYearOptions = () => { + const currentYear = dayjs().year(); + const yearOptionsRange = Array.from( + { length: currentYear - 2017 }, + (_, i) => { + const year = 2017 + i; + return { label: String(year), value: String(year) }; + }, + ); + return yearOptionsRange.map((year) => ({ + label: year.label, + value: year.value, + })); +}; + +function isSameStatsInputStatusRow( + a: StatsInputStatusList, + b: StatsInputStatusList, +): boolean { + return ( + a.ctpvNm === b.ctpvNm && a.sggNm === b.sggNm && a.statsCd === b.statsCd + ); +} + +function isSameVerifyResultRow( + a: VerifyResultItem, + b: VerifyResultItem, +): boolean { + if (a.vrfcSn != null && b.vrfcSn != null) return a.vrfcSn === b.vrfcSn; + return ( + a.statsYr === b.statsYr && + a.statsCd === b.statsCd && + a.ctpvNm === b.ctpvNm && + a.sggNm === b.sggNm && + a.vrfcTrgtNm === b.vrfcTrgtNm && + a.vrfcMthdCd === b.vrfcMthdCd + ); +} + +function useGridCheckboxSelection( + sourceRows: T[], + isSameRow: (a: T, b: T) => boolean, + setSelected: Dispatch>, +) { + const handleItemChecked = useCallback( + (itemIndex: number, checked: boolean) => { + const origin = sourceRows[itemIndex]; + if (origin === undefined) return; + setSelected((prev) => { + if (checked) { + if (prev.some((r) => isSameRow(r, origin))) return prev; + return [...prev, origin]; + } + return prev.filter((r) => !isSameRow(r, origin)); + }); + }, + [sourceRows, isSameRow, setSelected], + ); + + const handleAllChecked = useCallback( + (checked: boolean) => { + if (checked) setSelected([...sourceRows]); + else setSelected([]); + }, + [sourceRows, setSelected], + ); + + return { handleItemChecked, handleAllChecked }; +} + +const DEFAULT_FILTERS: StatisticsVerificationFilterState = { + ctpvNm: null, + sggNm: null, + statsYr: String(dayjs().year() - 1), + sewerBzmnCd: null, + statsCd: null, +}; + +const DEFAULT_SUB_FILTERS: StatisticsVerificationSubFilterState = { + ...DEFAULT_FILTERS, + prcsYnList: "1,2,3", + pbcpAprvYn: "N", +}; + +const STATUS_OPTIONS = [ + { label: "수정완료", value: "1" }, + { label: "예외통과", value: "2" }, + { label: "반려", value: "3" }, +]; + +const APPROVAL_STATUS_OPTIONS = [ + { label: "승인", value: "Y" }, + { label: "미승인", value: "N" }, +]; + +function StatisticsVerificationReport() { + const userInfo = useUserStore((state) => state.userInfo); + const { setMessage: alert } = useAlertStore((state) => state); + const { confirm } = useConfirm(); + const [filters, setFilters] = + useState(DEFAULT_FILTERS); + const [subFilters, setSubFilters] = + useState(DEFAULT_SUB_FILTERS); + const [submittedFilters, setSubmittedFilters] = + useState(DEFAULT_FILTERS); + const [submittedParams, setSubmittedParams] = useState<{ + main: StatisticsVerificationFilterState; + sub: StatisticsVerificationSubFilterState; + }>({ + main: DEFAULT_FILTERS, + sub: DEFAULT_SUB_FILTERS, + }); + const [selectedReportRows, setSelectedReportRows] = useState< + StatsInputStatusList[] + >([]); + const [selectedResultRows, setSelectedResultRows] = useState< + VerifyResultItem[] + >([]); + const editedResultRowMapRef = useRef>( + new Map(), + ); + const resultGridRef = useRef(null); + const prevLengthRef = useRef(0); + + const { data: sidoData } = useStAnlsSidoCombo(); + const { data: sigunguData } = useStAnlsSigunguCombo( + filters.sewerBzmnCd ?? "", + ); + + const statisticsVerificationReportQuery = + useStatsVrfcRptData(submittedFilters); + const statisticsVerificationResultQuery = + useStatsVrfcRsltData(submittedParams); + + const statisticsVerificationReportData = + statisticsVerificationReportQuery.data ?? []; + const statisticsVerificationReportGridRows = useMemo( + () => toGridRows(statisticsVerificationReportData), + [statisticsVerificationReportData], + ); + + const statisticsVerificationResultData = useMemo( + () => + statisticsVerificationResultQuery.data?.pages.flatMap( + (page) => page.verifyResultList ?? [], + ) ?? [], + [statisticsVerificationResultQuery.data], + ); + const statisticsVerificationResultGridRows = useMemo( + () => toGridRows(statisticsVerificationResultData), + [statisticsVerificationResultData], + ); + + function mergeWithEditedMap(rows: Record[]) { + const editedMap = editedResultRowMapRef.current; + if (editedMap.size === 0) return rows; + return rows.map((row) => { + const vrfcSn = Number(row.vrfcSn); + const edited = editedMap.get(vrfcSn); + return edited ? { ...row, ...edited } : row; + }); + } + + function captureCheckedVrfcSn(): number[] { + const gridView = resultGridRef.current?.gridView; + const dataProvider = resultGridRef.current?.dataProvider; + + if (!gridView || !dataProvider) return []; + + const checkedRows = gridView.getCheckedRows?.(true, false, true) ?? []; + return checkedRows + .map((row) => Number(dataProvider.getValue?.(row, "vrfcSn"))) + .filter((key) => !Number.isNaN(key)); + } + + function restoreCheckedVrfcSn( + keys: number[], + rows: Record[], + ) { + const gridView = resultGridRef.current?.gridView; + + if (!gridView || keys.length === 0) return; + + const keySet = new Set(keys); + const rowsToCheck = rows + .map((row, index) => ({ index, key: Number(row.vrfcSn) })) + .filter((item) => !Number.isNaN(item.key) && keySet.has(item.key)) + .map((item) => item.index); + + if (rowsToCheck.length === 0) return; + + gridView.checkRows?.(rowsToCheck, true, false); + } + + useEffect(() => { + const dataProvider = resultGridRef.current?.dataProvider; + const nextRows = statisticsVerificationResultGridRows; + const prevLength = prevLengthRef.current; + + if (!dataProvider || nextRows.length === 0) { + prevLengthRef.current = nextRows.length; + return; + } + + if (nextRows.length > prevLength && prevLength > 0) { + // 무한스크롤 시 데이터 처리 + const newRows = nextRows.slice(prevLength); + const mergedNewRows = mergeWithEditedMap(newRows); + dataProvider.addRows?.(mergedNewRows); + } else { + // 초기 조회 또는 재조회 시 데이터 처리 + const checkedKeys = captureCheckedVrfcSn(); + const mergedRows = mergeWithEditedMap(nextRows); + dataProvider.setRows?.(mergedRows); + restoreCheckedVrfcSn(checkedKeys, mergedRows); + } + + prevLengthRef.current = nextRows.length; + }, [statisticsVerificationResultGridRows]); + + const isLoading = + statisticsVerificationReportQuery.isFetching || + statisticsVerificationResultQuery.isFetching; + const isAppendingDetail = + statisticsVerificationResultQuery.isFetchingNextPage; + const canLoadMoreDetail = Boolean( + statisticsVerificationResultQuery.hasNextPage, + ); + + const { + handleItemChecked: handleReportItemChecked, + handleAllChecked: handleReportAllChecked, + } = useGridCheckboxSelection( + statisticsVerificationReportData, + isSameStatsInputStatusRow, + setSelectedReportRows, + ); + + const { + handleItemChecked: handleResultItemChecked, + handleAllChecked: handleResultAllChecked, + } = useGridCheckboxSelection( + statisticsVerificationResultData, + isSameVerifyResultRow, + setSelectedResultRows, + ); + + const { mutateAsync: onApproveStats } = useMutationWrapper({ + mutationFn: (approvalRequests: ApproveRequest[]) => + statisticsApprovalApi.approveStats(approvalRequests), + onSuccess: (data) => { + alert(data.message ?? "승인이 완료되었습니다."); + }, + }); + + const { mutateAsync: onRejectStats } = useMutationWrapper({ + mutationFn: (rejectRequests: RejectRequest[]) => + statisticsApprovalApi.rejectStats(rejectRequests), + onSuccess: (data) => { + alert(data.message ?? "반려가 완료되었습니다."); + }, + }); + + useEffect(() => { + if (!statisticsVerificationReportQuery.isError) return; + alert("통계검증리포트 조회 중 오류가 발생했습니다."); + setSelectedReportRows([]); + }, [ + alert, + statisticsVerificationReportQuery.errorUpdatedAt, + statisticsVerificationReportQuery.isError, + ]); + + function handleSearch( + nextFilters: StatisticsVerificationFilterState, + nextSubFilters: StatisticsVerificationSubFilterState, + ) { + setSelectedReportRows([]); + setSelectedResultRows([]); + setSubmittedFilters({ ...nextFilters }); + setSubmittedParams({ + main: { ...nextFilters }, + sub: { ...nextSubFilters }, + }); + } + + async function handleLoadMoreDetail() { + if (isLoading || isAppendingDetail || !canLoadMoreDetail) return; + + try { + await statisticsVerificationResultQuery.fetchNextPage(); + } catch { + alert("추가 데이터를 불러오는 중 오류가 발생했습니다."); + } + } + + function handleResultSearch() { + setSelectedResultRows([]); + setSubmittedParams({ + main: { ...filters }, + sub: { ...subFilters }, + }); + } + + function handleFilterChange< + K extends keyof StatisticsVerificationFilterState, + >(key: K) { + return ( + value?: StatisticsVerificationFilterState[K], + optionData?: { label: string; value: string }, + ) => { + if (key === "sewerBzmnCd") { + setFilters((prev) => ({ + ...prev, + ctpvNm: optionData?.label, + sewerBzmnCd: optionData?.value, + sggNm: "", + })); + return; + } + setFilters((prev) => ({ ...prev, [key]: value })); + }; + } + + function handleSubFilterChange< + K extends keyof StatisticsVerificationSubFilterState, + >(key: K) { + return (value?: StatisticsVerificationSubFilterState[K]) => { + setSubFilters((prev) => ({ ...prev, [key]: value })); + }; + } + + function handleCellEdited(params: CellEditedCallbackParams) { + const { itemIndex, fieldName, newValue } = params; + const dataProvider = resultGridRef.current?.dataProvider as + | { getValue?: (row: number, field: string) => string | number } + | undefined; + const vrfcSn = Number(dataProvider?.getValue?.(itemIndex, "vrfcSn")); + if (Number.isNaN(vrfcSn)) return; + + editedResultRowMapRef.current?.set(vrfcSn, { + ...(editedResultRowMapRef.current?.get(vrfcSn) ?? {}), + [fieldName]: newValue, + }); + } + + function handleReset() { + setFilters(DEFAULT_FILTERS); + setSubFilters(DEFAULT_SUB_FILTERS); + setSelectedReportRows([]); + setSelectedResultRows([]); + editedResultRowMapRef.current = new Map(); + prevLengthRef.current = 0; + handleSearch(DEFAULT_FILTERS, DEFAULT_SUB_FILTERS); + } + + async function handleApprove() { + const commonParam = { + statsYr: filters.statsYr, + sewerBzmnCd: filters.sewerBzmnCd ?? "", + statsCd: filters.statsCd ?? "", + mdfrId: userInfo?.userUnqId ?? "", + }; + const approvalRequests = selectedReportRows.map((row) => ({ + ...commonParam, + rjctOpnnCn: row.rjctOpnnCn ?? "", + lastCfmtnPicNm: row.laePicNm ?? "", + lastCfmtnPicTelNo: row.laePicTelNo ?? "", + lastCfmtnPicDeptNm: row.laeKbrdrDeptNm ?? "", + lastCfmtnPicJbgdNm: row.laeKbrdrJbgdNm ?? "", + })); + try { + if (await confirm("승인하시겠습니까?")) { + await onApproveStats(approvalRequests); + } + } catch { + alert("승인 중 오류가 발생했습니다."); + } + } + + async function handleReject() { + const commonParam = { + statsYr: filters.statsYr, + sewerBzmnCd: filters.sewerBzmnCd ?? "", + statsCd: filters.statsCd ?? "", + mdfrId: userInfo?.userUnqId ?? "", + }; + + const requestRows = selectedResultRows.map((row) => { + const vrfcSn = Number(row.vrfcSn); + const editedRow = Number.isNaN(vrfcSn) + ? undefined + : editedResultRowMapRef.current?.get(vrfcSn); + return { ...row, ...(editedRow ?? {}) }; + }); + + const hasEmptyRjctOpnnCn = requestRows.some( + (row) => !String(row?.rjctOpnnCn ?? "").trim(), + ); + if (hasEmptyRjctOpnnCn) { + alert("선택한 항목의 반려의견을 작성해주세요."); + return; + } + + const rejectRequests = requestRows.map((row) => { + const vrfcSn = Number(row.vrfcSn); + + return { + ...commonParam, + vrfcSn: Number.isNaN(vrfcSn) ? 0 : vrfcSn, + rjctOpnnCn: String(row.rjctOpnnCn ?? ""), + }; + }); + + try { + if (await confirm("반려하시겠습니까?")) { + await onRejectStats(rejectRequests); + } + } catch { + alert("반려 중 오류가 발생했습니다."); + } + } + + return ( +
+
+
+ ({ + label: item.adstNm, + value: item.adstCd, + })) ?? [] + } + /> + ({ + label: item.adstNm, + value: item.adstCd, + })) ?? [] + } + /> + +
+ + +
+
+
+ + + +
+
+ + {}} + showHeaderCheckbox={true} + gridHeight={30} + /> + +

+ 검증 결과 +

+
+
+ + +
+
+ + +
+
+ +
+ {}} + showHeaderCheckbox={true} + onCellEdited={handleCellEdited} + getGridRef={resultGridRef} + /> + + {(isLoading || isAppendingDetail) && ( +
+
+ +
+
+ )} +
+
+ ); +} + +interface FilterSelectProps { + label: string; + onChange: ( + value?: string, + optionData?: { label: string; value: string }, + ) => void; + options: Array<{ label: string; value: string }>; + value?: string; +} + +function FilterCombobox({ + label, + onChange, + options, + value, +}: FilterSelectProps) { + const anchor = useComboboxAnchor(); + const items = useMemo( + () => options.map((option) => option.value), + [options], + ); + const selectedValues = useMemo(() => { + if (!value) return []; + return value.split(",").filter(Boolean); + }, [value]); + + return ( +
+ + { + if (next == null) { + onChange(undefined); + return; + } + const list = Array.isArray(next) ? next : [next]; + onChange(list.length > 0 ? list.join(",") : undefined); + }} + > + + + {(values: string[]) => ( + <> + {values.map((v) => ( + + {options.find((o) => o.value === v) + ?.label ?? v} + + ))} + + + )} + + + + 검색 결과가 없습니다. + + {(item: string) => ( + + {options.find((o) => o.value === item)?.label ?? + item} + + )} + + + +
+ ); +} + +function FilterSelect({ label, onChange, options, value }: FilterSelectProps) { + return ( +
+ + +
+ ); +} + +export default StatisticsVerificationReport; diff --git a/apps/st/src/components/type/DataGrid.type.ts b/apps/st/src/components/type/DataGrid.type.ts index a8efd11ff..0ee95e977 100644 --- a/apps/st/src/components/type/DataGrid.type.ts +++ b/apps/st/src/components/type/DataGrid.type.ts @@ -1,6 +1,7 @@ import React from "react"; import { ValueType } from "realgrid"; +import type { RealGridReact } from "realgrid-react"; import { type SewerageSupplyRateStatusInfo } from "@/entities/statistics/model/input/statistics-sewage-supply-rate-input-interface"; @@ -241,6 +242,17 @@ export type CellTooltipCallback = ( params: CellTooltipCallbackParams, ) => string | undefined; +/** 셀 편집 완료 시 호출되는 콜백 (RealGrid onEditRowChanged) */ +export type CellEditedCallbackParams = { + itemIndex: number; + fieldName: string; + oldValue: string | number; + newValue: string | number; +}; +export type CellEditedCallback = ( + params: CellEditedCallbackParams, +) => void; + export type DataGridProps = { columnInfo: ColumnInfo; rowStartNum?: number; // 헤더 행 개수 (1부터 시작, 예: 2면 1행과 2행까지 헤더로 간주) @@ -259,10 +271,14 @@ export type DataGridProps = { cellStyleCallback?: CellStyleCallback; /** 셀 hover 시 툴팁 문구 (예: verificationResult의 rsnExpln) */ cellTooltipCallback?: CellTooltipCallback; + /** 셀 편집 완료 시 호출 (RealGrid onEditRowChanged) */ + onCellEdited?: CellEditedCallback; /** 저장 등 시점에 그리드 현재 데이터를 가져올 수 있도록 할당 (편집 반영된 값) */ getGridDataRef?: { current: (() => Record[]) | null; }; + /** 외부에서 gridRef에 직접 접근하기 위한 ref (setRows/addRows/setValue 직접 호출용) */ + getGridRef?: React.MutableRefObject; onRowClick?: (rowData: Record) => void; // 행 클릭 이벤트 /** CheckBar 행 체크 변경 */ onItemChecked?: (itemIndex: number, checked: boolean) => void; diff --git a/apps/st/src/entities/statistics/api/statistics-approval-service.ts b/apps/st/src/entities/statistics/api/statistics-approval-service.ts index bbfb9f943..2d961a66e 100644 --- a/apps/st/src/entities/statistics/api/statistics-approval-service.ts +++ b/apps/st/src/entities/statistics/api/statistics-approval-service.ts @@ -2,17 +2,28 @@ import { stClient } from "@/shared/api/client"; import { ENDPOINT } from "@/shared/config"; import type { - ApprovalRequest, ApprovalResponse, + ApproveRequest, + RejectRequest, StatisticsApprovalService, } from "../model/statistics-approval-interface"; class StatisticsApprovalApiService implements StatisticsApprovalService { public async approveStats( - params: ApprovalRequest[], + params: ApproveRequest[], ): Promise { const response = await stClient.post( - `${ENDPOINT.ST_SERVICE.APPROVAL}?type=VRFC&action=APPROVE`, + `${ENDPOINT.ST_SERVICE.APPROVAL}/approve?type=VRFC`, + params, + ); + return response.data; + } + + public async rejectStats( + params: RejectRequest[], + ): Promise { + const response = await stClient.post( + `${ENDPOINT.ST_SERVICE.APPROVAL}/reject?type=VRFC`, params, ); return response.data; diff --git a/apps/st/src/entities/statistics/index.ts b/apps/st/src/entities/statistics/index.ts index fe8e63faa..9c29551aa 100644 --- a/apps/st/src/entities/statistics/index.ts +++ b/apps/st/src/entities/statistics/index.ts @@ -64,12 +64,13 @@ export { export { verificationHistoryColumns } from "./model/statistics-analysis/verification-history-columns"; export { getVerificationListColumns } from "./model/statistics-analysis/verification-list-columns"; export { - type ApprovalRequest, type ApprovalResponse, + type ApproveRequest, + type RejectRequest, } from "./model/statistics-approval-interface"; export { - statisticsVerificationDetailColumnInfo, - statisticsVerificationSummaryColumnInfo, + statisticsVerificationReportColumnInfo, + statisticsVerificationResultColumnInfo, } from "./model/statistics-verification-report/statistics-verification-report-columns"; export { type StatisticsVerificationFilterState, diff --git a/apps/st/src/entities/statistics/model/statistics-approval-interface.ts b/apps/st/src/entities/statistics/model/statistics-approval-interface.ts index 9c0947967..4abf87b8d 100644 --- a/apps/st/src/entities/statistics/model/statistics-approval-interface.ts +++ b/apps/st/src/entities/statistics/model/statistics-approval-interface.ts @@ -1,13 +1,21 @@ -export interface ApprovalRequest { +export interface ApproveRequest { statsYr: string; sewerBzmnCd: string; statsCd: string; mdfrId: string; - rjctOpnnCn: string; - lastCfmtnPicNm: string; - lastCfmtnPicTelNo: string; - lastCfmtnPicDeptNm: string; - lastCfmtnPicJbgdNm: string; + lastCfmtnPicNm?: string; + lastCfmtnPicTelNo?: string; + lastCfmtnPicDeptNm?: string; + lastCfmtnPicJbgdNm?: string; +} + +export interface RejectRequest { + vrfcSn: number; + statsYr: string; + sewerBzmnCd: string; + statsCd: string; + mdfrId: string; + rjctOpnnCn?: string; } export interface ApprovalResponse { @@ -18,5 +26,6 @@ export interface ApprovalResponse { } export interface StatisticsApprovalService { - approveStats(params: ApprovalRequest[]): Promise; + approveStats(params: ApproveRequest[]): Promise; + rejectStats(params: RejectRequest[]): Promise; } diff --git a/apps/st/src/entities/statistics/model/statistics-verification-report/statistics-verification-report-columns.ts b/apps/st/src/entities/statistics/model/statistics-verification-report/statistics-verification-report-columns.ts index 3ed4f115c..1ebf0337c 100644 --- a/apps/st/src/entities/statistics/model/statistics-verification-report/statistics-verification-report-columns.ts +++ b/apps/st/src/entities/statistics/model/statistics-verification-report/statistics-verification-report-columns.ts @@ -2,7 +2,7 @@ import type { ColumnInfo } from "@/components/type/DataGrid.type"; const READ_ONLY_STYLE = "rg-cell-readonly"; -export const statisticsVerificationSummaryColumnInfo: ColumnInfo = { +export const statisticsVerificationReportColumnInfo: ColumnInfo = { columns: [ { name: "ctpvNm", @@ -111,19 +111,21 @@ export const statisticsVerificationSummaryColumnInfo: ColumnInfo = { ], }; -export const statisticsVerificationDetailColumnInfo: ColumnInfo = { +export const statisticsVerificationResultColumnInfo: ColumnInfo = { columns: [ { name: "ctpvNm", fieldName: "ctpvNm", header: { text: "시도" }, styleName: READ_ONLY_STYLE, + editable: false, }, { name: "sggNm", fieldName: "sggNm", header: { text: "시군구" }, styleName: READ_ONLY_STYLE, + editable: false, }, { name: "statsNm", @@ -131,6 +133,7 @@ export const statisticsVerificationDetailColumnInfo: ColumnInfo = { header: { text: "지표" }, width: 140, styleName: READ_ONLY_STYLE, + editable: false, }, { name: "vrfcTrgtNm", @@ -138,6 +141,7 @@ export const statisticsVerificationDetailColumnInfo: ColumnInfo = { header: { text: "세부항목" }, width: 180, styleName: READ_ONLY_STYLE, + editable: false, }, { name: "regDt", @@ -145,6 +149,7 @@ export const statisticsVerificationDetailColumnInfo: ColumnInfo = { header: { text: "검증날짜" }, width: 150, styleName: READ_ONLY_STYLE, + editable: false, }, { name: "rsnExpln", @@ -152,6 +157,7 @@ export const statisticsVerificationDetailColumnInfo: ColumnInfo = { header: { text: "오류내역" }, width: 200, styleName: READ_ONLY_STYLE, + editable: false, }, { name: "nowVlCn", @@ -159,6 +165,7 @@ export const statisticsVerificationDetailColumnInfo: ColumnInfo = { header: { text: "입력값" }, width: 90, styleName: READ_ONLY_STYLE, + editable: false, }, { name: "compVlCn", @@ -166,6 +173,7 @@ export const statisticsVerificationDetailColumnInfo: ColumnInfo = { header: { text: "비교값" }, width: 90, styleName: READ_ONLY_STYLE, + editable: false, }, { name: "prcsYn", @@ -173,6 +181,7 @@ export const statisticsVerificationDetailColumnInfo: ColumnInfo = { header: { text: "상태" }, width: 90, styleName: READ_ONLY_STYLE, + editable: false, }, { name: "prcsOpnnExpln", @@ -180,13 +189,14 @@ export const statisticsVerificationDetailColumnInfo: ColumnInfo = { header: { text: "예외사유" }, width: 170, styleName: READ_ONLY_STYLE, + editable: false, }, { name: "rjctOpnnCn", fieldName: "rjctOpnnCn", header: { text: "반려의견" }, width: 170, - styleName: READ_ONLY_STYLE, + editable: true, }, { name: "pbcpAprvYn", @@ -194,12 +204,14 @@ export const statisticsVerificationDetailColumnInfo: ColumnInfo = { header: { text: "승인여부" }, width: 90, styleName: READ_ONLY_STYLE, + editable: false, }, { name: "vrfcSn", fieldName: "vrfcSn", header: { text: "vrfcSn" }, visible: false, + editable: false, }, ], columnLayout: [ diff --git a/apps/st/src/shared/config/api.ts b/apps/st/src/shared/config/api.ts index 984135262..73aa77322 100644 --- a/apps/st/src/shared/config/api.ts +++ b/apps/st/src/shared/config/api.ts @@ -73,7 +73,7 @@ export const ENDPOINT = { INPUTCOMMON: "/st-input-service/api/v1/common", INPUTVERIFY: "/st-input-service/api/v1/verify", TOTAL: "/st-management-service/api/v1/stats", - APPROVAL: "/st-management-service/api/stats/approval", + APPROVAL: "/st-management-service/api/stats", }, STATVERIFY_SERVICE: { VRFCTGT_MNG: "/statverify-service/api/v1/vrfc-trgt-mng",