Files
invyone/frontend/components/v2/ConditionalConfigPanel.tsx
T
DDD1542 8b8186d1c0
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m7s
INVYONE Studio Config Panel — 좌측 팔레트 10 컴포넌트 cp 톤 1차 마이그
신규 / 마이그된 패널:
- 데이터 조회/선택: InvDataConfigPanel (wrapper) + InvRepeaterConfigPanel (V2Repeater 2029줄 폐기 → cp 신규 본체)
- 통합 컴포넌트 7개 in-place cp: button / container / divider / search / stats / table / title
- 옛 v2-* hidden 호환: InvDividerConfigPanel / InvTextConfigPanel / InvButtonConfigPanel

cp 인프라:
- _shared/cp/CPExtras.tsx 신규 — 8 공용 컴포넌트
  (Hint / DimText / InlineLoader / SectionLabel / CpChip / ChipPickerBox /
   FeatureChipGrid / CPVisualGrid)
- FeatureChipGrid: portal tooltip + Stripe 패턴 group cooldown (500ms delay → 즉시 전환)
- CPVisualGrid: 시각 미리보기 카드 (두께/색/사이즈/시맨틱 등 진짜 의미 preview)
- cp.css 다크 luminance 분리 + 키프레임 / CPPrimitives CPSelect 자동 검색·정렬

옛 V2 패널 4개 삭제:
- V2RepeaterConfigPanel (2029) / V2ButtonConfigPanel (2212) /
  V2DividerLineConfigPanel (236) / V2TextDisplayConfigPanel (304)

설계 노트 5개 추가 (notes/gbpark/2026-04-28-*.md):
- cp-panel-standard / cp-panel-day2-html-v4-match / invdata-inventory /
  inv-repeater-redesign / inv-naming-consolidation

다음: Inv* 일괄 네이밍 통합 + dead code 정리

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:21: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 { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get(`/common-codes/categories/${selectedField.codeGroup}/options`);
if (response.data.success && response.data.data) {
setDynamicOptions(
response.data.data.map((item: { value: string; label: string }) => ({
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;