docs: 다양한 문서 및 가이드 업데이트

- 여러 문서의 내용을 업데이트하여 최신 정보를 반영하였습니다.
- 컴포넌트 개발 가이드와 관련된 문서의 목차를 재구성하고, V2 및 Zod 레이아웃 시스템에 대한 내용을 추가하였습니다.
- 화면 컴포넌트 개발 가이드를 개선하여 핵심 원칙과 패턴을 명확히 설명하였습니다.
- 불필요한 문서 및 가이드를 삭제하고, 통합된 가이드를 통해 개발자들이 쉽게 참고할 수 있도록 하였습니다.
This commit is contained in:
kjs
2026-01-28 17:36:19 +09:00
parent e0ee375f01
commit 95bef976a5
276 changed files with 2544 additions and 2495 deletions
@@ -1,493 +0,0 @@
"use client";
/**
* ConditionalConfigPanel
*
* 비개발자도 쉽게 조건부 표시/숨김/활성화/비활성화를 설정할 수 있는 UI
*
* 사용처:
* - 화면관리 > 상세설정 패널
* - 화면관리 > 속성 패널
*/
import React, { useState, useEffect, useMemo } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Zap, Plus, Trash2, HelpCircle, Check, ChevronsUpDown } from "lucide-react";
import { ConditionalConfig } from "@/types/unified-components";
import { cn } from "@/lib/utils";
// ===== 타입 정의 =====
interface FieldOption {
id: string;
label: string;
type?: string; // text, number, select, checkbox, entity, code 등
options?: Array<{ value: string; label: string }>; // select 타입일 경우 옵션들
// 동적 옵션 로드를 위한 정보
entityTable?: string;
entityValueColumn?: string;
entityLabelColumn?: string;
codeGroup?: string;
}
interface ConditionalConfigPanelProps {
/** 현재 조건부 설정 */
config?: ConditionalConfig;
/** 설정 변경 콜백 */
onChange: (config: ConditionalConfig | undefined) => void;
/** 같은 화면에 있는 다른 필드들 (조건 필드로 선택 가능) */
availableFields: FieldOption[];
/** 현재 컴포넌트 ID (자기 자신은 조건 필드에서 제외) */
currentComponentId?: string;
}
// 연산자 옵션
const OPERATORS: Array<{ value: ConditionalConfig["operator"]; label: string; description: string }> = [
{ value: "=", label: "같음", description: "값이 정확히 일치할 때" },
{ value: "!=", label: "다름", description: "값이 일치하지 않을 때" },
{ value: ">", label: "보다 큼", description: "값이 더 클 때 (숫자)" },
{ value: "<", label: "보다 작음", description: "값이 더 작을 때 (숫자)" },
{ value: "in", label: "포함됨", description: "여러 값 중 하나일 때" },
{ value: "notIn", label: "포함 안됨", description: "여러 값 중 아무것도 아닐 때" },
{ value: "isEmpty", label: "비어있음", description: "값이 없을 때" },
{ value: "isNotEmpty", label: "값이 있음", description: "값이 있을 때" },
];
// 동작 옵션
const ACTIONS: Array<{ value: ConditionalConfig["action"]; label: string; description: string }> = [
{ value: "show", label: "표시", description: "조건 만족 시 이 필드를 표시" },
{ value: "hide", label: "숨김", description: "조건 만족 시 이 필드를 숨김" },
{ value: "enable", label: "활성화", description: "조건 만족 시 이 필드를 활성화" },
{ value: "disable", label: "비활성화", description: "조건 만족 시 이 필드를 비활성화" },
];
// ===== 컴포넌트 =====
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);
// Combobox 열림 상태
const [comboboxOpen, setComboboxOpen] = useState(false);
// 엔티티/공통코드 필드 선택 시 동적으로 옵션 로드
useEffect(() => {
const loadDynamicOptions = async () => {
if (!selectedField) {
setDynamicOptions([]);
return;
}
// 정적 옵션이 있으면 사용
if (selectedField.options && selectedField.options.length > 0) {
setDynamicOptions([]);
return;
}
// 엔티티 타입 (타입이 entity이거나, entityTable이 있으면 엔티티로 간주)
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;
}
// 공통코드 타입 (타입이 code이거나, codeGroup이 있으면 공통코드로 간주)
if (selectedField.codeGroup) {
setLoadingOptions(true);
try {
const { apiClient } = await import("@/lib/api/client");
// 올바른 API 경로: /common-codes/categories/:categoryCode/options
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]);
// config prop 변경 시 로컬 상태 동기화
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,
};
// enabled가 false이면 undefined 반환 (설정 제거)
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);
// 비어있음/값이있음 연산자는 value 필요 없음
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 className="text-xs text-muted-foreground italic">
( )
</div>
);
}
// 옵션 로딩 중
if (loadingOptions) {
return (
<div className="text-xs text-muted-foreground italic">
...
</div>
);
}
// 옵션이 있으면 검색 가능한 Combobox로 표시
if (fieldOptions.length > 0) {
const selectedOption = fieldOptions.find((opt) => opt.value === value);
return (
<Popover open={comboboxOpen} onOpenChange={setComboboxOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={comboboxOpen}
className="h-8 w-full justify-between text-xs font-normal"
>
<span className="truncate">
{selectedOption ? selectedOption.label : "값 선택"}
</span>
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="start">
<Command>
<CommandInput placeholder="검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs">
</CommandEmpty>
<CommandGroup>
{fieldOptions.map((opt) => (
<CommandItem
key={opt.value}
value={opt.label}
onSelect={() => {
handleValueChange(opt.value);
setComboboxOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
value === opt.value ? "opacity-100" : "opacity-0"
)}
/>
<span className="truncate">{opt.label}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
// 체크박스 타입이면 true/false Select
if (selectedField?.type === "checkbox" || selectedField?.type === "boolean") {
return (
<Select value={value} onValueChange={handleValueChange}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="값 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="true" className="text-xs"></SelectItem>
<SelectItem value="false" className="text-xs"> </SelectItem>
</SelectContent>
</Select>
);
}
// 숫자 타입
if (selectedField?.type === "number") {
return (
<Input
type="number"
value={value}
onChange={(e) => handleValueChange(e.target.value)}
placeholder="숫자 입력"
className="h-8 text-xs"
/>
);
}
// 기본: 텍스트 입력
return (
<Input
value={value}
onChange={(e) => handleValueChange(e.target.value)}
placeholder="값 입력"
className="h-8 text-xs"
/>
);
};
return (
<div className="space-y-4">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-orange-500" />
<span className="text-sm font-medium"> </span>
<span
className="text-muted-foreground cursor-help"
title="다른 필드의 값에 따라 이 필드를 표시/숨김/활성화/비활성화할 수 있습니다."
>
<HelpCircle className="h-3 w-3" />
</span>
</div>
<Switch
checked={enabled}
onCheckedChange={handleEnabledChange}
aria-label="조건부 표시 활성화"
/>
</div>
{/* 조건 설정 영역 */}
{enabled && (
<div className="space-y-3 rounded-lg border bg-muted/30 p-3">
{/* 조건 필드 선택 */}
<div className="space-y-1.5">
<Label className="text-xs font-medium"> </Label>
<Select value={field} onValueChange={handleFieldChange}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{selectableFields.length === 0 ? (
<div className="p-2 text-xs text-muted-foreground">
</div>
) : (
selectableFields.map((f) => (
<SelectItem key={f.id} value={f.id} className="text-xs">
{f.label || f.id}
</SelectItem>
))
)}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
{/* 연산자 선택 */}
<div className="space-y-1.5">
<Label className="text-xs font-medium"></Label>
<Select value={operator} onValueChange={handleOperatorChange}>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{OPERATORS.map((op) => (
<SelectItem key={op.value} value={op.value} className="text-xs">
<div className="flex flex-col">
<span>{op.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 값 입력 */}
<div className="space-y-1.5">
<Label className="text-xs font-medium"></Label>
{renderValueInput()}
</div>
{/* 동작 선택 */}
<div className="space-y-1.5">
<Label className="text-xs font-medium"></Label>
<Select value={action} onValueChange={handleActionChange}>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ACTIONS.map((act) => (
<SelectItem key={act.value} value={act.value} className="text-xs">
<div className="flex flex-col">
<span>{act.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
{ACTIONS.find(a => a.value === action)?.label}
</p>
</div>
{/* 미리보기 */}
{field && (
<div className="mt-3 rounded bg-slate-100 p-2">
<p className="text-[10px] font-medium text-slate-600"> :</p>
<p className="text-[11px] text-slate-800">
"{selectableFields.find(f => f.id === field)?.label || field}" {" "}
<span className="font-medium">
{operator === "isEmpty" ? "비어있으면" :
operator === "isNotEmpty" ? "값이 있으면" :
`"${value}"${operator === "=" ? "이면" :
operator === "!=" ? "이 아니면" :
operator === ">" ? "보다 크면" :
operator === "<" ? "보다 작으면" :
operator === "in" ? "에 포함되면" : "에 포함되지 않으면"}`}
</span>{" "}
{" "}
<span className="font-medium text-orange-600">
{action === "show" ? "표시" :
action === "hide" ? "숨김" :
action === "enable" ? "활성화" : "비활성화"}
</span>
</p>
</div>
)}
</div>
)}
</div>
);
}
export default ConditionalConfigPanel;
@@ -1,372 +0,0 @@
"use client";
/**
* DynamicConfigPanel
*
* JSON Schema 기반으로 동적으로 설정 UI를 생성하는 패널
* 모든 Unified 컴포넌트의 설정을 단일 컴포넌트로 처리
*/
import React, { useCallback, useMemo } from "react";
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 { Slider } from "@/components/ui/slider";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { ChevronDown } from "lucide-react";
import { JSONSchemaProperty, UnifiedConfigSchema } from "@/types/unified-components";
import { cn } from "@/lib/utils";
interface DynamicConfigPanelProps {
schema: UnifiedConfigSchema;
config: Record<string, unknown>;
onChange: (key: string, value: unknown) => void;
className?: string;
}
/**
* 개별 스키마 속성을 렌더링하는 컴포넌트
*/
function SchemaField({
name,
property,
value,
onChange,
path = [],
}: {
name: string;
property: JSONSchemaProperty;
value: unknown;
onChange: (key: string, value: unknown) => void;
path?: string[];
}) {
const fieldPath = [...path, name].join(".");
// 값 변경 핸들러
const handleChange = useCallback(
(newValue: unknown) => {
onChange(fieldPath, newValue);
},
[fieldPath, onChange]
);
// 타입에 따른 컴포넌트 렌더링
const renderField = () => {
// enum이 있으면 Select 렌더링
if (property.enum && property.enum.length > 0) {
return (
<Select
value={String(value ?? property.default ?? "")}
onValueChange={handleChange}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{property.enum.map((option) => (
<SelectItem key={option} value={option} className="text-xs">
{option}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
// 타입별 렌더링
switch (property.type) {
case "string":
return (
<Input
type="text"
value={String(value ?? property.default ?? "")}
onChange={(e) => handleChange(e.target.value)}
placeholder={property.description}
className="h-8 text-xs"
/>
);
case "number":
return (
<Input
type="number"
value={value !== undefined && value !== null ? Number(value) : ""}
onChange={(e) => handleChange(e.target.value ? Number(e.target.value) : undefined)}
placeholder={property.description}
className="h-8 text-xs"
/>
);
case "boolean":
return (
<Switch
checked={Boolean(value ?? property.default ?? false)}
onCheckedChange={handleChange}
/>
);
case "array":
// 배열은 간단한 텍스트 입력으로 처리 (쉼표 구분)
return (
<Textarea
value={Array.isArray(value) ? value.join(", ") : ""}
onChange={(e) => {
const arr = e.target.value.split(",").map((s) => s.trim()).filter(Boolean);
handleChange(arr);
}}
placeholder="쉼표로 구분하여 입력"
className="text-xs min-h-[60px]"
/>
);
case "object":
// 중첩 객체는 별도 섹션으로 렌더링
if (property.properties) {
return (
<div className="mt-2 pl-4 border-l-2 border-muted space-y-3">
{Object.entries(property.properties).map(([subName, subProp]) => (
<SchemaField
key={subName}
name={subName}
property={subProp}
value={(value as Record<string, unknown>)?.[subName]}
onChange={onChange}
path={[...path, name]}
/>
))}
</div>
);
}
return null;
default:
return (
<Input
type="text"
value={String(value ?? "")}
onChange={(e) => handleChange(e.target.value)}
className="h-8 text-xs"
/>
);
}
};
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium">
{property.title || name}
</Label>
{property.type === "boolean" && renderField()}
</div>
{property.description && (
<p className="text-[10px] text-muted-foreground">{property.description}</p>
)}
{property.type !== "boolean" && renderField()}
</div>
);
}
/**
* 메인 DynamicConfigPanel 컴포넌트
*/
export function DynamicConfigPanel({
schema,
config,
onChange,
className,
}: DynamicConfigPanelProps) {
// 속성들을 카테고리별로 그룹화
const groupedProperties = useMemo(() => {
const groups: Record<string, Array<[string, JSONSchemaProperty]>> = {
: [],
: [],
: [],
};
Object.entries(schema.properties).forEach(([name, property]) => {
// 이름 기반으로 그룹 분류
if (name.includes("style") || name.includes("Style")) {
groups["스타일"].push([name, property]);
} else if (
name.includes("cascade") ||
name.includes("mutual") ||
name.includes("conditional") ||
name.includes("autoFill")
) {
groups["고급"].push([name, property]);
} else {
groups["기본"].push([name, property]);
}
});
return groups;
}, [schema.properties]);
// 값 변경 핸들러 (중첩 경로 지원)
const handleChange = useCallback(
(path: string, value: unknown) => {
onChange(path, value);
},
[onChange]
);
return (
<div className={cn("space-y-4", className)}>
{Object.entries(groupedProperties).map(
([groupName, properties]) =>
properties.length > 0 && (
<Collapsible key={groupName} defaultOpen={groupName === "기본"}>
<Card>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer py-3 px-4">
<CardTitle className="text-sm font-medium flex items-center justify-between">
{groupName}
<ChevronDown className="h-4 w-4" />
</CardTitle>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="pt-0 space-y-4">
{properties.map(([name, property]) => (
<SchemaField
key={name}
name={name}
property={property}
value={config[name]}
onChange={handleChange}
/>
))}
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
)
)}
</div>
);
}
/**
* 기본 스키마들 (자주 사용되는 설정)
*/
export const COMMON_SCHEMAS = {
// UnifiedInput 기본 스키마
UnifiedInput: {
type: "object" as const,
properties: {
type: {
type: "string" as const,
enum: ["text", "number", "password", "slider", "color", "button"],
default: "text",
title: "입력 타입",
},
format: {
type: "string" as const,
enum: ["none", "email", "tel", "url", "currency", "biz_no"],
default: "none",
title: "형식",
},
placeholder: {
type: "string" as const,
title: "플레이스홀더",
},
min: {
type: "number" as const,
title: "최소값",
description: "숫자 타입 전용",
},
max: {
type: "number" as const,
title: "최대값",
description: "숫자 타입 전용",
},
step: {
type: "number" as const,
title: "증가 단위",
},
},
},
// UnifiedSelect 기본 스키마
UnifiedSelect: {
type: "object" as const,
properties: {
mode: {
type: "string" as const,
enum: ["dropdown", "radio", "check", "tag", "toggle", "swap"],
default: "dropdown",
title: "표시 모드",
},
source: {
type: "string" as const,
enum: ["static", "code", "db", "api", "entity"],
default: "static",
title: "데이터 소스",
},
codeGroup: {
type: "string" as const,
title: "코드 그룹",
description: "source가 code일 때 사용",
},
searchable: {
type: "boolean" as const,
default: false,
title: "검색 가능",
},
multiple: {
type: "boolean" as const,
default: false,
title: "다중 선택",
},
maxSelect: {
type: "number" as const,
title: "최대 선택 수",
},
cascading: {
type: "object" as const,
title: "연쇄 관계",
properties: {
parentField: { type: "string" as const, title: "부모 필드" },
filterColumn: { type: "string" as const, title: "필터 컬럼" },
clearOnChange: { type: "boolean" as const, default: true, title: "부모 변경시 초기화" },
},
},
},
},
// UnifiedDate 기본 스키마
UnifiedDate: {
type: "object" as const,
properties: {
type: {
type: "string" as const,
enum: ["date", "time", "datetime"],
default: "date",
title: "타입",
},
format: {
type: "string" as const,
default: "YYYY-MM-DD",
title: "날짜 형식",
},
range: {
type: "boolean" as const,
default: false,
title: "범위 선택",
},
showToday: {
type: "boolean" as const,
default: true,
title: "오늘 버튼",
},
},
},
} satisfies Record<string, UnifiedConfigSchema>;
export default DynamicConfigPanel;
-349
View File
@@ -1,349 +0,0 @@
"use client";
/**
* UnifiedBiz
*
* 통합 비즈니스 컴포넌트
* - flow: 플로우/워크플로우
* - rack: 랙 구조
* - map: 맵/위치
* - numbering: 채번 규칙
* - category: 카테고리 관리
* - mapping: 데이터 매핑
* - related-buttons: 관련 데이터 버튼
*/
import React, { forwardRef } from "react";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { UnifiedBizProps } from "@/types/unified-components";
import {
GitBranch,
LayoutGrid,
MapPin,
Hash,
FolderTree,
Link2,
FileText,
ArrowRight
} from "lucide-react";
/**
* 플로우 컴포넌트 (플레이스홀더)
* 실제 구현은 기존 FlowWidget과 연동
*/
const FlowBiz = forwardRef<HTMLDivElement, {
config?: Record<string, unknown>;
className?: string;
}>(({ config, className }, ref) => {
return (
<Card ref={ref} className={className}>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<GitBranch className="h-4 w-4" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-48 border-2 border-dashed rounded-lg flex items-center justify-center text-muted-foreground">
<div className="text-center">
<GitBranch className="h-8 w-8 mx-auto mb-2" />
<p className="text-sm"> </p>
<p className="text-xs"> FlowWidget과 </p>
</div>
</div>
</CardContent>
</Card>
);
});
FlowBiz.displayName = "FlowBiz";
/**
* 랙 구조 컴포넌트 (플레이스홀더)
* 실제 구현은 기존 RackStructure와 연동
*/
const RackBiz = forwardRef<HTMLDivElement, {
config?: Record<string, unknown>;
className?: string;
}>(({ config, className }, ref) => {
return (
<Card ref={ref} className={className}>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<LayoutGrid className="h-4 w-4" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-48 border-2 border-dashed rounded-lg flex items-center justify-center text-muted-foreground">
<div className="text-center">
<LayoutGrid className="h-8 w-8 mx-auto mb-2" />
<p className="text-sm"> </p>
<p className="text-xs"> RackStructure와 </p>
</div>
</div>
</CardContent>
</Card>
);
});
RackBiz.displayName = "RackBiz";
/**
* 맵 컴포넌트 (플레이스홀더)
*/
const MapBiz = forwardRef<HTMLDivElement, {
config?: Record<string, unknown>;
className?: string;
}>(({ config, className }, ref) => {
return (
<Card ref={ref} className={className}>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<MapPin className="h-4 w-4" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-48 border-2 border-dashed rounded-lg flex items-center justify-center text-muted-foreground">
<div className="text-center">
<MapPin className="h-8 w-8 mx-auto mb-2" />
<p className="text-sm"> </p>
<p className="text-xs"> </p>
</div>
</div>
</CardContent>
</Card>
);
});
MapBiz.displayName = "MapBiz";
/**
* 채번 규칙 컴포넌트 (플레이스홀더)
* 실제 구현은 기존 NumberingRuleComponent와 연동
*/
const NumberingBiz = forwardRef<HTMLDivElement, {
config?: Record<string, unknown>;
className?: string;
}>(({ config, className }, ref) => {
return (
<Card ref={ref} className={className}>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Hash className="h-4 w-4" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
<div>
<p className="font-medium text-sm"> </p>
<p className="text-xs text-muted-foreground"> </p>
</div>
<div className="font-mono text-sm bg-background px-2 py-1 rounded border">
PO-2024-0001
</div>
</div>
<p className="text-xs text-muted-foreground text-center">
NumberingRuleComponent와
</p>
</div>
</CardContent>
</Card>
);
});
NumberingBiz.displayName = "NumberingBiz";
/**
* 카테고리 관리 컴포넌트 (플레이스홀더)
* 실제 구현은 기존 CategoryManager와 연동
*/
const CategoryBiz = forwardRef<HTMLDivElement, {
config?: Record<string, unknown>;
className?: string;
}>(({ config, className }, ref) => {
return (
<Card ref={ref} className={className}>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<FolderTree className="h-4 w-4" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="pl-0 py-1 px-2 bg-muted/50 rounded">
<span className="text-sm"></span>
</div>
<div className="pl-4 py-1 px-2 text-sm text-muted-foreground">
</div>
<div className="pl-8 py-1 px-2 text-sm text-muted-foreground">
</div>
<p className="text-xs text-muted-foreground text-center mt-3">
CategoryManager와
</p>
</div>
</CardContent>
</Card>
);
});
CategoryBiz.displayName = "CategoryBiz";
/**
* 데이터 매핑 컴포넌트 (플레이스홀더)
*/
const MappingBiz = forwardRef<HTMLDivElement, {
config?: Record<string, unknown>;
className?: string;
}>(({ config, className }, ref) => {
return (
<Card ref={ref} className={className}>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Link2 className="h-4 w-4" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4 justify-center p-4">
<div className="text-center">
<div className="w-20 h-20 border-2 rounded-lg flex items-center justify-center mb-2">
<FileText className="h-6 w-6 text-muted-foreground" />
</div>
<p className="text-xs text-muted-foreground"></p>
</div>
<ArrowRight className="h-6 w-6 text-muted-foreground" />
<div className="text-center">
<div className="w-20 h-20 border-2 rounded-lg flex items-center justify-center mb-2">
<FileText className="h-6 w-6 text-muted-foreground" />
</div>
<p className="text-xs text-muted-foreground"></p>
</div>
</div>
</CardContent>
</Card>
);
});
MappingBiz.displayName = "MappingBiz";
/**
* 관련 데이터 버튼 컴포넌트 (플레이스홀더)
*/
const RelatedButtonsBiz = forwardRef<HTMLDivElement, {
config?: Record<string, unknown>;
className?: string;
}>(({ config, className }, ref) => {
const buttons = (config?.buttons as Array<{ label: string; icon?: string }>) || [
{ label: "관련 주문" },
{ label: "관련 출고" },
{ label: "이력 보기" },
];
return (
<div ref={ref} className={cn("flex flex-wrap gap-2", className)}>
{buttons.map((button, index) => (
<Button key={index} variant="outline" size="sm">
{button.label}
</Button>
))}
</div>
);
});
RelatedButtonsBiz.displayName = "RelatedButtonsBiz";
/**
* 메인 UnifiedBiz 컴포넌트
*/
export const UnifiedBiz = forwardRef<HTMLDivElement, UnifiedBizProps>(
(props, ref) => {
const {
id,
label,
style,
size,
config: configProp,
} = props;
// config가 없으면 기본값 사용
const config = configProp || { type: "flow" as const };
// 타입별 비즈니스 컴포넌트 렌더링
const renderBiz = () => {
const bizConfig = config.config || {};
const bizType = config.type || "flow";
switch (bizType) {
case "flow":
return <FlowBiz config={bizConfig} />;
case "rack":
return <RackBiz config={bizConfig} />;
case "map":
return <MapBiz config={bizConfig} />;
case "numbering":
return <NumberingBiz config={bizConfig} />;
case "category":
return <CategoryBiz config={bizConfig} />;
case "mapping":
return <MappingBiz config={bizConfig} />;
case "related-buttons":
return <RelatedButtonsBiz config={bizConfig} />;
default:
return (
<div className="p-4 border rounded text-center text-muted-foreground">
: {config.type}
</div>
);
}
};
const showLabel = label && style?.labelDisplay !== false;
const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height;
return (
<div
ref={ref}
id={id}
className="flex flex-col"
style={{
width: componentWidth,
height: componentHeight,
}}
>
{showLabel && (
<Label
htmlFor={id}
style={{
fontSize: style?.labelFontSize,
color: style?.labelColor,
fontWeight: style?.labelFontWeight,
marginBottom: style?.labelMarginBottom,
}}
className="text-sm font-medium flex-shrink-0"
>
{label}
</Label>
)}
<div className="flex-1 min-h-0">
{renderBiz()}
</div>
</div>
);
}
);
UnifiedBiz.displayName = "UnifiedBiz";
export default UnifiedBiz;
@@ -1,111 +0,0 @@
"use client";
/**
* UnifiedComponentRenderer
*
* Unified 컴포넌트를 동적으로 렌더링하는 컴포넌트
* props.unifiedType에 따라 적절한 컴포넌트를 렌더링
*/
import React, { forwardRef, useMemo } from "react";
import {
UnifiedComponentProps,
isUnifiedInput,
isUnifiedSelect,
isUnifiedDate,
isUnifiedText,
isUnifiedMedia,
isUnifiedList,
isUnifiedLayout,
isUnifiedGroup,
isUnifiedBiz,
isUnifiedHierarchy,
} from "@/types/unified-components";
import { UnifiedInput } from "./UnifiedInput";
import { UnifiedSelect } from "./UnifiedSelect";
import { UnifiedDate } from "./UnifiedDate";
import { UnifiedList } from "./UnifiedList";
import { UnifiedLayout } from "./UnifiedLayout";
import { UnifiedGroup } from "./UnifiedGroup";
import { UnifiedMedia } from "./UnifiedMedia";
import { UnifiedBiz } from "./UnifiedBiz";
import { UnifiedHierarchy } from "./UnifiedHierarchy";
interface UnifiedComponentRendererProps {
props: UnifiedComponentProps;
className?: string;
}
/**
* Unified 컴포넌트 렌더러
*/
export const UnifiedComponentRenderer = forwardRef<HTMLDivElement, UnifiedComponentRendererProps>(
({ props, className }, ref) => {
const component = useMemo(() => {
// 타입 가드를 사용하여 적절한 컴포넌트 렌더링
if (isUnifiedInput(props)) {
return <UnifiedInput {...props} />;
}
if (isUnifiedSelect(props)) {
return <UnifiedSelect {...props} />;
}
if (isUnifiedDate(props)) {
return <UnifiedDate {...props} />;
}
if (isUnifiedText(props)) {
// UnifiedText는 UnifiedInput의 textarea 모드로 대체
// 필요시 별도 구현
return (
<div className="p-2 border rounded text-sm text-muted-foreground">
UnifiedText (UnifiedInput textarea )
</div>
);
}
if (isUnifiedMedia(props)) {
return <UnifiedMedia {...props} />;
}
if (isUnifiedList(props)) {
return <UnifiedList {...props} />;
}
if (isUnifiedLayout(props)) {
return <UnifiedLayout {...props} />;
}
if (isUnifiedGroup(props)) {
return <UnifiedGroup {...props} />;
}
if (isUnifiedBiz(props)) {
return <UnifiedBiz {...props} />;
}
if (isUnifiedHierarchy(props)) {
return <UnifiedHierarchy {...props} />;
}
// 알 수 없는 타입
return (
<div className="p-2 border border-destructive rounded text-sm text-destructive">
: {(props as { unifiedType?: string }).unifiedType}
</div>
);
}, [props]);
return (
<div ref={ref} className={className}>
{component}
</div>
);
}
);
UnifiedComponentRenderer.displayName = "UnifiedComponentRenderer";
export default UnifiedComponentRenderer;
File diff suppressed because it is too large Load Diff
-488
View File
@@ -1,488 +0,0 @@
"use client";
/**
* UnifiedDate
*
* 통합 날짜/시간 컴포넌트
* - date: 날짜 선택
* - time: 시간 선택
* - datetime: 날짜+시간 선택
* - range 옵션: 범위 선택 (시작~종료)
*/
import React, { forwardRef, useCallback, useMemo, useState } from "react";
import { format, parse, isValid } from "date-fns";
import { ko } from "date-fns/locale";
import { Calendar as CalendarIcon, Clock } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { UnifiedDateProps, UnifiedDateType } from "@/types/unified-components";
// 날짜 형식 매핑
const DATE_FORMATS: Record<string, string> = {
"YYYY-MM-DD": "yyyy-MM-dd",
"YYYY/MM/DD": "yyyy/MM/dd",
"DD-MM-YYYY": "dd-MM-yyyy",
"DD/MM/YYYY": "dd/MM/yyyy",
"MM-DD-YYYY": "MM-dd-yyyy",
"MM/DD/YYYY": "MM/dd/yyyy",
"YYYY-MM-DD HH:mm": "yyyy-MM-dd HH:mm",
"YYYY-MM-DD HH:mm:ss": "yyyy-MM-dd HH:mm:ss",
};
// 날짜 문자열 → Date 객체
function parseDate(value: string | undefined, formatStr: string): Date | undefined {
if (!value) return undefined;
const dateFnsFormat = DATE_FORMATS[formatStr] || formatStr;
try {
// ISO 형식 먼저 시도
const isoDate = new Date(value);
if (isValid(isoDate)) return isoDate;
// 포맷에 맞게 파싱
const parsed = parse(value, dateFnsFormat, new Date());
return isValid(parsed) ? parsed : undefined;
} catch {
return undefined;
}
}
// Date 객체 → 날짜 문자열
function formatDate(date: Date | undefined, formatStr: string): string {
if (!date || !isValid(date)) return "";
const dateFnsFormat = DATE_FORMATS[formatStr] || formatStr;
return format(date, dateFnsFormat);
}
/**
* 단일 날짜 선택 컴포넌트
*/
const SingleDatePicker = forwardRef<
HTMLButtonElement,
{
value?: string;
onChange?: (value: string) => void;
dateFormat: string;
showToday?: boolean;
minDate?: string;
maxDate?: string;
disabled?: boolean;
readonly?: boolean;
className?: string;
}
>(
(
{ value, onChange, dateFormat = "YYYY-MM-DD", showToday = true, minDate, maxDate, disabled, readonly, className },
ref,
) => {
const [open, setOpen] = useState(false);
const date = useMemo(() => parseDate(value, dateFormat), [value, dateFormat]);
const minDateObj = useMemo(() => parseDate(minDate, dateFormat), [minDate, dateFormat]);
const maxDateObj = useMemo(() => parseDate(maxDate, dateFormat), [maxDate, dateFormat]);
const handleSelect = useCallback(
(selectedDate: Date | undefined) => {
if (selectedDate) {
onChange?.(formatDate(selectedDate, dateFormat));
setOpen(false);
}
},
[dateFormat, onChange],
);
const handleToday = useCallback(() => {
onChange?.(formatDate(new Date(), dateFormat));
setOpen(false);
}, [dateFormat, onChange]);
const handleClear = useCallback(() => {
onChange?.("");
setOpen(false);
}, [onChange]);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
ref={ref}
variant="outline"
disabled={disabled || readonly}
className={cn(
"h-10 w-full justify-start text-left font-normal",
!value && "text-muted-foreground",
className,
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{value || "날짜 선택"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={date}
onSelect={handleSelect}
initialFocus
locale={ko}
disabled={(date) => {
if (minDateObj && date < minDateObj) return true;
if (maxDateObj && date > maxDateObj) return true;
return false;
}}
/>
<div className="flex gap-2 p-3 pt-0">
{showToday && (
<Button variant="outline" size="sm" onClick={handleToday}>
</Button>
)}
<Button variant="ghost" size="sm" onClick={handleClear}>
</Button>
</div>
</PopoverContent>
</Popover>
);
},
);
SingleDatePicker.displayName = "SingleDatePicker";
/**
* 날짜 범위 선택 컴포넌트
*/
const RangeDatePicker = forwardRef<
HTMLDivElement,
{
value?: [string, string];
onChange?: (value: [string, string]) => void;
dateFormat: string;
minDate?: string;
maxDate?: string;
disabled?: boolean;
readonly?: boolean;
className?: string;
}
>(({ value = ["", ""], onChange, dateFormat = "YYYY-MM-DD", minDate, maxDate, disabled, readonly, className }, ref) => {
const [openStart, setOpenStart] = useState(false);
const [openEnd, setOpenEnd] = useState(false);
const startDate = useMemo(() => parseDate(value[0], dateFormat), [value, dateFormat]);
const endDate = useMemo(() => parseDate(value[1], dateFormat), [value, dateFormat]);
const minDateObj = useMemo(() => parseDate(minDate, dateFormat), [minDate, dateFormat]);
const maxDateObj = useMemo(() => parseDate(maxDate, dateFormat), [maxDate, dateFormat]);
const handleStartSelect = useCallback(
(date: Date | undefined) => {
if (date) {
const newStart = formatDate(date, dateFormat);
// 시작일이 종료일보다 크면 종료일도 같이 변경
if (endDate && date > endDate) {
onChange?.([newStart, newStart]);
} else {
onChange?.([newStart, value[1]]);
}
setOpenStart(false);
}
},
[value, dateFormat, endDate, onChange],
);
const handleEndSelect = useCallback(
(date: Date | undefined) => {
if (date) {
const newEnd = formatDate(date, dateFormat);
// 종료일이 시작일보다 작으면 시작일도 같이 변경
if (startDate && date < startDate) {
onChange?.([newEnd, newEnd]);
} else {
onChange?.([value[0], newEnd]);
}
setOpenEnd(false);
}
},
[value, dateFormat, startDate, onChange],
);
return (
<div ref={ref} className={cn("flex items-center gap-2", className)}>
{/* 시작 날짜 */}
<Popover open={openStart} onOpenChange={setOpenStart}>
<PopoverTrigger asChild>
<Button
variant="outline"
disabled={disabled || readonly}
className={cn("h-10 flex-1 justify-start text-left font-normal", !value[0] && "text-muted-foreground")}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{value[0] || "시작일"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={startDate}
onSelect={handleStartSelect}
initialFocus
locale={ko}
disabled={(date) => {
if (minDateObj && date < minDateObj) return true;
if (maxDateObj && date > maxDateObj) return true;
return false;
}}
/>
</PopoverContent>
</Popover>
<span className="text-muted-foreground">~</span>
{/* 종료 날짜 */}
<Popover open={openEnd} onOpenChange={setOpenEnd}>
<PopoverTrigger asChild>
<Button
variant="outline"
disabled={disabled || readonly}
className={cn("h-10 flex-1 justify-start text-left font-normal", !value[1] && "text-muted-foreground")}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{value[1] || "종료일"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={endDate}
onSelect={handleEndSelect}
initialFocus
locale={ko}
disabled={(date) => {
if (minDateObj && date < minDateObj) return true;
if (maxDateObj && date > maxDateObj) return true;
// 시작일보다 이전 날짜는 선택 불가
if (startDate && date < startDate) return true;
return false;
}}
/>
</PopoverContent>
</Popover>
</div>
);
});
RangeDatePicker.displayName = "RangeDatePicker";
/**
* 시간 선택 컴포넌트
*/
const TimePicker = forwardRef<
HTMLInputElement,
{
value?: string;
onChange?: (value: string) => void;
disabled?: boolean;
readonly?: boolean;
className?: string;
}
>(({ value, onChange, disabled, readonly, className }, ref) => {
return (
<div className={cn("relative", className)}>
<Clock className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
ref={ref}
type="time"
value={value || ""}
onChange={(e) => onChange?.(e.target.value)}
disabled={disabled}
readOnly={readonly}
className="h-10 pl-10"
/>
</div>
);
});
TimePicker.displayName = "TimePicker";
/**
* 날짜+시간 선택 컴포넌트
*/
const DateTimePicker = forwardRef<
HTMLDivElement,
{
value?: string;
onChange?: (value: string) => void;
dateFormat: string;
minDate?: string;
maxDate?: string;
disabled?: boolean;
readonly?: boolean;
className?: string;
}
>(({ value, onChange, dateFormat = "YYYY-MM-DD HH:mm", minDate, maxDate, disabled, readonly, className }, ref) => {
// 날짜와 시간 분리
const [datePart, timePart] = useMemo(() => {
if (!value) return ["", ""];
const parts = value.split(" ");
return [parts[0] || "", parts[1] || ""];
}, [value]);
const handleDateChange = useCallback(
(newDate: string) => {
const newValue = `${newDate} ${timePart || "00:00"}`;
onChange?.(newValue.trim());
},
[timePart, onChange],
);
const handleTimeChange = useCallback(
(newTime: string) => {
const newValue = `${datePart || format(new Date(), "yyyy-MM-dd")} ${newTime}`;
onChange?.(newValue.trim());
},
[datePart, onChange],
);
return (
<div ref={ref} className={cn("flex gap-2", className)}>
<div className="flex-1">
<SingleDatePicker
value={datePart}
onChange={handleDateChange}
dateFormat="YYYY-MM-DD"
minDate={minDate}
maxDate={maxDate}
disabled={disabled}
readonly={readonly}
/>
</div>
<div className="w-32">
<TimePicker value={timePart} onChange={handleTimeChange} disabled={disabled} readonly={readonly} />
</div>
</div>
);
});
DateTimePicker.displayName = "DateTimePicker";
/**
* 메인 UnifiedDate 컴포넌트
*/
export const UnifiedDate = forwardRef<HTMLDivElement, UnifiedDateProps>((props, ref) => {
const { id, label, required, readonly, disabled, style, size, config: configProp, value, onChange } = props;
// config가 없으면 기본값 사용
const config = configProp || { type: "date" as const };
const dateFormat = config.format || "YYYY-MM-DD";
// 타입별 컴포넌트 렌더링
const renderDatePicker = () => {
const isDisabled = disabled || readonly;
// 범위 선택
if (config.range) {
return (
<RangeDatePicker
value={Array.isArray(value) ? (value as [string, string]) : ["", ""]}
onChange={onChange as (value: [string, string]) => void}
dateFormat={dateFormat}
minDate={config.minDate}
maxDate={config.maxDate}
disabled={isDisabled}
readonly={readonly}
/>
);
}
// 타입별 렌더링
switch (config.type) {
case "date":
return (
<SingleDatePicker
value={typeof value === "string" ? value : ""}
onChange={(v) => onChange?.(v)}
dateFormat={dateFormat}
showToday={config.showToday}
minDate={config.minDate}
maxDate={config.maxDate}
disabled={isDisabled}
readonly={readonly}
/>
);
case "time":
return (
<TimePicker
value={typeof value === "string" ? value : ""}
onChange={(v) => onChange?.(v)}
disabled={isDisabled}
readonly={readonly}
/>
);
case "datetime":
return (
<DateTimePicker
value={typeof value === "string" ? value : ""}
onChange={(v) => onChange?.(v)}
dateFormat={dateFormat}
minDate={config.minDate}
maxDate={config.maxDate}
disabled={isDisabled}
readonly={readonly}
/>
);
default:
return (
<SingleDatePicker
value={typeof value === "string" ? value : ""}
onChange={(v) => onChange?.(v)}
dateFormat={dateFormat}
showToday={config.showToday}
disabled={isDisabled}
readonly={readonly}
/>
);
}
};
const showLabel = label && style?.labelDisplay !== false;
const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height;
return (
<div
ref={ref}
id={id}
className="flex flex-col"
style={{
width: componentWidth,
height: componentHeight,
}}
>
{showLabel && (
<Label
htmlFor={id}
style={{
fontSize: style?.labelFontSize,
color: style?.labelColor,
fontWeight: style?.labelFontWeight,
marginBottom: style?.labelMarginBottom,
}}
className="flex-shrink-0 text-sm font-medium"
>
{label}
{required && <span className="ml-0.5 text-orange-500">*</span>}
</Label>
)}
<div className="min-h-0 flex-1">{renderDatePicker()}</div>
</div>
);
});
UnifiedDate.displayName = "UnifiedDate";
export default UnifiedDate;
@@ -1,693 +0,0 @@
"use client";
/**
* UnifiedFormContext
*
* Unified 컴포넌트들이 폼 상태를 공유하고
* 조건부 로직, 저장/검증/초기화 등의 폼 액션을 처리할 수 있도록 하는 Context
*
* 레거시 컴포넌트와의 호환성을 유지하면서 새로운 기능을 제공합니다.
*/
import React, { createContext, useContext, useState, useCallback, useMemo, useRef } from "react";
import { ConditionalConfig, CascadingConfig } from "@/types/unified-components";
import { ValidationRule } from "@/types/unified-core";
import type {
FormStatus,
FieldError,
FieldState,
SubmitConfig,
SubmitResult,
ValidationResult,
FormEventDetail,
} from "@/types/unified-form";
// ===== 레거시 타입 호환 (기존 코드와 호환) =====
export interface FormFieldState {
value: unknown;
disabled?: boolean;
visible?: boolean;
error?: string;
}
export interface FormState {
[fieldId: string]: FormFieldState;
}
// ===== 확장된 Context 타입 =====
export interface UnifiedFormContextValue {
// === 기존 기능 (하위 호환) ===
formData: Record<string, unknown>;
fieldStates: FormState;
getValue: (fieldId: string) => unknown;
setValue: (fieldId: string, value: unknown) => void;
setValues: (values: Record<string, unknown>) => void;
evaluateCondition: (fieldId: string, config?: ConditionalConfig) => {
visible: boolean;
disabled: boolean;
};
getCascadingFilter: (config?: CascadingConfig) => unknown;
registerField: (fieldId: string, initialValue?: unknown) => void;
unregisterField: (fieldId: string) => void;
// === 새로운 기능 ===
// 원본 데이터 (수정 모드)
originalData: Record<string, unknown>;
// 폼 상태
status: FormStatus;
errors: FieldError[];
// 폼 액션
submit: (config?: Partial<SubmitConfig>) => Promise<SubmitResult>;
reset: () => void;
validate: (fieldIds?: string[]) => Promise<ValidationResult>;
clear: () => void;
// 초기 데이터 설정 (수정 모드 진입)
setInitialData: (data: Record<string, unknown>) => void;
// 에러 관리
setFieldError: (fieldId: string, error: string, type?: FieldError["type"]) => void;
clearFieldError: (fieldId: string) => void;
clearAllErrors: () => void;
// dirty 체크
getChangedFields: () => string[];
hasChanges: () => boolean;
// 리피터 데이터 관리
getRepeaterData: (fieldName: string) => unknown[];
setRepeaterData: (fieldName: string, data: unknown[]) => void;
addRepeaterRow: (fieldName: string, row: Record<string, unknown>) => void;
updateRepeaterRow: (fieldName: string, index: number, row: Record<string, unknown>) => void;
deleteRepeaterRow: (fieldName: string, index: number) => void;
}
// ===== Context 생성 =====
const UnifiedFormContext = createContext<UnifiedFormContextValue | null>(null);
// ===== 조건 평가 함수 =====
function evaluateOperator(
fieldValue: unknown,
operator: ConditionalConfig["operator"],
conditionValue: unknown
): boolean {
switch (operator) {
case "=":
return fieldValue === conditionValue;
case "!=":
return fieldValue !== conditionValue;
case ">":
return Number(fieldValue) > Number(conditionValue);
case "<":
return Number(fieldValue) < Number(conditionValue);
case "in":
if (Array.isArray(conditionValue)) {
return conditionValue.includes(fieldValue);
}
return false;
case "notIn":
if (Array.isArray(conditionValue)) {
return !conditionValue.includes(fieldValue);
}
return true;
case "isEmpty":
return fieldValue === null || fieldValue === undefined || fieldValue === "" ||
(Array.isArray(fieldValue) && fieldValue.length === 0);
case "isNotEmpty":
return fieldValue !== null && fieldValue !== undefined && fieldValue !== "" &&
!(Array.isArray(fieldValue) && fieldValue.length === 0);
default:
return true;
}
}
// ===== 초기 상태 =====
const initialFormStatus: FormStatus = {
isSubmitting: false,
isValidating: false,
isDirty: false,
isValid: true,
isLoading: false,
submitCount: 0,
};
// ===== Provider Props =====
interface UnifiedFormProviderProps {
children: React.ReactNode;
initialValues?: Record<string, unknown>;
onChange?: (formData: Record<string, unknown>) => void;
// 새로운 Props
submitConfig?: SubmitConfig;
onSubmit?: (data: Record<string, unknown>, config: SubmitConfig) => Promise<SubmitResult>;
onError?: (errors: FieldError[]) => void;
onReset?: () => void;
// 레거시 호환성
emitLegacyEvents?: boolean; // beforeFormSave 등 레거시 이벤트 발생 여부 (기본: true)
}
// ===== Provider 컴포넌트 =====
export function UnifiedFormProvider({
children,
initialValues = {},
onChange,
submitConfig: defaultSubmitConfig,
onSubmit,
onError,
onReset,
emitLegacyEvents = true,
}: UnifiedFormProviderProps) {
// 기존 상태
const [formData, setFormData] = useState<Record<string, unknown>>(initialValues);
const [fieldStates, setFieldStates] = useState<FormState>({});
// 새로운 상태
const [originalData, setOriginalData] = useState<Record<string, unknown>>(initialValues);
const [status, setStatus] = useState<FormStatus>(initialFormStatus);
const [errors, setErrors] = useState<FieldError[]>([]);
// 필드별 검증 규칙 저장
const validationRulesRef = useRef<Map<string, ValidationRule[]>>(new Map());
// ===== 기존 기능 =====
const getValue = useCallback((fieldId: string): unknown => {
return formData[fieldId];
}, [formData]);
const setValue = useCallback((fieldId: string, value: unknown) => {
setFormData(prev => {
const newData = { ...prev, [fieldId]: value };
// dirty 상태 업데이트
setStatus(s => ({ ...s, isDirty: true }));
onChange?.(newData);
return newData;
});
}, [onChange]);
const setValues = useCallback((values: Record<string, unknown>) => {
setFormData(prev => {
const newData = { ...prev, ...values };
setStatus(s => ({ ...s, isDirty: true }));
onChange?.(newData);
return newData;
});
}, [onChange]);
const evaluateCondition = useCallback((
fieldId: string,
config?: ConditionalConfig
): { visible: boolean; disabled: boolean } => {
if (!config || !config.enabled) {
return { visible: true, disabled: false };
}
const { field, operator, value, action } = config;
const fieldValue = formData[field];
const conditionMet = evaluateOperator(fieldValue, operator, value);
switch (action) {
case "show":
return { visible: conditionMet, disabled: false };
case "hide":
return { visible: !conditionMet, disabled: false };
case "enable":
return { visible: true, disabled: !conditionMet };
case "disable":
return { visible: true, disabled: conditionMet };
default:
return { visible: true, disabled: false };
}
}, [formData]);
const getCascadingFilter = useCallback((config?: CascadingConfig): unknown => {
if (!config) return undefined;
return formData[config.parentField];
}, [formData]);
const registerField = useCallback((fieldId: string, initialValue?: unknown) => {
if (initialValue !== undefined && formData[fieldId] === undefined) {
setFormData(prev => ({ ...prev, [fieldId]: initialValue }));
}
setFieldStates(prev => ({
...prev,
[fieldId]: { value: initialValue, visible: true, disabled: false },
}));
}, [formData]);
const unregisterField = useCallback((fieldId: string) => {
setFieldStates(prev => {
const next = { ...prev };
delete next[fieldId];
return next;
});
validationRulesRef.current.delete(fieldId);
}, []);
// ===== 새로운 기능: 폼 액션 =====
// 검증
const validate = useCallback(async (fieldIds?: string[]): Promise<ValidationResult> => {
setStatus(s => ({ ...s, isValidating: true }));
const newErrors: FieldError[] = [];
const fieldsToValidate = fieldIds || Array.from(validationRulesRef.current.keys());
for (const fieldId of fieldsToValidate) {
const rules = validationRulesRef.current.get(fieldId);
if (!rules) continue;
const value = formData[fieldId];
for (const rule of rules) {
let isValid = true;
switch (rule.type) {
case "required":
isValid = value !== null && value !== undefined && value !== "";
break;
case "minLength":
isValid = typeof value === "string" && value.length >= (rule.value as number);
break;
case "maxLength":
isValid = typeof value === "string" && value.length <= (rule.value as number);
break;
case "min":
isValid = typeof value === "number" && value >= (rule.value as number);
break;
case "max":
isValid = typeof value === "number" && value <= (rule.value as number);
break;
case "pattern":
isValid = typeof value === "string" && new RegExp(rule.value as string).test(value);
break;
case "email":
isValid = typeof value === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
break;
case "url":
isValid = typeof value === "string" && /^https?:\/\/.+/.test(value);
break;
}
if (!isValid) {
newErrors.push({
fieldId,
message: rule.message,
type: rule.type === "required" ? "required" : "format",
});
break; // 첫 번째 에러만 기록
}
}
}
setErrors(newErrors);
setStatus(s => ({
...s,
isValidating: false,
isValid: newErrors.length === 0
}));
return { valid: newErrors.length === 0, errors: newErrors };
}, [formData]);
// 저장
const submit = useCallback(async (config?: Partial<SubmitConfig>): Promise<SubmitResult> => {
const finalConfig = { ...defaultSubmitConfig, ...config } as SubmitConfig;
setStatus(s => ({ ...s, isSubmitting: true, submitCount: s.submitCount + 1 }));
try {
// 1. 검증
if (finalConfig.validateBeforeSubmit !== false) {
const validation = await validate();
if (!validation.valid) {
onError?.(validation.errors);
return { success: false, error: "검증 실패", errors: validation.errors };
}
}
// 2. 레거시 이벤트 발생 (리피터 데이터 수집)
let collectedData = { ...formData };
if (emitLegacyEvents && typeof window !== "undefined") {
const eventDetail: FormEventDetail = { formData: {} };
const legacyEvent = new CustomEvent("beforeFormSave", { detail: eventDetail });
window.dispatchEvent(legacyEvent);
// 이벤트에서 수집된 데이터 병합 (리피터 등)
collectedData = { ...collectedData, ...eventDetail.formData };
}
// 3. beforeSubmit 콜백
if (finalConfig.onBeforeSubmit) {
collectedData = await finalConfig.onBeforeSubmit(collectedData);
}
// 4. 추가 데이터 병합
if (finalConfig.additionalData) {
collectedData = { ...collectedData, ...finalConfig.additionalData };
}
// 5. 저장 실행
let result: SubmitResult;
if (onSubmit) {
result = await onSubmit(collectedData, finalConfig);
} else {
// 기본 저장 로직 (API 호출)
// 실제 구현은 외부에서 onSubmit으로 제공
result = { success: true, data: collectedData };
}
// 6. 성공 시 처리
if (result.success) {
setOriginalData({ ...formData });
setStatus(s => ({ ...s, isDirty: false }));
// afterFormSave 이벤트 발생
if (emitLegacyEvents && typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("afterFormSave", {
detail: { success: true, data: result.data }
}));
}
// afterSubmit 콜백
finalConfig.onAfterSubmit?.(result);
}
return result;
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "저장 중 오류 발생";
return { success: false, error: errorMessage };
} finally {
setStatus(s => ({ ...s, isSubmitting: false }));
}
}, [formData, defaultSubmitConfig, validate, onSubmit, onError, emitLegacyEvents]);
// 초기화 (원본 데이터로 복원)
const reset = useCallback(() => {
setFormData({ ...originalData });
setErrors([]);
setStatus(s => ({ ...s, isDirty: false, isValid: true }));
onReset?.();
}, [originalData, onReset]);
// 비우기
const clear = useCallback(() => {
setFormData({});
setErrors([]);
setStatus(s => ({ ...s, isDirty: true, isValid: true }));
}, []);
// 초기 데이터 설정 (수정 모드 진입)
const setInitialData = useCallback((data: Record<string, unknown>) => {
setFormData(data);
setOriginalData(data);
setStatus(s => ({ ...s, isDirty: false }));
}, []);
// ===== 에러 관리 =====
const setFieldError = useCallback((fieldId: string, message: string, type: FieldError["type"] = "custom") => {
setErrors(prev => {
const filtered = prev.filter(e => e.fieldId !== fieldId);
return [...filtered, { fieldId, message, type }];
});
setStatus(s => ({ ...s, isValid: false }));
}, []);
const clearFieldError = useCallback((fieldId: string) => {
setErrors(prev => {
const filtered = prev.filter(e => e.fieldId !== fieldId);
return filtered;
});
}, []);
const clearAllErrors = useCallback(() => {
setErrors([]);
setStatus(s => ({ ...s, isValid: true }));
}, []);
// ===== dirty 체크 =====
const getChangedFields = useCallback((): string[] => {
const changed: string[] = [];
const allKeys = new Set([...Object.keys(formData), ...Object.keys(originalData)]);
for (const key of allKeys) {
if (JSON.stringify(formData[key]) !== JSON.stringify(originalData[key])) {
changed.push(key);
}
}
return changed;
}, [formData, originalData]);
const hasChanges = useCallback((): boolean => {
return JSON.stringify(formData) !== JSON.stringify(originalData);
}, [formData, originalData]);
// ===== 리피터 데이터 관리 =====
const getRepeaterData = useCallback((fieldName: string): unknown[] => {
const data = formData[fieldName];
if (Array.isArray(data)) return data;
if (typeof data === "string") {
try {
return JSON.parse(data);
} catch {
return [];
}
}
return [];
}, [formData]);
const setRepeaterData = useCallback((fieldName: string, data: unknown[]) => {
setValue(fieldName, data);
}, [setValue]);
const addRepeaterRow = useCallback((fieldName: string, row: Record<string, unknown>) => {
const current = getRepeaterData(fieldName);
setValue(fieldName, [...current, row]);
}, [getRepeaterData, setValue]);
const updateRepeaterRow = useCallback((fieldName: string, index: number, row: Record<string, unknown>) => {
const current = getRepeaterData(fieldName) as Record<string, unknown>[];
const updated = [...current];
updated[index] = { ...updated[index], ...row };
setValue(fieldName, updated);
}, [getRepeaterData, setValue]);
const deleteRepeaterRow = useCallback((fieldName: string, index: number) => {
const current = getRepeaterData(fieldName);
const updated = current.filter((_, i) => i !== index);
setValue(fieldName, updated);
}, [getRepeaterData, setValue]);
// ===== Context 값 =====
const contextValue = useMemo<UnifiedFormContextValue>(() => ({
// 기존 기능
formData,
fieldStates,
getValue,
setValue,
setValues,
evaluateCondition,
getCascadingFilter,
registerField,
unregisterField,
// 새로운 기능
originalData,
status,
errors,
submit,
reset,
validate,
clear,
setInitialData,
setFieldError,
clearFieldError,
clearAllErrors,
getChangedFields,
hasChanges,
getRepeaterData,
setRepeaterData,
addRepeaterRow,
updateRepeaterRow,
deleteRepeaterRow,
}), [
formData,
fieldStates,
getValue,
setValue,
setValues,
evaluateCondition,
getCascadingFilter,
registerField,
unregisterField,
originalData,
status,
errors,
submit,
reset,
validate,
clear,
setInitialData,
setFieldError,
clearFieldError,
clearAllErrors,
getChangedFields,
hasChanges,
getRepeaterData,
setRepeaterData,
addRepeaterRow,
updateRepeaterRow,
deleteRepeaterRow,
]);
return (
<UnifiedFormContext.Provider value={contextValue}>
{children}
</UnifiedFormContext.Provider>
);
}
// ===== 커스텀 훅 =====
/**
* UnifiedForm 컨텍스트 사용 (Context가 없으면 에러)
*/
export function useUnifiedForm(): UnifiedFormContextValue {
const context = useContext(UnifiedFormContext);
if (!context) {
throw new Error("useUnifiedForm must be used within UnifiedFormProvider");
}
return context;
}
/**
* UnifiedForm 컨텍스트 사용 (Context가 없어도 에러 안 남, null 반환)
* 레거시 호환성을 위해 사용
*/
export function useUnifiedFormOptional(): UnifiedFormContextValue | null {
return useContext(UnifiedFormContext);
}
/**
* 개별 필드 훅 - 조건부 상태와 값을 한 번에 관리
*/
export function useUnifiedField(
fieldId: string,
conditional?: ConditionalConfig
): {
value: unknown;
setValue: (value: unknown) => void;
visible: boolean;
disabled: boolean;
error?: FieldError;
} {
const { getValue, setValue, evaluateCondition, errors } = useUnifiedForm();
const value = getValue(fieldId);
const { visible, disabled } = evaluateCondition(fieldId, conditional);
const error = errors.find(e => e.fieldId === fieldId);
const handleSetValue = useCallback((newValue: unknown) => {
setValue(fieldId, newValue);
}, [fieldId, setValue]);
return {
value,
setValue: handleSetValue,
visible,
disabled,
error,
};
}
/**
* 연쇄 선택 훅 - 부모 필드 값에 따라 옵션 필터링
*/
export function useCascadingOptions<T extends { parentValue?: unknown }>(
options: T[],
cascading?: CascadingConfig
): T[] {
const { getCascadingFilter } = useUnifiedForm();
if (!cascading) return options;
const parentValue = getCascadingFilter(cascading);
if (parentValue === undefined || parentValue === null || parentValue === "") {
return [];
}
return options.filter(opt => opt.parentValue === parentValue);
}
/**
* 폼 액션 훅 - 저장/검증/초기화 등 액션에만 접근
*/
export function useFormActions() {
const { submit, reset, validate, clear, hasChanges, status, errors } = useUnifiedForm();
return {
submit,
reset,
validate,
clear,
hasChanges,
isSubmitting: status.isSubmitting,
isValidating: status.isValidating,
isDirty: status.isDirty,
isValid: status.isValid,
errors,
};
}
/**
* 리피터 데이터 훅 - 특정 리피터 필드 관리
*/
export function useRepeaterField<T extends Record<string, unknown> = Record<string, unknown>>(
fieldName: string
) {
const {
getRepeaterData,
setRepeaterData,
addRepeaterRow,
updateRepeaterRow,
deleteRepeaterRow
} = useUnifiedForm();
const data = getRepeaterData(fieldName) as T[];
return {
data,
setData: (newData: T[]) => setRepeaterData(fieldName, newData),
addRow: (row: T) => addRepeaterRow(fieldName, row),
updateRow: (index: number, row: Partial<T>) => updateRepeaterRow(fieldName, index, row as Record<string, unknown>),
deleteRow: (index: number) => deleteRepeaterRow(fieldName, index),
count: data.length,
};
}
export default UnifiedFormContext;
@@ -1,456 +0,0 @@
"use client";
/**
* UnifiedGroup
*
* 통합 그룹 컴포넌트
* - tabs: 탭 그룹
* - accordion: 아코디언 그룹
* - section: 섹션 그룹
* - card-section: 카드 섹션
* - modal: 모달 그룹
* - form-modal: 폼 모달 그룹
*/
import React, { forwardRef, useState, useCallback } from "react";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { UnifiedGroupProps, TabItem } from "@/types/unified-components";
import { ChevronDown, ChevronRight, X } from "lucide-react";
/**
* 탭 그룹 컴포넌트
*/
const TabsGroup = forwardRef<HTMLDivElement, {
tabs?: TabItem[];
activeTab?: string;
onTabChange?: (tabId: string) => void;
children?: React.ReactNode;
className?: string;
}>(({ tabs = [], activeTab, onTabChange, children, className }, ref) => {
const [internalActiveTab, setInternalActiveTab] = useState(activeTab || tabs[0]?.id || "");
const currentTab = activeTab || internalActiveTab;
const handleTabChange = useCallback((tabId: string) => {
setInternalActiveTab(tabId);
onTabChange?.(tabId);
}, [onTabChange]);
// 탭 정보가 있으면 탭 사용, 없으면 children 그대로 렌더링
if (tabs.length === 0) {
return (
<div ref={ref} className={className}>
{children}
</div>
);
}
return (
<Tabs
ref={ref}
value={currentTab}
onValueChange={handleTabChange}
className={className}
>
<TabsList className="grid w-full" style={{ gridTemplateColumns: `repeat(${tabs.length}, 1fr)` }}>
{tabs.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id}>
{tab.title}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => (
<TabsContent key={tab.id} value={tab.id} className="mt-4">
{tab.content || children}
</TabsContent>
))}
</Tabs>
);
});
TabsGroup.displayName = "TabsGroup";
/**
* 아코디언 그룹 컴포넌트
*/
const AccordionGroup = forwardRef<HTMLDivElement, {
title?: string;
collapsible?: boolean;
defaultExpanded?: boolean;
children?: React.ReactNode;
className?: string;
}>(({ title, collapsible = true, defaultExpanded = true, children, className }, ref) => {
const [isOpen, setIsOpen] = useState(defaultExpanded);
if (!collapsible) {
return (
<div ref={ref} className={cn("border rounded-lg", className)}>
{title && (
<div className="p-4 border-b bg-muted/50">
<h3 className="font-medium">{title}</h3>
</div>
)}
<div className="p-4">{children}</div>
</div>
);
}
return (
<Collapsible ref={ref} open={isOpen} onOpenChange={setIsOpen} className={cn("border rounded-lg", className)}>
<CollapsibleTrigger asChild>
<div className="flex items-center justify-between p-4 cursor-pointer hover:bg-muted/50">
<h3 className="font-medium">{title || "그룹"}</h3>
{isOpen ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="p-4 pt-0 border-t">{children}</div>
</CollapsibleContent>
</Collapsible>
);
});
AccordionGroup.displayName = "AccordionGroup";
/**
* 섹션 그룹 컴포넌트
*/
const SectionGroup = forwardRef<HTMLDivElement, {
title?: string;
description?: string;
collapsible?: boolean;
defaultExpanded?: boolean;
children?: React.ReactNode;
className?: string;
}>(({ title, description, collapsible = false, defaultExpanded = true, children, className }, ref) => {
const [isOpen, setIsOpen] = useState(defaultExpanded);
if (collapsible) {
return (
<Collapsible ref={ref} open={isOpen} onOpenChange={setIsOpen} className={cn("space-y-2", className)}>
<CollapsibleTrigger asChild>
<div className="flex items-center justify-between cursor-pointer">
<div>
{title && <h3 className="text-lg font-semibold">{title}</h3>}
{description && <p className="text-sm text-muted-foreground">{description}</p>}
</div>
{isOpen ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="pt-2">{children}</div>
</CollapsibleContent>
</Collapsible>
);
}
return (
<div ref={ref} className={cn("space-y-2", className)}>
{(title || description) && (
<div>
{title && <h3 className="text-lg font-semibold">{title}</h3>}
{description && <p className="text-sm text-muted-foreground">{description}</p>}
</div>
)}
{children}
</div>
);
});
SectionGroup.displayName = "SectionGroup";
/**
* 카드 섹션 그룹 컴포넌트
*/
const CardSectionGroup = forwardRef<HTMLDivElement, {
title?: string;
description?: string;
collapsible?: boolean;
defaultExpanded?: boolean;
children?: React.ReactNode;
className?: string;
}>(({ title, description, collapsible = false, defaultExpanded = true, children, className }, ref) => {
const [isOpen, setIsOpen] = useState(defaultExpanded);
if (collapsible) {
return (
<Card ref={ref} className={className}>
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer hover:bg-muted/50">
<div className="flex items-center justify-between">
<div>
{title && <CardTitle>{title}</CardTitle>}
{description && <CardDescription>{description}</CardDescription>}
</div>
{isOpen ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</div>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="pt-0">{children}</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
);
}
return (
<Card ref={ref} className={className}>
{(title || description) && (
<CardHeader>
{title && <CardTitle>{title}</CardTitle>}
{description && <CardDescription>{description}</CardDescription>}
</CardHeader>
)}
<CardContent className={title || description ? "pt-0" : ""}>{children}</CardContent>
</Card>
);
});
CardSectionGroup.displayName = "CardSectionGroup";
/**
* 모달 그룹 컴포넌트
*/
const ModalGroup = forwardRef<HTMLDivElement, {
title?: string;
description?: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
modalSize?: "sm" | "md" | "lg" | "xl";
children?: React.ReactNode;
className?: string;
}>(({ title, description, open = false, onOpenChange, modalSize = "md", children, className }, ref) => {
const sizeClasses = {
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-lg",
xl: "max-w-xl",
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent ref={ref} className={cn(sizeClasses[modalSize], className)}>
{(title || description) && (
<DialogHeader>
{title && <DialogTitle>{title}</DialogTitle>}
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
)}
{children}
</DialogContent>
</Dialog>
);
});
ModalGroup.displayName = "ModalGroup";
/**
* 폼 모달 그룹 컴포넌트
*/
const FormModalGroup = forwardRef<HTMLDivElement, {
title?: string;
description?: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
modalSize?: "sm" | "md" | "lg" | "xl";
onSubmit?: () => void;
onCancel?: () => void;
submitLabel?: string;
cancelLabel?: string;
children?: React.ReactNode;
className?: string;
}>(({
title,
description,
open = false,
onOpenChange,
modalSize = "md",
onSubmit,
onCancel,
submitLabel = "저장",
cancelLabel = "취소",
children,
className
}, ref) => {
const sizeClasses = {
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-lg",
xl: "max-w-xl",
};
const handleCancel = useCallback(() => {
onCancel?.();
onOpenChange?.(false);
}, [onCancel, onOpenChange]);
const handleSubmit = useCallback(() => {
onSubmit?.();
}, [onSubmit]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent ref={ref} className={cn(sizeClasses[modalSize], className)}>
{(title || description) && (
<DialogHeader>
{title && <DialogTitle>{title}</DialogTitle>}
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
)}
<div className="py-4">{children}</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={handleCancel}>
{cancelLabel}
</Button>
<Button onClick={handleSubmit}>
{submitLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
});
FormModalGroup.displayName = "FormModalGroup";
/**
* 메인 UnifiedGroup 컴포넌트
*/
export const UnifiedGroup = forwardRef<HTMLDivElement, UnifiedGroupProps>(
(props, ref) => {
const {
id,
style,
size,
config: configProp,
children,
open,
onOpenChange,
} = props;
// config가 없으면 기본값 사용
const config = configProp || { type: "section" as const, tabs: [] };
// 타입별 그룹 렌더링
const renderGroup = () => {
const groupType = config.type || "section";
switch (groupType) {
case "tabs":
return (
<TabsGroup
tabs={config.tabs}
activeTab={config.activeTab}
>
{children}
</TabsGroup>
);
case "accordion":
return (
<AccordionGroup
title={config.title}
collapsible={config.collapsible}
defaultExpanded={config.defaultExpanded}
>
{children}
</AccordionGroup>
);
case "section":
return (
<SectionGroup
title={config.title}
collapsible={config.collapsible}
defaultExpanded={config.defaultExpanded}
>
{children}
</SectionGroup>
);
case "card-section":
return (
<CardSectionGroup
title={config.title}
collapsible={config.collapsible}
defaultExpanded={config.defaultExpanded}
>
{children}
</CardSectionGroup>
);
case "modal":
return (
<ModalGroup
title={config.title}
open={open}
onOpenChange={onOpenChange}
modalSize={config.modalSize}
>
{children}
</ModalGroup>
);
case "form-modal":
return (
<FormModalGroup
title={config.title}
open={open}
onOpenChange={onOpenChange}
modalSize={config.modalSize}
>
{children}
</FormModalGroup>
);
default:
return (
<SectionGroup title={config.title}>
{children}
</SectionGroup>
);
}
};
const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height;
return (
<div
ref={ref}
id={id}
style={{
width: componentWidth,
height: componentHeight,
}}
>
{renderGroup()}
</div>
);
}
);
UnifiedGroup.displayName = "UnifiedGroup";
export default UnifiedGroup;
@@ -1,501 +0,0 @@
"use client";
/**
* UnifiedHierarchy
*
* 통합 계층 구조 컴포넌트
* - tree: 트리 뷰
* - org: 조직도
* - bom: BOM 구조
* - cascading: 연쇄 드롭다운
*/
import React, { forwardRef, useCallback, useState } from "react";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { UnifiedHierarchyProps, HierarchyNode } from "@/types/unified-components";
import {
ChevronRight,
ChevronDown,
Folder,
FolderOpen,
File,
Plus,
Minus,
GripVertical,
User,
Users,
Building
} from "lucide-react";
/**
* 트리 노드 컴포넌트
*/
const TreeNode = forwardRef<HTMLDivElement, {
node: HierarchyNode;
level: number;
maxLevel?: number;
selectedNode?: HierarchyNode;
onSelect?: (node: HierarchyNode) => void;
editable?: boolean;
draggable?: boolean;
showQty?: boolean;
className?: string;
}>(({
node,
level,
maxLevel,
selectedNode,
onSelect,
editable,
draggable,
showQty,
className
}, ref) => {
const [isOpen, setIsOpen] = useState(level < 2);
const hasChildren = node.children && node.children.length > 0;
const isSelected = selectedNode?.id === node.id;
// 최대 레벨 제한
if (maxLevel && level >= maxLevel) {
return null;
}
return (
<div ref={ref} className={className}>
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<div
className={cn(
"flex items-center gap-1 py-1 px-2 rounded cursor-pointer hover:bg-muted/50",
isSelected && "bg-primary/10 text-primary"
)}
style={{ paddingLeft: `${level * 16 + 8}px` }}
onClick={() => onSelect?.(node)}
>
{/* 드래그 핸들 */}
{draggable && (
<GripVertical className="h-4 w-4 text-muted-foreground cursor-grab" />
)}
{/* 확장/축소 아이콘 */}
{hasChildren ? (
<CollapsibleTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="icon" className="h-5 w-5 p-0">
{isOpen ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
</CollapsibleTrigger>
) : (
<span className="w-5" />
)}
{/* 폴더/파일 아이콘 */}
{hasChildren ? (
isOpen ? (
<FolderOpen className="h-4 w-4 text-amber-500" />
) : (
<Folder className="h-4 w-4 text-amber-500" />
)
) : (
<File className="h-4 w-4 text-muted-foreground" />
)}
{/* 라벨 */}
<span className="flex-1 text-sm truncate">{node.label}</span>
{/* 수량 (BOM용) */}
{showQty && node.data?.qty && (
<span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
x{String(node.data.qty)}
</span>
)}
{/* 편집 버튼 */}
{editable && (
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100"
onClick={(e) => { e.stopPropagation(); }}
>
<Plus className="h-3 w-3" />
</Button>
</div>
)}
</div>
{/* 자식 노드 */}
{hasChildren && (
<CollapsibleContent>
{node.children!.map((child) => (
<TreeNode
key={child.id}
node={child}
level={level + 1}
maxLevel={maxLevel}
selectedNode={selectedNode}
onSelect={onSelect}
editable={editable}
draggable={draggable}
showQty={showQty}
/>
))}
</CollapsibleContent>
)}
</Collapsible>
</div>
);
});
TreeNode.displayName = "TreeNode";
/**
* 트리 뷰 컴포넌트
*/
const TreeView = forwardRef<HTMLDivElement, {
data: HierarchyNode[];
selectedNode?: HierarchyNode;
onNodeSelect?: (node: HierarchyNode) => void;
editable?: boolean;
draggable?: boolean;
maxLevel?: number;
className?: string;
}>(({ data, selectedNode, onNodeSelect, editable, draggable, maxLevel, className }, ref) => {
return (
<div ref={ref} className={cn("border rounded-lg p-2", className)}>
{data.length === 0 ? (
<div className="py-8 text-center text-muted-foreground text-sm">
</div>
) : (
data.map((node) => (
<TreeNode
key={node.id}
node={node}
level={0}
maxLevel={maxLevel}
selectedNode={selectedNode}
onSelect={onNodeSelect}
editable={editable}
draggable={draggable}
/>
))
)}
</div>
);
});
TreeView.displayName = "TreeView";
/**
* 조직도 뷰 컴포넌트
*/
const OrgView = forwardRef<HTMLDivElement, {
data: HierarchyNode[];
selectedNode?: HierarchyNode;
onNodeSelect?: (node: HierarchyNode) => void;
className?: string;
}>(({ data, selectedNode, onNodeSelect, className }, ref) => {
const renderOrgNode = (node: HierarchyNode, isRoot = false) => {
const isSelected = selectedNode?.id === node.id;
const hasChildren = node.children && node.children.length > 0;
return (
<div key={node.id} className="flex flex-col items-center">
{/* 노드 카드 */}
<div
className={cn(
"flex flex-col items-center p-3 border rounded-lg cursor-pointer hover:border-primary transition-colors",
isSelected && "border-primary bg-primary/5",
isRoot && "bg-primary/10"
)}
onClick={() => onNodeSelect?.(node)}
>
<div className={cn(
"w-10 h-10 rounded-full flex items-center justify-center mb-2",
isRoot ? "bg-primary text-primary-foreground" : "bg-muted"
)}>
{isRoot ? (
<Building className="h-5 w-5" />
) : hasChildren ? (
<Users className="h-5 w-5" />
) : (
<User className="h-5 w-5" />
)}
</div>
<div className="text-center">
<div className="font-medium text-sm">{node.label}</div>
{node.data?.title && (
<div className="text-xs text-muted-foreground">{String(node.data.title)}</div>
)}
</div>
</div>
{/* 자식 노드 */}
{hasChildren && (
<>
{/* 연결선 */}
<div className="w-px h-4 bg-border" />
<div className="flex gap-4">
{node.children!.map((child, index) => (
<React.Fragment key={child.id}>
{index > 0 && <div className="w-4" />}
{renderOrgNode(child)}
</React.Fragment>
))}
</div>
</>
)}
</div>
);
};
return (
<div ref={ref} className={cn("overflow-auto p-4", className)}>
{data.length === 0 ? (
<div className="py-8 text-center text-muted-foreground text-sm">
</div>
) : (
<div className="flex flex-col items-center gap-4">
{data.map((node) => renderOrgNode(node, true))}
</div>
)}
</div>
);
});
OrgView.displayName = "OrgView";
/**
* BOM 뷰 컴포넌트 (수량 포함 트리)
*/
const BomView = forwardRef<HTMLDivElement, {
data: HierarchyNode[];
selectedNode?: HierarchyNode;
onNodeSelect?: (node: HierarchyNode) => void;
editable?: boolean;
className?: string;
}>(({ data, selectedNode, onNodeSelect, editable, className }, ref) => {
return (
<div ref={ref} className={cn("border rounded-lg p-2", className)}>
{data.length === 0 ? (
<div className="py-8 text-center text-muted-foreground text-sm">
BOM
</div>
) : (
data.map((node) => (
<TreeNode
key={node.id}
node={node}
level={0}
selectedNode={selectedNode}
onSelect={onNodeSelect}
editable={editable}
showQty={true}
/>
))
)}
</div>
);
});
BomView.displayName = "BomView";
/**
* 연쇄 드롭다운 컴포넌트
*/
const CascadingView = forwardRef<HTMLDivElement, {
data: HierarchyNode[];
selectedNode?: HierarchyNode;
onNodeSelect?: (node: HierarchyNode) => void;
maxLevel?: number;
className?: string;
}>(({ data, selectedNode, onNodeSelect, maxLevel = 3, className }, ref) => {
const [selections, setSelections] = useState<string[]>([]);
// 레벨별 옵션 가져오기
const getOptionsForLevel = (level: number): HierarchyNode[] => {
if (level === 0) return data;
let currentNodes = data;
for (let i = 0; i < level; i++) {
const selectedId = selections[i];
if (!selectedId) return [];
const selectedNode = currentNodes.find((n) => n.id === selectedId);
if (!selectedNode?.children) return [];
currentNodes = selectedNode.children;
}
return currentNodes;
};
// 선택 핸들러
const handleSelect = (level: number, nodeId: string) => {
const newSelections = [...selections.slice(0, level), nodeId];
setSelections(newSelections);
// 마지막 선택된 노드 찾기
let node = data.find((n) => n.id === newSelections[0]);
for (let i = 1; i < newSelections.length; i++) {
node = node?.children?.find((n) => n.id === newSelections[i]);
}
if (node) {
onNodeSelect?.(node);
}
};
return (
<div ref={ref} className={cn("flex gap-2", className)}>
{Array.from({ length: maxLevel }, (_, level) => {
const options = getOptionsForLevel(level);
const isDisabled = level > 0 && !selections[level - 1];
return (
<Select
key={level}
value={selections[level] || ""}
onValueChange={(value) => handleSelect(level, value)}
disabled={isDisabled || options.length === 0}
>
<SelectTrigger className="flex-1">
<SelectValue placeholder={`${level + 1}단계 선택`} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.id} value={option.id}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
})}
</div>
);
});
CascadingView.displayName = "CascadingView";
/**
* 메인 UnifiedHierarchy 컴포넌트
*/
export const UnifiedHierarchy = forwardRef<HTMLDivElement, UnifiedHierarchyProps>(
(props, ref) => {
const {
id,
label,
required,
style,
size,
config: configProp,
data = [],
selectedNode,
onNodeSelect,
} = props;
// config가 없으면 기본값 사용
const config = configProp || { type: "tree" as const, viewMode: "tree" as const, dataSource: "static" as const };
// 뷰모드별 렌더링
const renderHierarchy = () => {
const viewMode = config.viewMode || config.type || "tree";
switch (viewMode) {
case "tree":
return (
<TreeView
data={data}
selectedNode={selectedNode}
onNodeSelect={onNodeSelect}
editable={config.editable}
draggable={config.draggable}
maxLevel={config.maxLevel}
/>
);
case "org":
return (
<OrgView
data={data}
selectedNode={selectedNode}
onNodeSelect={onNodeSelect}
/>
);
case "bom":
return (
<BomView
data={data}
selectedNode={selectedNode}
onNodeSelect={onNodeSelect}
editable={config.editable}
/>
);
case "dropdown":
case "cascading":
return (
<CascadingView
data={data}
selectedNode={selectedNode}
onNodeSelect={onNodeSelect}
maxLevel={config.maxLevel}
/>
);
default:
return (
<TreeView
data={data}
selectedNode={selectedNode}
onNodeSelect={onNodeSelect}
/>
);
}
};
const showLabel = label && style?.labelDisplay !== false;
const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height;
return (
<div
ref={ref}
id={id}
className="flex flex-col"
style={{
width: componentWidth,
height: componentHeight,
}}
>
{showLabel && (
<Label
htmlFor={id}
style={{
fontSize: style?.labelFontSize,
color: style?.labelColor,
fontWeight: style?.labelFontWeight,
marginBottom: style?.labelMarginBottom,
}}
className="text-sm font-medium flex-shrink-0"
>
{label}
{required && <span className="text-orange-500 ml-0.5">*</span>}
</Label>
)}
<div className="flex-1 min-h-0">
{renderHierarchy()}
</div>
</div>
);
}
);
UnifiedHierarchy.displayName = "UnifiedHierarchy";
export default UnifiedHierarchy;
@@ -1,816 +0,0 @@
"use client";
/**
* UnifiedInput
*
* 통합 입력 컴포넌트
* - text: 텍스트 입력
* - number: 숫자 입력
* - password: 비밀번호 입력
* - slider: 슬라이더 입력
* - color: 색상 선택
* - button: 버튼 (입력이 아닌 액션)
*/
import React, { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Input } from "@/components/ui/input";
import { Slider } from "@/components/ui/slider";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { UnifiedInputProps, UnifiedInputConfig, UnifiedInputFormat } from "@/types/unified-components";
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
import { AutoGenerationConfig } from "@/types/screen";
import { previewNumberingCode } from "@/lib/api/numberingRule";
// 형식별 입력 마스크 및 검증 패턴
const FORMAT_PATTERNS: Record<UnifiedInputFormat, { pattern: RegExp; placeholder: string }> = {
none: { pattern: /.*/, placeholder: "" },
email: { pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, placeholder: "example@email.com" },
tel: { pattern: /^\d{2,3}-\d{3,4}-\d{4}$/, placeholder: "010-1234-5678" },
url: { pattern: /^https?:\/\/.+/, placeholder: "https://example.com" },
currency: { pattern: /^[\d,]+$/, placeholder: "1,000,000" },
biz_no: { pattern: /^\d{3}-\d{2}-\d{5}$/, placeholder: "123-45-67890" },
};
// 통화 형식 변환
function formatCurrency(value: string | number): string {
const num = typeof value === "string" ? parseFloat(value.replace(/,/g, "")) : value;
if (isNaN(num)) return "";
return num.toLocaleString("ko-KR");
}
// 사업자번호 형식 변환
function formatBizNo(value: string): string {
const digits = value.replace(/\D/g, "");
if (digits.length <= 3) return digits;
if (digits.length <= 5) return `${digits.slice(0, 3)}-${digits.slice(3)}`;
return `${digits.slice(0, 3)}-${digits.slice(3, 5)}-${digits.slice(5, 10)}`;
}
// 전화번호 형식 변환
function formatTel(value: string): string {
const digits = value.replace(/\D/g, "");
if (digits.length <= 3) return digits;
if (digits.length <= 7) return `${digits.slice(0, 3)}-${digits.slice(3)}`;
if (digits.length <= 11) return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7)}`;
return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7, 11)}`;
}
/**
* 텍스트 입력 컴포넌트
*/
const TextInput = forwardRef<
HTMLInputElement,
{
value?: string | number;
onChange?: (value: string) => void;
format?: UnifiedInputFormat;
mask?: string;
placeholder?: string;
readonly?: boolean;
disabled?: boolean;
className?: string;
}
>(({ value, onChange, format = "none", placeholder, readonly, disabled, className }, ref) => {
// 형식에 따른 값 포맷팅
const formatValue = useCallback(
(val: string): string => {
switch (format) {
case "currency":
return formatCurrency(val);
case "biz_no":
return formatBizNo(val);
case "tel":
return formatTel(val);
default:
return val;
}
},
[format],
);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
let newValue = e.target.value;
// 형식에 따른 자동 포맷팅
if (format === "currency") {
// 숫자와 쉼표만 허용
newValue = newValue.replace(/[^\d,]/g, "");
newValue = formatCurrency(newValue);
} else if (format === "biz_no") {
newValue = formatBizNo(newValue);
} else if (format === "tel") {
newValue = formatTel(newValue);
}
onChange?.(newValue);
},
[format, onChange],
);
const displayValue = useMemo(() => {
if (value === undefined || value === null) return "";
return formatValue(String(value));
}, [value, formatValue]);
const inputPlaceholder = placeholder || FORMAT_PATTERNS[format].placeholder;
return (
<Input
ref={ref}
type="text"
value={displayValue}
onChange={handleChange}
placeholder={inputPlaceholder}
readOnly={readonly}
disabled={disabled}
className={cn("h-full w-full", className)}
/>
);
});
TextInput.displayName = "TextInput";
/**
* 숫자 입력 컴포넌트
*/
const NumberInput = forwardRef<
HTMLInputElement,
{
value?: number;
onChange?: (value: number | undefined) => void;
min?: number;
max?: number;
step?: number;
placeholder?: string;
readonly?: boolean;
disabled?: boolean;
className?: string;
}
>(({ value, onChange, min, max, step = 1, placeholder, readonly, disabled, className }, ref) => {
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
if (val === "") {
onChange?.(undefined);
return;
}
let num = parseFloat(val);
// 범위 제한
if (min !== undefined && num < min) num = min;
if (max !== undefined && num > max) num = max;
onChange?.(num);
},
[min, max, onChange],
);
return (
<Input
ref={ref}
type="number"
value={value ?? ""}
onChange={handleChange}
min={min}
max={max}
step={step}
placeholder={placeholder || "숫자 입력"}
readOnly={readonly}
disabled={disabled}
className={cn("h-full w-full", className)}
/>
);
});
NumberInput.displayName = "NumberInput";
/**
* 비밀번호 입력 컴포넌트
*/
const PasswordInput = forwardRef<
HTMLInputElement,
{
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
readonly?: boolean;
disabled?: boolean;
className?: string;
}
>(({ value, onChange, placeholder, readonly, disabled, className }, ref) => {
const [showPassword, setShowPassword] = useState(false);
return (
<div className="relative">
<Input
ref={ref}
type={showPassword ? "text" : "password"}
value={value ?? ""}
onChange={(e) => onChange?.(e.target.value)}
placeholder={placeholder || "비밀번호 입력"}
readOnly={readonly}
disabled={disabled}
className={cn("h-full w-full pr-10", className)}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-2 -translate-y-1/2 text-xs"
>
{showPassword ? "숨김" : "보기"}
</button>
</div>
);
});
PasswordInput.displayName = "PasswordInput";
/**
* 슬라이더 입력 컴포넌트
*/
const SliderInput = forwardRef<
HTMLDivElement,
{
value?: number;
onChange?: (value: number) => void;
min?: number;
max?: number;
step?: number;
disabled?: boolean;
className?: string;
}
>(({ value, onChange, min = 0, max = 100, step = 1, disabled, className }, ref) => {
return (
<div ref={ref} className={cn("flex items-center gap-4", className)}>
<Slider
value={[value ?? min]}
onValueChange={(values) => onChange?.(values[0])}
min={min}
max={max}
step={step}
disabled={disabled}
className="flex-1"
/>
<span className="w-12 text-right text-sm font-medium">{value ?? min}</span>
</div>
);
});
SliderInput.displayName = "SliderInput";
/**
* 색상 선택 컴포넌트
*/
const ColorInput = forwardRef<
HTMLInputElement,
{
value?: string;
onChange?: (value: string) => void;
disabled?: boolean;
className?: string;
}
>(({ value, onChange, disabled, className }, ref) => {
return (
<div className={cn("flex items-center gap-2", className)}>
<Input
ref={ref}
type="color"
value={value || "#000000"}
onChange={(e) => onChange?.(e.target.value)}
disabled={disabled}
className="h-full w-12 cursor-pointer p-1"
/>
<Input
type="text"
value={value || "#000000"}
onChange={(e) => onChange?.(e.target.value)}
disabled={disabled}
className="h-full flex-1 uppercase"
maxLength={7}
/>
</div>
);
});
ColorInput.displayName = "ColorInput";
/**
* 여러 줄 텍스트 입력 컴포넌트
*/
const TextareaInput = forwardRef<
HTMLTextAreaElement,
{
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
rows?: number;
readonly?: boolean;
disabled?: boolean;
className?: string;
}
>(({ value = "", onChange, placeholder, rows = 3, readonly, disabled, className }, ref) => {
return (
<textarea
ref={ref}
value={value}
onChange={(e) => onChange?.(e.target.value)}
placeholder={placeholder}
rows={rows}
readOnly={readonly}
disabled={disabled}
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[60px] w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-sm focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
/>
);
});
TextareaInput.displayName = "TextareaInput";
/**
* 메인 UnifiedInput 컴포넌트
*/
export const UnifiedInput = forwardRef<HTMLDivElement, UnifiedInputProps>((props, ref) => {
const { id, label, required, readonly, disabled, style, size, config: configProp, value, onChange } = props;
// formData 추출 (채번규칙 날짜 컬럼 기준 생성 시 사용)
const formData = (props as any).formData || {};
const columnName = (props as any).columnName;
// config가 없으면 기본값 사용
const config = (configProp || { type: "text" }) as UnifiedInputConfig & {
inputType?: string;
rows?: number;
autoGeneration?: AutoGenerationConfig;
};
// 자동생성 설정 추출
const autoGeneration: AutoGenerationConfig = (props as any).autoGeneration ||
(config as any).autoGeneration || {
type: "none",
enabled: false,
};
// 자동생성 상태 관리
const [autoGeneratedValue, setAutoGeneratedValue] = useState<string | null>(null);
const isGeneratingRef = useRef(false);
const hasGeneratedRef = useRef(false);
const lastFormDataRef = useRef<string>(""); // 마지막 formData 추적 (채번 규칙용)
// 채번 타입 자동생성 상태
const [isGeneratingNumbering, setIsGeneratingNumbering] = useState(false);
const hasGeneratedNumberingRef = useRef(false);
// tableName 추출 (props에서 전달받거나 config에서)
const tableName = (props as any).tableName || (config as any).tableName;
// 수정 모드 여부 확인
const originalData = (props as any).originalData || (props as any)._originalData;
const isEditMode = originalData && Object.keys(originalData).length > 0;
// 채번 규칙인 경우 formData 변경 감지 (자기 자신 필드 제외)
const formDataForNumbering = useMemo(() => {
if (autoGeneration.type !== "numbering_rule") return "";
// 자기 자신의 값은 제외 (무한 루프 방지)
const { [columnName]: _, ...rest } = formData;
return JSON.stringify(rest);
}, [autoGeneration.type, formData, columnName]);
// 자동생성 로직
useEffect(() => {
const generateValue = async () => {
// 자동생성 비활성화 또는 생성 중
if (!autoGeneration.enabled || isGeneratingRef.current) {
return;
}
// 수정 모드에서는 자동생성 안함
if (isEditMode) {
return;
}
// 채번 규칙인 경우: formData가 변경되었는지 확인
const isNumberingRule = autoGeneration.type === "numbering_rule";
const formDataChanged =
isNumberingRule && formDataForNumbering !== lastFormDataRef.current && lastFormDataRef.current !== "";
// 이미 생성되었고, formData 변경이 아닌 경우 스킵
if (hasGeneratedRef.current && !formDataChanged) {
return;
}
// 첫 생성 시: 값이 이미 있으면 스킵 (formData 변경 시에는 강제 재생성)
if (!formDataChanged && value !== undefined && value !== null && value !== "") {
return;
}
isGeneratingRef.current = true;
try {
// formData를 전달하여 날짜 컬럼 기준 생성 지원
const generatedValue = await AutoGenerationUtils.generateValue(autoGeneration, columnName, formData);
if (generatedValue !== null && generatedValue !== undefined) {
setAutoGeneratedValue(generatedValue);
onChange?.(generatedValue);
hasGeneratedRef.current = true;
// formData 기록
if (isNumberingRule) {
lastFormDataRef.current = formDataForNumbering;
}
}
} catch (error) {
console.error("자동생성 실패:", error);
} finally {
isGeneratingRef.current = false;
}
};
generateValue();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoGeneration.enabled, autoGeneration.type, isEditMode, formDataForNumbering]);
// 채번 규칙 ID 캐싱
const numberingRuleIdRef = useRef<string | null>(null);
const lastCategoryValuesRef = useRef<string>("");
// 사용자가 직접 입력 중인지 추적 (재생성 방지)
const userEditedNumberingRef = useRef<boolean>(false);
// 원래 수동 입력 부분이 있었는지 추적 (____가 있었으면 계속 편집 가능)
const hadManualPartRef = useRef<boolean>(false);
// 채번 템플릿 저장 (____가 포함된 원본 형태)
const numberingTemplateRef = useRef<string>("");
// 사용자가 수동 입력한 값 저장
const [manualInputValue, setManualInputValue] = useState<string>("");
// formData에서 카테고리 관련 값 추출 (채번 파트에서 카테고리 사용 시)
// 채번 필드 자체의 값은 제외해야 함 (무한 루프 방지)
const categoryValuesForNumbering = useMemo(() => {
const inputType = config.inputType || config.type || "text";
if (inputType !== "numbering") return "";
// formData에서 category 타입 필드 값들을 추출 (채번 필드 자체는 제외)
const categoryFields: Record<string, string> = {};
for (const [key, val] of Object.entries(formData)) {
// 현재 채번 필드(columnName)는 제외
if (key === columnName) continue;
if (typeof val === "string" && val) {
categoryFields[key] = val;
}
}
return JSON.stringify(categoryFields);
}, [config.inputType, config.type, formData, columnName]);
// 채번 타입 자동생성 로직 (테이블 관리에서 설정된 numberingRuleId 사용)
useEffect(() => {
const generateNumberingCode = async () => {
const inputType = config.inputType || config.type || "text";
// numbering 타입이 아니면 스킵
if (inputType !== "numbering") {
return;
}
// 수정 모드에서는 자동생성 안함
if (isEditMode) {
return;
}
// 생성 중이면 스킵
if (isGeneratingNumbering) {
return;
}
// 사용자가 직접 편집한 경우 재생성 안함 (단, 카테고리 변경 시에는 재생성)
const categoryChanged = categoryValuesForNumbering !== lastCategoryValuesRef.current;
if (userEditedNumberingRef.current && !categoryChanged) {
return;
}
// 이미 생성되었고 카테고리 값이 변경되지 않았으면 스킵
if (hasGeneratedNumberingRef.current && !categoryChanged) {
return;
}
// 첫 생성 시: 값이 이미 있고 카테고리 변경이 아니면 스킵
if (!categoryChanged && value !== undefined && value !== null && value !== "") {
return;
}
// tableName과 columnName이 필요
if (!tableName || !columnName) {
console.warn("채번 타입: tableName 또는 columnName이 없습니다", { tableName, columnName });
return;
}
setIsGeneratingNumbering(true);
try {
// 채번 규칙 ID 캐싱 (한 번만 조회)
if (!numberingRuleIdRef.current) {
const { getTableColumns } = await import("@/lib/api/tableManagement");
const columnsResponse = await getTableColumns(tableName);
if (!columnsResponse.success || !columnsResponse.data) {
console.warn("테이블 컬럼 정보 조회 실패:", columnsResponse);
return;
}
const columns = columnsResponse.data.columns || columnsResponse.data;
const targetColumn = columns.find((col: { columnName: string }) => col.columnName === columnName);
if (!targetColumn) {
console.warn("컬럼 정보를 찾을 수 없습니다:", columnName);
return;
}
// detailSettings에서 numberingRuleId 추출
if (targetColumn.detailSettings && typeof targetColumn.detailSettings === "string") {
try {
const parsed = JSON.parse(targetColumn.detailSettings);
numberingRuleIdRef.current = parsed.numberingRuleId || null;
} catch {
// JSON 파싱 실패
}
}
}
const numberingRuleId = numberingRuleIdRef.current;
if (!numberingRuleId) {
console.warn("채번 규칙 ID가 설정되지 않았습니다. 테이블 관리에서 설정하세요.", { tableName, columnName });
return;
}
// 채번 코드 생성 (formData 전달하여 카테고리 값 기반 생성)
const previewResponse = await previewNumberingCode(numberingRuleId, formData);
if (previewResponse.success && previewResponse.data?.generatedCode) {
const generatedCode = previewResponse.data.generatedCode;
hasGeneratedNumberingRef.current = true;
lastCategoryValuesRef.current = categoryValuesForNumbering;
// 수동 입력 부분이 있는 경우
if (generatedCode.includes("____")) {
hadManualPartRef.current = true;
const oldTemplate = numberingTemplateRef.current;
numberingTemplateRef.current = generatedCode;
// 카테고리 변경으로 템플릿이 바뀌었을 때 기존 사용자 입력값 유지
if (oldTemplate && oldTemplate !== generatedCode) {
// 템플릿이 변경되었지만 사용자 입력값은 유지
const templateParts = generatedCode.split("____");
const templatePrefix = templateParts[0] || "";
const templateSuffix = templateParts.length > 1 ? templateParts.slice(1).join("") : "";
// 기존 manualInputValue를 사용하여 새 값 조합 (상태는 유지)
// 참고: setManualInputValue는 호출하지 않음 (기존 값 유지)
const finalValue = templatePrefix + (userEditedNumberingRef.current ? "" : "") + templateSuffix;
// 사용자가 입력한 적이 없으면 템플릿 그대로
if (!userEditedNumberingRef.current) {
setAutoGeneratedValue(generatedCode);
onChange?.(generatedCode);
}
// 사용자가 입력한 적이 있으면 입력값 유지하며 템플릿만 변경
// (manualInputValue 상태는 유지되므로 UI에서 자동 반영)
} else {
// 첫 생성
setAutoGeneratedValue(generatedCode);
onChange?.(generatedCode);
userEditedNumberingRef.current = false;
}
} else {
// 수동 입력 부분 없음
setAutoGeneratedValue(generatedCode);
onChange?.(generatedCode);
userEditedNumberingRef.current = false;
}
// 채번 코드 생성 성공
} else {
console.warn("채번 코드 생성 실패:", previewResponse);
}
} catch (error) {
console.error("채번 자동생성 오류:", error);
} finally {
setIsGeneratingNumbering(false);
}
};
generateNumberingCode();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tableName, columnName, isEditMode, categoryValuesForNumbering]);
// 실제 표시할 값 (자동생성 값 또는 props value)
const displayValue = autoGeneratedValue ?? value;
// 조건부 렌더링 체크
// TODO: conditional 처리 로직 추가
// 타입별 입력 컴포넌트 렌더링
const renderInput = () => {
const inputType = config.inputType || config.type || "text";
switch (inputType) {
case "text":
return (
<TextInput
value={displayValue}
onChange={(v) => {
setAutoGeneratedValue(null); // 사용자 입력 시 자동생성 값 초기화
onChange?.(v);
}}
format={config.format}
mask={config.mask}
placeholder={config.placeholder}
readonly={readonly || (autoGeneration.enabled && hasGeneratedRef.current)}
disabled={disabled}
/>
);
case "number":
return (
<NumberInput
value={typeof displayValue === "number" ? displayValue : undefined}
onChange={(v) => {
setAutoGeneratedValue(null);
onChange?.(v ?? 0);
}}
min={config.min}
max={config.max}
step={config.step}
placeholder={config.placeholder}
readonly={readonly}
disabled={disabled}
/>
);
case "password":
return (
<PasswordInput
value={typeof displayValue === "string" ? displayValue : ""}
onChange={(v) => {
setAutoGeneratedValue(null);
onChange?.(v);
}}
placeholder={config.placeholder}
readonly={readonly}
disabled={disabled}
/>
);
case "slider":
return (
<SliderInput
value={typeof displayValue === "number" ? displayValue : (config.min ?? 0)}
onChange={(v) => {
setAutoGeneratedValue(null);
onChange?.(v);
}}
min={config.min}
max={config.max}
step={config.step}
disabled={disabled}
/>
);
case "color":
return (
<ColorInput
value={typeof displayValue === "string" ? displayValue : "#000000"}
onChange={(v) => {
setAutoGeneratedValue(null);
onChange?.(v);
}}
disabled={disabled}
/>
);
case "textarea":
return (
<TextareaInput
value={displayValue as string}
onChange={(v) => {
setAutoGeneratedValue(null);
onChange?.(v);
}}
placeholder={config.placeholder}
rows={config.rows}
readonly={readonly}
disabled={disabled}
/>
);
case "numbering": {
// 채번 타입: ____ 부분만 편집 가능하게 처리
const template = numberingTemplateRef.current;
const canEdit = hadManualPartRef.current && template;
// 채번 필드 렌더링
// 템플릿이 없으면 읽기 전용 (아직 생성 전이거나 수동 입력 부분 없음)
if (!canEdit) {
return (
<TextInput
value={displayValue || ""}
onChange={() => {}}
placeholder={isGeneratingNumbering ? "생성 중..." : "자동 생성됩니다"}
readonly={true}
disabled={disabled || isGeneratingNumbering}
/>
);
}
// 템플릿에서 prefix와 suffix 추출
const templateParts = template.split("____");
const templatePrefix = templateParts[0] || "";
const templateSuffix = templateParts.length > 1 ? templateParts.slice(1).join("") : "";
return (
<div className="flex h-full items-center rounded-md border">
{/* 고정 접두어 */}
{templatePrefix && (
<span className="text-muted-foreground bg-muted flex h-full items-center px-2 text-sm">
{templatePrefix}
</span>
)}
{/* 편집 가능한 부분 */}
<input
type="text"
value={manualInputValue}
onChange={(e) => {
const newUserInput = e.target.value;
setManualInputValue(newUserInput);
// 전체 값 조합
const newValue = templatePrefix + newUserInput + templateSuffix;
userEditedNumberingRef.current = true;
setAutoGeneratedValue(newValue);
onChange?.(newValue);
}}
placeholder="입력"
className="h-full min-w-[60px] flex-1 bg-transparent px-2 text-sm focus-visible:outline-none"
disabled={disabled || isGeneratingNumbering}
/>
{/* 고정 접미어 */}
{templateSuffix && (
<span className="text-muted-foreground bg-muted flex h-full items-center px-2 text-sm">
{templateSuffix}
</span>
)}
</div>
);
}
default:
return (
<TextInput
value={displayValue}
onChange={(v) => {
setAutoGeneratedValue(null);
onChange?.(v);
}}
placeholder={config.placeholder}
readonly={readonly}
disabled={disabled}
/>
);
}
};
// 라벨이 표시될 때 입력 필드가 차지할 높이 계산
const showLabel = label && style?.labelDisplay !== false;
// size에서 우선 가져오고, 없으면 style에서 가져옴
const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height;
return (
<div
ref={ref}
id={id}
className="flex flex-col"
style={{
width: componentWidth,
height: componentHeight,
}}
>
{showLabel && (
<Label
htmlFor={id}
style={{
fontSize: style?.labelFontSize,
color: style?.labelColor,
fontWeight: style?.labelFontWeight,
marginBottom: style?.labelMarginBottom,
}}
className="flex-shrink-0 text-sm font-medium"
>
{label}
{required && <span className="ml-0.5 text-orange-500">*</span>}
</Label>
)}
<div className="min-h-0 flex-1">{renderInput()}</div>
</div>
);
});
UnifiedInput.displayName = "UnifiedInput";
export default UnifiedInput;
@@ -1,399 +0,0 @@
"use client";
/**
* UnifiedLayout
*
* 통합 레이아웃 컴포넌트
* - grid: 그리드 레이아웃
* - split: 분할 레이아웃
* - flex: 플렉스 레이아웃
* - divider: 구분선
* - screen-embed: 화면 임베딩
*/
import React, { forwardRef, useCallback, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { UnifiedLayoutProps } from "@/types/unified-components";
import { GripVertical, GripHorizontal } from "lucide-react";
/**
* 그리드 레이아웃 컴포넌트 (12컬럼 시스템)
*
* 사용법:
* - columns: 컬럼 수 (기본 12, 전통적 그리드)
* - colSpan: 자식 요소별 span 지정 시 사용
* - Tailwind의 grid-cols-12 기반
*/
const GridLayout = forwardRef<HTMLDivElement, {
columns?: number; // 12컬럼 시스템에서 몇 컬럼으로 나눌지 (1-12)
gap?: string;
children?: React.ReactNode;
className?: string;
use12Column?: boolean; // 12컬럼 시스템 사용 여부
}>(({ columns = 12, gap = "16px", children, className, use12Column = true }, ref) => {
// 12컬럼 그리드 클래스 매핑
const gridColsClass: Record<number, string> = {
1: "grid-cols-1",
2: "grid-cols-2",
3: "grid-cols-3",
4: "grid-cols-4",
5: "grid-cols-5",
6: "grid-cols-6",
7: "grid-cols-7",
8: "grid-cols-8",
9: "grid-cols-9",
10: "grid-cols-10",
11: "grid-cols-11",
12: "grid-cols-12",
};
// 12컬럼 시스템 사용 시
if (use12Column) {
return (
<div
ref={ref}
className={cn(
"grid",
gridColsClass[columns] || "grid-cols-12",
className
)}
style={{ gap }}
>
{children}
</div>
);
}
// 기존 방식 (동적 컬럼 수)
return (
<div
ref={ref}
className={cn("grid", className)}
style={{
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
gap,
}}
>
{children}
</div>
);
});
GridLayout.displayName = "GridLayout";
/**
* 분할 레이아웃 컴포넌트 (리사이즈 가능)
*/
const SplitLayout = forwardRef<HTMLDivElement, {
direction?: "horizontal" | "vertical";
splitRatio?: number[];
gap?: string;
children?: React.ReactNode;
className?: string;
}>(({ direction = "horizontal", splitRatio = [50, 50], gap = "8px", children, className }, ref) => {
const containerRef = useRef<HTMLDivElement>(null);
const [ratio, setRatio] = useState(splitRatio);
const [isDragging, setIsDragging] = useState(false);
const childArray = React.Children.toArray(children);
const isHorizontal = direction === "horizontal";
// 리사이저 드래그 시작
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setIsDragging(true);
const startPos = isHorizontal ? e.clientX : e.clientY;
const startRatio = [...ratio];
const container = containerRef.current;
const handleMouseMove = (moveEvent: MouseEvent) => {
if (!container) return;
const containerSize = isHorizontal ? container.offsetWidth : container.offsetHeight;
const currentPos = isHorizontal ? moveEvent.clientX : moveEvent.clientY;
const delta = currentPos - startPos;
const deltaPercent = (delta / containerSize) * 100;
const newFirst = Math.max(10, Math.min(90, startRatio[0] + deltaPercent));
const newSecond = 100 - newFirst;
setRatio([newFirst, newSecond]);
};
const handleMouseUp = () => {
setIsDragging(false);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}, [isHorizontal, ratio]);
return (
<div
ref={(node) => {
(containerRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
}}
className={cn(
"flex",
isHorizontal ? "flex-row" : "flex-col",
className
)}
style={{ gap }}
>
{/* 첫 번째 패널 */}
<div
className="overflow-auto"
style={{
[isHorizontal ? "width" : "height"]: `${ratio[0]}%`,
flexShrink: 0,
}}
>
{childArray[0]}
</div>
{/* 리사이저 */}
<div
className={cn(
"flex items-center justify-center bg-border hover:bg-primary/20 transition-colors",
isHorizontal ? "w-2 cursor-col-resize" : "h-2 cursor-row-resize",
isDragging && "bg-primary/30"
)}
onMouseDown={handleMouseDown}
>
{isHorizontal ? (
<GripVertical className="h-4 w-4 text-muted-foreground" />
) : (
<GripHorizontal className="h-4 w-4 text-muted-foreground" />
)}
</div>
{/* 두 번째 패널 */}
<div
className="overflow-auto flex-1"
style={{
[isHorizontal ? "width" : "height"]: `${ratio[1]}%`,
}}
>
{childArray[1]}
</div>
</div>
);
});
SplitLayout.displayName = "SplitLayout";
/**
* 플렉스 레이아웃 컴포넌트
*/
const FlexLayout = forwardRef<HTMLDivElement, {
direction?: "horizontal" | "vertical";
gap?: string;
wrap?: boolean;
justify?: "start" | "center" | "end" | "between" | "around";
align?: "start" | "center" | "end" | "stretch";
children?: React.ReactNode;
className?: string;
}>(({
direction = "horizontal",
gap = "16px",
wrap = false,
justify = "start",
align = "stretch",
children,
className
}, ref) => {
const justifyMap = {
start: "flex-start",
center: "center",
end: "flex-end",
between: "space-between",
around: "space-around",
};
const alignMap = {
start: "flex-start",
center: "center",
end: "flex-end",
stretch: "stretch",
};
return (
<div
ref={ref}
className={cn("flex", className)}
style={{
flexDirection: direction === "horizontal" ? "row" : "column",
flexWrap: wrap ? "wrap" : "nowrap",
justifyContent: justifyMap[justify],
alignItems: alignMap[align],
gap,
}}
>
{children}
</div>
);
});
FlexLayout.displayName = "FlexLayout";
/**
* 구분선 컴포넌트
*/
const DividerLayout = forwardRef<HTMLDivElement, {
direction?: "horizontal" | "vertical";
className?: string;
}>(({ direction = "horizontal", className }, ref) => {
return (
<div
ref={ref}
className={cn(
"bg-border",
direction === "horizontal" ? "h-px w-full my-4" : "w-px h-full mx-4",
className
)}
/>
);
});
DividerLayout.displayName = "DividerLayout";
/**
* 화면 임베딩 컴포넌트
*/
const ScreenEmbedLayout = forwardRef<HTMLDivElement, {
screenId?: number;
className?: string;
}>(({ screenId, className }, ref) => {
if (!screenId) {
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center h-32 border-2 border-dashed rounded-lg text-muted-foreground",
className
)}
>
</div>
);
}
// TODO: 실제 화면 임베딩 로직 구현
// InteractiveScreenViewer와 연동 필요
return (
<div
ref={ref}
className={cn(
"border rounded-lg p-4",
className
)}
>
<div className="text-sm text-muted-foreground mb-2">
(ID: {screenId})
</div>
<div className="h-48 bg-muted/30 rounded flex items-center justify-center">
{/* 여기에 InteractiveScreenViewer 렌더링 */}
</div>
</div>
);
});
ScreenEmbedLayout.displayName = "ScreenEmbedLayout";
/**
* 메인 UnifiedLayout 컴포넌트
*/
export const UnifiedLayout = forwardRef<HTMLDivElement, UnifiedLayoutProps>(
(props, ref) => {
const {
id,
style,
size,
config: configProp,
children,
} = props;
// config가 없으면 기본값 사용
const config = configProp || { type: "grid" as const, columns: 2 };
// 타입별 레이아웃 렌더링
const renderLayout = () => {
const layoutType = config.type || "grid";
switch (layoutType) {
case "grid":
return (
<GridLayout
columns={config.columns}
gap={config.gap}
>
{children}
</GridLayout>
);
case "split":
return (
<SplitLayout
direction={config.direction}
splitRatio={config.splitRatio}
gap={config.gap}
>
{children}
</SplitLayout>
);
case "flex":
return (
<FlexLayout
direction={config.direction}
gap={config.gap}
>
{children}
</FlexLayout>
);
case "divider":
return (
<DividerLayout
direction={config.direction}
/>
);
case "screen-embed":
return (
<ScreenEmbedLayout
screenId={config.screenId}
/>
);
default:
return (
<GridLayout columns={config.columns} gap={config.gap}>
{children}
</GridLayout>
);
}
};
const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height;
return (
<div
ref={ref}
id={id}
style={{
width: componentWidth,
height: componentHeight,
}}
>
{renderLayout()}
</div>
);
}
);
UnifiedLayout.displayName = "UnifiedLayout";
export default UnifiedLayout;
-176
View File
@@ -1,176 +0,0 @@
"use client";
/**
* UnifiedList
*
* 통합 리스트 컴포넌트
* 기존 TableListComponent를 래핑하여 동일한 기능 제공
*/
import React, { forwardRef, useMemo } from "react";
import { TableListComponent } from "@/lib/registry/components/table-list/TableListComponent";
import { UnifiedListProps } from "@/types/unified-components";
/**
* 메인 UnifiedList 컴포넌트
* 기존 TableListComponent의 모든 기능을 그대로 사용
*/
export const UnifiedList = forwardRef<HTMLDivElement, UnifiedListProps>((props, ref) => {
const { id, style, size, config: configProp, onRowSelect } = props;
// config가 없으면 기본값 사용
const config = configProp || {
viewMode: "table" as const,
source: "static" as const,
columns: [],
};
// 테이블명 추출 (여러 가능한 경로에서 시도)
const tableName = config.dataSource?.table || (config as any).tableName || (props as any).tableName;
// columns 형식 변환 (UnifiedListConfigPanel 형식 -> TableListComponent 형식)
const tableColumns = useMemo(
() =>
(config.columns || []).map((col: any, index: number) => ({
columnName: col.key || col.field || "",
displayName: col.title || col.header || col.key || col.field || "",
width: col.width ? parseInt(col.width, 10) : undefined,
visible: true,
sortable: true,
searchable: true,
align: "left" as const,
order: index,
isEntityJoin: col.isJoinColumn || false,
thousandSeparator: col.thousandSeparator !== false, // 천단위 구분자 (기본: true)
})),
[config.columns],
);
// TableListComponent에 전달할 component 객체 생성
const componentObj = useMemo(
() => ({
id: id || "unified-list",
type: "table-list",
config: {
selectedTable: tableName,
tableName: tableName,
columns: tableColumns,
displayMode: config.viewMode === "card" ? "card" : "table",
cardConfig: {
idColumn: config.cardConfig?.titleColumn || tableColumns[0]?.columnName || "id",
titleColumn: config.cardConfig?.titleColumn || tableColumns[0]?.columnName || "",
subtitleColumn: config.cardConfig?.subtitleColumn || undefined,
descriptionColumn: config.cardConfig?.descriptionColumn || undefined,
imageColumn: config.cardConfig?.imageColumn || undefined,
cardsPerRow: config.cardConfig?.cardsPerRow || 3,
cardSpacing: 16,
showActions: false,
},
showHeader: config.viewMode !== "card", // 카드 모드에서는 테이블 헤더 숨김
showFooter: false,
checkbox: {
enabled: true, // 항상 체크박스 활성화 (modalDataStore에 자동 저장)
position: "left" as const,
showHeader: true,
},
height: "auto" as const, // auto로 변경하여 스크롤 가능하게
autoWidth: true,
stickyHeader: true,
autoLoad: true,
horizontalScroll: {
enabled: true,
minColumnWidth: 100,
maxColumnWidth: 300,
},
pagination: {
enabled: config.pagination !== false,
pageSize: config.pageSize || 10,
position: "bottom" as const,
showPageSize: true, // 사용자가 실제 화면에서 페이지 크기 변경 가능
pageSizeOptions: [5, 10, 20, 50, 100],
},
filter: {
enabled: false, // 필터 비활성화 (필요시 활성화)
position: "top" as const,
searchPlaceholder: "검색...",
},
actions: {
enabled: false,
items: [],
},
tableStyle: {
striped: false,
bordered: true,
hover: true,
compact: false,
},
toolbar: {
showRefresh: true,
showExport: false,
showColumnToggle: false,
},
},
style: {},
gridColumns: 1,
}),
[
id,
tableName,
tableColumns,
config.viewMode,
config.pagination,
config.pageSize,
config.cardConfig,
onRowSelect,
],
);
// 테이블이 없으면 안내 메시지
if (!tableName) {
return (
<div
ref={ref}
id={id}
className="bg-muted/20 flex items-center justify-center rounded-lg border p-8"
style={{
width: size?.width || style?.width || "100%",
height: size?.height || style?.height || 400,
}}
>
<p className="text-muted-foreground text-sm"> .</p>
</div>
);
}
return (
<div
ref={ref}
id={id}
className="flex flex-col overflow-auto"
style={{
width: size?.width || style?.width || "100%",
height: size?.height || style?.height || 400,
}}
>
<TableListComponent
component={componentObj}
tableName={tableName}
style={{
width: "100%",
minHeight: "100%",
display: "flex",
flexDirection: "column",
}}
onSelectedRowsChange={
onRowSelect
? (_, selectedData) => {
onRowSelect(selectedData);
}
: undefined
}
/>
</div>
);
});
UnifiedList.displayName = "UnifiedList";
@@ -1,575 +0,0 @@
"use client";
/**
* UnifiedMedia
*
* 통합 미디어 컴포넌트
* - file: 파일 업로드
* - image: 이미지 업로드/표시
* - video: 비디오
* - audio: 오디오
*/
import React, { forwardRef, useCallback, useRef, useState } from "react";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { UnifiedMediaProps } from "@/types/unified-components";
import { Upload, X, File, Image as ImageIcon, Video, Music, Eye, Download, Trash2 } from "lucide-react";
/**
* 파일 크기 포맷팅
*/
function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
/**
* 파일 타입 아이콘 가져오기
*/
function getFileIcon(type: string) {
if (type.startsWith("image/")) return ImageIcon;
if (type.startsWith("video/")) return Video;
if (type.startsWith("audio/")) return Music;
return File;
}
/**
* 파일 업로드 컴포넌트
*/
const FileUploader = forwardRef<HTMLDivElement, {
value?: string | string[];
onChange?: (value: string | string[]) => void;
multiple?: boolean;
accept?: string;
maxSize?: number;
disabled?: boolean;
uploadEndpoint?: string;
className?: string;
}>(({
value,
onChange,
multiple = false,
accept = "*",
maxSize = 10485760, // 10MB
disabled,
uploadEndpoint = "/api/upload",
className
}, ref) => {
const inputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const files = Array.isArray(value) ? value : value ? [value] : [];
// 파일 선택 핸들러
const handleFileSelect = useCallback(async (selectedFiles: FileList | null) => {
if (!selectedFiles || selectedFiles.length === 0) return;
setError(null);
const fileArray = Array.from(selectedFiles);
// 크기 검증
for (const file of fileArray) {
if (file.size > maxSize) {
setError(`파일 크기가 ${formatFileSize(maxSize)}를 초과합니다: ${file.name}`);
return;
}
}
setIsUploading(true);
try {
const uploadedUrls: string[] = [];
for (const file of fileArray) {
const formData = new FormData();
formData.append("file", file);
const response = await fetch(uploadEndpoint, {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error(`업로드 실패: ${file.name}`);
}
const data = await response.json();
if (data.success && data.url) {
uploadedUrls.push(data.url);
} else if (data.filePath) {
uploadedUrls.push(data.filePath);
}
}
if (multiple) {
onChange?.([...files, ...uploadedUrls]);
} else {
onChange?.(uploadedUrls[0] || "");
}
} catch (err) {
setError(err instanceof Error ? err.message : "업로드 중 오류가 발생했습니다");
} finally {
setIsUploading(false);
}
}, [files, multiple, maxSize, uploadEndpoint, onChange]);
// 드래그 앤 드롭 핸들러
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
handleFileSelect(e.dataTransfer.files);
}, [handleFileSelect]);
// 파일 삭제 핸들러
const handleRemove = useCallback((index: number) => {
const newFiles = files.filter((_, i) => i !== index);
onChange?.(multiple ? newFiles : "");
}, [files, multiple, onChange]);
return (
<div ref={ref} className={cn("space-y-3", className)}>
{/* 업로드 영역 */}
<div
className={cn(
"border-2 border-dashed rounded-lg p-6 text-center transition-colors",
isDragging && "border-primary bg-primary/5",
disabled && "opacity-50 cursor-not-allowed",
!disabled && "cursor-pointer hover:border-primary/50"
)}
onClick={() => !disabled && inputRef.current?.click()}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
ref={inputRef}
type="file"
accept={accept}
multiple={multiple}
disabled={disabled}
onChange={(e) => handleFileSelect(e.target.files)}
className="hidden"
/>
{isUploading ? (
<div className="flex flex-col items-center gap-2">
<div className="animate-spin h-8 w-8 border-2 border-primary border-t-transparent rounded-full" />
<span className="text-sm text-muted-foreground"> ...</span>
</div>
) : (
<div className="flex flex-col items-center gap-2">
<Upload className="h-8 w-8 text-muted-foreground" />
<div className="text-sm">
<span className="font-medium text-primary"></span>
<span className="text-muted-foreground"> </span>
</div>
<div className="text-xs text-muted-foreground">
{formatFileSize(maxSize)}
{accept !== "*" && ` (${accept})`}
</div>
</div>
)}
</div>
{/* 에러 메시지 */}
{error && (
<div className="text-sm text-destructive">{error}</div>
)}
{/* 업로드된 파일 목록 */}
{files.length > 0 && (
<div className="space-y-2">
{files.map((file, index) => (
<div
key={index}
className="flex items-center gap-2 p-2 bg-muted/50 rounded-md"
>
<File className="h-4 w-4 text-muted-foreground" />
<span className="flex-1 text-sm truncate">{file.split("/").pop()}</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleRemove(index)}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
</div>
);
});
FileUploader.displayName = "FileUploader";
/**
* 이미지 업로드/표시 컴포넌트
*/
const ImageUploader = forwardRef<HTMLDivElement, {
value?: string | string[];
onChange?: (value: string | string[]) => void;
multiple?: boolean;
accept?: string;
maxSize?: number;
preview?: boolean;
disabled?: boolean;
uploadEndpoint?: string;
className?: string;
}>(({
value,
onChange,
multiple = false,
accept = "image/*",
maxSize = 10485760,
preview = true,
disabled,
uploadEndpoint = "/api/upload",
className
}, ref) => {
const inputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const images = Array.isArray(value) ? value : value ? [value] : [];
// 파일 선택 핸들러
const handleFileSelect = useCallback(async (selectedFiles: FileList | null) => {
if (!selectedFiles || selectedFiles.length === 0) return;
setIsUploading(true);
try {
const fileArray = Array.from(selectedFiles);
const uploadedUrls: string[] = [];
for (const file of fileArray) {
// 미리보기 생성
if (preview) {
const reader = new FileReader();
reader.onload = () => setPreviewUrl(reader.result as string);
reader.readAsDataURL(file);
}
const formData = new FormData();
formData.append("file", file);
const response = await fetch(uploadEndpoint, {
method: "POST",
body: formData,
});
if (response.ok) {
const data = await response.json();
if (data.success && data.url) {
uploadedUrls.push(data.url);
} else if (data.filePath) {
uploadedUrls.push(data.filePath);
}
}
}
if (multiple) {
onChange?.([...images, ...uploadedUrls]);
} else {
onChange?.(uploadedUrls[0] || "");
}
} finally {
setIsUploading(false);
setPreviewUrl(null);
}
}, [images, multiple, preview, uploadEndpoint, onChange]);
// 이미지 삭제 핸들러
const handleRemove = useCallback((index: number) => {
const newImages = images.filter((_, i) => i !== index);
onChange?.(multiple ? newImages : "");
}, [images, multiple, onChange]);
return (
<div ref={ref} className={cn("space-y-3", className)}>
{/* 이미지 미리보기 */}
{preview && images.length > 0 && (
<div className={cn(
"grid gap-2",
multiple ? "grid-cols-2 sm:grid-cols-3 lg:grid-cols-4" : "grid-cols-1"
)}>
{images.map((src, index) => (
<div key={index} className="relative group aspect-square rounded-lg overflow-hidden border">
<img
src={src}
alt={`이미지 ${index + 1}`}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<Button
variant="secondary"
size="icon"
className="h-8 w-8"
onClick={() => window.open(src, "_blank")}
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="destructive"
size="icon"
className="h-8 w-8"
onClick={() => handleRemove(index)}
disabled={disabled}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
{/* 업로드 버튼 */}
{(!images.length || multiple) && (
<div
className={cn(
"border-2 border-dashed rounded-lg p-4 text-center transition-colors",
isDragging && "border-primary bg-primary/5",
disabled && "opacity-50 cursor-not-allowed",
!disabled && "cursor-pointer hover:border-primary/50"
)}
onClick={() => !disabled && inputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={(e) => { e.preventDefault(); setIsDragging(false); }}
onDrop={(e) => { e.preventDefault(); setIsDragging(false); handleFileSelect(e.dataTransfer.files); }}
>
<input
ref={inputRef}
type="file"
accept={accept}
multiple={multiple}
disabled={disabled}
onChange={(e) => handleFileSelect(e.target.files)}
className="hidden"
/>
{isUploading ? (
<div className="flex items-center justify-center gap-2">
<div className="animate-spin h-5 w-5 border-2 border-primary border-t-transparent rounded-full" />
<span className="text-sm text-muted-foreground"> ...</span>
</div>
) : (
<div className="flex items-center justify-center gap-2">
<ImageIcon className="h-5 w-5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
{multiple ? "추가" : "선택"}
</span>
</div>
)}
</div>
)}
</div>
);
});
ImageUploader.displayName = "ImageUploader";
/**
* 비디오 컴포넌트
*/
const VideoPlayer = forwardRef<HTMLDivElement, {
value?: string;
className?: string;
}>(({ value, className }, ref) => {
if (!value) {
return (
<div
ref={ref}
className={cn(
"aspect-video flex items-center justify-center border rounded-lg bg-muted/50",
className
)}
>
<Video className="h-8 w-8 text-muted-foreground" />
</div>
);
}
return (
<div ref={ref} className={cn("aspect-video rounded-lg overflow-hidden", className)}>
<video
src={value}
controls
className="w-full h-full object-cover"
/>
</div>
);
});
VideoPlayer.displayName = "VideoPlayer";
/**
* 오디오 컴포넌트
*/
const AudioPlayer = forwardRef<HTMLDivElement, {
value?: string;
className?: string;
}>(({ value, className }, ref) => {
if (!value) {
return (
<div
ref={ref}
className={cn(
"h-12 flex items-center justify-center border rounded-lg bg-muted/50",
className
)}
>
<Music className="h-5 w-5 text-muted-foreground" />
</div>
);
}
return (
<div ref={ref} className={cn("", className)}>
<audio
src={value}
controls
className="w-full"
/>
</div>
);
});
AudioPlayer.displayName = "AudioPlayer";
/**
* 메인 UnifiedMedia 컴포넌트
*/
export const UnifiedMedia = forwardRef<HTMLDivElement, UnifiedMediaProps>(
(props, ref) => {
const {
id,
label,
required,
readonly,
disabled,
style,
size,
config: configProp,
value,
onChange,
} = props;
// config가 없으면 기본값 사용
const config = configProp || { type: "image" as const };
// 타입별 미디어 컴포넌트 렌더링
const renderMedia = () => {
const isDisabled = disabled || readonly;
const mediaType = config.type || "image";
switch (mediaType) {
case "file":
return (
<FileUploader
value={value}
onChange={onChange}
multiple={config.multiple}
accept={config.accept}
maxSize={config.maxSize}
disabled={isDisabled}
uploadEndpoint={config.uploadEndpoint}
/>
);
case "image":
return (
<ImageUploader
value={value}
onChange={onChange}
multiple={config.multiple}
accept={config.accept || "image/*"}
maxSize={config.maxSize}
preview={config.preview}
disabled={isDisabled}
uploadEndpoint={config.uploadEndpoint}
/>
);
case "video":
return (
<VideoPlayer
value={typeof value === "string" ? value : value?.[0]}
/>
);
case "audio":
return (
<AudioPlayer
value={typeof value === "string" ? value : value?.[0]}
/>
);
default:
return (
<FileUploader
value={value}
onChange={onChange}
disabled={isDisabled}
/>
);
}
};
const showLabel = label && style?.labelDisplay !== false;
const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height;
return (
<div
ref={ref}
id={id}
className="flex flex-col"
style={{
width: componentWidth,
height: componentHeight,
}}
>
{showLabel && (
<Label
htmlFor={id}
style={{
fontSize: style?.labelFontSize,
color: style?.labelColor,
fontWeight: style?.labelFontWeight,
marginBottom: style?.labelMarginBottom,
}}
className="text-sm font-medium flex-shrink-0"
>
{label}
{required && <span className="text-orange-500 ml-0.5">*</span>}
</Label>
)}
<div className="flex-1 min-h-0">
{renderMedia()}
</div>
</div>
);
}
);
UnifiedMedia.displayName = "UnifiedMedia";
export default UnifiedMedia;
@@ -1,939 +0,0 @@
"use client";
/**
* UnifiedRepeater 컴포넌트
*
* 렌더링 모드:
* - inline: 현재 테이블 컬럼 직접 입력
* - modal: 엔티티 선택 (FK 저장) + 추가 입력 컬럼
*
* RepeaterTable 및 ItemSelectionModal 재사용
*/
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import {
UnifiedRepeaterConfig,
UnifiedRepeaterProps,
RepeaterColumnConfig as UnifiedColumnConfig,
DEFAULT_REPEATER_CONFIG,
} from "@/types/unified-repeater";
import { apiClient } from "@/lib/api/client";
import { allocateNumberingCode } from "@/lib/api/numberingRule";
import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core";
// modal-repeater-table 컴포넌트 재사용
import { RepeaterTable } from "@/lib/registry/components/modal-repeater-table/RepeaterTable";
import { ItemSelectionModal } from "@/lib/registry/components/modal-repeater-table/ItemSelectionModal";
import { RepeaterColumnConfig } from "@/lib/registry/components/modal-repeater-table/types";
// 전역 UnifiedRepeater 등록 (buttonActions에서 사용)
declare global {
interface Window {
__unifiedRepeaterInstances?: Set<string>;
}
}
export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
config: propConfig,
parentId,
data: initialData,
onDataChange,
onRowClick,
className,
}) => {
// 설정 병합
const config: UnifiedRepeaterConfig = useMemo(
() => ({
...DEFAULT_REPEATER_CONFIG,
...propConfig,
dataSource: { ...DEFAULT_REPEATER_CONFIG.dataSource, ...propConfig.dataSource },
features: { ...DEFAULT_REPEATER_CONFIG.features, ...propConfig.features },
modal: { ...DEFAULT_REPEATER_CONFIG.modal, ...propConfig.modal },
}),
[propConfig],
);
// 상태
const [data, setData] = useState<any[]>(initialData || []);
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
const [modalOpen, setModalOpen] = useState(false);
// 🆕 데이터 변경 시 자동으로 컬럼 너비 조정 트리거
const [autoWidthTrigger, setAutoWidthTrigger] = useState(0);
// 소스 테이블 컬럼 라벨 매핑
const [sourceColumnLabels, setSourceColumnLabels] = useState<Record<string, string>>({});
// 🆕 소스 테이블의 카테고리 타입 컬럼 목록
const [sourceCategoryColumns, setSourceCategoryColumns] = useState<string[]>([]);
// 🆕 카테고리 코드 → 라벨 매핑 (RepeaterTable 표시용)
const [categoryLabelMap, setCategoryLabelMap] = useState<Record<string, string>>({});
// 현재 테이블 컬럼 정보 (inputType 매핑용)
const [currentTableColumnInfo, setCurrentTableColumnInfo] = useState<Record<string, any>>({});
// 동적 데이터 소스 상태
const [activeDataSources, setActiveDataSources] = useState<Record<string, string>>({});
// 🆕 최신 엔티티 참조 정보 (column_labels에서 조회)
const [resolvedSourceTable, setResolvedSourceTable] = useState<string>("");
const [resolvedReferenceKey, setResolvedReferenceKey] = useState<string>("id");
const isModalMode = config.renderMode === "modal";
// 전역 리피터 등록
// 🆕 useCustomTable이 설정된 경우 mainTableName 사용 (실제 저장될 테이블)
useEffect(() => {
const targetTableName = config.useCustomTable && config.mainTableName
? config.mainTableName
: config.dataSource?.tableName;
if (targetTableName) {
if (!window.__unifiedRepeaterInstances) {
window.__unifiedRepeaterInstances = new Set();
}
window.__unifiedRepeaterInstances.add(targetTableName);
}
return () => {
if (targetTableName && window.__unifiedRepeaterInstances) {
window.__unifiedRepeaterInstances.delete(targetTableName);
}
};
}, [config.useCustomTable, config.mainTableName, config.dataSource?.tableName]);
// 저장 이벤트 리스너
useEffect(() => {
const handleSaveEvent = async (event: CustomEvent) => {
// 🆕 mainTableName이 설정된 경우 우선 사용, 없으면 dataSource.tableName 사용
const tableName = config.useCustomTable && config.mainTableName
? config.mainTableName
: config.dataSource?.tableName;
const eventParentId = event.detail?.parentId;
const mainFormData = event.detail?.mainFormData;
// 🆕 마스터 테이블에서 생성된 ID (FK 연결용)
const masterRecordId = event.detail?.masterRecordId || mainFormData?.id;
if (!tableName || data.length === 0) {
return;
}
// UnifiedRepeater 저장 시작
const saveInfo = {
tableName,
useCustomTable: config.useCustomTable,
mainTableName: config.mainTableName,
foreignKeyColumn: config.foreignKeyColumn,
masterRecordId,
dataLength: data.length,
};
console.log("UnifiedRepeater 저장 시작", saveInfo);
try {
// 테이블 유효 컬럼 조회
let validColumns: Set<string> = new Set();
try {
const columnsResponse = await apiClient.get(`/table-management/tables/${tableName}/columns`);
const columns =
columnsResponse.data?.data?.columns || columnsResponse.data?.columns || columnsResponse.data || [];
validColumns = new Set(columns.map((col: any) => col.columnName || col.column_name || col.name));
} catch {
console.warn("테이블 컬럼 정보 조회 실패");
}
for (let i = 0; i < data.length; i++) {
const row = data[i];
// 내부 필드 제거
const cleanRow = Object.fromEntries(Object.entries(row).filter(([key]) => !key.startsWith("_")));
// 메인 폼 데이터 병합 (커스텀 테이블 사용 시에는 메인 폼 데이터 병합 안함)
let mergedData: Record<string, any>;
if (config.useCustomTable && config.mainTableName) {
// 커스텀 테이블: 리피터 데이터만 저장
mergedData = { ...cleanRow };
// 🆕 FK 자동 연결 - foreignKeySourceColumn이 설정된 경우 해당 컬럼 값 사용
if (config.foreignKeyColumn) {
// foreignKeySourceColumn이 있으면 mainFormData에서 해당 컬럼 값 사용
// 없으면 마스터 레코드 ID 사용 (기존 동작)
const sourceColumn = config.foreignKeySourceColumn;
let fkValue: any;
if (sourceColumn && mainFormData && mainFormData[sourceColumn] !== undefined) {
// mainFormData에서 참조 컬럼 값 가져오기
fkValue = mainFormData[sourceColumn];
} else {
// 기본: 마스터 레코드 ID 사용
fkValue = masterRecordId;
}
if (fkValue !== undefined && fkValue !== null) {
mergedData[config.foreignKeyColumn] = fkValue;
}
}
} else {
// 기존 방식: 메인 폼 데이터 병합
const { id: _mainId, ...mainFormDataWithoutId } = mainFormData || {};
mergedData = {
...mainFormDataWithoutId,
...cleanRow,
};
}
// 유효하지 않은 컬럼 제거
const filteredData: Record<string, any> = {};
for (const [key, value] of Object.entries(mergedData)) {
if (validColumns.size === 0 || validColumns.has(key)) {
filteredData[key] = value;
}
}
await apiClient.post(`/table-management/tables/${tableName}/add`, filteredData);
}
} catch (error) {
console.error("❌ UnifiedRepeater 저장 실패:", error);
throw error;
}
};
// V2 EventBus 구독
const unsubscribe = v2EventBus.subscribe(
V2_EVENTS.REPEATER_SAVE,
async (payload) => {
const tableName = config.useCustomTable && config.mainTableName
? config.mainTableName
: config.dataSource?.tableName;
if (payload.tableName === tableName) {
await handleSaveEvent({ detail: payload } as CustomEvent);
}
},
{ componentId: `unified-repeater-${config.dataSource?.tableName}` }
);
// 레거시 이벤트도 계속 지원 (점진적 마이그레이션)
window.addEventListener("repeaterSave" as any, handleSaveEvent);
return () => {
unsubscribe();
window.removeEventListener("repeaterSave" as any, handleSaveEvent);
};
}, [data, config.dataSource?.tableName, config.useCustomTable, config.mainTableName, config.foreignKeyColumn, parentId]);
// 현재 테이블 컬럼 정보 로드
useEffect(() => {
const loadCurrentTableColumnInfo = async () => {
const tableName = config.dataSource?.tableName;
if (!tableName) return;
try {
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
const columns = response.data?.data?.columns || response.data?.columns || response.data || [];
const columnMap: Record<string, any> = {};
columns.forEach((col: any) => {
const name = col.columnName || col.column_name || col.name;
columnMap[name] = {
inputType: col.inputType || col.input_type || col.webType || "text",
displayName: col.displayName || col.display_name || col.label || name,
detailSettings: col.detailSettings || col.detail_settings,
};
});
setCurrentTableColumnInfo(columnMap);
} catch (error) {
console.error("컬럼 정보 로드 실패:", error);
}
};
loadCurrentTableColumnInfo();
}, [config.dataSource?.tableName]);
// 🆕 FK 컬럼 기반으로 최신 참조 테이블 정보 조회 (column_labels에서)
useEffect(() => {
const resolveEntityReference = async () => {
const tableName = config.dataSource?.tableName;
const foreignKey = config.dataSource?.foreignKey;
if (!isModalMode || !tableName || !foreignKey) {
// config에 저장된 값을 기본값으로 사용
setResolvedSourceTable(config.dataSource?.sourceTable || "");
setResolvedReferenceKey(config.dataSource?.referenceKey || "id");
return;
}
try {
// 현재 테이블의 컬럼 정보에서 FK 컬럼의 참조 테이블 조회
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
const columns = response.data?.data?.columns || response.data?.columns || response.data || [];
const fkColumn = columns.find((col: any) => (col.columnName || col.column_name || col.name) === foreignKey);
if (fkColumn) {
// column_labels의 reference_table 사용 (항상 최신값)
const refTable =
fkColumn.detailSettings?.referenceTable ||
fkColumn.reference_table ||
fkColumn.referenceTable ||
config.dataSource?.sourceTable ||
"";
const refKey =
fkColumn.detailSettings?.referenceColumn ||
fkColumn.reference_column ||
fkColumn.referenceColumn ||
config.dataSource?.referenceKey ||
"id";
setResolvedSourceTable(refTable);
setResolvedReferenceKey(refKey);
} else {
// FK 컬럼을 찾지 못한 경우 config 값 사용
setResolvedSourceTable(config.dataSource?.sourceTable || "");
setResolvedReferenceKey(config.dataSource?.referenceKey || "id");
}
} catch (error) {
console.error("엔티티 참조 정보 조회 실패:", error);
// 오류 시 config 값 사용
setResolvedSourceTable(config.dataSource?.sourceTable || "");
setResolvedReferenceKey(config.dataSource?.referenceKey || "id");
}
};
resolveEntityReference();
}, [
config.dataSource?.tableName,
config.dataSource?.foreignKey,
config.dataSource?.sourceTable,
config.dataSource?.referenceKey,
isModalMode,
]);
// 소스 테이블 컬럼 라벨 로드 (modal 모드) - resolvedSourceTable 사용
// 🆕 카테고리 타입 컬럼도 함께 감지
useEffect(() => {
const loadSourceColumnLabels = async () => {
if (!isModalMode || !resolvedSourceTable) return;
try {
const response = await apiClient.get(`/table-management/tables/${resolvedSourceTable}/columns`);
const columns = response.data?.data?.columns || response.data?.columns || response.data || [];
const labels: Record<string, string> = {};
const categoryCols: string[] = [];
columns.forEach((col: any) => {
const name = col.columnName || col.column_name || col.name;
labels[name] = col.displayName || col.display_name || col.label || name;
// 🆕 카테고리 타입 컬럼 감지
const inputType = col.inputType || col.input_type || "";
if (inputType === "category") {
categoryCols.push(name);
}
});
setSourceColumnLabels(labels);
setSourceCategoryColumns(categoryCols);
} catch (error) {
console.error("소스 컬럼 라벨 로드 실패:", error);
}
};
loadSourceColumnLabels();
}, [resolvedSourceTable, isModalMode]);
// UnifiedColumnConfig → RepeaterColumnConfig 변환
// 🆕 모든 컬럼을 columns 배열의 순서대로 처리 (isSourceDisplay 플래그로 구분)
const repeaterColumns: RepeaterColumnConfig[] = useMemo(() => {
return config.columns
.filter((col: UnifiedColumnConfig) => col.visible !== false)
.map((col: UnifiedColumnConfig): RepeaterColumnConfig => {
const colInfo = currentTableColumnInfo[col.key];
const inputType = col.inputType || colInfo?.inputType || "text";
// 소스 표시 컬럼인 경우 (모달 모드에서 읽기 전용)
if (col.isSourceDisplay) {
const label = col.title || sourceColumnLabels[col.key] || col.key;
return {
field: `_display_${col.key}`,
label,
type: "text",
editable: false,
calculated: true,
width: col.width === "auto" ? undefined : col.width,
};
}
// 일반 입력 컬럼
let type: "text" | "number" | "date" | "select" | "category" = "text";
if (inputType === "number" || inputType === "decimal") type = "number";
else if (inputType === "date" || inputType === "datetime") type = "date";
else if (inputType === "code") type = "select";
else if (inputType === "category") type = "category"; // 🆕 카테고리 타입
// 🆕 카테고리 참조 ID 가져오기 (tableName.columnName 형식)
// category 타입인 경우 현재 테이블명과 컬럼명을 조합
let categoryRef: string | undefined;
if (inputType === "category") {
// 🆕 소스 표시 컬럼이면 소스 테이블 사용, 아니면 타겟 테이블 사용
const tableName = col.isSourceDisplay ? resolvedSourceTable : config.dataSource?.tableName;
if (tableName) {
categoryRef = `${tableName}.${col.key}`;
}
}
return {
field: col.key,
label: col.title || colInfo?.displayName || col.key,
type,
editable: col.editable !== false,
width: col.width === "auto" ? undefined : col.width,
required: false,
categoryRef, // 🆕 카테고리 참조 ID 전달
hidden: col.hidden, // 🆕 히든 처리
autoFill: col.autoFill, // 🆕 자동 입력 설정
};
});
}, [config.columns, sourceColumnLabels, currentTableColumnInfo, resolvedSourceTable, config.dataSource?.tableName]);
// 🆕 데이터 변경 시 카테고리 라벨 로드 (RepeaterTable 표시용)
useEffect(() => {
const loadCategoryLabels = async () => {
if (sourceCategoryColumns.length === 0 || data.length === 0) {
return;
}
// 데이터에서 카테고리 컬럼의 모든 고유 코드 수집
const allCodes = new Set<string>();
for (const row of data) {
for (const col of sourceCategoryColumns) {
// _display_ 접두사가 있는 컬럼과 원본 컬럼 모두 확인
const val = row[`_display_${col}`] || row[col];
if (val && typeof val === "string") {
const codes = val
.split(",")
.map((c: string) => c.trim())
.filter(Boolean);
for (const code of codes) {
if (!categoryLabelMap[code] && code.startsWith("CATEGORY_")) {
allCodes.add(code);
}
}
}
}
}
if (allCodes.size === 0) {
return;
}
try {
const response = await apiClient.post("/table-categories/labels-by-codes", {
valueCodes: Array.from(allCodes),
});
if (response.data?.success && response.data.data) {
setCategoryLabelMap((prev) => ({
...prev,
...response.data.data,
}));
}
} catch (error) {
console.error("카테고리 라벨 조회 실패:", error);
}
};
loadCategoryLabels();
}, [data, sourceCategoryColumns]);
// 데이터 변경 핸들러
const handleDataChange = useCallback(
(newData: any[]) => {
setData(newData);
// 🆕 _targetTable 메타데이터 포함하여 전달 (백엔드에서 테이블 분리용)
if (onDataChange) {
const targetTable = config.useCustomTable && config.mainTableName
? config.mainTableName
: config.dataSource?.tableName;
if (targetTable) {
// 각 행에 _targetTable 추가
const dataWithTarget = newData.map(row => ({
...row,
_targetTable: targetTable,
}));
onDataChange(dataWithTarget);
} else {
onDataChange(newData);
}
}
// 🆕 데이터 변경 시 자동으로 컬럼 너비 조정
setAutoWidthTrigger((prev) => prev + 1);
},
[onDataChange, config.useCustomTable, config.mainTableName, config.dataSource?.tableName],
);
// 행 변경 핸들러
const handleRowChange = useCallback(
(index: number, newRow: any) => {
const newData = [...data];
newData[index] = newRow;
setData(newData);
// 🆕 _targetTable 메타데이터 포함
if (onDataChange) {
const targetTable = config.useCustomTable && config.mainTableName
? config.mainTableName
: config.dataSource?.tableName;
if (targetTable) {
const dataWithTarget = newData.map(row => ({
...row,
_targetTable: targetTable,
}));
onDataChange(dataWithTarget);
} else {
onDataChange(newData);
}
}
},
[data, onDataChange, config.useCustomTable, config.mainTableName, config.dataSource?.tableName],
);
// 행 삭제 핸들러
const handleRowDelete = useCallback(
(index: number) => {
const newData = data.filter((_, i) => i !== index);
handleDataChange(newData); // 🆕 handleDataChange 사용
// 선택 상태 업데이트
const newSelected = new Set<number>();
selectedRows.forEach((i) => {
if (i < index) newSelected.add(i);
else if (i > index) newSelected.add(i - 1);
});
setSelectedRows(newSelected);
},
[data, selectedRows, handleDataChange],
);
// 일괄 삭제 핸들러
const handleBulkDelete = useCallback(() => {
const newData = data.filter((_, index) => !selectedRows.has(index));
handleDataChange(newData); // 🆕 handleDataChange 사용
setSelectedRows(new Set());
}, [data, selectedRows, handleDataChange]);
// 행 추가 (inline 모드)
// 🆕 자동 입력 값 생성 함수 (동기 - 채번 제외)
const generateAutoFillValueSync = useCallback(
(col: any, rowIndex: number, mainFormData?: Record<string, unknown>) => {
if (!col.autoFill || col.autoFill.type === "none") return undefined;
const now = new Date();
switch (col.autoFill.type) {
case "currentDate":
return now.toISOString().split("T")[0]; // YYYY-MM-DD
case "currentDateTime":
return now.toISOString().slice(0, 19).replace("T", " "); // YYYY-MM-DD HH:mm:ss
case "sequence":
return rowIndex + 1; // 1부터 시작하는 순번
case "numbering":
// 채번은 별도 비동기 처리 필요
return null; // null 반환하여 비동기 처리 필요함을 표시
case "fromMainForm":
if (col.autoFill.sourceField && mainFormData) {
return mainFormData[col.autoFill.sourceField];
}
return "";
case "fixed":
return col.autoFill.fixedValue ?? "";
default:
return undefined;
}
},
[],
);
// 🆕 채번 API 호출 (비동기)
const generateNumberingCode = useCallback(async (ruleId: string): Promise<string> => {
try {
const result = await allocateNumberingCode(ruleId);
if (result.success && result.data?.generatedCode) {
return result.data.generatedCode;
}
console.error("채번 실패:", result.error);
return "";
} catch (error) {
console.error("채번 API 호출 실패:", error);
return "";
}
}, []);
// 🆕 행 추가 (inline 모드 또는 모달 열기) - 비동기로 변경
const handleAddRow = useCallback(async () => {
if (isModalMode) {
setModalOpen(true);
} else {
const newRow: any = { _id: `new_${Date.now()}` };
const currentRowCount = data.length;
// 먼저 동기적 자동 입력 값 적용
for (const col of config.columns) {
const autoValue = generateAutoFillValueSync(col, currentRowCount);
if (autoValue === null && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId) {
// 채번 규칙: 즉시 API 호출
newRow[col.key] = await generateNumberingCode(col.autoFill.numberingRuleId);
} else if (autoValue !== undefined) {
newRow[col.key] = autoValue;
} else {
newRow[col.key] = "";
}
}
const newData = [...data, newRow];
handleDataChange(newData);
}
}, [isModalMode, config.columns, data, handleDataChange, generateAutoFillValueSync, generateNumberingCode]);
// 모달에서 항목 선택 - 비동기로 변경
const handleSelectItems = useCallback(
async (items: Record<string, unknown>[]) => {
const fkColumn = config.dataSource?.foreignKey;
const currentRowCount = data.length;
// 채번이 필요한 컬럼 찾기
const numberingColumns = config.columns.filter(
(col) => !col.isSourceDisplay && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId
);
const newRows = await Promise.all(
items.map(async (item, index) => {
const row: any = { _id: `new_${Date.now()}_${Math.random()}` };
// FK 값 저장 (resolvedReferenceKey 사용)
if (fkColumn && item[resolvedReferenceKey]) {
row[fkColumn] = item[resolvedReferenceKey];
}
// 모든 컬럼 처리 (순서대로)
for (const col of config.columns) {
if (col.isSourceDisplay) {
// 소스 표시 컬럼: 소스 테이블에서 값 복사 (읽기 전용)
row[`_display_${col.key}`] = item[col.key] || "";
} else {
// 자동 입력 값 적용
const autoValue = generateAutoFillValueSync(col, currentRowCount + index);
if (autoValue === null && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId) {
// 채번 규칙: 즉시 API 호출
row[col.key] = await generateNumberingCode(col.autoFill.numberingRuleId);
} else if (autoValue !== undefined) {
row[col.key] = autoValue;
} else if (row[col.key] === undefined) {
// 입력 컬럼: 빈 값으로 초기화
row[col.key] = "";
}
}
}
return row;
})
);
const newData = [...data, ...newRows];
handleDataChange(newData);
setModalOpen(false);
},
[config.dataSource?.foreignKey, resolvedReferenceKey, config.columns, data, handleDataChange, generateAutoFillValueSync, generateNumberingCode],
);
// 소스 컬럼 목록 (모달용) - 🆕 columns 배열에서 isSourceDisplay인 것만 필터링
const sourceColumns = useMemo(() => {
return config.columns
.filter((col) => col.isSourceDisplay && col.visible !== false)
.map((col) => col.key)
.filter((key) => key && key !== "none");
}, [config.columns]);
// 🆕 beforeFormSave 이벤트에서 채번 placeholder를 실제 값으로 변환
const dataRef = useRef(data);
dataRef.current = data;
useEffect(() => {
const handleBeforeFormSave = async (event: Event) => {
const customEvent = event as CustomEvent;
const formData = customEvent.detail?.formData;
if (!formData || !dataRef.current.length) return;
// 채번 placeholder가 있는 행들을 찾아서 실제 값으로 변환
const processedData = await Promise.all(
dataRef.current.map(async (row) => {
const newRow = { ...row };
for (const key of Object.keys(newRow)) {
const value = newRow[key];
if (typeof value === "string" && value.startsWith("__NUMBERING_RULE__")) {
// __NUMBERING_RULE__ruleId__ 형식에서 ruleId 추출
const match = value.match(/__NUMBERING_RULE__(.+)__/);
if (match) {
const ruleId = match[1];
try {
const result = await allocateNumberingCode(ruleId);
if (result.success && result.data?.generatedCode) {
newRow[key] = result.data.generatedCode;
} else {
console.error("채번 실패:", result.error);
newRow[key] = ""; // 채번 실패 시 빈 값
}
} catch (error) {
console.error("채번 API 호출 실패:", error);
newRow[key] = "";
}
}
}
}
return newRow;
}),
);
// 처리된 데이터를 formData에 추가
const fieldName = config.fieldName || "repeaterData";
formData[fieldName] = processedData;
};
// V2 EventBus 구독
const unsubscribe = v2EventBus.subscribe(
V2_EVENTS.FORM_SAVE_COLLECT,
async (payload) => {
// formData 객체가 있으면 데이터 수집
const fakeEvent = {
detail: { formData: payload.formData },
} as CustomEvent;
await handleBeforeFormSave(fakeEvent);
},
{ componentId: `unified-repeater-${config.dataSource?.tableName}` }
);
// 레거시 이벤트도 계속 지원 (점진적 마이그레이션)
window.addEventListener("beforeFormSave", handleBeforeFormSave);
return () => {
unsubscribe();
window.removeEventListener("beforeFormSave", handleBeforeFormSave);
};
}, [config.fieldName]);
// 🆕 데이터 전달 이벤트 리스너 (transferData 버튼 액션용)
useEffect(() => {
// componentDataTransfer: 특정 컴포넌트 ID로 데이터 전달
const handleComponentDataTransfer = async (event: Event) => {
const customEvent = event as CustomEvent;
const { targetComponentId, data: transferData, mappingRules, mode } = customEvent.detail || {};
// 이 컴포넌트가 대상인지 확인
if (targetComponentId !== parentId && targetComponentId !== config.fieldName) {
return;
}
if (!transferData || transferData.length === 0) {
return;
}
// 데이터 매핑 처리
const mappedData = transferData.map((item: any, index: number) => {
const newRow: any = { _id: `transfer_${Date.now()}_${index}` };
if (mappingRules && mappingRules.length > 0) {
// 매핑 규칙이 있으면 적용
mappingRules.forEach((rule: any) => {
newRow[rule.targetField] = item[rule.sourceField];
});
} else {
// 매핑 규칙 없으면 그대로 복사
Object.assign(newRow, item);
}
return newRow;
});
// mode에 따라 데이터 처리
if (mode === "replace") {
handleDataChange(mappedData);
} else if (mode === "merge") {
// 중복 제거 후 병합 (id 기준)
const existingIds = new Set(data.map((row) => row.id || row._id));
const newItems = mappedData.filter((row: any) => !existingIds.has(row.id || row._id));
handleDataChange([...data, ...newItems]);
} else {
// 기본: append
handleDataChange([...data, ...mappedData]);
}
};
// splitPanelDataTransfer: 분할 패널에서 전역 이벤트로 전달
const handleSplitPanelDataTransfer = async (event: Event) => {
const customEvent = event as CustomEvent;
const { data: transferData, mappingRules, mode, sourcePosition } = customEvent.detail || {};
if (!transferData || transferData.length === 0) {
return;
}
// 데이터 매핑 처리
const mappedData = transferData.map((item: any, index: number) => {
const newRow: any = { _id: `transfer_${Date.now()}_${index}` };
if (mappingRules && mappingRules.length > 0) {
mappingRules.forEach((rule: any) => {
newRow[rule.targetField] = item[rule.sourceField];
});
} else {
Object.assign(newRow, item);
}
return newRow;
});
// mode에 따라 데이터 처리
if (mode === "replace") {
handleDataChange(mappedData);
} else {
handleDataChange([...data, ...mappedData]);
}
};
// V2 EventBus 구독
const unsubscribeComponent = v2EventBus.subscribe(
V2_EVENTS.COMPONENT_DATA_TRANSFER,
(payload) => {
const fakeEvent = {
detail: {
targetComponentId: payload.targetComponentId,
transferData: [payload.data],
mappingRules: [],
mode: "append",
},
} as CustomEvent;
handleComponentDataTransfer(fakeEvent);
},
{ componentId: `unified-repeater-${config.dataSource?.tableName}` }
);
const unsubscribeSplitPanel = v2EventBus.subscribe(
V2_EVENTS.SPLIT_PANEL_DATA_TRANSFER,
(payload) => {
const fakeEvent = {
detail: {
transferData: [payload.data],
mappingRules: [],
mode: "append",
},
} as CustomEvent;
handleSplitPanelDataTransfer(fakeEvent);
},
{ componentId: `unified-repeater-${config.dataSource?.tableName}` }
);
// 레거시 이벤트도 계속 지원 (점진적 마이그레이션)
window.addEventListener("componentDataTransfer", handleComponentDataTransfer as EventListener);
window.addEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener);
return () => {
unsubscribeComponent();
unsubscribeSplitPanel();
window.removeEventListener("componentDataTransfer", handleComponentDataTransfer as EventListener);
window.removeEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener);
};
}, [parentId, config.fieldName, data, handleDataChange]);
return (
<div className={cn("space-y-4", className)}>
{/* 헤더 영역 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-sm">
{data.length > 0 && `${data.length}개 항목`}
{selectedRows.size > 0 && ` (${selectedRows.size}개 선택됨)`}
</span>
</div>
<div className="flex gap-2">
{selectedRows.size > 0 && (
<Button variant="destructive" onClick={handleBulkDelete} className="h-8 text-xs sm:h-10 sm:text-sm">
({selectedRows.size})
</Button>
)}
<Button onClick={handleAddRow} className="h-8 text-xs sm:h-10 sm:text-sm">
<Plus className="mr-2 h-4 w-4" />
{isModalMode ? config.modal?.buttonText || "검색" : "추가"}
</Button>
</div>
</div>
{/* Repeater 테이블 */}
<RepeaterTable
columns={repeaterColumns}
data={data}
onDataChange={handleDataChange}
onRowChange={handleRowChange}
onRowDelete={handleRowDelete}
activeDataSources={activeDataSources}
onDataSourceChange={(field, optionId) => {
setActiveDataSources((prev) => ({ ...prev, [field]: optionId }));
}}
selectedRows={selectedRows}
onSelectionChange={setSelectedRows}
equalizeWidthsTrigger={autoWidthTrigger}
categoryColumns={sourceCategoryColumns}
categoryLabelMap={categoryLabelMap}
/>
{/* 항목 선택 모달 (modal 모드) - 검색 필드는 표시 컬럼과 동일하게 자동 설정 */}
{isModalMode && (
<ItemSelectionModal
open={modalOpen}
onOpenChange={setModalOpen}
sourceTable={resolvedSourceTable}
sourceColumns={sourceColumns}
sourceSearchFields={sourceColumns}
multiSelect={config.features?.multiSelect ?? true}
modalTitle={config.modal?.title || "항목 검색"}
alreadySelected={data}
uniqueField={resolvedReferenceKey}
onSelect={handleSelectItems}
columnLabels={sourceColumnLabels}
categoryColumns={sourceCategoryColumns}
/>
)}
</div>
);
};
UnifiedRepeater.displayName = "UnifiedRepeater";
// V2ErrorBoundary로 래핑된 안전한 버전 export
export const SafeUnifiedRepeater: React.FC<UnifiedRepeaterProps> = (props) => {
return (
<V2ErrorBoundary
componentId={props.parentId || "unified-repeater"}
componentType="UnifiedRepeater"
fallbackStyle="compact"
>
<UnifiedRepeater {...props} />
</V2ErrorBoundary>
);
};
export default UnifiedRepeater;
@@ -1,783 +0,0 @@
"use client";
/**
* UnifiedSelect
*
* 통합 선택 컴포넌트
* - dropdown: 드롭다운 선택
* - radio: 라디오 버튼 그룹
* - check: 체크박스 그룹
* - tag: 태그 선택
* - toggle: 토글 스위치
* - swap: 스왑 선택 (좌우 이동)
*/
import React, { forwardRef, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Checkbox } from "@/components/ui/checkbox";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { UnifiedSelectProps, SelectOption } from "@/types/unified-components";
import { Check, ChevronsUpDown, X, ArrowLeftRight } from "lucide-react";
import { apiClient } from "@/lib/api/client";
import UnifiedFormContext from "./UnifiedFormContext";
/**
* 드롭다운 선택 컴포넌트
*/
const DropdownSelect = forwardRef<HTMLButtonElement, {
options: SelectOption[];
value?: string | string[];
onChange?: (value: string | string[]) => void;
placeholder?: string;
searchable?: boolean;
multiple?: boolean;
maxSelect?: number;
allowClear?: boolean;
disabled?: boolean;
className?: string;
}>(({
options,
value,
onChange,
placeholder = "선택",
searchable,
multiple,
maxSelect,
allowClear = true,
disabled,
className
}, ref) => {
const [open, setOpen] = useState(false);
// 단일 선택 + 검색 불가능 → 기본 Select 사용
if (!searchable && !multiple) {
return (
<Select
value={typeof value === "string" ? value : value?.[0] ?? ""}
onValueChange={(v) => onChange?.(v)}
disabled={disabled}
>
<SelectTrigger ref={ref} className={cn("h-10", className)}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
// 검색 가능 또는 다중 선택 → Combobox 사용
const selectedValues = useMemo(() => {
if (!value) return [];
return Array.isArray(value) ? value : [value];
}, [value]);
const selectedLabels = useMemo(() => {
return selectedValues
.map((v) => options.find((o) => o.value === v)?.label)
.filter(Boolean) as string[];
}, [selectedValues, options]);
const handleSelect = useCallback((selectedValue: string) => {
if (multiple) {
const newValues = selectedValues.includes(selectedValue)
? selectedValues.filter((v) => v !== selectedValue)
: maxSelect && selectedValues.length >= maxSelect
? selectedValues
: [...selectedValues, selectedValue];
onChange?.(newValues);
} else {
onChange?.(selectedValue);
setOpen(false);
}
}, [multiple, selectedValues, maxSelect, onChange]);
const handleClear = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
onChange?.(multiple ? [] : "");
}, [multiple, onChange]);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
ref={ref}
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn("h-10 w-full justify-between font-normal", className)}
>
<span className="truncate flex-1 text-left">
{selectedLabels.length > 0
? multiple
? `${selectedLabels.length}개 선택됨`
: selectedLabels[0]
: placeholder}
</span>
<div className="flex items-center gap-1 ml-2">
{allowClear && selectedValues.length > 0 && (
<X
className="h-4 w-4 opacity-50 hover:opacity-100"
onClick={handleClear}
/>
)}
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command
filter={(value, search) => {
// value는 CommandItem의 value (라벨)
// search는 검색어
if (!search) return 1;
const normalizedValue = value.toLowerCase();
const normalizedSearch = search.toLowerCase();
if (normalizedValue.includes(normalizedSearch)) return 1;
return 0;
}}
>
{searchable && <CommandInput placeholder="검색..." className="h-9" />}
<CommandList>
<CommandEmpty> .</CommandEmpty>
<CommandGroup>
{options.map((option) => {
const displayLabel = option.label || option.value || "(빈 값)";
return (
<CommandItem
key={option.value}
value={displayLabel}
onSelect={() => handleSelect(option.value)}
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedValues.includes(option.value) ? "opacity-100" : "opacity-0"
)}
/>
{displayLabel}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
});
DropdownSelect.displayName = "DropdownSelect";
/**
* 라디오 선택 컴포넌트
*/
const RadioSelect = forwardRef<HTMLDivElement, {
options: SelectOption[];
value?: string;
onChange?: (value: string) => void;
disabled?: boolean;
className?: string;
}>(({ options, value, onChange, disabled, className }, ref) => {
return (
<RadioGroup
ref={ref}
value={value ?? ""}
onValueChange={onChange}
disabled={disabled}
className={cn("flex flex-wrap gap-4", className)}
>
{options.map((option) => (
<div key={option.value} className="flex items-center space-x-2">
<RadioGroupItem value={option.value} id={`radio-${option.value}`} />
<Label htmlFor={`radio-${option.value}`} className="text-sm cursor-pointer">
{option.label}
</Label>
</div>
))}
</RadioGroup>
);
});
RadioSelect.displayName = "RadioSelect";
/**
* 체크박스 선택 컴포넌트
*/
const CheckSelect = forwardRef<HTMLDivElement, {
options: SelectOption[];
value?: string[];
onChange?: (value: string[]) => void;
maxSelect?: number;
disabled?: boolean;
className?: string;
}>(({ options, value = [], onChange, maxSelect, disabled, className }, ref) => {
const handleChange = useCallback((optionValue: string, checked: boolean) => {
if (checked) {
if (maxSelect && value.length >= maxSelect) return;
onChange?.([...value, optionValue]);
} else {
onChange?.(value.filter((v) => v !== optionValue));
}
}, [value, maxSelect, onChange]);
return (
<div ref={ref} className={cn("flex flex-wrap gap-4", className)}>
{options.map((option) => (
<div key={option.value} className="flex items-center space-x-2">
<Checkbox
id={`check-${option.value}`}
checked={value.includes(option.value)}
onCheckedChange={(checked) => handleChange(option.value, checked as boolean)}
disabled={disabled || (maxSelect && value.length >= maxSelect && !value.includes(option.value))}
/>
<Label htmlFor={`check-${option.value}`} className="text-sm cursor-pointer">
{option.label}
</Label>
</div>
))}
</div>
);
});
CheckSelect.displayName = "CheckSelect";
/**
* 태그 선택 컴포넌트
*/
const TagSelect = forwardRef<HTMLDivElement, {
options: SelectOption[];
value?: string[];
onChange?: (value: string[]) => void;
maxSelect?: number;
disabled?: boolean;
className?: string;
}>(({ options, value = [], onChange, maxSelect, disabled, className }, ref) => {
const handleToggle = useCallback((optionValue: string) => {
const isSelected = value.includes(optionValue);
if (isSelected) {
onChange?.(value.filter((v) => v !== optionValue));
} else {
if (maxSelect && value.length >= maxSelect) return;
onChange?.([...value, optionValue]);
}
}, [value, maxSelect, onChange]);
return (
<div ref={ref} className={cn("flex flex-wrap gap-2", className)}>
{options.map((option) => {
const isSelected = value.includes(option.value);
return (
<Badge
key={option.value}
variant={isSelected ? "default" : "outline"}
className={cn(
"cursor-pointer transition-colors",
disabled && "opacity-50 cursor-not-allowed"
)}
onClick={() => !disabled && handleToggle(option.value)}
>
{option.label}
{isSelected && <X className="ml-1 h-3 w-3" />}
</Badge>
);
})}
</div>
);
});
TagSelect.displayName = "TagSelect";
/**
* 토글 선택 컴포넌트 (Boolean용)
*/
const ToggleSelect = forwardRef<HTMLDivElement, {
options: SelectOption[];
value?: string;
onChange?: (value: string) => void;
disabled?: boolean;
className?: string;
}>(({ options, value, onChange, disabled, className }, ref) => {
// 토글은 2개 옵션만 지원
const [offOption, onOption] = options.length >= 2
? [options[0], options[1]]
: [{ value: "false", label: "아니오" }, { value: "true", label: "예" }];
const isOn = value === onOption.value;
return (
<div ref={ref} className={cn("flex items-center gap-3", className)}>
<span className={cn("text-sm", !isOn && "font-medium")}>{offOption.label}</span>
<Switch
checked={isOn}
onCheckedChange={(checked) => onChange?.(checked ? onOption.value : offOption.value)}
disabled={disabled}
/>
<span className={cn("text-sm", isOn && "font-medium")}>{onOption.label}</span>
</div>
);
});
ToggleSelect.displayName = "ToggleSelect";
/**
* 스왑 선택 컴포넌트 (좌우 이동 방식)
*/
const SwapSelect = forwardRef<HTMLDivElement, {
options: SelectOption[];
value?: string[];
onChange?: (value: string[]) => void;
maxSelect?: number;
disabled?: boolean;
className?: string;
}>(({ options, value = [], onChange, disabled, className }, ref) => {
const available = useMemo(() =>
options.filter((o) => !value.includes(o.value)),
[options, value]
);
const selected = useMemo(() =>
options.filter((o) => value.includes(o.value)),
[options, value]
);
const handleMoveRight = useCallback((optionValue: string) => {
onChange?.([...value, optionValue]);
}, [value, onChange]);
const handleMoveLeft = useCallback((optionValue: string) => {
onChange?.(value.filter((v) => v !== optionValue));
}, [value, onChange]);
const handleMoveAllRight = useCallback(() => {
onChange?.(options.map((o) => o.value));
}, [options, onChange]);
const handleMoveAllLeft = useCallback(() => {
onChange?.([]);
}, [onChange]);
return (
<div ref={ref} className={cn("flex gap-2 items-stretch", className)}>
{/* 왼쪽: 선택 가능 */}
<div className="flex-1 border rounded-md">
<div className="p-2 bg-muted text-xs font-medium border-b"> </div>
<div className="p-2 space-y-1 max-h-40 overflow-y-auto">
{available.map((option) => (
<div
key={option.value}
className={cn(
"p-2 text-sm rounded cursor-pointer hover:bg-accent",
disabled && "opacity-50 cursor-not-allowed"
)}
onClick={() => !disabled && handleMoveRight(option.value)}
>
{option.label}
</div>
))}
{available.length === 0 && (
<div className="text-xs text-muted-foreground p-2"> </div>
)}
</div>
</div>
{/* 중앙: 이동 버튼 */}
<div className="flex flex-col gap-1 justify-center">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={handleMoveAllRight}
disabled={disabled || available.length === 0}
>
<ArrowLeftRight className="h-4 w-4 rotate-180" />
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={handleMoveAllLeft}
disabled={disabled || selected.length === 0}
>
<ArrowLeftRight className="h-4 w-4" />
</Button>
</div>
{/* 오른쪽: 선택됨 */}
<div className="flex-1 border rounded-md">
<div className="p-2 bg-primary/10 text-xs font-medium border-b"></div>
<div className="p-2 space-y-1 max-h-40 overflow-y-auto">
{selected.map((option) => (
<div
key={option.value}
className={cn(
"p-2 text-sm rounded cursor-pointer hover:bg-accent flex justify-between items-center",
disabled && "opacity-50 cursor-not-allowed"
)}
onClick={() => !disabled && handleMoveLeft(option.value)}
>
<span>{option.label}</span>
<X className="h-3 w-3 opacity-50" />
</div>
))}
{selected.length === 0 && (
<div className="text-xs text-muted-foreground p-2"> </div>
)}
</div>
</div>
</div>
);
});
SwapSelect.displayName = "SwapSelect";
/**
* 메인 UnifiedSelect 컴포넌트
*/
export const UnifiedSelect = forwardRef<HTMLDivElement, UnifiedSelectProps>(
(props, ref) => {
const {
id,
label,
required,
readonly,
disabled,
style,
size,
config: configProp,
value,
onChange,
tableName,
columnName,
} = props;
// config가 없으면 기본값 사용
const config = configProp || { mode: "dropdown" as const, source: "static" as const, options: [] };
const [options, setOptions] = useState<SelectOption[]>(config.options || []);
const [loading, setLoading] = useState(false);
const [optionsLoaded, setOptionsLoaded] = useState(false);
// 옵션 로딩에 필요한 값들만 추출 (객체 참조 대신 원시값 사용)
const rawSource = config.source;
const categoryTable = (config as any).categoryTable;
const categoryColumn = (config as any).categoryColumn;
// category 소스 유지 (category_values_test 테이블에서 로드)
const source = rawSource;
const codeGroup = config.codeGroup;
const entityTable = config.entityTable;
const entityValueColumn = config.entityValueColumn || config.entityValueField;
const entityLabelColumn = config.entityLabelColumn || config.entityLabelField;
const table = config.table;
const valueColumn = config.valueColumn;
const labelColumn = config.labelColumn;
const apiEndpoint = config.apiEndpoint;
const staticOptions = config.options;
// 계층 코드 연쇄 선택 관련
const hierarchical = config.hierarchical;
const parentField = config.parentField;
// FormContext에서 부모 필드 값 가져오기 (Context가 없으면 null)
const formContext = useContext(UnifiedFormContext);
// 부모 필드의 값 계산
const parentValue = useMemo(() => {
if (!hierarchical || !parentField) return null;
// FormContext가 있으면 거기서 값 가져오기
if (formContext) {
const val = formContext.getValue(parentField);
return val as string | null;
}
return null;
}, [hierarchical, parentField, formContext]);
// 데이터 소스에 따른 옵션 로딩 (원시값 의존성만 사용)
useEffect(() => {
// 계층 구조인 경우 부모 값이 변경되면 다시 로드
if (hierarchical && source === "code") {
setOptionsLoaded(false);
}
}, [parentValue, hierarchical, source]);
useEffect(() => {
// 이미 로드된 경우 스킵 (static 제외, 계층 구조 제외)
if (optionsLoaded && source !== "static") {
return;
}
const loadOptions = async () => {
if (source === "static") {
setOptions(staticOptions || []);
setOptionsLoaded(true);
return;
}
setLoading(true);
try {
let fetchedOptions: SelectOption[] = [];
if (source === "code" && codeGroup) {
// 계층 구조 사용 시 자식 코드만 로드
if (hierarchical) {
const params = new URLSearchParams();
if (parentValue) {
params.append("parentCodeValue", parentValue);
}
const queryString = params.toString();
const url = `/common-codes/categories/${codeGroup}/children${queryString ? `?${queryString}` : ""}`;
const response = await apiClient.get(url);
const data = response.data;
if (data.success && data.data) {
fetchedOptions = data.data.map((item: { value: string; label: string; hasChildren: boolean }) => ({
value: item.value,
label: item.label,
}));
}
} else {
// 일반 공통코드에서 로드 (올바른 API 경로: /common-codes/categories/:categoryCode/options)
const response = await apiClient.get(`/common-codes/categories/${codeGroup}/options`);
const data = response.data;
if (data.success && data.data) {
fetchedOptions = data.data.map((item: { value: string; label: string }) => ({
value: item.value,
label: item.label,
}));
}
}
} else if (source === "db" && table) {
// DB 테이블에서 로드
const response = await apiClient.get(`/entity/${table}/options`, {
params: {
value: valueColumn || "id",
label: labelColumn || "name",
},
});
const data = response.data;
if (data.success && data.data) {
fetchedOptions = data.data;
}
} else if (source === "entity" && entityTable) {
// 엔티티(참조 테이블)에서 로드
const valueCol = entityValueColumn || "id";
const labelCol = entityLabelColumn || "name";
const response = await apiClient.get(`/entity/${entityTable}/options`, {
params: {
value: valueCol,
label: labelCol,
},
});
const data = response.data;
if (data.success && data.data) {
fetchedOptions = data.data;
}
} else if (source === "api" && apiEndpoint) {
// 외부 API에서 로드
const response = await apiClient.get(apiEndpoint);
const data = response.data;
if (Array.isArray(data)) {
fetchedOptions = data;
}
} else if (source === "category") {
// 카테고리에서 로드 (category_values_test 테이블)
// tableName, columnName은 props에서 가져옴
const catTable = categoryTable || tableName;
const catColumn = categoryColumn || columnName;
if (catTable && catColumn) {
const response = await apiClient.get(`/table-categories/${catTable}/${catColumn}/values`);
const data = response.data;
if (data.success && data.data) {
// 트리 구조를 평탄화하여 옵션으로 변환
// value로 valueId를 사용하여 채번 규칙 매핑과 일치하도록 함
const flattenTree = (items: { valueId: number; valueCode: string; valueLabel: string; children?: any[] }[], depth: number = 0): SelectOption[] => {
const result: SelectOption[] = [];
for (const item of items) {
const prefix = depth > 0 ? " ".repeat(depth) + "└ " : "";
result.push({
value: String(item.valueId), // valueId를 value로 사용 (채번 매핑과 일치)
label: prefix + item.valueLabel,
});
if (item.children && item.children.length > 0) {
result.push(...flattenTree(item.children, depth + 1));
}
}
return result;
};
fetchedOptions = flattenTree(data.data);
}
}
} else if (source === "select" || source === "distinct") {
// 해당 테이블의 해당 컬럼에서 DISTINCT 값 조회
// tableName, columnName은 props에서 가져옴
// 🆕 columnName이 컴포넌트 ID 형식(comp_xxx)이면 유효하지 않으므로 건너뜀
const isValidColumnName = columnName && !columnName.startsWith("comp_");
if (tableName && isValidColumnName) {
const response = await apiClient.get(`/entity/${tableName}/distinct/${columnName}`);
const data = response.data;
if (data.success && data.data) {
fetchedOptions = data.data.map((item: { value: string; label: string }) => ({
value: String(item.value),
label: String(item.label),
}));
}
} else if (!isValidColumnName) {
// columnName이 없거나 유효하지 않으면 빈 옵션
console.warn("UnifiedSelect: 유효한 columnName이 없어 옵션을 로드하지 않습니다.", { tableName, columnName });
}
}
setOptions(fetchedOptions);
setOptionsLoaded(true);
} catch (error) {
console.error("옵션 로딩 실패:", error);
setOptions([]);
} finally {
setLoading(false);
}
};
loadOptions();
}, [source, entityTable, entityValueColumn, entityLabelColumn, codeGroup, table, valueColumn, labelColumn, apiEndpoint, staticOptions, optionsLoaded, hierarchical, parentValue]);
// 모드별 컴포넌트 렌더링
const renderSelect = () => {
if (loading) {
return <div className="h-10 flex items-center text-sm text-muted-foreground"> ...</div>;
}
const isDisabled = disabled || readonly;
switch (config.mode) {
case "dropdown":
return (
<DropdownSelect
options={options}
value={value}
onChange={onChange}
placeholder="선택"
searchable={config.searchable}
multiple={config.multiple}
maxSelect={config.maxSelect}
allowClear={config.allowClear}
disabled={isDisabled}
/>
);
case "radio":
return (
<RadioSelect
options={options}
value={typeof value === "string" ? value : value?.[0]}
onChange={(v) => onChange?.(v)}
disabled={isDisabled}
/>
);
case "check":
return (
<CheckSelect
options={options}
value={Array.isArray(value) ? value : value ? [value] : []}
onChange={onChange}
maxSelect={config.maxSelect}
disabled={isDisabled}
/>
);
case "tag":
return (
<TagSelect
options={options}
value={Array.isArray(value) ? value : value ? [value] : []}
onChange={onChange}
maxSelect={config.maxSelect}
disabled={isDisabled}
/>
);
case "toggle":
return (
<ToggleSelect
options={options}
value={typeof value === "string" ? value : value?.[0]}
onChange={(v) => onChange?.(v)}
disabled={isDisabled}
/>
);
case "swap":
return (
<SwapSelect
options={options}
value={Array.isArray(value) ? value : value ? [value] : []}
onChange={onChange}
maxSelect={config.maxSelect}
disabled={isDisabled}
/>
);
default:
return (
<DropdownSelect
options={options}
value={value}
onChange={onChange}
disabled={isDisabled}
/>
);
}
};
const showLabel = label && style?.labelDisplay !== false;
const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height;
return (
<div
ref={ref}
id={id}
className="flex flex-col"
style={{
width: componentWidth,
height: componentHeight,
}}
>
{showLabel && (
<Label
htmlFor={id}
style={{
fontSize: style?.labelFontSize,
color: style?.labelColor,
fontWeight: style?.labelFontWeight,
marginBottom: style?.labelMarginBottom,
}}
className="text-sm font-medium flex-shrink-0"
>
{label}
{required && <span className="text-orange-500 ml-0.5">*</span>}
</Label>
)}
<div className="flex-1 min-h-0">
{renderSelect()}
</div>
</div>
);
}
);
UnifiedSelect.displayName = "UnifiedSelect";
export default UnifiedSelect;
@@ -1,458 +0,0 @@
"use client";
/**
* UnifiedBiz 설정 패널
* 통합 비즈니스 컴포넌트의 세부 설정을 관리합니다.
*/
import React, { useState, useEffect } 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 { Separator } from "@/components/ui/separator";
import { Checkbox } from "@/components/ui/checkbox";
import { tableTypeApi } from "@/lib/api/screen";
interface UnifiedBizConfigPanelProps {
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
}
interface TableOption {
tableName: string;
displayName: string;
}
interface ColumnOption {
columnName: string;
displayName: string;
}
export const UnifiedBizConfigPanel: React.FC<UnifiedBizConfigPanelProps> = ({
config,
onChange,
}) => {
// 테이블 목록
const [tables, setTables] = useState<TableOption[]>([]);
const [loadingTables, setLoadingTables] = useState(false);
// 컬럼 목록 (소스/대상/관련 테이블용)
const [sourceColumns, setSourceColumns] = useState<ColumnOption[]>([]);
const [targetColumns, setTargetColumns] = useState<ColumnOption[]>([]);
const [relatedColumns, setRelatedColumns] = useState<ColumnOption[]>([]);
const [categoryColumns, setCategoryColumns] = useState<ColumnOption[]>([]);
const [loadingColumns, setLoadingColumns] = useState(false);
// 설정 업데이트 핸들러
const updateConfig = (field: string, value: any) => {
onChange({ ...config, [field]: value });
};
// 테이블 목록 로드
useEffect(() => {
const loadTables = async () => {
setLoadingTables(true);
try {
const data = await tableTypeApi.getTables();
setTables(data.map(t => ({
tableName: t.tableName,
displayName: t.displayName || t.tableName
})));
} catch (error) {
console.error("테이블 목록 로드 실패:", error);
} finally {
setLoadingTables(false);
}
};
loadTables();
}, []);
// 소스 테이블 선택 시 컬럼 목록 로드
useEffect(() => {
const loadColumns = async () => {
if (!config.sourceTable) {
setSourceColumns([]);
return;
}
try {
const data = await tableTypeApi.getColumns(config.sourceTable);
setSourceColumns(data.map((c: any) => ({
columnName: c.columnName || c.column_name,
displayName: c.displayName || c.columnName || c.column_name
})));
} catch (error) {
console.error("소스 컬럼 로드 실패:", error);
}
};
loadColumns();
}, [config.sourceTable]);
// 대상 테이블 선택 시 컬럼 목록 로드
useEffect(() => {
const loadColumns = async () => {
if (!config.targetTable) {
setTargetColumns([]);
return;
}
try {
const data = await tableTypeApi.getColumns(config.targetTable);
setTargetColumns(data.map((c: any) => ({
columnName: c.columnName || c.column_name,
displayName: c.displayName || c.columnName || c.column_name
})));
} catch (error) {
console.error("대상 컬럼 로드 실패:", error);
}
};
loadColumns();
}, [config.targetTable]);
// 관련 테이블 선택 시 컬럼 목록 로드
useEffect(() => {
const loadColumns = async () => {
if (!config.relatedTable) {
setRelatedColumns([]);
return;
}
try {
const data = await tableTypeApi.getColumns(config.relatedTable);
setRelatedColumns(data.map((c: any) => ({
columnName: c.columnName || c.column_name,
displayName: c.displayName || c.columnName || c.column_name
})));
} catch (error) {
console.error("관련 컬럼 로드 실패:", error);
}
};
loadColumns();
}, [config.relatedTable]);
// 카테고리 테이블 선택 시 컬럼 목록 로드
useEffect(() => {
const loadColumns = async () => {
if (!config.tableName) {
setCategoryColumns([]);
return;
}
setLoadingColumns(true);
try {
const data = await tableTypeApi.getColumns(config.tableName);
setCategoryColumns(data.map((c: any) => ({
columnName: c.columnName || c.column_name,
displayName: c.displayName || c.columnName || c.column_name
})));
} catch (error) {
console.error("카테고리 컬럼 로드 실패:", error);
} finally {
setLoadingColumns(false);
}
};
loadColumns();
}, [config.tableName]);
return (
<div className="space-y-4">
{/* 비즈니스 타입 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.bizType || config.type || "flow"}
onValueChange={(value) => updateConfig("bizType", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="타입 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="flow"></SelectItem>
<SelectItem value="rack"> </SelectItem>
<SelectItem value="map"></SelectItem>
<SelectItem value="numbering"> </SelectItem>
<SelectItem value="category"></SelectItem>
<SelectItem value="data-mapping"> </SelectItem>
<SelectItem value="related-data"> </SelectItem>
</SelectContent>
</Select>
</div>
<Separator />
{/* 플로우 설정 */}
{config.bizType === "flow" && (
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> ID</Label>
<Input
type="number"
value={config.flowId || ""}
onChange={(e) => updateConfig("flowId", e.target.value ? Number(e.target.value) : undefined)}
placeholder="플로우 ID"
className="h-8 text-xs"
/>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="editable"
checked={config.editable || false}
onCheckedChange={(checked) => updateConfig("editable", checked)}
/>
<label htmlFor="editable" className="text-xs"> </label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="showMinimap"
checked={config.showMinimap || false}
onCheckedChange={(checked) => updateConfig("showMinimap", checked)}
/>
<label htmlFor="showMinimap" className="text-xs"> </label>
</div>
</div>
)}
{/* 랙 구조 설정 */}
{config.bizType === "rack" && (
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Input
type="number"
value={config.rows || ""}
onChange={(e) => updateConfig("rows", e.target.value ? Number(e.target.value) : undefined)}
placeholder="5"
min="1"
className="h-8 text-xs"
/>
</div>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Input
type="number"
value={config.columns || ""}
onChange={(e) => updateConfig("columns", e.target.value ? Number(e.target.value) : undefined)}
placeholder="10"
min="1"
className="h-8 text-xs"
/>
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="showLabels"
checked={config.showLabels !== false}
onCheckedChange={(checked) => updateConfig("showLabels", checked)}
/>
<label htmlFor="showLabels" className="text-xs"> </label>
</div>
</div>
)}
{/* 채번 규칙 설정 */}
{config.bizType === "numbering" && (
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> ID</Label>
<Input
type="number"
value={config.ruleId || ""}
onChange={(e) => updateConfig("ruleId", e.target.value ? Number(e.target.value) : undefined)}
placeholder="규칙 ID"
className="h-8 text-xs"
/>
</div>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"></Label>
<Input
value={config.prefix || ""}
onChange={(e) => updateConfig("prefix", e.target.value)}
placeholder="예: INV-"
className="h-8 text-xs"
/>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="autoGenerate"
checked={config.autoGenerate !== false}
onCheckedChange={(checked) => updateConfig("autoGenerate", checked)}
/>
<label htmlFor="autoGenerate" className="text-xs"> </label>
</div>
</div>
)}
{/* 카테고리 설정 */}
{config.bizType === "category" && (
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Select
value={config.tableName || ""}
onValueChange={(value) => {
updateConfig("tableName", value);
updateConfig("columnName", "");
}}
disabled={loadingTables}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={loadingTables ? "로딩 중..." : "테이블 선택"} />
</SelectTrigger>
<SelectContent>
{tables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{config.tableName && (
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"></Label>
<Select
value={config.columnName || ""}
onValueChange={(value) => updateConfig("columnName", value)}
disabled={loadingColumns}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={loadingColumns ? "로딩 중..." : "컬럼 선택"} />
</SelectTrigger>
<SelectContent>
{categoryColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
)}
{/* 데이터 매핑 설정 */}
{config.bizType === "data-mapping" && (
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Select
value={config.sourceTable || ""}
onValueChange={(value) => updateConfig("sourceTable", value)}
disabled={loadingTables}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={loadingTables ? "로딩 중..." : "테이블 선택"} />
</SelectTrigger>
<SelectContent>
{tables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Select
value={config.targetTable || ""}
onValueChange={(value) => updateConfig("targetTable", value)}
disabled={loadingTables}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={loadingTables ? "로딩 중..." : "테이블 선택"} />
</SelectTrigger>
<SelectContent>
{tables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
{/* 관련 데이터 설정 */}
{config.bizType === "related-data" && (
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Select
value={config.relatedTable || ""}
onValueChange={(value) => {
updateConfig("relatedTable", value);
updateConfig("linkColumn", "");
}}
disabled={loadingTables}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={loadingTables ? "로딩 중..." : "테이블 선택"} />
</SelectTrigger>
<SelectContent>
{tables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{config.relatedTable && (
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Select
value={config.linkColumn || ""}
onValueChange={(value) => updateConfig("linkColumn", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{relatedColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Input
value={config.buttonText || ""}
onChange={(e) => updateConfig("buttonText", e.target.value)}
placeholder="관련 데이터 보기"
className="h-8 text-xs"
/>
</div>
</div>
)}
</div>
);
};
UnifiedBizConfigPanel.displayName = "UnifiedBizConfigPanel";
export default UnifiedBizConfigPanel;
@@ -1,149 +0,0 @@
"use client";
/**
* UnifiedDate 설정 패널
* 통합 날짜 컴포넌트의 세부 설정을 관리합니다.
*/
import React 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 { Separator } from "@/components/ui/separator";
import { Checkbox } from "@/components/ui/checkbox";
interface UnifiedDateConfigPanelProps {
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
}
export const UnifiedDateConfigPanel: React.FC<UnifiedDateConfigPanelProps> = ({
config,
onChange,
}) => {
// 설정 업데이트 핸들러
const updateConfig = (field: string, value: any) => {
onChange({ ...config, [field]: value });
};
return (
<div className="space-y-4">
{/* 날짜 타입 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.dateType || config.type || "date"}
onValueChange={(value) => updateConfig("dateType", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="타입 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="date"></SelectItem>
<SelectItem value="time"></SelectItem>
<SelectItem value="datetime">+</SelectItem>
</SelectContent>
</Select>
</div>
<Separator />
{/* 표시 형식 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.format || "YYYY-MM-DD"}
onValueChange={(value) => updateConfig("format", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="형식 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="YYYY-MM-DD">YYYY-MM-DD</SelectItem>
<SelectItem value="YYYY/MM/DD">YYYY/MM/DD</SelectItem>
<SelectItem value="DD/MM/YYYY">DD/MM/YYYY</SelectItem>
<SelectItem value="MM/DD/YYYY">MM/DD/YYYY</SelectItem>
<SelectItem value="YYYY년 MM월 DD일">YYYY년 MM월 DD일</SelectItem>
{(config.dateType === "time" || config.dateType === "datetime") && (
<>
<SelectItem value="HH:mm">HH:mm</SelectItem>
<SelectItem value="HH:mm:ss">HH:mm:ss</SelectItem>
<SelectItem value="YYYY-MM-DD HH:mm">YYYY-MM-DD HH:mm</SelectItem>
<SelectItem value="YYYY-MM-DD HH:mm:ss">YYYY-MM-DD HH:mm:ss</SelectItem>
</>
)}
</SelectContent>
</Select>
</div>
<Separator />
{/* 날짜 범위 제한 */}
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Input
type="date"
value={config.minDate || ""}
onChange={(e) => updateConfig("minDate", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Input
type="date"
value={config.maxDate || ""}
onChange={(e) => updateConfig("maxDate", e.target.value)}
className="h-8 text-xs"
/>
</div>
</div>
</div>
<Separator />
{/* 추가 옵션 */}
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="flex items-center space-x-2">
<Checkbox
id="range"
checked={config.range || false}
onCheckedChange={(checked) => updateConfig("range", checked)}
/>
<label htmlFor="range" className="text-xs"> (~)</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="showToday"
checked={config.showToday !== false}
onCheckedChange={(checked) => updateConfig("showToday", checked)}
/>
<label htmlFor="showToday" className="text-xs"> </label>
</div>
{(config.dateType === "datetime" || config.dateType === "time") && (
<div className="flex items-center space-x-2">
<Checkbox
id="showSeconds"
checked={config.showSeconds || false}
onCheckedChange={(checked) => updateConfig("showSeconds", checked)}
/>
<label htmlFor="showSeconds" className="text-xs"> </label>
</div>
)}
</div>
</div>
);
};
UnifiedDateConfigPanel.displayName = "UnifiedDateConfigPanel";
export default UnifiedDateConfigPanel;
@@ -1,222 +0,0 @@
"use client";
/**
* UnifiedGroup 설정 패널
* 통합 그룹 컴포넌트의 세부 설정을 관리합니다.
*/
import React 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 { Separator } from "@/components/ui/separator";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { Plus, Trash2 } from "lucide-react";
interface UnifiedGroupConfigPanelProps {
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
}
export const UnifiedGroupConfigPanel: React.FC<UnifiedGroupConfigPanelProps> = ({
config,
onChange,
}) => {
// 설정 업데이트 핸들러
const updateConfig = (field: string, value: any) => {
onChange({ ...config, [field]: value });
};
// 탭 관리
const tabs = config.tabs || [];
const addTab = () => {
const newTabs = [...tabs, { id: `tab-${Date.now()}`, label: "새 탭", content: "" }];
updateConfig("tabs", newTabs);
};
const updateTab = (index: number, field: string, value: string) => {
const newTabs = [...tabs];
newTabs[index] = { ...newTabs[index], [field]: value };
updateConfig("tabs", newTabs);
};
const removeTab = (index: number) => {
const newTabs = tabs.filter((_: any, i: number) => i !== index);
updateConfig("tabs", newTabs);
};
return (
<div className="space-y-4">
{/* 그룹 타입 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.groupType || config.type || "section"}
onValueChange={(value) => updateConfig("groupType", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="타입 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="section"></SelectItem>
<SelectItem value="tabs"></SelectItem>
<SelectItem value="accordion"></SelectItem>
<SelectItem value="card"> </SelectItem>
<SelectItem value="modal"></SelectItem>
<SelectItem value="form-modal"> </SelectItem>
</SelectContent>
</Select>
</div>
<Separator />
{/* 제목 */}
<div className="space-y-2">
<Label className="text-xs font-medium"></Label>
<Input
value={config.title || ""}
onChange={(e) => updateConfig("title", e.target.value)}
placeholder="그룹 제목"
className="h-8 text-xs"
/>
</div>
{/* 탭 설정 */}
{config.groupType === "tabs" && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium"> </Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={addTab}
className="h-6 px-2 text-xs"
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-2 max-h-40 overflow-y-auto">
{tabs.map((tab: any, index: number) => (
<div key={index} className="flex items-center gap-2">
<Input
value={tab.id || ""}
onChange={(e) => updateTab(index, "id", e.target.value)}
placeholder="ID"
className="h-7 text-xs flex-1"
/>
<Input
value={tab.label || ""}
onChange={(e) => updateTab(index, "label", e.target.value)}
placeholder="라벨"
className="h-7 text-xs flex-1"
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeTab(index)}
className="h-7 w-7 p-0 text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
{tabs.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-2">
</p>
)}
</div>
</div>
)}
{/* 섹션/아코디언 옵션 */}
{(config.groupType === "section" || config.groupType === "accordion" || !config.groupType) && (
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="collapsible"
checked={config.collapsible || false}
onCheckedChange={(checked) => updateConfig("collapsible", checked)}
/>
<label htmlFor="collapsible" className="text-xs">/ </label>
</div>
{config.collapsible && (
<div className="flex items-center space-x-2">
<Checkbox
id="defaultOpen"
checked={config.defaultOpen !== false}
onCheckedChange={(checked) => updateConfig("defaultOpen", checked)}
/>
<label htmlFor="defaultOpen" className="text-xs"> </label>
</div>
)}
</div>
)}
{/* 모달 옵션 */}
{(config.groupType === "modal" || config.groupType === "form-modal") && (
<div className="space-y-3">
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.modalSize || "md"}
onValueChange={(value) => updateConfig("modalSize", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sm"> (400px)</SelectItem>
<SelectItem value="md"> (600px)</SelectItem>
<SelectItem value="lg"> (800px)</SelectItem>
<SelectItem value="xl"> (1000px)</SelectItem>
<SelectItem value="full"> </SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="closeable"
checked={config.closeable !== false}
onCheckedChange={(checked) => updateConfig("closeable", checked)}
/>
<label htmlFor="closeable" className="text-xs"> </label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="backdrop"
checked={config.backdrop !== false}
onCheckedChange={(checked) => updateConfig("backdrop", checked)}
/>
<label htmlFor="backdrop" className="text-xs"> </label>
</div>
</div>
)}
{/* 헤더 표시 여부 */}
<Separator />
<div className="flex items-center space-x-2">
<Checkbox
id="showHeader"
checked={config.showHeader !== false}
onCheckedChange={(checked) => updateConfig("showHeader", checked)}
/>
<label htmlFor="showHeader" className="text-xs"> </label>
</div>
</div>
);
};
UnifiedGroupConfigPanel.displayName = "UnifiedGroupConfigPanel";
export default UnifiedGroupConfigPanel;
@@ -1,410 +0,0 @@
"use client";
/**
* UnifiedHierarchy 설정 패널
* 통합 계층 컴포넌트의 세부 설정을 관리합니다.
*/
import React, { useState, useEffect } 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 { Separator } from "@/components/ui/separator";
import { Checkbox } from "@/components/ui/checkbox";
import { tableTypeApi } from "@/lib/api/screen";
interface UnifiedHierarchyConfigPanelProps {
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
}
interface TableOption {
tableName: string;
displayName: string;
}
interface ColumnOption {
columnName: string;
displayName: string;
}
export const UnifiedHierarchyConfigPanel: React.FC<UnifiedHierarchyConfigPanelProps> = ({
config,
onChange,
}) => {
// 테이블 목록
const [tables, setTables] = useState<TableOption[]>([]);
const [loadingTables, setLoadingTables] = useState(false);
// 컬럼 목록
const [columns, setColumns] = useState<ColumnOption[]>([]);
const [loadingColumns, setLoadingColumns] = useState(false);
// 설정 업데이트 핸들러
const updateConfig = (field: string, value: any) => {
onChange({ ...config, [field]: value });
};
// 테이블 목록 로드
useEffect(() => {
const loadTables = async () => {
setLoadingTables(true);
try {
const data = await tableTypeApi.getTables();
setTables(data.map(t => ({
tableName: t.tableName,
displayName: t.displayName || t.tableName
})));
} catch (error) {
console.error("테이블 목록 로드 실패:", error);
} finally {
setLoadingTables(false);
}
};
loadTables();
}, []);
// 테이블 선택 시 컬럼 목록 로드
useEffect(() => {
const loadColumns = async () => {
if (!config.tableName) {
setColumns([]);
return;
}
setLoadingColumns(true);
try {
const data = await tableTypeApi.getColumns(config.tableName);
setColumns(data.map((c: any) => ({
columnName: c.columnName || c.column_name,
displayName: c.displayName || c.columnName || c.column_name
})));
} catch (error) {
console.error("컬럼 목록 로드 실패:", error);
} finally {
setLoadingColumns(false);
}
};
loadColumns();
}, [config.tableName]);
return (
<div className="space-y-4">
{/* 계층 타입 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.hierarchyType || config.type || "tree"}
onValueChange={(value) => updateConfig("hierarchyType", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="타입 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="tree"></SelectItem>
<SelectItem value="org-chart"></SelectItem>
<SelectItem value="bom">BOM (Bill of Materials)</SelectItem>
<SelectItem value="cascading"> </SelectItem>
</SelectContent>
</Select>
</div>
<Separator />
{/* 뷰 모드 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.viewMode || "tree"}
onValueChange={(value) => updateConfig("viewMode", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="방식 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="tree"></SelectItem>
<SelectItem value="table"></SelectItem>
<SelectItem value="chart"></SelectItem>
<SelectItem value="cascading"> </SelectItem>
</SelectContent>
</Select>
</div>
<Separator />
{/* 데이터 소스 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.dataSource || "static"}
onValueChange={(value) => updateConfig("dataSource", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="소스 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="static"> </SelectItem>
<SelectItem value="db"></SelectItem>
<SelectItem value="api">API</SelectItem>
</SelectContent>
</Select>
</div>
{/* DB 설정 */}
{config.dataSource === "db" && (
<div className="space-y-3">
{/* 테이블 선택 */}
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"></Label>
<Select
value={config.tableName || ""}
onValueChange={(value) => {
updateConfig("tableName", value);
// 테이블 변경 시 컬럼 초기화
updateConfig("idColumn", "");
updateConfig("parentIdColumn", "");
updateConfig("labelColumn", "");
}}
disabled={loadingTables}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={loadingTables ? "로딩 중..." : "테이블 선택"} />
</SelectTrigger>
<SelectContent>
{tables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 컬럼 선택 */}
{config.tableName && (
<>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground">ID </Label>
<Select
value={config.idColumn || ""}
onValueChange={(value) => updateConfig("idColumn", value)}
disabled={loadingColumns}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={loadingColumns ? "로딩 중..." : "선택"} />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> ID </Label>
<Select
value={config.parentIdColumn || ""}
onValueChange={(value) => updateConfig("parentIdColumn", value)}
disabled={loadingColumns}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={loadingColumns ? "로딩 중..." : "선택"} />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Select
value={config.labelColumn || ""}
onValueChange={(value) => updateConfig("labelColumn", value)}
disabled={loadingColumns}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={loadingColumns ? "로딩 중..." : "선택"} />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
</div>
)}
{/* API 설정 */}
{config.dataSource === "api" && (
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground">API </Label>
<Input
value={config.apiEndpoint || ""}
onChange={(e) => updateConfig("apiEndpoint", e.target.value)}
placeholder="/api/hierarchy"
className="h-8 text-xs"
/>
</div>
)}
<Separator />
{/* 옵션 */}
<div className="space-y-3">
<Label className="text-xs font-medium"></Label>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Input
type="number"
value={config.maxLevel || ""}
onChange={(e) => updateConfig("maxLevel", e.target.value ? Number(e.target.value) : undefined)}
placeholder="제한 없음"
min="1"
className="h-8 text-xs"
/>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="draggable"
checked={config.draggable || false}
onCheckedChange={(checked) => updateConfig("draggable", checked)}
/>
<label htmlFor="draggable" className="text-xs"> </label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="selectable"
checked={config.selectable !== false}
onCheckedChange={(checked) => updateConfig("selectable", checked)}
/>
<label htmlFor="selectable" className="text-xs"> </label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="multiSelect"
checked={config.multiSelect || false}
onCheckedChange={(checked) => updateConfig("multiSelect", checked)}
/>
<label htmlFor="multiSelect" className="text-xs"> </label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="showCheckbox"
checked={config.showCheckbox || false}
onCheckedChange={(checked) => updateConfig("showCheckbox", checked)}
/>
<label htmlFor="showCheckbox" className="text-xs"> </label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="expandAll"
checked={config.expandAll || false}
onCheckedChange={(checked) => updateConfig("expandAll", checked)}
/>
<label htmlFor="expandAll" className="text-xs"> </label>
</div>
</div>
{/* BOM 전용 설정 */}
{config.hierarchyType === "bom" && (
<>
<Separator />
<div className="space-y-3">
<Label className="text-xs font-medium">BOM </Label>
<div className="flex items-center space-x-2">
<Checkbox
id="showQuantity"
checked={config.showQuantity !== false}
onCheckedChange={(checked) => updateConfig("showQuantity", checked)}
/>
<label htmlFor="showQuantity" className="text-xs"> </label>
</div>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Select
value={config.quantityColumn || ""}
onValueChange={(value) => updateConfig("quantityColumn", value)}
disabled={loadingColumns || !config.tableName}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</>
)}
{/* 연쇄 선택박스 전용 설정 */}
{config.hierarchyType === "cascading" && (
<>
<Separator />
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Select
value={config.parentField || ""}
onValueChange={(value) => updateConfig("parentField", value)}
disabled={loadingColumns || !config.tableName}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="clearOnParentChange"
checked={config.clearOnParentChange !== false}
onCheckedChange={(checked) => updateConfig("clearOnParentChange", checked)}
/>
<label htmlFor="clearOnParentChange" className="text-xs"> </label>
</div>
</div>
</>
)}
</div>
);
};
UnifiedHierarchyConfigPanel.displayName = "UnifiedHierarchyConfigPanel";
export default UnifiedHierarchyConfigPanel;
@@ -1,489 +0,0 @@
"use client";
/**
* UnifiedInput 설정 패널
* 통합 입력 컴포넌트의 세부 설정을 관리합니다.
*/
import React, { useState, useEffect } 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 { Separator } from "@/components/ui/separator";
import { Checkbox } from "@/components/ui/checkbox";
import { AutoGenerationType, AutoGenerationConfig } from "@/types/screen";
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
import { getAvailableNumberingRules } from "@/lib/api/numberingRule";
import { NumberingRuleConfig } from "@/types/numbering-rule";
interface UnifiedInputConfigPanelProps {
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
menuObjid?: number; // 메뉴 OBJID (채번 규칙 필터링용)
}
export const UnifiedInputConfigPanel: React.FC<UnifiedInputConfigPanelProps> = ({ config, onChange, menuObjid }) => {
// 채번 규칙 목록 상태
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
const [loadingRules, setLoadingRules] = useState(false);
// 부모 메뉴 목록 상태 (채번규칙 사용을 위한 선택)
const [parentMenus, setParentMenus] = useState<any[]>([]);
const [loadingMenus, setLoadingMenus] = useState(false);
// 선택된 메뉴 OBJID
const [selectedMenuObjid, setSelectedMenuObjid] = useState<number | undefined>(() => {
return config.autoGeneration?.selectedMenuObjid || menuObjid;
});
// 설정 업데이트 핸들러
const updateConfig = (field: string, value: any) => {
onChange({ ...config, [field]: value });
};
// 부모 메뉴 목록 로드 (사용자 메뉴의 레벨 2만)
useEffect(() => {
const loadMenus = async () => {
setLoadingMenus(true);
try {
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get("/admin/menus");
if (response.data.success && response.data.data) {
const allMenus = response.data.data;
// 사용자 메뉴(menu_type='1')의 레벨 2만 필터링
const level2UserMenus = allMenus.filter((menu: any) =>
menu.menu_type === '1' && menu.lev === 2
);
setParentMenus(level2UserMenus);
}
} catch (error) {
console.error("부모 메뉴 로드 실패:", error);
} finally {
setLoadingMenus(false);
}
};
loadMenus();
}, []);
// 채번 규칙 목록 로드 (선택된 메뉴 기준)
useEffect(() => {
const loadRules = async () => {
if (config.autoGeneration?.type !== "numbering_rule") {
return;
}
if (!selectedMenuObjid) {
setNumberingRules([]);
return;
}
setLoadingRules(true);
try {
const response = await getAvailableNumberingRules(selectedMenuObjid);
if (response.success && response.data) {
setNumberingRules(response.data);
}
} catch (error) {
console.error("채번 규칙 목록 로드 실패:", error);
setNumberingRules([]);
} finally {
setLoadingRules(false);
}
};
loadRules();
}, [selectedMenuObjid, config.autoGeneration?.type]);
return (
<div className="space-y-4">
{/* 입력 타입 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.inputType || config.type || "text"}
onValueChange={(value) => updateConfig("inputType", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="입력 타입 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="text"></SelectItem>
<SelectItem value="number"></SelectItem>
<SelectItem value="password"></SelectItem>
<SelectItem value="textarea"> </SelectItem>
<SelectItem value="slider"></SelectItem>
<SelectItem value="color"> </SelectItem>
<SelectItem value="numbering"> ()</SelectItem>
</SelectContent>
</Select>
</div>
{/* 채번 타입 전용 설정 */}
{config.inputType === "numbering" && (
<div className="space-y-3">
<Separator />
<div className="rounded-md border border-blue-200 bg-blue-50 p-3">
<p className="text-xs font-medium text-blue-800"> </p>
<p className="mt-1 text-[10px] text-blue-700">
<strong> </strong> .
<br />
.
</p>
</div>
{/* 채번 필드는 기본적으로 읽기전용 */}
<div className="flex items-center space-x-2">
<Checkbox
id="numberingReadonly"
checked={config.readonly !== false}
onCheckedChange={(checked) => {
updateConfig("readonly", checked);
}}
/>
<Label htmlFor="numberingReadonly" className="text-xs font-medium cursor-pointer">
()
</Label>
</div>
<p className="text-muted-foreground text-[10px] pl-6">
</p>
</div>
)}
{/* 채번 타입이 아닌 경우에만 추가 설정 표시 */}
{config.inputType !== "numbering" && (
<>
<Separator />
{/* 형식 (텍스트/숫자용) */}
{(config.inputType === "text" || !config.inputType) && (
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select value={config.format || "none"} onValueChange={(value) => updateConfig("format", value)}>
<SelectTrigger className="h-8 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>
)}
{/* 플레이스홀더 */}
<div className="space-y-2">
<Label className="text-xs font-medium"></Label>
<Input
value={config.placeholder || ""}
onChange={(e) => updateConfig("placeholder", e.target.value)}
placeholder="입력 안내 텍스트"
className="h-8 text-xs"
/>
</div>
{/* 숫자/슬라이더 전용 설정 */}
{(config.inputType === "number" || config.inputType === "slider") && (
<>
<Separator />
<div className="grid grid-cols-3 gap-2">
<div className="space-y-2">
<Label className="text-xs font-medium"></Label>
<Input
type="number"
value={config.min ?? ""}
onChange={(e) => updateConfig("min", e.target.value ? Number(e.target.value) : undefined)}
placeholder="0"
className="h-8 text-xs"
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium"></Label>
<Input
type="number"
value={config.max ?? ""}
onChange={(e) => updateConfig("max", e.target.value ? Number(e.target.value) : undefined)}
placeholder="100"
className="h-8 text-xs"
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium"></Label>
<Input
type="number"
value={config.step ?? ""}
onChange={(e) => updateConfig("step", e.target.value ? Number(e.target.value) : undefined)}
placeholder="1"
className="h-8 text-xs"
/>
</div>
</div>
</>
)}
{/* 여러 줄 텍스트 전용 설정 */}
{config.inputType === "textarea" && (
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Input
type="number"
value={config.rows || 3}
onChange={(e) => updateConfig("rows", parseInt(e.target.value) || 3)}
min={2}
max={20}
className="h-8 text-xs"
/>
</div>
)}
{/* 마스크 입력 (선택) */}
<div className="space-y-2">
<Label className="text-xs font-medium"> ()</Label>
<Input
value={config.mask || ""}
onChange={(e) => updateConfig("mask", e.target.value)}
placeholder="예: ###-####-####"
className="h-8 text-xs"
/>
<p className="text-muted-foreground text-[10px]"># = , A = , * = </p>
</div>
<Separator />
{/* 자동생성 기능 */}
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="autoGenerationEnabled"
checked={config.autoGeneration?.enabled || false}
onCheckedChange={(checked) => {
const currentConfig = config.autoGeneration || { type: "none", enabled: false };
updateConfig("autoGeneration", {
...currentConfig,
enabled: checked as boolean,
});
}}
/>
<Label htmlFor="autoGenerationEnabled" className="text-xs font-medium cursor-pointer">
</Label>
</div>
{/* 자동생성 타입 선택 */}
{config.autoGeneration?.enabled && (
<div className="space-y-3 pl-6">
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.autoGeneration?.type || "none"}
onValueChange={(value: AutoGenerationType) => {
const currentConfig = config.autoGeneration || { type: "none", enabled: false };
updateConfig("autoGeneration", {
...currentConfig,
type: value,
});
}}
>
<SelectTrigger className="h-8 text-xs">
<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="numbering_rule"> </SelectItem>
<SelectItem value="random_string"> </SelectItem>
<SelectItem value="random_number"> </SelectItem>
<SelectItem value="company_code"> </SelectItem>
<SelectItem value="department"> </SelectItem>
</SelectContent>
</Select>
{/* 선택된 타입 설명 */}
{config.autoGeneration?.type && config.autoGeneration.type !== "none" && (
<p className="text-muted-foreground text-[10px]">
{AutoGenerationUtils.getTypeDescription(config.autoGeneration.type)}
</p>
)}
</div>
{/* 채번 규칙 선택 */}
{config.autoGeneration?.type === "numbering_rule" && (
<>
{/* 부모 메뉴 선택 */}
<div className="space-y-2">
<Label className="text-xs font-medium">
<span className="text-destructive">*</span>
</Label>
<Select
value={selectedMenuObjid?.toString() || ""}
onValueChange={(value) => {
const menuId = parseInt(value);
setSelectedMenuObjid(menuId);
updateConfig("autoGeneration", {
...config.autoGeneration,
selectedMenuObjid: menuId,
});
}}
disabled={loadingMenus}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={loadingMenus ? "메뉴 로딩 중..." : "채번규칙을 사용할 메뉴 선택"} />
</SelectTrigger>
<SelectContent>
{parentMenus.length === 0 ? (
<SelectItem value="no-menus" disabled>
</SelectItem>
) : (
parentMenus.map((menu) => (
<SelectItem key={menu.objid} value={menu.objid.toString()}>
{menu.menu_name_kor}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
{/* 채번 규칙 선택 */}
{selectedMenuObjid ? (
<div className="space-y-2">
<Label className="text-xs font-medium">
<span className="text-destructive">*</span>
</Label>
<Select
value={config.autoGeneration?.options?.numberingRuleId || ""}
onValueChange={(value) => {
updateConfig("autoGeneration", {
...config.autoGeneration,
options: {
...config.autoGeneration?.options,
numberingRuleId: value,
},
});
}}
disabled={loadingRules}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={loadingRules ? "규칙 로딩 중..." : "채번 규칙 선택"} />
</SelectTrigger>
<SelectContent>
{numberingRules.length === 0 ? (
<SelectItem value="no-rules" disabled>
</SelectItem>
) : (
numberingRules.map((rule) => (
<SelectItem key={rule.ruleId} value={rule.ruleId}>
{rule.ruleName}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
) : (
<div className="rounded-md border border-amber-200 bg-amber-50 p-2 text-xs text-amber-800">
</div>
)}
</>
)}
{/* 자동생성 옵션 (랜덤/순차용) */}
{config.autoGeneration?.type &&
["random_string", "random_number", "sequence"].includes(config.autoGeneration.type) && (
<div className="space-y-2">
{/* 길이 설정 */}
{["random_string", "random_number"].includes(config.autoGeneration.type) && (
<div className="space-y-1">
<Label className="text-xs font-medium"></Label>
<Input
type="number"
min="1"
max="50"
value={config.autoGeneration?.options?.length || 8}
onChange={(e) => {
updateConfig("autoGeneration", {
...config.autoGeneration,
options: {
...config.autoGeneration?.options,
length: parseInt(e.target.value) || 8,
},
});
}}
className="h-8 text-xs"
/>
</div>
)}
{/* 접두사 */}
<div className="space-y-1">
<Label className="text-xs font-medium"></Label>
<Input
value={config.autoGeneration?.options?.prefix || ""}
onChange={(e) => {
updateConfig("autoGeneration", {
...config.autoGeneration,
options: {
...config.autoGeneration?.options,
prefix: e.target.value,
},
});
}}
placeholder="예: INV-"
className="h-8 text-xs"
/>
</div>
{/* 접미사 */}
<div className="space-y-1">
<Label className="text-xs font-medium"></Label>
<Input
value={config.autoGeneration?.options?.suffix || ""}
onChange={(e) => {
updateConfig("autoGeneration", {
...config.autoGeneration,
options: {
...config.autoGeneration?.options,
suffix: e.target.value,
},
});
}}
className="h-8 text-xs"
/>
</div>
{/* 미리보기 */}
<div className="space-y-1">
<Label className="text-xs font-medium"></Label>
<div className="rounded border bg-muted p-2 text-xs font-mono">
{AutoGenerationUtils.generatePreviewValue(config.autoGeneration)}
</div>
</div>
</div>
)}
</div>
)}
</div>
</>
)}
</div>
);
};
UnifiedInputConfigPanel.displayName = "UnifiedInputConfigPanel";
export default UnifiedInputConfigPanel;
@@ -1,256 +0,0 @@
"use client";
/**
* UnifiedLayout 설정 패널
* 통합 레이아웃 컴포넌트의 세부 설정을 관리합니다.
*/
import React 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 { Separator } from "@/components/ui/separator";
import { Checkbox } from "@/components/ui/checkbox";
interface UnifiedLayoutConfigPanelProps {
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
}
export const UnifiedLayoutConfigPanel: React.FC<UnifiedLayoutConfigPanelProps> = ({
config,
onChange,
}) => {
// 설정 업데이트 핸들러
const updateConfig = (field: string, value: any) => {
onChange({ ...config, [field]: value });
};
return (
<div className="space-y-4">
{/* 레이아웃 타입 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.layoutType || config.type || "grid"}
onValueChange={(value) => updateConfig("layoutType", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="타입 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="grid"></SelectItem>
<SelectItem value="split"> </SelectItem>
<SelectItem value="flex"></SelectItem>
<SelectItem value="divider"></SelectItem>
<SelectItem value="screen-embed"> </SelectItem>
</SelectContent>
</Select>
</div>
<Separator />
{/* 그리드 설정 */}
{(config.layoutType === "grid" || !config.layoutType) && (
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="flex items-center space-x-2">
<Checkbox
id="use12Column"
checked={config.use12Column !== false}
onCheckedChange={(checked) => updateConfig("use12Column", checked)}
/>
<label htmlFor="use12Column" className="text-xs">12 </label>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Select
value={String(config.columns || 12)}
onValueChange={(value) => updateConfig("columns", Number(value))}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1</SelectItem>
<SelectItem value="2">2</SelectItem>
<SelectItem value="3">3</SelectItem>
<SelectItem value="4">4</SelectItem>
<SelectItem value="6">6</SelectItem>
<SelectItem value="12">12</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> (px)</Label>
<Input
value={config.gap || "16"}
onChange={(e) => updateConfig("gap", e.target.value)}
placeholder="16"
className="h-8 text-xs"
/>
</div>
</div>
</div>
)}
{/* 분할 패널 설정 */}
{config.layoutType === "split" && (
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Select
value={config.direction || "horizontal"}
onValueChange={(value) => updateConfig("direction", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="horizontal"></SelectItem>
<SelectItem value="vertical"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> (%)</Label>
<div className="grid grid-cols-2 gap-2">
<Input
type="number"
value={config.splitRatio?.[0] || 50}
onChange={(e) => updateConfig("splitRatio", [Number(e.target.value), 100 - Number(e.target.value)])}
placeholder="50"
min="10"
max="90"
className="h-8 text-xs"
/>
<Input
type="number"
value={config.splitRatio?.[1] || 50}
disabled
className="h-8 text-xs bg-muted"
/>
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="resizable"
checked={config.resizable !== false}
onCheckedChange={(checked) => updateConfig("resizable", checked)}
/>
<label htmlFor="resizable" className="text-xs"> </label>
</div>
</div>
)}
{/* 플렉스 설정 */}
{config.layoutType === "flex" && (
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"></Label>
<Select
value={config.direction || "row"}
onValueChange={(value) => updateConfig("direction", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="row"></SelectItem>
<SelectItem value="column"></SelectItem>
<SelectItem value="row-reverse"> ()</SelectItem>
<SelectItem value="column-reverse"> ()</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"></Label>
<Select
value={config.justifyContent || "flex-start"}
onValueChange={(value) => updateConfig("justifyContent", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="flex-start"></SelectItem>
<SelectItem value="center"></SelectItem>
<SelectItem value="flex-end"></SelectItem>
<SelectItem value="space-between"> </SelectItem>
<SelectItem value="space-around"> </SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Select
value={config.alignItems || "stretch"}
onValueChange={(value) => updateConfig("alignItems", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="flex-start"></SelectItem>
<SelectItem value="center"></SelectItem>
<SelectItem value="flex-end"></SelectItem>
<SelectItem value="stretch"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> (px)</Label>
<Input
value={config.gap || "16"}
onChange={(e) => updateConfig("gap", e.target.value)}
placeholder="16"
className="h-8 text-xs"
/>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="wrap"
checked={config.wrap || false}
onCheckedChange={(checked) => updateConfig("wrap", checked)}
/>
<label htmlFor="wrap" className="text-xs"> </label>
</div>
</div>
)}
{/* 화면 임베드 설정 */}
{config.layoutType === "screen-embed" && (
<div className="space-y-2">
<Label className="text-xs font-medium"> ID</Label>
<Input
type="number"
value={config.screenId || ""}
onChange={(e) => updateConfig("screenId", e.target.value ? Number(e.target.value) : undefined)}
placeholder="화면 ID"
className="h-8 text-xs"
/>
</div>
)}
</div>
);
};
UnifiedLayoutConfigPanel.displayName = "UnifiedLayoutConfigPanel";
export default UnifiedLayoutConfigPanel;
@@ -1,167 +0,0 @@
"use client";
/**
* UnifiedList 설정 패널
* TableListConfigPanel을 래핑하여 동일한 설정 기능을 제공합니다.
* 카드 표시는 별도의 card-display 컴포넌트를 사용합니다.
*/
import React, { useMemo } from "react";
import { TableListConfigPanel } from "@/lib/registry/components/table-list/TableListConfigPanel";
import { TableListConfig } from "@/lib/registry/components/table-list/types";
interface UnifiedListConfigPanelProps {
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
/** 현재 화면의 테이블명 */
currentTableName?: string;
}
/**
* UnifiedList 설정 패널
* TableListConfigPanel과 동일한 기능을 제공
*/
export const UnifiedListConfigPanel: React.FC<UnifiedListConfigPanelProps> = ({
config,
onChange,
currentTableName,
}) => {
// UnifiedList config를 TableListConfig 형식으로 변환
const tableListConfig: TableListConfig = useMemo(() => {
// 컬럼 형식 변환: UnifiedList columns -> TableList columns
const columns = (config.columns || []).map((col: any, index: number) => ({
columnName: col.key || col.columnName || col.field || "",
displayName: col.title || col.header || col.displayName || col.key || col.columnName || col.field || "",
width: col.width ? parseInt(col.width, 10) : undefined,
visible: col.visible !== false,
sortable: col.sortable !== false,
searchable: col.searchable !== false,
align: col.align || "left",
order: index,
isEntityJoin: col.isJoinColumn || col.isEntityJoin || false,
thousandSeparator: col.thousandSeparator,
editable: col.editable,
entityDisplayConfig: col.entityDisplayConfig,
}));
return {
selectedTable: config.tableName || config.dataSource?.table || currentTableName,
tableName: config.tableName || config.dataSource?.table || currentTableName,
columns,
useCustomTable: config.useCustomTable,
customTableName: config.customTableName,
isReadOnly: config.isReadOnly !== false, // UnifiedList는 기본적으로 읽기 전용
displayMode: "table", // 테이블 모드 고정 (카드는 card-display 컴포넌트 사용)
pagination: config.pagination !== false ? {
enabled: true,
pageSize: config.pageSize || 10,
position: "bottom",
showPageSize: true,
pageSizeOptions: [5, 10, 20, 50, 100],
} : {
enabled: false,
pageSize: 10,
position: "bottom",
showPageSize: false,
pageSizeOptions: [10],
},
filter: config.filter,
dataFilter: config.dataFilter,
checkbox: {
enabled: true,
position: "left",
showHeader: true,
},
height: "auto",
autoWidth: true,
stickyHeader: true,
autoLoad: true,
horizontalScroll: {
enabled: true,
minColumnWidth: 100,
maxColumnWidth: 300,
},
};
}, [config, currentTableName]);
// TableListConfig 변경을 UnifiedList config 형식으로 변환
const handleConfigChange = (partialConfig: Partial<TableListConfig>) => {
const newConfig: Record<string, any> = { ...config };
// 테이블 설정 변환
if (partialConfig.selectedTable !== undefined) {
newConfig.tableName = partialConfig.selectedTable;
if (!newConfig.dataSource) {
newConfig.dataSource = {};
}
newConfig.dataSource.table = partialConfig.selectedTable;
}
if (partialConfig.tableName !== undefined) {
newConfig.tableName = partialConfig.tableName;
if (!newConfig.dataSource) {
newConfig.dataSource = {};
}
newConfig.dataSource.table = partialConfig.tableName;
}
if (partialConfig.useCustomTable !== undefined) {
newConfig.useCustomTable = partialConfig.useCustomTable;
}
if (partialConfig.customTableName !== undefined) {
newConfig.customTableName = partialConfig.customTableName;
}
if (partialConfig.isReadOnly !== undefined) {
newConfig.isReadOnly = partialConfig.isReadOnly;
}
// 컬럼 형식 변환: TableList columns -> UnifiedList columns
if (partialConfig.columns !== undefined) {
newConfig.columns = partialConfig.columns.map((col: any) => ({
key: col.columnName,
field: col.columnName,
title: col.displayName,
header: col.displayName,
width: col.width ? String(col.width) : undefined,
visible: col.visible,
sortable: col.sortable,
searchable: col.searchable,
align: col.align,
isJoinColumn: col.isEntityJoin,
isEntityJoin: col.isEntityJoin,
thousandSeparator: col.thousandSeparator,
editable: col.editable,
entityDisplayConfig: col.entityDisplayConfig,
}));
}
// 페이지네이션 변환
if (partialConfig.pagination !== undefined) {
newConfig.pagination = partialConfig.pagination?.enabled;
newConfig.pageSize = partialConfig.pagination?.pageSize || 10;
}
// 필터 변환
if (partialConfig.filter !== undefined) {
newConfig.filter = partialConfig.filter;
}
// 데이터 필터 변환
if (partialConfig.dataFilter !== undefined) {
newConfig.dataFilter = partialConfig.dataFilter;
}
console.log("⚙️ UnifiedListConfigPanel handleConfigChange:", { partialConfig, newConfig });
onChange(newConfig);
};
return (
<TableListConfigPanel
config={tableListConfig}
onChange={handleConfigChange}
screenTableName={currentTableName}
/>
);
};
UnifiedListConfigPanel.displayName = "UnifiedListConfigPanel";
export default UnifiedListConfigPanel;
@@ -1,212 +0,0 @@
"use client";
/**
* UnifiedMedia 설정 패널
* 통합 미디어 컴포넌트의 세부 설정을 관리합니다.
*/
import React 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 { Separator } from "@/components/ui/separator";
import { Checkbox } from "@/components/ui/checkbox";
interface UnifiedMediaConfigPanelProps {
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
}
export const UnifiedMediaConfigPanel: React.FC<UnifiedMediaConfigPanelProps> = ({
config,
onChange,
}) => {
// 설정 업데이트 핸들러
const updateConfig = (field: string, value: any) => {
onChange({ ...config, [field]: value });
};
return (
<div className="space-y-4">
{/* 미디어 타입 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.mediaType || config.type || "image"}
onValueChange={(value) => updateConfig("mediaType", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="타입 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="file"></SelectItem>
<SelectItem value="image"></SelectItem>
<SelectItem value="video"></SelectItem>
<SelectItem value="audio"></SelectItem>
</SelectContent>
</Select>
</div>
<Separator />
{/* 허용 파일 형식 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Input
value={config.accept || ""}
onChange={(e) => updateConfig("accept", e.target.value)}
placeholder="예: .jpg,.png,.pdf"
className="h-8 text-xs"
/>
<p className="text-[10px] text-muted-foreground">
. : .jpg,.png,.gif image/*
</p>
</div>
{/* 최대 파일 크기 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> (MB)</Label>
<Input
type="number"
value={config.maxSize || ""}
onChange={(e) => updateConfig("maxSize", e.target.value ? Number(e.target.value) : undefined)}
placeholder="10"
min="1"
className="h-8 text-xs"
/>
</div>
{/* 최대 파일 수 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Input
type="number"
value={config.maxFiles || ""}
onChange={(e) => updateConfig("maxFiles", e.target.value ? Number(e.target.value) : undefined)}
placeholder="제한 없음"
min="1"
className="h-8 text-xs"
/>
</div>
<Separator />
{/* 옵션 */}
<div className="space-y-3">
<Label className="text-xs font-medium"></Label>
<div className="flex items-center space-x-2">
<Checkbox
id="multiple"
checked={config.multiple || false}
onCheckedChange={(checked) => updateConfig("multiple", checked)}
/>
<label htmlFor="multiple" className="text-xs"> </label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="preview"
checked={config.preview !== false}
onCheckedChange={(checked) => updateConfig("preview", checked)}
/>
<label htmlFor="preview" className="text-xs"> </label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="dragDrop"
checked={config.dragDrop !== false}
onCheckedChange={(checked) => updateConfig("dragDrop", checked)}
/>
<label htmlFor="dragDrop" className="text-xs"> </label>
</div>
</div>
{/* 이미지 전용 설정 */}
{config.mediaType === "image" && (
<>
<Separator />
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> (px)</Label>
<Input
type="number"
value={config.maxWidth || ""}
onChange={(e) => updateConfig("maxWidth", e.target.value ? Number(e.target.value) : undefined)}
placeholder="자동"
className="h-8 text-xs"
/>
</div>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> (px)</Label>
<Input
type="number"
value={config.maxHeight || ""}
onChange={(e) => updateConfig("maxHeight", e.target.value ? Number(e.target.value) : undefined)}
placeholder="자동"
className="h-8 text-xs"
/>
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="crop"
checked={config.crop || false}
onCheckedChange={(checked) => updateConfig("crop", checked)}
/>
<label htmlFor="crop" className="text-xs"> </label>
</div>
</div>
</>
)}
{/* 비디오/오디오 전용 설정 */}
{(config.mediaType === "video" || config.mediaType === "audio") && (
<>
<Separator />
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="flex items-center space-x-2">
<Checkbox
id="autoplay"
checked={config.autoplay || false}
onCheckedChange={(checked) => updateConfig("autoplay", checked)}
/>
<label htmlFor="autoplay" className="text-xs"> </label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="controls"
checked={config.controls !== false}
onCheckedChange={(checked) => updateConfig("controls", checked)}
/>
<label htmlFor="controls" className="text-xs"> </label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="loop"
checked={config.loop || false}
onCheckedChange={(checked) => updateConfig("loop", checked)}
/>
<label htmlFor="loop" className="text-xs"> </label>
</div>
</div>
</>
)}
</div>
);
};
UnifiedMediaConfigPanel.displayName = "UnifiedMediaConfigPanel";
export default UnifiedMediaConfigPanel;
File diff suppressed because it is too large Load Diff
@@ -1,363 +0,0 @@
"use client";
/**
* UnifiedSelect 설정 패널
* 통합 선택 컴포넌트의 세부 설정을 관리합니다.
*/
import React, { useState, useEffect, useCallback } 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 { Separator } from "@/components/ui/separator";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { Plus, Trash2, Loader2 } from "lucide-react";
import { apiClient } from "@/lib/api/client";
interface ColumnOption {
columnName: string;
columnLabel: string;
}
interface UnifiedSelectConfigPanelProps {
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
/** 컬럼의 inputType (entity 타입인 경우에만 엔티티 소스 표시) */
inputType?: string;
}
export const UnifiedSelectConfigPanel: React.FC<UnifiedSelectConfigPanelProps> = ({
config,
onChange,
inputType,
}) => {
// 엔티티 타입인지 확인
const isEntityType = inputType === "entity";
// 엔티티 테이블의 컬럼 목록
const [entityColumns, setEntityColumns] = useState<ColumnOption[]>([]);
const [loadingColumns, setLoadingColumns] = useState(false);
// 설정 업데이트 핸들러
const updateConfig = (field: string, value: any) => {
onChange({ ...config, [field]: value });
};
// 엔티티 테이블 변경 시 컬럼 목록 조회
const loadEntityColumns = useCallback(async (tableName: string) => {
if (!tableName) {
setEntityColumns([]);
return;
}
setLoadingColumns(true);
try {
const response = await apiClient.get(`/table-management/tables/${tableName}/columns?size=500`);
const data = response.data.data || response.data;
const columns = data.columns || data || [];
const columnOptions: ColumnOption[] = columns.map((col: any) => {
const name = col.columnName || col.column_name || col.name;
// displayName 우선 사용
const label = col.displayName || col.display_name || col.columnLabel || col.column_label || name;
return {
columnName: name,
columnLabel: label,
};
});
setEntityColumns(columnOptions);
} catch (error) {
console.error("컬럼 목록 조회 실패:", error);
setEntityColumns([]);
} finally {
setLoadingColumns(false);
}
}, []);
// 엔티티 테이블이 변경되면 컬럼 목록 로드
useEffect(() => {
if (config.source === "entity" && config.entityTable) {
loadEntityColumns(config.entityTable);
}
}, [config.source, config.entityTable, loadEntityColumns]);
// 정적 옵션 관리
const options = config.options || [];
const addOption = () => {
const newOptions = [...options, { value: "", label: "" }];
updateConfig("options", newOptions);
};
const updateOption = (index: number, field: "value" | "label", value: string) => {
const newOptions = [...options];
newOptions[index] = { ...newOptions[index], [field]: value };
updateConfig("options", newOptions);
};
const removeOption = (index: number) => {
const newOptions = options.filter((_: any, i: number) => i !== index);
updateConfig("options", newOptions);
};
return (
<div className="space-y-4">
{/* 선택 모드 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.mode || "dropdown"}
onValueChange={(value) => updateConfig("mode", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="모드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="dropdown"></SelectItem>
<SelectItem value="radio"> </SelectItem>
<SelectItem value="check"></SelectItem>
<SelectItem value="tag"> </SelectItem>
<SelectItem value="toggle"> </SelectItem>
<SelectItem value="swap"> </SelectItem>
</SelectContent>
</Select>
</div>
<Separator />
{/* 데이터 소스 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.source || "static"}
onValueChange={(value) => updateConfig("source", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="소스 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="static"> </SelectItem>
<SelectItem value="code"> </SelectItem>
{/* 엔티티 타입일 때만 엔티티 옵션 표시 */}
{isEntityType && <SelectItem value="entity"></SelectItem>}
</SelectContent>
</Select>
</div>
{/* 정적 옵션 관리 */}
{config.source === "static" && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium"> </Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={addOption}
className="h-6 px-2 text-xs"
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-2 max-h-40 overflow-y-auto">
{options.map((option: any, index: number) => (
<div key={index} className="flex items-center gap-2">
<Input
value={option.value || ""}
onChange={(e) => updateOption(index, "value", e.target.value)}
placeholder="값"
className="h-7 text-xs flex-1"
/>
<Input
value={option.label || ""}
onChange={(e) => updateOption(index, "label", e.target.value)}
placeholder="표시 텍스트"
className="h-7 text-xs flex-1"
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeOption(index)}
className="h-7 w-7 p-0 text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
{options.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-2">
</p>
)}
</div>
</div>
)}
{/* 공통 코드 설정 - 테이블 타입 관리에서 설정되므로 정보만 표시 */}
{config.source === "code" && (
<div className="space-y-1">
<Label className="text-xs font-medium"> </Label>
{config.codeGroup ? (
<p className="text-sm font-medium text-foreground">{config.codeGroup}</p>
) : (
<p className="text-xs text-amber-600">
</p>
)}
</div>
)}
{/* 엔티티(참조 테이블) 설정 */}
{config.source === "entity" && (
<div className="space-y-3">
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Input
value={config.entityTable || ""}
readOnly
disabled
placeholder="테이블 타입 관리에서 설정"
className="h-8 text-xs bg-muted"
/>
<p className="text-[10px] text-muted-foreground">
( )
</p>
</div>
{/* 컬럼 로딩 중 표시 */}
{loadingColumns && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
)}
{/* 컬럼 선택 - 테이블이 설정되어 있고 컬럼 목록이 있는 경우 Select로 표시 */}
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label className="text-xs font-medium"> ()</Label>
{entityColumns.length > 0 ? (
<Select
value={config.entityValueColumn || ""}
onValueChange={(value) => updateConfig("entityValueColumn", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{entityColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={config.entityValueColumn || ""}
onChange={(e) => updateConfig("entityValueColumn", e.target.value)}
placeholder="id"
className="h-8 text-xs"
/>
)}
<p className="text-[10px] text-muted-foreground"> </p>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
{entityColumns.length > 0 ? (
<Select
value={config.entityLabelColumn || ""}
onValueChange={(value) => updateConfig("entityLabelColumn", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{entityColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={config.entityLabelColumn || ""}
onChange={(e) => updateConfig("entityLabelColumn", e.target.value)}
placeholder="name"
className="h-8 text-xs"
/>
)}
<p className="text-[10px] text-muted-foreground"> </p>
</div>
</div>
{/* 컬럼이 없는 경우 안내 */}
{config.entityTable && !loadingColumns && entityColumns.length === 0 && (
<p className="text-[10px] text-amber-600">
. .
</p>
)}
</div>
)}
<Separator />
{/* 추가 옵션 */}
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="flex items-center space-x-2">
<Checkbox
id="multiple"
checked={config.multiple || false}
onCheckedChange={(checked) => updateConfig("multiple", checked)}
/>
<label htmlFor="multiple" className="text-xs"> </label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="searchable"
checked={config.searchable || false}
onCheckedChange={(checked) => updateConfig("searchable", checked)}
/>
<label htmlFor="searchable" className="text-xs"> </label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="allowClear"
checked={config.allowClear !== false}
onCheckedChange={(checked) => updateConfig("allowClear", checked)}
/>
<label htmlFor="allowClear" className="text-xs"> </label>
</div>
</div>
{/* 다중 선택 시 최대 개수 */}
{config.multiple && (
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Input
type="number"
value={config.maxSelect ?? ""}
onChange={(e) => updateConfig("maxSelect", e.target.value ? Number(e.target.value) : undefined)}
placeholder="제한 없음"
min="1"
className="h-8 text-xs"
/>
</div>
)}
</div>
);
};
UnifiedSelectConfigPanel.displayName = "UnifiedSelectConfigPanel";
export default UnifiedSelectConfigPanel;
@@ -1,15 +0,0 @@
/**
* Unified 컴포넌트 설정 패널 인덱스
*/
export { UnifiedInputConfigPanel } from "./UnifiedInputConfigPanel";
export { UnifiedSelectConfigPanel } from "./UnifiedSelectConfigPanel";
export { UnifiedDateConfigPanel } from "./UnifiedDateConfigPanel";
export { UnifiedListConfigPanel } from "./UnifiedListConfigPanel";
export { UnifiedLayoutConfigPanel } from "./UnifiedLayoutConfigPanel";
export { UnifiedGroupConfigPanel } from "./UnifiedGroupConfigPanel";
export { UnifiedMediaConfigPanel } from "./UnifiedMediaConfigPanel";
export { UnifiedBizConfigPanel } from "./UnifiedBizConfigPanel";
export { UnifiedHierarchyConfigPanel } from "./UnifiedHierarchyConfigPanel";
-125
View File
@@ -1,125 +0,0 @@
/**
* Unified Components 모듈 인덱스
*
* 10개의 통합 컴포넌트 시스템
*/
// Phase 1 컴포넌트
export { UnifiedInput } from "./UnifiedInput";
export { UnifiedSelect } from "./UnifiedSelect";
export { UnifiedDate } from "./UnifiedDate";
// Phase 2 컴포넌트
export { UnifiedList } from "./UnifiedList";
export { UnifiedLayout } from "./UnifiedLayout";
export { UnifiedGroup } from "./UnifiedGroup";
// Phase 3 컴포넌트
export { UnifiedMedia } from "./UnifiedMedia";
export { UnifiedBiz } from "./UnifiedBiz";
export { UnifiedHierarchy } from "./UnifiedHierarchy";
// UnifiedText는 UnifiedInput의 textarea 모드로 대체 가능
// 렌더러
export { UnifiedComponentRenderer } from "./UnifiedComponentRenderer";
// 설정 패널
export { DynamicConfigPanel, COMMON_SCHEMAS } from "./DynamicConfigPanel";
// 데모 컴포넌트
export { UnifiedComponentsDemo } from "./UnifiedComponentsDemo";
// 폼 컨텍스트 및 액션
export {
UnifiedFormProvider,
useUnifiedForm,
useUnifiedFormOptional,
useUnifiedField,
useCascadingOptions,
useFormActions,
useRepeaterField,
} from "./UnifiedFormContext";
// 설정 UI 패널
export { ConditionalConfigPanel } from "./ConditionalConfigPanel";
// 폼 관련 타입 re-export
export type {
FormStatus,
FieldError,
FieldState,
SubmitConfig,
SubmitResult,
ValidationResult,
FieldMapping,
ScreenDataTransferConfig,
FormCompatibilityBridge,
} from "@/types/unified-form";
// 타입 re-export
export type {
// 공통 타입
UnifiedComponentType,
UnifiedBaseProps,
ConditionalConfig,
AutoFillConfig,
CascadingConfig,
MutualExclusionConfig,
// UnifiedInput 타입
UnifiedInputType,
UnifiedInputFormat,
UnifiedInputConfig,
UnifiedInputProps,
// UnifiedSelect 타입
UnifiedSelectMode,
UnifiedSelectSource,
SelectOption,
UnifiedSelectConfig,
UnifiedSelectProps,
// UnifiedDate 타입
UnifiedDateType,
UnifiedDateConfig,
UnifiedDateProps,
// UnifiedList 타입
UnifiedListViewMode,
ListColumn,
UnifiedListConfig,
UnifiedListProps,
// UnifiedLayout 타입
UnifiedLayoutType,
UnifiedLayoutConfig,
UnifiedLayoutProps,
// UnifiedGroup 타입
UnifiedGroupType,
TabItem,
UnifiedGroupConfig,
UnifiedGroupProps,
// UnifiedMedia 타입
UnifiedMediaType,
UnifiedMediaConfig,
UnifiedMediaProps,
// UnifiedBiz 타입
UnifiedBizType,
UnifiedBizConfig,
UnifiedBizProps,
// UnifiedHierarchy 타입
UnifiedHierarchyType,
UnifiedHierarchyViewMode,
HierarchyNode,
UnifiedHierarchyConfig,
UnifiedHierarchyProps,
// 통합 Props
UnifiedComponentProps,
} from "@/types/unified-components";
@@ -1,201 +0,0 @@
"use client";
/**
* Unified 컴포넌트 레지스트리 등록
*
* 9개의 Unified 컴포넌트를 ComponentRegistry에 등록합니다.
*/
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
import { ComponentDefinition, ComponentCategory } from "@/types/component";
import { WebType } from "@/types/screen";
// 실제 컴포넌트 import
import { UnifiedInput } from "./UnifiedInput";
import { UnifiedSelect } from "./UnifiedSelect";
import { UnifiedDate } from "./UnifiedDate";
import { UnifiedList } from "./UnifiedList";
import { UnifiedLayout } from "./UnifiedLayout";
import { UnifiedGroup } from "./UnifiedGroup";
import { UnifiedMedia } from "./UnifiedMedia";
import { UnifiedBiz } from "./UnifiedBiz";
import { UnifiedHierarchy } from "./UnifiedHierarchy";
// 설정 패널 import
import { UnifiedInputConfigPanel } from "./config-panels/UnifiedInputConfigPanel";
import { UnifiedSelectConfigPanel } from "./config-panels/UnifiedSelectConfigPanel";
import { UnifiedDateConfigPanel } from "./config-panels/UnifiedDateConfigPanel";
import { UnifiedListConfigPanel } from "./config-panels/UnifiedListConfigPanel";
import { UnifiedLayoutConfigPanel } from "./config-panels/UnifiedLayoutConfigPanel";
import { UnifiedGroupConfigPanel } from "./config-panels/UnifiedGroupConfigPanel";
import { UnifiedMediaConfigPanel } from "./config-panels/UnifiedMediaConfigPanel";
import { UnifiedBizConfigPanel } from "./config-panels/UnifiedBizConfigPanel";
import { UnifiedHierarchyConfigPanel } from "./config-panels/UnifiedHierarchyConfigPanel";
// Unified 컴포넌트 정의
const unifiedComponentDefinitions: ComponentDefinition[] = [
{
id: "unified-input",
name: "통합 입력",
description: "텍스트, 숫자, 비밀번호, 슬라이더, 컬러 등 다양한 입력 타입을 지원하는 통합 컴포넌트",
category: ComponentCategory.UNIFIED,
webType: "text" as WebType,
component: UnifiedInput as any,
tags: ["input", "text", "number", "password", "slider", "color", "unified"],
defaultSize: { width: 200, height: 40 },
configPanel: UnifiedInputConfigPanel as any,
defaultConfig: {
inputType: "text",
format: "none",
placeholder: "",
},
},
{
id: "unified-select",
name: "통합 선택",
description: "드롭다운, 라디오, 체크박스, 태그, 토글 등 다양한 선택 방식을 지원하는 통합 컴포넌트",
category: ComponentCategory.UNIFIED,
webType: "select" as WebType,
component: UnifiedSelect as any,
tags: ["select", "dropdown", "radio", "checkbox", "toggle", "unified"],
defaultSize: { width: 200, height: 40 },
configPanel: UnifiedSelectConfigPanel as any,
defaultConfig: {
mode: "dropdown",
source: "static",
options: [],
},
},
{
id: "unified-date",
name: "통합 날짜",
description: "날짜, 시간, 날짜시간, 날짜 범위 등을 지원하는 통합 컴포넌트",
category: ComponentCategory.UNIFIED,
webType: "date" as WebType,
component: UnifiedDate as any,
tags: ["date", "time", "datetime", "datepicker", "unified"],
defaultSize: { width: 200, height: 40 },
configPanel: UnifiedDateConfigPanel as any,
defaultConfig: {
dateType: "date",
format: "YYYY-MM-DD",
},
},
{
id: "unified-list",
name: "통합 목록",
description: "테이블, 카드, 칸반, 리스트 등 다양한 데이터 표시 방식을 지원하는 통합 컴포넌트",
category: ComponentCategory.UNIFIED,
webType: "list" as WebType,
component: UnifiedList as any,
tags: ["list", "table", "card", "kanban", "data", "unified"],
defaultSize: { width: 600, height: 400 },
configPanel: UnifiedListConfigPanel as any,
defaultConfig: {
viewMode: "table",
source: "static",
columns: [],
pagination: true,
sortable: true,
},
},
{
id: "unified-layout",
name: "통합 레이아웃",
description: "그리드, 분할 패널, 플렉스 등 다양한 레이아웃 구조를 지원하는 통합 컴포넌트",
category: ComponentCategory.UNIFIED,
webType: "container" as WebType,
component: UnifiedLayout as any,
tags: ["layout", "grid", "split", "flex", "container", "unified"],
defaultSize: { width: 400, height: 300 },
configPanel: UnifiedLayoutConfigPanel as any,
defaultConfig: {
layoutType: "grid",
columns: 2,
gap: "16",
use12Column: true,
},
},
{
id: "unified-group",
name: "통합 그룹",
description: "탭, 아코디언, 섹션, 모달 등 그룹 요소를 지원하는 통합 컴포넌트",
category: ComponentCategory.UNIFIED,
webType: "group" as WebType,
component: UnifiedGroup as any,
tags: ["group", "tabs", "accordion", "section", "modal", "unified"],
defaultSize: { width: 400, height: 300 },
configPanel: UnifiedGroupConfigPanel as any,
defaultConfig: {
groupType: "section",
title: "",
collapsible: false,
defaultOpen: true,
},
},
{
id: "unified-media",
name: "통합 미디어",
description: "이미지, 비디오, 오디오, 파일 업로드 등을 지원하는 통합 컴포넌트",
category: ComponentCategory.UNIFIED,
webType: "file" as WebType,
component: UnifiedMedia as any,
tags: ["media", "image", "video", "audio", "file", "upload", "unified"],
defaultSize: { width: 300, height: 200 },
configPanel: UnifiedMediaConfigPanel as any,
defaultConfig: {
mediaType: "image",
multiple: false,
preview: true,
},
},
{
id: "unified-biz",
name: "통합 비즈니스",
description: "플로우, 랙, 채번규칙 등 비즈니스 기능을 지원하는 통합 컴포넌트",
category: ComponentCategory.UNIFIED,
webType: "custom" as WebType,
component: UnifiedBiz as any,
tags: ["business", "flow", "rack", "numbering", "category", "unified"],
defaultSize: { width: 500, height: 400 },
configPanel: UnifiedBizConfigPanel as any,
defaultConfig: {
bizType: "flow",
},
},
{
id: "unified-hierarchy",
name: "통합 계층",
description: "트리, 조직도, BOM, 연쇄 선택박스 등 계층 구조를 지원하는 통합 컴포넌트",
category: ComponentCategory.UNIFIED,
webType: "tree" as WebType,
component: UnifiedHierarchy as any,
tags: ["hierarchy", "tree", "org-chart", "bom", "cascading", "unified"],
defaultSize: { width: 400, height: 400 },
configPanel: UnifiedHierarchyConfigPanel as any,
defaultConfig: {
hierarchyType: "tree",
viewMode: "tree",
dataSource: "static",
},
},
];
/**
* Unified 컴포넌트들을 ComponentRegistry에 등록
*/
export function registerUnifiedComponents(): void {
for (const definition of unifiedComponentDefinitions) {
try {
// 이미 등록되어 있으면 스킵
if (ComponentRegistry.getComponent(definition.id)) {
continue;
}
ComponentRegistry.registerComponent(definition);
} catch (error) {
console.error(`❌ Unified 컴포넌트 등록 실패: ${definition.id}`, error);
}
}
}
export default registerUnifiedComponents;