2348800e68
Build & Deploy to K8s / build-and-deploy (push) Successful in 9m22s
카테고리/캐스케이딩 시스템 (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>
385 lines
12 KiB
TypeScript
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;
|