Files
invyone/frontend/components/v2/ConditionalConfigPanel.tsx
T
DDD1542 2348800e68
Build & Deploy to K8s / build-and-deploy (push) Successful in 9m22s
refactor(common-code): 마스터-디테일 재설계 — code_info(그룹) + code_detail(재귀 트리)
카테고리/캐스케이딩 시스템 (B/C/D) 전부 폐기:
- BE: mapper/Service/Controller 9세트 삭제 (cascading*, categoryTree, tableCategoryValue, categoryValueCascading, codeMerge)
- FE: 페이지 3 + API 8 + hooks 2 + 폐기 컴포넌트 6 삭제, 14곳 의존성 정리
- DB: 12 테이블 DROP, TABLE_TYPE_COLUMNS.CODE_CATEGORY → CODE_INFO rename

신설 commonCode 마스터-디테일:
- code_info: 1레벨 그룹 마스터
- code_detail: 2~∞ depth 재귀 트리 (parent_detail_id self-FK, depth 자동 계산)
- API: /api/common-codes/{info,detail}
- CodeCategoryFormModal/Panel → CodeInfoFormModal/Panel rename
- code_category 컬럼명 전부 code_info 로 치환 (mapper/Java/FE)
- 옛 commonCode API URL (/categories/...) → getCodeOptions 어댑터 + /detail?code_info=... 전환

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:50:50 +09:00

385 lines
12 KiB
TypeScript

