diff --git a/apps/st/src/components/css/DataGrid.css b/apps/st/src/components/css/DataGrid.css index 2f3519372..f4a2020f0 100644 --- a/apps/st/src/components/css/DataGrid.css +++ b/apps/st/src/components/css/DataGrid.css @@ -1,5 +1,11 @@ @tailwind components; +/* RealGrid 기본은 white-space: nowrap → max-width만 주면 잘림. 너비 초과 시에만 줄바꿈 */ +.rg-tooltip { + max-width: 60rem; + white-space: pre-line; +} + @layer components { .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell { @apply !bg-white; diff --git a/apps/st/src/components/module/DataGrid.tsx b/apps/st/src/components/module/DataGrid.tsx index aba00d672..896659c00 100644 --- a/apps/st/src/components/module/DataGrid.tsx +++ b/apps/st/src/components/module/DataGrid.tsx @@ -86,6 +86,40 @@ function mergeGridRowsWithSourceMetadata( }); } +/** row.verificationResult 필드별 오류 배열이 하나라도 있으면 true */ +function rowHasVerificationError( + row: Record, +): boolean { + const verification = ( + row as { verificationResult?: Record } + ).verificationResult; + if (!verification || typeof verification !== "object") return false; + for (const value of Object.values(verification)) { + if (Array.isArray(value) && value.length > 0) return true; + } + return false; +} + +function rowsHaveVerificationErrors( + rows: Record[], +): boolean { + return rows.some(rowHasVerificationError); +} + +function sortRowsVerifyErrorsFirst( + rows: Record[], +): Record[] { + return rows + .map((row, index) => ({ row, index })) + .sort((a, b) => { + const aErr = rowHasVerificationError(a.row) ? 0 : 1; + const bErr = rowHasVerificationError(b.row) ? 0 : 1; + if (aErr !== bErr) return aErr - bErr; + return a.index - b.index; + }) + .map(({ row }) => row); +} + function applyCellStyleCallbacksToColumns( gridView: unknown, columns: ColumnConfig[], @@ -804,6 +838,12 @@ const DataGrid = ({ const emptyCellHoverRafRef = useRef(null); const [emptyCellHoverTooltip, setEmptyCellHoverTooltip] = useState(null); + /** 검증 오류 행 우선보기 (오류 있는 데이터일 때만 UI 노출) */ + const [preferVerifyErrors, setPreferVerifyErrors] = useState(false); + const verifyPriorityBaselineRef = useRef< + Record[] | null + >(null); + const skipVerifyPriorityResetRef = useRef(false); columnsRef.current = columns; rowDataRef.current = rowData; cellStyleCallbackRef.current = cellStyleCallback; @@ -814,6 +854,90 @@ const DataGrid = ({ onCellEditedRef.current = onCellEdited; onDataCellClickRef.current = onDataCellClick; + const hasVerificationErrors = rowsHaveVerificationErrors(rowData); + const showVerifyPriorityToggle = hasVerificationErrors; + + // 조회 등으로 rowData가 교체되면 우선보기 해제 (자체 setRowData는 스킵) + useEffect(() => { + if (skipVerifyPriorityResetRef.current) { + skipVerifyPriorityResetRef.current = false; + return; + } + verifyPriorityBaselineRef.current = null; + setPreferVerifyErrors(false); + }, [rowData]); + + // 오류가 없어지면 체크 해제 + 원본 순서 복원 + useEffect(() => { + if (hasVerificationErrors || !preferVerifyErrors) return; + const baseline = verifyPriorityBaselineRef.current; + verifyPriorityBaselineRef.current = null; + setPreferVerifyErrors(false); + if (baseline) { + skipVerifyPriorityResetRef.current = true; + setRowData(baseline); + } + }, [hasVerificationErrors, preferVerifyErrors, setRowData]); + + const collectCurrentRowsWithMetadata = () => { + const fieldNames = columns + .map((c) => c.fieldName) + .filter(Boolean) as string[]; + const gv = gridRef.current?.gridView as + | { + getDataSource?: () => { + getRowCount(): number; + getValue(r: number, f: string): unknown; + }; + commit?: (force?: boolean) => boolean; + } + | undefined; + if (!gv?.getDataSource) { + return rowDataRef.current.map((r) => ({ ...r })); + } + try { + gv.commit?.(); + } catch { + /* ignore */ + } + const ds = gv.getDataSource(); + if (!ds) return rowDataRef.current.map((r) => ({ ...r })); + const cnt = ds.getRowCount(); + const rows: Record[] = []; + for (let i = 0; i < cnt; i++) { + const r: Record = {}; + for (const f of fieldNames) + r[f] = ds.getValue(i, f) as string | number; + rows.push(r); + } + return mergeGridRowsWithSourceMetadata( + rows, + rowDataRef.current, + fieldNames, + ); + }; + + const handlePreferVerifyErrorsChange = (checked: boolean) => { + if (checked) { + const current = collectCurrentRowsWithMetadata(); + if (!rowsHaveVerificationErrors(current)) return; + verifyPriorityBaselineRef.current = current.map((r) => ({ ...r })); + sortDirRef.current = {}; + originalRowsRef.current = {}; + skipVerifyPriorityResetRef.current = true; + setPreferVerifyErrors(true); + setRowData(sortRowsVerifyErrorsFirst(current)); + return; + } + const baseline = verifyPriorityBaselineRef.current; + verifyPriorityBaselineRef.current = null; + skipVerifyPriorityResetRef.current = true; + setPreferVerifyErrors(false); + if (baseline) { + setRowData(baseline); + } + }; + // rowData 변경 시 편집 중인 셀을 commit한 뒤 수동으로 setRows (RealGridReact에 rows prop을 넘기지 않음) const rowDataMountedRef = useRef(false); useEffect(() => { @@ -2086,60 +2210,79 @@ useEffect(() => { onChange={handleFileChange} />
- {statusInfo && ( -
- - 진행상황:{" "} - {statusInfo.laeSbmsnSttsCd === null || - statusInfo.laeSbmsnSttsCd === "0" ? ( - "미작성" - ) : statusInfo.laeSbmsnSttsCd === "1" ? ( - 작성중 - ) : statusInfo.laeSbmsnSttsCd === "2" ? ( - 제출 - ) : statusInfo.laeSbmsnSttsCd === "3" ? ( - 승인 - ) : statusInfo.laeSbmsnSttsCd === "4" ? ( - - 반려 - - - +
+ {statusInfo && ( +
+ + 진행상황:{" "} + {statusInfo.laeSbmsnSttsCd === null || + statusInfo.laeSbmsnSttsCd === "0" ? ( + "미작성" + ) : statusInfo.laeSbmsnSttsCd === "1" ? ( + 작성중 + ) : statusInfo.laeSbmsnSttsCd === "2" ? ( + 제출 + ) : statusInfo.laeSbmsnSttsCd === "3" ? ( + 승인 + ) : statusInfo.laeSbmsnSttsCd === "4" ? ( + + 반려 + + + + - - ) : ( - 검증중 - )} - - - 반려사유:{" "} - {statusInfo.rjctOpnnCn - ? statusInfo.rjctOpnnCn - : "-"} - - - 제출일:{" "} - {statusInfo.laeSbmsnYmd - ? statusInfo.laeSbmsnYmd.length === 8 && - /^\d{8}$/.test(statusInfo.laeSbmsnYmd) - ? `${statusInfo.laeSbmsnYmd.slice(0, 4)}-${statusInfo.laeSbmsnYmd.slice(4, 6)}-${statusInfo.laeSbmsnYmd.slice(6, 8)}` - : statusInfo.laeSbmsnYmd - : "미제출"} - - - 담당자:{" "} - {statusInfo.laePicNm ? statusInfo.laePicNm : "-"} - - - 연락처:{" "} - {statusInfo.laePicTelNo - ? statusInfo.laePicTelNo - : "-"} - -
- )} + ) : ( + 검증중 + )} + + + 반려사유:{" "} + {statusInfo.rjctOpnnCn + ? statusInfo.rjctOpnnCn + : "-"} + + + 제출일:{" "} + {statusInfo.laeSbmsnYmd + ? statusInfo.laeSbmsnYmd.length === 8 && + /^\d{8}$/.test(statusInfo.laeSbmsnYmd) + ? `${statusInfo.laeSbmsnYmd.slice(0, 4)}-${statusInfo.laeSbmsnYmd.slice(4, 6)}-${statusInfo.laeSbmsnYmd.slice(6, 8)}` + : statusInfo.laeSbmsnYmd + : "미제출"} + + + 담당자:{" "} + {statusInfo.laePicNm ? statusInfo.laePicNm : "-"} + + + 연락처:{" "} + {statusInfo.laePicTelNo + ? statusInfo.laePicTelNo + : "-"} + +
+ )} + {showVerifyPriorityToggle && ( + + )} +
{isExcelImport && ( +
+ +
+ 조건연산자는 규칙 전체에서 1개만 선택할 + 수 있습니다. 조건연산자를 기준으로 + 앞쪽은 기준 대상, 뒤쪽은 비교대상으로 + 구분됩니다. + {conditionBoundary === -1 && ( + + {" "} + 현재 조건연산자가 선택되지 않아 + 전체가 비교대상과 동일하게 + 처리됩니다. + + )} +
-
+ +
-
-
-
- : + ) + ) : (
- } + )} { setShowFomulaDialog(open); @@ -369,4 +636,4 @@ const DetailComponent = ({ selectedVrfcRow }: DetailComponentProps) => { ); }; -export default DetailComponent; \ No newline at end of file +export default DetailComponent; diff --git a/apps/st/src/components/statistics/verification/generalVerification/FormulaComponents.tsx b/apps/st/src/components/statistics/verification/generalVerification/FormulaComponents.tsx index d1e7f7940..90dcedc06 100644 --- a/apps/st/src/components/statistics/verification/generalVerification/FormulaComponents.tsx +++ b/apps/st/src/components/statistics/verification/generalVerification/FormulaComponents.tsx @@ -1,41 +1,83 @@ -import { Button, Input } from "@common/components";; -import OperatorsComponents from "./OperatorsComponents"; +import { Button, Input } from "@common/components"; + import type { VerifyDetailResponseDto } from "@/entities/statistics/model/general-verification-interface"; +import OperatorsComponents from "./OperatorsComponents"; + +type ClauseZone = "" | "zone-condition" | "zone-result"; + interface FormulaComponentsProps { data?: VerifyDetailResponseDto | null; index?: number; + zone?: ClauseZone; + groupBy?: boolean; + onGroupByChange?: (checked: boolean) => void; onDelete?: () => void; onOperatorChange?: (value: string) => void; - handleFomulaDialog?: (show: boolean, data?: VerifyDetailResponseDto | null, index?: number) => void; + handleFomulaDialog?: ( + show: boolean, + data?: VerifyDetailResponseDto | null, + index?: number, + ) => void; } -const FormulaComponents = ({ data, index, onDelete, onOperatorChange, handleFomulaDialog }: FormulaComponentsProps) => { +const zoneClassName: Record = { + "": "bg-[#f9fafb]", + "zone-condition": "bg-[#eef4ff]", + "zone-result": "bg-[#fff4ea]", +}; + +const FormulaComponents = ({ + data, + index, + zone = "", + groupBy = false, + onGroupByChange, + onDelete, + onOperatorChange, + handleFomulaDialog, +}: FormulaComponentsProps) => { const cmprOpert = data?.cmprOpert; return ( -
+
handleFomulaDialog?.(true, data, index)} readOnly /> - +
+ +
+
); }; -export default FormulaComponents; \ No newline at end of file +export default FormulaComponents; diff --git a/apps/st/src/components/statistics/verification/generalVerification/RegistDialog.tsx b/apps/st/src/components/statistics/verification/generalVerification/RegistDialog.tsx index 84c24aec4..9775a7b73 100644 --- a/apps/st/src/components/statistics/verification/generalVerification/RegistDialog.tsx +++ b/apps/st/src/components/statistics/verification/generalVerification/RegistDialog.tsx @@ -384,9 +384,8 @@ const RegistDialog = memo( - 공통 검증 - 도단위 검증 - 시단위 검증 + 총계 검증 + 행 검증 diff --git a/apps/st/src/components/util/column/input/PublicSewageFactInpColumn/PublicSewageFactColumn.ts b/apps/st/src/components/util/column/input/PublicSewageFactInpColumn/PublicSewageFactColumn.ts index 7dbda56e5..9d2067de0 100644 --- a/apps/st/src/components/util/column/input/PublicSewageFactInpColumn/PublicSewageFactColumn.ts +++ b/apps/st/src/components/util/column/input/PublicSewageFactInpColumn/PublicSewageFactColumn.ts @@ -50,9 +50,9 @@ export const PublicSewageFactColumn = { { name: 'prcsQnt', fieldName: 'prcsQnt', header: { text: '방류량(500m³/일 이상/미만)' }, editable: false, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0', styleName: 'rg-cell-readonly' }, // 방류량 - (m³/일) - { name: 'prcsPhysCpct', fieldName: 'prcsPhysCpct', header: { text: '물리적' }, editable: false, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0', styleName: 'rg-cell-readonly' }, - { name: 'prcsBoilCpct', fieldName: 'prcsBoilCpct', header: { text: '생물학적' }, editable: false, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0', styleName: 'rg-cell-readonly' }, - { name: 'prcsAltdCpct', fieldName: 'prcsAltdCpct', header: { text: '고도' }, editable: false, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0', styleName: 'rg-cell-readonly' }, + { name: 'prcsPhysCpct', fieldName: 'prcsPhysCpct', header: { text: '물리적' }, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, + { name: 'prcsBoilCpct', fieldName: 'prcsBoilCpct', header: { text: '생물학적' }, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, + { name: 'prcsAltdCpct', fieldName: 'prcsAltdCpct', header: { text: '고도' }, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, // 수질 { name: 'inflwBodVl', fieldName: 'inflwBodVl', header: { text: '유입BOD' }, editable: false, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0', styleName: 'rg-cell-readonly' }, diff --git a/apps/st/src/components/util/grid/columnGuides.ts b/apps/st/src/components/util/grid/columnGuides.ts new file mode 100644 index 000000000..d230bb948 --- /dev/null +++ b/apps/st/src/components/util/grid/columnGuides.ts @@ -0,0 +1,28 @@ +import { statisticsApi } from "@/entities/statistics/api/statistics-service"; + +/** column-guides API 응답 → fieldName(engColNm) → guide 텍스트 */ +export function mapColumnGuideListToRecord( + data: { engColNm?: string; columnGuide?: string }[], +): Record { + const next: Record = {}; + for (const item of data) { + const key = item.engColNm?.trim(); + const guide = item.columnGuide?.trim(); + if (key && guide) next[key] = guide; + } + return next; +} + +export async function fetchColumnGuidesRecord( + statsCd: string, +): Promise> { + try { + const res = await statisticsApi.getColumnGuides(statsCd); + if (res.status !== "success" || !Array.isArray(res.data)) { + return {}; + } + return mapColumnGuideListToRecord(res.data); + } catch { + return {}; + } +} diff --git a/apps/st/src/components/util/grid/computedFieldCallbacks.ts b/apps/st/src/components/util/grid/computedFieldCallbacks.ts index 0506bee8a..231234e86 100644 --- a/apps/st/src/components/util/grid/computedFieldCallbacks.ts +++ b/apps/st/src/components/util/grid/computedFieldCallbacks.ts @@ -14,6 +14,16 @@ function truncateToFirstDecimal(value: number): number { return Math.floor(value * 10) / 10; } +/** + * IEEE 754 가산 잡음 제거. + * 통계 표시 자릿수(대개 0~3)보다 충분히 큰 자리로 반올림해 + * 112.229999… → 112.23 처럼 만들어 RealGrid `#,##0.000` 표시 오차를 막는다. + */ +function sanitizeFloatNoise(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.round(value * 1e6) / 1e6; +} + /** 합계용 valueCallback 생성 */ export function createSumValueCallback(fieldNames: string[]): DataFieldValueCallback { return (_prod, _dataRow, _fieldName, fieldNamesArr, values) => { @@ -22,7 +32,7 @@ export function createSumValueCallback(fieldNames: string[]): DataFieldValueCall const idx = fieldNamesArr.indexOf(f); if (idx >= 0) sum += toNum(values[idx]); } - return sum; + return sanitizeFloatNoise(sum); }; } diff --git a/apps/st/src/entities/statistics/model/input/statistics-sewage-supply-rate-input-interface.ts b/apps/st/src/entities/statistics/model/input/statistics-sewage-supply-rate-input-interface.ts index d58f3c41e..c13243912 100644 --- a/apps/st/src/entities/statistics/model/input/statistics-sewage-supply-rate-input-interface.ts +++ b/apps/st/src/entities/statistics/model/input/statistics-sewage-supply-rate-input-interface.ts @@ -121,7 +121,7 @@ export interface SewerageSupplyRateSaveRequest { sggNm: string; sewerBzmnCd: string; userId: string; - sewerBzmnFrgnrWholPpltnCnt?: number; + sewerBzmnArea?: number; saveList?: SewerageSupplyRateSaveItem[]; submitList?: SewerageSupplyRateSaveItem[]; laePicNm?: string;