diff --git a/apps/st/.env.example b/apps/st/.env.example index e9667be0f..c2715e0cd 100644 --- a/apps/st/.env.example +++ b/apps/st/.env.example @@ -4,4 +4,5 @@ NEXT_PUBLIC_PROXY_HOST=http://localhost:3001 SERVER_API_URL=http://192.168.0.102:8000 NEXT_PUBLIC_SITE_ID=ST NEXT_PUBLIC_SUB_DOMAIN=http://localhost:3000 -NEXT_PUBLIC_TINY_API_KEY=t9nc4z6inquvvd5pq1vea8wcp7lb01qmqseh6egms9vpaju2 \ No newline at end of file +NEXT_PUBLIC_TINY_API_KEY=t9nc4z6inquvvd5pq1vea8wcp7lb01qmqseh6egms9vpaju2 +NEXT_PUBLIC_ST_DASHBOARD_BBS_ID=13 \ No newline at end of file diff --git a/apps/st/src/components/css/DataGrid.css b/apps/st/src/components/css/DataGrid.css index 843289179..d90874c8c 100644 --- a/apps/st/src/components/css/DataGrid.css +++ b/apps/st/src/components/css/DataGrid.css @@ -6,7 +6,7 @@ } /* 셀 클래스가 있으면 위 기본색보다 우선 (같은 specificity + 나중 규칙) */ .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-readonly { - @apply !bg-[#f9f8f8] !text-[#666]; + @apply !bg-[#f0f0f0] !text-[#666]; } .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-verify-error { @apply !bg-[#ffe6e6] !text-[#c00]; diff --git a/apps/st/src/components/module/DataGrid.tsx b/apps/st/src/components/module/DataGrid.tsx index 4e8a9c86d..27fcfef82 100644 --- a/apps/st/src/components/module/DataGrid.tsx +++ b/apps/st/src/components/module/DataGrid.tsx @@ -457,10 +457,29 @@ function sortRowsGroupPreserving( } /** columnInfo → RealGrid DataField 배열 (computed → valueCallback 포함, setFields용) */ +function getDecimalPlacesFromDataFormat(dataFormat?: string): number | null { + if (!dataFormat) return null; + const match = dataFormat.match(/\.(0+|#+)/); + const decimalToken = match?.[1]; + if (!decimalToken) return null; + return decimalToken.length; +} + +function truncateByDecimalPlaces(value: number, decimalPlaces: number): number { + if (!Number.isFinite(value)) return 0; + if (decimalPlaces < 0) return value; + const factor = 10 ** decimalPlaces; + const scaled = value * factor; + // 절삭(truncate): 반올림 없이 소수점 이하를 버린다. + const truncated = scaled >= 0 ? Math.floor(scaled) : Math.ceil(scaled); + return truncated / factor; +} + function buildFieldsFromColumns( columns: { fieldName: string; valueType?: ValueType; + dataFormat?: string; datetimeFormat?: string; editor?: { type?: string; datetimeFormat?: string }; computed?: import("../type/DataGrid.type").ComputedFieldConfig; @@ -509,17 +528,23 @@ function buildFieldsFromColumns( "subtrahendFields" in col.computed && col.computed.minuendFields.length > 0; const hasSumOfMaxGroups = + col.computed.type === "sumOfMaxOfFieldAndSumGroups" && "groups" in col.computed && Array.isArray(col.computed.groups) && col.computed.groups.length > 0; + const hasPumpGrossPpqty = + col.computed.type === "pumpGrossPpqty" && + "pumpGroups" in col.computed && + col.computed.pumpGroups.length > 0; if ( hasFields || hasNumeratorDenominator || hasMaxOfFieldAndSum || hasDifferenceOfSums || - hasSumOfMaxGroups + hasSumOfMaxGroups || + hasPumpGrossPpqty ) { - field.valueCallback = getComputedValueCallback( + const computedCallback = getComputedValueCallback( col.computed, ) as ( prod: unknown, @@ -528,6 +553,24 @@ function buildFieldsFromColumns( fieldNames: string[], values: unknown[], ) => number; + const decimalPlaces = getDecimalPlacesFromDataFormat(col.dataFormat); + field.valueCallback = ( + prod: unknown, + dataRow: number, + fieldName: string, + fieldNames: string[], + values: unknown[], + ) => { + const computedValue = computedCallback( + prod, + dataRow, + fieldName, + fieldNames, + values, + ); + if (decimalPlaces == null) return computedValue; + return truncateByDecimalPlaces(computedValue, decimalPlaces); + }; } } return field; @@ -1046,6 +1089,14 @@ const DataGrid = ({ } columns.forEach((column) => { const gv = gridView as GridViewWithSetColumnProperty; + const exists = + typeof ( + gridView as { columnByName?: (name: string) => unknown } + ).columnByName === "function" && + (gridView as { columnByName: (name: string) => unknown }).columnByName( + column.name, + ) != null; + if (!exists) return; gv.setColumnProperty?.( column.name, "header", @@ -1181,10 +1232,63 @@ const DataGrid = ({ const hasFooterUserSpans = checkFooterUserSpans(columnLayout); if (hasFooterUserSpans) { + const validColumnNames = new Set(columns.map((c) => c.name)); + const sanitizeColumnLayout = ( + layout: unknown[], + ): unknown[] => { + const result: unknown[] = []; + for (const item of layout) { + if (typeof item === "string") { + if (validColumnNames.has(item)) { + result.push(item); + } + continue; + } + if (!item || typeof item !== "object") continue; + + if ("column" in item) { + const columnName = (item as { column?: unknown }).column; + if ( + typeof columnName === "string" && + validColumnNames.has(columnName) + ) { + result.push(item); + } + continue; + } + + if ("items" in item && Array.isArray((item as { items?: unknown[] }).items)) { + const nextItems = sanitizeColumnLayout( + (item as { items: unknown[] }).items, + ); + if (nextItems.length > 0) { + result.push({ + ...(item as Record), + items: nextItems, + }); + } + continue; + } + + result.push(item); + } + return result; + }; + // setColumnLayout은 그리드가 완전히 렌더링된 후 호출해야 함 setTimeout(() => { if (gridRef.current?.gridView && columnLayout) { - gridRef.current.gridView.setColumnLayout?.(columnLayout); + const safeLayout = sanitizeColumnLayout(columnLayout as unknown[]); + if (safeLayout.length === 0) return; + try { + ( + gridRef.current.gridView.setColumnLayout as + | ((layout: unknown) => void) + | undefined + )?.(safeLayout); + } catch (e) { + console.warn("setColumnLayout skipped due to invalid columns:", e); + } } }, 100); } diff --git a/apps/st/src/components/module/ExceptionProcessingHistoryDialog.tsx b/apps/st/src/components/module/ExceptionProcessingHistoryDialog.tsx index ff72a51ef..4d94b5aac 100644 --- a/apps/st/src/components/module/ExceptionProcessingHistoryDialog.tsx +++ b/apps/st/src/components/module/ExceptionProcessingHistoryDialog.tsx @@ -33,7 +33,7 @@ import DataGrid from "@/components/module/DataGrid"; import { VerificationResultHistoryPagination } from "@/components/module/VerificationResultHistoryPagination"; import type { CellStyleCallbackParams, ColumnInfo } from "@/components/type/DataGrid.type"; -/** 통계 입력 현황 > 행 클릭 시 예외처리(prcsYn=3) 내역만 조회 */ +/** 통계 입력 현황 > 행 클릭 시 사용자예외처리(prcsYn=2) 내역만 조회 */ export interface ExceptionProcessingHistoryQueryContext { sewerBzmnCd: string; ctpvNm: string; @@ -92,13 +92,15 @@ function exceptionPrcsYnNmCellStyle({ fieldName, row }: CellStyleCallbackParams) const exceptionHistoryColumnInfo: ColumnInfo = { 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: "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: "prcsYnNm", fieldName: "prcsYnNm", header: { text: "처리여부" }, editable: false }, - { name: "pbcpAprvYn", fieldName: "pbcpAprvYn", header: { text: "공단승인여부" }, editable: false }, + { name: "stVrfRjtComNm", fieldName: "stVrfRjtComNm", header: { text: "반려상태" }, editable: false }, + { name: "prcsOpnnExpln", fieldName: "prcsOpnnExpln", header: { text: "처리의견" }, editable: false }, + { name: "rjctOpnnCn", fieldName: "rjctOpnnCn", header: { text: "반려의견" }, editable: false }, { name: "regDt", fieldName: "regDt", header: { text: "등록일시" }, editable: false }, { name: "mdfcnDt", fieldName: "mdfcnDt", header: { text: "갱신일시" }, editable: false }, { @@ -114,13 +116,15 @@ const exceptionHistoryColumnInfo: ColumnInfo = { ], columnLayout: [ "ctpvNm", - "sggNm", + "admdstNm", "vrfcTrgtNm", "rsnExpln", "nowVlCn", "compVlCn", "prcsYnNm", - "pbcpAprvYn", + "stVrfRjtComNm", + "prcsOpnnExpln", + "rjctOpnnCn", "regDt", "mdfcnDt", "individualReject", @@ -190,9 +194,9 @@ export function ExceptionProcessingHistoryDialog({ sggNm: queryContext.sggNm, statsYr: queryContext.statsYr, statsCd: queryContext.statsCd, - pbcpAprvYn: "N", vrfcMthdCd, prcsYnList: PRCS_YN_LIST, + rejectCodeList: "0,1,2", page: pageIndex + 1, size: 30, }; @@ -308,14 +312,16 @@ export function ExceptionProcessingHistoryDialog({ const mapRows = (entries: VerificationResultReportItem[]): ExceptionHistoryGridRow[] => entries.map((item) => ({ ctpvNm: item.ctpvNm, - sggNm: item.sggNm, + admdstNm: item.admdstNm ?? item.sggNm ?? "-", vrfcTrgtNm: item.vrfcTrgtNm, rsnExpln: item.rsnExpln, nowVlCn: item.nowVlCn ?? "-", compVlCn: item.compVlCn ?? "-", prcsYnNm: item.prcsYnNm ?? "-", + stVrfRjtComNm: item.stVrfRjtComNm ?? "-", + prcsOpnnExpln: item.prcsOpnnExpln ?? "-", + rjctOpnnCn: item.rjctOpnnCn ?? "-", prcsYn: item.prcsYn ?? "", - pbcpAprvYn: item.pbcpAprvYn, regDt: (item.regDt ?? "").slice(0, 16), mdfcnDt: (item.mdfcnDt ?? "").slice(0, 16), individualReject: "개별 반려", diff --git a/apps/st/src/components/module/SearchForm.tsx b/apps/st/src/components/module/SearchForm.tsx index a82822756..58faea987 100644 --- a/apps/st/src/components/module/SearchForm.tsx +++ b/apps/st/src/components/module/SearchForm.tsx @@ -41,7 +41,7 @@ const getSggListFetcher = async ( key: [string, string, string, string], ): Promise> => { const gugunSkipYn = doList.includes(key[3]) ? "N" : "Y"; - return statisticsApi.getSggAllList(key[1], { + return statisticsApi.getSggList(key[1], { instSeCd: key[2], gugunSkipYn, }); diff --git a/apps/st/src/components/module/VerificationResultDialog.tsx b/apps/st/src/components/module/VerificationResultDialog.tsx index 1141348aa..0f2de8007 100644 --- a/apps/st/src/components/module/VerificationResultDialog.tsx +++ b/apps/st/src/components/module/VerificationResultDialog.tsx @@ -92,13 +92,12 @@ const verificationSubmitResultColumnInfo: ColumnInfo = { { name: "vrfcMthdNm", fieldName: "vrfcMthdNm", header: { text: "구분" }, editable: false }, { name: "vrfcMthdCd", fieldName: "vrfcMthdCd", header: { text: "검증유형코드" }, editable: false, visible: false }, { 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: "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 }, { @@ -111,13 +110,12 @@ const verificationSubmitResultColumnInfo: ColumnInfo = { columnLayout: [ "vrfcMthdNm", "ctpvNm", - "sggNm", + "admdstNm", "vrfcTrgtNm", "rsnExpln", "nowVlCn", "compVlCn", "prcsYn", - "pbcpAprvYn", "regDt", "mdfcnDt", "prcsOpnnExpln", @@ -202,13 +200,12 @@ export function VerificationResultDialog({ vrfcMthdCd, vrfcMthdNm: VRFC_MTHD_NAME_MAP[vrfcMthdCd] ?? item.vrfcMthdNm, ctpvNm: item.ctpvNm, - sggNm: item.sggNm, + admdstNm: item.admdstNm ?? 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, + prcsYn: item.prcsYnNm ?? PRCS_YN_LABEL[item.prcsYn] ?? item.prcsYn, regDt: (item.regDt ?? "").slice(0, 16), mdfcnDt: (item.mdfcnDt ?? "").slice(0, 16), prcsOpnnExpln: item.prcsOpnnExpln ?? "", @@ -226,9 +223,9 @@ export function VerificationResultDialog({ sggNm: stableQueryContext.sggNm, statsYr: stableQueryContext.statsYr, statsCd: stableQueryContext.statsCd, - pbcpAprvYn: "N", vrfcMthdCd, prcsYnList: "0", + rejectCodeList: "0,1,2", page: page + 1, size: 10000, }; diff --git a/apps/st/src/components/module/VerificationResultHistoryDialog.tsx b/apps/st/src/components/module/VerificationResultHistoryDialog.tsx index 6fe0606d7..745b53cc5 100644 --- a/apps/st/src/components/module/VerificationResultHistoryDialog.tsx +++ b/apps/st/src/components/module/VerificationResultHistoryDialog.tsx @@ -137,12 +137,15 @@ function verificationPrcsYnNmCellStyle({ fieldName, row }: CellStyleCallbackPara const verificationResultColumnInfo: ColumnInfo = { 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: "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: "prcsYnNm", fieldName: "prcsYnNm", header: { text: "처리여부" }, editable: false }, + { name: "stVrfRjtComNm", fieldName: "stVrfRjtComNm", header: { text: "반려상태" }, editable: false }, + { name: "prcsOpnnExpln", fieldName: "prcsOpnnExpln", header: { text: "처리의견" }, editable: false }, + { name: "rjctOpnnCn", fieldName: "rjctOpnnCn", header: { text: "반려의견" }, editable: false }, { name: "regDt", fieldName: "regDt", header: { text: "등록일시" }, editable: false }, { name: "mdfcnDt", fieldName: "mdfcnDt", header: { text: "갱신일시" }, editable: false }, { name: "prcsYn", fieldName: "prcsYn", header: { text: "검증결과상태코드" }, editable: false, visible: false }, @@ -150,12 +153,15 @@ const verificationResultColumnInfo: ColumnInfo = { ], columnLayout: [ "ctpvNm", - "sggNm", + "admdstNm", "vrfcTrgtNm", "rsnExpln", "nowVlCn", "compVlCn", "prcsYnNm", + "stVrfRjtComNm", + "prcsOpnnExpln", + "rjctOpnnCn", "regDt", "mdfcnDt", "prcsYn", @@ -245,9 +251,9 @@ export function VerificationResultHistoryDialog({ sggNm: stableQueryContext.sggNm, statsYr: stableQueryContext.statsYr, statsCd: stableQueryContext.statsCd, - pbcpAprvYn: "N", vrfcMthdCd, - prcsYnList: "0,2,3", + prcsYnList: "0,2", + rejectCodeList: "0,1,2", page: pageIndex + 1, size: PAGE_SIZE, }; @@ -376,14 +382,16 @@ export function VerificationResultHistoryDialog({ vrfcSn: item.vrfcSn, __checked: isChecked ? 1 : 0, ctpvNm: item.ctpvNm, - sggNm: item.sggNm, + admdstNm: item.admdstNm ?? item.sggNm ?? "-", vrfcTrgtNm: item.vrfcTrgtNm, rsnExpln: item.rsnExpln, nowVlCn: item.nowVlCn ?? "-", compVlCn: item.compVlCn ?? "-", prcsYnNm: item.prcsYnNm ?? "-", + stVrfRjtComNm: item.stVrfRjtComNm ?? "-", + prcsOpnnExpln: item.prcsOpnnExpln ?? "-", + rjctOpnnCn: item.rjctOpnnCn ?? "-", prcsYn: item.prcsYn ?? "", - pbcpAprvYn: item.pbcpAprvYn, regDt: (item.regDt ?? "").slice(0, 16), mdfcnDt: (item.mdfcnDt ?? "").slice(0, 16), }; diff --git a/apps/st/src/components/module/post-submit-verification-results.ts b/apps/st/src/components/module/post-submit-verification-results.ts index a46ad79cb..4efe25993 100644 --- a/apps/st/src/components/module/post-submit-verification-results.ts +++ b/apps/st/src/components/module/post-submit-verification-results.ts @@ -34,8 +34,8 @@ export async function hasAnyPostSubmitVerificationResult( sggNm: ctx.sggNm, statsYr: ctx.statsYr, statsCd: ctx.statsCd, - pbcpAprvYn: "N" as const, prcsYnList: "0", + rejectCodeList: "0,1,2", page: 1, size: 1, }; diff --git a/apps/st/src/components/statistics/VerificationSelectionReport.tsx b/apps/st/src/components/statistics/VerificationSelectionReport.tsx index 18edbf4cc..f9e0fc599 100644 --- a/apps/st/src/components/statistics/VerificationSelectionReport.tsx +++ b/apps/st/src/components/statistics/VerificationSelectionReport.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { type InfiniteData, keepPreviousData, @@ -9,12 +9,12 @@ import { } from "@tanstack/react-query"; import { useTabStore as useGridTabStore } from "@rsone/components"; -import { statisticsApi } from "@/entities/statistics/api/statistics-service"; +import { verificationInputReportApi } from "@/entities/statistics/api/verification-input-report-service"; import type { - VerificationResultListRequest, - VerificationResultListResponse, - VerificationResultReportItem, -} from "@/entities/statistics/model/statistics-interface"; + VerificationInputReportItem, + VerificationInputReportResponse, + VerificationInputReportTypeCountingInfo, +} from "@/entities/statistics/model/verification-input-report-interface"; import { useAlertStore, useMenuStore } from "@/shared/lib"; import { findMenuItemByUrlPath, @@ -59,8 +59,8 @@ const TYPE_CODE_NAME_MAP: Record = { const PAGE_SIZE = 100; const SCROLL_LOAD_THRESHOLD_PX = 120; -function getNextVerificationResultPage( - lastPage: VerificationResultListResponse, +function getNextVerificationInputReportPage( + lastPage: VerificationInputReportResponse, ): number | undefined { const pageInfo = lastPage?.data?.pageInfo; if (!pageInfo) return undefined; @@ -69,19 +69,29 @@ function getNextVerificationResultPage( return undefined; } -function flattenVerificationResults( - data: InfiniteData | undefined, -): VerificationResultReportItem[] { +function flattenVerificationInputReportResults( + data: InfiniteData | undefined, +): VerificationInputReportItem[] { return ( data?.pages.flatMap((page) => page?.data?.verifyResultList ?? []) ?? [] ); } -function getVerificationResultTotalCount( - data: InfiniteData | undefined, - loadedCount: number, -): number { - return data?.pages[0]?.data?.pageInfo?.totalElements ?? loadedCount; +function getVerifyTypeCountingInfo( + data: InfiniteData | undefined, +): VerificationInputReportTypeCountingInfo | null { + return data?.pages[0]?.data?.verifyTypeCountingInfo ?? null; +} + +function formatReportItemTitle(item: VerificationInputReportItem): string { + return item.statsNm?.trim() || item.vrfcTrgtNm?.trim() || item.statsCd; +} + +function formatReportItemRegion(item: VerificationInputReportItem): string { + const parts = [item.ctpvNm, item.sggNm].filter( + (v): v is string => Boolean(v?.trim()), + ); + return parts.length > 0 ? parts.join(" ") : "-"; } function useVerificationReportList( @@ -89,28 +99,31 @@ function useVerificationReportList( statsCdQueryKey: string, vrfcMthdCd: VerificationReportTabKey, statsCd: string, + enabled: boolean, ) { return useInfiniteQuery({ queryKey: [ - "verificationResultList", + "verificationInputReport", statsYr, vrfcMthdCd, statsCdQueryKey, ], initialPageParam: 1, queryFn: ({ pageParam }) => - statisticsApi.getVerificationResultList({ + verificationInputReportApi.getVerificationInputReport({ statsYr, - pbcpAprvYn: "N", + vrfcMthdCd, page: pageParam, size: PAGE_SIZE, - prcsYnList: "0", - vrfcMthdCd, ...(!(statsCd === "" || statsCd === "ALL") && { statsCd }), - } satisfies VerificationResultListRequest), - getNextPageParam: getNextVerificationResultPage, + }), + getNextPageParam: getNextVerificationInputReportPage, placeholderData: keepPreviousData, refetchOnWindowFocus: false, + // 탭 전환만으로는 재조회하지 않음(명시적 새로고침/조건 변경 시만 조회) + staleTime: Infinity, + refetchOnMount: false, + enabled, }); } @@ -132,28 +145,46 @@ const VerificationSelectionReport = ({ const alert = useAlertStore((s) => s.setMessage); const [activeTab, setActiveTab] = useState("NV"); const [statsCd, setStatsCd] = useState("ALL"); + const [isFullReloading, setIsFullReloading] = useState(false); const statsYr = String(dayjs().year() - 1); const statsCdQueryKey = statsCd === "" || statsCd === "ALL" ? "" : statsCd; + const handleStatsCdChange = useCallback( + (nextStatsCd: string) => { + setStatsCd(nextStatsCd); + setActiveTab("NV"); + setIsFullReloading(true); + + // 통계항목 변경 시 모든 탭 캐시 제거 후 NV부터 다시 조회 + void queryClient.removeQueries({ + queryKey: ["verificationInputReport", statsYr], + }); + }, + [queryClient, statsYr], + ); + const nvQuery = useVerificationReportList( statsYr, statsCdQueryKey, "NV", statsCd, + activeTab === "NV", ); const imrQuery = useVerificationReportList( statsYr, statsCdQueryKey, "IMR", statsCd, + activeTab === "IMR", ); const hbQuery = useVerificationReportList( statsYr, statsCdQueryKey, "HB", statsCd, + activeTab === "HB", ); const queryByTab = { @@ -162,44 +193,51 @@ const VerificationSelectionReport = ({ HB: hbQuery, } as const; - const isInitialLoading = - nvQuery.isPending || imrQuery.isPending || hbQuery.isPending; - const isRefreshing = - (nvQuery.isFetching && !nvQuery.isPending) || - (imrQuery.isFetching && !imrQuery.isPending) || - (hbQuery.isFetching && !hbQuery.isPending); - const error = nvQuery.error ?? imrQuery.error ?? hbQuery.error; + const activeQuery = queryByTab[activeTab]; + const isRefreshing = activeQuery.isFetching && !activeQuery.isPending; + const error = activeQuery.error; + + /** 통계항목 변경·최초 진입: 탭 영역 숨기고 카드 전체 로딩만 표시 */ + const hideTabsSection = + isFullReloading || + (activeTab === "NV" && !nvQuery.data && (nvQuery.isPending || nvQuery.isFetching)); + + useEffect(() => { + if (!isFullReloading) return; + if (nvQuery.isFetching) return; + if (nvQuery.isSuccess || nvQuery.isError) { + setIsFullReloading(false); + } + }, [isFullReloading, nvQuery.isFetching, nvQuery.isSuccess, nvQuery.isError]); const handleRefresh = useCallback(() => { - const tabCodes: VerificationReportTabKey[] = ["NV", "IMR", "HB"]; - void Promise.all( - tabCodes.map((code) => - queryClient.resetQueries({ - queryKey: [ - "verificationResultList", - statsYr, - code, - statsCdQueryKey, - ], - }), - ), - ); - }, [queryClient, statsYr, statsCdQueryKey]); + setStatsCd("ALL"); + setActiveTab("NV"); + setIsFullReloading(true); + void queryClient.removeQueries({ + queryKey: ["verificationInputReport", statsYr], + }); + }, [queryClient, statsYr]); - const listsByTab: Record = { - NV: flattenVerificationResults(nvQuery.data), - IMR: flattenVerificationResults(imrQuery.data), - HB: flattenVerificationResults(hbQuery.data), + const listsByTab: Record = { + NV: flattenVerificationInputReportResults(nvQuery.data), + IMR: flattenVerificationInputReportResults(imrQuery.data), + HB: flattenVerificationInputReportResults(hbQuery.data), }; + const typeCountingInfo = + getVerifyTypeCountingInfo(nvQuery.data) ?? + getVerifyTypeCountingInfo(imrQuery.data) ?? + getVerifyTypeCountingInfo(hbQuery.data); + const totalCountByTab: Record = { - NV: getVerificationResultTotalCount(nvQuery.data, listsByTab.NV.length), - IMR: getVerificationResultTotalCount(imrQuery.data, listsByTab.IMR.length), - HB: getVerificationResultTotalCount(hbQuery.data, listsByTab.HB.length), + NV: typeCountingInfo?.nvCount ?? listsByTab.NV.length, + IMR: typeCountingInfo?.imrCount ?? listsByTab.IMR.length, + HB: typeCountingInfo?.hbCount ?? listsByTab.HB.length, }; const openInputFromStatsCd = useCallback( - (item: VerificationResultReportItem) => { + (item: VerificationInputReportItem) => { const nav = resolveInputPageNavigationFromStatsCd(item.statsCd); if (!nav) { alert( @@ -263,9 +301,21 @@ const VerificationSelectionReport = ({ [], ); + const isTabContentLoading = (tab: VerificationReportTabKey) => { + const query = queryByTab[tab]; + return ( + activeTab === tab && + !hideTabsSection && + (query.isPending || + (query.isFetching && !query.isFetchingNextPage) || + (!query.data && !query.error)) + ); + }; + const renderResultList = ( typeCode: VerificationReportTabKey, - items: VerificationResultReportItem[], + items: VerificationInputReportItem[], + isTabLoading: boolean, hasNextPage: boolean, isFetchingNextPage: boolean, fetchNextPage: () => void, @@ -281,7 +331,13 @@ const VerificationSelectionReport = ({ ) } > - {items.length === 0 && !isFetchingNextPage ? ( + {isTabLoading ? ( +
+
+ +
+
+ ) : items.length === 0 && !isFetchingNextPage ? (
항목이 없습니다.
@@ -303,7 +359,7 @@ const VerificationSelectionReport = ({ >
- {item.statsNm} + {formatReportItemTitle(item)}
- {item.admdstNm} + {formatReportItemRegion(item)} 현재: {item.nowVlCn ?? "-"} / 비교:{" "} {item.compVlCn ?? "-"} @@ -383,25 +439,25 @@ const VerificationSelectionReport = ({
- {isInitialLoading && ( + {hideTabsSection && (
)} - {error && ( + {!hideTabsSection && error && (
데이터를 불러올 수 없습니다.
)} - {!isInitialLoading && !error && ( + {!hideTabsSection && !error && (
- {activeTab === tab.value - ? renderResultList( - tab.value, - listsByTab[tab.value], - queryByTab[tab.value] - .hasNextPage ?? false, - queryByTab[tab.value] - .isFetchingNextPage, - queryByTab[tab.value] - .fetchNextPage, - ) - : null} + {renderResultList( + tab.value, + listsByTab[tab.value], + isTabContentLoading(tab.value), + queryByTab[tab.value].hasNextPage ?? false, + queryByTab[tab.value].isFetchingNextPage, + queryByTab[tab.value].fetchNextPage, + )} ))} diff --git a/apps/st/src/components/statistics/input/PublicSewageFacilityInp.tsx b/apps/st/src/components/statistics/input/PublicSewageFacilityInp.tsx index 1aaa36585..30162faac 100644 --- a/apps/st/src/components/statistics/input/PublicSewageFacilityInp.tsx +++ b/apps/st/src/components/statistics/input/PublicSewageFacilityInp.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react'; import { useConfirm } from '@frontend-opensource/use-react-hooks'; @@ -157,7 +157,9 @@ const PublicSewageFacilityInp = () => { const globalProgressVisible = useGlobalProgressStore((state) => state.visible); const [isLoading, setIsLoading] = useState(false); const [hasSearched, setHasSearched] = useState(false); - const [isNotApplicable, setIsNotApplicable] = useState(false); + const [isNotApplicableByTab, setIsNotApplicableByTab] = useState>( + () => new Map(), + ); const [showIsNotApplicable, setShowIsNotApplicable] = useState(false); const [searchValues, setSearchValues] = useState(null); const [lastSearchedValues, setLastSearchedValues] = useState(null); @@ -175,6 +177,19 @@ const PublicSewageFacilityInp = () => { const [mainFacilityMappingSaveDialogKey, setMainFacilityMappingSaveDialogKey] = useState(0); const [verificationResultDialogOpen, setVerificationResultDialogOpen] = useState(false); const [verificationHistoryOpen, setVerificationHistoryOpen] = useState(false); + const isNotApplicable = isNotApplicableByTab.get('ST00060001') ?? false; + const setIsNotApplicable: Dispatch> = (next) => { + setIsNotApplicableByTab((prev) => { + const current = prev.get('ST00060001') ?? false; + const resolved = + typeof next === 'function' + ? (next as (prevState: boolean) => boolean)(current) + : next; + const nextMap = new Map(prev); + nextMap.set('ST00060001', resolved); + return nextMap; + }); + }; const periodEnterableRef = useRef(true); const searchDoneRef = useRef(false); @@ -655,8 +670,6 @@ const PublicSewageFacilityInp = () => { param: PublicSewageFacilityRequestParams, searchContext: SearchFormValues | null, ): Promise => { - // 서버 데이터로 새로 불러오는 시점이므로 "해당없음" 체크는 항상 초기화 - setIsNotApplicable(false); try { if (tabKey === 'ST00060001') { const response = await statisticsInputPublicSewageFacilityApiService.getPublicSewageFacility(param); @@ -856,6 +869,11 @@ const PublicSewageFacilityInp = () => { setLastSearchedValues(values); searchDoneRef.current = true; setHasSearched(true); + setIsNotApplicableByTab((prev) => { + const next = new Map(prev); + next.set('ST00060001', false); + return next; + }); setIsLoading(true); const param = buildPublicSewageFacilityParams(values); @@ -870,6 +888,19 @@ const PublicSewageFacilityInp = () => { }; const handleActiveTabChange = async (tabKey: string) => { + const prevTabKey = activeTabRef.current; + if (prevTabKey === 'ST00060001') { + const latestRowsInPrevTab = + st60001GridGetRowsRef.current?.() ?? rowDataMap.get('ST00060001') ?? []; + if (latestRowsInPrevTab.length > 0 || rowDataMap.has('ST00060001')) { + setRowDataMap((prev) => { + const next = new Map(prev); + next.set('ST00060001', latestRowsInPrevTab); + return next; + }); + } + } + activeTabRef.current = tabKey; setActiveTab(tabKey); @@ -885,7 +916,7 @@ const PublicSewageFacilityInp = () => { const handleReset = () => { setIsLoading(false); setHasSearched(false); - setIsNotApplicable(false); + setIsNotApplicableByTab(new Map()); setShowIsNotApplicable(false); setRowDataMap(new Map()); @@ -1215,7 +1246,7 @@ const PublicSewageFacilityInp = () => { return ( 05. 펌프장 const PumpingStationInp = () => { - const SubmitExceptionFunc = () => { - console.log('submit exception'); - // 예외 제출 로직 - }; - const getLastYearDataFunc = () => { console.log('get last year data'); // 지난연도 데이터 가져오기 로직 @@ -53,7 +47,6 @@ const PumpingStationInp = () => {
- @@ -67,7 +60,7 @@ const PumpingStationInp = () => {
{ values: SearchFormValues; tabKey: string; } | null>(null); + /** ST00020002 무데이터 안내/자동이동은 동일 조회조건에서 1회만 수행 */ + const factInstallNoDataHandledKeyRef = useRef(null); const [verificationSearchDefaults, setVerificationSearchDefaults] = useState(null); @@ -549,6 +551,12 @@ const SewagePipeInp = () => { mergeIntoExistingMap: boolean, updateLastSearchedValues: boolean, ): Promise => { + const searchConditionKey = JSON.stringify({ + statsYr: searchParam.statsYr ?? "", + ctpvNm: searchParam.ctpvNm ?? "", + sggNm: searchParam.sggNm ?? "", + sewerBzmnCd: searchParam.sewerBzmnCd ?? "", + }); const applyRowMap = (apply: (m: Map[]>) => void) => { if (mergeIntoExistingMap) { setRowDataMap((prev) => { @@ -595,6 +603,23 @@ const SewagePipeInp = () => { ctpvNm: commonInfo.ctpvNm, ...d, })); + if (convertedData.length === 0) { + const alreadyHandledForSameSearch = + factInstallNoDataHandledKeyRef.current === searchConditionKey; + if (!alreadyHandledForSameSearch) { + factInstallNoDataHandledKeyRef.current = searchConditionKey; + alert("시설별 설치 현황에 조회된 데이터가 없습니다.\n시설별 현황 관리에서 설정해주세요"); + activeTabRef.current = "ST00020006"; + setActiveTab("ST00020006"); + return await loadSewagePipeActiveTabData( + "ST00020006", + searchParam, + values, + true, + updateLastSearchedValues, + ); + } + } applyRowMap((m) => m.set('ST00020002', toGridRows(convertedData))); setTabStatusInfoMap((prev) => new Map(prev).set(tabKey, response.data.statusInfo)); setTabStatsCdMap((prev) => new Map(prev).set(tabKey, commonInfo.statsCd)); @@ -795,7 +820,39 @@ const SewagePipeInp = () => { }, [searchFormRemountKey]); + const getCurrentRowsForTab = (tabKey: string): Record[] => { + switch (tabKey) { + case REGIONAL_FACT_TAB_KEY: + return getGridDataRef.current?.() ?? []; + case "ST00020002": + return getFactInstallGridDataRef.current?.() ?? []; + case "ST00020003": + return getPipeTypeGridDataRef.current?.() ?? []; + case "ST00020004": + return getPipeDiameterGridDataRef.current?.() ?? []; + case "ST00020005": + return getManholeGridDataRef.current?.() ?? []; + case "ST00020006": + return [ + ...(rowDataMap.get("treatmentPlantConnectionInp") ?? []), + ...(rowDataMap.get("adminAreaConnectionInp") ?? []), + ]; + default: + return rowDataMap.get(tabKey) ?? []; + } + }; + const handleActiveTabChange = async (tabKey: string) => { + const prevTabKey = activeTabRef.current; + const latestRowsInPrevTab = getCurrentRowsForTab(prevTabKey); + if (latestRowsInPrevTab.length > 0 || rowDataMap.has(prevTabKey)) { + setRowDataMap((prev) => { + const next = new Map(prev); + next.set(prevTabKey, latestRowsInPrevTab); + return next; + }); + } + activeTabRef.current = tabKey; setActiveTab(tabKey); setStatsCd(tabStatsCdMap.get(tabKey) ?? ""); @@ -1735,7 +1792,7 @@ const SewagePipeInp = () => { const maxYy = getManholeLastDataYear() % 100; const saveList = currentData.map((d) => { - const yearlyStats = []; + const yearlyStats: SeweragePipeInputManholeStatusSaveYearlyStatItem[] = []; for (let yy = minYy; yy <= maxYy; yy++) { const year = 2000 + yy; const wholKey = `yy${yy}WholMnhlCnt`; @@ -1978,7 +2035,7 @@ const SewagePipeInp = () => { return ( { }, [searchFormRemountKey]); const handleActiveTabChange = async (tabKey: string) => { + const prevTabKey = activeTabRef.current; + const latestRowsInPrevTab = getTabGridRows(prevTabKey); + if (latestRowsInPrevTab.length > 0 || rowDataMap.has(prevTabKey)) { + setRowDataMap((prev) => { + const next = new Map(prev); + next.set(prevTabKey, latestRowsInPrevTab); + return next; + }); + } + activeTabRef.current = tabKey; setActiveTab(tabKey); if (!hasSearched || !lastSearchedValues || !searchParamRef.current) return; @@ -1110,11 +1120,7 @@ const SewagePipeMaintainInp = () => { return ( , + ); +} + +function buildSewageReservoirDeleteList( + loadedSnapshot: SewageReservoirSaveItem[], + currentRows: Record[], +): SewageReservoirSaveItem[] { + const currentFcltSnSet = new Set( + currentRows + .map((row) => String(row.fcltSn ?? "").trim()) + .filter((sn) => sn !== ""), + ); + return loadedSnapshot.filter((item) => { + const sn = String(item.fcltSn ?? "").trim(); + return sn !== "" && !currentFcltSnSet.has(sn); + }); +} + +/** 시군구명(`sggNm`) 기준 행정구역 코드·명 */ +function resolveSewageReservoirAdmdst( + sggNm: string, + sggOptions: Sgg[], +): { admdstCd: string; admdstNm: string } { + const trimmed = sggNm.trim(); + const matched = sggOptions.find((s) => s.gugun === trimmed); + if (matched) { + return { admdstCd: matched.adstDstrCd, admdstNm: matched.gugun }; + } + return { admdstCd: "", admdstNm: trimmed }; +} + +/** 그리드 행 → ST00040001 저장/삭제 API 항목 */ +function buildSewageReservoirSaveItem( + row: Record, + sggOptions: Sgg[] = [], +): SewageReservoirSaveItem { + const { admdstCd, admdstNm } = resolveSewageReservoirAdmdst( + String(row.sggNm ?? ""), + sggOptions, + ); + return { + admdstCd, + admdstNm, + fcltSn: String(row.fcltSn ?? ""), + fcltNm: String(row.fcltNm ?? ""), + fcltPstnCn: String(row.fcltPstnCn ?? ""), + oprtngStrtYmd: toCompactYmd(row.oprtngStrtYmd), + planDesignFreqCnt: toGridNumber(row.planDesignFreqCnt), + fldngPrvtnYn: normalizeOxYnValue(row.fldngPrvtnYn), + wqPlltnMtgtYn: normalizeOxYnValue(row.wqPlltnMtgtYn), + rtlztnUsgYn: normalizeOxYnValue(row.rtlztnUsgYn), + fcltStrctCn: nullableTrimmedString(row.fcltStrctCn), + wtrStrgZoneArea: toGridNumber(row.wtrStrgZoneArea), + impdFcltCpct: toGridNumber(row.impdFcltCpct), + grssPpqty: toGridNumber(row.grssPpqty), + pumpFcltN1DmtrExpln: String(row.pumpFcltN1DmtrExpln ?? ""), + pumpFcltN1PerPpqty: toGridNumber(row.pumpFcltN1PerPpqty), + pumpFcltN1PumpCnt: toGridNumber(row.pumpFcltN1PumpCnt), + pumpFcltN1RsrvPumpCnt: toGridNumber(row.pumpFcltN1RsrvPumpCnt), + pumpFcltN2DmtrExpln: String(row.pumpFcltN2DmtrExpln ?? ""), + pumpFcltN2PerPpqty: toGridNumber(row.pumpFcltN2PerPpqty), + pumpFcltN2PumpCnt: toGridNumber(row.pumpFcltN2PumpCnt), + pumpFcltN2RsrvPumpCnt: toGridNumber(row.pumpFcltN2RsrvPumpCnt), + pumpFcltN3DmtrExpln: String(row.pumpFcltN3DmtrExpln ?? ""), + pumpFcltN3PerPpqty: toGridNumber(row.pumpFcltN3PerPpqty), + pumpFcltN3PumpCnt: toGridNumber(row.pumpFcltN3PumpCnt), + pumpFcltN3RsrvPumpCnt: toGridNumber(row.pumpFcltN3RsrvPumpCnt), + prcsgFcltYn: String(row.prcsgFcltYn ?? ""), + ddrztnEqpmYn: String(row.ddrztnEqpmYn ?? ""), + oslfEtgtYn: String(row.oslfEtgtYn ?? ""), + mltvMthCn: nullableTrimmedString(row.mltvMthCn), + impdMthCn: nullableTrimmedString(row.impdMthCn), + impdPrcsMthdCn: nullableTrimmedString(row.impdPrcsMthdCn), + dschgWtrsExpln: String(row.dschgWtrsExpln ?? ""), + prcptnLastDspsQnt: toGridNumber(row.prcptnLastDspsQnt), + ctmnLastDspsQnt: toGridNumber(row.ctmnLastDspsQnt), + prcptnLastDspsMthdExpln: String(row.prcptnLastDspsMthdExpln ?? ""), + ctmnLastDspsMthdExpln: String(row.ctmnLastDspsMthdExpln ?? ""), + oprtngDayCnt: toGridNumber(row.oprtngDayCnt), + grssMltvQnt: toGridNumber(row.grssMltvQnt), + rmrk: nullableTrimmedString(row.rmrk), + }; +} + function mapRainfallPumpStationListToGridRows( list: RainfallPumpStationItem[], ctpvNm: string, @@ -275,6 +389,7 @@ function mapRainfallPumpStationListToGridRows( // 하수도 통계 관리 > 04. 하수저류 시설 및 빗물 펌프장 const SewageReservoirPumpInp = () => { const { setMessage: alert } = useAlertStore((state) => state); + const { confirm } = useConfirm(); const { isAdminInfo } = useAdminStore(); const toolbarGroupAllowed = useStatisticsInputToolbarGroupGate(); @@ -282,24 +397,228 @@ const SewageReservoirPumpInp = () => { console.log("submit"); }; - const saveFunc = () => { - console.log("save"); - }; + /** 마지막 조회 시 서버에 있던 하수저류시설 목록 (deleteList 비교 기준) */ + const loadedSewageReservoirSnapshotRef = useRef([]); const cnacelSubmitFunc = () => { - console.log("cancel submit"); + void (async () => { + try { + if (!(await confirm("제출된 내용을 취소하시겠습니까?"))) return; + + if (!lastSearchedValues) { + alert("먼저 조회를 진행해주세요."); + return; + } + + const tabKey = activeTabRef.current; + const statsCdForTab = tabStatsCdMap.get(tabKey) ?? tabKey; + const userInfo = await getUserInfo(); + + const requestData: cancelSubmitRequest = { + sewerBzmnCd: lastSearchedValues.bsnmCd ?? "", + ctpvNm: lastSearchedValues.sido ?? "", + sggNm: + lastSearchedValues.gugun === "ALL" + ? "" + : (lastSearchedValues.gugun ?? ""), + statsYr: lastSearchedValues.year ?? "", + mdfrId: userInfo.userUnqId, + statsCd: statsCdForTab, + }; + + const response = await statisticsApi.cancelSubmit(requestData); + + if (response.status === "success") { + setIsLoading(true); + await loadActiveTabData(tabKey, lastSearchedValues, false, false); + alert("제출 취소되었습니다."); + } else { + alert(response.message ?? "제출 취소 중 오류가 발생했습니다."); + } + } catch { + alert("제출 취소 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + })(); + }; + + const isCheckedRow = (row: Record): boolean => { + const v = row.__checked; + return v === 1 || v === "1" || v === "Y" || v === "y" || v === "true"; + }; + + const getCheckedItemIndexesFromGrid = ( + tabKey: string, + rowCount: number, + ): number[] => { + const gridRef = + tabKey === SEWAGE_RESERVOIR_STATS_CD + ? reservoirGridRef + : tabKey === rainfallPumpTabKey + ? rainfallPumpGridRef + : null; + if (!gridRef?.current?.gridView) return []; + + const gridView = gridRef.current.gridView as { + getCheckedItems?: () => number[]; + getCheckedRows?: () => number[]; + }; + + const checked = + (typeof gridView.getCheckedItems === "function" + ? gridView.getCheckedItems() + : typeof gridView.getCheckedRows === "function" + ? gridView.getCheckedRows() + : []) ?? []; + + return checked.filter( + (idx) => Number.isInteger(idx) && idx >= 0 && idx < rowCount, + ); + }; + + const getCurrentRowsForTab = ( + tabKey: string, + ): Record[] => { + if (tabKey === SEWAGE_RESERVOIR_STATS_CD) { + return reservoirGridGetRowsRef.current?.() ?? rowDataMap.get(tabKey) ?? []; + } + if (tabKey === rainfallPumpTabKey) { + return rainfallPumpGridGetRowsRef.current?.() ?? rowDataMap.get(tabKey) ?? []; + } + return rowDataMap.get(tabKey) ?? []; + }; + + const commitGridForTab = (tabKey: string) => { + const gridRef = + tabKey === SEWAGE_RESERVOIR_STATS_CD + ? reservoirGridRef + : tabKey === rainfallPumpTabKey + ? rainfallPumpGridRef + : null; + const gridView = gridRef?.current?.gridView as + | { + commit?: (force?: boolean) => void; + cancel?: () => void; + } + | undefined; + try { + gridView?.commit?.(true); + } catch { + gridView?.cancel?.(); + } }; const deleteArticleFunc = () => { - console.log("delete article"); + const tabKey = activeTabRef.current; + const currentRows = getCurrentRowsForTab(tabKey); + if (currentRows.length === 0) { + alert("삭제할 데이터가 없습니다."); + return; + } + + const checkedIndexes = getCheckedItemIndexesFromGrid( + tabKey, + currentRows.length, + ); + const checkedIndexSet = + checkedIndexes.length > 0 + ? new Set(checkedIndexes) + : new Set( + currentRows + .map((row, idx) => (isCheckedRow(row) ? idx : -1)) + .filter((idx) => idx >= 0), + ); + + const nextRows = currentRows.filter((_, idx) => !checkedIndexSet.has(idx)); + if (nextRows.length === currentRows.length) { + alert("삭제할 항목을 선택해주세요."); + return; + } + + setRowDataMap((prev) => { + const next = new Map(prev); + next.set(tabKey, nextRows); + return next; + }); + + }; + + const saveFunc = () => { + void (async () => { + const tabKey = activeTabRef.current; + if (tabKey !== SEWAGE_RESERVOIR_STATS_CD) { + alert("저장할 수 없는 탭입니다."); + return; + } + if (!lastSearchedValues) { + alert("먼저 조회를 진행해주세요."); + return; + } + if (!buildGugunOkForToolbar(lastSearchedValues)) { + alert("시군구를 선택해주세요."); + return; + } + if (!(await confirm("저장 하시겠습니까?"))) return; + + commitGridForTab(tabKey); + const noApplicable = isNotApplicableByTab.get(tabKey) ?? false; + const currentData = getCurrentRowsForTab(tabKey); + const saveList = noApplicable + ? [] + : currentData.map((row) => + buildSewageReservoirSaveItem(row, sggOptions), + ); + const deleteList = buildSewageReservoirDeleteList( + loadedSewageReservoirSnapshotRef.current, + currentData, + ); + + if (!noApplicable && saveList.length === 0 && deleteList.length === 0) { + alert("저장할 변경 사항이 없습니다."); + return; + } + + try { + setIsLoading(true); + const userInfo = await getUserInfo(); + const response = + await statisticsInputSewageReservoirPumpApi.saveSewageReservoir({ + statsYr: lastSearchedValues.year ?? "", + ctpvNm: lastSearchedValues.sido ?? "", + sggNm: + lastSearchedValues.gugun === "ALL" + ? "" + : (lastSearchedValues.gugun ?? ""), + sewerBzmnCd: lastSearchedValues.bsnmCd ?? "", + userId: userInfo.userUnqId, + ...(isAdminInfo ? { superAdmin: "superAdmin" } : {}), + ...(noApplicable ? { noApplicable: true } : {}), + saveList, + deleteList, + }); + + if (response.status === "success") { + await loadActiveTabData(tabKey, lastSearchedValues, false, false); + alert("저장되었습니다."); + } else { + alert(response.message ?? "저장 중 오류가 발생했습니다."); + } + } catch { + alert("저장 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + })(); }; const [activeTab, setActiveTab] = useState(firstTabKey); const [rowDataMap, setRowDataMap] = useState< Map[]> >(new Map()); - const [isNotApplicable, setIsNotApplicable] = useState(false); - const [showIsNotApplicable, setShowIsNotApplicable] = useState(false); + const [isNotApplicableByTab, setIsNotApplicableByTab] = useState>( + () => new Map(), + ); const [searchValues, setSearchValues] = useState(null); const [lastSearchedValues, setLastSearchedValues] = useState( null, @@ -317,13 +636,40 @@ const SewageReservoirPumpInp = () => { const [verificationHistoryOpen, setVerificationHistoryOpen] = useState(false); const [sggOptions, setSggOptions] = useState([]); const reservoirGridRef = useRef(null); + const rainfallPumpGridRef = useRef(null); + const reservoirGridGetRowsRef = + useRef<(() => Record[]) | null>(null); + const rainfallPumpGridGetRowsRef = + useRef<(() => Record[]) | null>(null); const sggOptionsSidoRef = useRef(null); const searchDoneRef = useRef(false); const periodEnterableRef = useRef(true); const activeTabRef = useRef(activeTab); + const loadedTabKeysRef = useRef>(new Set()); activeTabRef.current = activeTab; + const shouldShowNotApplicable = ( + tabKey: string, + hasRows: boolean, + statusInfo: SewerageStatusInfo | null | undefined, + ): boolean => + (tabKey === SEWAGE_RESERVOIR_STATS_CD || tabKey === rainfallPumpTabKey) && + hasSearched && + !hasRows && + computeTabEnterable(statusInfo, inputPeriodEnterable); + + const setIsNotApplicableForTab = useCallback( + (tabKey: string, nextValue: boolean) => { + setIsNotApplicableByTab((prev) => { + const next = new Map(prev); + next.set(tabKey, nextValue); + return next; + }); + }, + [], + ); + const commitToolbarSnapshotForLoadedTab = ( tabKey: string, statusInfo: SewerageStatusInfo | null, @@ -371,8 +717,8 @@ const SewageReservoirPumpInp = () => { updateLastSearchedValues: boolean, overrides?: { mode?: string }, ): Promise => { - // 서버 데이터로 새로 불러오는 시점이므로 "해당없음" 체크는 항상 초기화 - setIsNotApplicable(false); + // 서버 데이터로 새로 불러오는 시점이므로 현재 탭의 "해당없음" 체크는 초기화 + setIsNotApplicableForTab(tabKey, false); if (tabKey === SEWAGE_RESERVOIR_STATS_CD) { const response = await statisticsInputSewageReservoirPumpApi.getSewageReservoirList( @@ -386,15 +732,14 @@ const SewageReservoirPumpInp = () => { const { commonInfo, statusInfo, sewageReservoirList } = response.data; const list = sewageReservoirList ?? []; + loadedSewageReservoirSnapshotRef.current = list + .filter((item) => String(item.fcltSn ?? "").trim() !== "") + .map(buildSewageReservoirSaveItemFromApiItem); const gridRows = mapSewageReservoirListToGridRows( list, commonInfo.ctpvNm, ); - if (activeTabRef.current === tabKey) { - setShowIsNotApplicable(list.length === 0); - } - applyRowMap(mergeIntoExistingMap, (m) => { m.set(tabKey, gridRows); }); @@ -409,6 +754,7 @@ const SewageReservoirPumpInp = () => { gridRows.length > 0, values, ); + loadedTabKeysRef.current.add(tabKey); return true; } @@ -444,6 +790,7 @@ const SewageReservoirPumpInp = () => { gridRows.length > 0, values, ); + loadedTabKeysRef.current.add(tabKey); return true; } @@ -490,6 +837,7 @@ const SewageReservoirPumpInp = () => { }, []); const handleReset = () => { + loadedSewageReservoirSnapshotRef.current = []; setRowDataMap(new Map()); setTabStatusInfoMap(new Map()); setTabStatsCdMap(new Map()); @@ -499,10 +847,10 @@ const SewageReservoirPumpInp = () => { sggOptionsSidoRef.current = null; setHasSearched(false); setInputPeriodEnterable(true); - setIsNotApplicable(false); - setShowIsNotApplicable(false); + setIsNotApplicableByTab(new Map()); searchDoneRef.current = false; periodEnterableRef.current = true; + loadedTabKeysRef.current.clear(); setActiveTab(firstTabKey); }; @@ -521,6 +869,7 @@ const SewageReservoirPumpInp = () => { setTabStatusInfoMap(new Map()); setTabStatsCdMap(new Map()); setToolbarSnapshotByTab(new Map()); + loadedTabKeysRef.current.clear(); setActiveTab(firstTabKey); } @@ -529,6 +878,12 @@ const SewageReservoirPumpInp = () => { searchDoneRef.current = true; setHasSearched(true); setLastSearchedValues(values); + const loadedSgg = await loadSggOptions(values.sido); + sggOptionsSidoRef.current = values.sido; + if (loadedSgg.length === 0) { + alert("시군구 목록을 불러오지 못했습니다."); + return; + } const tabKeyToLoad = yearChangedFromLastSearch ? firstTabKey : activeTabRef.current; if (yearChangedFromLastSearch) { @@ -551,15 +906,21 @@ const SewageReservoirPumpInp = () => { }; const handleActiveTabChange = async (tabKey: string) => { - setActiveTab(tabKey); - if (tabKey === SEWAGE_RESERVOIR_STATS_CD) { - setShowIsNotApplicable( - hasSearched && !tabRowDataExists(rowDataMap, tabKey), - ); - } else { - setShowIsNotApplicable(false); + const prevTabKey = activeTabRef.current; + commitGridForTab(prevTabKey); + const latestRowsInPrevTab = getCurrentRowsForTab(prevTabKey); + if (latestRowsInPrevTab.length > 0 || rowDataMap.has(prevTabKey)) { + setRowDataMap((prev) => { + const next = new Map(prev); + next.set(prevTabKey, latestRowsInPrevTab); + return next; + }); } + + activeTabRef.current = tabKey; + setActiveTab(tabKey); if (!hasSearched || !lastSearchedValues) return; + if (loadedTabKeysRef.current.has(tabKey)) return; if (tabRowDataExists(rowDataMap, tabKey)) return; try { @@ -602,6 +963,26 @@ const SewageReservoirPumpInp = () => { activeTab === SEWAGE_RESERVOIR_STATS_CD || activeTab === rainfallPumpTabKey; const showArticleToolbarCard = hasSearched && isReservoirOrRainfallTab && toolbarGroupAllowed; + const isNotApplicableForActiveTab = isNotApplicableByTab.get(activeTab) ?? false; + const showNotApplicableForActiveTab = + loadedTabKeysRef.current.has(activeTab) && + shouldShowNotApplicable( + activeTab, + tabRowDataExists(rowDataMap, activeTab), + tabStatusInfoMap.get(activeTab), + ); + const setIsNotApplicableForActiveTab: Dispatch> = useCallback( + (next) => { + const tabKey = activeTabRef.current; + const current = isNotApplicableByTab.get(tabKey) ?? false; + const resolved = + typeof next === "function" + ? (next as (prevState: boolean) => boolean)(current) + : next; + setIsNotApplicableForTab(tabKey, resolved); + }, + [isNotApplicableByTab, setIsNotApplicableForTab], + ); const openVerificationHistoryDialog = () => { const ctx = lastSearchedValues ?? searchValues; @@ -673,8 +1054,6 @@ const SewageReservoirPumpInp = () => { // 행 추가 직후 setColumnProperty를 강제 재적용하면 타이밍 이슈로 // `fieldIndex is out of bounds`가 발생할 수 있어 제거한다. - setShowIsNotApplicable(false); - const statusInfo = tabStatusInfoMap.get(tabKey) ?? null; commitToolbarSnapshotForLoadedTab(tabKey, statusInfo, true, ctx); }; @@ -690,6 +1069,13 @@ const SewageReservoirPumpInp = () => { ) || isAdminInfo; const isReservoirTab = tabKey === SEWAGE_RESERVOIR_STATS_CD; const columns = (tab.columns ?? []).map((col: ColumnConfig) => { + if (isReservoirTab && col.fieldName === "ctpvNm") { + return { + ...col, + editable: false as const, + styleName: col.styleName ?? "rg-cell-readonly", + }; + } if (isReservoirTab && col.fieldName === "sggNm") { return buildSggNmColumn(col, canEdit, sggOptions); } @@ -709,31 +1095,6 @@ const SewageReservoirPumpInp = () => { return map; }, [tabStatusInfoMap, inputPeriodEnterable, isAdminInfo, sggOptions]); - const handleSewageReservoirCellEdited = useCallback( - (params: CellEditedCallbackParams) => { - if (params.fieldName !== "sggNm") return; - const selected = sggOptions.find((s) => s.gugun === params.newValue); - if (!selected) return; - - const tabKey = SEWAGE_RESERVOIR_STATS_CD; - setRowDataMap((prev) => { - const next = new Map(prev); - const rows = [...(next.get(tabKey) ?? [])]; - const row = rows[params.itemIndex]; - if (!row) return prev; - rows[params.itemIndex] = { - ...row, - sggNm: selected.gugun, - admdstCd: selected.adstDstrCd, - admdstNm: selected.gugun, - }; - next.set(tabKey, rows); - return next; - }); - }, - [sggOptions], - ); - const verificationCellStyle = ({ fieldName, row, @@ -817,10 +1178,10 @@ const SewageReservoirPumpInp = () => { {showArticleToolbarCard && (
- {showIsNotApplicable && ( + {showNotApplicableForActiveTab && ( )} @@ -859,13 +1220,13 @@ const SewageReservoirPumpInp = () => { return ( { return next; }); }} + getGridDataRef={ + tabKey === SEWAGE_RESERVOIR_STATS_CD + ? reservoirGridGetRowsRef + : tabKey === rainfallPumpTabKey + ? rainfallPumpGridGetRowsRef + : undefined + } showHeaderCheckbox statusInfo={tabStatusInfo} isExcelImport={ @@ -903,13 +1271,6 @@ const SewageReservoirPumpInp = () => { } cellStyleCallback={verificationCellStyle} cellTooltipCallback={verificationCellTooltip} - onCellEdited={ - isReservoirTab && - tabEnterableForGrid && - sggOptions.length > 0 - ? handleSewageReservoirCellEdited - : undefined - } /> ); @@ -921,6 +1282,7 @@ const SewageReservoirPumpInp = () => { onOpenChange={setVerificationHistoryOpen} queryContext={verificationQueryContext} /> + ); }; diff --git a/apps/st/src/components/statistics/management/LogHistory.tsx b/apps/st/src/components/statistics/management/LogHistory.tsx index b51578c0f..6258f5e63 100644 --- a/apps/st/src/components/statistics/management/LogHistory.tsx +++ b/apps/st/src/components/statistics/management/LogHistory.tsx @@ -196,6 +196,20 @@ const LogHistory = () => { const handleSearch = async (e: React.FormEvent) => { e.preventDefault(); + + if (startDate && endDate) { + const start = dayjs(startDate); + const end = dayjs(endDate); + if (start.isAfter(end, "day")) { + alert("시작일은 종료일보다 늦을 수 없습니다."); + return; + } + if (end.isBefore(start, "day")) { + alert("종료일은 시작일보다 빠를 수 없습니다."); + return; + } + } + await loadLogs(1); }; diff --git a/apps/st/src/components/statistics/management/NotEnteredStatus.tsx b/apps/st/src/components/statistics/management/NotEnteredStatus.tsx index 7b0740b27..ea0c0aaf4 100644 --- a/apps/st/src/components/statistics/management/NotEnteredStatus.tsx +++ b/apps/st/src/components/statistics/management/NotEnteredStatus.tsx @@ -213,6 +213,7 @@ function buildRowsFromStatusList( statKeys: string[], ): Record[] { const rowMap = new Map>(); + const rowOrder: string[] = []; for (const item of statusList) { const { sido, sgg } = splitSidoSggNm(item.sidoSggNm); @@ -228,6 +229,7 @@ function buildRowsFromStatusList( row[key] = ""; } rowMap.set(rowKey, row); + rowOrder.push(rowKey); } const row = rowMap.get(rowKey)!; @@ -236,11 +238,7 @@ function buildRowsFromStatusList( } } - return Array.from(rowMap.values()).sort((a, b) => { - const sidoCmp = String(a[SIDO_FIELD]).localeCompare(String(b[SIDO_FIELD]), "ko"); - if (sidoCmp !== 0) return sidoCmp; - return String(a[SGG_FIELD]).localeCompare(String(b[SGG_FIELD]), "ko"); - }); + return rowOrder.map((key) => rowMap.get(key)!); } function buildUnSubmitDataRequest( diff --git a/apps/st/src/components/type/DataGrid.type.ts b/apps/st/src/components/type/DataGrid.type.ts index f02424f48..ae56e0597 100644 --- a/apps/st/src/components/type/DataGrid.type.ts +++ b/apps/st/src/components/type/DataGrid.type.ts @@ -149,6 +149,14 @@ export type ComputedFieldConfig = denominatorFields: string[]; thirdFields: string[]; type: "swrgInstlrtRate"; + } + | { + pumpGroups: Array<{ + pumpCntField: string; + rsrvPumpCntField: string; + perPpqtyField: string; + }>; + type: "pumpGrossPpqty"; }; export type ColumnConfig = { diff --git a/apps/st/src/components/util/column/input/PumpingStationInpColumn.ts b/apps/st/src/components/util/column/input/PumpingStationInpColumn.ts index 6a174daca..b478b3bc9 100644 --- a/apps/st/src/components/util/column/input/PumpingStationInpColumn.ts +++ b/apps/st/src/components/util/column/input/PumpingStationInpColumn.ts @@ -8,69 +8,74 @@ import { ValueType } from "realgrid"; export const PumpingStationInpColumn = { columns: [ // 행정구역 - { name: 'sido', fieldName: '시도', header: { text: '시도' }, editable: false }, - { name: 'sgg', fieldName: '시군구', header: { text: '시군구' }, footer: { text: '합계' } }, + { name: 'ctpvNm', fieldName: 'ctpvNm', header: { text: '시도' }, footer: { text: '합계' }, editable: false, styleName: 'rg-cell-readonly' }, + { name: 'sggNm', fieldName: 'sggNm', header: { text: '시군구' } }, // 기본 정보 - { name: 'treatmentFacilityName', fieldName: '처리장명', header: { text: '처리장명' } }, - { name: 'facilityName', fieldName: '시설명', header: { text: '시설명' } }, - { name: 'classification', fieldName: '구분(합류식 분류식)', header: { text: '구분(합류식 분류식)' } }, - { name: 'location', fieldName: '위치', header: { text: '위치' } }, - { name: 'operationDate', fieldName: '가동 년월일 yyyy-mm-dd', header: { text: '가동 년월일 yyyy-mm-dd' } }, - { name: 'planDrainageArea', fieldName: '계획배수면적(㎢)', header: { text: '계획배수면적(㎢)' }, footer: footerDecimal3, valueType: ValueType.NUMBER, dataFormat: '#,##0.000' }, + { name: 'swtrmtFcltNm', fieldName: 'swtrmtFcltNm', header: { text: '처리장명' } }, + { name: 'fcltNm', fieldName: 'fcltNm', header: { text: '시설명' } }, + { name: 'pumpSeNm', fieldName: 'pumpSeNm', header: { text: '구분(합류식 분류식)' } }, + { name: 'fcltPstnCn', fieldName: 'fcltPstnCn', header: { text: '위치' } }, + { name: 'oprtngStrtYmd', fieldName: 'oprtngStrtYmd', header: { text: '가동 년월일 yyyy-mm-dd' } }, + { name: 'planMltvArea', fieldName: 'planMltvArea', header: { text: '계획배수면적(㎢)' }, footer: footerDecimal3, valueType: ValueType.NUMBER, dataFormat: '#,##0.000' }, // 계획시간 대 최대배수량 - { name: 'fairWeatherFlow', fieldName: '청천시(A)', header: { text: '청천시(A)' }, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, - { name: 'rainyWeatherFlow', fieldName: '우천시(B)', header: { text: '우천시(B)' }, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, - { name: 'rainwaterFlow', fieldName: '우수량(B-A)', header: { text: '우수량(B-A)' }, footer: footerDecimal1, editable: false, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, + { name: 'sndyArea', fieldName: 'sndyArea', header: { text: '청천시(A)' }, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, + { name: 'rainArea', fieldName: 'rainArea', header: { text: '우천시(B)' }, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, + { name: 'exlnQnt', fieldName: 'exlnQnt', header: { text: '우수량(B-A)' }, footer: footerDecimal1, editable: false, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, // 배수능력 > 총 양수량 (오수) - { name: 'sewageTotalPumpCapacity', fieldName: '총양수량(m³/min)', header: { text: '총양수량(m³/min)' }, footer: footerDecimal1, editable: false, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, + { name: 'drtwPumpGrssPpqty', fieldName: 'drtwPumpGrssPpqty', header: { text: '총양수량(m³/min)' }, footer: footerDecimal1, editable: false, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, // 배수능력 > 오수펌프(1) - { name: 'sewagePump1Diameter', fieldName: '오수펌프(1) 구경(mm)', header: { text: '구경(mm)' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, - { name: 'sewagePump1Capacity', fieldName: '오수펌프(1) 대당 양수량(m³/min·대)', header: { text: '대당 양수량(m³/min·대)' }, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, - { name: 'sewagePump1Count', fieldName: '오수펌프(1) 펌프대수', header: { text: '펌프대수' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, - { name: 'sewagePump1Reserve', fieldName: '오수펌프(1) 예비펌프대수', header: { text: '예비펌프대수' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: 'drtwPumpN1DmtrArea', fieldName: 'drtwPumpN1DmtrArea', header: { text: '구경(mm)' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: 'drtwPumpN1Ppqty', fieldName: 'drtwPumpN1Ppqty', header: { text: '대당 양수량(m³/min·대)' }, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, + { name: 'drtwPumpN1Cnt', fieldName: 'drtwPumpN1Cnt', header: { text: '펌프대수' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: 'drtwPumpN1RsrvCnt', fieldName: 'drtwPumpN1RsrvCnt', header: { text: '예비펌프대수' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, // 배수능력 > 오수펌프(2) - { name: 'sewagePump2Diameter', fieldName: '오수펌프(2) 구경(mm)', header: { text: '구경(mm)' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, - { name: 'sewagePump2Capacity', fieldName: '오수펌프(2) 대당 양수량(m³/min·대)', header: { text: '대당 양수량(m³/min·대)' }, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, - { name: 'sewagePump2Count', fieldName: '오수펌프(2) 펌프대수', header: { text: '펌프대수' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, - { name: 'sewagePump2Reserve', fieldName: '오수펌프(2) 예비펌프대수', header: { text: '예비펌프대수' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: 'drtwPumpN2DmtrArea', fieldName: 'drtwPumpN2DmtrArea', header: { text: '구경(mm)' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: 'drtwPumpN2Ppqty', fieldName: 'drtwPumpN2Ppqty', header: { text: '대당 양수량(m³/min·대)' }, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, + { name: 'drtwPumpN2Cnt', fieldName: 'drtwPumpN2Cnt', header: { text: '펌프대수' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: 'drtwPumpN2RsrvCnt', fieldName: 'drtwPumpN2RsrvCnt', header: { text: '예비펌프대수' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, // 배수능력 > 오수펌프(3) - { name: 'sewagePump3Diameter', fieldName: '오수펌프(3) 구경(mm)', header: { text: '구경(mm)' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, - { name: 'sewagePump3Capacity', fieldName: '오수펌프(3) 대당 양수량(m³/min·대)', header: { text: '대당 양수량(m³/min·대)' }, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, - { name: 'sewagePump3Count', fieldName: '오수펌프(3) 펌프대수', header: { text: '펌프대수' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, - { name: 'sewagePump3Reserve', fieldName: '오수펌프(3) 예비펌프대수', header: { text: '예비펌프대수' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: 'drtwPumpN3DmtrArea', fieldName: 'drtwPumpN3DmtrArea', header: { text: '구경(mm)' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: 'drtwPumpN3Ppqty', fieldName: 'drtwPumpN3Ppqty', header: { text: '대당 양수량(m³/min·대)' }, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, + { name: 'drtwPumpN3Cnt', fieldName: 'drtwPumpN3Cnt', header: { text: '펌프대수' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: 'drtwPumpN3RsrvCnt', fieldName: 'drtwPumpN3RsrvCnt', header: { text: '예비펌프대수' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, // 배수능력 > 총 양수량 (우수) - { name: 'rainwaterTotalPumpCapacity', fieldName: '총양수량(m³/min)', header: { text: '총양수량(m³/min)' }, footer: footerDecimal1, editable: false, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, + { name: 'exlnPumpGrssPpqty', fieldName: 'exlnPumpGrssPpqty', header: { text: '총양수량(m³/min)' }, footer: footerDecimal1, editable: false, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, // 배수능력 > 우수펌프(1) - { name: 'rainwaterPump1Diameter', fieldName: '우수펌프(1) 구경(mm)', header: { text: '구경(mm)' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, - { name: 'rainwaterPump1Capacity', fieldName: '우수펌프(1) 대당 양수량(m³/min·대)', header: { text: '대당 양수량(m³/min·대)' }, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, - { name: 'rainwaterPump1Count', fieldName: '우수펌프(1) 펌프대수', header: { text: '펌프대수' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, - { name: 'rainwaterPump1Reserve', fieldName: '우수펌프(1) 예비펌프대수', header: { text: '예비펌프대수' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: 'exlnPumpN1DmtrArea', fieldName: 'exlnPumpN1DmtrArea', header: { text: '구경(mm)' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: 'exlnPumpN1Ppqty', fieldName: 'exlnPumpN1Ppqty', header: { text: '대당 양수량(m³/min·대)' }, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, + { name: 'exlnPumpN1Cnt', fieldName: 'exlnPumpN1Cnt', header: { text: '펌프대수' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: 'exlnPumpN1RsrvCnt', fieldName: 'exlnPumpN1RsrvCnt', header: { text: '예비펌프대수' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, // 배수능력 > 우수펌프(2) - { name: 'rainwaterPump2Diameter', fieldName: '우수펌프(2) 구경(mm)', header: { text: '구경(mm)' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, - { name: 'rainwaterPump2Capacity', fieldName: '우수펌프(2) 대당 양수량(m³/min·대)', header: { text: '대당 양수량(m³/min·대)' }, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, - { name: 'rainwaterPump2Count', fieldName: '우수펌프(2) 펌프대수', header: { text: '펌프대수' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, - { name: 'rainwaterPump2Reserve', fieldName: '우수펌프(2) 예비펌프대수', header: { text: '예비펌프대수' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: 'exlnPumpN2DmtrArea', fieldName: 'exlnPumpN2DmtrArea', header: { text: '구경(mm)' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: 'exlnPumpN2Ppqty', fieldName: 'exlnPumpN2Ppqty', header: { text: '대당 양수량(m³/min·대)' }, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, + { name: 'exlnPumpN2Cnt', fieldName: 'exlnPumpN2Cnt', header: { text: '펌프대수' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: 'exlnPumpN2RsrvCnt', fieldName: 'exlnPumpN2RsrvCnt', header: { text: '예비펌프대수' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, // 배수능력 > 우수펌프(3) - { name: 'rainwaterPump3Diameter', fieldName: '우수펌프(3) 구경(mm)', header: { text: '구경(mm)' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, - { name: 'rainwaterPump3Capacity', fieldName: '우수펌프(3) 대당 양수량(m³/min·대)', header: { text: '대당 양수량(m³/min·대)' }, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, - { name: 'rainwaterPump3Count', fieldName: '우수펌프(3) 펌프대수', header: { text: '펌프대수' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, - { name: 'rainwaterPump3Reserve', fieldName: '우수펌프(3) 예비펌프대수', header: { text: '예비펌프대수' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: 'exlnPumpN3DmtrArea', fieldName: 'exlnPumpN3DmtrArea', header: { text: '구경(mm)' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: 'exlnPumpN3Ppqty', fieldName: 'exlnPumpN3Ppqty', header: { text: '대당 양수량(m³/min·대)' }, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, + { name: 'exlnPumpN3Cnt', fieldName: 'exlnPumpN3Cnt', header: { text: '펌프대수' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: 'exlnPumpN3RsrvCnt', fieldName: 'exlnPumpN3RsrvCnt', header: { text: '예비펌프대수' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, // 연간전력 사용량 - { name: 'annualPowerUsage', fieldName: '연간전력사용량(kWh/년)', header: { text: '연간전력사용량(kWh/년)' }, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, + { name: 'anlElpwrUseQnt', fieldName: 'anlElpwrUseQnt', header: { text: '연간전력사용량(kWh/년)' }, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, // 비고 - { name: 'remarks', fieldName: '비고', header: { text: '비고' } }, + { name: 'rmrk', fieldName: 'rmrk', header: { text: '비고' } }, + + // Hidden Columns : 시스템 관리를 위한 값 + { name: 'admdstCd', fieldName: 'admdstCd', header: { text: '행정구역코드' }, visible: false, }, + { name: 'fcltSn', fieldName: 'fcltSn', header: { text: '시설일련번호' }, visible: false, }, + { name: 'admdstNm', fieldName: 'admdstNm', header: { text: '행정구역명' }, visible: false }, ], columnLayout: [ @@ -78,66 +83,69 @@ export const PumpingStationInpColumn = { name: 'administrativeArea', hideChildHeaders: false, header: { text: '행정구역' }, - items: ['sido', 'sgg'], + items: ['ctpvNm', 'sggNm'], }, - 'treatmentFacilityName', - 'facilityName', - 'classification', - 'location', - 'operationDate', - 'planDrainageArea', + 'swtrmtFcltNm', + 'fcltNm', + 'pumpSeNm', + 'fcltPstnCn', + 'oprtngStrtYmd', + 'planMltvArea', { name: 'planFlowGroup', hideChildHeaders: false, header: { text: '계획시간대최대배수량(m³/hr)' }, - items: ['fairWeatherFlow', 'rainyWeatherFlow', 'rainwaterFlow'], + items: ['sndyArea', 'rainArea', 'exlnQnt'], }, { name: 'drainageCapability', hideChildHeaders: false, header: { text: '배수능력' }, items: [ - 'sewageTotalPumpCapacity', + 'drtwPumpGrssPpqty', { name: 'sewagePump1Group', hideChildHeaders: false, header: { text: '오수펌프(1)' }, - items: ['sewagePump1Diameter', 'sewagePump1Capacity', 'sewagePump1Count', 'sewagePump1Reserve'], + items: ['drtwPumpN1DmtrArea', 'drtwPumpN1Ppqty', 'drtwPumpN1Cnt', 'drtwPumpN1RsrvCnt'], }, { name: 'sewagePump2Group', hideChildHeaders: false, header: { text: '오수펌프(2)' }, - items: ['sewagePump2Diameter', 'sewagePump2Capacity', 'sewagePump2Count', 'sewagePump2Reserve'], + items: ['drtwPumpN2DmtrArea', 'drtwPumpN2Ppqty', 'drtwPumpN2Cnt', 'drtwPumpN2RsrvCnt'], }, { name: 'sewagePump3Group', hideChildHeaders: false, header: { text: '오수펌프(3)' }, - items: ['sewagePump3Diameter', 'sewagePump3Capacity', 'sewagePump3Count', 'sewagePump3Reserve'], + items: ['drtwPumpN3DmtrArea', 'drtwPumpN3Ppqty', 'drtwPumpN3Cnt', 'drtwPumpN3RsrvCnt'], }, - 'rainwaterTotalPumpCapacity', + 'exlnPumpGrssPpqty', { name: 'rainwaterPump1Group', hideChildHeaders: false, header: { text: '우수펌프(1)' }, - items: ['rainwaterPump1Diameter', 'rainwaterPump1Capacity', 'rainwaterPump1Count', 'rainwaterPump1Reserve'], + items: ['exlnPumpN1DmtrArea', 'exlnPumpN1Ppqty', 'exlnPumpN1Cnt', 'exlnPumpN1RsrvCnt'], }, { name: 'rainwaterPump2Group', hideChildHeaders: false, header: { text: '우수펌프(2)' }, - items: ['rainwaterPump2Diameter', 'rainwaterPump2Capacity', 'rainwaterPump2Count', 'rainwaterPump2Reserve'], + items: ['exlnPumpN2DmtrArea', 'exlnPumpN2Ppqty', 'exlnPumpN2Cnt', 'exlnPumpN2RsrvCnt'], }, { name: 'rainwaterPump3Group', hideChildHeaders: false, header: { text: '우수펌프(3)' }, - items: ['rainwaterPump3Diameter', 'rainwaterPump3Capacity', 'rainwaterPump3Count', 'rainwaterPump3Reserve'], + items: ['exlnPumpN3DmtrArea', 'exlnPumpN3Ppqty', 'exlnPumpN3Cnt', 'exlnPumpN3RsrvCnt'], }, ], }, - 'annualPowerUsage', - 'remarks', + 'anlElpwrUseQnt', + 'rmrk', + 'admdstCd', + 'fcltSn', + 'admdstNm' ] }; \ No newline at end of file diff --git a/apps/st/src/components/util/column/input/sewageReservoirPump/RainfallPumpStationInpColumn.ts b/apps/st/src/components/util/column/input/sewageReservoirPump/RainfallPumpStationInpColumn.ts index ebd09ca2b..695fb8213 100644 --- a/apps/st/src/components/util/column/input/sewageReservoirPump/RainfallPumpStationInpColumn.ts +++ b/apps/st/src/components/util/column/input/sewageReservoirPump/RainfallPumpStationInpColumn.ts @@ -1,6 +1,33 @@ +import type { ColumnConfig } from "@/components/type/DataGrid.type"; import { footerInt, footerDecimal1, footerDecimal2, footerDecimal3 } from "@/components/util/grid/footerSummary"; import { ValueType } from "realgrid"; +const RAINFALL_PUMP_FCLTY_KIND_VALUES = ["해당없음", "제1종", "제2종", "제3종"] as const; +const RAINFALL_PUMP_YN_VALUES = ["유", "무"] as const; + +function dropdownColumn( + col: Pick & + Partial, + options: readonly string[], +): ColumnConfig { + const values = [...options]; + return { + ...col, + values, + labels: values, + lookupDisplay: true, + editor: { + type: "dropdown", + domainOnly: true, + textReadOnly: true, + commitOnSelect: true, + dropDownWhenClick: true, + values, + labels: values, + }, + }; +} + // 하수도 통계 관리 > 04.하수저류시설 및 빗물펌프장 > tab 2: 빗물펌프장 export const RainfallPumpStationInpColumn = { columns: [ @@ -10,7 +37,10 @@ export const RainfallPumpStationInpColumn = { { name: 'fcltNm', fieldName: 'fcltNm', header: { text: '시설명' } }, { name: 'fcltPstnCn', fieldName: 'fcltPstnCn', header: { text: '위치' } }, - { name: 'fcltyKndCn', fieldName: 'fcltyKndCn', header: { text: '시설물종류' } }, + dropdownColumn( + { name: 'fcltyKndCn', fieldName: 'fcltyKndCn', header: { text: '시설물종류' } }, + RAINFALL_PUMP_FCLTY_KIND_VALUES, + ), { name: 'oprtngStrtYmd', @@ -29,14 +59,46 @@ export const RainfallPumpStationInpColumn = { { name: 'wtrStrgZoneArea', fieldName: 'wtrStrgZoneArea', header: { text: '집수구역면적(㎢)' }, footer: footerDecimal3, valueType: ValueType.NUMBER, dataFormat: '#,##0.000' }, // 유수지 - { name: 'rsvrYn', fieldName: 'rsvrYn', header: { text: '유수지유무(유,무)' } }, + dropdownColumn( + { name: 'rsvrYn', fieldName: 'rsvrYn', header: { text: '유수지유무(유,무)' } }, + RAINFALL_PUMP_YN_VALUES, + ), { name: 'rsvrImpndQnt', fieldName: 'rsvrImpndQnt', header: { text: '저류랑(m³)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, // 흡수정 { name: 'abstnRgnImpdQnt', fieldName: 'abstnRgnImpdQnt', header: { text: '저류랑(m³)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, // 펌프시설 > 총양수량(m³/min) - { name: 'grssPpqty', fieldName: 'grssPpqty', header: { text: '총양수량(m³/min)' }, editable: false, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, + { + name: 'grssPpqty', + fieldName: 'grssPpqty', + header: { text: '총양수량(m³/min)' }, + editable: false, + footer: footerDecimal1, + valueType: ValueType.NUMBER, + dataFormat: '#,##0.0', + styleName: 'rg-cell-total', + computed: { + type: 'pumpGrossPpqty' as const, + pumpGroups: [ + { + pumpCntField: 'pumpFcltN1PumpCnt', + rsrvPumpCntField: 'pumpFcltN1RsrvPumpCnt', + perPpqtyField: 'pumpFcltN1PerPpqty', + }, + { + pumpCntField: 'pumpFcltN2PumpCnt', + rsrvPumpCntField: 'pumpFcltN2RsrvPumpCnt', + perPpqtyField: 'pumpFcltN2PerPpqty', + }, + { + pumpCntField: 'pumpFcltN3PumpCnt', + rsrvPumpCntField: 'pumpFcltN3RsrvPumpCnt', + perPpqtyField: 'pumpFcltN3PerPpqty', + }, + ], + }, + }, // 펌프시설1 { name: 'pumpFcltN1DmtrExpln', fieldName: 'pumpFcltN1DmtrExpln', header: { text: '구경(mm)' } }, @@ -57,9 +119,18 @@ export const RainfallPumpStationInpColumn = { { name: 'pumpFcltN3RsrvPumpCnt', fieldName: 'pumpFcltN3RsrvPumpCnt', header: { text: '예비펌프대수' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, // 부대시설 - { name: 'prcsgFcltYn', fieldName: 'prcsgFcltYn', header: { text: '전처리시설유무(유/무)' } }, - { name: 'ddrztnEqpmYn', fieldName: 'ddrztnEqpmYn', header: { text: '탈취설비유무(유/무)' } }, - { name: 'oslfEtgtYn', fieldName: 'oslfEtgtYn', header: { text: '자가발전유무(유/무)' } }, + dropdownColumn( + { name: 'prcsgFcltYn', fieldName: 'prcsgFcltYn', header: { text: '전처리시설유무(유/무)' } }, + RAINFALL_PUMP_YN_VALUES, + ), + dropdownColumn( + { name: 'ddrztnEqpmYn', fieldName: 'ddrztnEqpmYn', header: { text: '탈취설비유무(유/무)' } }, + RAINFALL_PUMP_YN_VALUES, + ), + dropdownColumn( + { name: 'oslfEtgtYn', fieldName: 'oslfEtgtYn', header: { text: '자가발전유무(유/무)' } }, + RAINFALL_PUMP_YN_VALUES, + ), // 방류수역 { name: 'dschgWtrsExpln', fieldName: 'dschgWtrsExpln', header: { text: '방류수역' } }, diff --git a/apps/st/src/components/util/column/input/sewageReservoirPump/SewageReservoirInpColumn.ts b/apps/st/src/components/util/column/input/sewageReservoirPump/SewageReservoirInpColumn.ts index 1cd46cf9e..8747c5204 100644 --- a/apps/st/src/components/util/column/input/sewageReservoirPump/SewageReservoirInpColumn.ts +++ b/apps/st/src/components/util/column/input/sewageReservoirPump/SewageReservoirInpColumn.ts @@ -11,6 +11,10 @@ export const SEWAGE_RESERVOIR_FCLT_STRCT_VALUES = [ "지하터널식", "기타", ] as const; +export const SEWAGE_RESERVOIR_YN_VALUES = ["유", "무"] as const; +export const SEWAGE_RESERVOIR_MLTV_MTH_VALUES = ["자연배수", "강제배수"] as const; +export const SEWAGE_RESERVOIR_IMPD_MTH_VALUES = ["직렬", "병렬"] as const; +export const SEWAGE_RESERVOIR_IMPD_PRCS_MTHD_VALUES = ["자체처리", "연계처리"] as const; function dropdownColumn( col: Pick & @@ -99,7 +103,36 @@ export const SewageReservoirInpColumn = { { name: 'impdFcltCpct', fieldName: 'impdFcltCpct', header: { text: '시설용량(m³)' }, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, // 펌프시설 > 총양수량 - { name: 'grssPpqty', fieldName: 'grssPpqty', header: { text: '총양수량(m³/min)' }, editable: false, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, + { + name: 'grssPpqty', + fieldName: 'grssPpqty', + header: { text: '총양수량(m³/min)' }, + editable: false, + footer: footerDecimal1, + valueType: ValueType.NUMBER, + dataFormat: '#,##0.0', + styleName: 'rg-cell-total', + computed: { + type: 'pumpGrossPpqty' as const, + pumpGroups: [ + { + pumpCntField: 'pumpFcltN1PumpCnt', + rsrvPumpCntField: 'pumpFcltN1RsrvPumpCnt', + perPpqtyField: 'pumpFcltN1PerPpqty', + }, + { + pumpCntField: 'pumpFcltN2PumpCnt', + rsrvPumpCntField: 'pumpFcltN2RsrvPumpCnt', + perPpqtyField: 'pumpFcltN2PerPpqty', + }, + { + pumpCntField: 'pumpFcltN3PumpCnt', + rsrvPumpCntField: 'pumpFcltN3RsrvPumpCnt', + perPpqtyField: 'pumpFcltN3PerPpqty', + }, + ], + }, + }, // 펌프시설 1 { name: 'pumpFcltN1DmtrExpln', fieldName: 'pumpFcltN1DmtrExpln', header: { text: '구경(mm)' } }, @@ -120,18 +153,60 @@ export const SewageReservoirInpColumn = { { name: 'pumpFcltN3RsrvPumpCnt', fieldName: 'pumpFcltN3RsrvPumpCnt', header: { text: '예비펌프대수' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, // 부대시설 - { name: 'prcsgFcltYn', fieldName: 'prcsgFcltYn', header: { text: '전처리시설유무(유/무)' } }, - { name: 'ddrztnEqpmYn', fieldName: 'ddrztnEqpmYn', header: { text: '탈취설비유무(유/무)' } }, - { name: 'oslfEtgtYn', fieldName: 'oslfEtgtYn', header: { text: '자가발전유무(유/무)' } }, + dropdownColumn( + { + name: "prcsgFcltYn", + fieldName: "prcsgFcltYn", + header: { text: "전처리시설유무(유/무)" }, + }, + SEWAGE_RESERVOIR_YN_VALUES, + ), + dropdownColumn( + { + name: "ddrztnEqpmYn", + fieldName: "ddrztnEqpmYn", + header: { text: "탈취설비유무(유/무)" }, + }, + SEWAGE_RESERVOIR_YN_VALUES, + ), + dropdownColumn( + { + name: "oslfEtgtYn", + fieldName: "oslfEtgtYn", + header: { text: "자가발전유무(유/무)" }, + }, + SEWAGE_RESERVOIR_YN_VALUES, + ), // 배수 방식 - { name: 'mltvMthCn', fieldName: 'mltvMthCn', header: { text: '배수방식(자연배수,강제배수)' } }, - + dropdownColumn( + { + name: "mltvMthCn", + fieldName: "mltvMthCn", + header: { text: "배수방식(자연배수,강제배수)" }, + }, + SEWAGE_RESERVOIR_MLTV_MTH_VALUES, + ), + // 저류 방식 - { name: 'impdMthCn', fieldName: 'impdMthCn', header: { text: '저류방식(직렬,병렬)' } }, - + dropdownColumn( + { + name: "impdMthCn", + fieldName: "impdMthCn", + header: { text: "저류방식(직렬,병렬)" }, + }, + SEWAGE_RESERVOIR_IMPD_MTH_VALUES, + ), + // 저류수 처리방법 - { name: 'impdPrcsMthdCn', fieldName: 'impdPrcsMthdCn', header: { text: '저류수처리방법(자체처리,연계처리)' } }, + dropdownColumn( + { + name: "impdPrcsMthdCn", + fieldName: "impdPrcsMthdCn", + header: { text: "저류수처리방법(자체처리,연계처리)" }, + }, + SEWAGE_RESERVOIR_IMPD_PRCS_MTHD_VALUES, + ), // 방류 수역 { name: 'dschgWtrsExpln', fieldName: 'dschgWtrsExpln', header: { text: '방류수역(또는 연계처리장명)' } }, diff --git a/apps/st/src/components/util/grid/computedFieldCallbacks.ts b/apps/st/src/components/util/grid/computedFieldCallbacks.ts index 453cfa8c6..65aa811ca 100644 --- a/apps/st/src/components/util/grid/computedFieldCallbacks.ts +++ b/apps/st/src/components/util/grid/computedFieldCallbacks.ts @@ -179,6 +179,29 @@ export function createSwrgInstlrtRateValueCallback( }; } +/** 총양수량: Σ(펌프대수 + 예비펌프대수) × 대당 양수량 */ +export function createPumpGrossPpqtyValueCallback( + pumpGroups: Array<{ + pumpCntField: string; + rsrvPumpCntField: string; + perPpqtyField: string; + }>, +): DataFieldValueCallback { + return (_prod, _dataRow, _fieldName, fieldNamesArr, values) => { + let total = 0; + for (const group of pumpGroups) { + const pumpIdx = fieldNamesArr.indexOf(group.pumpCntField); + const rsrvIdx = fieldNamesArr.indexOf(group.rsrvPumpCntField); + const perIdx = fieldNamesArr.indexOf(group.perPpqtyField); + const pumpCnt = pumpIdx >= 0 ? toNum(values[pumpIdx]) : 0; + const rsrvCnt = rsrvIdx >= 0 ? toNum(values[rsrvIdx]) : 0; + const perPpqty = perIdx >= 0 ? toNum(values[perIdx]) : 0; + total += (pumpCnt + rsrvCnt) * perPpqty; + } + return total; + }; +} + /** 연산 타입에 맞는 valueCallback 반환 */ export function getComputedValueCallback(config: ComputedFieldConfig): DataFieldValueCallback { switch (config.type) { @@ -209,5 +232,7 @@ export function getComputedValueCallback(config: ComputedFieldConfig): DataField return createCoverageRateValueCallback(config.numeratorFields, config.denominatorFields); case 'swrgInstlrtRate': return createSwrgInstlrtRateValueCallback(config.numeratorFields, config.denominatorFields, config.thirdFields); + case 'pumpGrossPpqty': + return createPumpGrossPpqtyValueCallback(config.pumpGroups); } } diff --git a/apps/st/src/components/util/grid/ymdFormat.ts b/apps/st/src/components/util/grid/ymdFormat.ts index 888611424..16c98384e 100644 --- a/apps/st/src/components/util/grid/ymdFormat.ts +++ b/apps/st/src/components/util/grid/ymdFormat.ts @@ -5,11 +5,61 @@ export function formatCompactYmdForGrid(ymd: string | number | null | undefined) return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}`; } -/** 그리드 값 → API 8자리 일자(yyyyMMdd) */ -export function toCompactYmd(value: string | number | null | undefined): string { - const s = String(value ?? "").replace(/\D/g, ""); - if (s.length !== 8) return ""; - return s; +function isValidCompactYmdDigits(ymd: string): boolean { + if (!/^\d{8}$/.test(ymd)) return false; + const y = Number(ymd.slice(0, 4)); + const m = Number(ymd.slice(4, 6)); + const d = Number(ymd.slice(6, 8)); + if (y < 1000 || y > 9999 || m < 1 || m > 12 || d < 1 || d > 31) return false; + const dt = new Date(y, m - 1, d); + return dt.getFullYear() === y && dt.getMonth() === m - 1 && dt.getDate() === d; +} + +function formatDateToCompactYmd(date: Date): string { + if (Number.isNaN(date.getTime())) return ""; + const y = date.getFullYear(); + const m = date.getMonth() + 1; + const d = date.getDate(); + if (y < 1000 || y > 9999) return ""; + return `${y}${String(m).padStart(2, "0")}${String(d).padStart(2, "0")}`; +} + +/** + * 그리드·API 값 → 8자리 일자(yyyyMMdd). + * RealGrid datetime은 Date 객체로 올 수 있어, 숫자만 이어붙이는 방식은 사용하지 않습니다. + */ +export function toCompactYmd( + value: string | number | Date | null | undefined, +): string { + if (value == null || value === "") return ""; + + if (value instanceof Date) { + return formatDateToCompactYmd(value); + } + + if (typeof value === "number" && Number.isFinite(value)) { + if (value >= 19000101 && value <= 21001231) { + const compact = String(Math.trunc(value)).padStart(8, "0").slice(-8); + return isValidCompactYmdDigits(compact) ? compact : ""; + } + if (value > 1e11) { + return formatDateToCompactYmd(new Date(value)); + } + } + + const trimmed = String(value).trim(); + const isoLike = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})/); + if (isoLike) { + const compact = `${isoLike[1]}${isoLike[2]}${isoLike[3]}`; + return isValidCompactYmdDigits(compact) ? compact : ""; + } + + const digits = trimmed.replace(/\D/g, ""); + if (digits.length === 8 && isValidCompactYmdDigits(digits)) { + return digits; + } + + return ""; } /** 설치목적 등 O/X 표기 → 대문자 O 또는 X (그 외는 원문 유지) */ diff --git a/apps/st/src/entities/post/api/post-queries.ts b/apps/st/src/entities/post/api/post-queries.ts index 22c14e419..33809944c 100644 --- a/apps/st/src/entities/post/api/post-queries.ts +++ b/apps/st/src/entities/post/api/post-queries.ts @@ -2,30 +2,14 @@ import { useQuery } from "@tanstack/react-query"; import { ENDPOINT } from "@/shared/config"; -import type { RecentPostSearchParams } from "../model/post-interface"; +import type { UserPostSearchParams } from "../model/post-interface"; import { postApi } from "./post-service"; -export function useRecentPostList(params?: RecentPostSearchParams) { +export function useUserPostList(params?: UserPostSearchParams) { return useQuery({ - queryKey: createRecentPostListQueryKey(params), - queryFn: () => postApi.getRecentPostList(params), + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, params], + queryFn: () => postApi.getUserPostList(params), refetchOnWindowFocus: false, }); } - -function createRecentPostListQueryKey(params?: RecentPostSearchParams) { - return [ - ENDPOINT.BOARD_SERVICE.POSTS, - "recentPosts", - params?.page ?? "", - params?.size ?? "", - params?.sort ?? "", - params?.keywordType ?? "", - params?.keyword ?? "", - params?.bbsId ?? "", - params?.delYn ?? "", - params?.frDateYmd ?? "", - params?.toDateYmd ?? "", - ]; -} diff --git a/apps/st/src/entities/post/api/post-service.ts b/apps/st/src/entities/post/api/post-service.ts index 5a8a77df6..da66f8462 100644 --- a/apps/st/src/entities/post/api/post-service.ts +++ b/apps/st/src/entities/post/api/post-service.ts @@ -1,22 +1,22 @@ import { client } from "@/shared/api"; -import { ENDPOINT } from "@/shared/config"; +import { ENDPOINT, ST_DASHBOARD_BBS_ID } from "@/shared/config"; import type { PostService, - RecentPostListResponse, - RecentPostSearchParams, + UserPostListResponse, + UserPostSearchParams, } from "../model/post-interface"; -async function getRecentPostList( - params?: RecentPostSearchParams, -): Promise { - const response = await client.get( - `${ENDPOINT.BOARD_SERVICE.POSTS}/recentPosts`, +async function getUserPostList( + params?: UserPostSearchParams, +): Promise { + const response = await client.get( + `${ENDPOINT.BOARD_SERVICE.POSTS}/user/${ST_DASHBOARD_BBS_ID}`, { params }, ); return response.data; } export const postApi: PostService = { - getRecentPostList, + getUserPostList, }; diff --git a/apps/st/src/entities/post/index.ts b/apps/st/src/entities/post/index.ts index 451271145..cb9293561 100644 --- a/apps/st/src/entities/post/index.ts +++ b/apps/st/src/entities/post/index.ts @@ -1,8 +1,3 @@ -export { useRecentPostList } from "./api/post-queries"; +export { useUserPostList } from "./api/post-queries"; export { postApi } from "./api/post-service"; -export { - type PostService, - type PstRecentListResponse, - type RecentPostListResponse, - type RecentPostSearchParams, -} from "./model/post-interface"; +export { type PostService, type UserPost } from "./model/post-interface"; diff --git a/apps/st/src/entities/post/model/post-interface.ts b/apps/st/src/entities/post/model/post-interface.ts index 9282b2f35..fa9aa8fe5 100644 --- a/apps/st/src/entities/post/model/post-interface.ts +++ b/apps/st/src/entities/post/model/post-interface.ts @@ -1,15 +1,17 @@ import type { PageableList, PageableParams } from "@/shared/model"; -export interface RecentPostSearchParams extends PageableParams { +export interface UserPostSearchParams extends PageableParams { keywordType?: string; keyword?: string; - bbsId?: number; - delYn?: string; - frDateYmd?: string; - toDateYmd?: string; + popupYn?: string; + upndFixYn?: string; + prdPstgYn?: string; + siteId?: string; + bbsTypeCd?: string; + ansUseYn?: string; } -export interface PstRecentListResponse { +export interface UserPost { rowNo: number; bbsId: number; pstNo: number; @@ -29,14 +31,12 @@ export interface PstRecentListResponse { regDt: string; rgtrNm: string; cmntCnt: number; - bbsTypeCd: string; - bbsNm: string; } -export type RecentPostListResponse = PageableList; +export type UserPostListResponse = PageableList; export interface PostService { - getRecentPostList( - params?: RecentPostSearchParams, - ): Promise; + getUserPostList( + params?: UserPostSearchParams, + ): Promise; } diff --git a/apps/st/src/entities/statistics/api/input/statistics-input-sewage-reservoir-pump-service.ts b/apps/st/src/entities/statistics/api/input/statistics-input-sewage-reservoir-pump-service.ts index 3241efcb7..764a27cde 100644 --- a/apps/st/src/entities/statistics/api/input/statistics-input-sewage-reservoir-pump-service.ts +++ b/apps/st/src/entities/statistics/api/input/statistics-input-sewage-reservoir-pump-service.ts @@ -5,6 +5,8 @@ import { type StatisticsInputSewageReservoirPumpService, type SewageReservoirApiResponse, type SewageReservoirRequestParams, + type SewageReservoirSaveRequest, + type SewageReservoirSaveResponse, type RainfallPumpStationApiResponse, } from "../../model/input/statistics-input-sewage-reservoir-pump-interface"; @@ -28,6 +30,17 @@ export class StatisticsInputSewageReservoirPumpApiService implements StatisticsI ); return response.data; } + + /** 하수저류시설 저장 — ST00040001 */ + public async saveSewageReservoir( + request: SewageReservoirSaveRequest, + ): Promise { + const response = await stClient.put( + `${ENDPOINT.ST_SERVICE.INPUT}/sewage-reservoir-pump/sewage-reservoir/save`, + request, + ); + return response.data; + } } export const statisticsInputSewageReservoirPumpApi = new StatisticsInputSewageReservoirPumpApiService(); diff --git a/apps/st/src/entities/statistics/api/statistics-service.ts b/apps/st/src/entities/statistics/api/statistics-service.ts index 570bd9ce6..f8171d885 100644 --- a/apps/st/src/entities/statistics/api/statistics-service.ts +++ b/apps/st/src/entities/statistics/api/statistics-service.ts @@ -293,7 +293,7 @@ export class StatisticsApiService implements StatisticsService { ...(statsCd && statsCd !== "ALL" ? { statsCd } : {}), }; const response = await stClient.get( - `${ENDPOINT.ST_SERVICE.INPUTVERIFY}/results`, + `${ENDPOINT.ST_SERVICE.INPUTVERIFY}/results/input`, { params }, ); return response.data; diff --git a/apps/st/src/entities/statistics/api/verification-input-report-service.ts b/apps/st/src/entities/statistics/api/verification-input-report-service.ts new file mode 100644 index 000000000..b8fba2b79 --- /dev/null +++ b/apps/st/src/entities/statistics/api/verification-input-report-service.ts @@ -0,0 +1,42 @@ +import { getUserInfo } from "../../../../../../packages/common/components/src/api"; + +import { stClient } from "@/shared/api/client"; +import { ENDPOINT } from "@/shared/config"; + +import type { + VerificationInputReportRequest, + VerificationInputReportResponse, + VerificationInputReportService, +} from "../model/verification-input-report-interface"; + +/** 이 API 전용 헤더 키 (백엔드 스펙: `token-id`) */ +const TOKEN_ID_HEADER = "token-id"; + +export class VerificationInputReportApiService implements VerificationInputReportService { + public async getVerificationInputReport( + param: VerificationInputReportRequest, + ): Promise { + const userInfo = await getUserInfo(); + const { statsCd, page, size, ...rest } = param; + + const params: VerificationInputReportRequest = { + ...rest, + ...(statsCd && statsCd !== "ALL" ? { statsCd } : {}), + ...(page != null ? { page } : {}), + ...(size != null ? { size } : {}), + }; + + const response = await stClient.get( + `${ENDPOINT.ST_SERVICE.INPUTVERIFY}/results/input/report`, + { + params, + headers: { + [TOKEN_ID_HEADER]: userInfo.userUnqId, + }, + }, + ); + return response.data; + } +} + +export const verificationInputReportApi = new VerificationInputReportApiService(); diff --git a/apps/st/src/entities/statistics/model/input/statistics-input-sewage-reservoir-pump-interface.ts b/apps/st/src/entities/statistics/model/input/statistics-input-sewage-reservoir-pump-interface.ts index fc85abb5f..15885626c 100644 --- a/apps/st/src/entities/statistics/model/input/statistics-input-sewage-reservoir-pump-interface.ts +++ b/apps/st/src/entities/statistics/model/input/statistics-input-sewage-reservoir-pump-interface.ts @@ -28,7 +28,7 @@ export interface SewageReservoirCommonInfo { /** * 하수저류시설 조회 `data.sewageReservoirList[]` 한 행. - * `SewageReservoirInpColumn` fieldName과 동일 (ctpvNm은 commonInfo에서 그리드에 합침). + * `SewageReservoirInpColumn` fieldName과 동일 (`ctpvNm` 그리드 값은 `commonInfo.ctpvNm` 시도명). */ export interface SewageReservoirItem { admdstCd: string; @@ -166,9 +166,38 @@ export interface SewageReservoirRequestParams { fcltNm?: string; } +/** + * 하수저류시설(ST00040001) 저장 — `saveList` / `deleteList` 항목. + * `sggNm`·검증결과는 저장 요청에서 제외. + */ +export type SewageReservoirSaveItem = Omit< + SewageReservoirItem, + "sggNm" | "verificationResult" +>; + +/** 하수저류시설(ST00040001) 저장 API 요청 */ +export interface SewageReservoirSaveRequest { + statsYr: string; + ctpvNm: string; + sggNm: string; + sewerBzmnCd: string; + userId: string; + superAdmin?: string; + noApplicable?: boolean; + saveList: SewageReservoirSaveItem[]; + deleteList: SewageReservoirSaveItem[]; +} + +export interface SewageReservoirSaveResponse { + status: string; + message?: string; + data?: string; +} + export interface StatisticsInputSewageReservoirPumpService { getSewageReservoirList(params: SewageReservoirRequestParams): Promise; getRainfallPumpStationList( params: SewageReservoirRequestParams, ): Promise; + saveSewageReservoir(request: SewageReservoirSaveRequest): Promise; } diff --git a/apps/st/src/entities/statistics/model/statistics-interface.ts b/apps/st/src/entities/statistics/model/statistics-interface.ts index 53b9c74e6..4b001c8a0 100644 --- a/apps/st/src/entities/statistics/model/statistics-interface.ts +++ b/apps/st/src/entities/statistics/model/statistics-interface.ts @@ -159,10 +159,13 @@ export interface VerificationResultListRequest { ctpvNm?: string; sggNm?: string; statsCd?: string; - /** 검증 방법 코드 (예: IMR) */ + /** 검증 방법 코드 (예: NV, IMR, HB) */ vrfcMthdCd?: string; - /** 처리여부 코드 목록, 콤마 구분 */ + /** 처리여부 코드 목록, 콤마 구분 (예: 0,2) */ prcsYnList?: string; + /** 반려상태 코드 목록, 콤마 구분 (예: 0,1,2) */ + rejectCodeList?: string; + /** @deprecated 신규 API에서는 미사용 */ pbcpAprvYn?: string; page?: number; size?: number; @@ -172,7 +175,6 @@ export interface VerificationResultListRequest { export interface VerificationResultReportItem { statsYr: string; statsCd: string; - statsNm: string; sewerBzmnCd: string; ctpvNm: string; sggNm: string; @@ -181,18 +183,22 @@ export interface VerificationResultReportItem { vrfcMthdCd: string; vrfcMthdNm: string; vrfcTrgtNm: string; - regDt: string; rsnExpln: string; nowVlCn: string; compVlCn: string | null; prcsYn: string; prcsYnNm: string; prcsOpnnExpln: string | null; - prcrId: string | null; + stVrfRjtComCd: string; + stVrfRjtComNm: string; rjctOpnnCn: string | null; - pbcpAprvYn: string; - mdfrId: string | null; + regDt: string; mdfcnDt: string; + /** 하위 호환: 일부 API/화면에서만 내려올 수 있음 */ + statsNm?: string; + prcrId?: string | null; + pbcpAprvYn?: string; + mdfrId?: string | null; } export interface VerificationResultListPageInfo { diff --git a/apps/st/src/entities/statistics/model/verification-input-report-interface.ts b/apps/st/src/entities/statistics/model/verification-input-report-interface.ts new file mode 100644 index 000000000..0af515cbf --- /dev/null +++ b/apps/st/src/entities/statistics/model/verification-input-report-interface.ts @@ -0,0 +1,58 @@ +/** 이상치 선별 보고서 조회 요청 (`GET .../results/input/report`) */ +export interface VerificationInputReportRequest { + statsYr: string; + vrfcMthdCd: string; + statsCd?: string; + page?: number; + size?: number; +} + +export interface VerificationInputReportTypeCountingInfo { + nvCount: number; + imrCount: number; + hbCount: number; +} + +/** 이상치 선별 보고서 목록 한 행 */ +export interface VerificationInputReportItem { + statsYr: string; + statsCd: string; + /** 통계코드명 (타이틀 표시용) */ + statsNm?: string; + sewerBzmnCd: string; + ctpvNm: string; + sggNm: string | null; + vrfcSn: number; + vrfcMthdCd: string; + vrfcMthdNm: string; + vrfcTrgtNm: string; + rsnExpln: string; + nowVlCn: string; + compVlCn: string | null; + vrfcStatusNm: string; +} + +export interface VerificationInputReportPageInfo { + totalElements: number; + totalPages: number; + currentPage: number; + size: number; +} + +export interface VerificationInputReportData { + verifyTypeCountingInfo: VerificationInputReportTypeCountingInfo; + verifyResultList: VerificationInputReportItem[]; + pageInfo: VerificationInputReportPageInfo; +} + +export interface VerificationInputReportResponse { + status: string; + message?: string; + data?: VerificationInputReportData; +} + +export interface VerificationInputReportService { + getVerificationInputReport( + param: VerificationInputReportRequest, + ): Promise; +} diff --git a/apps/st/src/pages/statistics/ui/panels/notice-panel.tsx b/apps/st/src/pages/statistics/ui/panels/notice-panel.tsx index 1bd281b28..bb3804b6c 100644 --- a/apps/st/src/pages/statistics/ui/panels/notice-panel.tsx +++ b/apps/st/src/pages/statistics/ui/panels/notice-panel.tsx @@ -5,8 +5,9 @@ import { useRouter } from "next/navigation"; import { createColumnHelper, getCoreRowModel } from "@tanstack/react-table"; import dayjs from "dayjs"; +import { ST_DASHBOARD_BBS_ID } from "@/shared/config"; import { useDataTable } from "@/shared/lib"; -import { type PstRecentListResponse, useRecentPostList } from "@/entities/post"; +import { type UserPost, useUserPostList } from "@/entities/post"; import { Button } from "@common/components"; import { useDragHandle } from "../sortable-panel"; @@ -15,25 +16,23 @@ export function NoticePanel() { const { dragHandleProps, isDragging } = useDragHandle(); const router = useRouter(); - const recentPostListQuery = useRecentPostList({ + const userPostListQuery = useUserPostList({ page: 0, size: 4, keywordType: "bbsTypeCd", keyword: "BBSNOTICE", }); - const handleRowClick = (post: PstRecentListResponse) => { - router.push( - `/cms/${post.bbsTypeCd.toLowerCase()}/${post.bbsId}/${post.pstNo}`, - ); + const handleRowClick = (post: UserPost) => { + router.push(`/cms/bbsnotice/${ST_DASHBOARD_BBS_ID}/${post.pstNo}`); }; const handleMoreClick = () => { - router.push("/cms/bbsnotice/7"); + router.push(`/cms/bbsnotice/${ST_DASHBOARD_BBS_ID}`); }; const { tableComponent } = useDataTable({ - query: recentPostListQuery, + query: userPostListQuery, tableOptions: { columns: noticeColumns, getCoreRowModel: getCoreRowModel(), @@ -52,8 +51,11 @@ export function NoticePanel() { title="헤더를 드래그하여 패널 순서 변경" >

공지사항

+
+