[ST] refactor: input - 자동 계산 cell 부동소수점 문제 해결을 위해 decimal.js 라이브러리 추가 및 계산 로직 수정, 6-2 지표 로직 수정
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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<Record<string, string | number>[]>(rowData);
|
||||
const cellStyleCallbackRef = useRef<CellStyleCallback | undefined>(cellStyleCallback);
|
||||
const checkableCallbackRef = useRef<DataGridProps["checkableCallback"]>(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<string, unknown>).__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 {
|
||||
|
||||
@@ -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<string, string | number>,
|
||||
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<string, string | number>,
|
||||
@@ -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<string, unknown>).__checked)) return;
|
||||
if (gridView.checkItem) {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -312,6 +312,28 @@ const PublicSewageFacilityInp = () => {
|
||||
return v === 1 || v === '1' || v === 'Y' || v === 'y' || v === 'true';
|
||||
};
|
||||
|
||||
const isSelfMappingTarget = (
|
||||
row: Record<string, string | number>,
|
||||
mainFcltSn: string | null,
|
||||
): boolean => Boolean(mainFcltSn) && String(row.fcltSn ?? '') === mainFcltSn;
|
||||
|
||||
const hasCheckedSelfMapping = (
|
||||
rows: Record<string, string | number>[],
|
||||
mainFcltSn: string | null,
|
||||
): boolean => rows.some((r) => isSelfMappingTarget(r, mainFcltSn) && isCheckedRow(r));
|
||||
|
||||
/** 왼쪽에서 선택한 시설을 오른쪽에서 본인 체크하면 다른 시설은 해제 */
|
||||
const applyExclusiveSelfMappingChecks = (
|
||||
rows: Record<string, string | number>[],
|
||||
mainFcltSn: string | null,
|
||||
): Record<string, string | number>[] => {
|
||||
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<string>();
|
||||
|
||||
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);
|
||||
|
||||
@@ -418,6 +418,11 @@ export type DataGridProps = {
|
||||
onRowsChange?: (rows: Record<string, string | number>[]) => void;
|
||||
fixedColumns?: number; // 고정할 컬럼 개수 (좌측부터)
|
||||
showHeaderCheckbox?: boolean; // 헤더 체크박스 표시 여부 (기본값: true)
|
||||
/** CheckBar 행별 체크 가능 여부 (false면 해당 행 체크박스 비활성) */
|
||||
checkableCallback?: (
|
||||
row: Record<string, string | number>,
|
||||
itemIndex: number,
|
||||
) => boolean;
|
||||
rowData: Record<string, string | number>[];
|
||||
setRowData: React.Dispatch<
|
||||
React.SetStateAction<Record<string, string | number>[]>
|
||||
@@ -532,6 +537,11 @@ export type SplitDataGridProps = {
|
||||
rightOnItemChecked?: (itemIndex: number, checked: boolean) => void;
|
||||
/** 우측 분할 그리드 헤더 전체 체크 변경 */
|
||||
rightOnItemAllChecked?: (checked: boolean) => void;
|
||||
/** 우측 분할 그리드 행별 체크 가능 여부 */
|
||||
rightCheckableCallback?: (
|
||||
row: Record<string, string | number>,
|
||||
itemIndex: number,
|
||||
) => boolean;
|
||||
/** 전체 정렬 기능 on/off (기본값: false) */
|
||||
enableSort?: boolean;
|
||||
};
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@ export const MainFacilityTargetColumn = {
|
||||
|
||||
export const MainFacilityInpColumn = {
|
||||
tabKey: 'ST00060002',
|
||||
tabLabel: '중심 처리 시설',
|
||||
tabLabel: '위탁 관리 입력',
|
||||
isSplitGrid: true,
|
||||
left: MainFacilityColumn,
|
||||
right: MainFacilityTargetColumn,
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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');
|
||||
export const footerAvgDecimalArrayByDivision = createAvgFooterArrayByDivision('#,##0.0');
|
||||
|
||||
Reference in New Issue
Block a user