Files
frontend/apps/st/src/components/statistics/verification/generalVerification/DetailComponent.tsx
T

1210 lines
53 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?.cmprArtopnCn ?? null;
}
}
return null;
};
const isLikePatternOperator = (value: string | null | undefined) =>
value != null && LIKE_PATTERN_OPERATORS.has(value);
type OperatorItem = {
artopnSn: string;
artopnSymbl: string;
artopnKornNm: string;
opertSeCd?: string;
};
type ConnectorOperatorItem = OperatorItem;
type SaveDetailRow = {
fmlaCombSeq: number;
trgtStatsCd: string;
refStatsCd: string | null;
kornArtopnFmlaCn: string;
engArtopnFmlaCn: string;
cmprArtopnCn: 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.artopnSymbl === 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.artopnSymbl === value);
return op ? /조건/.test(op.artopnKornNm) : 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.artopnSymbl));
const connectorSet = new Set(connectorOps.map((o) => o.artopnSymbl));
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?.kornArtopnFmlaCn?.trim() ?? "";
const eng = block.data?.engArtopnFmlaCn?.trim() ?? "";
if (!korn || !eng) {
return {
ok: false,
message: `수식이 비어있는 항목이 있습니다.\n(순번: ${ordinal})`,
};
}
const cmpr = block.data?.cmprArtopnCn ?? 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?.cmprArtopnCn ?? 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?.cmprArtopnCn ?? 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({
fmlaCombSeq: seq,
trgtStatsCd,
refStatsCd: block.data?.refStatsCd ?? null,
kornArtopnFmlaCn: block.data?.kornArtopnFmlaCn ?? "",
engArtopnFmlaCn: block.data?.engArtopnFmlaCn ?? "",
cmprArtopnCn: 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?.cmprArtopnCn ?? null;
const moveToConnector = isConnectorOperatorSymbol(cmpr, connectorOps);
blocks.push({
kind: "formula",
id: nextId(),
data: row
? moveToConnector
? { ...row, cmprArtopnCn: 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;
mutationBlocked?: boolean;
blockedMessage?: string | null;
}
const DetailComponent = ({
selectedVrfcRow,
mutationBlocked = false,
blockedMessage = null,
}: 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.artopnSymbl === value);
if (!op) return false;
return /조건/.test(op.artopnKornNm);
};
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,
kornArtopnFmlaCn: row.kornArtopnFmlaCn ?? "",
engArtopnFmlaCn: row.engArtopnFmlaCn ?? "",
cmprArtopnCn: row.cmprArtopnCn ?? null,
grpByYn: flags[idx] ? "Y" : "N",
}
: null,
);
};
const handleSaveClick = async () => {
if (mutationBlocked) {
alert(
blockedMessage ??
"입력기간에는 검증항목을 등록하거나 수정할 수 없습니다.",
);
return;
}
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.cmprArtopnCn;
if (cmpr == null || UNARY_TERMINAL_OPERATORS.has(cmpr)) {
return prev;
}
const next = [...prev];
next[lastFormulaIdx] = {
...last,
data: { ...last.data, cmprArtopnCn: null },
};
return next;
});
if (await confirm("등록하시겠습니까?")) {
try {
const userInfo = await getUserInfo();
let response;
const vrfcSn = detailInfo?.commonInfo.vrfcSn;
if (vrfcSn == null) {
alert("검증 일련번호가 없습니다.");
return;
}
if (!hasPersistedDetails) {
response =
await generalverificationApiService.RegistVrfcDetail(vrfcSn, {
detailList: rows,
rgtrUuid: userInfo.userUnqId,
});
} else {
response =
await generalverificationApiService.UpdateVrfcDetail(vrfcSn, {
detailList: rows,
mdfrUuid: 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,
) => {
if (mutationBlocked) {
alert(
blockedMessage ??
"입력기간에는 검증항목을 등록하거나 수정할 수 없습니다.",
);
return;
}
setSelectedFormula(formula ?? null);
setSelectedFormulaBlockId(blockId ?? null);
setShowFomulaDialog(true);
};
const handleFormulaConfirm = (payload: {
kornArtopnFmlaCn: string;
engArtopnFmlaCn: string;
refStatsCd: string | null;
refStatsNm: 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,
kornArtopnFmlaCn: payload.kornArtopnFmlaCn,
engArtopnFmlaCn: payload.engArtopnFmlaCn,
refStatsCd: payload.refStatsCd,
refStatsNm: payload.refStatsNm,
}
: {
vrfcDtlSn: null,
refStatsCd: payload.refStatsCd,
refStatsNm: payload.refStatsNm,
kornArtopnFmlaCn: payload.kornArtopnFmlaCn,
engArtopnFmlaCn: payload.engArtopnFmlaCn,
cmprArtopnCn: null,
grpByYn: item.groupBy ? "Y" : "N",
},
};
return next;
});
setShowFomulaDialog(false);
setSelectedFormula(null);
setSelectedFormulaBlockId(null);
};
const handleResetClick = async () => {
if (mutationBlocked) {
alert(
blockedMessage ??
"입력기간에는 검증항목을 등록하거나 수정할 수 없습니다.",
);
return;
}
const currentSignature = JSON.stringify(
normalizeFormulaListForCompare(formulaList, groupByFlags),
);
if (currentSignature === persistedFormulaSignatureRef.current) {
alert("변경된 내용이 없습니다.");
return;
}
if (
await confirm(
`변경 전 데이터로 리셋하시겠습니까?\n등록 완료된 데이터는 등록 전 데이터를 불러올 수 없습니다.`,
)
) {
getVrfcDetail();
}
};
const handleAddClause = () => {
if (mutationBlocked) {
alert(
blockedMessage ??
"입력기간에는 검증항목을 등록하거나 수정할 수 없습니다.",
);
return;
}
setRuleBlocks((prev) => [
...prev,
{
kind: "formula",
id: nextBlockId(),
data: null,
groupBy: false,
},
]);
};
/** 항상 목록 맨 뒤(마지막 수식 뒤 포함)에 조건·논리 연산자 Select 추가 */
const handleAddConnector = () => {
if (mutationBlocked) {
alert(
blockedMessage ??
"입력기간에는 검증항목을 등록하거나 수정할 수 없습니다.",
);
return;
}
if (connectorOperators.length === 0) {
alert("조건·논리 연산자 목록을 불러오지 못했습니다.");
return;
}
const defaultValue =
connectorOperators.find((op) => op.artopnSymbl.toLowerCase() === "and")
?.artopnSymbl ?? connectorOperators[0]!.artopnSymbl;
setRuleBlocks((prev) => [
...prev,
{
kind: "connector",
id: nextBlockId(),
value: defaultValue,
},
]);
};
const handleRemoveBlock = (blockId: string) => {
if (mutationBlocked) {
alert(
blockedMessage ??
"입력기간에는 검증항목을 등록하거나 수정할 수 없습니다.",
);
return;
}
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
.dcptDgt === 0
? "소수점 생략"
: `${detailInfo?.commonInfo.dcptDgt}자리에서 ${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}
disabled={mutationBlocked}
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.artopnSymbl,
) &&
conditionBoundaryBlockIndex !==
-1 &&
conditionBoundaryBlockIndex !==
blockIndex;
return (
<SelectItem
key={
opt.artopnSn
}
value={
opt.artopnSymbl
}
disabled={
disabled
}
>
{
opt.artopnKornNm
}
</SelectItem>
);
},
)}
</SelectContent>
</Select>
<Button
type="button"
size="icon"
color="red"
className="size-[3.2rem] shrink-0 rounded-full"
disabled={mutationBlocked}
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}
disabled={mutationBlocked}
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,
cmprArtopnCn: cmprOpert,
}
: {
vrfcDtlSn:
null,
refStatsCd:
null,
refStatsNm:
null,
kornArtopnFmlaCn:
"",
engArtopnFmlaCn:
"",
cmprArtopnCn: cmprOpert,
grpByYn:
cur.groupBy
? "Y"
: "N",
},
};
return next;
},
);
}}
onDelete={() =>
handleRemoveBlock(
block.id,
)
}
handleFomulaDialog={(
show,
formula,
) => {
if (show) {
handleClickVrfcDetail(
formula,
block.id,
);
} else {
setShowFomulaDialog(
false,
);
}
}}
vrfcClsfCd={detailInfo?.commonInfo.vrfcClsfCd}
/>
</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"
disabled={mutationBlocked}
title={
mutationBlocked
? (blockedMessage ?? undefined)
: undefined
}
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"
disabled={mutationBlocked}
title={
mutationBlocked
? (blockedMessage ?? undefined)
: undefined
}
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={
mutationBlocked
? (blockedMessage ?? "리셋")
: "리셋"
}
disabled={mutationBlocked}
onClick={handleResetClick}
>
<i className="diveicon di-arrow-reset" />
</Button>
<Button
type="button"
disabled={mutationBlocked}
title={
mutationBlocked
? (blockedMessage ?? undefined)
: undefined
}
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;