"use client";
/**
* ConditionalConfigPanel
*
* 비개발자도 쉽게 조건부 표시/숨김/활성화/비활성화를 설정할 수 있는 UI
*
* cp 프리미티브 기반 — V2PropertiesPanel 의 CPGroup "조건부 표시" 안에서 호출됨.
* 외부 헤더는 부모 CPGroup 이 담당하므로 자체 헤더는 제거.
*/
import React, { useState, useEffect, useMemo } from "react";
import { ConditionalConfig } from "@/types/v2-components";
import {
CPRow,
CPText,
CPSwitch,
CPSelect,
} from "./config-panels/_shared/cp";
// ===== 타입 정의 =====
interface FieldOption {
id: string;
label: string;
type?: string;
options?: Array<{ value: string; label: string }>;
entityTable?: string;
entityValueColumn?: string;
entityLabelColumn?: string;
codeGroup?: string;
}
interface ConditionalConfigPanelProps {
config?: ConditionalConfig;
onChange: (config: ConditionalConfig | undefined) => void;
availableFields: FieldOption[];
currentComponentId?: string;
}
const OPERATORS: Array<{ value: ConditionalConfig["operator"]; label: string }> = [
{ value: "=", label: "같음 (=)" },
{ value: "!=", label: "다름 (≠)" },
{ value: ">", label: "보다 큼 (>)" },
{ value: "<", label: "보다 작음 (<)" },
{ value: "in", label: "포함됨" },
{ value: "notIn", label: "포함 안됨" },
{ value: "isEmpty", label: "비어있음" },
{ value: "isNotEmpty", label: "값이 있음" },
];
const ACTIONS: Array<{ value: ConditionalConfig["action"]; label: string }> = [
{ value: "show", label: "표시" },
{ value: "hide", label: "숨김" },
{ value: "enable", label: "활성화" },
{ value: "disable", label: "비활성화" },
];
// ===== 컴포넌트 =====
export function ConditionalConfigPanel({
config,
onChange,
availableFields,
currentComponentId,
}: ConditionalConfigPanelProps) {
const [enabled, setEnabled] = useState(config?.enabled ?? false);
const [field, setField] = useState(config?.field ?? "");
const [operator, setOperator] = useState<ConditionalConfig["operator"]>(config?.operator ?? "=");
const [value, setValue] = useState<string>(String(config?.value ?? ""));
const [action, setAction] = useState<ConditionalConfig["action"]>(config?.action ?? "show");
const selectableFields = useMemo(() => {
return availableFields.filter((f) => f.id !== currentComponentId);
}, [availableFields, currentComponentId]);
const selectedField = useMemo(() => {
return selectableFields.find((f) => f.id === field);
}, [selectableFields, field]);
const [dynamicOptions, setDynamicOptions] = useState<Array<{ value: string; label: string }>>([]);
const [loadingOptions, setLoadingOptions] = useState(false);
useEffect(() => {
const loadDynamicOptions = async () => {
if (!selectedField) {
setDynamicOptions([]);
return;
}
if (selectedField.options && selectedField.options.length > 0) {
setDynamicOptions([]);
return;
}
if (selectedField.entityTable) {
setLoadingOptions(true);
try {
const { apiClient } = await import("@/lib/api/client");
const valueCol = selectedField.entityValueColumn || "id";
const labelCol = selectedField.entityLabelColumn || "name";
const response = await apiClient.get(`/entity/${selectedField.entityTable}/options`, {
params: { value: valueCol, label: labelCol },
});
if (response.data.success && response.data.data) {
setDynamicOptions(response.data.data);
}
} catch (error) {
console.error("엔티티 옵션 로드 실패:", error);
setDynamicOptions([]);
} finally {
setLoadingOptions(false);
}
return;
}
if (selectedField.codeGroup) {
setLoadingOptions(true);
try {
const { getCodeOptions } = await import("@/lib/api/commonCode");
const response = await getCodeOptions(selectedField.codeGroup);
if (response.success && response.data) {
setDynamicOptions(
response.data.map((item) => ({
value: item.value,
label: item.label,
})),
);
}
} catch (error) {
console.error("공통코드 옵션 로드 실패:", error);
setDynamicOptions([]);
} finally {
setLoadingOptions(false);
}
return;
}
setDynamicOptions([]);
};
loadDynamicOptions();
}, [
selectedField?.id,
selectedField?.entityTable,
selectedField?.entityValueColumn,
selectedField?.entityLabelColumn,
selectedField?.codeGroup,
]);
const fieldOptions = useMemo(() => {
if (selectedField?.options && selectedField.options.length > 0) {
return selectedField.options;
}
return dynamicOptions;
}, [selectedField?.options, dynamicOptions]);
useEffect(() => {
setEnabled(config?.enabled ?? false);
setField(config?.field ?? "");
setOperator(config?.operator ?? "=");
setValue(String(config?.value ?? ""));
setAction(config?.action ?? "show");
}, [config]);
const updateConfig = (updates: Partial<ConditionalConfig>) => {
const newConfig: ConditionalConfig = {
enabled: updates.enabled ?? enabled,
field: updates.field ?? field,
operator: updates.operator ?? operator,
value: updates.value ?? value,
action: updates.action ?? action,
};
if (!newConfig.enabled) {
onChange(undefined);
} else {
onChange(newConfig);
}
};
const handleEnabledChange = (checked: boolean) => {
setEnabled(checked);
updateConfig({ enabled: checked });
};
const handleFieldChange = (newField: string) => {
setField(newField);
setValue("");
updateConfig({ field: newField, value: "" });
};
const handleOperatorChange = (newOperator: ConditionalConfig["operator"]) => {
setOperator(newOperator);
if (newOperator === "isEmpty" || newOperator === "isNotEmpty") {
setValue("");
updateConfig({ operator: newOperator, value: "" });
} else {
updateConfig({ operator: newOperator });
}
};
const handleValueChange = (newValue: string) => {
setValue(newValue);
let parsedValue: unknown = newValue;
if (selectedField?.type === "number") {
parsedValue = Number(newValue);
} else if (newValue === "true") {
parsedValue = true;
} else if (newValue === "false") {
parsedValue = false;
}
updateConfig({ value: parsedValue });
};
const handleActionChange = (newAction: ConditionalConfig["action"]) => {
setAction(newAction);
updateConfig({ action: newAction });
};
// 값 입력 — 필드 타입에 따라 분기
const renderValueInput = () => {
if (operator === "isEmpty" || operator === "isNotEmpty") {
return (
<div style={{ fontSize: 11, color: "var(--cp-text-muted)", fontStyle: "italic", padding: "6px 0" }}>
( )
</div>
);
}
if (loadingOptions) {
return (
<div style={{ fontSize: 11, color: "var(--cp-text-muted)", fontStyle: "italic", padding: "6px 0" }}>
...
</div>
);
}
if (fieldOptions.length > 0) {
return (
<CPSelect value={value} onChange={handleValueChange}>
<option value=""> </option>
{fieldOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</CPSelect>
);
}
if (selectedField?.type === "checkbox" || selectedField?.type === "boolean") {
return (
<CPSelect value={value} onChange={handleValueChange}>
<option value=""> </option>
<option value="true"></option>
<option value="false"> </option>
</CPSelect>
);
}
if (selectedField?.type === "number") {
return (
<CPText
type="number"
mono
value={value}
onChange={handleValueChange}
placeholder="숫자 입력"
/>
);
}
return <CPText value={value} onChange={handleValueChange} placeholder="값 입력" />;
};
const operatorPhrase = (op: ConditionalConfig["operator"], v: string) => {
if (op === "isEmpty") return "비어있으면";
if (op === "isNotEmpty") return "값이 있으면";
if (op === "=") return `"${v}" 이면`;
if (op === "!=") return `"${v}" 이 아니면`;
if (op === ">") return `"${v}" 보다 크면`;
if (op === "<") return `"${v}" 보다 작으면`;
if (op === "in") return `"${v}" 에 포함되면`;
if (op === "notIn") return `"${v}" 에 포함되지 않으면`;
return "";
};
return (
<div>
<CPRow label="활성화">
<CPSwitch value={enabled} onChange={handleEnabledChange} />
</CPRow>
{enabled && (
<>
<CPRow label="조건 필드" help="이 필드의 값에 따라 조건이 적용됩니다">
<CPSelect value={field} onChange={handleFieldChange}>
<option value=""> </option>
{selectableFields.map((f) => (
<option key={f.id} value={f.id}>
{f.label || f.id}
</option>
))}
</CPSelect>
</CPRow>
<CPRow label="조건">
<CPSelect
value={operator}
onChange={(v) => handleOperatorChange(v as ConditionalConfig["operator"])}
>
{OPERATORS.map((op) => (
<option key={op.value} value={op.value}>
{op.label}
</option>
))}
</CPSelect>
</CPRow>
<CPRow label="값">{renderValueInput()}</CPRow>
<CPRow
label="동작"
help={`조건이 만족되면 이 필드를 ${ACTIONS.find((a) => a.value === action)?.label}합니다`}
>
<CPSelect
value={action}
onChange={(v) => handleActionChange(v as ConditionalConfig["action"])}
>
{ACTIONS.map((act) => (
<option key={act.value} value={act.value}>
{act.label}
</option>
))}
</CPSelect>
</CPRow>
{field && (
<div
style={{
marginTop: 8,
padding: "8px 10px",
background: "rgba(var(--v5-primary-rgb), 0.06)",
border: "1px solid var(--cp-border-subtle)",
borderRadius: 6,
fontSize: 11,
color: "var(--cp-text-sec)",
lineHeight: 1.5,
}}
>
<div
style={{
fontSize: 9.5,
fontWeight: 700,
color: "var(--cp-text-muted)",
marginBottom: 4,
letterSpacing: "0.06em",
textTransform: "uppercase",
}}
>
</div>
<div>
<span style={{ fontWeight: 600, color: "var(--cp-text)" }}>
"{selectableFields.find((f) => f.id === field)?.label || field}"
</span>{" "}
{" "}
<span style={{ fontWeight: 600 }}>{operatorPhrase(operator, value)}</span> {" "}
<span style={{ fontWeight: 700, color: "rgb(var(--v5-primary-rgb))" }}>
{ACTIONS.find((a) => a.value === action)?.label}
</span>
</div>
</div>
)}
</>
)}
</div>
);
}
export default ConditionalConfigPanel;