diff --git a/apps/st/package.json b/apps/st/package.json index 7ae6c9f53..3919123ff 100644 --- a/apps/st/package.json +++ b/apps/st/package.json @@ -41,6 +41,7 @@ "chart.js": "^4.4.8", "chartjs-plugin-zoom": "^2.2.0", "dayjs": "^1.11.13", + "decimal.js": "^10.6.0", "js-cookie": "^3.0.5", "jszip": "^3.10.1", "lucide-react": "^0.474.0", diff --git a/apps/st/src/components/module/CompactDataGrid.tsx b/apps/st/src/components/module/CompactDataGrid.tsx index fad888a5f..7ffbaa1b3 100644 --- a/apps/st/src/components/module/CompactDataGrid.tsx +++ b/apps/st/src/components/module/CompactDataGrid.tsx @@ -7,6 +7,7 @@ import { RealGridReact, RGDataColumn } from "realgrid-react" import type { CellStyleCallback, CellStyleCallbackParams,CellStyleCallbackResult, DataGridProps, FooterItemWithCallback, GridViewWithSetColumns, RealGridColumnInfo } from "../type/DataGrid.type" import { getComputedValueCallback } from "@/components/util/grid/computedFieldCallbacks" +import { wrapWithFormatTruncate } from "@/components/util/grid/decimalNumber" function mergeCellStyleCallbackResult( baseStyleName: string | undefined, @@ -32,7 +33,7 @@ declare module "realgrid" { setOptions(options: { footer?: { visible?: boolean }; summaryMode?: 'aggregate' | 'statistical' | 'none'; - checkBar?: { visible?: boolean }; + checkBar?: { visible?: boolean; checkableOnly?: boolean }; }): void; } } @@ -49,10 +50,11 @@ type GridViewWithCheckApi = GridViewWithSetColumns & { checkItem?: (itemIndex: number, checked: boolean, exclusive?: boolean) => void; checkRow?: (dataRow: number, checked: boolean, exclusive?: boolean) => void; checkAll?: (checked: boolean) => void; + setCheckable?: (itemIndex: number, checkable: boolean) => void; }; /** columnInfo → RealGrid DataField 배열 (computed → valueCallback 포함, setFields용) */ -function buildFieldsFromColumns(columns: { fieldName: string; valueType?: ValueType; computed?: import("../type/DataGrid.type").ComputedFieldConfig }[]) { +function buildFieldsFromColumns(columns: { fieldName: string; valueType?: ValueType; dataFormat?: string; computed?: import("../type/DataGrid.type").ComputedFieldConfig }[]) { return columns.map((col) => { const field: { fieldName: string; dataType: string; valueCallback?: (prod: unknown, dataRow: number, fieldName: string, fieldNames: string[], values: unknown[]) => number } = { fieldName: col.fieldName, @@ -68,7 +70,7 @@ function buildFieldsFromColumns(columns: { fieldName: string; valueType?: ValueT const hasSumOfMaxGroups = "groups" in col.computed && Array.isArray(col.computed.groups) && col.computed.groups.length > 0; const hasSumPerTenThousand = col.computed.type === "sumPerTenThousand" && "numeratorFields" in col.computed && col.computed.numeratorFields.length > 0 && "denominatorField" in col.computed && Boolean(col.computed.denominatorField); if (hasFields || hasNumeratorDenominator || hasMaxOfFieldAndSum || hasDifferenceOfSums || hasHalfSumOfSums || hasSumOfTwoHalfSumOfSums || hasSumOfMaxGroups || hasSumPerTenThousand) { - field.valueCallback = getComputedValueCallback(col.computed) as (prod: unknown, dataRow: number, fieldName: string, fieldNames: string[], values: unknown[]) => number; + field.valueCallback = wrapWithFormatTruncate(getComputedValueCallback(col.computed), col.dataFormat); } } return field; @@ -138,6 +140,7 @@ const CompactDataGrid = ({ onRowClick, onItemChecked, onItemAllChecked, + checkableCallback, compactFullWidth = false, compactGridHeightClass = "h-[55vh]", headerHeight = 60, @@ -147,9 +150,11 @@ const CompactDataGrid = ({ const columnsRef = useRef(columns); const rowDataRef = useRef[]>(rowData); const cellStyleCallbackRef = useRef(cellStyleCallback); + const checkableCallbackRef = useRef(checkableCallback); columnsRef.current = columns; rowDataRef.current = rowData; cellStyleCallbackRef.current = cellStyleCallback; + checkableCallbackRef.current = checkableCallback; useLayoutEffect(() => { if (!cellStyleCallback || !gridRef.current?.gridView) return; @@ -178,8 +183,10 @@ const CompactDataGrid = ({ try { checkGrid.checkAll?.(false); rowData.forEach((row, itemIndex) => { + const checkable = checkableCallbackRef.current?.(row, itemIndex) ?? true; + checkGrid.setCheckable?.(itemIndex, checkable); const checked = checkByRowFlag((row as Record).__checked); - if (!checked) return; + if (!checked || !checkable) return; if (checkGrid.checkItem) { checkGrid.checkItem(itemIndex, true, false); return; @@ -276,7 +283,7 @@ const CompactDataGrid = ({ // 헤더 체크박스 표시 여부 설정 gridView.setOptions({ - checkBar: { visible: showHeaderCheckbox }, + checkBar: { visible: showHeaderCheckbox, checkableOnly: true }, rowIndicator: { visible: false }, }); @@ -499,6 +506,7 @@ const CompactDataGrid = ({ compactFullWidth, compactGridHeightClass, headerHeight, + checkableCallback, ]); // 행 클릭 이벤트 @@ -527,6 +535,17 @@ const CompactDataGrid = ({ if (onItemChecked) { gridView.onItemChecked = (_grid, itemIndex, checked) => { + const row = rowDataRef.current[itemIndex]; + if ( + row && + checkableCallbackRef.current && + !checkableCallbackRef.current(row, itemIndex) + ) { + if (checked) { + (_grid as GridViewWithCheckApi).checkItem?.(itemIndex, false, false); + } + return; + } onItemChecked(itemIndex, checked); }; } else { diff --git a/apps/st/src/components/module/DataGrid.tsx b/apps/st/src/components/module/DataGrid.tsx index e679a75fa..fea761a3f 100644 --- a/apps/st/src/components/module/DataGrid.tsx +++ b/apps/st/src/components/module/DataGrid.tsx @@ -24,6 +24,7 @@ import type { } from "../type/DataGrid.type"; import { getComputedValueCallback } from "@/components/util/grid/computedFieldCallbacks"; +import { wrapWithFormatTruncate } from "@/components/util/grid/decimalNumber"; import { buildExcelExportFileBaseName, buildFixedColumnNameSet, @@ -251,7 +252,7 @@ declare module "realgrid" { setOptions(options: { footer?: { visible?: boolean }; summaryMode?: "aggregate" | "statistical" | "none"; - checkBar?: { visible?: boolean; exclusive?: boolean }; + checkBar?: { visible?: boolean; exclusive?: boolean; syncHeadCheck?: boolean }; }): void; } } @@ -409,7 +410,13 @@ function reapplyListEditorsOnGrid( type GridViewWithCheckApi = { checkItem?: (itemIndex: number, checked: boolean, exclusive?: boolean) => void; checkRow?: (dataRow: number, checked: boolean, exclusive?: boolean) => void; - checkAll?: (checked: boolean) => void; + checkAll?: ( + checked: boolean, + visibleOnly?: boolean, + checkableOnly?: boolean, + checkEvent?: boolean, + ) => void; + setAllCheck?: (checked: boolean) => void; }; function isRowCheckedFlag(value: unknown): boolean { @@ -689,26 +696,6 @@ 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 decimalToken.length; - // #,##0 등 정수 포맷 — 소수부 없음 → 0자리 절삭(내림) - if (/[#0]/.test(dataFormat)) return 0; - return null; -} - -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; @@ -823,33 +810,10 @@ function buildFieldsFromColumns( hasQuotientRatioPercent || hasQuotientTimesMultiplierPercent ) { - const computedCallback = getComputedValueCallback( - col.computed, - ) as ( - prod: unknown, - dataRow: number, - fieldName: string, - 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); - }; + field.valueCallback = wrapWithFormatTruncate( + getComputedValueCallback(col.computed), + col.dataFormat, + ); } } return field; @@ -1047,7 +1011,17 @@ const DataGrid = ({ if (!dp?.setRows) return; try { gv?.commit?.(); } catch { try { gv?.cancel?.(); } catch { /* noop */ } } dp.setRows(rowData); - }, [rowData]); + if (showHeaderCheckbox) { + const checkGrid = gridRef.current?.gridView as GridViewWithCheckApi | undefined; + isSyncingChecksFromRowDataRef.current = true; + try { + checkGrid?.checkAll?.(false, false, false, false); + checkGrid?.setAllCheck?.(false); + } finally { + isSyncingChecksFromRowDataRef.current = false; + } + } + }, [rowData, showHeaderCheckbox]); // highlightedRowId가 변경될 때 해당 행으로 스크롤 및 선택 useEffect(() => { @@ -1634,7 +1608,7 @@ useEffect(() => { // 헤더 체크박스 표시 여부 설정 gridView.setOptions({ - checkBar: { visible: showHeaderCheckbox, exclusive: false }, + checkBar: { visible: showHeaderCheckbox, exclusive: false, syncHeadCheck: true }, rowIndicator: { visible: false }, }); @@ -2054,14 +2028,10 @@ useEffect(() => { gridView.refresh?.(); }, [footerVerificationResult, columns]); - // 행 클릭 이벤트 + 검증 오류 셀 클릭 시 showMessage (공식 문서 방식) + // 행·셀 클릭 이벤트 (검증 오류 툴팁은 hover만 — onShowTooltip / 빈 셀 오버레이) useEffect(() => { const gridView = gridRef.current?.gridView; - if ( - !gridView || - (!onRowClick && !cellTooltipCallback && !onDataCellClick) - ) - return; + if (!gridView || (!onRowClick && !onDataCellClick)) return; gridView.onCellClicked = (grid, clickData) => { if ( @@ -2082,46 +2052,6 @@ useEffect(() => { ).getCurrent?.(); const fieldName = cellIndex?.fieldName ?? cellIndex?.column ?? ""; - // cellTooltipCallback 있으면 해당 셀 메시지로 showMessage (공식 문서: TooltipOptions + CellIndex) - // 빈 셀은 hover 오버레이로만 표시하므로 클릭 showMessage는 생략 (이중 표출 방지) - if (cellTooltipCallbackRef.current && row && cellIndex) { - if (!isEmptyGridCellValue(row[fieldName])) { - const msg = cellTooltipCallbackRef.current({ - row: row as Record, - fieldName, - }); - if (msg) { - const colName = - columns.find( - (c) => - c.fieldName === fieldName || - c.name === cellIndex.column, - )?.name ?? - cellIndex.column ?? - fieldName; - ( - gridView as { - showMessage?: ( - opts: { - message: string; - hideDuration?: number; - }, - idx: { - itemIndex: number; - column: string; - }, - ) => void; - } - ).showMessage?.( - { message: msg, hideDuration: 5000 }, - { - itemIndex: cellIndex.itemIndex, - column: colName, - }, - ); - } - } - } if (onDataCellClickRef.current && row !== undefined) { onDataCellClickRef.current({ row: (row ?? {}) as Record, @@ -2139,7 +2069,7 @@ useEffect(() => { return () => { if (gridView.onCellClicked) gridView.onCellClicked = undefined; }; - }, [onRowClick, onDataCellClick, cellTooltipCallback, columns]); + }, [onRowClick, onDataCellClick]); // RealGrid 네이티브 툴팁은 데이터 셀만 지원 → 빈 셀·총계 footer는 React 오버레이로 보완 useEffect(() => { @@ -2404,15 +2334,13 @@ useEffect(() => { const gridView = gridRef.current.gridView as GridViewWithCheckApi; - const hasCheckedColumn = rowData.some((row) => - Object.prototype.hasOwnProperty.call(row, "__checked"), - ); - if (!hasCheckedColumn) return; - + // rowData 교체(조회/저장/제출/항목삭제) 시 헤더·행 체크를 해제한다. + // `__checked`가 있는 행만 다시 체크한다. 없으면 전부 해제 상태로 둔다. const timer = window.setTimeout(() => { isSyncingChecksFromRowDataRef.current = true; try { - gridView.checkAll?.(false); + gridView.checkAll?.(false, false, false, false); + gridView.setAllCheck?.(false); rowData.forEach((row, itemIndex) => { if (!isRowCheckedFlag((row as Record).__checked)) return; if (gridView.checkItem) { diff --git a/apps/st/src/components/module/SplitDataGrid.tsx b/apps/st/src/components/module/SplitDataGrid.tsx index d7e33ea9b..75f8d2988 100644 --- a/apps/st/src/components/module/SplitDataGrid.tsx +++ b/apps/st/src/components/module/SplitDataGrid.tsx @@ -16,6 +16,7 @@ const SplitDataGrid = ({ leftCellStyleCallback, rightOnItemChecked, rightOnItemAllChecked, + rightCheckableCallback, enableSort = false, }: SplitDataGridProps) => { return ( @@ -39,6 +40,7 @@ const SplitDataGrid = ({ setRowData={setRightRowData} onItemChecked={rightOnItemChecked} onItemAllChecked={rightOnItemAllChecked} + checkableCallback={rightCheckableCallback} /> ); diff --git a/apps/st/src/components/module/TabDataGrid.tsx b/apps/st/src/components/module/TabDataGrid.tsx index 4b6b14bbc..04b372333 100644 --- a/apps/st/src/components/module/TabDataGrid.tsx +++ b/apps/st/src/components/module/TabDataGrid.tsx @@ -44,6 +44,7 @@ function mergeCellStyleCallbackResult( import SplitDataGrid from "./SplitDataGrid"; import { getComputedValueCallback } from "@/components/util/grid/computedFieldCallbacks" +import { wrapWithFormatTruncate } from "@/components/util/grid/decimalNumber" import { buildExcelExportFileBaseName, buildFixedColumnNameSet, @@ -252,7 +253,7 @@ function sortRowsGroupPreserving( } /** columnInfo → RealGrid DataField 배열 (computed → valueCallback 포함, setFields용) */ -function buildFieldsFromColumns(columns: { fieldName: string; valueType?: ValueType; computed?: import("../type/DataGrid.type").ComputedFieldConfig }[]) { +function buildFieldsFromColumns(columns: { fieldName: string; valueType?: ValueType; dataFormat?: string; computed?: import("../type/DataGrid.type").ComputedFieldConfig }[]) { return columns.map((col) => { const field: { fieldName: string; dataType: string; valueCallback?: (prod: unknown, dataRow: number, fieldName: string, fieldNames: string[], values: unknown[]) => number } = { fieldName: col.fieldName, @@ -268,7 +269,7 @@ function buildFieldsFromColumns(columns: { fieldName: string; valueType?: ValueT const hasSumOfMaxGroups = "groups" in col.computed && Array.isArray(col.computed.groups) && col.computed.groups.length > 0; const hasSumPerTenThousand = col.computed.type === "sumPerTenThousand" && "numeratorFields" in col.computed && col.computed.numeratorFields.length > 0 && "denominatorField" in col.computed && Boolean(col.computed.denominatorField); if (hasFields || hasNumeratorDenominator || hasMaxOfFieldAndSum || hasDifferenceOfSums || hasHalfSumOfSums || hasSumOfTwoHalfSumOfSums || hasSumOfMaxGroups || hasSumPerTenThousand) { - field.valueCallback = getComputedValueCallback(col.computed) as (prod: unknown, dataRow: number, fieldName: string, fieldNames: string[], values: unknown[]) => number; + field.valueCallback = wrapWithFormatTruncate(getComputedValueCallback(col.computed), col.dataFormat); } } return field; diff --git a/apps/st/src/components/statistics/input/PublicSewageFacilityInp.tsx b/apps/st/src/components/statistics/input/PublicSewageFacilityInp.tsx index fa5892179..d6b4152cb 100644 --- a/apps/st/src/components/statistics/input/PublicSewageFacilityInp.tsx +++ b/apps/st/src/components/statistics/input/PublicSewageFacilityInp.tsx @@ -312,6 +312,28 @@ const PublicSewageFacilityInp = () => { return v === 1 || v === '1' || v === 'Y' || v === 'y' || v === 'true'; }; + const isSelfMappingTarget = ( + row: Record, + mainFcltSn: string | null, + ): boolean => Boolean(mainFcltSn) && String(row.fcltSn ?? '') === mainFcltSn; + + const hasCheckedSelfMapping = ( + rows: Record[], + mainFcltSn: string | null, + ): boolean => rows.some((r) => isSelfMappingTarget(r, mainFcltSn) && isCheckedRow(r)); + + /** 왼쪽에서 선택한 시설을 오른쪽에서 본인 체크하면 다른 시설은 해제 */ + const applyExclusiveSelfMappingChecks = ( + rows: Record[], + mainFcltSn: string | null, + ): Record[] => { + if (!hasCheckedSelfMapping(rows, mainFcltSn)) return rows; + return rows.map((r) => ({ + ...r, + __checked: isSelfMappingTarget(r, mainFcltSn) ? 'Y' : '', + })); + }; + const executeSubmit = async (submitInfo: StatisticsInputSubmitInfoFormData) => { const verificationBlocked = getVerificationPeriodActionBlockedMessage(periodCheckList); if (verificationBlocked) { @@ -520,19 +542,36 @@ const PublicSewageFacilityInp = () => { return; } - const saveList: PublicSewageMainFacilityMappingSaveItem[] = rightRows - .filter((r) => isCheckedRow(r)) - .map((r) => ({ - fcltSn: nullableTrimmedString(r.fcltSn), - admdstCd: nullableTrimmedString(r.admdstCd), - admdstNm: nullableTrimmedString(r.admdstNm), - swtrmtFcltCd: nullableTrimmedString(r.swtrmtFcltCd), - swtrmtFcltNm: nullableTrimmedString(r.swtrmtFcltNm), - isMainYn: nullableTrimmedString(r.isMainYn), - })); + const checkedRows = rightRows.filter((r) => isCheckedRow(r)); + const exclusiveSelf = hasCheckedSelfMapping(rightRows, selectedMainFacilitySn); + const saveList: PublicSewageMainFacilityMappingSaveItem[] = ( + exclusiveSelf + ? checkedRows.filter((r) => isSelfMappingTarget(r, selectedMainFacilitySn)) + : checkedRows + ).map((r) => ({ + fcltSn: nullableTrimmedString(r.fcltSn), + admdstCd: nullableTrimmedString(r.admdstCd), + admdstNm: nullableTrimmedString(r.admdstNm), + swtrmtFcltCd: nullableTrimmedString(r.swtrmtFcltCd), + swtrmtFcltNm: nullableTrimmedString(r.swtrmtFcltNm), + isMainYn: nullableTrimmedString(r.isMainYn), + })); + + const exclusiveDroppedSns = exclusiveSelf + ? new Set( + checkedRows + .filter((r) => !isSelfMappingTarget(r, selectedMainFacilitySn)) + .map((r) => String(r.fcltSn ?? '')) + .filter((sn) => sn !== ''), + ) + : new Set(); const deleteList = rightRows - .filter((r) => String(r.mappedYn ?? '') === 'Y' && !isCheckedRow(r)) + .filter((r) => { + const sn = String(r.fcltSn ?? ''); + if (String(r.mappedYn ?? '') === 'Y' && !isCheckedRow(r)) return true; + return exclusiveDroppedSns.has(sn) && String(r.mappedYn ?? '') === 'Y'; + }) .map((r) => String(r.fcltSn ?? '')) .filter((sn) => sn !== ''); @@ -1132,10 +1171,13 @@ const PublicSewageFacilityInp = () => { if (rawList.length === 0) { alert('해당 시설은 중심처리시설의 위탁시설로 연결되어있습니다.'); } - const mappedTargetRows = rawList.map((item) => ({ - ...item, - __checked: item.mappedYn === 'Y' ? 'Y' : '', - })); + const mappedTargetRows = applyExclusiveSelfMappingChecks( + rawList.map((item) => ({ + ...item, + __checked: item.mappedYn === 'Y' ? 'Y' : '', + })), + fcltSn, + ); setRowDataMap((prev) => { const next = new Map(prev); next.set('MainFacilityTarget', toGridRows(mappedTargetRows)); @@ -1318,11 +1360,25 @@ const PublicSewageFacilityInp = () => { const next = new Map(prev); const rows = [...(next.get('MainFacilityTarget') ?? [])]; if (!rows[itemIndex]) return prev; + const clicked = rows[itemIndex]; + if ( + checked && + hasCheckedSelfMapping(rows, selectedMainFacilitySn) && + !isSelfMappingTarget(clicked, selectedMainFacilitySn) + ) { + return prev; + } rows[itemIndex] = { - ...rows[itemIndex], + ...clicked, __checked: checked ? 'Y' : '', }; - next.set('MainFacilityTarget', rows); + next.set( + 'MainFacilityTarget', + applyExclusiveSelfMappingChecks( + rows, + selectedMainFacilitySn, + ), + ); return next; }); } @@ -1333,16 +1389,59 @@ const PublicSewageFacilityInp = () => { ? (checked) => { setRowDataMap((prev) => { const next = new Map(prev); - const rows = [...(next.get('MainFacilityTarget') ?? [])].map((r) => ({ - ...r, - __checked: checked ? 'Y' : '', - })); - next.set('MainFacilityTarget', rows); + const rows = [...(next.get('MainFacilityTarget') ?? [])]; + if ( + checked && + hasCheckedSelfMapping(rows, selectedMainFacilitySn) + ) { + next.set( + 'MainFacilityTarget', + applyExclusiveSelfMappingChecks( + rows, + selectedMainFacilitySn, + ), + ); + return next; + } + next.set( + 'MainFacilityTarget', + rows.map((r) => ({ + ...r, + __checked: + checked && + !isSelfMappingTarget( + r, + selectedMainFacilitySn, + ) + ? 'Y' + : '', + })), + ); return next; }); } : undefined } + rightCheckableCallback={ + tab.tabKey === 'ST00060002' + ? (row) => { + const rows = + rowDataMap.get('MainFacilityTarget') ?? []; + if ( + !hasCheckedSelfMapping( + rows, + selectedMainFacilitySn, + ) + ) { + return true; + } + return isSelfMappingTarget( + row, + selectedMainFacilitySn, + ); + } + : undefined + } setLeftRowData={(updater) => { setRowDataMap((prev) => { const next = new Map(prev); diff --git a/apps/st/src/components/type/DataGrid.type.ts b/apps/st/src/components/type/DataGrid.type.ts index 8f286df05..19c233180 100644 --- a/apps/st/src/components/type/DataGrid.type.ts +++ b/apps/st/src/components/type/DataGrid.type.ts @@ -418,6 +418,11 @@ export type DataGridProps = { onRowsChange?: (rows: Record[]) => void; fixedColumns?: number; // 고정할 컬럼 개수 (좌측부터) showHeaderCheckbox?: boolean; // 헤더 체크박스 표시 여부 (기본값: true) + /** CheckBar 행별 체크 가능 여부 (false면 해당 행 체크박스 비활성) */ + checkableCallback?: ( + row: Record, + itemIndex: number, + ) => boolean; rowData: Record[]; setRowData: React.Dispatch< React.SetStateAction[]> @@ -532,6 +537,11 @@ export type SplitDataGridProps = { rightOnItemChecked?: (itemIndex: number, checked: boolean) => void; /** 우측 분할 그리드 헤더 전체 체크 변경 */ rightOnItemAllChecked?: (checked: boolean) => void; + /** 우측 분할 그리드 행별 체크 가능 여부 */ + rightCheckableCallback?: ( + row: Record, + itemIndex: number, + ) => boolean; /** 전체 정렬 기능 on/off (기본값: false) */ enableSort?: boolean; }; diff --git a/apps/st/src/components/util/column/input/PublicSewageFactInpColumn/MainFacilityInpColumn.ts b/apps/st/src/components/util/column/input/PublicSewageFactInpColumn/MainFacilityInpColumn.ts index 14cb5281d..31c33a0fd 100644 --- a/apps/st/src/components/util/column/input/PublicSewageFactInpColumn/MainFacilityInpColumn.ts +++ b/apps/st/src/components/util/column/input/PublicSewageFactInpColumn/MainFacilityInpColumn.ts @@ -70,7 +70,7 @@ export const MainFacilityTargetColumn = { export const MainFacilityInpColumn = { tabKey: 'ST00060002', - tabLabel: '중심 처리 시설', + tabLabel: '위탁 관리 입력', isSplitGrid: true, left: MainFacilityColumn, right: MainFacilityTargetColumn, diff --git a/apps/st/src/components/util/grid/computedFieldCallbacks.ts b/apps/st/src/components/util/grid/computedFieldCallbacks.ts index 245974fc9..b38d546ee 100644 --- a/apps/st/src/components/util/grid/computedFieldCallbacks.ts +++ b/apps/st/src/components/util/grid/computedFieldCallbacks.ts @@ -1,4 +1,13 @@ -import type { ComputedFieldConfig,DataFieldValueCallback } from "@/components/type/DataGrid.type"; +import Decimal from "decimal.js"; + +import type { ComputedFieldConfig, DataFieldValueCallback } from "@/components/type/DataGrid.type"; + +import { + decimalToGridNumber, + sumDecimals, + toDecimal, + truncDecimal, +} from "@/components/util/grid/decimalNumber"; /** 값 → 숫자 변환 (null/undefined/빈문자열 → 0) */ function toNum(v: unknown): number { @@ -8,78 +17,41 @@ function toNum(v: unknown): number { return isNaN(n) ? 0 : n; } -/** 소수점 첫째 자리 이후 버림 (반올림 없음) */ -function truncateToFirstDecimal(value: number): number { - if (!Number.isFinite(value)) return 0; - return Math.floor(value * 10) / 10; -} - -/** computed sum용 스케일 (표시 최대 소수 3자리 + 여유) */ -const SUM_DECIMAL_SCALE = 1e6; - -/** - * 스케일 정수 합을 Number로 되돌릴 때 생기는 IEEE 오차 보정. - * 39057.34는 이진수로 39057.33999… 가 되어 RealGrid `#,##0.00` 절사 시 39057.33이 된다. - * 1e-12는 이 값에서 *100 경계를 넘지 못하므로, 값 크기에 비례한 ULP만큼 밀어 올린다. - */ -function numberFromScaledSum(scaledSum: number): number { - if (scaledSum === 0) return 0; - const raw = scaledSum / SUM_DECIMAL_SCALE; - const ulp = Number.EPSILON * Math.max(1, Math.abs(raw)); - return raw + Math.sign(raw) * ulp * 8; -} - -/** - * 각 값을 소수 6자리 정수로 올린 뒤 더한 다음 다시 나눈다. - * 예: 2712.5 + 6291.13 → (2712500000 + 6291130000) / 1e6 = 9003.63 - */ -function sumViaScaledIntegers(nums: number[]): number { - let scaledSum = 0; - for (const n of nums) { - if (!Number.isFinite(n)) continue; - scaledSum += Math.round(n * SUM_DECIMAL_SCALE); - } - return numberFromScaledSum(scaledSum); -} - -/** 합계용 valueCallback 생성 */ -export function createSumValueCallback(fieldNames: string[]): DataFieldValueCallback { - return (_prod, _dataRow, _fieldName, fieldNamesArr, values) => { - const nums: number[] = []; - for (const f of fieldNames) { - const idx = fieldNamesArr.indexOf(f); - if (idx >= 0) nums.push(toNum(values[idx])); - } - return sumViaScaledIntegers(nums); - }; -} - -/** 평균용 valueCallback 생성 */ -export function createAvgValueCallback(fieldNames: string[]): DataFieldValueCallback { - return (_prod, _dataRow, _fieldName, fieldNamesArr, values) => { - const nums: number[] = []; - for (const f of fieldNames) { - const idx = fieldNamesArr.indexOf(f); - if (idx >= 0) { - nums.push(toNum(values[idx])); - } - } - if (nums.length === 0) return 0; - return sumViaScaledIntegers(nums) / nums.length; - }; -} - -function sumFieldValues( +function fieldNums( fields: string[], fieldNamesArr: string[], values: unknown[], -): number { +): number[] { const nums: number[] = []; for (const f of fields) { const idx = fieldNamesArr.indexOf(f); if (idx >= 0) nums.push(toNum(values[idx])); } - return sumViaScaledIntegers(nums); + return nums; +} + +function fieldDecimal( + field: string, + fieldNamesArr: string[], + values: unknown[], +): Decimal { + const idx = fieldNamesArr.indexOf(field); + return toDecimal(idx >= 0 ? toNum(values[idx]) : 0); +} + +/** 합계용 valueCallback 생성 */ +export function createSumValueCallback(fieldNames: string[]): DataFieldValueCallback { + return (_prod, _dataRow, _fieldName, fieldNamesArr, values) => + decimalToGridNumber(sumDecimals(fieldNums(fieldNames, fieldNamesArr, values))); +} + +/** 평균용 valueCallback 생성 */ +export function createAvgValueCallback(fieldNames: string[]): DataFieldValueCallback { + return (_prod, _dataRow, _fieldName, fieldNamesArr, values) => { + const nums = fieldNums(fieldNames, fieldNamesArr, values); + if (nums.length === 0) return 0; + return decimalToGridNumber(sumDecimals(nums).div(nums.length)); + }; } /** halfSumOfSums 한 건 — (sum(first) + sum(second)) / 2, 소수 내림 */ @@ -89,9 +61,9 @@ function computeHalfSumOfSums( fieldNamesArr: string[], values: unknown[], ): number { - const firstSum = sumFieldValues(firstSumFields, fieldNamesArr, values); - const secondSum = sumFieldValues(secondSumFields, fieldNamesArr, values); - return Math.floor((firstSum + secondSum) / 2); + const firstSum = sumDecimals(fieldNums(firstSumFields, fieldNamesArr, values)); + const secondSum = sumDecimals(fieldNums(secondSumFields, fieldNamesArr, values)); + return firstSum.plus(secondSum).div(2).floor().toNumber(); } export type HalfSumOfSumsGroup = { @@ -134,11 +106,9 @@ export function createDifferenceValueCallback(fields: string[]): DataFieldValueC const b = fields[1] ?? ""; return (_prod, _dataRow, _fieldName, fieldNamesArr, values) => { if (!a || !b) return 0; - const ia = fieldNamesArr.indexOf(a); - const ib = fieldNamesArr.indexOf(b); - const va = ia >= 0 ? toNum(values[ia]) : 0; - const vb = ib >= 0 ? toNum(values[ib]) : 0; - return va - vb; + return decimalToGridNumber( + fieldDecimal(a, fieldNamesArr, values).minus(fieldDecimal(b, fieldNamesArr, values)), + ); }; } @@ -148,15 +118,9 @@ export function createDifferenceOfSumsValueCallback( subtrahendFields: string[], ): DataFieldValueCallback { return (_prod, _dataRow, _fieldName, fieldNamesArr, values) => { - const sumOf = (fields: string[]) => { - let sum = 0; - for (const f of fields) { - const idx = fieldNamesArr.indexOf(f); - if (idx >= 0) sum += toNum(values[idx]); - } - return sum; - }; - return sumOf(minuendFields) - sumOf(subtrahendFields); + const minuend = sumDecimals(fieldNums(minuendFields, fieldNamesArr, values)); + const subtrahend = sumDecimals(fieldNums(subtrahendFields, fieldNamesArr, values)); + return decimalToGridNumber(minuend.minus(subtrahend)); }; } @@ -181,14 +145,9 @@ export function createMaxOfFieldAndSumValueCallback( sumFields: string[], ): DataFieldValueCallback { return (_prod, _dataRow, _fieldName, fieldNamesArr, values) => { - const baseIdx = fieldNamesArr.indexOf(baseField); - const baseVal = baseIdx >= 0 ? toNum(values[baseIdx]) : 0; - let sumVal = 0; - for (const f of sumFields) { - const idx = fieldNamesArr.indexOf(f); - if (idx >= 0) sumVal += toNum(values[idx]); - } - return Math.max(baseVal, sumVal); + const baseVal = fieldDecimal(baseField, fieldNamesArr, values); + const sumVal = sumDecimals(fieldNums(sumFields, fieldNamesArr, values)); + return decimalToGridNumber(Decimal.max(baseVal, sumVal)); }; } @@ -197,18 +156,13 @@ export function createSumOfMaxOfFieldAndSumGroupsValueCallback( groups: Array<{ baseField: string; sumFields: string[] }>, ): DataFieldValueCallback { return (_prod, _dataRow, _fieldName, fieldNamesArr, values) => { - let total = 0; + let total = new Decimal(0); for (const group of groups) { - const baseIdx = fieldNamesArr.indexOf(group.baseField); - const baseVal = baseIdx >= 0 ? toNum(values[baseIdx]) : 0; - let sumVal = 0; - for (const f of group.sumFields) { - const idx = fieldNamesArr.indexOf(f); - if (idx >= 0) sumVal += toNum(values[idx]); - } - total += Math.max(baseVal, sumVal); + const baseVal = fieldDecimal(group.baseField, fieldNamesArr, values); + const sumVal = sumDecimals(fieldNums(group.sumFields, fieldNamesArr, values)); + total = total.plus(Decimal.max(baseVal, sumVal)); } - return total; + return decimalToGridNumber(total); }; } @@ -218,17 +172,10 @@ export function createCoverageRateValueCallback( denominatorFields: string[] ): DataFieldValueCallback { return (_prod, _dataRow, _fieldName, fieldNamesArr, values) => { - const getSum = (fields: string[]) => { - let sum = 0; - for (const f of fields) { - const idx = fieldNamesArr.indexOf(f); - if (idx >= 0) sum += toNum(values[idx]); - } - return sum; - }; - const total2 = getSum(numeratorFields); - const total1 = getSum(denominatorFields); - return total1 === 0 ? 0 : (total2 / total1) * 100; + const total2 = sumDecimals(fieldNums(numeratorFields, fieldNamesArr, values)); + const total1 = sumDecimals(fieldNums(denominatorFields, fieldNamesArr, values)); + if (total1.isZero()) return 0; + return decimalToGridNumber(total2.div(total1).times(100)); }; } @@ -239,28 +186,27 @@ export function createSwrgInstlrtRateValueCallback( thirdFields: string[], ): DataFieldValueCallback { return (_prod, _dataRow, _fieldName, fieldNamesArr, values) => { + const trunc1 = (d: Decimal) => truncDecimal(d, 1); const getSum = (fields: string[]) => { - let sum = 0; + let sum = new Decimal(0); for (const f of fields) { const idx = fieldNamesArr.indexOf(f); if (idx >= 0) { - sum += truncateToFirstDecimal(toNum(values[idx])); + sum = sum.plus(trunc1(toDecimal(toNum(values[idx])))); } } - return truncateToFirstDecimal(sum); + return trunc1(sum); }; const sum1 = getSum(numeratorFields); const sum2 = getSum(denominatorFields); + const piplSupprt = trunc1(fieldDecimal(thirdFields[0] || "piplSupprt", fieldNamesArr, values)); - const piplSupprt = truncateToFirstDecimal( - toNum(values[fieldNamesArr.indexOf(thirdFields[0] || "piplSupprt")]), - ); + const ratioPercent = sum1.isZero() + ? new Decimal(0) + : trunc1(sum2.div(sum1).times(100)); + const result = trunc1(ratioPercent.times(piplSupprt).div(100)); - const ratioPercent = - sum1 === 0 ? 0 : truncateToFirstDecimal((sum2 / sum1) * 100); - const result = truncateToFirstDecimal((ratioPercent * piplSupprt) / 100); - - return result; + return decimalToGridNumber(result); }; } @@ -270,12 +216,10 @@ export function createBodPrcsRtValueCallback( dschgField: string, ): DataFieldValueCallback { return (_prod, _dataRow, _fieldName, fieldNamesArr, values) => { - const inflwIdx = fieldNamesArr.indexOf(inflwField); - const dschgIdx = fieldNamesArr.indexOf(dschgField); - const inflw = inflwIdx >= 0 ? toNum(values[inflwIdx]) : 0; - const dschg = dschgIdx >= 0 ? toNum(values[dschgIdx]) : 0; - if (inflw === 0) return 0; - return (inflw - dschg) / inflw; + const inflw = fieldDecimal(inflwField, fieldNamesArr, values); + const dschg = fieldDecimal(dschgField, fieldNamesArr, values); + if (inflw.isZero()) return 0; + return decimalToGridNumber(inflw.minus(dschg).div(inflw)); }; } @@ -288,17 +232,14 @@ export function createPumpGrossPpqtyValueCallback( }>, ): DataFieldValueCallback { return (_prod, _dataRow, _fieldName, fieldNamesArr, values) => { - let total = 0; + let total = new Decimal(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; + const pumpCnt = fieldDecimal(group.pumpCntField, fieldNamesArr, values); + const rsrvCnt = fieldDecimal(group.rsrvPumpCntField, fieldNamesArr, values); + const perPpqty = fieldDecimal(group.perPpqtyField, fieldNamesArr, values); + total = total.plus(pumpCnt.plus(rsrvCnt).times(perPpqty)); } - return total; + return decimalToGridNumber(total); }; } @@ -310,18 +251,14 @@ export function createQuotientRatioPercentValueCallback( const [numField, numDenField] = numeratorQuotient; const [denNumField, denDenField] = denominatorQuotient; return (_prod, _dataRow, _fieldName, fieldNamesArr, values) => { - const getVal = (field: string) => { - const idx = fieldNamesArr.indexOf(field); - return idx >= 0 ? toNum(values[idx]) : 0; - }; - const numDen = getVal(numDenField); - const denDen = getVal(denDenField); - const denNum = getVal(denNumField); - if (numDen === 0 || denDen === 0 || denNum === 0) return 0; - const numerator = getVal(numField) / numDen; - const denominator = denNum / denDen; - if (denominator === 0) return 0; - return (numerator / denominator) * 100; + const numDen = fieldDecimal(numDenField, fieldNamesArr, values); + const denDen = fieldDecimal(denDenField, fieldNamesArr, values); + const denNum = fieldDecimal(denNumField, fieldNamesArr, values); + if (numDen.isZero() || denDen.isZero() || denNum.isZero()) return 0; + const numerator = fieldDecimal(numField, fieldNamesArr, values).div(numDen); + const denominator = denNum.div(denDen); + if (denominator.isZero()) return 0; + return decimalToGridNumber(numerator.div(denominator).times(100)); }; } @@ -331,11 +268,10 @@ export function createSumPerTenThousandValueCallback( denominatorField: string, ): DataFieldValueCallback { return (_prod, _dataRow, _fieldName, fieldNamesArr, values) => { - const sum = sumFieldValues(numeratorFields, fieldNamesArr, values); - const denIdx = fieldNamesArr.indexOf(denominatorField); - const den = denIdx >= 0 ? toNum(values[denIdx]) : 0; - if (den === 0) return 0; - return truncateToFirstDecimal((sum / den) * 10000); + const den = fieldDecimal(denominatorField, fieldNamesArr, values); + if (den.isZero()) return 0; + const sum = sumDecimals(fieldNums(numeratorFields, fieldNamesArr, values)); + return decimalToGridNumber(truncDecimal(sum.div(den).times(10000), 1)); }; } @@ -345,12 +281,11 @@ export function createQuotientValueCallback(fields: string[]): DataFieldValueCal const denominatorField = fields[1] ?? ""; return (_prod, _dataRow, _fieldName, fieldNamesArr, values) => { if (!numeratorField || !denominatorField) return 0; - const numIdx = fieldNamesArr.indexOf(numeratorField); - const denIdx = fieldNamesArr.indexOf(denominatorField); - const numerator = numIdx >= 0 ? toNum(values[numIdx]) : 0; - const denominator = denIdx >= 0 ? toNum(values[denIdx]) : 0; - if (denominator === 0) return 0; - return numerator / denominator; + const denominator = fieldDecimal(denominatorField, fieldNamesArr, values); + if (denominator.isZero()) return 0; + return decimalToGridNumber( + fieldDecimal(numeratorField, fieldNamesArr, values).div(denominator), + ); }; } @@ -361,15 +296,11 @@ export function createQuotientTimesMultiplierPercentValueCallback( multiplierField: string, ): DataFieldValueCallback { return (_prod, _dataRow, _fieldName, fieldNamesArr, values) => { - const getVal = (field: string) => { - const idx = fieldNamesArr.indexOf(field); - return idx >= 0 ? toNum(values[idx]) : 0; - }; - const denominator = getVal(denominatorField); - if (denominator === 0) return 0; - const quotient = getVal(numeratorField) / denominator; - const multiplier = getVal(multiplierField); - return truncateToFirstDecimal(quotient * multiplier * 100); + const denominator = fieldDecimal(denominatorField, fieldNamesArr, values); + if (denominator.isZero()) return 0; + const quotient = fieldDecimal(numeratorField, fieldNamesArr, values).div(denominator); + const multiplier = fieldDecimal(multiplierField, fieldNamesArr, values); + return decimalToGridNumber(truncDecimal(quotient.times(multiplier).times(100), 1)); }; } diff --git a/apps/st/src/components/util/grid/decimalNumber.ts b/apps/st/src/components/util/grid/decimalNumber.ts new file mode 100644 index 000000000..5034dfb7c --- /dev/null +++ b/apps/st/src/components/util/grid/decimalNumber.ts @@ -0,0 +1,100 @@ +import Decimal from "decimal.js"; + +import type { DataFieldValueCallback } from "@/components/type/DataGrid.type"; + +/** IEEE Number를 최단 십진 문자열로 복원 (69.96 → "69.96") */ +export function toDecimal(n: number): Decimal { + if (!Number.isFinite(n) || n === 0) return new Decimal(0); + return new Decimal(n.toString()); +} + +function ulpOf(n: number): number { + return Number.EPSILON * Math.max(1, Math.abs(n)); +} + +/** RealGrid `#,##0.0` / `.00` / `.000` 절사가 의도한 십진값보다 작아지는지 */ +function needsDisplayNudge(n: number, intended: Decimal): boolean { + for (const places of [1, 2, 3] as const) { + const factor = 10 ** places; + const scaled = n * factor; + const ieeeTrunc = n >= 0 ? Math.floor(scaled) : Math.ceil(scaled); + const intendedTrunc = intended.times(new Decimal(10).pow(places)).trunc(); + if (n >= 0 && new Decimal(ieeeTrunc).lt(intendedTrunc)) return true; + if (n < 0 && new Decimal(ieeeTrunc).gt(intendedTrunc)) return true; + } + return false; +} + +/** + * Decimal → RealGrid Number. + * 18856.1처럼 이진 근사가 실제보다 작으면 `#,##0.00` 절사 시 18856.09가 되므로 + * 그때만 1 ULP씩 올려 표시를 맞춘다. 14384.26처럼 이미 충분한 값은 그대로 둔다. + */ +export function decimalToGridNumber(value: Decimal): number { + if (value.isZero()) return 0; + let n = value.toNumber(); + const sign = Math.sign(n) || 1; + for (let i = 0; i < 16; i++) { + if (!needsDisplayNudge(n, value)) return n; + n += sign * ulpOf(n); + } + return n; +} + +export function truncDecimal(value: Decimal, decimalPlaces: number): Decimal { + if (decimalPlaces < 0) return value; + const factor = new Decimal(10).pow(decimalPlaces); + return value.times(factor).trunc().div(factor); +} + +/** + * 절삭. `18856.1 * 100`이 1885609.999…가 되어 Math.floor 시 18856.09가 되므로 + * 최단 십진 문자열로 복원한 뒤 Decimal trunc, RealGrid 표시용으로 ULP 보정. + */ +export function truncateByDecimalPlaces(value: number, decimalPlaces: number): number { + if (!Number.isFinite(value)) return 0; + if (decimalPlaces < 0) return value; + return decimalToGridNumber(truncDecimal(toDecimal(value), decimalPlaces)); +} + +export function getDecimalPlacesFromDataFormat(dataFormat?: string): number | null { + if (!dataFormat) return null; + const match = dataFormat.match(/\.(0+|#+)/); + const decimalToken = match?.[1]; + if (decimalToken) return decimalToken.length; + if (/[#0]/.test(dataFormat)) return 0; + return null; +} + +export function wrapWithFormatTruncate( + callback: DataFieldValueCallback, + dataFormat?: string, +): ( + prod: unknown, + dataRow: number, + fieldName: string, + fieldNames: string[], + values: unknown[], +) => number { + const decimalPlaces = getDecimalPlacesFromDataFormat(dataFormat); + return (prod, dataRow, fieldName, fieldNames, values) => { + const computedValue = callback(prod, dataRow, fieldName, fieldNames, values); + const n = typeof computedValue === "number" ? computedValue : Number(computedValue); + const num = Number.isFinite(n) ? n : 0; + if (decimalPlaces == null) return num; + return truncateByDecimalPlaces(num, decimalPlaces); + }; +} + +export function sumDecimals(nums: number[]): Decimal { + let sum = new Decimal(0); + for (const n of nums) { + if (!Number.isFinite(n)) continue; + sum = sum.plus(toDecimal(n)); + } + return sum; +} + +export function sumToGridNumber(nums: number[]): number { + return decimalToGridNumber(sumDecimals(nums)); +} diff --git a/apps/st/src/components/util/grid/footerSummary.ts b/apps/st/src/components/util/grid/footerSummary.ts index ba709d4d4..225a0a63d 100644 --- a/apps/st/src/components/util/grid/footerSummary.ts +++ b/apps/st/src/components/util/grid/footerSummary.ts @@ -1,52 +1,51 @@ -import type { DataColumn,GridView } from "@/components/type/DataGrid.type"; +import type { DataColumn, GridView } from "@/components/type/DataGrid.type"; -// 그리드 푸터 합계 계산 함수 (계산 로직) -export const getFooterSummary = (grid: GridView, column: DataColumn) => { - // 데이터 소스에서 직접 합계 계산 +import { + decimalToGridNumber, + sumDecimals, + truncDecimal, +} from "@/components/util/grid/decimalNumber"; + +function toNumeric(cellValue: unknown): number | null { + if (cellValue == null || cellValue === "") return null; + const numericValue = + typeof cellValue === "string" + ? Number(String(cellValue).replace(/,/g, "")) + : Number(cellValue); + if (!Number.isFinite(numericValue)) return null; + return numericValue; +} + +function collectNumericValues( + grid: GridView, + column: DataColumn, + includeRow?: (rowIndex: number) => boolean, +): number[] { const dataSource = grid.getDataSource(); const rowCount = dataSource.getRowCount(); - let sum = 0; + const nums: number[] = []; for (let i = 0; i < rowCount; i++) { - const cellValue = dataSource.getValue(i, column.fieldName); - - // 문자열인 경우 숫자로 변환 - const numericValue = typeof cellValue === 'string' - ? Number(String(cellValue).replace(/,/g, '')) - : Number(cellValue); - - if (!isNaN(numericValue) && numericValue !== null && numericValue !== undefined) { - sum += numericValue; - } + if (includeRow && !includeRow(i)) continue; + const n = toNumeric(dataSource.getValue(i, column.fieldName)); + if (n != null) nums.push(n); } - return sum; -}; + return nums; +} + +// 그리드 푸터 합계 계산 함수 (계산 로직) +export const getFooterSummary = (grid: GridView, column: DataColumn) => + decimalToGridNumber(sumDecimals(collectNumericValues(grid, column))); // 평균 계산 함수 export const getFooterAvgSummary = (grid: GridView, column: DataColumn) => { - const dataSource = grid.getDataSource(); - const rowCount = dataSource.getRowCount(); - - let sum = 0; - let avg = 0; - - for (let i = 0; i < rowCount; i++) { - const cellValue = dataSource.getValue(i, column.fieldName); - - const numericValue = typeof cellValue === 'string' - ? Number(String(cellValue).replace(/,/g, '')) - : Number(cellValue); - - if (!isNaN(numericValue) && numericValue !== null && numericValue !== undefined) { - sum += numericValue; - } - } - - avg = sum / rowCount; - - return Math.floor(avg * 10) / 10; -} + const nums = collectNumericValues(grid, column); + const rowCount = grid.getDataSource().getRowCount(); + if (rowCount === 0) return 0; + const avg = sumDecimals(nums).div(rowCount); + return decimalToGridNumber(truncDecimal(avg, 1)); +}; // 구분 필드의 고유값들을 찾는 함수 (첫 번째, 두 번째). 기본 필드명 'displayYear' const getDivisionValues = (grid: GridView, divisionFieldName: string = 'displayYear'): string[] => { @@ -73,38 +72,21 @@ export const getFooterSummaryByDivision = ( divisionIndex: number, divisionFieldName: string = 'displayYear' ) => { - const dataSource = grid.getDataSource(); - const rowCount = dataSource.getRowCount(); - let sum = 0; - - // "구분" 필드의 고유값 찾기 const divisionValues = getDivisionValues(grid, divisionFieldName); if (divisionValues.length <= divisionIndex) { - return 0; // 해당 인덱스의 고유값이 없으면 0 반환 + return 0; } const targetDivision = divisionValues[divisionIndex]; + const dataSource = grid.getDataSource(); - for (let i = 0; i < rowCount; i++) { - // "구분" 필드 값 확인 - const divisionValue = dataSource.getValue(i, divisionFieldName); - if (String(divisionValue) !== targetDivision) { - continue; // 해당 "구분" 값이 아니면 스킵 - } - - const cellValue = dataSource.getValue(i, column.fieldName); - - // 문자열인 경우 숫자로 변환 - const numericValue = typeof cellValue === 'string' - ? Number(String(cellValue).replace(/,/g, '')) - : Number(cellValue); - - if (!isNaN(numericValue) && numericValue !== null && numericValue !== undefined) { - sum += numericValue; - } - } - - return sum; + return decimalToGridNumber( + sumDecimals( + collectNumericValues(grid, column, (i) => + String(dataSource.getValue(i, divisionFieldName)) === targetDivision, + ), + ), + ); }; // 구분 필드 값에 따라 필터링하여 평균 계산하는 함수. 기본 필드명 'displayYear' @@ -115,45 +97,22 @@ export const getFooterAvgSummaryByDivision = ( divisionIndex: number, divisionFieldName: string = 'displayYear' ) => { - const dataSource = grid.getDataSource(); - const rowCount = dataSource.getRowCount(); - let sum = 0; - let count = 0; - - // "구분" 필드의 고유값 찾기 const divisionValues = getDivisionValues(grid, divisionFieldName); if (divisionValues.length <= divisionIndex) { - return 0; // 해당 인덱스의 고유값이 없으면 0 반환 + return 0; } const targetDivision = divisionValues[divisionIndex]; + const dataSource = grid.getDataSource(); + const nums = collectNumericValues(grid, column, (i) => + String(dataSource.getValue(i, divisionFieldName)) === targetDivision, + ); - for (let i = 0; i < rowCount; i++) { - // "구분" 필드 값 확인 - const divisionValue = dataSource.getValue(i, divisionFieldName); - if (String(divisionValue) !== targetDivision) { - continue; // 해당 "구분" 값이 아니면 스킵 - } - - const cellValue = dataSource.getValue(i, column.fieldName); - - // 문자열인 경우 숫자로 변환 - const numericValue = typeof cellValue === 'string' - ? Number(String(cellValue).replace(/,/g, '')) - : Number(cellValue); - - if (!isNaN(numericValue) && numericValue !== null && numericValue !== undefined) { - sum += numericValue; - count += 1; - } - } - - if (count === 0) { + if (nums.length === 0) { return 0; } - const avg = sum / count; - return Math.floor(avg * 10) / 10; + return decimalToGridNumber(truncDecimal(sumDecimals(nums).div(nums.length), 1)); }; // Footer 설정 객체 생성 함수 @@ -249,4 +208,4 @@ export const footerDecimal3ArrayByDivision = createSumFooterArrayByDivision('#,# export const footerAvgDecimal = createAvgFooter('#,##0.0'); // "구분" 값에 따라 필터링하는 평균 Footer 배열 (소수점 1자리, 2줄) -export const footerAvgDecimalArrayByDivision = createAvgFooterArrayByDivision('#,##0.0'); \ No newline at end of file +export const footerAvgDecimalArrayByDivision = createAvgFooterArrayByDivision('#,##0.0');