1129 lines
49 KiB
TypeScript
1129 lines
49 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
|
|
|
import { useAlertStore } from "@/shared/lib";
|
|
import { NoData } from "@/shared/ui/no-data";
|
|
import { generalverificationApiService } from "@/entities/statistics/api/general-verification-service";
|
|
import type {
|
|
VerifyDetailData,
|
|
VerifyDetailResponseDto,
|
|
} from "@/entities/statistics/model/general-verification-interface";
|
|
import {
|
|
Button,
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableRow,
|
|
} from "@common/components";
|
|
import { LoadingWithAnimation } from "@common/components";
|
|
|
|
import { getUserInfo } from "../../../../../../../packages/common/components/src/api";
|
|
|
|
import FomulaDialog from "./FomulaDialog";
|
|
import FormulaComponents from "./FormulaComponents";
|
|
|
|
type ConnectorValue = string;
|
|
type ClauseZone = "" | "zone-condition" | "zone-result";
|
|
|
|
type FormulaBlock = {
|
|
kind: "formula";
|
|
id: string;
|
|
data: VerifyDetailResponseDto | null;
|
|
groupBy: boolean;
|
|
};
|
|
|
|
type ConnectorBlock = {
|
|
kind: "connector";
|
|
id: string;
|
|
value: ConnectorValue;
|
|
};
|
|
|
|
type RuleBlock = FormulaBlock | ConnectorBlock;
|
|
|
|
const CONNECTOR_OPERATORS_QUERY = "opertSeCd=COND&opertSeCd=LOGC";
|
|
const CMPR_OPERATORS_QUERY = "opertSeCd=CMPR";
|
|
|
|
const UNARY_TERMINAL_OPERATORS = new Set(["is_null", "!is_null", "floor"]);
|
|
const LIKE_PATTERN_OPERATORS = new Set(["like", "!like"]);
|
|
|
|
/** 현재 수식 블록 바로 앞 수식의 비교 연산자(cmprOpert) */
|
|
const getPreviousFormulaCmprOpert = (
|
|
blocks: RuleBlock[],
|
|
formulaBlockIndex: number,
|
|
): string | null => {
|
|
for (let i = formulaBlockIndex - 1; i >= 0; i -= 1) {
|
|
const block = blocks[i];
|
|
if (block?.kind === "formula") {
|
|
return block.data?.cmprOpert ?? null;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const isLikePatternOperator = (value: string | null | undefined) =>
|
|
value != null && LIKE_PATTERN_OPERATORS.has(value);
|
|
|
|
type OperatorItem = {
|
|
opertSn: string;
|
|
opertSymbl: string;
|
|
opertKornNm: string;
|
|
};
|
|
|
|
type ConnectorOperatorItem = OperatorItem;
|
|
|
|
type SaveDetailRow = {
|
|
eqtnCombSeq: number;
|
|
trgtStatsCd: string;
|
|
refStatsCd: string | null;
|
|
kornArtopnEqtnCn: string;
|
|
engArtopnEqtnCn: string;
|
|
cmprOpert: string | null;
|
|
grpByYn: string;
|
|
};
|
|
|
|
/** StrictMode/중복 호출에도 네트워크는 1회만 */
|
|
let connectorOperatorsCache: ConnectorOperatorItem[] | null = null;
|
|
let connectorOperatorsInflight: Promise<ConnectorOperatorItem[]> | null =
|
|
null;
|
|
|
|
let cmprOperatorsCache: OperatorItem[] | null = null;
|
|
let cmprOperatorsInflight: Promise<OperatorItem[]> | null = null;
|
|
|
|
const loadOperatorsByQuery = async (
|
|
query: string,
|
|
): Promise<OperatorItem[]> => {
|
|
const response =
|
|
await generalverificationApiService.getVrfcOperators(query);
|
|
return response.status === "success" && response.data
|
|
? response.data
|
|
: [];
|
|
};
|
|
|
|
const loadConnectorOperators = (): Promise<ConnectorOperatorItem[]> => {
|
|
if (connectorOperatorsCache) {
|
|
return Promise.resolve(connectorOperatorsCache);
|
|
}
|
|
if (connectorOperatorsInflight) {
|
|
return connectorOperatorsInflight;
|
|
}
|
|
|
|
connectorOperatorsInflight = (async () => {
|
|
try {
|
|
const data = await loadOperatorsByQuery(CONNECTOR_OPERATORS_QUERY);
|
|
connectorOperatorsCache = data;
|
|
return data;
|
|
} catch {
|
|
connectorOperatorsInflight = null;
|
|
return [];
|
|
}
|
|
})();
|
|
|
|
return connectorOperatorsInflight;
|
|
};
|
|
|
|
const loadCmprOperators = (): Promise<OperatorItem[]> => {
|
|
if (cmprOperatorsCache) {
|
|
return Promise.resolve(cmprOperatorsCache);
|
|
}
|
|
if (cmprOperatorsInflight) {
|
|
return cmprOperatorsInflight;
|
|
}
|
|
|
|
cmprOperatorsInflight = (async () => {
|
|
try {
|
|
const data = await loadOperatorsByQuery(CMPR_OPERATORS_QUERY);
|
|
cmprOperatorsCache = data;
|
|
return data;
|
|
} catch {
|
|
cmprOperatorsInflight = null;
|
|
return [];
|
|
}
|
|
})();
|
|
|
|
return cmprOperatorsInflight;
|
|
};
|
|
|
|
const isConnectorOperatorSymbol = (
|
|
value: string | null | undefined,
|
|
connectorOps: ConnectorOperatorItem[],
|
|
) => {
|
|
if (value == null || value === "") return false;
|
|
return connectorOps.some((op) => op.opertSymbl === value);
|
|
};
|
|
|
|
const isConditionConnectorValue = (
|
|
value: string,
|
|
connectorOps: ConnectorOperatorItem[],
|
|
) => {
|
|
const normalized = value.trim().toLowerCase();
|
|
if (
|
|
normalized === "cond" ||
|
|
normalized === "condition" ||
|
|
normalized === "조건" ||
|
|
normalized === "조건연산자"
|
|
) {
|
|
return true;
|
|
}
|
|
const op = connectorOps.find((o) => o.opertSymbl === value);
|
|
return op ? /조건/.test(op.opertKornNm) : false;
|
|
};
|
|
|
|
/** ruleBlocks 기준 등록 벨리데이션 + 저장 rows 생성 */
|
|
const validateAndBuildSaveRows = (
|
|
blocks: RuleBlock[],
|
|
trgtStatsCd: string,
|
|
cmprOps: OperatorItem[],
|
|
connectorOps: ConnectorOperatorItem[],
|
|
): { ok: true; rows: SaveDetailRow[] } | { ok: false; message: string } => {
|
|
const cmprSet = new Set(cmprOps.map((o) => o.opertSymbl));
|
|
const connectorSet = new Set(connectorOps.map((o) => o.opertSymbl));
|
|
|
|
const formulaBlockIndexes = blocks
|
|
.map((b, i) => (b.kind === "formula" ? i : -1))
|
|
.filter((i) => i >= 0);
|
|
|
|
if (formulaBlockIndexes.length === 0) {
|
|
return { ok: false, message: "수식이 없습니다." };
|
|
}
|
|
|
|
if (blocks[0]?.kind === "connector") {
|
|
return {
|
|
ok: false,
|
|
message: "조건·논리 연산자는 수식 앞에 올 수 없습니다.",
|
|
};
|
|
}
|
|
if (blocks.at(-1)?.kind === "connector") {
|
|
return {
|
|
ok: false,
|
|
message:
|
|
"마지막은 수식이어야 합니다. 맨 끝의 조건·논리 연산자를 제거해주세요.",
|
|
};
|
|
}
|
|
|
|
let conditionOperatorCount = 0;
|
|
for (let i = 0; i < blocks.length; i += 1) {
|
|
const block = blocks[i];
|
|
if (!block || block.kind !== "connector") continue;
|
|
|
|
if (!connectorSet.has(block.value)) {
|
|
return {
|
|
ok: false,
|
|
message: `알 수 없는 조건·논리 연산자입니다. (${block.value})`,
|
|
};
|
|
}
|
|
if (isConditionConnectorValue(block.value, connectorOps)) {
|
|
conditionOperatorCount += 1;
|
|
}
|
|
|
|
const prev = blocks[i - 1];
|
|
const next = blocks[i + 1];
|
|
if (prev?.kind !== "formula" || next?.kind !== "formula") {
|
|
return {
|
|
ok: false,
|
|
message:
|
|
"조건·논리 연산자는 수식과 수식 사이에만 올 수 있습니다.",
|
|
};
|
|
}
|
|
}
|
|
|
|
if (conditionOperatorCount > 1) {
|
|
return {
|
|
ok: false,
|
|
message: "조건연산자는 규칙 전체에서 1개만 선택할 수 있습니다.",
|
|
};
|
|
}
|
|
|
|
for (let fi = 0; fi < formulaBlockIndexes.length; fi += 1) {
|
|
const blockIndex = formulaBlockIndexes[fi]!;
|
|
const block = blocks[blockIndex] as FormulaBlock;
|
|
const ordinal = fi + 1;
|
|
const korn = block.data?.kornArtopnEqtnCn?.trim() ?? "";
|
|
const eng = block.data?.engArtopnEqtnCn?.trim() ?? "";
|
|
|
|
if (!korn || !eng) {
|
|
return {
|
|
ok: false,
|
|
message: `수식이 비어있는 항목이 있습니다.\n(순번: ${ordinal})`,
|
|
};
|
|
}
|
|
|
|
const cmpr = block.data?.cmprOpert ?? null;
|
|
if (cmpr != null && cmpr !== "" && !cmprSet.has(cmpr)) {
|
|
return {
|
|
ok: false,
|
|
message: `알 수 없는 비교 연산자입니다. (${cmpr})\n(순번: ${ordinal})`,
|
|
};
|
|
}
|
|
|
|
const next = blocks[blockIndex + 1];
|
|
const isLast = fi === formulaBlockIndexes.length - 1;
|
|
const isUnary =
|
|
cmpr != null && UNARY_TERMINAL_OPERATORS.has(cmpr);
|
|
|
|
if (cmpr && next?.kind === "connector") {
|
|
return {
|
|
ok: false,
|
|
message: `비교 연산자와 조건·논리 연산자를 같은 수식에 함께 둘 수 없습니다.\n(순번: ${ordinal})`,
|
|
};
|
|
}
|
|
|
|
if (isUnary && !isLast) {
|
|
return {
|
|
ok: false,
|
|
message: `단항 연산자(is_null, !is_null, floor)는 맨 마지막 수식에만 사용할 수 있습니다.\n(순번: ${ordinal})`,
|
|
};
|
|
}
|
|
|
|
if (!isLast) {
|
|
const linkedByCmpr =
|
|
cmpr != null && cmpr !== "" && !isUnary;
|
|
const linkedByConnector = next?.kind === "connector";
|
|
if (!linkedByCmpr && !linkedByConnector) {
|
|
return {
|
|
ok: false,
|
|
message: `수식 사이에 비교 연산자 또는 조건·논리 연산자가 필요합니다.\n(순번: ${ordinal})`,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
if (formulaBlockIndexes.length === 1) {
|
|
const only = blocks[formulaBlockIndexes[0]!] as FormulaBlock;
|
|
const cmpr = only.data?.cmprOpert ?? null;
|
|
if (cmpr == null || !UNARY_TERMINAL_OPERATORS.has(cmpr)) {
|
|
return {
|
|
ok: false,
|
|
message: "해당 연산자는 최소 2개 항목이 필요합니다.",
|
|
};
|
|
}
|
|
}
|
|
|
|
const rows: SaveDetailRow[] = [];
|
|
let seq = 1;
|
|
for (let i = 0; i < blocks.length; i += 1) {
|
|
const block = blocks[i];
|
|
if (!block || block.kind !== "formula") continue;
|
|
|
|
const next = blocks[i + 1];
|
|
const connectorValue =
|
|
next?.kind === "connector" ? next.value : null;
|
|
let cmprOpert = block.data?.cmprOpert ?? connectorValue ?? null;
|
|
|
|
const hasFollowingFormula = blocks
|
|
.slice(i + 1)
|
|
.some((b) => b.kind === "formula");
|
|
if (
|
|
!hasFollowingFormula &&
|
|
cmprOpert != null &&
|
|
!UNARY_TERMINAL_OPERATORS.has(cmprOpert)
|
|
) {
|
|
// 마지막 수식의 이항 비교연산자는 저장하지 않음
|
|
cmprOpert = null;
|
|
}
|
|
|
|
rows.push({
|
|
eqtnCombSeq: seq,
|
|
trgtStatsCd,
|
|
refStatsCd: block.data?.refStatsCd ?? null,
|
|
kornArtopnEqtnCn: block.data?.kornArtopnEqtnCn ?? "",
|
|
engArtopnEqtnCn: block.data?.engArtopnEqtnCn ?? "",
|
|
cmprOpert,
|
|
grpByYn: block.groupBy ? "Y" : "N",
|
|
});
|
|
seq += 1;
|
|
}
|
|
|
|
return { ok: true, rows };
|
|
};
|
|
|
|
/** cmprOpert가 조건·논리 연산자면 수식은 연산자 없음 + 뒤에 커넥터 블록 생성 */
|
|
const buildRuleBlocksFromFormulas = (
|
|
list: (VerifyDetailResponseDto | null)[],
|
|
connectorOps: ConnectorOperatorItem[],
|
|
nextId: () => string,
|
|
): RuleBlock[] => {
|
|
const blocks: RuleBlock[] = [];
|
|
|
|
for (const row of list) {
|
|
const cmpr = row?.cmprOpert ?? null;
|
|
const moveToConnector = isConnectorOperatorSymbol(cmpr, connectorOps);
|
|
|
|
blocks.push({
|
|
kind: "formula",
|
|
id: nextId(),
|
|
data: row
|
|
? moveToConnector
|
|
? { ...row, cmprOpert: null }
|
|
: row
|
|
: null,
|
|
groupBy: row?.grpByYn === "Y",
|
|
});
|
|
|
|
if (moveToConnector && cmpr) {
|
|
blocks.push({
|
|
kind: "connector",
|
|
id: nextId(),
|
|
value: cmpr,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (blocks.length === 0) {
|
|
return [
|
|
{
|
|
kind: "formula",
|
|
id: nextId(),
|
|
data: null,
|
|
groupBy: false,
|
|
},
|
|
];
|
|
}
|
|
|
|
return blocks;
|
|
};
|
|
|
|
interface DetailComponentProps {
|
|
selectedVrfcRow: number | null;
|
|
}
|
|
|
|
const DetailComponent = ({ selectedVrfcRow }: DetailComponentProps) => {
|
|
const [detailInfo, setDetailInfo] = useState<VerifyDetailData | null>(null);
|
|
/** 수식 / 조건·논리 연산자를 순서대로 담는 UI 블록 (서로 강제 교차하지 않음) */
|
|
const [ruleBlocks, setRuleBlocks] = useState<RuleBlock[]>([
|
|
{ kind: "formula", id: "init-0", data: null, groupBy: false },
|
|
]);
|
|
const [hasPersistedDetails, setHasPersistedDetails] =
|
|
useState<boolean>(false);
|
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
|
const [showFomulaDialog, setShowFomulaDialog] = useState<boolean>(false);
|
|
const [selectedFormula, setSelectedFormula] =
|
|
useState<VerifyDetailResponseDto | null>(null);
|
|
const [selectedFormulaBlockId, setSelectedFormulaBlockId] = useState<
|
|
string | null
|
|
>(null);
|
|
const [connectorOperators, setConnectorOperators] = useState<
|
|
ConnectorOperatorItem[]
|
|
>([]);
|
|
const { confirm } = useConfirm();
|
|
|
|
const { setMessage: alert } = useAlertStore((state) => state);
|
|
|
|
const persistedFormulaSignatureRef = useRef<string>("");
|
|
const blockIdRef = useRef(0);
|
|
const nextBlockId = () => `block-${++blockIdRef.current}`;
|
|
|
|
/** API 심볼/한글명으로 조건연산자 판별 (기준·비교 존, 1개 제한, 안내문구) */
|
|
const isConditionOperator = (value: string) => {
|
|
const normalized = value.trim().toLowerCase();
|
|
if (
|
|
normalized === "cond" ||
|
|
normalized === "condition" ||
|
|
normalized === "조건" ||
|
|
normalized === "조건연산자"
|
|
) {
|
|
return true;
|
|
}
|
|
const op = connectorOperators.find((o) => o.opertSymbl === value);
|
|
if (!op) return false;
|
|
return /조건/.test(op.opertKornNm);
|
|
};
|
|
|
|
const formulaBlocks = ruleBlocks.filter(
|
|
(b): b is FormulaBlock => b.kind === "formula",
|
|
);
|
|
const formulaList = formulaBlocks.map((b) => b.data);
|
|
const groupByFlags = formulaBlocks.map((b) => b.groupBy);
|
|
|
|
/** like / !like 직후 수식일 때 '사용자 직접 입력' 체크박스 표시 */
|
|
const allowLikeDirectInput = useMemo(() => {
|
|
if (!selectedFormulaBlockId) return false;
|
|
const blockIndex = ruleBlocks.findIndex(
|
|
(b) => b.kind === "formula" && b.id === selectedFormulaBlockId,
|
|
);
|
|
if (blockIndex < 0) return false;
|
|
const prevCmprOpert = getPreviousFormulaCmprOpert(
|
|
ruleBlocks,
|
|
blockIndex,
|
|
);
|
|
return isLikePatternOperator(prevCmprOpert);
|
|
}, [ruleBlocks, selectedFormulaBlockId]);
|
|
|
|
const conditionBoundaryBlockIndex = ruleBlocks.findIndex(
|
|
(b) => b.kind === "connector" && isConditionOperator(b.value),
|
|
);
|
|
const hasConditionOperator = conditionBoundaryBlockIndex !== -1;
|
|
|
|
const zoneForBlockIndex = (blockIndex: number): ClauseZone => {
|
|
// 조건연산자 미선택 시 전체를 비교대상과 동일하게 처리
|
|
if (!hasConditionOperator) return "zone-result";
|
|
return blockIndex < conditionBoundaryBlockIndex
|
|
? "zone-condition"
|
|
: "zone-result";
|
|
};
|
|
|
|
const normalizeFormulaListForCompare = (
|
|
list: (VerifyDetailResponseDto | null)[],
|
|
flags: boolean[] = [],
|
|
) => {
|
|
return list.map((row, idx) =>
|
|
row
|
|
? {
|
|
vrfcDtlSn: row.vrfcDtlSn ?? null,
|
|
refStatsCd: row.refStatsCd ?? null,
|
|
kornArtopnEqtnCn: row.kornArtopnEqtnCn ?? "",
|
|
engArtopnEqtnCn: row.engArtopnEqtnCn ?? "",
|
|
cmprOpert: row.cmprOpert ?? null,
|
|
grpByYn: flags[idx] ? "Y" : "N",
|
|
}
|
|
: null,
|
|
);
|
|
};
|
|
|
|
const handleSaveClick = async () => {
|
|
const trgtStatsCd = detailInfo?.commonInfo.trgtStatsCd;
|
|
if (!trgtStatsCd) {
|
|
alert("대상 통계항목(trgtStatsCd) 정보가 없습니다.");
|
|
return;
|
|
}
|
|
|
|
const [cmprOps, connectorOps] = await Promise.all([
|
|
loadCmprOperators(),
|
|
loadConnectorOperators(),
|
|
]);
|
|
setConnectorOperators(connectorOps);
|
|
|
|
const validated = validateAndBuildSaveRows(
|
|
ruleBlocks,
|
|
trgtStatsCd,
|
|
cmprOps,
|
|
connectorOps,
|
|
);
|
|
if (!validated.ok) {
|
|
alert(validated.message);
|
|
return;
|
|
}
|
|
const { rows } = validated;
|
|
|
|
// 마지막 수식의 이항 비교연산자 제거를 UI에도 반영
|
|
setRuleBlocks((prev) => {
|
|
let lastFormulaIdx = -1;
|
|
for (let i = prev.length - 1; i >= 0; i -= 1) {
|
|
if (prev[i]?.kind === "formula") {
|
|
lastFormulaIdx = i;
|
|
break;
|
|
}
|
|
}
|
|
if (lastFormulaIdx < 0) return prev;
|
|
const last = prev[lastFormulaIdx];
|
|
if (!last || last.kind !== "formula" || !last.data) return prev;
|
|
const cmpr = last.data.cmprOpert;
|
|
if (cmpr == null || UNARY_TERMINAL_OPERATORS.has(cmpr)) {
|
|
return prev;
|
|
}
|
|
const next = [...prev];
|
|
next[lastFormulaIdx] = {
|
|
...last,
|
|
data: { ...last.data, 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;
|
|
const nextList = list?.length ? [...list] : [null];
|
|
|
|
const connectorOps = await loadConnectorOperators();
|
|
setConnectorOperators(connectorOps);
|
|
|
|
const blocks = buildRuleBlocksFromFormulas(
|
|
nextList,
|
|
connectorOps,
|
|
nextBlockId,
|
|
);
|
|
setRuleBlocks(blocks);
|
|
|
|
setHasPersistedDetails((list?.length ?? 0) > 0);
|
|
|
|
const transformedFormulas = blocks
|
|
.filter((b): b is FormulaBlock => b.kind === "formula")
|
|
.map((b) => b.data);
|
|
const transformedFlags = blocks
|
|
.filter((b): b is FormulaBlock => b.kind === "formula")
|
|
.map((b) => b.groupBy);
|
|
persistedFormulaSignatureRef.current = JSON.stringify(
|
|
normalizeFormulaListForCompare(
|
|
transformedFormulas,
|
|
transformedFlags,
|
|
),
|
|
);
|
|
} else {
|
|
alert(response.message || "데이터 조회 중 오류가 발생했습니다.");
|
|
}
|
|
} catch {
|
|
alert("데이터 조회 중 오류가 발생했습니다.");
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleClickVrfcDetail = (
|
|
formula?: VerifyDetailResponseDto | null,
|
|
blockId?: string,
|
|
) => {
|
|
setSelectedFormula(formula ?? null);
|
|
setSelectedFormulaBlockId(blockId ?? null);
|
|
setShowFomulaDialog(true);
|
|
};
|
|
|
|
const handleFormulaConfirm = (payload: {
|
|
kornArtopnEqtnCn: string;
|
|
engArtopnEqtnCn: string;
|
|
refStatsCd: string | null;
|
|
}) => {
|
|
setRuleBlocks((prev) => {
|
|
const i = prev.findIndex(
|
|
(b) =>
|
|
b.kind === "formula" && b.id === selectedFormulaBlockId,
|
|
);
|
|
if (i < 0) return prev;
|
|
const item = prev[i];
|
|
if (!item || item.kind !== "formula") return prev;
|
|
const next = [...prev];
|
|
next[i] = {
|
|
...item,
|
|
data: item.data
|
|
? {
|
|
...item.data,
|
|
kornArtopnEqtnCn: payload.kornArtopnEqtnCn,
|
|
engArtopnEqtnCn: payload.engArtopnEqtnCn,
|
|
refStatsCd: payload.refStatsCd,
|
|
}
|
|
: {
|
|
vrfcDtlSn: null,
|
|
refStatsCd: payload.refStatsCd,
|
|
refStatsNm: null,
|
|
kornArtopnEqtnCn: payload.kornArtopnEqtnCn,
|
|
engArtopnEqtnCn: payload.engArtopnEqtnCn,
|
|
cmprOpert: null,
|
|
grpByYn: item.groupBy ? "Y" : "N",
|
|
},
|
|
};
|
|
return next;
|
|
});
|
|
setShowFomulaDialog(false);
|
|
setSelectedFormula(null);
|
|
setSelectedFormulaBlockId(null);
|
|
};
|
|
|
|
const handleResetClick = async () => {
|
|
const currentSignature = JSON.stringify(
|
|
normalizeFormulaListForCompare(formulaList, groupByFlags),
|
|
);
|
|
if (currentSignature === persistedFormulaSignatureRef.current) {
|
|
alert("변경된 내용이 없습니다.");
|
|
return;
|
|
}
|
|
|
|
if (
|
|
await confirm(
|
|
`변경 전 데이터로 리셋하시겠습니까?\n등록 완료된 데이터는 등록 전 데이터를 불러올 수 없습니다.`,
|
|
)
|
|
) {
|
|
getVrfcDetail();
|
|
}
|
|
};
|
|
|
|
const handleAddClause = () => {
|
|
setRuleBlocks((prev) => [
|
|
...prev,
|
|
{
|
|
kind: "formula",
|
|
id: nextBlockId(),
|
|
data: null,
|
|
groupBy: false,
|
|
},
|
|
]);
|
|
};
|
|
|
|
/** 항상 목록 맨 뒤(마지막 수식 뒤 포함)에 조건·논리 연산자 Select 추가 */
|
|
const handleAddConnector = () => {
|
|
if (connectorOperators.length === 0) {
|
|
alert("조건·논리 연산자 목록을 불러오지 못했습니다.");
|
|
return;
|
|
}
|
|
const defaultValue =
|
|
connectorOperators.find((op) => op.opertSymbl.toLowerCase() === "and")
|
|
?.opertSymbl ?? connectorOperators[0]!.opertSymbl;
|
|
setRuleBlocks((prev) => [
|
|
...prev,
|
|
{
|
|
kind: "connector",
|
|
id: nextBlockId(),
|
|
value: defaultValue,
|
|
},
|
|
]);
|
|
};
|
|
|
|
const handleRemoveBlock = (blockId: string) => {
|
|
setRuleBlocks((prev) => {
|
|
const next = prev.filter((b) => b.id !== blockId);
|
|
if (next.some((b) => b.kind === "formula")) return next;
|
|
return [
|
|
{
|
|
kind: "formula",
|
|
id: nextBlockId(),
|
|
data: null,
|
|
groupBy: false,
|
|
},
|
|
];
|
|
});
|
|
};
|
|
|
|
useEffect(() => {
|
|
void loadConnectorOperators().then(setConnectorOperators);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (selectedVrfcRow != null) {
|
|
getVrfcDetail();
|
|
}
|
|
}, [selectedVrfcRow]);
|
|
|
|
return (
|
|
<>
|
|
<div className="card card-border !mx-0 flex h-full min-h-0 w-1/2 flex-col">
|
|
<div className="card-header shrink-0">
|
|
<h3 className="text-2xl font-medium text-label">
|
|
검증 상세 정보
|
|
</h3>
|
|
</div>
|
|
<div className="card !m-4 flex min-h-0 flex-1 flex-col !p-0">
|
|
{selectedVrfcRow ? (
|
|
isLoading ? (
|
|
<div className="absolute inset-0 z-50 flex items-center justify-center rounded-lg p-10 [data-theme='black']:bg-transparent">
|
|
<div className="relative size-[5rem]">
|
|
<LoadingWithAnimation color="primary" />
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="m-2 flex min-h-0 flex-1 flex-col gap-4">
|
|
<div className="shrink-0">
|
|
<Table
|
|
variant="secondary"
|
|
className="table-fixed"
|
|
>
|
|
<colgroup>
|
|
<col width="25%" />
|
|
<col width="25%" />
|
|
<col width="25%" />
|
|
<col width="25%" />
|
|
</colgroup>
|
|
<TableBody>
|
|
<TableRow>
|
|
<TableHead>검증명</TableHead>
|
|
<TableCell className="text-center">
|
|
{detailInfo?.commonInfo
|
|
.vrfcNm ?? "-"}
|
|
</TableCell>
|
|
<TableHead>통계항목</TableHead>
|
|
<TableCell className="text-center">
|
|
{detailInfo?.commonInfo
|
|
.trgtStatsNm ?? "-"}
|
|
</TableCell>
|
|
</TableRow>
|
|
<TableRow>
|
|
<TableHead>검증분류</TableHead>
|
|
<TableCell className="text-center">
|
|
{detailInfo?.commonInfo
|
|
.vrfcClsfNm ?? "-"}
|
|
</TableCell>
|
|
<TableHead>
|
|
소수점처리방식
|
|
</TableHead>
|
|
<TableCell className="text-center">
|
|
{detailInfo?.commonInfo
|
|
.dcptDigtCnt === 0
|
|
? "소수점 생략"
|
|
: `${detailInfo?.commonInfo.dcptDigtCnt}자리에서 ${detailInfo?.commonInfo.dcptPrcsMthNm}`}
|
|
</TableCell>
|
|
</TableRow>
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
|
|
<div className="flex min-h-0 flex-1 flex-col overflow-auto px-1">
|
|
<div className="mb-1 font-bold text-[#33373f]">
|
|
수식 정보
|
|
</div>
|
|
<div className="mb-3.5 flex gap-4 text-[#6b7280]">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="inline-block size-3 rounded-sm bg-[#dbe8ff]" />
|
|
기준 대상
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="inline-block size-3 rounded-sm bg-[#ffe9d6]" />
|
|
비교 대상
|
|
</div>
|
|
</div>
|
|
|
|
{ruleBlocks.map((block, blockIndex) => {
|
|
if (block.kind === "connector") {
|
|
return (
|
|
<div
|
|
key={block.id}
|
|
className="flex items-center justify-center gap-2 pt-1 pb-2"
|
|
>
|
|
<div className="h-px flex-1 bg-[#e2e5ea]" />
|
|
<Select
|
|
value={block.value}
|
|
onValueChange={(v) => {
|
|
setRuleBlocks(
|
|
(prev) => {
|
|
const next =
|
|
[
|
|
...prev,
|
|
];
|
|
const cur =
|
|
next[
|
|
blockIndex
|
|
];
|
|
if (
|
|
!cur ||
|
|
cur.kind !==
|
|
"connector"
|
|
) {
|
|
return prev;
|
|
}
|
|
next[
|
|
blockIndex
|
|
] = {
|
|
...cur,
|
|
value: v,
|
|
};
|
|
return next;
|
|
},
|
|
);
|
|
}}
|
|
>
|
|
<SelectTrigger
|
|
className={`h-[3.2rem] w-[150px] rounded-full border text-center font-semibold focus:ring-0 ${isConditionOperator(
|
|
block.value,
|
|
)
|
|
? "border-[#3a5bc7] bg-[#eef3ff] text-[#3a5bc7]"
|
|
: "border-[#d8dce3] bg-white text-[#33373f]"
|
|
}`}
|
|
>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent className="w-[150px]">
|
|
{connectorOperators.map(
|
|
(opt) => {
|
|
const disabled =
|
|
isConditionOperator(
|
|
opt.opertSymbl,
|
|
) &&
|
|
conditionBoundaryBlockIndex !==
|
|
-1 &&
|
|
conditionBoundaryBlockIndex !==
|
|
blockIndex;
|
|
return (
|
|
<SelectItem
|
|
key={
|
|
opt.opertSn
|
|
}
|
|
value={
|
|
opt.opertSymbl
|
|
}
|
|
disabled={
|
|
disabled
|
|
}
|
|
>
|
|
{
|
|
opt.opertKornNm
|
|
}
|
|
</SelectItem>
|
|
);
|
|
},
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
<Button
|
|
type="button"
|
|
size="icon"
|
|
color="red"
|
|
className="size-[3.2rem] shrink-0 rounded-full"
|
|
onClick={() =>
|
|
handleRemoveBlock(
|
|
block.id,
|
|
)
|
|
}
|
|
>
|
|
<i className="ri-subtract-fill text-[16px]" />
|
|
</Button>
|
|
<div className="h-px flex-1 bg-[#e2e5ea]" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const formulaIndex = ruleBlocks
|
|
.slice(0, blockIndex)
|
|
.filter((b) => b.kind === "formula")
|
|
.length;
|
|
return (
|
|
<div key={block.id}>
|
|
<FormulaComponents
|
|
data={block.data}
|
|
index={formulaIndex}
|
|
zone={zoneForBlockIndex(
|
|
blockIndex,
|
|
)}
|
|
groupBy={block.groupBy}
|
|
onGroupByChange={(
|
|
checked,
|
|
) => {
|
|
setRuleBlocks(
|
|
(prev) => {
|
|
const next = [
|
|
...prev,
|
|
];
|
|
const cur =
|
|
next[
|
|
blockIndex
|
|
];
|
|
if (
|
|
!cur ||
|
|
cur.kind !==
|
|
"formula"
|
|
) {
|
|
return prev;
|
|
}
|
|
next[
|
|
blockIndex
|
|
] = {
|
|
...cur,
|
|
groupBy:
|
|
checked,
|
|
};
|
|
return next;
|
|
},
|
|
);
|
|
}}
|
|
onOperatorChange={(v) => {
|
|
setRuleBlocks(
|
|
(prev) => {
|
|
const next = [
|
|
...prev,
|
|
];
|
|
const cur =
|
|
next[
|
|
blockIndex
|
|
];
|
|
if (
|
|
!cur ||
|
|
cur.kind !==
|
|
"formula"
|
|
) {
|
|
return prev;
|
|
}
|
|
const cmprOpert =
|
|
v ===
|
|
"none"
|
|
? null
|
|
: v;
|
|
next[
|
|
blockIndex
|
|
] = {
|
|
...cur,
|
|
data: cur.data
|
|
? {
|
|
...cur.data,
|
|
cmprOpert,
|
|
}
|
|
: {
|
|
vrfcDtlSn:
|
|
null,
|
|
refStatsCd:
|
|
null,
|
|
refStatsNm:
|
|
null,
|
|
kornArtopnEqtnCn:
|
|
"",
|
|
engArtopnEqtnCn:
|
|
"",
|
|
cmprOpert,
|
|
grpByYn:
|
|
cur.groupBy
|
|
? "Y"
|
|
: "N",
|
|
},
|
|
};
|
|
return next;
|
|
},
|
|
);
|
|
}}
|
|
onDelete={() =>
|
|
handleRemoveBlock(
|
|
block.id,
|
|
)
|
|
}
|
|
handleFomulaDialog={(
|
|
show,
|
|
formula,
|
|
) => {
|
|
if (show) {
|
|
handleClickVrfcDetail(
|
|
formula,
|
|
block.id,
|
|
);
|
|
} else {
|
|
setShowFomulaDialog(
|
|
false,
|
|
);
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
<div className="mt-2.5 flex flex-wrap items-center justify-center gap-2">
|
|
<Button
|
|
type="button"
|
|
className="h-[3.6rem] gap-1.5 px-4"
|
|
onClick={handleAddClause}
|
|
>
|
|
<i className="ri-play-list-add-fill text-[16px]" />
|
|
수식 추가
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
color="gray"
|
|
className="h-[3.6rem] gap-1.5 px-4"
|
|
onClick={handleAddConnector}
|
|
>
|
|
<i className="ri-git-commit-line text-[16px]" />
|
|
조건 & 논리 연산자 추가
|
|
</Button>
|
|
</div>
|
|
<div className="mt-4 border-t border-[#eef0f3] px-1 py-3 text-[#8a8f98]">
|
|
조건연산자는 규칙 전체에서 1개만 선택할
|
|
수 있습니다. 조건연산자를 기준으로
|
|
앞쪽은 기준 대상, 뒤쪽은 비교대상으로
|
|
구분됩니다.
|
|
<span className="font-semibold block text-[#f24e5b]">
|
|
검증 결과는 비교대상 기준으로 입력값과 기준값을 저장합니다.
|
|
</span>
|
|
{!hasConditionOperator && (
|
|
<span className="font-semibold block text-[#f24e5b]">
|
|
{" "}
|
|
현재 조건연산자가 선택되지 않아
|
|
전체가 비교대상과 동일하게
|
|
처리됩니다.
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex shrink-0 items-center justify-end gap-2">
|
|
<Button
|
|
type="button"
|
|
color="red"
|
|
title="리셋"
|
|
onClick={handleResetClick}
|
|
>
|
|
<i className="diveicon di-arrow-reset" />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
onClick={handleSaveClick}
|
|
>
|
|
등록
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
) : (
|
|
<div className="flex h-full items-center justify-center">
|
|
<NoData />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<FomulaDialog
|
|
key={
|
|
showFomulaDialog
|
|
? (selectedFormula?.vrfcDtlSn ?? "new")
|
|
: "closed"
|
|
}
|
|
open={showFomulaDialog}
|
|
onOpenChange={(open) => {
|
|
setShowFomulaDialog(open);
|
|
if (!open) {
|
|
setSelectedFormula(null);
|
|
setSelectedFormulaBlockId(null);
|
|
}
|
|
}}
|
|
data={selectedFormula}
|
|
trgtStatsCd={detailInfo?.commonInfo.trgtStatsCd}
|
|
trgtStatsNm={detailInfo?.commonInfo.trgtStatsNm}
|
|
allowLikeDirectInput={allowLikeDirectInput}
|
|
onConfirm={handleFormulaConfirm}
|
|
/>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default DetailComponent;
|