diff --git a/apps/st/src/components/statistics/verification/GeneralVerificationManagement.tsx b/apps/st/src/components/statistics/verification/GeneralVerificationManagement.tsx index 7746febae..face7555d 100644 --- a/apps/st/src/components/statistics/verification/GeneralVerificationManagement.tsx +++ b/apps/st/src/components/statistics/verification/GeneralVerificationManagement.tsx @@ -19,10 +19,6 @@ import DetailComponent from './generalVerification/DetailComponent'; import RegistDialog from './generalVerification/RegistDialog'; import { getCoreRowModel, useReactTable } from "@tanstack/react-table"; -import { useForm } from "react-hook-form"; -import { zodResolver } from '@hookform/resolvers/zod'; -import { generalVerificationMasterSubmitSchema, type GeneralVerificationMasterSubmitFormData } from '@/entities/statistics/model/general-verification-master-submit-schema'; -// import { useDataTable } from "@/shared/lib"; import { getGeneralVerificationColumns } from '@/components/util/column/management/GeneralVerificationColumn'; import type { GeneralverificationList } from '@/entities/statistics/model/general-verification-interface'; import { generalverificationApiService } from '@/entities/statistics/api/general-verification-service'; @@ -43,7 +39,7 @@ const GeneralVerificationManagement = () => { const [vrfcMasterData, setVrfcMasterData] = useState([]); const [rowSelection, setRowSelection] = useState>({}); const [isLoading, setIsLoading] = useState(false); - const [selectedVrfcRow, setSelectedVrfcRow] = useState(null); + const [selectedVrfcRow, setSelectedVrfcRow] = useState(null); const [showRegistForm, setShowRegistForm] = useState(false); const { setMessage: alert } = useAlertStore((state) => state); @@ -86,33 +82,15 @@ const GeneralVerificationManagement = () => { data: vrfcMasterData, columns, getCoreRowModel: getCoreRowModel(), - getRowId: (row) => row.vrfcSn, + getRowId: (row) => String(row.vrfcSn), state: { rowSelection }, onRowSelectionChange: setRowSelection, }); - const form = useForm({ - resolver: zodResolver(generalVerificationMasterSubmitSchema), - defaultValues: {}, - }); - const handleVrfcMasterRowClick = async (row: GeneralverificationList) => { - try { - const response = await generalverificationApiService.getGeneralverificationInfo(row.vrfcSn); - - if (response.status === 'success') { - setRowSelection({ [row.vrfcSn]: true }); - setSelectedVrfcRow(row); - setShowRegistForm(false); - - form.reset(response.data); - } else { - alert(response.message || '데이터 조회 중 오류가 발생했습니다.'); - form.reset({}); - } - } catch { - alert('데이터 조회 중 오류가 발생했습니다.'); - } + setRowSelection({ [row.vrfcSn]: true }); + setSelectedVrfcRow(row.vrfcSn); + setShowRegistForm(false); }; const handleReset = () => { @@ -122,11 +100,6 @@ const GeneralVerificationManagement = () => { setShowRegistForm(false); } - const handleClickRegist = () => { - // setRowSelection({}); - // setSelectedVrfcRow(null); - }; - useEffect(() => { handleSearch(); }, []); @@ -190,7 +163,7 @@ const GeneralVerificationManagement = () => {
- @@ -219,7 +192,7 @@ const GeneralVerificationManagement = () => { }
- + diff --git a/apps/st/src/components/statistics/verification/generalVerification/CopyDialog.tsx b/apps/st/src/components/statistics/verification/generalVerification/CopyDialog.tsx new file mode 100644 index 000000000..b0e81c08f --- /dev/null +++ b/apps/st/src/components/statistics/verification/generalVerification/CopyDialog.tsx @@ -0,0 +1,147 @@ +import { useState, useEffect } from "react"; + +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogClose, + Label, + Input, +} from "@common/components"; +import { CodeSelect } from "@/entities/code"; +import { useAlertStore } from "@/shared/lib"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { getUserInfo } from "../../../../../../../packages/common/components/src/api"; +import { LoadingWithAnimation } from "@common/components"; + +import { generalverificationApiService } from "@/entities/statistics/api/general-verification-service"; + +import type { GeneralverificationList } from "@/entities/statistics/model/general-verification-interface"; + +interface CopyDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + children?: React.ReactNode; + row?: GeneralverificationList; + /** 수정/등록 성공 시 부모에서 목록 재조회 등 실행 */ + onSuccess?: () => void; +} + +const CopyDialog = ({ + children, + open, + onOpenChange, + row, + onSuccess, + ...props +}: CopyDialogProps) => { + + const [selectedCode, setSelectedCode] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const { setMessage: alert } = useAlertStore((state) => state); + const { confirm } = useConfirm(); + + const handleChangeCode = (value: string) => { + if (row?.trgtStatsCd === value) { + alert('원본과 동일한 통계항목으로 복사할 수 없습니다.'); + return; + } + + setSelectedCode(value); + }; + + const handleCopyItem = async () => { + if (!selectedCode) { + alert('복사 대상 통계항목을 선택해주세요.'); + return; + } + + if (await confirm('복사하시겠습니까?')) { + try { + setIsLoading(true); + const userInfo = await getUserInfo(); + const response = await generalverificationApiService.CopyVrfcData({ + vrfcSn: row?.vrfcSn ?? null, + targetStatsCd: selectedCode ?? '', + rgtrId: userInfo.userUnqId + }); + + if (response.status === 'success') { + alert('복사되었습니다.'); + onOpenChange(false); + onSuccess?.(); + } else { + alert(response.message || '데이터 복사 중 오류가 발생했습니다.'); + } + } catch { + alert('데이터 복사 중 오류가 발생했습니다.') + } finally { + setIsLoading(false); + } + } + }; + + useEffect(() => { + if (!open) setSelectedCode(''); + }, [open]); + + return ( + + {children} + + + + {row?.vrfcNm} + + + {isLoading && +
+
+ +
+
+ } +
+
+ + +
+
+ + +
+
+ + + + + + +
+
+ ); +} + +export default CopyDialog; \ No newline at end of file diff --git a/apps/st/src/components/statistics/verification/generalVerification/DetailComponent.tsx b/apps/st/src/components/statistics/verification/generalVerification/DetailComponent.tsx index 752c6ff6b..f976488f0 100644 --- a/apps/st/src/components/statistics/verification/generalVerification/DetailComponent.tsx +++ b/apps/st/src/components/statistics/verification/generalVerification/DetailComponent.tsx @@ -1,12 +1,8 @@ +import { useEffect, useRef, useState } from 'react'; + import { NoData } from '@/shared/ui/no-data'; import { - // Button, - // Label, - // Select, - // SelectTrigger, - // SelectContent, - // SelectValue, - // SelectItem, + Button, Input, Table, TableBody, @@ -14,79 +10,333 @@ import { TableHead, TableRow, } from "@common/components"; -import { FormItem, FormControl, FormField, Form } from "@/shared/ui/form"; +import FormulaComponents from './FormulaComponents'; +import { LoadingWithAnimation } from '@common/components'; +import FomulaDialog from './FomulaDialog'; -import { type UseFormReturn } from "react-hook-form"; +import { useAlertStore } from '@/shared/lib'; +import { useConfirm } from '@frontend-opensource/use-react-hooks'; -import type { GeneralverificationList } from '@/entities/statistics/model/general-verification-interface'; -import { type GeneralVerificationMasterSubmitFormData } from '@/entities/statistics/model/general-verification-master-submit-schema'; +import { generalverificationApiService } from '@/entities/statistics/api/general-verification-service'; +import type { VerifyDetailData, VerifyDetailResponseDto } from '@/entities/statistics/model/general-verification-interface'; +import { getUserInfo } from '../../../../../../../packages/common/components/src/api'; interface DetailComponentProps { - selectedVrfcRow: GeneralverificationList | null; - form: UseFormReturn; + selectedVrfcRow: number | null; } -const DetailComponent = ({ selectedVrfcRow, form }: DetailComponentProps) => { - return ( -
-
-

검증 상세 정보

-
-
- {selectedVrfcRow ? - <> -
-
- -
- - - - - - - - - - * 검증명 - - ( - - - - - {form.formState.errors.vrfcNm && ( -

- {form.formState.errors.vrfcNm.message} -

- )} -
- )} - /> -
- * 통계항목 - {(selectedVrfcRow as GeneralverificationList)?.trgtStatsNm ?? "-"} -
-
-
-
-
- -
- - : - +const DetailComponent = ({ selectedVrfcRow }: DetailComponentProps) => { + + const [detailInfo, setDetailInfo] = useState(null); + const [formulaList, setFormulaList] = useState<(VerifyDetailResponseDto | null)[]>([]); + const [hasPersistedDetails, setHasPersistedDetails] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [showFomulaDialog, setShowFomulaDialog] = useState(false); + const [selectedFormula, setSelectedFormula] = useState(null); + const [selectedFormulaIndex, setSelectedFormulaIndex] = useState(0); + const { confirm } = useConfirm(); + + const { setMessage: alert } = useAlertStore((state) => state); + + const persistedFormulaSignatureRef = useRef(''); + + const normalizeFormulaListForCompare = (list: (VerifyDetailResponseDto | null)[]) => { + return list.map((row) => + row + ? { + vrfcDtlSn: row.vrfcDtlSn ?? null, + refStatsCd: row.refStatsCd ?? null, + kornArtopnEqtnCn: row.kornArtopnEqtnCn ?? '', + engArtopnEqtnCn: row.engArtopnEqtnCn ?? '', + cmprOpert: row.cmprOpert ?? null, } + : null, + ); + }; + + const handleSaveClick = async () => { + const trgtStatsCd = detailInfo?.commonInfo.trgtStatsCd; + if (!trgtStatsCd) { + alert('대상 통계항목(trgtStatsCd) 정보가 없습니다.'); + return; + } + const rows = formulaList.map((row, idx) => ({ + eqtnCombSeq: idx + 1, + trgtStatsCd, + refStatsCd: row?.refStatsCd ?? null, + kornArtopnEqtnCn: row?.kornArtopnEqtnCn ?? '', + engArtopnEqtnCn: row?.engArtopnEqtnCn ?? '', + cmprOpert: row?.cmprOpert ?? null, + })); + + if (rows.length < 2) { + alert('수식 항목은 최소 2개 이상 등록해야 합니다.'); + return; + } + + const invalidIdx = rows.findIndex( + (r) => r.kornArtopnEqtnCn.trim().length === 0 || r.engArtopnEqtnCn.trim().length === 0, + ); + if (invalidIdx !== -1) { + alert(`수식이 비어있는 항목이 있습니다.\n(순번: ${invalidIdx + 1})`); + return; + } + + // 마지막을 제외한 항목들은 연산자(cmprOpert)가 반드시 있어야 함 + if (rows.length > 1) { + const invalidOpertIdx = rows.slice(0, -1).findIndex((r) => r.cmprOpert == null); + if (invalidOpertIdx !== -1) { + alert(`연산자가 선택되지 않은 항목이 있습니다.\n(순번: ${invalidOpertIdx + 1})`); + return; + } + } + + // 마지막 항목의 연산자는 무조건 null 처리 + if (rows.length > 0) { + const lastRow = rows[rows.length - 1]; + if (lastRow) lastRow.cmprOpert = null; + setFormulaList((prev) => { + if (prev.length === 0) return prev; + const last = prev[prev.length - 1]; + if (!last) return prev; + if (last.cmprOpert == null) return prev; + const next = [...prev]; + next[next.length - 1] = { ...last, cmprOpert: null }; + return next; + }); + } + + if (await confirm('등록하시겠습니까?')) { + try { + const userInfo = await getUserInfo(); + let response; + + if (!hasPersistedDetails) { + response = await generalverificationApiService.RegistVrfcDetail({ vrfcSn: detailInfo?.commonInfo.vrfcSn ?? null, detailList: rows, rgtrId: userInfo.userUnqId }); + } else { + response = await generalverificationApiService.UpdateVrfcDetail({ vrfcSn: detailInfo?.commonInfo.vrfcSn ?? null, detailList: rows, mdfrId: userInfo.userUnqId }); + } + + if (response?.status === 'success') { + alert('등록되었습니다.'); + getVrfcDetail(); + } else { + alert(response?.message || '데이터 등록 중 오류가 발생했습니다.'); + } + } catch { + alert('데이터 등록 중 오류가 발생했습니다.'); + } + } + }; + + const getVrfcDetail = async () => { + try { + setIsLoading(true); + + const response = await generalverificationApiService.getVrfcDetail(selectedVrfcRow!); + + if (response.status === 'success') { + setDetailInfo(response.data); + const list = response.data.verifyDetailResponseDtoList; + setFormulaList(list?.length ? [...list] : [null]); + setHasPersistedDetails((list?.length ?? 0) > 0); + // "기존 데이터" 스냅샷(서버 응답 기준)을 시그니처로 저장 + persistedFormulaSignatureRef.current = JSON.stringify( + normalizeFormulaListForCompare(list?.length ? [...list] : [null]), + ); + } else { + alert(response.message || '데이터 조회 중 오류가 발생했습니다.'); + } + } catch { + alert('데이터 조회 중 오류가 발생했습니다.'); + } finally { + setIsLoading(false); + } + }; + + const handleClickVrfcDetail = (formula?: VerifyDetailResponseDto | null, index?: number) => { + setSelectedFormula(formula ?? null); + setSelectedFormulaIndex(index ?? 0); + setShowFomulaDialog(true); + }; + + const handleFormulaConfirm = (payload: { kornArtopnEqtnCn: string; engArtopnEqtnCn: string; refStatsCd: string | null }) => { + setFormulaList((prev) => { + const next = [...prev]; + const i = selectedFormulaIndex; + if (i < 0 || i >= next.length) return prev; + const item = next[i]; + next[i] = item + ? { + ...item, + kornArtopnEqtnCn: payload.kornArtopnEqtnCn, + engArtopnEqtnCn: payload.engArtopnEqtnCn, + refStatsCd: payload.refStatsCd, + } + : { + vrfcDtlSn: null, + refStatsCd: payload.refStatsCd, + refStatsNm: null, + kornArtopnEqtnCn: payload.kornArtopnEqtnCn, + engArtopnEqtnCn: payload.engArtopnEqtnCn, + cmprOpert: null, + }; + return next; + }); + setShowFomulaDialog(false); + setSelectedFormula(null); + }; + + const handleResetClick = async () => { + const currentSignature = JSON.stringify(normalizeFormulaListForCompare(formulaList)); + if (currentSignature === persistedFormulaSignatureRef.current) { + alert('변경된 내용이 없습니다.'); + return; + } + + if (await confirm(`변경 전 데이터로 리셋하시겠습니까?\n등록 완료된 데이터는 등록 전 데이터를 불러올 수 없습니다.`)) { + getVrfcDetail(); + } + }; + + useEffect(() => { + if (selectedVrfcRow != null) { + getVrfcDetail(); + } + }, [selectedVrfcRow]); + + return ( + <> +
+
+

검증 상세 정보

+
+
+ {selectedVrfcRow ? + isLoading ? +
+
+ +
+
+ : +
+ + + + + + + + + + 검증명 + {detailInfo?.commonInfo.vrfcNm ?? "-"} + 통계항목 + {detailInfo?.commonInfo.trgtStatsNm ?? "-"} + + + 검증분류 + {detailInfo?.commonInfo.vrfcClsfNm ?? "-"} + 소수점처리방식 + + {detailInfo?.commonInfo.dcptDigtCnt === 0 ? + '소수점 생략' + : + `${detailInfo?.commonInfo.dcptDigtCnt}자리에서 ${detailInfo?.commonInfo.dcptPrcsMthNm}` + } + + + +
+
+

수식 정보

+ {formulaList.map((data, index) => ( + { + setFormulaList((prev) => { + const next = [...prev]; + if (index < 0 || index >= next.length) return prev; + const current = next[index]; + const cmprOpert = v === 'none' ? null : v; + next[index] = current + ? { ...current, cmprOpert } + : { + vrfcDtlSn: null, + refStatsCd: null, + refStatsNm: null, + kornArtopnEqtnCn: '', + engArtopnEqtnCn: '', + cmprOpert, + }; + return next; + }); + }} + onDelete={() => { + setFormulaList((prev) => { + const next = prev.filter((_, i) => i !== index); + return next.length > 0 ? next : [null]; + }); + }} + handleFomulaDialog={(show, formula, idx) => { + if (show) handleClickVrfcDetail(formula, idx ?? index); + else setShowFomulaDialog(false); + }} + /> + ))} +
+
+ +
+
+ + +
+
+ : +
+ +
+ } +
-
+ { + setShowFomulaDialog(open); + if (!open) setSelectedFormula(null); + }} + data={selectedFormula} + trgtStatsCd={detailInfo?.commonInfo.trgtStatsCd} + trgtStatsNm={detailInfo?.commonInfo.trgtStatsNm} + onConfirm={handleFormulaConfirm} + /> + ); }; diff --git a/apps/st/src/components/statistics/verification/generalVerification/FomulaDialog.tsx b/apps/st/src/components/statistics/verification/generalVerification/FomulaDialog.tsx new file mode 100644 index 000000000..a559008d0 --- /dev/null +++ b/apps/st/src/components/statistics/verification/generalVerification/FomulaDialog.tsx @@ -0,0 +1,593 @@ +import { useEffect, useRef, useState } from "react"; + +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogClose, + Textarea, + Skeleton, +} from "@common/components"; +import { CodeSelect } from "@/entities/code"; + +import { generalverificationApiService } from "@/entities/statistics/api/general-verification-service"; +import type { VerifyDetailResponseDto, VerifyColumnListResponseData, SelectedVerifyColumnListResponseData } from "@/entities/statistics/model/general-verification-interface"; + +import { useAlertStore } from "@/shared/lib"; + +export interface FomulaDialogConfirmPayload { + kornArtopnEqtnCn: string; + engArtopnEqtnCn: string; + /** 참조 통계코드 (선택 안 했으면 null) */ + refStatsCd: string | null; +} + +interface FomulaDialogProps { + open: boolean; + onOpenChange?: (open: boolean) => void; + data?: VerifyDetailResponseDto | null; + trgtStatsCd?: string; + trgtStatsNm?: string; + /** 확인 클릭 시 검사 통과 후 호출. 해당 행에 반영 후 다이얼로그 닫기는 부모에서 처리 */ + onConfirm?: (payload: FomulaDialogConfirmPayload) => void; +} + +const FomulaDialog = ({ open, onOpenChange, data, trgtStatsCd, trgtStatsNm, onConfirm }: FomulaDialogProps) => { + const [columnInfo, setColumnInfo] = useState(null); + const [selectedColumnInfo, setSelectedColumnInfo] = useState(null); + const [showCommonColumns, setShowCommonColumns] = useState(true); + const [showTargetColumns, setShowTargetColumns] = useState(true); + const [showRefColumns, setShowRefColumns] = useState(true); + /** 참조 통계 - 다이얼로그 내부에서만 사용. 열 때마다 해당 행(data)의 저장값으로 초기화, 저장 시에만 부모에 반영 예정 */ + const [refStatsCdLocal, setRefStatsCdLocal] = useState(''); + /** 수식 = 토큰 배열. 한글명 중복 시 출처(공통/대상/참조)별 engColNm 보존 */ + const [formulaDisplaySegments, setFormulaDisplaySegments] = useState([]); + const [formulaValueSegments, setFormulaValueSegments] = useState([]); + /** 확인 클릭 시 검사 실패 메시지 (Textarea 아래 빨간 글씨) */ + const [formulaSaveError, setFormulaSaveError] = useState(null); + /** 참조 통계 변경 시 안내 메시지 */ + const [refStatsChangeNotice, setRefStatsChangeNotice] = useState(null); + + const formulaTextareaRef = useRef(null); + + const { setMessage: alert } = useAlertStore((state) => state); + + /** 다이얼로그에서 쓰는 기호 → 제출용 value. 백엔드 규격에 맞게 수정 가능 */ + const OPERATOR_SYMBOL_TO_VALUE: Record = { + '+': '+', + '-': '-', + '×': '*', + '%': '/', + '(': '(', + ')': ')', + }; + + /** 공통 → 대상 → 참조 순, 첫 등장 kornColNm만 사용 (로드/직접입력 시 파싱용) */ + const getKornToEngMapForParse = (): Map => { + const map = new Map(); + const refCols = selectedColumnInfo?.targetColumnInfo?.colList ?? columnInfo?.refColumnInfo?.colList ?? []; + [...(columnInfo?.commonColumnInfo ?? []), ...(columnInfo?.targetColumnInfo?.colList ?? []), ...refCols].forEach( + (item) => { + if (!map.has(item.kornColNm)) map.set(item.kornColNm, item.engColNm); + } + ); + return map; + }; + + /** 한글 수식 문자열을 토큰(표시/값) 배열로 파싱. 컬럼명은 longest match */ + const parseDisplayToSegments = (displayStr: string): { display: string[]; value: string[] } => { + if (displayStr.length === 0) return { display: [], value: [] }; + const kornToEng = getKornToEngMapForParse(); + const kornList = [...kornToEng.keys()].filter(Boolean).sort((a, b) => b.length - a.length); + const display: string[] = []; + const value: string[] = []; + let i = 0; + while (i < displayStr.length) { + let matched = false; + for (const korn of kornList) { + if (displayStr.slice(i, i + korn.length) === korn) { + display.push(korn); + value.push(kornToEng.get(korn) ?? korn); + i += korn.length; + matched = true; + break; + } + } + if (!matched) { + const ch = displayStr[i]; + if (ch != null) { + display.push(ch); + value.push(ch); + } + i += 1; + } + } + return { display, value }; + }; + + const handleFormulaKey = (val: string) => { + setFormulaSaveError(null); + if (val === 'backspace') { + setFormulaDisplaySegments((prev) => prev.slice(0, -1)); + setFormulaValueSegments((prev) => prev.slice(0, -1)); + } else if (val === 'ac') { + setFormulaDisplaySegments([]); + setFormulaValueSegments([]); + } else { + setFormulaDisplaySegments((prev) => [...prev, val]); + setFormulaValueSegments((prev) => [...prev, val]); + } + }; + + /** 컬럼 버튼: 한글 표시 + 해당 출처의 영문명 저장 (한글 중복 시에도 구분) */ + const handleFormulaColumn = (kornColNm: string, engColNm: string) => { + setFormulaSaveError(null); + setFormulaDisplaySegments((prev) => [...prev, kornColNm]); + setFormulaValueSegments((prev) => [...prev, engColNm]); + }; + + /** 제출/저장 시 사용할 영문 수식 (컬럼 engColNm + 연산자 기호 → value 치환) */ + const getFormulaValueForSubmit = (): string => + formulaValueSegments.map((seg) => OPERATOR_SYMBOL_TO_VALUE[seg] ?? seg).join(''); + + const OPERATORS = ['+', '-', '×', '/', '%'] as const; + const isOperator = (s: string) => s.length === 1 && (OPERATORS as readonly string[]).includes(s); + const isParen = (s: string) => s === '(' || s === ')'; + const isValueToken = (s: string) => + s.length > 1 || (s.length === 1 && !isOperator(s) && !isParen(s)); + const isDigit = (s: string) => s.length === 1 && /[0-9]/.test(s); + + /** 저장 시 수식 규칙 검사. 오류 시 메시지 반환, 통과 시 null */ + const validateFormulaForSave = (segments: string[]): string | null => { + if (segments.length === 0) return null; + + for (let i = 0; i < segments.length - 1; i++) { + const a = segments[i]; + const b = segments[i + 1]; + if (a != null && b != null && isValueToken(a) && isValueToken(b) && !(isDigit(a) && isDigit(b))) { + return '컬럼(또는 숫자) 사이에 더하기, 빼기, 나누기, 곱하기 기호가 있어야 합니다.'; + } + } + + // "(값)"도 하나의 값으로 취급: 값/닫는괄호 뒤에는 '(' 또는 값이 올 수 없고, 사이에 연산자가 있어야 함. + // 예: 컬럼(100), 값)(100) 같은 형태는 연산자 누락으로 간주 + for (let i = 0; i < segments.length - 1; i++) { + const a = segments[i]; + const b = segments[i + 1]; + if (a == null || b == null) continue; + // 연속 숫자(예: 50)는 예외 허용 + if (isDigit(a) && isDigit(b)) continue; + + const aIsValueBoundary = isValueToken(a) || a === ')'; + const bIsValueBoundary = b === '(' || isValueToken(b); + if (aIsValueBoundary && bIsValueBoundary) { + return '컬럼(또는 숫자) 사이에 더하기, 빼기, 나누기, 곱하기 기호가 있어야 합니다.'; + } + } + + const last = segments[segments.length - 1]; + if (last && isOperator(last)) { + return '수식은 기호로 끝날 수 없습니다. (닫는 괄호 제외)'; + } + + const stack: boolean[] = []; + for (let i = 0; i < segments.length; i++) { + const s = segments[i]; + if (s == null) continue; + if (s === '(') { + stack.push(false); + } else if (s === ')') { + if (stack.length === 0) return '닫는 괄호가 여는 괄호 없이 사용되었습니다.'; + const hadValue = stack.pop(); + if (!hadValue) return '괄호 안에 값이 있어야 합니다.'; + if (stack.length > 0) stack[stack.length - 1] = true; + } else if (stack.length > 0 && isValueToken(s)) { + stack[stack.length - 1] = true; + } + } + if (stack.length > 0) return '여는 괄호에 대응하는 닫는 괄호가 없습니다.'; + + return null; + }; + + const getVerifyColumnList = async () => { + try { + if (!trgtStatsCd) return; + let response; + + if (data?.vrfcDtlSn == null || data.vrfcDtlSn <= 0) { + response = await generalverificationApiService.getSelectedVerifyColumnList(trgtStatsCd) + } else { + response = await generalverificationApiService.getVerifyColumnList({ + trgtStatsCd, + vrfcDtlSn: data.vrfcDtlSn, + refStatsCd: data.refStatsCd, + }); + } + + + if (response?.status === 'success') { + setColumnInfo(response.data); + } else { + alert(response?.message || '데이터 조회 중 오류가 발생했습니다.'); + } + } catch { + alert('데이터 조회 중 오류가 발생했습니다.'); + } + }; + + const getSelectedVerifyColumnList = async (statsCd: string) => { + try { + const response = await generalverificationApiService.getSelectedVerifyColumnList(statsCd); + if (response?.status === 'success' && response.data) { + setSelectedColumnInfo({ + targetColumnInfo: response.data.targetColumnInfo ?? { statsCd: statsCd, colList: [] }, + commonColumnInfo: response.data.commonColumnInfo ?? [], + }); + } else { + setSelectedColumnInfo(null); + } + } catch { + setSelectedColumnInfo(null); + } + }; + + useEffect(() => { + if (open) { + setColumnInfo(null); + setSelectedColumnInfo(null); + } + if (open) void getVerifyColumnList(); + }, [open, trgtStatsCd, data?.vrfcDtlSn]); + + useEffect(() => { + if (open) setRefStatsCdLocal(data?.refStatsCd ?? ''); + }, [open, data?.refStatsCd, data?.vrfcDtlSn]); + + useEffect(() => { + if (!open) { + setSelectedColumnInfo(null); + setRefStatsCdLocal(''); + setFormulaDisplaySegments([]); + setFormulaValueSegments([]); + setFormulaSaveError(null); + setRefStatsChangeNotice(null); + } + }, [open]); + + useEffect(() => { + if (open) { + const str = data?.kornArtopnEqtnCn ?? ''; + const { display, value } = parseDisplayToSegments(str); + setFormulaDisplaySegments(display); + setFormulaValueSegments(value); + } + }, [open, data?.kornArtopnEqtnCn, data?.vrfcDtlSn]); + + /** + * 재오픈 직후에는 columnInfo가 아직 없어서 parseDisplayToSegments가 컬럼명을 매칭 못하고 + * 한글을 1글자씩 토큰화하는 경우가 있음. + * 컬럼 목록 로딩 후(= map이 채워진 후) "표시 문자열이 원본과 동일 + 값 토큰이 표시 토큰과 동일"한 케이스만 재파싱해서 + * engColNm 치환이 반영되도록 보정한다. + */ + useEffect(() => { + if (!open) return; + const src = data?.kornArtopnEqtnCn ?? ''; + if (!src) return; + + const displayJoined = formulaDisplaySegments.join(''); + const valueJoined = formulaValueSegments.join(''); + // 아직 컬럼 치환이 안 된 상태(= value가 display와 동일)에서만 재파싱 + if (displayJoined !== src) return; + if (valueJoined !== displayJoined) return; + + // 컬럼 목록이 준비된 이후에만 의미가 있음 + const hasColumns = + (columnInfo?.commonColumnInfo?.length ?? 0) > 0 || + (columnInfo?.targetColumnInfo?.colList?.length ?? 0) > 0 || + (columnInfo?.refColumnInfo?.colList?.length ?? 0) > 0 || + (selectedColumnInfo?.targetColumnInfo?.colList?.length ?? 0) > 0; + if (!hasColumns) return; + + const { display, value } = parseDisplayToSegments(src); + const nextDisplayJoined = display.join(''); + const nextValueJoined = value.join(''); + // 파싱 결과가 현재와 완전히 같으면 setState 반복으로 무한 렌더가 날 수 있으니 중단 + if (nextDisplayJoined === displayJoined && nextValueJoined === valueJoined) return; + // 컬럼 치환이 전혀 안 됐다면(= value가 display와 동일) 보정의 의미가 없으니 중단 + if (nextValueJoined === nextDisplayJoined) return; + + setFormulaDisplaySegments(display); + setFormulaValueSegments(value); + }, [ + open, + data?.kornArtopnEqtnCn, + columnInfo?.commonColumnInfo, + columnInfo?.targetColumnInfo?.colList, + columnInfo?.refColumnInfo?.colList, + selectedColumnInfo?.targetColumnInfo?.colList, + formulaDisplaySegments, + formulaValueSegments, + ]); + + useEffect(() => { + if (!open || columnInfo?.kornArtopnEqtnCn == null) return; + if (data?.kornArtopnEqtnCn !== columnInfo.kornArtopnEqtnCn) return; + const { display, value } = parseDisplayToSegments(columnInfo.kornArtopnEqtnCn); + setFormulaDisplaySegments(display); + setFormulaValueSegments(value); + }, [open, data?.kornArtopnEqtnCn, columnInfo?.kornArtopnEqtnCn]); + + const handleChangeRefStatsCd = (next: string) => { + if (!open) return; + + // 현재 refStatsCdLocal에 해당하는 참조 컬럼이 수식에 포함돼 있으면 변경을 막고 안내만 표시 + const currentRefCols = refColList ?? []; + const currentRefEngSet = new Set(currentRefCols.map((c) => c.engColNm).filter(Boolean)); + const hasCurrentRefToken = + currentRefEngSet.size > 0 && formulaValueSegments.some((seg) => currentRefEngSet.has(seg)); + + if (hasCurrentRefToken) { + setRefStatsChangeNotice('수식에 기존 참조 컬럼이 포함되어 있어 참조 통계를 변경할 수 없습니다. 먼저 해당 참조 컬럼을 제거해주세요.'); + return; + } + + setRefStatsChangeNotice(null); + setRefStatsCdLocal(next); + }; + + /** 참조 통계 선택 시 해당 통계의 컬럼 목록으로 교체 */ + useEffect(() => { + if (!open) return; + if (refStatsCdLocal) { + void getSelectedVerifyColumnList(refStatsCdLocal); + } else { + setSelectedColumnInfo(null); + } + }, [open, refStatsCdLocal]); + + /** 참조 컬럼: 선택한 참조 통계로 조회한 목록이 있으면 사용, 없으면 getVerifyColumnList 결과 사용 */ + const refColList = selectedColumnInfo?.targetColumnInfo?.colList ?? columnInfo?.refColumnInfo?.colList ?? []; + + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) { + setFormulaDisplaySegments([]); + setFormulaValueSegments([]); + setFormulaSaveError(null); + setSelectedColumnInfo(null); + setRefStatsCdLocal(''); + } + onOpenChange?.(nextOpen); + }; + + return ( + + + + + 수식 상세 정보 + + +
+
+
+

기호 및 숫자

+
+ + + + + + + {[1, 2, 3, 4, 5, 6, 7, 8, 9, 0, '⌫', 'AC'].map((n) => { + const keyVal = n !== 'AC' && n !== '⌫' ? String(n) : n === '⌫' ? 'backspace' : 'ac'; + return ( + + ); + })} +
+
+
+

수식 미리보기

+