추가모달 상세설정 구현
This commit is contained in:
parent
941c6d9d84
commit
b5edef274f
|
|
@ -55,10 +55,54 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
// 현재 사용자 정보
|
||||
const [currentUser, setCurrentUser] = useState<UserInfo | null>(null);
|
||||
|
||||
// 테이블 컬럼 타입 정보 (웹 타입 포함)
|
||||
const [tableColumns, setTableColumns] = useState<any[]>([]);
|
||||
|
||||
// 파일 업로드 관련 상태
|
||||
const [uploadingFiles, setUploadingFiles] = useState<Record<string, boolean>>({});
|
||||
const [uploadedFiles, setUploadedFiles] = useState<Record<string, File[]>>({});
|
||||
|
||||
// 검색 가능한 컬럼만 필터링
|
||||
const visibleColumns = component.columns?.filter((col: DataTableColumn) => col.visible) || [];
|
||||
const searchFilters = component.filters || [];
|
||||
|
||||
// 컬럼의 실제 웹 타입 정보 찾기
|
||||
const getColumnWebType = useCallback(
|
||||
(columnName: string) => {
|
||||
// 먼저 컴포넌트에 설정된 컬럼에서 찾기 (화면 관리에서 설정한 값 우선)
|
||||
const componentColumn = component.columns?.find((col) => col.columnName === columnName);
|
||||
if (componentColumn?.widgetType && componentColumn.widgetType !== "text") {
|
||||
return componentColumn.widgetType;
|
||||
}
|
||||
|
||||
// 없으면 테이블 타입 관리에서 설정된 값 찾기
|
||||
const tableColumn = tableColumns.find((col) => col.columnName === columnName);
|
||||
return tableColumn?.webType || "text";
|
||||
},
|
||||
[component.columns, tableColumns],
|
||||
);
|
||||
|
||||
// 컬럼의 상세 설정 정보 찾기
|
||||
const getColumnDetailSettings = useCallback(
|
||||
(columnName: string) => {
|
||||
// 먼저 컴포넌트에 설정된 컬럼에서 찾기 (화면 관리에서 설정한 값 우선)
|
||||
const componentColumn = component.columns?.find((col) => col.columnName === columnName);
|
||||
if (componentColumn?.webTypeConfig) {
|
||||
return componentColumn.webTypeConfig;
|
||||
}
|
||||
|
||||
// 없으면 테이블 타입 관리에서 설정된 값 찾기
|
||||
const tableColumn = tableColumns.find((col) => col.columnName === columnName);
|
||||
try {
|
||||
return tableColumn?.detailSettings ? JSON.parse(tableColumn.detailSettings) : {};
|
||||
} catch {
|
||||
console.warn("상세 설정 파싱 실패:", tableColumn?.detailSettings);
|
||||
return {};
|
||||
}
|
||||
},
|
||||
[component.columns, tableColumns],
|
||||
);
|
||||
|
||||
// 그리드 컬럼 계산
|
||||
const totalGridColumns = visibleColumns.reduce((sum, col) => sum + (col.gridColumns || 2), 0);
|
||||
|
||||
|
|
@ -119,6 +163,24 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
fetchCurrentUser();
|
||||
}, []);
|
||||
|
||||
// 테이블 컬럼 정보 로드 (웹 타입 정보 포함)
|
||||
useEffect(() => {
|
||||
const fetchTableColumns = async () => {
|
||||
try {
|
||||
console.log("🔄 테이블 컬럼 정보 로드 시작:", component.tableName);
|
||||
const columns = await tableTypeApi.getColumns(component.tableName);
|
||||
setTableColumns(columns);
|
||||
console.log("✅ 테이블 컬럼 정보 로드 완료:", columns);
|
||||
} catch (error) {
|
||||
console.error("테이블 컬럼 정보 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (component.tableName) {
|
||||
fetchTableColumns();
|
||||
}
|
||||
}, [component.tableName]);
|
||||
|
||||
// 초기 데이터 로드
|
||||
useEffect(() => {
|
||||
loadData(1, searchValues);
|
||||
|
|
@ -302,6 +364,145 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
}));
|
||||
}, []);
|
||||
|
||||
// 파일 업로드 핸들러
|
||||
const handleFileUpload = useCallback(
|
||||
async (columnName: string, files: FileList | null, isEdit: boolean = false) => {
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const detailSettings = getColumnDetailSettings(columnName);
|
||||
const maxSize = detailSettings?.maxSize || 10 * 1024 * 1024; // 기본 10MB
|
||||
const acceptedTypes = detailSettings?.accept
|
||||
? detailSettings.accept.split(",").map((type: string) => type.trim())
|
||||
: [];
|
||||
const multiple = detailSettings?.multiple || false;
|
||||
|
||||
// 파일 검증
|
||||
const validFiles: File[] = [];
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
|
||||
// 크기 체크
|
||||
if (file.size > maxSize) {
|
||||
alert(`파일 크기가 너무 큽니다. 최대 ${Math.round(maxSize / 1024 / 1024)}MB까지 가능합니다.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 타입 체크
|
||||
if (
|
||||
acceptedTypes.length > 0 &&
|
||||
!acceptedTypes.some((type: string) => {
|
||||
if (type.startsWith(".")) {
|
||||
return file.name.toLowerCase().endsWith(type.toLowerCase());
|
||||
} else {
|
||||
return file.type.includes(type);
|
||||
}
|
||||
})
|
||||
) {
|
||||
alert(`지원하지 않는 파일 형식입니다. (${acceptedTypes.join(", ")})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
validFiles.push(file);
|
||||
if (!multiple) break; // 단일 파일만 허용
|
||||
}
|
||||
|
||||
if (validFiles.length === 0) return;
|
||||
|
||||
try {
|
||||
setUploadingFiles((prev) => ({ ...prev, [columnName]: true }));
|
||||
|
||||
// TODO: 실제 파일 업로드 API 호출
|
||||
// const uploadPromises = validFiles.map(file => uploadFileToServer(file));
|
||||
// const uploadResults = await Promise.all(uploadPromises);
|
||||
|
||||
// 임시: 파일 정보를 로컬 상태에 저장
|
||||
setUploadedFiles((prev) => ({
|
||||
...prev,
|
||||
[columnName]: multiple ? [...(prev[columnName] || []), ...validFiles] : validFiles,
|
||||
}));
|
||||
|
||||
// 폼 데이터 업데이트
|
||||
const fileNames = validFiles.map((file) => file.name).join(", ");
|
||||
if (isEdit) {
|
||||
handleEditFormChange(columnName, fileNames);
|
||||
} else {
|
||||
handleAddFormChange(columnName, fileNames);
|
||||
}
|
||||
|
||||
console.log("✅ 파일 업로드 완료:", validFiles);
|
||||
} catch (error) {
|
||||
console.error("파일 업로드 실패:", error);
|
||||
alert("파일 업로드에 실패했습니다.");
|
||||
} finally {
|
||||
setUploadingFiles((prev) => ({ ...prev, [columnName]: false }));
|
||||
}
|
||||
},
|
||||
[getColumnDetailSettings, handleAddFormChange, handleEditFormChange],
|
||||
);
|
||||
|
||||
// 파일 제거 핸들러
|
||||
const handleFileRemove = useCallback(
|
||||
(columnName: string, fileIndex: number, isEdit: boolean = false) => {
|
||||
setUploadedFiles((prev) => {
|
||||
const currentFiles = prev[columnName] || [];
|
||||
const newFiles = currentFiles.filter((_, index) => index !== fileIndex);
|
||||
|
||||
// 폼 데이터 업데이트
|
||||
const fileNames = newFiles.map((file) => file.name).join(", ");
|
||||
if (isEdit) {
|
||||
handleEditFormChange(columnName, fileNames);
|
||||
} else {
|
||||
handleAddFormChange(columnName, fileNames);
|
||||
}
|
||||
|
||||
return { ...prev, [columnName]: newFiles };
|
||||
});
|
||||
},
|
||||
[handleAddFormChange, handleEditFormChange],
|
||||
);
|
||||
|
||||
// 파일 목록 렌더링 컴포넌트
|
||||
const renderFileList = useCallback(
|
||||
(columnName: string, isEdit: boolean = false) => {
|
||||
const currentFiles = uploadedFiles[columnName] || [];
|
||||
const isUploading = uploadingFiles[columnName];
|
||||
|
||||
if (currentFiles.length === 0 && !isUploading) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-2 space-y-2">
|
||||
{currentFiles.map((file, index) => (
|
||||
<div key={index} className="flex items-center justify-between rounded border bg-gray-50 p-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="text-xs text-gray-600">📄</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{file.name}</p>
|
||||
<p className="text-xs text-gray-500">{(file.size / 1024).toFixed(1)} KB</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleFileRemove(columnName, index, isEdit)}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{isUploading && (
|
||||
<div className="flex items-center space-x-2 rounded border bg-blue-50 p-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-sm text-blue-600">업로드 중...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[uploadedFiles, uploadingFiles, handleFileRemove],
|
||||
);
|
||||
|
||||
// 데이터 추가 제출 핸들러
|
||||
const handleAddSubmit = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -359,6 +560,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
if (!isAdding) {
|
||||
setShowAddModal(false);
|
||||
setAddFormData({});
|
||||
setUploadedFiles({}); // 파일 상태 초기화
|
||||
}
|
||||
}, [isAdding]);
|
||||
|
||||
|
|
@ -449,6 +651,10 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
const isRequired = isRequiredField(column.columnName);
|
||||
const advancedConfig = component.addModalConfig?.advancedFieldConfigs?.[column.columnName];
|
||||
|
||||
// 데이터베이스에서 실제 웹 타입 가져오기
|
||||
const actualWebType = getColumnWebType(column.columnName);
|
||||
const detailSettings = getColumnDetailSettings(column.columnName);
|
||||
|
||||
// 자동 생성 필드는 수정에서 읽기 전용으로 처리
|
||||
if (advancedConfig?.inputType === "auto") {
|
||||
return (
|
||||
|
|
@ -488,15 +694,17 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
className: isRequired && !value ? "border-orange-300 focus:border-orange-500" : "",
|
||||
};
|
||||
|
||||
switch (column.widgetType) {
|
||||
// 실제 웹 타입에 따라 입력 컴포넌트 렌더링
|
||||
switch (actualWebType) {
|
||||
case "text":
|
||||
case "email":
|
||||
case "tel":
|
||||
return (
|
||||
<div>
|
||||
<Input
|
||||
type={column.widgetType === "email" ? "email" : column.widgetType === "tel" ? "tel" : "text"}
|
||||
type={actualWebType === "email" ? "email" : actualWebType === "tel" ? "tel" : "text"}
|
||||
{...commonProps}
|
||||
maxLength={detailSettings?.maxLength}
|
||||
/>
|
||||
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
||||
</div>
|
||||
|
|
@ -506,7 +714,13 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
case "decimal":
|
||||
return (
|
||||
<div>
|
||||
<Input type="number" step={column.widgetType === "decimal" ? "0.01" : "1"} {...commonProps} />
|
||||
<Input
|
||||
type="number"
|
||||
step={actualWebType === "decimal" ? detailSettings?.step || "0.01" : "1"}
|
||||
min={detailSettings?.min}
|
||||
max={detailSettings?.max}
|
||||
{...commonProps}
|
||||
/>
|
||||
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -514,7 +728,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
case "date":
|
||||
return (
|
||||
<div>
|
||||
<Input type="date" {...commonProps} />
|
||||
<Input type="date" min={detailSettings?.minDate} max={detailSettings?.maxDate} {...commonProps} />
|
||||
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -522,15 +736,136 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
case "datetime":
|
||||
return (
|
||||
<div>
|
||||
<Input type="datetime-local" {...commonProps} />
|
||||
<Input type="datetime-local" min={detailSettings?.minDate} max={detailSettings?.maxDate} {...commonProps} />
|
||||
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "select":
|
||||
case "dropdown":
|
||||
// TODO: 동적 옵션 로드
|
||||
return <Input {...commonProps} placeholder={`${column.label} 선택... (개발 중)`} readOnly />;
|
||||
// 상세 설정에서 옵션 목록 가져오기
|
||||
const options = detailSettings?.options || [];
|
||||
if (options.length > 0) {
|
||||
return (
|
||||
<div>
|
||||
<Select value={value} onValueChange={(newValue) => handleEditFormChange(column.columnName, newValue)}>
|
||||
<SelectTrigger className={commonProps.className}>
|
||||
<SelectValue placeholder={commonProps.placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option: any, index: number) => (
|
||||
<SelectItem key={index} value={option.value || option}>
|
||||
{option.label || option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return <Input {...commonProps} placeholder={`${column.label} 선택... (옵션 설정 필요)`} readOnly />;
|
||||
}
|
||||
|
||||
case "radio":
|
||||
// 상세 설정에서 옵션 목록 가져오기
|
||||
const radioOptions = detailSettings?.options || [];
|
||||
if (radioOptions.length > 0) {
|
||||
return (
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
{radioOptions.map((option: any, index: number) => (
|
||||
<div key={index} className="flex items-center space-x-2">
|
||||
<input
|
||||
type="radio"
|
||||
id={`${column.columnName}-edit-${index}`}
|
||||
name={`${column.columnName}-edit`}
|
||||
value={option.value || option}
|
||||
checked={value === (option.value || option)}
|
||||
onChange={(e) => handleEditFormChange(column.columnName, e.target.value)}
|
||||
className="text-primary focus:ring-primary"
|
||||
/>
|
||||
<Label htmlFor={`${column.columnName}-edit-${index}`} className="text-sm">
|
||||
{option.label || option}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return <Input {...commonProps} placeholder={`${column.label} 선택... (옵션 설정 필요)`} readOnly />;
|
||||
}
|
||||
|
||||
case "textarea":
|
||||
return (
|
||||
<div>
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
||||
handleEditFormChange(column.columnName, e.target.value)
|
||||
}
|
||||
placeholder={advancedConfig?.placeholder || `${column.label} 입력...`}
|
||||
required={isRequired}
|
||||
className={`border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[80px] w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
isRequired && !value ? "border-orange-300 focus:border-orange-500" : ""
|
||||
}`}
|
||||
rows={detailSettings?.rows || 3}
|
||||
maxLength={detailSettings?.maxLength}
|
||||
/>
|
||||
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "boolean":
|
||||
case "checkbox":
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={value === true || value === "true" || value === 1}
|
||||
onCheckedChange={(checked) => handleEditFormChange(column.columnName, checked)}
|
||||
/>
|
||||
<Label>{column.label}</Label>
|
||||
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "file":
|
||||
return (
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input
|
||||
type="file"
|
||||
accept={detailSettings?.accept}
|
||||
multiple={detailSettings?.multiple}
|
||||
onChange={(e) => handleFileUpload(column.columnName, e.target.files, true)}
|
||||
className="hidden"
|
||||
id={`file-edit-${column.columnName}`}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => document.getElementById(`file-edit-${column.columnName}`)?.click()}
|
||||
disabled={uploadingFiles[column.columnName]}
|
||||
className="gap-2"
|
||||
>
|
||||
{uploadingFiles[column.columnName] ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4" />
|
||||
)}
|
||||
파일 선택
|
||||
</Button>
|
||||
{detailSettings?.accept && <span className="text-xs text-gray-500">({detailSettings.accept})</span>}
|
||||
</div>
|
||||
{renderFileList(column.columnName, true)}
|
||||
</div>
|
||||
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
|
|
@ -548,6 +883,10 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
const isRequired = isRequiredField(column.columnName);
|
||||
const advancedConfig = component.addModalConfig?.advancedFieldConfigs?.[column.columnName];
|
||||
|
||||
// 데이터베이스에서 실제 웹 타입 가져오기
|
||||
const actualWebType = getColumnWebType(column.columnName);
|
||||
const detailSettings = getColumnDetailSettings(column.columnName);
|
||||
|
||||
// 읽기 전용 또는 자동 값인 경우
|
||||
if (advancedConfig?.inputType === "readonly" || advancedConfig?.inputType === "auto") {
|
||||
return (
|
||||
|
|
@ -572,15 +911,17 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
className: isRequired && !value ? "border-orange-300 focus:border-orange-500" : "",
|
||||
};
|
||||
|
||||
switch (column.widgetType) {
|
||||
// 실제 웹 타입에 따라 입력 컴포넌트 렌더링
|
||||
switch (actualWebType) {
|
||||
case "text":
|
||||
case "email":
|
||||
case "tel":
|
||||
return (
|
||||
<div>
|
||||
<Input
|
||||
type={column.widgetType === "email" ? "email" : column.widgetType === "tel" ? "tel" : "text"}
|
||||
type={actualWebType === "email" ? "email" : actualWebType === "tel" ? "tel" : "text"}
|
||||
{...commonProps}
|
||||
maxLength={detailSettings?.maxLength}
|
||||
/>
|
||||
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
||||
</div>
|
||||
|
|
@ -590,7 +931,13 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
case "decimal":
|
||||
return (
|
||||
<div>
|
||||
<Input type="number" step={column.widgetType === "decimal" ? "0.01" : "1"} {...commonProps} />
|
||||
<Input
|
||||
type="number"
|
||||
step={actualWebType === "decimal" ? detailSettings?.step || "0.01" : "1"}
|
||||
min={detailSettings?.min}
|
||||
max={detailSettings?.max}
|
||||
{...commonProps}
|
||||
/>
|
||||
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -598,7 +945,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
case "date":
|
||||
return (
|
||||
<div>
|
||||
<Input type="date" {...commonProps} />
|
||||
<Input type="date" min={detailSettings?.minDate} max={detailSettings?.maxDate} {...commonProps} />
|
||||
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -606,47 +953,151 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
case "datetime":
|
||||
return (
|
||||
<div>
|
||||
<Input type="datetime-local" {...commonProps} />
|
||||
<Input type="datetime-local" min={detailSettings?.minDate} max={detailSettings?.maxDate} {...commonProps} />
|
||||
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "select":
|
||||
case "dropdown":
|
||||
// TODO: 동적 옵션 로드
|
||||
return (
|
||||
<Select value={value} onValueChange={(newValue) => handleAddFormChange(column.columnName, newValue)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={`${column.label} 선택...`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">선택 안함</SelectItem>
|
||||
<SelectItem value="option1">옵션 1</SelectItem>
|
||||
<SelectItem value="option2">옵션 2</SelectItem>
|
||||
<SelectItem value="option3">옵션 3</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
// 상세 설정에서 옵션 목록 가져오기
|
||||
const options = detailSettings?.options || [];
|
||||
if (options.length > 0) {
|
||||
return (
|
||||
<div>
|
||||
<Select value={value} onValueChange={(newValue) => handleAddFormChange(column.columnName, newValue)}>
|
||||
<SelectTrigger className={commonProps.className}>
|
||||
<SelectValue placeholder={commonProps.placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option: any, index: number) => (
|
||||
<SelectItem key={index} value={option.value || option}>
|
||||
{option.label || option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return <Input {...commonProps} placeholder={`${column.label} 선택... (옵션 설정 필요)`} readOnly />;
|
||||
}
|
||||
|
||||
case "radio":
|
||||
// 상세 설정에서 옵션 목록 가져오기
|
||||
const radioOptions = detailSettings?.options || [];
|
||||
const defaultValue = detailSettings?.defaultValue;
|
||||
|
||||
// 추가 모달에서는 기본값이 있으면 초기값으로 설정
|
||||
if (radioOptions.length > 0) {
|
||||
// 폼 데이터에 값이 없고 기본값이 있으면 기본값 설정
|
||||
if (!value && defaultValue) {
|
||||
setTimeout(() => handleAddFormChange(column.columnName, defaultValue), 0);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
{radioOptions.map((option: any, index: number) => (
|
||||
<div key={index} className="flex items-center space-x-2">
|
||||
<input
|
||||
type="radio"
|
||||
id={`${column.columnName}-add-${index}`}
|
||||
name={`${column.columnName}-add`}
|
||||
value={option.value || option}
|
||||
checked={value === (option.value || option)}
|
||||
onChange={(e) => handleAddFormChange(column.columnName, e.target.value)}
|
||||
className="text-primary focus:ring-primary"
|
||||
/>
|
||||
<Label htmlFor={`${column.columnName}-add-${index}`} className="text-sm">
|
||||
{option.label || option}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return <Input {...commonProps} placeholder={`${column.label} 선택... (옵션 설정 필요)`} readOnly />;
|
||||
}
|
||||
|
||||
case "boolean":
|
||||
case "checkbox":
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={value === true || value === "true"}
|
||||
checked={value === true || value === "true" || value === 1}
|
||||
onCheckedChange={(checked) => handleAddFormChange(column.columnName, checked)}
|
||||
/>
|
||||
<Label>{column.label}</Label>
|
||||
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "textarea":
|
||||
return (
|
||||
<div>
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
||||
handleAddFormChange(column.columnName, e.target.value)
|
||||
}
|
||||
placeholder={advancedConfig?.placeholder || `${column.label} 입력...`}
|
||||
required={isRequired}
|
||||
className={`border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[80px] w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
isRequired && !value ? "border-orange-300 focus:border-orange-500" : ""
|
||||
}`}
|
||||
rows={detailSettings?.rows || 3}
|
||||
maxLength={detailSettings?.maxLength}
|
||||
/>
|
||||
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "file":
|
||||
return (
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input
|
||||
type="file"
|
||||
accept={detailSettings?.accept}
|
||||
multiple={detailSettings?.multiple}
|
||||
onChange={(e) => handleFileUpload(column.columnName, e.target.files, false)}
|
||||
className="hidden"
|
||||
id={`file-add-${column.columnName}`}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => document.getElementById(`file-add-${column.columnName}`)?.click()}
|
||||
disabled={uploadingFiles[column.columnName]}
|
||||
className="gap-2"
|
||||
>
|
||||
{uploadingFiles[column.columnName] ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4" />
|
||||
)}
|
||||
파일 선택
|
||||
</Button>
|
||||
{detailSettings?.accept && <span className="text-xs text-gray-500">({detailSettings.accept})</span>}
|
||||
</div>
|
||||
{renderFileList(column.columnName, false)}
|
||||
</div>
|
||||
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => handleAddFormChange(column.columnName, e.target.value)}
|
||||
placeholder={`${column.label} 입력...`}
|
||||
/>
|
||||
<div>
|
||||
<Input {...commonProps} />
|
||||
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
@ -1088,6 +1539,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
setShowEditModal(false);
|
||||
setEditFormData({});
|
||||
setEditingRowData(null);
|
||||
setUploadedFiles({}); // 파일 상태 초기화
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
|
@ -1118,6 +1570,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
setShowEditModal(false);
|
||||
setEditFormData({});
|
||||
setEditingRowData(null);
|
||||
setUploadedFiles({}); // 파일 상태 초기화
|
||||
}}
|
||||
disabled={isEditing}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -84,6 +84,12 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({ comp
|
|||
// 필터별 로컬 입력 상태
|
||||
const [localFilterInputs, setLocalFilterInputs] = useState<Record<string, string>>({});
|
||||
|
||||
// 컬럼별 상세 설정 상태
|
||||
const [localColumnDetailSettings, setLocalColumnDetailSettings] = useState<Record<string, any>>({});
|
||||
|
||||
// 컬럼별 상세 설정 확장/축소 상태
|
||||
const [isColumnDetailOpen, setIsColumnDetailOpen] = useState<Record<string, boolean>>({});
|
||||
|
||||
// 모달 설정 확장/축소 상태
|
||||
const [isModalConfigOpen, setIsModalConfigOpen] = useState<Record<string, boolean>>({});
|
||||
|
||||
|
|
@ -379,6 +385,347 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({ comp
|
|||
[component.columns, onUpdateComponent],
|
||||
);
|
||||
|
||||
// 컬럼 상세 설정 업데이트 (테이블 타입 관리에도 반영)
|
||||
const updateColumnDetailSettings = useCallback(
|
||||
async (columnId: string, webTypeConfig: any) => {
|
||||
// 1. 먼저 화면 컴포넌트의 컬럼 설정 업데이트
|
||||
const updatedColumns = component.columns.map((col) => (col.id === columnId ? { ...col, webTypeConfig } : col));
|
||||
console.log("🔄 컬럼 상세 설정 업데이트:", { columnId, webTypeConfig, updatedColumns });
|
||||
onUpdateComponent({ columns: updatedColumns });
|
||||
|
||||
// 2. 테이블 타입 관리에도 반영 (라디오 타입인 경우)
|
||||
const targetColumn = component.columns.find((col) => col.id === columnId);
|
||||
if (targetColumn && targetColumn.widgetType === "radio" && selectedTable) {
|
||||
try {
|
||||
// TODO: 테이블 타입 관리 API 호출하여 웹 타입과 상세 설정 업데이트
|
||||
console.log("📡 테이블 타입 관리 업데이트 필요:", {
|
||||
tableName: component.tableName,
|
||||
columnName: targetColumn.columnName,
|
||||
webType: "radio",
|
||||
detailSettings: JSON.stringify(webTypeConfig),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("테이블 타입 관리 업데이트 실패:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
[component.columns, component.tableName, selectedTable, onUpdateComponent],
|
||||
);
|
||||
|
||||
// 컬럼의 현재 웹 타입 가져오기 (테이블 타입 관리에서 설정된 값)
|
||||
const getColumnWebType = useCallback(
|
||||
(column: DataTableColumn) => {
|
||||
// 테이블 타입 관리에서 설정된 웹 타입 찾기
|
||||
if (!selectedTable) return "text";
|
||||
|
||||
const tableColumn = selectedTable.columns.find((col) => col.columnName === column.columnName);
|
||||
return (
|
||||
tableColumn?.webType ||
|
||||
getWidgetTypeFromColumn(tableColumn || { columnName: column.columnName, dataType: "text" })
|
||||
);
|
||||
},
|
||||
[selectedTable],
|
||||
);
|
||||
|
||||
// 컬럼의 현재 상세 설정 가져오기
|
||||
const getColumnCurrentDetailSettings = useCallback((column: DataTableColumn) => {
|
||||
return column.webTypeConfig || {};
|
||||
}, []);
|
||||
|
||||
// 웹 타입별 상세 설정 렌더링
|
||||
const renderColumnDetailSettings = useCallback(
|
||||
(column: DataTableColumn) => {
|
||||
const webType = getColumnWebType(column);
|
||||
const currentSettings = getColumnCurrentDetailSettings(column);
|
||||
const localSettings = localColumnDetailSettings[column.id] || currentSettings;
|
||||
|
||||
const updateSettings = (newSettings: any) => {
|
||||
const merged = { ...localSettings, ...newSettings };
|
||||
setLocalColumnDetailSettings((prev) => ({
|
||||
...prev,
|
||||
[column.id]: merged,
|
||||
}));
|
||||
updateColumnDetailSettings(column.id, merged);
|
||||
};
|
||||
|
||||
switch (webType) {
|
||||
case "select":
|
||||
case "dropdown":
|
||||
case "radio":
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">옵션 목록</Label>
|
||||
<div className="space-y-2">
|
||||
{(localSettings.options || []).map((option: any, index: number) => {
|
||||
// 안전한 값 추출
|
||||
const currentLabel =
|
||||
typeof option === "object" && option !== null
|
||||
? option.label || option.value || ""
|
||||
: String(option || "");
|
||||
|
||||
return (
|
||||
<div key={index} className="flex items-center space-x-2">
|
||||
<Input
|
||||
value={currentLabel}
|
||||
onChange={(e) => {
|
||||
const newOptions = [...(localSettings.options || [])];
|
||||
newOptions[index] = { label: e.target.value, value: e.target.value };
|
||||
updateSettings({ options: newOptions });
|
||||
}}
|
||||
placeholder="옵션명"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newOptions = (localSettings.options || []).filter((_: any, i: number) => i !== index);
|
||||
updateSettings({ options: newOptions });
|
||||
}}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newOption = { label: "", value: "" };
|
||||
updateSettings({ options: [...(localSettings.options || []), newOption] });
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
옵션 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{webType === "radio" ? (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">기본값 설정</Label>
|
||||
<Select
|
||||
value={localSettings.defaultValue || "__NONE__"}
|
||||
onValueChange={(value) => updateSettings({ defaultValue: value === "__NONE__" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="기본값 선택..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__NONE__">선택 안함</SelectItem>
|
||||
{(localSettings.options || []).map((option: any, index: number) => {
|
||||
// 안전한 문자열 변환
|
||||
const getStringValue = (val: any): string => {
|
||||
if (typeof val === "string") return val;
|
||||
if (typeof val === "number") return String(val);
|
||||
if (typeof val === "object" && val !== null) {
|
||||
return val.label || val.value || val.name || JSON.stringify(val);
|
||||
}
|
||||
return String(val || "");
|
||||
};
|
||||
|
||||
const optionValue = getStringValue(option.value || option.label || option) || `option-${index}`;
|
||||
const optionLabel =
|
||||
getStringValue(option.label || option.value || option) || `옵션 ${index + 1}`;
|
||||
|
||||
return (
|
||||
<SelectItem key={index} value={optionValue}>
|
||||
{optionLabel}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={localSettings.multiple || false}
|
||||
onCheckedChange={(checked) => updateSettings({ multiple: checked })}
|
||||
/>
|
||||
<Label className="text-xs">다중 선택 허용</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "number":
|
||||
case "decimal":
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">최소값</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={localSettings.min || ""}
|
||||
onChange={(e) => updateSettings({ min: e.target.value ? Number(e.target.value) : undefined })}
|
||||
placeholder="최소값"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">최대값</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={localSettings.max || ""}
|
||||
onChange={(e) => updateSettings({ max: e.target.value ? Number(e.target.value) : undefined })}
|
||||
placeholder="최대값"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{webType === "decimal" && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">단계 (step)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={localSettings.step || "0.01"}
|
||||
onChange={(e) => updateSettings({ step: e.target.value })}
|
||||
placeholder="0.01"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "date":
|
||||
case "datetime":
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">최소 날짜</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={localSettings.minDate || ""}
|
||||
onChange={(e) => updateSettings({ minDate: e.target.value })}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">최대 날짜</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={localSettings.maxDate || ""}
|
||||
onChange={(e) => updateSettings({ maxDate: e.target.value })}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{webType === "datetime" && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={localSettings.showSeconds || false}
|
||||
onCheckedChange={(checked) => updateSettings({ showSeconds: checked })}
|
||||
/>
|
||||
<Label className="text-xs">초 단위까지 표시</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "text":
|
||||
case "email":
|
||||
case "tel":
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">최대 길이</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={localSettings.maxLength || ""}
|
||||
onChange={(e) => updateSettings({ maxLength: e.target.value ? Number(e.target.value) : undefined })}
|
||||
placeholder="최대 문자 수"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">플레이스홀더</Label>
|
||||
<Input
|
||||
value={localSettings.placeholder || ""}
|
||||
onChange={(e) => updateSettings({ placeholder: e.target.value })}
|
||||
placeholder="입력 안내 텍스트"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "textarea":
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">행 수</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={localSettings.rows || "3"}
|
||||
onChange={(e) => updateSettings({ rows: Number(e.target.value) })}
|
||||
placeholder="3"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">최대 길이</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={localSettings.maxLength || ""}
|
||||
onChange={(e) => updateSettings({ maxLength: e.target.value ? Number(e.target.value) : undefined })}
|
||||
placeholder="최대 문자 수"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "file":
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">허용 파일 형식</Label>
|
||||
<Input
|
||||
value={localSettings.accept || ""}
|
||||
onChange={(e) => updateSettings({ accept: e.target.value })}
|
||||
placeholder=".jpg,.png,.pdf"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">최대 파일 크기 (MB)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={localSettings.maxSize ? localSettings.maxSize / 1024 / 1024 : "10"}
|
||||
onChange={(e) => updateSettings({ maxSize: Number(e.target.value) * 1024 * 1024 })}
|
||||
placeholder="10"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={localSettings.multiple || false}
|
||||
onCheckedChange={(checked) => updateSettings({ multiple: checked })}
|
||||
/>
|
||||
<Label className="text-xs">다중 파일 허용</Label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return <div className="py-2 text-xs text-gray-500">이 웹 타입({webType})에 대한 상세 설정이 없습니다.</div>;
|
||||
}
|
||||
},
|
||||
[getColumnWebType, getColumnCurrentDetailSettings, localColumnDetailSettings, updateColumnDetailSettings],
|
||||
);
|
||||
|
||||
// 컬럼 삭제
|
||||
const removeColumn = useCallback(
|
||||
(columnId: string) => {
|
||||
|
|
@ -1060,19 +1407,39 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({ comp
|
|||
<Badge variant="outline" className="text-xs">
|
||||
{column.columnName}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{getColumnWebType(column)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsColumnDetailOpen((prev) => ({
|
||||
...prev,
|
||||
[column.id]: !prev[column.id],
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Settings className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
removeColumn(column.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
removeColumn(column.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
|
|
@ -1162,6 +1529,19 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({ comp
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 웹 타입 상세 설정 */}
|
||||
{isColumnDetailOpen[column.id] && (
|
||||
<div className="border-t pt-2">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Label className="text-xs font-medium">웹 타입 상세 설정</Label>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{getColumnWebType(column)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="pl-2">{renderColumnDetailSettings(column)}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 모달 전용 설정 */}
|
||||
{component.enableAdd && (
|
||||
<div className="border-t pt-2">
|
||||
|
|
|
|||
Loading…
Reference in New Issue