엔티티타입 연쇄관계관리 설정 추가

This commit is contained in:
kjs
2025-12-17 11:48:05 +09:00
parent 0832e7b6eb
commit 3d287bb883
10 changed files with 890 additions and 390 deletions

View File

@@ -6,18 +6,10 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Database, Search, Plus, Trash2 } from "lucide-react";
import { Database, Search, Info } from "lucide-react";
import { WebTypeConfigPanelProps } from "@/lib/registry/types";
import { WidgetComponent, EntityTypeConfig } from "@/types/screen";
interface EntityField {
name: string;
label: string;
type: string;
visible: boolean;
}
import { tableTypeApi } from "@/lib/api/screen";
export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
component,
@@ -27,16 +19,31 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
const widget = component as WidgetComponent;
const config = (widget.webTypeConfig as EntityTypeConfig) || {};
// 로컬 상태
// 테이블 타입 관리에서 설정된 참조 테이블 정보
const [referenceInfo, setReferenceInfo] = useState<{
referenceTable: string;
referenceColumn: string;
displayColumn: string;
isLoading: boolean;
error: string | null;
}>({
referenceTable: "",
referenceColumn: "",
displayColumn: "",
isLoading: true,
error: null,
});
// 로컬 상태 (UI 관련 설정만)
const [localConfig, setLocalConfig] = useState<EntityTypeConfig>({
entityType: config.entityType || "",
displayFields: config.displayFields || [],
searchFields: config.searchFields || [],
valueField: config.valueField || "id",
labelField: config.labelField || "name",
valueField: config.valueField || "",
labelField: config.labelField || "",
multiple: config.multiple || false,
searchable: config.searchable !== false, // 기본값 true
placeholder: config.placeholder || "엔티티를 선택하세요",
searchable: config.searchable !== false,
placeholder: config.placeholder || "항목을 선택하세요",
emptyMessage: config.emptyMessage || "검색 결과가 없습니다",
pageSize: config.pageSize || 20,
minSearchLength: config.minSearchLength || 1,
@@ -47,10 +54,95 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
filters: config.filters || {},
});
// 새 필드 추가용 상태
const [newFieldName, setNewFieldName] = useState("");
const [newFieldLabel, setNewFieldLabel] = useState("");
const [newFieldType, setNewFieldType] = useState("string");
// 테이블 타입 관리에서 설정된 참조 테이블 정보 로드
useEffect(() => {
const loadReferenceInfo = async () => {
// 컴포넌트의 테이블명과 컬럼명이 있는 경우에만 조회
const tableName = widget.tableName;
const columnName = widget.columnName;
if (!tableName || !columnName) {
setReferenceInfo({
referenceTable: "",
referenceColumn: "",
displayColumn: "",
isLoading: false,
error: "테이블 또는 컬럼 정보가 없습니다.",
});
return;
}
try {
// 테이블 타입 관리에서 컬럼 정보 조회
const columns = await tableTypeApi.getColumns(tableName);
const columnInfo = columns.find((col: any) =>
(col.columnName || col.column_name) === columnName
);
if (columnInfo) {
const refTable = columnInfo.referenceTable || columnInfo.reference_table || "";
const refColumn = columnInfo.referenceColumn || columnInfo.reference_column || "";
const dispColumn = columnInfo.displayColumn || columnInfo.display_column || "";
// detailSettings에서도 정보 확인 (JSON 파싱)
let detailSettings: any = {};
if (columnInfo.detailSettings) {
try {
if (typeof columnInfo.detailSettings === 'string') {
detailSettings = JSON.parse(columnInfo.detailSettings);
} else {
detailSettings = columnInfo.detailSettings;
}
} catch {
// JSON 파싱 실패 시 무시
}
}
const finalRefTable = refTable || detailSettings.referenceTable || "";
const finalRefColumn = refColumn || detailSettings.referenceColumn || "";
const finalDispColumn = dispColumn || detailSettings.displayColumn || "";
setReferenceInfo({
referenceTable: finalRefTable,
referenceColumn: finalRefColumn,
displayColumn: finalDispColumn,
isLoading: false,
error: null,
});
// webTypeConfig에 참조 테이블 정보 자동 설정
if (finalRefTable) {
const newConfig = {
...localConfig,
valueField: finalRefColumn || "id",
labelField: finalDispColumn || "name",
};
setLocalConfig(newConfig);
onUpdateProperty("webTypeConfig", newConfig);
}
} else {
setReferenceInfo({
referenceTable: "",
referenceColumn: "",
displayColumn: "",
isLoading: false,
error: "컬럼 정보를 찾을 수 없습니다.",
});
}
} catch (error) {
console.error("참조 테이블 정보 로드 실패:", error);
setReferenceInfo({
referenceTable: "",
referenceColumn: "",
displayColumn: "",
isLoading: false,
error: "참조 테이블 정보 로드 실패",
});
}
};
loadReferenceInfo();
}, [widget.tableName, widget.columnName]);
// 컴포넌트 변경 시 로컬 상태 동기화
useEffect(() => {
@@ -59,11 +151,11 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
entityType: currentConfig.entityType || "",
displayFields: currentConfig.displayFields || [],
searchFields: currentConfig.searchFields || [],
valueField: currentConfig.valueField || "id",
labelField: currentConfig.labelField || "name",
valueField: currentConfig.valueField || referenceInfo.referenceColumn || "",
labelField: currentConfig.labelField || referenceInfo.displayColumn || "",
multiple: currentConfig.multiple || false,
searchable: currentConfig.searchable !== false,
placeholder: currentConfig.placeholder || "엔티티를 선택하세요",
placeholder: currentConfig.placeholder || "항목을 선택하세요",
emptyMessage: currentConfig.emptyMessage || "검색 결과가 없습니다",
pageSize: currentConfig.pageSize || 20,
minSearchLength: currentConfig.minSearchLength || 1,
@@ -73,7 +165,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
apiEndpoint: currentConfig.apiEndpoint || "",
filters: currentConfig.filters || {},
});
}, [widget.webTypeConfig]);
}, [widget.webTypeConfig, referenceInfo.referenceColumn, referenceInfo.displayColumn]);
// 설정 업데이트 핸들러 (즉시 부모에게 전달 - 드롭다운, 체크박스 등)
const updateConfig = (field: keyof EntityTypeConfig, value: any) => {
@@ -92,89 +184,6 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
onUpdateProperty("webTypeConfig", localConfig);
};
// 필드 추가
const addDisplayField = () => {
if (!newFieldName.trim() || !newFieldLabel.trim()) return;
const newField: EntityField = {
name: newFieldName.trim(),
label: newFieldLabel.trim(),
type: newFieldType,
visible: true,
};
const newFields = [...localConfig.displayFields, newField];
updateConfig("displayFields", newFields);
setNewFieldName("");
setNewFieldLabel("");
setNewFieldType("string");
};
// 필드 제거
const removeDisplayField = (index: number) => {
const newFields = localConfig.displayFields.filter((_, i) => i !== index);
updateConfig("displayFields", newFields);
};
// 필드 업데이트 (입력 중) - 로컬 상태만 업데이트
const updateDisplayField = (index: number, field: keyof EntityField, value: any) => {
const newFields = [...localConfig.displayFields];
newFields[index] = { ...newFields[index], [field]: value };
setLocalConfig({ ...localConfig, displayFields: newFields });
};
// 필드 업데이트 완료 (onBlur) - 부모에게 전달
const handleFieldBlur = () => {
onUpdateProperty("webTypeConfig", localConfig);
};
// 검색 필드 토글
const toggleSearchField = (fieldName: string) => {
const currentSearchFields = localConfig.searchFields || [];
const newSearchFields = currentSearchFields.includes(fieldName)
? currentSearchFields.filter((f) => f !== fieldName)
: [...currentSearchFields, fieldName];
updateConfig("searchFields", newSearchFields);
};
// 기본 엔티티 타입들
const commonEntityTypes = [
{ value: "user", label: "사용자", fields: ["id", "name", "email", "department"] },
{ value: "department", label: "부서", fields: ["id", "name", "code", "parentId"] },
{ value: "product", label: "제품", fields: ["id", "name", "code", "category", "price"] },
{ value: "customer", label: "고객", fields: ["id", "name", "company", "contact"] },
{ value: "project", label: "프로젝트", fields: ["id", "name", "status", "manager", "startDate"] },
];
// 기본 엔티티 타입 적용
const applyEntityType = (entityType: string) => {
const entityConfig = commonEntityTypes.find((e) => e.value === entityType);
if (!entityConfig) return;
updateConfig("entityType", entityType);
updateConfig("apiEndpoint", `/api/entities/${entityType}`);
const defaultFields: EntityField[] = entityConfig.fields.map((field) => ({
name: field,
label: field.charAt(0).toUpperCase() + field.slice(1),
type: field.includes("Date") ? "date" : field.includes("price") || field.includes("Id") ? "number" : "string",
visible: true,
}));
updateConfig("displayFields", defaultFields);
updateConfig("searchFields", [entityConfig.fields[1] || "name"]); // 두 번째 필드를 기본 검색 필드로
};
// 필드 타입 옵션
const fieldTypes = [
{ value: "string", label: "문자열" },
{ value: "number", label: "숫자" },
{ value: "date", label: "날짜" },
{ value: "boolean", label: "불린" },
{ value: "email", label: "이메일" },
{ value: "url", label: "URL" },
];
return (
<Card>
<CardHeader>
@@ -182,12 +191,70 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
<Database className="h-4 w-4" />
</CardTitle>
<CardDescription className="text-xs"> .</CardDescription>
<CardDescription className="text-xs">
.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* 기본 설정 */}
{/* 참조 테이블 정보 (테이블 타입 관리에서 설정된 값 - 읽기 전용) */}
<div className="space-y-3">
<h4 className="text-sm font-medium"> </h4>
<h4 className="text-sm font-medium flex items-center gap-2">
<span className="bg-muted text-muted-foreground px-1.5 py-0.5 rounded text-[10px]">
</span>
</h4>
{referenceInfo.isLoading ? (
<div className="bg-muted/50 rounded-md border p-3">
<p className="text-xs text-muted-foreground"> ...</p>
</div>
) : referenceInfo.error ? (
<div className="bg-destructive/10 rounded-md border border-destructive/20 p-3">
<p className="text-xs text-destructive flex items-center gap-1">
<Info className="h-3 w-3" />
{referenceInfo.error}
</p>
<p className="text-[10px] text-muted-foreground mt-1">
.
</p>
</div>
) : !referenceInfo.referenceTable ? (
<div className="bg-amber-500/10 rounded-md border border-amber-500/20 p-3">
<p className="text-xs text-amber-700 flex items-center gap-1">
<Info className="h-3 w-3" />
.
</p>
<p className="text-[10px] text-muted-foreground mt-1">
.
</p>
</div>
) : (
<div className="bg-muted/50 rounded-md border p-3 space-y-2">
<div className="grid grid-cols-3 gap-2 text-xs">
<div>
<span className="text-muted-foreground"> :</span>
<div className="font-medium">{referenceInfo.referenceTable}</div>
</div>
<div>
<span className="text-muted-foreground"> :</span>
<div className="font-medium">{referenceInfo.referenceColumn || "-"}</div>
</div>
<div>
<span className="text-muted-foreground"> :</span>
<div className="font-medium">{referenceInfo.displayColumn || "-"}</div>
</div>
</div>
<p className="text-[10px] text-muted-foreground">
.
</p>
</div>
)}
</div>
{/* UI 모드 설정 */}
<div className="space-y-3">
<h4 className="text-sm font-medium">UI </h4>
{/* UI 모드 선택 */}
<div className="space-y-2">
@@ -216,208 +283,6 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
</p>
</div>
<div className="space-y-2">
<Label htmlFor="entityType" className="text-xs">
</Label>
<Input
id="entityType"
value={localConfig.entityType || ""}
onChange={(e) => updateConfigLocal("entityType", e.target.value)}
onBlur={handleInputBlur}
placeholder="user, product, department..."
className="text-xs"
/>
</div>
<div className="space-y-2">
<Label className="text-xs"> </Label>
<div className="grid grid-cols-2 gap-2">
{commonEntityTypes.map((entity) => (
<Button
key={entity.value}
size="sm"
variant="outline"
onClick={() => applyEntityType(entity.value)}
className="text-xs"
>
{entity.label}
</Button>
))}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="apiEndpoint" className="text-xs">
API
</Label>
<Input
id="apiEndpoint"
value={localConfig.apiEndpoint || ""}
onChange={(e) => updateConfigLocal("apiEndpoint", e.target.value)}
onBlur={handleInputBlur}
placeholder="/api/entities/user"
className="text-xs"
/>
</div>
</div>
{/* 필드 매핑 */}
<div className="space-y-3">
<h4 className="text-sm font-medium"> </h4>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label htmlFor="valueField" className="text-xs">
</Label>
<Input
id="valueField"
value={localConfig.valueField || ""}
onChange={(e) => updateConfigLocal("valueField", e.target.value)}
onBlur={handleInputBlur}
placeholder="id"
className="text-xs"
/>
</div>
<div className="space-y-2">
<Label htmlFor="labelField" className="text-xs">
</Label>
<Input
id="labelField"
value={localConfig.labelField || ""}
onChange={(e) => updateConfigLocal("labelField", e.target.value)}
onBlur={handleInputBlur}
placeholder="name"
className="text-xs"
/>
</div>
</div>
</div>
{/* 표시 필드 관리 */}
<div className="space-y-3">
<h4 className="text-sm font-medium"> </h4>
{/* 새 필드 추가 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<div className="flex gap-2">
<Input
value={newFieldName}
onChange={(e) => setNewFieldName(e.target.value)}
placeholder="필드명"
className="flex-1 text-xs"
/>
<Input
value={newFieldLabel}
onChange={(e) => setNewFieldLabel(e.target.value)}
placeholder="라벨"
className="flex-1 text-xs"
/>
<Select value={newFieldType} onValueChange={setNewFieldType}>
<SelectTrigger className="w-24 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{fieldTypes.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
onClick={addDisplayField}
disabled={!newFieldName.trim() || !newFieldLabel.trim()}
className="text-xs"
>
<Plus className="h-3 w-3" />
</Button>
</div>
</div>
{/* 현재 필드 목록 */}
<div className="space-y-2">
<Label className="text-xs"> ({localConfig.displayFields.length})</Label>
<div className="max-h-40 space-y-2 overflow-y-auto">
{localConfig.displayFields.map((field, index) => (
<div key={`${field.name}-${index}`} className="flex items-center gap-2 rounded border p-2">
<Switch
checked={field.visible}
onCheckedChange={(checked) => {
const newFields = [...localConfig.displayFields];
newFields[index] = { ...newFields[index], visible: checked };
const newConfig = { ...localConfig, displayFields: newFields };
setLocalConfig(newConfig);
onUpdateProperty("webTypeConfig", newConfig);
}}
/>
<Input
value={field.name}
onChange={(e) => updateDisplayField(index, "name", e.target.value)}
onBlur={handleFieldBlur}
placeholder="필드명"
className="flex-1 text-xs"
/>
<Input
value={field.label}
onChange={(e) => updateDisplayField(index, "label", e.target.value)}
onBlur={handleFieldBlur}
placeholder="라벨"
className="flex-1 text-xs"
/>
<Select
value={field.type}
onValueChange={(value) => {
const newFields = [...localConfig.displayFields];
newFields[index] = { ...newFields[index], type: value };
const newConfig = { ...localConfig, displayFields: newFields };
setLocalConfig(newConfig);
onUpdateProperty("webTypeConfig", newConfig);
}}
>
<SelectTrigger className="w-24 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{fieldTypes.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant={localConfig.searchFields.includes(field.name) ? "default" : "outline"}
onClick={() => toggleSearchField(field.name)}
className="p-1 text-xs"
title={localConfig.searchFields.includes(field.name) ? "검색 필드에서 제거" : "검색 필드로 추가"}
>
<Search className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => removeDisplayField(index)}
className="p-1 text-xs"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
</div>
{/* 검색 설정 */}
<div className="space-y-3">
<h4 className="text-sm font-medium"> </h4>
<div className="space-y-2">
<Label htmlFor="placeholder" className="text-xs">
@@ -427,7 +292,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
value={localConfig.placeholder || ""}
onChange={(e) => updateConfigLocal("placeholder", e.target.value)}
onBlur={handleInputBlur}
placeholder="엔티티를 선택하세요"
placeholder="항목을 선택하세요"
className="text-xs"
/>
</div>
@@ -445,6 +310,11 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
className="text-xs"
/>
</div>
</div>
{/* 검색 설정 */}
<div className="space-y-3">
<h4 className="text-sm font-medium"> </h4>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
@@ -483,7 +353,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
<Label htmlFor="searchable" className="text-xs">
</Label>
<p className="text-muted-foreground text-xs"> .</p>
<p className="text-muted-foreground text-xs"> .</p>
</div>
<Switch
id="searchable"
@@ -497,7 +367,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
<Label htmlFor="multiple" className="text-xs">
</Label>
<p className="text-muted-foreground text-xs"> .</p>
<p className="text-muted-foreground text-xs"> .</p>
</div>
<Switch
id="multiple"
@@ -507,33 +377,6 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
</div>
</div>
{/* 필터 설정 */}
<div className="space-y-3">
<h4 className="text-sm font-medium"> </h4>
<div className="space-y-2">
<Label htmlFor="filters" className="text-xs">
JSON
</Label>
<Textarea
id="filters"
value={JSON.stringify(localConfig.filters || {}, null, 2)}
onChange={(e) => {
try {
const parsed = JSON.parse(e.target.value);
updateConfig("filters", parsed);
} catch {
// 유효하지 않은 JSON은 무시
}
}}
placeholder='{"status": "active", "department": "IT"}'
className="font-mono text-xs"
rows={3}
/>
<p className="text-muted-foreground text-xs">API JSON .</p>
</div>
</div>
{/* 상태 설정 */}
<div className="space-y-3">
<h4 className="text-sm font-medium"> </h4>
@@ -543,7 +386,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
<Label htmlFor="required" className="text-xs">
</Label>
<p className="text-muted-foreground text-xs"> .</p>
<p className="text-muted-foreground text-xs"> .</p>
</div>
<Switch
id="required"
@@ -557,7 +400,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
<Label htmlFor="readonly" className="text-xs">
</Label>
<p className="text-muted-foreground text-xs"> .</p>
<p className="text-muted-foreground text-xs"> .</p>
</div>
<Switch
id="readonly"
@@ -574,31 +417,18 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
<div className="space-y-2">
<div className="flex items-center gap-2 rounded border bg-white p-2">
<Database className="h-4 w-4 text-gray-400" />
<span className="flex-1 text-xs text-muted-foreground">{localConfig.placeholder || "엔티티를 선택하세요"}</span>
<span className="flex-1 text-xs text-muted-foreground">
{localConfig.placeholder || "항목을 선택하세요"}
</span>
{localConfig.searchable && <Search className="h-4 w-4 text-gray-400" />}
</div>
{localConfig.displayFields.length > 0 && (
<div className="text-muted-foreground text-xs">
<div className="font-medium"> :</div>
<div className="mt-1 flex flex-wrap gap-1">
{localConfig.displayFields
.filter((f) => f.visible)
.map((field, index) => (
<span key={index} className="rounded bg-gray-100 px-2 py-1">
{field.label}
{localConfig.searchFields.includes(field.name) && " 🔍"}
</span>
))}
</div>
</div>
)}
<div className="text-muted-foreground text-xs">
: {localConfig.entityType || "미정"} : {localConfig.valueField} :{" "}
{localConfig.labelField}
{localConfig.multiple && " • 다중선택"}
{localConfig.required && " • 필수"}
<div>: {referenceInfo.referenceTable || "미정"}</div>
<div> : {localConfig.valueField || referenceInfo.referenceColumn || "-"}</div>
<div> : {localConfig.labelField || referenceInfo.displayColumn || "-"}</div>
{localConfig.multiple && <span> / </span>}
{localConfig.required && <span> / </span>}
</div>
</div>
</div>
@@ -609,5 +439,3 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
};
EntityConfigPanel.displayName = "EntityConfigPanel";