c3a43179e3
- Changed color schemes for various screen types and roles to align with the new design guidelines, enhancing visual consistency across the application. - Updated background colors for components based on their types, such as changing 'bg-slate-400' to 'bg-muted-foreground' and adjusting other color mappings for better clarity. - Improved the styling of the ScreenNode and V2PropertiesPanel components to ensure a more cohesive user experience. - Enhanced the DynamicComponentRenderer to support dynamic loading of column metadata with cache invalidation for better performance. These changes aim to refine the UI and improve the overall aesthetic of the application, ensuring a more modern and user-friendly interface.
758 lines
40 KiB
TypeScript
758 lines
40 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* V2 통합 필드 설정 패널
|
|
* 입력(text/number/textarea/numbering)과 선택(select/category/entity)을
|
|
* 하나의 패널에서 전환할 수 있는 통합 설정 UI
|
|
*/
|
|
|
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import {
|
|
Type, Hash, AlignLeft, ListOrdered, List, Database, FolderTree,
|
|
Settings, ChevronDown, Plus, Trash2, Loader2, Filter,
|
|
} from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { apiClient } from "@/lib/api/client";
|
|
import { AutoGenerationType } from "@/types/screen";
|
|
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
|
|
import { getAvailableNumberingRulesForScreen } from "@/lib/api/numberingRule";
|
|
import { NumberingRuleConfig } from "@/types/numbering-rule";
|
|
import type { V2SelectFilter } from "@/types/v2-components";
|
|
|
|
// ─── 필드 유형 카드 정의 ───
|
|
const FIELD_TYPE_CARDS = [
|
|
{ value: "text", icon: Type, label: "텍스트", desc: "일반 텍스트 입력", group: "input" },
|
|
{ value: "number", icon: Hash, label: "숫자", desc: "숫자만 입력", group: "input" },
|
|
{ value: "textarea", icon: AlignLeft, label: "여러 줄", desc: "긴 텍스트 입력", group: "input" },
|
|
{ value: "select", icon: List, label: "셀렉트", desc: "직접 옵션 선택", group: "select" },
|
|
{ value: "category", icon: FolderTree, label: "카테고리", desc: "등록된 선택지", group: "select" },
|
|
{ value: "entity", icon: Database, label: "테이블 참조", desc: "다른 테이블 참조", group: "select" },
|
|
{ value: "numbering", icon: ListOrdered, label: "채번", desc: "자동 번호 생성", group: "input" },
|
|
] as const;
|
|
|
|
type FieldType = typeof FIELD_TYPE_CARDS[number]["value"];
|
|
|
|
// 필터 조건 관련 상수
|
|
const OPERATOR_OPTIONS = [
|
|
{ value: "=", label: "같음 (=)" },
|
|
{ value: "!=", label: "다름 (!=)" },
|
|
{ value: ">", label: "초과 (>)" },
|
|
{ value: "<", label: "미만 (<)" },
|
|
{ value: ">=", label: "이상 (>=)" },
|
|
{ value: "<=", label: "이하 (<=)" },
|
|
{ value: "in", label: "포함 (IN)" },
|
|
{ value: "notIn", label: "미포함 (NOT IN)" },
|
|
{ value: "like", label: "유사 (LIKE)" },
|
|
{ value: "isNull", label: "NULL" },
|
|
{ value: "isNotNull", label: "NOT NULL" },
|
|
] as const;
|
|
|
|
const VALUE_TYPE_OPTIONS = [
|
|
{ value: "static", label: "고정값" },
|
|
{ value: "field", label: "폼 필드 참조" },
|
|
{ value: "user", label: "로그인 사용자" },
|
|
] as const;
|
|
|
|
const USER_FIELD_OPTIONS = [
|
|
{ value: "companyCode", label: "회사코드" },
|
|
{ value: "userId", label: "사용자ID" },
|
|
{ value: "deptCode", label: "부서코드" },
|
|
{ value: "userName", label: "사용자명" },
|
|
] as const;
|
|
|
|
interface ColumnOption {
|
|
columnName: string;
|
|
columnLabel: string;
|
|
}
|
|
|
|
interface CategoryValueOption {
|
|
valueCode: string;
|
|
valueLabel: string;
|
|
}
|
|
|
|
// ─── 하위 호환: 기존 config에서 fieldType 추론 ───
|
|
function resolveFieldType(config: Record<string, any>, componentType?: string, metaInputType?: string): FieldType {
|
|
// DB input_type이 전달된 경우 (데이터타입관리에서 변경 시) 우선 적용
|
|
if (metaInputType && metaInputType !== "direct" && metaInputType !== "auto") {
|
|
const dbType = metaInputType as FieldType;
|
|
if (["text", "number", "textarea", "numbering", "select", "category", "entity"].includes(dbType)) {
|
|
return dbType;
|
|
}
|
|
}
|
|
|
|
if (config.fieldType) return config.fieldType as FieldType;
|
|
|
|
// v2-select 계열
|
|
if (componentType === "v2-select" || config.source) {
|
|
const source = config.source === "code" ? "category" : config.source;
|
|
if (source === "entity") return "entity";
|
|
if (source === "category") return "category";
|
|
return "select";
|
|
}
|
|
|
|
// v2-input 계열
|
|
const it = config.inputType || config.type;
|
|
if (it === "number") return "number";
|
|
if (it === "textarea") return "textarea";
|
|
if (it === "numbering") return "numbering";
|
|
return "text";
|
|
}
|
|
|
|
// ─── 필터 조건 서브 컴포넌트 ───
|
|
const FilterConditionsSection: React.FC<{
|
|
filters: V2SelectFilter[];
|
|
columns: ColumnOption[];
|
|
loadingColumns: boolean;
|
|
targetTable: string;
|
|
onFiltersChange: (filters: V2SelectFilter[]) => void;
|
|
}> = ({ filters, columns, loadingColumns, targetTable, onFiltersChange }) => {
|
|
const addFilter = () => {
|
|
onFiltersChange([...filters, { column: "", operator: "=", valueType: "static", value: "" }]);
|
|
};
|
|
const updateFilter = (index: number, patch: Partial<V2SelectFilter>) => {
|
|
const updated = [...filters];
|
|
updated[index] = { ...updated[index], ...patch };
|
|
if (patch.valueType) {
|
|
if (patch.valueType === "static") { updated[index].fieldRef = undefined; updated[index].userField = undefined; }
|
|
else if (patch.valueType === "field") { updated[index].value = undefined; updated[index].userField = undefined; }
|
|
else if (patch.valueType === "user") { updated[index].value = undefined; updated[index].fieldRef = undefined; }
|
|
}
|
|
if (patch.operator === "isNull" || patch.operator === "isNotNull") {
|
|
updated[index].value = undefined; updated[index].fieldRef = undefined;
|
|
updated[index].userField = undefined; updated[index].valueType = "static";
|
|
}
|
|
onFiltersChange(updated);
|
|
};
|
|
const removeFilter = (index: number) => onFiltersChange(filters.filter((_, i) => i !== index));
|
|
const needsValue = (op: string) => op !== "isNull" && op !== "isNotNull";
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-1.5">
|
|
<Filter className="h-3.5 w-3.5 text-muted-foreground" />
|
|
<span className="text-xs font-medium">데이터 필터</span>
|
|
</div>
|
|
<Button type="button" variant="ghost" size="sm" onClick={addFilter} className="h-6 px-2 text-xs">
|
|
<Plus className="mr-1 h-3 w-3" />추가
|
|
</Button>
|
|
</div>
|
|
<p className="text-muted-foreground text-[10px]">{targetTable} 테이블에서 옵션을 불러올 때 적용할 조건</p>
|
|
{loadingColumns && (
|
|
<div className="text-muted-foreground flex items-center gap-2 text-xs"><Loader2 className="h-3 w-3 animate-spin" />컬럼 목록 로딩 중...</div>
|
|
)}
|
|
{filters.length === 0 && <p className="text-muted-foreground py-2 text-center text-xs">필터 조건이 없습니다</p>}
|
|
<div className="space-y-2">
|
|
{filters.map((filter, index) => (
|
|
<div key={index} className="space-y-2 rounded-md border p-3">
|
|
<div className="flex items-center gap-1.5">
|
|
<Select value={filter.column || ""} onValueChange={(v) => updateFilter(index, { column: v })}>
|
|
<SelectTrigger className="h-7 flex-1 text-[11px]"><SelectValue placeholder="컬럼" /></SelectTrigger>
|
|
<SelectContent>{columns.map((col) => (<SelectItem key={col.columnName} value={col.columnName}>{col.columnLabel}</SelectItem>))}</SelectContent>
|
|
</Select>
|
|
<Select value={filter.operator || "="} onValueChange={(v) => updateFilter(index, { operator: v as V2SelectFilter["operator"] })}>
|
|
<SelectTrigger className="h-7 flex-1 text-[11px]"><SelectValue /></SelectTrigger>
|
|
<SelectContent>{OPERATOR_OPTIONS.map((op) => (<SelectItem key={op.value} value={op.value}>{op.label}</SelectItem>))}</SelectContent>
|
|
</Select>
|
|
<Button type="button" variant="ghost" size="sm" onClick={() => removeFilter(index)} className="text-destructive h-8 w-8 shrink-0 p-0"><Trash2 className="h-3 w-3" /></Button>
|
|
</div>
|
|
{needsValue(filter.operator) && (
|
|
<div className="flex items-center gap-1.5">
|
|
<Select value={filter.valueType || "static"} onValueChange={(v) => updateFilter(index, { valueType: v as V2SelectFilter["valueType"] })}>
|
|
<SelectTrigger className="h-7 w-[100px] shrink-0 text-[11px]"><SelectValue /></SelectTrigger>
|
|
<SelectContent>{VALUE_TYPE_OPTIONS.map((vt) => (<SelectItem key={vt.value} value={vt.value}>{vt.label}</SelectItem>))}</SelectContent>
|
|
</Select>
|
|
{(filter.valueType || "static") === "static" && (
|
|
<Input value={String(filter.value ?? "")} onChange={(e) => updateFilter(index, { value: e.target.value })}
|
|
placeholder={filter.operator === "in" || filter.operator === "notIn" ? "값1, 값2, ..." : "값 입력"} className="h-7 flex-1 text-[11px]" />
|
|
)}
|
|
{filter.valueType === "field" && (
|
|
<Input value={filter.fieldRef || ""} onChange={(e) => updateFilter(index, { fieldRef: e.target.value })} placeholder="참조할 필드명" className="h-7 flex-1 text-[11px]" />
|
|
)}
|
|
{filter.valueType === "user" && (
|
|
<Select value={filter.userField || ""} onValueChange={(v) => updateFilter(index, { userField: v as V2SelectFilter["userField"] })}>
|
|
<SelectTrigger className="h-7 flex-1 text-[11px]"><SelectValue placeholder="사용자 필드" /></SelectTrigger>
|
|
<SelectContent>{USER_FIELD_OPTIONS.map((uf) => (<SelectItem key={uf.value} value={uf.value}>{uf.label}</SelectItem>))}</SelectContent>
|
|
</Select>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ─── 메인 컴포넌트 ───
|
|
|
|
interface V2FieldConfigPanelProps {
|
|
config: Record<string, any>;
|
|
onChange: (config: Record<string, any>) => void;
|
|
tableName?: string;
|
|
columnName?: string;
|
|
tables?: Array<{ tableName: string; displayName?: string; tableComment?: string }>;
|
|
menuObjid?: number;
|
|
screenTableName?: string;
|
|
inputType?: string;
|
|
componentType?: string;
|
|
}
|
|
|
|
export const V2FieldConfigPanel: React.FC<V2FieldConfigPanelProps> = ({
|
|
config,
|
|
onChange,
|
|
tableName,
|
|
columnName,
|
|
tables = [],
|
|
screenTableName,
|
|
inputType: metaInputType,
|
|
componentType,
|
|
}) => {
|
|
const fieldType = resolveFieldType(config, componentType, metaInputType);
|
|
const isSelectGroup = ["select", "category", "entity"].includes(fieldType);
|
|
|
|
// ─── 채번 관련 상태 (테이블 기반) ───
|
|
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
|
|
const [loadingRules, setLoadingRules] = useState(false);
|
|
const numberingTableName = screenTableName || tableName;
|
|
|
|
// ─── 셀렉트 관련 상태 ───
|
|
const [entityColumns, setEntityColumns] = useState<ColumnOption[]>([]);
|
|
const [loadingColumns, setLoadingColumns] = useState(false);
|
|
const [categoryValues, setCategoryValues] = useState<CategoryValueOption[]>([]);
|
|
const [loadingCategoryValues, setLoadingCategoryValues] = useState(false);
|
|
const [filterColumns, setFilterColumns] = useState<ColumnOption[]>([]);
|
|
const [loadingFilterColumns, setLoadingFilterColumns] = useState(false);
|
|
|
|
const [advancedOpen, setAdvancedOpen] = useState(false);
|
|
|
|
const updateConfig = (field: string, value: any) => {
|
|
onChange({ ...config, [field]: value });
|
|
};
|
|
|
|
// ─── 필드 타입 전환 핸들러 ───
|
|
const handleFieldTypeChange = (newType: FieldType) => {
|
|
const newIsSelect = ["select", "category", "entity"].includes(newType);
|
|
const base: Record<string, any> = { ...config, fieldType: newType };
|
|
|
|
if (newIsSelect) {
|
|
base.source = newType === "category" ? "category" : newType === "entity" ? "entity" : "static";
|
|
delete base.inputType;
|
|
} else {
|
|
base.inputType = newType;
|
|
// 선택형 -> 입력형 전환 시 source 잔류 제거 (안 지우면 '카테고리 값이 없습니다' 같은 오류 표시)
|
|
delete base.source;
|
|
}
|
|
|
|
if (newType === "numbering") {
|
|
base.autoGeneration = {
|
|
...config.autoGeneration,
|
|
type: "numbering_rule" as AutoGenerationType,
|
|
tableName: numberingTableName,
|
|
};
|
|
base.readonly = config.readonly ?? true;
|
|
}
|
|
|
|
onChange(base);
|
|
|
|
// table_type_columns.input_type 동기화 (카테고리/엔티티 등 설정 가능하도록)
|
|
const syncTableName = screenTableName || tableName;
|
|
const syncColumnName = columnName || config.columnName || config.fieldName;
|
|
if (syncTableName && syncColumnName) {
|
|
apiClient.put(`/table-management/tables/${syncTableName}/columns/${syncColumnName}/input-type`, {
|
|
inputType: newType,
|
|
}).then(() => {
|
|
// 왼쪽 테이블 패널의 컬럼 타입 뱃지 갱신
|
|
window.dispatchEvent(new CustomEvent("table-columns-refresh"));
|
|
}).catch(() => { /* 동기화 실패해도 화면 설정은 유지 */ });
|
|
}
|
|
};
|
|
|
|
// ─── 채번 규칙 로드 (테이블 기반) ───
|
|
useEffect(() => {
|
|
if (fieldType !== "numbering") return;
|
|
if (!numberingTableName) { setNumberingRules([]); return; }
|
|
const load = async () => {
|
|
setLoadingRules(true);
|
|
try {
|
|
const resp = await getAvailableNumberingRulesForScreen(numberingTableName);
|
|
if (resp.success && resp.data) setNumberingRules(resp.data);
|
|
else setNumberingRules([]);
|
|
} catch { setNumberingRules([]); } finally { setLoadingRules(false); }
|
|
};
|
|
load();
|
|
}, [numberingTableName, fieldType]);
|
|
|
|
// ─── 엔티티 컬럼 로드 ───
|
|
const loadEntityColumns = useCallback(async (tblName: string) => {
|
|
if (!tblName) { setEntityColumns([]); return; }
|
|
setLoadingColumns(true);
|
|
try {
|
|
const resp = await apiClient.get(`/table-management/tables/${tblName}/columns?size=500`);
|
|
const data = resp.data.data || resp.data;
|
|
const cols = data.columns || data || [];
|
|
setEntityColumns(cols.map((col: any) => ({
|
|
columnName: col.columnName || col.column_name || col.name,
|
|
columnLabel: col.displayName || col.display_name || col.columnLabel || col.column_label || col.columnName || col.column_name || col.name,
|
|
})));
|
|
} catch { setEntityColumns([]); } finally { setLoadingColumns(false); }
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (fieldType === "entity" && config.entityTable) loadEntityColumns(config.entityTable);
|
|
}, [fieldType, config.entityTable, loadEntityColumns]);
|
|
|
|
// ─── 카테고리 값 로드 ───
|
|
const loadCategoryValues = useCallback(async (catTable: string, catColumn: string) => {
|
|
if (!catTable || !catColumn) { setCategoryValues([]); return; }
|
|
setLoadingCategoryValues(true);
|
|
try {
|
|
const resp = await apiClient.get(`/table-categories/${catTable}/${catColumn}/values`);
|
|
if (resp.data.success && resp.data.data) {
|
|
const flattenTree = (items: any[], depth = 0): CategoryValueOption[] => {
|
|
const result: CategoryValueOption[] = [];
|
|
for (const item of items) {
|
|
result.push({ valueCode: item.valueCode, valueLabel: depth > 0 ? `${" ".repeat(depth)}${item.valueLabel}` : item.valueLabel });
|
|
if (item.children?.length) result.push(...flattenTree(item.children, depth + 1));
|
|
}
|
|
return result;
|
|
};
|
|
setCategoryValues(flattenTree(resp.data.data));
|
|
}
|
|
} catch { setCategoryValues([]); } finally { setLoadingCategoryValues(false); }
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (fieldType === "category") {
|
|
const catTable = config.categoryTable || tableName;
|
|
const catColumn = config.categoryColumn || columnName;
|
|
if (catTable && catColumn) loadCategoryValues(catTable, catColumn);
|
|
}
|
|
}, [fieldType, config.categoryTable, config.categoryColumn, tableName, columnName, loadCategoryValues]);
|
|
|
|
// ─── 필터 컬럼 로드 ───
|
|
const filterTargetTable = useMemo(() => {
|
|
if (fieldType === "entity") return config.entityTable;
|
|
if (fieldType === "category") return config.categoryTable || tableName;
|
|
return null;
|
|
}, [fieldType, config.entityTable, config.categoryTable, tableName]);
|
|
|
|
useEffect(() => {
|
|
if (!filterTargetTable) { setFilterColumns([]); return; }
|
|
const load = async () => {
|
|
setLoadingFilterColumns(true);
|
|
try {
|
|
const resp = await apiClient.get(`/table-management/tables/${filterTargetTable}/columns?size=500`);
|
|
const data = resp.data.data || resp.data;
|
|
const cols = data.columns || data || [];
|
|
setFilterColumns(cols.map((col: any) => ({
|
|
columnName: col.columnName || col.column_name || col.name,
|
|
columnLabel: col.displayName || col.display_name || col.columnLabel || col.column_label || col.columnName || col.column_name || col.name,
|
|
})));
|
|
} catch { setFilterColumns([]); } finally { setLoadingFilterColumns(false); }
|
|
};
|
|
load();
|
|
}, [filterTargetTable]);
|
|
|
|
// ─── 옵션 관리 (select static) ───
|
|
const options = config.options || [];
|
|
const addOption = () => updateConfig("options", [...options, { value: "", label: "" }]);
|
|
const updateOptionValue = (index: number, value: string) => {
|
|
const newOpts = [...options];
|
|
newOpts[index] = { ...newOpts[index], value, label: value };
|
|
updateConfig("options", newOpts);
|
|
};
|
|
const removeOption = (index: number) => updateConfig("options", options.filter((_: any, i: number) => i !== index));
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* ═══ 1단계: 필드 유형 선택 ═══ */}
|
|
<div className="space-y-2">
|
|
<p className="text-sm font-medium">이 필드는 어떤 유형인가요?</p>
|
|
<p className="text-[11px] text-muted-foreground">유형에 따라 입력 방식이 바뀌어요</p>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-3 gap-2">
|
|
{FIELD_TYPE_CARDS.map((card) => {
|
|
const Icon = card.icon;
|
|
const isSelected = fieldType === card.value;
|
|
return (
|
|
<button
|
|
key={card.value}
|
|
type="button"
|
|
onClick={() => handleFieldTypeChange(card.value)}
|
|
className={cn(
|
|
"flex flex-col items-center justify-center rounded-lg border p-2.5 text-center transition-all min-h-[72px]",
|
|
isSelected
|
|
? "border-primary bg-primary/5 ring-1 ring-primary/20"
|
|
: "border-border hover:border-primary/50 hover:bg-muted/50"
|
|
)}
|
|
>
|
|
<Icon className={cn("h-4 w-4 mb-1", isSelected ? "text-primary" : "text-muted-foreground")} />
|
|
<span className={cn("text-[11px] font-medium leading-tight", isSelected ? "text-primary" : "text-foreground")}>{card.label}</span>
|
|
<span className="text-[9px] text-muted-foreground leading-tight mt-0.5">{card.desc}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* ═══ 2단계: 유형별 상세 설정 ═══ */}
|
|
|
|
{/* ─── 텍스트/숫자/여러줄: 기본 설정 ─── */}
|
|
{(fieldType === "text" || fieldType === "number" || fieldType === "textarea") && (
|
|
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
|
<div className="flex items-center justify-between py-1">
|
|
<span className="text-xs text-muted-foreground">안내 텍스트</span>
|
|
<Input value={config.placeholder || ""} onChange={(e) => updateConfig("placeholder", e.target.value)} placeholder="입력 안내" className="h-7 w-[160px] text-xs" />
|
|
</div>
|
|
|
|
{fieldType === "text" && (
|
|
<div className="flex items-center justify-between py-1">
|
|
<span className="text-xs text-muted-foreground">입력 형식</span>
|
|
<Select value={config.format || "none"} onValueChange={(v) => updateConfig("format", v)}>
|
|
<SelectTrigger className="h-7 w-[160px] text-xs"><SelectValue placeholder="형식 선택" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">제한 없음</SelectItem>
|
|
<SelectItem value="email">이메일</SelectItem>
|
|
<SelectItem value="tel">전화번호</SelectItem>
|
|
<SelectItem value="url">URL</SelectItem>
|
|
<SelectItem value="currency">통화</SelectItem>
|
|
<SelectItem value="biz_no">사업자번호</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
|
|
{fieldType === "number" && (
|
|
<div className="space-y-2 pt-1">
|
|
<p className="text-xs text-muted-foreground">값 범위</p>
|
|
<div className="flex gap-2">
|
|
<div className="flex-1">
|
|
<Label className="text-[10px] text-muted-foreground">최소값</Label>
|
|
<Input type="number" value={config.min ?? ""} onChange={(e) => updateConfig("min", e.target.value ? Number(e.target.value) : undefined)} placeholder="0" className="h-7 text-xs" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<Label className="text-[10px] text-muted-foreground">최대값</Label>
|
|
<Input type="number" value={config.max ?? ""} onChange={(e) => updateConfig("max", e.target.value ? Number(e.target.value) : undefined)} placeholder="100" className="h-7 text-xs" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<Label className="text-[10px] text-muted-foreground">단계</Label>
|
|
<Input type="number" value={config.step ?? ""} onChange={(e) => updateConfig("step", e.target.value ? Number(e.target.value) : undefined)} placeholder="1" className="h-7 text-xs" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{fieldType === "textarea" && (
|
|
<div className="flex items-center justify-between py-1">
|
|
<span className="text-xs text-muted-foreground">줄 수</span>
|
|
<Input type="number" value={config.rows || 3} onChange={(e) => updateConfig("rows", parseInt(e.target.value) || 3)} min={2} max={20} className="h-7 w-[160px] text-xs" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* ─── 셀렉트 (직접 입력): 옵션 관리 ─── */}
|
|
{fieldType === "select" && (
|
|
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium">옵션 목록</span>
|
|
<Button type="button" variant="outline" size="sm" onClick={addOption} className="h-7 px-2 text-xs">
|
|
<Plus className="mr-1 h-3 w-3" />추가
|
|
</Button>
|
|
</div>
|
|
{options.length > 0 ? (
|
|
<div className="max-h-40 space-y-1.5 overflow-y-auto">
|
|
{options.map((option: any, index: number) => (
|
|
<div key={index} className="flex items-center gap-2">
|
|
<Input value={option.value || ""} onChange={(e) => updateOptionValue(index, e.target.value)} placeholder={`옵션 ${index + 1}`} className="h-8 flex-1 text-sm" />
|
|
<Button type="button" variant="ghost" size="icon" onClick={() => removeOption(index)} className="text-destructive h-8 w-8 shrink-0"><Trash2 className="h-4 w-4" /></Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-6 text-muted-foreground">
|
|
<List className="mx-auto mb-2 h-8 w-8 opacity-30" />
|
|
<p className="text-sm">아직 옵션이 없어요</p>
|
|
<p className="text-xs">위의 추가 버튼으로 옵션을 만들어보세요</p>
|
|
</div>
|
|
)}
|
|
{options.length > 0 && (
|
|
<div className="border-t pt-3 mt-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs text-muted-foreground">기본 선택값</span>
|
|
<Select value={config.defaultValue || "_none_"} onValueChange={(v) => updateConfig("defaultValue", v === "_none_" ? "" : v)}>
|
|
<SelectTrigger className="h-8 w-[160px] text-sm"><SelectValue placeholder="선택 안함" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="_none_">선택 안함</SelectItem>
|
|
{options.map((opt: any, i: number) => (<SelectItem key={`d-${i}`} value={opt.value || `_idx_${i}`}>{opt.label || opt.value || `옵션 ${i + 1}`}</SelectItem>))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* ─── 카테고리 ─── */}
|
|
{fieldType === "category" && (
|
|
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<FolderTree className="h-4 w-4 text-primary" />
|
|
<span className="text-sm font-medium">카테고리</span>
|
|
</div>
|
|
{config.source === "code" && config.codeGroup && (
|
|
<div className="rounded-md border bg-background p-3">
|
|
<p className="text-xs text-muted-foreground">코드 그룹</p>
|
|
<p className="mt-0.5 text-sm font-medium">{config.codeGroup}</p>
|
|
</div>
|
|
)}
|
|
<div className="rounded-md border bg-background p-3">
|
|
<div className="flex gap-6">
|
|
<div><p className="text-xs text-muted-foreground">테이블</p><p className="text-sm font-medium">{config.categoryTable || tableName || "-"}</p></div>
|
|
<div><p className="text-xs text-muted-foreground">컬럼</p><p className="text-sm font-medium">{config.categoryColumn || columnName || "-"}</p></div>
|
|
</div>
|
|
</div>
|
|
{loadingCategoryValues && <div className="text-muted-foreground flex items-center gap-2 text-xs"><Loader2 className="h-3 w-3 animate-spin" />카테고리 값 로딩 중...</div>}
|
|
{categoryValues.length > 0 && (
|
|
<div>
|
|
<p className="mb-1.5 text-xs text-muted-foreground">{categoryValues.length}개의 값이 있어요</p>
|
|
<div className="max-h-28 overflow-y-auto rounded-md border bg-background p-2 space-y-0.5">
|
|
{categoryValues.map((cv) => (
|
|
<div key={cv.valueCode} className="flex items-center gap-2 px-1.5 py-0.5 text-xs">
|
|
<span className="shrink-0 font-mono text-[10px] text-muted-foreground">{cv.valueCode}</span>
|
|
<span className="truncate">{cv.valueLabel}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="mt-3 flex items-center justify-between">
|
|
<span className="text-xs text-muted-foreground">기본 선택값</span>
|
|
<Select value={config.defaultValue || "_none_"} onValueChange={(v) => updateConfig("defaultValue", v === "_none_" ? "" : v)}>
|
|
<SelectTrigger className="h-8 w-[160px] text-sm"><SelectValue placeholder="선택 안함" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="_none_">선택 안함</SelectItem>
|
|
{categoryValues.map((cv) => (<SelectItem key={cv.valueCode} value={cv.valueCode}>{cv.valueLabel}</SelectItem>))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{!loadingCategoryValues && categoryValues.length === 0 && (
|
|
<p className="text-[10px] text-amber-600">카테고리 값이 없습니다. 테이블 카테고리 관리에서 값을 추가해주세요.</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* ─── 테이블 참조 (entity) ─── */}
|
|
{fieldType === "entity" && (
|
|
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<Database className="h-4 w-4 text-primary" />
|
|
<span className="text-sm font-medium">테이블 참조</span>
|
|
</div>
|
|
<div>
|
|
<p className="mb-1.5 text-xs text-muted-foreground">참조 테이블</p>
|
|
<Select value={config.entityTable || ""} onValueChange={(v) => onChange({ ...config, entityTable: v, entityValueColumn: "", entityLabelColumn: "" })}>
|
|
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="테이블을 선택해주세요" /></SelectTrigger>
|
|
<SelectContent>
|
|
{tables.map((t) => (<SelectItem key={t.tableName} value={t.tableName}>{t.displayName || t.tableComment ? `${t.displayName || t.tableComment} (${t.tableName})` : t.tableName}</SelectItem>))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
{loadingColumns && <div className="text-muted-foreground flex items-center gap-2 text-xs"><Loader2 className="h-3 w-3 animate-spin" />컬럼 목록 로딩 중...</div>}
|
|
{entityColumns.length > 0 && (
|
|
<div className="space-y-3">
|
|
<div>
|
|
<p className="mb-1.5 text-xs text-muted-foreground">실제 저장되는 값</p>
|
|
<Select value={config.entityValueColumn || ""} onValueChange={(v) => updateConfig("entityValueColumn", v)}>
|
|
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="컬럼 선택" /></SelectTrigger>
|
|
<SelectContent>{entityColumns.map((col) => (<SelectItem key={col.columnName} value={col.columnName}>{col.columnLabel}</SelectItem>))}</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<p className="mb-1.5 text-xs text-muted-foreground">사용자에게 보여지는 텍스트</p>
|
|
<Select value={config.entityLabelColumn || ""} onValueChange={(v) => updateConfig("entityLabelColumn", v)}>
|
|
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="컬럼 선택" /></SelectTrigger>
|
|
<SelectContent>{entityColumns.map((col) => (<SelectItem key={col.columnName} value={col.columnName}>{col.columnLabel}</SelectItem>))}</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<p className="text-[11px] text-muted-foreground">엔티티 선택 시 같은 폼의 관련 필드가 자동으로 채워져요</p>
|
|
</div>
|
|
)}
|
|
{config.entityTable && !loadingColumns && entityColumns.length === 0 && (
|
|
<p className="text-[10px] text-amber-600">선택한 테이블의 컬럼 정보를 불러올 수 없어요.</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* ─── 채번 (테이블 기반) ─── */}
|
|
{fieldType === "numbering" && (
|
|
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<ListOrdered className="h-4 w-4 text-primary" />
|
|
<span className="text-sm font-medium">채번 규칙</span>
|
|
</div>
|
|
{numberingTableName ? (
|
|
<div className="rounded-md border bg-background p-2">
|
|
<p className="text-xs text-muted-foreground">대상 테이블</p>
|
|
<p className="text-sm font-medium mt-0.5">{numberingTableName}</p>
|
|
</div>
|
|
) : (
|
|
<p className="text-xs text-amber-600">화면에 연결된 테이블이 없어서 채번 규칙을 불러올 수 없어요.</p>
|
|
)}
|
|
{numberingTableName && (
|
|
<div>
|
|
<p className="mb-1.5 text-xs text-muted-foreground">채번 규칙</p>
|
|
{loadingRules ? (
|
|
<div className="text-muted-foreground flex items-center gap-2 text-xs py-1"><Loader2 className="h-3 w-3 animate-spin" />채번 규칙 로딩 중...</div>
|
|
) : numberingRules.length > 0 ? (
|
|
<Select value={config.autoGeneration?.numberingRuleId || ""} onValueChange={(v) => {
|
|
onChange({ ...config, autoGeneration: { ...config.autoGeneration, type: "numbering_rule" as AutoGenerationType, numberingRuleId: v, tableName: numberingTableName } });
|
|
}}>
|
|
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="채번 규칙 선택" /></SelectTrigger>
|
|
<SelectContent>
|
|
{numberingRules.map((rule) => (<SelectItem key={rule.ruleId} value={String(rule.ruleId)}>{rule.ruleName} ({rule.separator || "-"}{"{번호}"})</SelectItem>))}
|
|
</SelectContent>
|
|
</Select>
|
|
) : (
|
|
<p className="text-xs text-muted-foreground">이 테이블에 등록된 채번 규칙이 없어요</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="flex items-center justify-between py-1">
|
|
<div>
|
|
<p className="text-sm">읽기전용</p>
|
|
<p className="text-[11px] text-muted-foreground">채번 필드는 자동 생성되므로 읽기전용을 권장해요</p>
|
|
</div>
|
|
<Switch checked={config.readonly !== false} onCheckedChange={(checked) => updateConfig("readonly", checked)} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ─── 데이터 필터 (선택형 + 테이블 있을 때만) ─── */}
|
|
{isSelectGroup && fieldType !== "select" && filterTargetTable && (
|
|
<div className="rounded-lg border bg-muted/30 p-4">
|
|
<FilterConditionsSection
|
|
filters={(config.filters as V2SelectFilter[]) || []}
|
|
columns={filterColumns}
|
|
loadingColumns={loadingFilterColumns}
|
|
targetTable={filterTargetTable}
|
|
onFiltersChange={(filters) => updateConfig("filters", filters)}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* ═══ 3단계: 고급 설정 ═══ */}
|
|
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
|
<CollapsibleTrigger asChild>
|
|
<button type="button" className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50">
|
|
<div className="flex items-center gap-2">
|
|
<Settings className="h-4 w-4 text-muted-foreground" />
|
|
<span className="text-sm font-medium">고급 설정</span>
|
|
</div>
|
|
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", advancedOpen && "rotate-180")} />
|
|
</button>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
|
|
{/* 선택형: 선택 방식, 복수 선택, 검색 등 */}
|
|
{isSelectGroup && (
|
|
<>
|
|
<div>
|
|
<p className="mb-1.5 text-xs text-muted-foreground">선택 방식</p>
|
|
<Select value={config.mode || "dropdown"} onValueChange={(v) => updateConfig("mode", v)}>
|
|
<SelectTrigger className="h-8 text-sm"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="dropdown">드롭다운</SelectItem>
|
|
<SelectItem value="combobox">검색 가능 드롭다운</SelectItem>
|
|
<SelectItem value="radio">라디오 버튼</SelectItem>
|
|
<SelectItem value="check">체크박스</SelectItem>
|
|
<Separator className="my-1" />
|
|
<SelectItem value="tag">태그 선택</SelectItem>
|
|
<SelectItem value="toggle">토글</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between py-1">
|
|
<div><p className="text-sm">여러 개 선택</p><p className="text-[11px] text-muted-foreground">한 번에 여러 값을 선택할 수 있어요</p></div>
|
|
<Switch checked={config.multiple || false} onCheckedChange={(v) => updateConfig("multiple", v)} />
|
|
</div>
|
|
{config.multiple && (
|
|
<div className="ml-4 border-l-2 border-primary/20 pl-3">
|
|
<div className="flex items-center justify-between py-1">
|
|
<span className="text-xs text-muted-foreground">최대 선택 개수</span>
|
|
<Input type="number" value={config.maxSelect ?? ""} onChange={(e) => updateConfig("maxSelect", e.target.value ? Number(e.target.value) : undefined)} placeholder="제한 없음" min={1} className="h-7 w-[100px] text-xs" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="flex items-center justify-between py-1">
|
|
<div><p className="text-sm">검색 기능</p><p className="text-[11px] text-muted-foreground">옵션이 많을 때 검색으로 찾을 수 있어요</p></div>
|
|
<Switch checked={config.searchable || false} onCheckedChange={(v) => updateConfig("searchable", v)} />
|
|
</div>
|
|
<div className="flex items-center justify-between py-1">
|
|
<div><p className="text-sm">선택 초기화</p><p className="text-[11px] text-muted-foreground">선택한 값을 지울 수 있는 X 버튼이 표시돼요</p></div>
|
|
<Switch checked={config.allowClear !== false} onCheckedChange={(v) => updateConfig("allowClear", v)} />
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* 입력형: 자동 생성 */}
|
|
{!isSelectGroup && fieldType !== "numbering" && (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between py-1">
|
|
<div><p className="text-sm">자동 생성</p><p className="text-[11px] text-muted-foreground">값이 자동으로 채워져요</p></div>
|
|
<Switch checked={config.autoGeneration?.enabled || false} onCheckedChange={(checked) => updateConfig("autoGeneration", { ...config.autoGeneration || { type: "none", enabled: false }, enabled: checked })} />
|
|
</div>
|
|
{config.autoGeneration?.enabled && (
|
|
<div className="ml-1 border-l-2 border-primary/20 pl-3">
|
|
<div>
|
|
<p className="mb-1.5 text-xs text-muted-foreground">생성 방식</p>
|
|
<Select value={config.autoGeneration?.type || "none"} onValueChange={(v: AutoGenerationType) => updateConfig("autoGeneration", { ...config.autoGeneration, type: v })}>
|
|
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="자동생성 타입 선택" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">자동생성 없음</SelectItem>
|
|
<SelectItem value="uuid">UUID 생성</SelectItem>
|
|
<SelectItem value="current_user">현재 사용자 ID</SelectItem>
|
|
<SelectItem value="current_time">현재 시간</SelectItem>
|
|
<SelectItem value="sequence">순차 번호</SelectItem>
|
|
<SelectItem value="company_code">회사 코드</SelectItem>
|
|
<SelectItem value="department">부서 코드</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
{config.autoGeneration?.type && config.autoGeneration.type !== "none" && (
|
|
<p className="text-[11px] text-muted-foreground mt-1">{AutoGenerationUtils.getTypeDescription(config.autoGeneration.type)}</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 입력 마스크 */}
|
|
<div className="flex items-center justify-between py-1">
|
|
<div>
|
|
<span className="text-xs text-muted-foreground">입력 마스크</span>
|
|
<p className="text-[10px] text-muted-foreground mt-0.5"># = 숫자, A = 문자, * = 모두</p>
|
|
</div>
|
|
<Input value={config.mask || ""} onChange={(e) => updateConfig("mask", e.target.value)} placeholder="###-####-####" className="h-7 w-[140px] text-xs" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
V2FieldConfigPanel.displayName = "V2FieldConfigPanel";
|
|
|
|
export default V2FieldConfigPanel;
|