Merge branch 'main' into feature/screen-management

This commit is contained in:
kjs 2025-12-12 13:50:43 +09:00
commit add98673bb
22 changed files with 2982 additions and 2211 deletions

View File

@ -898,9 +898,10 @@ class NumberingRuleService {
switch (part.partType) {
case "sequence": {
// 순번 (현재 순번으로 미리보기, 증가 안 함)
// 순번 (다음 할당될 순번으로 미리보기, 실제 증가는 allocate 시)
const length = autoConfig.sequenceLength || 3;
return String(rule.currentSequence || 1).padStart(length, "0");
const nextSequence = (rule.currentSequence || 0) + 1;
return String(nextSequence).padStart(length, "0");
}
case "number": {

View File

@ -176,7 +176,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
loadGroupData();
}
}
}, [modalState.isOpen, modalState.screenId]);
}, [modalState.isOpen, modalState.screenId, modalState.groupByColumns, modalState.tableName]);
// 🆕 그룹 데이터 조회 함수
const loadGroupData = async () => {
@ -225,7 +225,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
const dataArray = Array.isArray(response) ? response : response?.data || [];
if (dataArray.length > 0) {
console.log("✅ 그룹 데이터 조회 성공:", dataArray);
console.log("✅ 그룹 데이터 조회 성공:", dataArray.length, "건");
setGroupData(dataArray);
setOriginalGroupData(JSON.parse(JSON.stringify(dataArray))); // Deep copy
toast.info(`${dataArray.length}개의 관련 품목을 불러왔습니다.`);
@ -751,15 +751,8 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
},
};
// 🔍 디버깅: 컴포넌트 렌더링 시점의 groupData 확인
if (component.id === screenData.components[0]?.id) {
console.log("🔍 [EditModal] InteractiveScreenViewerDynamic props:", {
componentId: component.id,
groupDataLength: groupData.length,
groupData: groupData,
formData: groupData.length > 0 ? groupData[0] : formData,
});
}
const groupedDataProp = groupData.length > 0 ? groupData : undefined;
// 🔑 첨부파일 컴포넌트가 행(레코드) 단위로 파일을 저장할 수 있도록 tableName 추가
const enrichedFormData = {
@ -811,7 +804,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
onSave={handleSave}
isInModal={true}
// 🆕 그룹 데이터를 ModalRepeaterTable에 전달
groupedData={groupData.length > 0 ? groupData : undefined}
groupedData={groupedDataProp}
// 🆕 수정 모달에서 읽기 전용 필드 지정 (수주번호, 거래처)
disabledFields={["order_no", "partner_id"]}
/>

View File

@ -267,7 +267,7 @@ export const FloatingPanel: React.FC<FloatingPanelProps> = ({
{/* 컨텐츠 */}
<div
ref={contentRef}
className={autoHeight ? "flex-1" : "flex-1 overflow-auto"}
className={autoHeight ? "flex-1 w-full overflow-hidden" : "flex-1 w-full overflow-y-auto overflow-x-hidden"}
style={
autoHeight
? {}

View File

@ -878,7 +878,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
};
return (
<div className="space-y-4" key={selectedComponent.id}>
<div className="space-y-4 w-full min-w-0" key={selectedComponent.id}>
<div className="flex items-center gap-2 border-b pb-2">
<Settings className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold">{definition.name} </h3>
@ -998,7 +998,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
</div>
{/* 설정 패널 영역 */}
<div className="flex-1 overflow-y-auto p-4">{renderComponentConfigPanel()}</div>
<div className="flex-1 overflow-y-auto overflow-x-hidden p-4 w-full">{renderComponentConfigPanel()}</div>
</div>
);
}
@ -1156,8 +1156,8 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
</div>
{/* 컴포넌트 설정 패널 */}
<div className="flex-1 overflow-y-auto px-6 pb-6">
<div className="space-y-6">
<div className="flex-1 overflow-y-auto overflow-x-hidden px-6 pb-6 w-full min-w-0">
<div className="space-y-6 w-full min-w-0">
{/* DynamicComponentConfigPanel */}
<DynamicComponentConfigPanel
componentId={componentId}
@ -1396,8 +1396,8 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
</div>
{/* 상세 설정 영역 */}
<div className="flex-1 overflow-y-auto p-4">
<div className="space-y-6">
<div className="flex-1 overflow-y-auto overflow-x-hidden p-4 w-full min-w-0">
<div className="space-y-6 w-full min-w-0">
{console.log("🔍 [DetailSettingsPanel] widget 타입:", selectedComponent?.type, "autoFill:", widget.autoFill)}
{/* 🆕 자동 입력 섹션 */}
<div className="space-y-4 rounded-md border border-red-500 bg-yellow-50 p-4">

View File

@ -32,7 +32,7 @@ export const uploadFiles = async (params: {
files: FileList | File[];
tableName?: string;
fieldName?: string;
recordId?: string;
recordId?: string | number;
docType?: string;
docTypeName?: string;
targetObjid?: string;
@ -43,6 +43,7 @@ export const uploadFiles = async (params: {
columnName?: string;
isVirtualFileColumn?: boolean;
companyCode?: string; // 🔒 멀티테넌시: 회사 코드
isRecordMode?: boolean; // 🆕 레코드 모드 플래그
}): Promise<FileUploadResponse> => {
const formData = new FormData();
@ -55,7 +56,7 @@ export const uploadFiles = async (params: {
// 추가 파라미터들 추가
if (params.tableName) formData.append("tableName", params.tableName);
if (params.fieldName) formData.append("fieldName", params.fieldName);
if (params.recordId) formData.append("recordId", params.recordId);
if (params.recordId) formData.append("recordId", String(params.recordId));
if (params.docType) formData.append("docType", params.docType);
if (params.docTypeName) formData.append("docTypeName", params.docTypeName);
if (params.targetObjid) formData.append("targetObjid", params.targetObjid);
@ -66,6 +67,8 @@ export const uploadFiles = async (params: {
if (params.columnName) formData.append("columnName", params.columnName);
if (params.isVirtualFileColumn !== undefined) formData.append("isVirtualFileColumn", params.isVirtualFileColumn.toString());
if (params.companyCode) formData.append("companyCode", params.companyCode); // 🔒 멀티테넌시
// 🆕 레코드 모드 플래그 추가 (백엔드에서 attachments 컬럼 자동 업데이트용)
if (params.isRecordMode !== undefined) formData.append("isRecordMode", params.isRecordMode.toString());
const response = await apiClient.post("/files/upload", formData, {
headers: {

View File

@ -317,7 +317,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// modal-repeater-table은 배열 데이터를 다루므로 빈 배열로 초기화
let currentValue;
if (componentType === "modal-repeater-table") {
if (componentType === "modal-repeater-table" || componentType === "repeat-screen-modal") {
// EditModal에서 전달된 groupedData가 있으면 우선 사용
currentValue = props.groupedData || formData?.[fieldName] || [];
} else {
@ -449,7 +449,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 🆕 그룹 데이터 전달 (EditModal → ConditionalContainer → ModalRepeaterTable)
// Note: 이 props들은 DOM 요소에 전달되면 안 됨
// 각 컴포넌트에서 명시적으로 destructure하여 사용해야 함
_groupedData: props.groupedData,
groupedData: props.groupedData, // ✅ 언더스코어 제거하여 직접 전달
_groupedData: props.groupedData, // 하위 호환성 유지
// 🆕 UniversalFormModal용 initialData 전달
// originalData를 사용 (최초 전달된 값, formData는 계속 변경되므로 사용하면 안됨)
_initialData: originalData || formData,

View File

@ -602,6 +602,9 @@ export const AccordionBasicComponent: React.FC<AccordionBasicComponentProps> = (
isInModal: _isInModal,
isPreview: _isPreview,
originalData: _originalData,
_originalData: __originalData,
_initialData: __initialData,
_groupedData: __groupedData,
allComponents: _allComponents,
selectedRows: _selectedRows,
selectedRowsData: _selectedRowsData,

View File

@ -984,6 +984,9 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
flowSelectedStepId: _flowSelectedStepId, // 플로우 선택 스텝 ID 필터링
onFlowRefresh: _onFlowRefresh, // 플로우 새로고침 콜백 필터링
originalData: _originalData, // 부분 업데이트용 원본 데이터 필터링
_originalData: __originalData, // DOM 필터링
_initialData: __initialData, // DOM 필터링
_groupedData: __groupedData, // DOM 필터링
refreshKey: _refreshKey, // 필터링 추가
isInModal: _isInModal, // 필터링 추가
mode: _mode, // 필터링 추가

View File

@ -86,6 +86,9 @@ export const DividerLineComponent: React.FC<DividerLineComponentProps> = ({
isInModal: _isInModal,
readonly: _readonly,
originalData: _originalData,
_originalData: __originalData,
_initialData: __initialData,
_groupedData: __groupedData,
allComponents: _allComponents,
onUpdateLayout: _onUpdateLayout,
selectedRows: _selectedRows,

View File

@ -603,10 +603,16 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
targetObjid,
});
// 🔑 레코드 모드일 때는 effectiveTableName을 우선 사용
// formData.linkedTable이 'screen_files' 같은 기본값일 수 있으므로 레코드 모드에서는 무시
const finalLinkedTable = effectiveIsRecordMode
? effectiveTableName
: (formData?.linkedTable || effectiveTableName);
const uploadData = {
// 🎯 formData에서 백엔드 API 설정 가져오기
autoLink: formData?.autoLink || true,
linkedTable: formData?.linkedTable || effectiveTableName,
linkedTable: finalLinkedTable,
recordId: effectiveRecordId || `temp_${component.id}`,
columnName: effectiveColumnName,
isVirtualFileColumn: formData?.isVirtualFileColumn || true,
@ -621,12 +627,26 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
isRecordMode: effectiveIsRecordMode,
};
console.log("📤 [FileUploadComponent] uploadData 최종:", {
isRecordMode: effectiveIsRecordMode,
linkedTable: finalLinkedTable,
recordId: effectiveRecordId,
columnName: effectiveColumnName,
targetObjid,
});
console.log("🚀 [FileUploadComponent] uploadFiles API 호출 직전:", {
filesCount: filesToUpload.length,
uploadData,
});
const response = await uploadFiles({
files: filesToUpload,
...uploadData,
});
console.log("📥 [FileUploadComponent] uploadFiles API 응답:", response);
if (response.success) {
// FileUploadResponse 타입에 맞게 files 배열 사용

View File

@ -37,6 +37,71 @@ export function RepeaterTable({
// 동적 데이터 소스 Popover 열림 상태
const [openPopover, setOpenPopover] = useState<string | null>(null);
// 컬럼 너비 상태 관리
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(() => {
const widths: Record<string, number> = {};
columns.forEach((col) => {
widths[col.field] = col.width ? parseInt(col.width) : 120;
});
return widths;
});
// 기본 너비 저장 (리셋용)
const defaultWidths = React.useMemo(() => {
const widths: Record<string, number> = {};
columns.forEach((col) => {
widths[col.field] = col.width ? parseInt(col.width) : 120;
});
return widths;
}, [columns]);
// 리사이즈 상태
const [resizing, setResizing] = useState<{ field: string; startX: number; startWidth: number } | null>(null);
// 리사이즈 핸들러
const handleMouseDown = (e: React.MouseEvent, field: string) => {
e.preventDefault();
setResizing({
field,
startX: e.clientX,
startWidth: columnWidths[field] || 120,
});
};
// 더블클릭으로 기본 너비로 리셋
const handleDoubleClick = (field: string) => {
setColumnWidths((prev) => ({
...prev,
[field]: defaultWidths[field] || 120,
}));
};
useEffect(() => {
if (!resizing) return;
const handleMouseMove = (e: MouseEvent) => {
if (!resizing) return;
const diff = e.clientX - resizing.startX;
const newWidth = Math.max(60, resizing.startWidth + diff);
setColumnWidths((prev) => ({
...prev,
[resizing.field]: newWidth,
}));
};
const handleMouseUp = () => {
setResizing(null);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [resizing, columns, data]);
// 데이터 변경 감지 (필요시 활성화)
// useEffect(() => {
// console.log("📊 RepeaterTable 데이터 업데이트:", data.length, "개 행");
@ -79,7 +144,7 @@ export function RepeaterTable({
onChange={(e) =>
handleCellEdit(rowIndex, column.field, parseFloat(e.target.value) || 0)
}
className="h-7 text-xs"
className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none"
/>
);
@ -107,7 +172,7 @@ export function RepeaterTable({
type="date"
value={formatDateValue(value)}
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
className="h-7 text-xs"
className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none"
/>
);
@ -119,7 +184,7 @@ export function RepeaterTable({
handleCellEdit(rowIndex, column.field, newValue)
}
>
<SelectTrigger className="h-7 text-xs">
<SelectTrigger className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none">
<SelectValue />
</SelectTrigger>
<SelectContent>
@ -138,19 +203,19 @@ export function RepeaterTable({
type="text"
value={value || ""}
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
className="h-7 text-xs"
className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none"
/>
);
}
};
return (
<div className="border rounded-md overflow-hidden bg-background">
<div className="overflow-x-auto max-h-[240px] overflow-y-auto">
<table className="w-full text-xs sm:text-sm">
<thead className="bg-muted sticky top-0 z-10">
<div className="border border-gray-200 bg-white">
<div className="overflow-x-auto max-h-[400px] overflow-y-auto">
<table className="w-full text-xs border-collapse">
<thead className="bg-gray-50 sticky top-0 z-10">
<tr>
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-12">
<th className="px-3 py-2 text-left font-medium text-gray-700 border-b border-r border-gray-200 w-12">
#
</th>
{columns.map((col) => {
@ -163,101 +228,113 @@ export function RepeaterTable({
return (
<th
key={col.field}
className="px-4 py-2 text-left font-medium text-muted-foreground"
style={{ width: col.width }}
className="px-3 py-2 text-left font-medium text-gray-700 border-b border-r border-gray-200 relative group cursor-pointer select-none"
style={{ width: `${columnWidths[col.field]}px` }}
onDoubleClick={() => handleDoubleClick(col.field)}
title="더블클릭하여 기본 너비로 되돌리기"
>
{hasDynamicSource ? (
<Popover
open={openPopover === col.field}
onOpenChange={(open) => setOpenPopover(open ? col.field : null)}
>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"inline-flex items-center gap-1 hover:text-primary transition-colors",
"focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 rounded px-1 -mx-1"
)}
<div className="flex items-center justify-between pointer-events-none">
<div className="flex items-center gap-1 pointer-events-auto">
{hasDynamicSource ? (
<Popover
open={openPopover === col.field}
onOpenChange={(open) => setOpenPopover(open ? col.field : null)}
>
<span>{col.label}</span>
<ChevronDown className="h-3 w-3 opacity-60" />
</button>
</PopoverTrigger>
<PopoverContent
className="w-auto min-w-[160px] p-1"
align="start"
sideOffset={4}
>
<div className="text-[10px] text-muted-foreground px-2 py-1 border-b mb-1">
</div>
{col.dynamicDataSource!.options.map((option) => (
<button
key={option.id}
type="button"
onClick={() => {
onDataSourceChange?.(col.field, option.id);
setOpenPopover(null);
}}
className={cn(
"w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm",
"hover:bg-accent hover:text-accent-foreground transition-colors",
"focus:outline-none focus-visible:bg-accent",
activeOption?.id === option.id && "bg-accent/50"
)}
>
<Check
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"h-3 w-3",
activeOption?.id === option.id ? "opacity-100" : "opacity-0"
"inline-flex items-center gap-1 hover:text-blue-600 transition-colors",
"focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 rounded px-1 -mx-1"
)}
/>
<span>{option.label}</span>
</button>
))}
</PopoverContent>
</Popover>
) : (
<>
{col.label}
{col.required && <span className="text-destructive ml-1">*</span>}
</>
)}
</th>
>
<span>{col.label}</span>
<ChevronDown className="h-3 w-3 opacity-60" />
</button>
</PopoverTrigger>
<PopoverContent
className="w-auto min-w-[160px] p-1"
align="start"
sideOffset={4}
>
<div className="text-[10px] text-muted-foreground px-2 py-1 border-b mb-1">
</div>
{col.dynamicDataSource!.options.map((option) => (
<button
key={option.id}
type="button"
onClick={() => {
onDataSourceChange?.(col.field, option.id);
setOpenPopover(null);
}}
className={cn(
"w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm",
"hover:bg-accent hover:text-accent-foreground transition-colors",
"focus:outline-none focus-visible:bg-accent",
activeOption?.id === option.id && "bg-accent/50"
)}
>
<Check
className={cn(
"h-3 w-3",
activeOption?.id === option.id ? "opacity-100" : "opacity-0"
)}
/>
<span>{option.label}</span>
</button>
))}
</PopoverContent>
</Popover>
) : (
<>
{col.label}
{col.required && <span className="text-red-500 ml-1">*</span>}
</>
)}
</div>
{/* 리사이즈 핸들 */}
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-blue-500 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-auto"
onMouseDown={(e) => handleMouseDown(e, col.field)}
title="드래그하여 너비 조정"
/>
</div>
</th>
);
})}
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-20">
<th className="px-3 py-2 text-left font-medium text-gray-700 border-b border-r border-gray-200 w-20">
</th>
</tr>
</thead>
<tbody className="bg-background">
<tbody className="bg-white">
{data.length === 0 ? (
<tr>
<td
colSpan={columns.length + 2}
className="px-4 py-8 text-center text-muted-foreground"
className="px-4 py-8 text-center text-gray-500 border-b border-gray-200"
>
</td>
</tr>
) : (
data.map((row, rowIndex) => (
<tr key={rowIndex} className="border-t hover:bg-accent/50">
<td className="px-4 py-2 text-center text-muted-foreground">
<tr key={rowIndex} className="hover:bg-blue-50/50 transition-colors">
<td className="px-3 py-1 text-center text-gray-600 border-b border-r border-gray-200">
{rowIndex + 1}
</td>
{columns.map((col) => (
<td key={col.field} className="px-2 py-1">
<td key={col.field} className="px-1 py-1 border-b border-r border-gray-200">
{renderCell(row, col, rowIndex)}
</td>
))}
<td className="px-4 py-2 text-center">
<td className="px-3 py-1 text-center border-b border-r border-gray-200">
<Button
variant="ghost"
size="sm"
onClick={() => onRowDelete(rowIndex)}
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
className="h-7 w-7 p-0 text-red-500 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>

View File

@ -52,11 +52,15 @@ export function RepeatScreenModalComponent({
config,
className,
groupedData: propsGroupedData, // EditModal에서 전달받는 그룹 데이터
// DynamicComponentRenderer에서 전달되는 props (DOM 전달 방지)
_initialData,
_originalData: _propsOriginalData,
_groupedData,
...props
}: RepeatScreenModalComponentProps) {
}: RepeatScreenModalComponentProps & { _initialData?: any; _originalData?: any; _groupedData?: any }) {
// props에서도 groupedData를 추출 (DynamicWebTypeRenderer에서 전달될 수 있음)
// DynamicComponentRenderer에서는 _groupedData로 전달됨
const groupedData = propsGroupedData || (props as any).groupedData || (props as any)._groupedData;
const groupedData = propsGroupedData || (props as any).groupedData || _groupedData;
const componentConfig = {
...config,
...component?.config,

View File

@ -2517,7 +2517,7 @@ function LayoutRowConfigModal({
</div>
{/* 외부 데이터 소스 설정 */}
<div className="border rounded p-3 bg-blue-50 space-y-2">
<div className="border rounded p-3 bg-blue-50 space-y-3">
<div className="flex items-center justify-between">
<Label className="text-xs font-semibold"> </Label>
<Switch
@ -2529,28 +2529,234 @@ function LayoutRowConfigModal({
/>
</div>
{row.tableDataSource?.enabled && (
<div className="grid grid-cols-2 gap-2">
<>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
<TableSelector
value={row.tableDataSource?.sourceTable || ""}
onValueChange={(value) => onUpdateRow({
onChange={(value) => onUpdateRow({
tableDataSource: { ...row.tableDataSource!, sourceTable: value }
})}
>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{allTables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.displayName || table.tableName}
</SelectItem>
))}
</SelectContent>
</Select>
/>
</div>
</div>
{/* 조인 조건 설정 */}
<div className="space-y-2 pt-2 border-t border-blue-200">
<div className="space-y-1">
<Label className="text-[10px] font-semibold"> </Label>
<p className="text-[9px] text-muted-foreground">
</p>
</div>
{(row.tableDataSource?.joinConditions || []).map((condition, conditionIndex) => (
<div key={`join-${conditionIndex}`} className="space-y-2 p-2 border rounded bg-white">
<div className="flex items-center justify-between">
<span className="text-[9px] font-medium"> {conditionIndex + 1}</span>
<Button
size="sm"
variant="ghost"
onClick={() => {
const newConditions = [...(row.tableDataSource?.joinConditions || [])];
newConditions.splice(conditionIndex, 1);
onUpdateRow({
tableDataSource: { ...row.tableDataSource!, joinConditions: newConditions }
});
}}
className="h-5 w-5 p-0"
>
<X className="h-3 w-3" />
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-[9px]"> ()</Label>
<SourceColumnSelector
sourceTable={row.tableDataSource?.sourceTable || ""}
value={condition.sourceKey}
onChange={(value) => {
const newConditions = [...(row.tableDataSource?.joinConditions || [])];
newConditions[conditionIndex] = { ...condition, sourceKey: value };
onUpdateRow({
tableDataSource: { ...row.tableDataSource!, joinConditions: newConditions }
});
}}
placeholder="예: sales_order_id"
/>
<p className="text-[8px] text-muted-foreground">
</p>
</div>
<div className="space-y-1">
<Label className="text-[9px]"> ()</Label>
<SourceColumnSelector
sourceTable={dataSourceTable || ""}
value={condition.referenceKey}
onChange={(value) => {
const newConditions = [...(row.tableDataSource?.joinConditions || [])];
newConditions[conditionIndex] = { ...condition, referenceKey: value };
onUpdateRow({
tableDataSource: { ...row.tableDataSource!, joinConditions: newConditions }
});
}}
placeholder="예: id"
/>
<p className="text-[8px] text-muted-foreground">
</p>
</div>
</div>
<div className="mt-1 p-1.5 bg-blue-50 rounded border border-blue-100">
<p className="text-[8px] text-blue-700 font-mono">
{row.tableDataSource?.sourceTable}.{condition.sourceKey} = {dataSourceTable}.{condition.referenceKey}
</p>
<p className="text-[8px] text-muted-foreground mt-0.5">
</p>
</div>
</div>
))}
<Button
size="sm"
variant="outline"
onClick={() => {
const newConditions = [
...(row.tableDataSource?.joinConditions || []),
{ sourceKey: "", referenceKey: "", referenceType: "card" as const },
];
onUpdateRow({
tableDataSource: { ...row.tableDataSource!, joinConditions: newConditions }
});
}}
className="w-full h-7 text-[9px]"
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
{/* 필터 설정 */}
<div className="space-y-2 pt-2 border-t border-blue-200">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label className="text-[10px] font-semibold"> </Label>
<p className="text-[9px] text-muted-foreground">
</p>
</div>
<Switch
checked={row.tableDataSource?.filterConfig?.enabled || false}
onCheckedChange={(checked) => {
onUpdateRow({
tableDataSource: {
...row.tableDataSource!,
filterConfig: {
enabled: checked,
filterField: "",
filterType: "notEquals",
referenceField: "",
referenceSource: "representativeData",
},
},
});
}}
className="scale-75"
/>
</div>
{row.tableDataSource?.filterConfig?.enabled && (
<div className="space-y-2 p-2 bg-amber-50 rounded border border-amber-200">
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-[9px]"> </Label>
<SourceColumnSelector
sourceTable={row.tableDataSource?.sourceTable || ""}
value={row.tableDataSource?.filterConfig?.filterField || ""}
onChange={(value) => {
onUpdateRow({
tableDataSource: {
...row.tableDataSource!,
filterConfig: {
...row.tableDataSource!.filterConfig!,
filterField: value,
},
},
});
}}
placeholder="예: order_no"
/>
<p className="text-[8px] text-muted-foreground">
</p>
</div>
<div className="space-y-1">
<Label className="text-[9px]"> </Label>
<SourceColumnSelector
sourceTable={dataSourceTable || ""}
value={row.tableDataSource?.filterConfig?.referenceField || ""}
onChange={(value) => {
onUpdateRow({
tableDataSource: {
...row.tableDataSource!,
filterConfig: {
...row.tableDataSource!.filterConfig!,
referenceField: value,
},
},
});
}}
placeholder="예: order_no"
/>
<p className="text-[8px] text-muted-foreground">
</p>
</div>
</div>
<div className="space-y-1">
<Label className="text-[9px]"> </Label>
<Select
value={row.tableDataSource?.filterConfig?.filterType || "notEquals"}
onValueChange={(value: "equals" | "notEquals") => {
onUpdateRow({
tableDataSource: {
...row.tableDataSource!,
filterConfig: {
...row.tableDataSource!.filterConfig!,
filterType: value,
},
},
});
}}
>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="notEquals"> ( )</SelectItem>
<SelectItem value="equals"> ()</SelectItem>
</SelectContent>
</Select>
</div>
<div className="mt-2 p-1.5 bg-amber-100 rounded border border-amber-200">
<p className="text-[8px] text-amber-800 font-mono">
{row.tableDataSource?.sourceTable}.{row.tableDataSource?.filterConfig?.filterField} != .{row.tableDataSource?.filterConfig?.referenceField}
</p>
<p className="text-[8px] text-muted-foreground mt-0.5">
{row.tableDataSource?.filterConfig?.filterType === "notEquals"
? "현재 선택한 행과 다른 데이터만 표시합니다"
: "현재 선택한 행과 같은 데이터만 표시합니다"}
</p>
</div>
</div>
)}
</div>
</>
)}
</div>

View File

@ -4065,30 +4065,54 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}
// 📎 첨부파일 타입: 파일 아이콘과 개수 표시
if (inputType === "file" || inputType === "attachment" || column.columnName === "attachments") {
// 컬럼명이 'attachments'를 포함하거나, inputType이 file/attachment인 경우
const isAttachmentColumn =
inputType === "file" ||
inputType === "attachment" ||
column.columnName === "attachments" ||
column.columnName?.toLowerCase().includes("attachment") ||
column.columnName?.toLowerCase().includes("file");
if (isAttachmentColumn) {
// JSONB 배열 또는 JSON 문자열 파싱
let files: any[] = [];
try {
if (typeof value === "string") {
files = JSON.parse(value);
if (typeof value === "string" && value.trim()) {
const parsed = JSON.parse(value);
files = Array.isArray(parsed) ? parsed : [];
} else if (Array.isArray(value)) {
files = value;
} else if (value && typeof value === "object") {
// 단일 객체인 경우 배열로 변환
files = [value];
}
} catch {
} catch (e) {
// 파싱 실패 시 빈 배열
console.warn("📎 [TableList] 첨부파일 파싱 실패:", { columnName: column.columnName, value, error: e });
}
if (!files || files.length === 0) {
return <span className="text-muted-foreground text-xs">-</span>;
}
// 파일 개수와 아이콘 표시
// 파일 이름 표시 (여러 개면 쉼표로 구분)
const { Paperclip } = require("lucide-react");
const fileNames = files.map((f: any) => f.realFileName || f.real_file_name || f.name || "파일").join(", ");
return (
<div className="flex items-center gap-1 text-sm">
<Paperclip className="h-4 w-4 text-gray-500" />
<span className="text-blue-600 font-medium">{files.length}</span>
<span className="text-muted-foreground text-xs"></span>
<div className="flex items-center gap-1.5 text-sm max-w-full">
<Paperclip className="h-4 w-4 text-gray-500 flex-shrink-0" />
<span
className="text-blue-600 truncate"
title={fileNames}
>
{fileNames}
</span>
{files.length > 1 && (
<span className="text-muted-foreground text-xs flex-shrink-0">
({files.length})
</span>
)}
</div>
);
}

View File

@ -224,6 +224,9 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
isInModal: _isInModal,
isPreview: _isPreview,
originalData: _originalData,
_originalData: __originalData,
_initialData: __initialData,
_groupedData: __groupedData,
allComponents: _allComponents,
selectedRows: _selectedRows,
selectedRowsData: _selectedRowsData,

View File

@ -126,11 +126,13 @@ export function UniversalFormModalComponent({
initialData: propInitialData,
// DynamicComponentRenderer에서 전달되는 props (DOM 전달 방지를 위해 _ prefix 사용)
_initialData,
_originalData,
_groupedData,
onSave,
onCancel,
onChange,
...restProps // 나머지 props는 DOM에 전달하지 않음
}: UniversalFormModalComponentProps & { _initialData?: any }) {
}: UniversalFormModalComponentProps & { _initialData?: any; _originalData?: any; _groupedData?: any }) {
// initialData 우선순위: 직접 전달된 prop > DynamicComponentRenderer에서 전달된 prop
const initialData = propInitialData || _initialData;
// 설정 병합
@ -216,14 +218,24 @@ export function UniversalFormModalComponent({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 빈 의존성 배열 - 마운트 시 한 번만 실행
// config 변경 시에만 재초기화 (initialData 변경은 무시)
// config 변경 시에만 재초기화 (initialData 변경은 무시) - 채번규칙 제외
useEffect(() => {
if (!hasInitialized.current) return; // 최초 초기화 전이면 스킵
initializeForm();
console.log('[useEffect config 변경] 재초기화 스킵 (채번 중복 방지)');
// initializeForm(); // 주석 처리 - config 변경 시 재초기화 안 함 (채번 중복 방지)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]);
// 컴포넌트 unmount 시 채번 플래그 초기화
useEffect(() => {
return () => {
console.log('[채번] 컴포넌트 unmount - 플래그 초기화');
numberingGeneratedRef.current = false;
isGeneratingRef.current = false;
};
}, []);
// 🆕 beforeFormSave 이벤트 리스너 - ButtonPrimary 저장 시 formData를 전달
// 설정된 필드(columnName)만 병합하여 의도치 않은 덮어쓰기 방지
useEffect(() => {
@ -301,6 +313,8 @@ export function UniversalFormModalComponent({
// 폼 초기화
const initializeForm = useCallback(async () => {
console.log('[initializeForm] 시작');
// 캡처된 initialData 사용 (props로 전달된 initialData가 아닌)
const effectiveInitialData = capturedInitialData.current || initialData;
@ -351,7 +365,9 @@ export function UniversalFormModalComponent({
setOriginalData(effectiveInitialData || {});
// 채번규칙 자동 생성
console.log('[initializeForm] generateNumberingValues 호출');
await generateNumberingValues(newFormData);
console.log('[initializeForm] 완료');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]); // initialData는 의존성에서 제거 (capturedInitialData.current 사용)
@ -369,9 +385,26 @@ export function UniversalFormModalComponent({
return item;
};
// 채번규칙 자동 생성
// 채번규칙 자동 생성 (중복 호출 방지)
const numberingGeneratedRef = useRef(false);
const isGeneratingRef = useRef(false); // 진행 중 플래그 추가
const generateNumberingValues = useCallback(
async (currentFormData: FormDataState) => {
// 이미 생성되었거나 진행 중이면 스킵
if (numberingGeneratedRef.current) {
console.log('[채번] 이미 생성됨 - 스킵');
return;
}
if (isGeneratingRef.current) {
console.log('[채번] 생성 진행 중 - 스킵');
return;
}
isGeneratingRef.current = true; // 진행 중 표시
console.log('[채번] 생성 시작');
const updatedData = { ...currentFormData };
let hasChanges = false;
@ -386,10 +419,14 @@ export function UniversalFormModalComponent({
!updatedData[field.columnName]
) {
try {
const response = await generateNumberingCode(field.numberingRule.ruleId);
console.log(`[채번 API 호출] ${field.columnName}, ruleId: ${field.numberingRule.ruleId}`);
// generateOnOpen: 모달 열 때 실제 순번 할당 (DB 시퀀스 즉시 증가)
const response = await allocateNumberingCode(field.numberingRule.ruleId);
if (response.success && response.data?.generatedCode) {
updatedData[field.columnName] = response.data.generatedCode;
hasChanges = true;
numberingGeneratedRef.current = true; // 생성 완료 표시
console.log(`[채번 완료] ${field.columnName} = ${response.data.generatedCode}`);
}
} catch (error) {
console.error(`채번규칙 생성 실패 (${field.columnName}):`, error);
@ -398,6 +435,8 @@ export function UniversalFormModalComponent({
}
}
isGeneratingRef.current = false; // 진행 완료
if (hasChanges) {
setFormData(updatedData);
}
@ -629,23 +668,16 @@ export function UniversalFormModalComponent({
}
});
// 저장 시점 채번규칙 처리 (allocateNumberingCode로 실제 순번 증가)
// 저장 시점 채번규칙 처리 (generateOnSave만 처리)
for (const section of config.sections) {
for (const field of section.fields) {
if (field.numberingRule?.enabled && field.numberingRule?.ruleId) {
// generateOnSave: 저장 시 새로 생성
// generateOnOpen: 열 때 미리보기로 표시했지만, 저장 시 실제 순번 할당 필요
if (field.numberingRule.generateOnSave && !dataToSave[field.columnName]) {
const response = await allocateNumberingCode(field.numberingRule.ruleId);
if (response.success && response.data?.generatedCode) {
dataToSave[field.columnName] = response.data.generatedCode;
}
} else if (field.numberingRule.generateOnOpen && dataToSave[field.columnName]) {
// generateOnOpen인 경우, 미리보기 값이 있더라도 실제 순번 할당
const response = await allocateNumberingCode(field.numberingRule.ruleId);
if (response.success && response.data?.generatedCode) {
dataToSave[field.columnName] = response.data.generatedCode;
}
if (field.numberingRule?.enabled && field.numberingRule?.generateOnSave && field.numberingRule?.ruleId) {
const response = await allocateNumberingCode(field.numberingRule.ruleId);
if (response.success && response.data?.generatedCode) {
dataToSave[field.columnName] = response.data.generatedCode;
console.log(`[채번 할당] ${field.columnName} = ${response.data.generatedCode}`);
} else {
console.error(`[채번 실패] ${field.columnName}:`, response.error);
}
}
}

View File

@ -0,0 +1,842 @@
"use client";
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Plus, Trash2, Settings as SettingsIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import {
FormFieldConfig,
LinkedFieldMapping,
FIELD_TYPE_OPTIONS,
SELECT_OPTION_TYPE_OPTIONS,
LINKED_FIELD_DISPLAY_FORMAT_OPTIONS,
} from "../types";
// 도움말 텍스트 컴포넌트
const HelpText = ({ children }: { children: React.ReactNode }) => (
<p className="text-[10px] text-muted-foreground mt-0.5">{children}</p>
);
interface FieldDetailSettingsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
field: FormFieldConfig;
onSave: (updates: Partial<FormFieldConfig>) => void;
tables: { name: string; label: string }[];
tableColumns: { [tableName: string]: { name: string; type: string; label: string }[] };
numberingRules: { id: string; name: string }[];
onLoadTableColumns: (tableName: string) => void;
}
export function FieldDetailSettingsModal({
open,
onOpenChange,
field,
onSave,
tables,
tableColumns,
numberingRules,
onLoadTableColumns,
}: FieldDetailSettingsModalProps) {
// 로컬 상태로 필드 설정 관리
const [localField, setLocalField] = useState<FormFieldConfig>(field);
// open이 변경될 때마다 필드 데이터 동기화
useEffect(() => {
if (open) {
setLocalField(field);
}
}, [open, field]);
// 필드 업데이트 함수
const updateField = (updates: Partial<FormFieldConfig>) => {
setLocalField((prev) => ({ ...prev, ...updates }));
};
// 저장 함수
const handleSave = () => {
onSave(localField);
onOpenChange(false);
};
// 연결 필드 매핑 추가
const addLinkedFieldMapping = () => {
const newMapping: LinkedFieldMapping = {
sourceColumn: "",
targetColumn: "",
};
const mappings = [...(localField.linkedFieldGroup?.mappings || []), newMapping];
updateField({
linkedFieldGroup: {
...localField.linkedFieldGroup,
enabled: true,
mappings,
},
});
};
// 연결 필드 매핑 삭제
const removeLinkedFieldMapping = (index: number) => {
const mappings = [...(localField.linkedFieldGroup?.mappings || [])];
mappings.splice(index, 1);
updateField({
linkedFieldGroup: {
...localField.linkedFieldGroup,
mappings,
},
});
};
// 연결 필드 매핑 업데이트
const updateLinkedFieldMapping = (index: number, updates: Partial<LinkedFieldMapping>) => {
const mappings = [...(localField.linkedFieldGroup?.mappings || [])];
mappings[index] = { ...mappings[index], ...updates };
updateField({
linkedFieldGroup: {
...localField.linkedFieldGroup,
mappings,
},
});
};
// 소스 테이블 컬럼 목록
const sourceTableColumns = localField.linkedFieldGroup?.sourceTable
? tableColumns[localField.linkedFieldGroup.sourceTable] || []
: [];
// Select 옵션의 참조 테이블 컬럼 목록
const selectTableColumns = localField.selectOptions?.tableName
? tableColumns[localField.selectOptions.tableName] || []
: [];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-[700px] max-h-[85vh] flex flex-col p-0">
<DialogHeader className="px-4 pt-4 pb-2 border-b shrink-0">
<DialogTitle className="text-base"> : {localField.label}</DialogTitle>
<DialogDescription className="text-xs">
, , .
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden px-4">
<ScrollArea className="h-[calc(85vh-180px)]">
<div className="space-y-4 py-3 pr-3">
{/* 기본 정보 섹션 */}
<div className="space-y-3 border rounded-lg p-3 bg-card">
<h3 className="text-xs font-semibold"> </h3>
<div>
<Label className="text-[10px]"> </Label>
<Select
value={localField.fieldType}
onValueChange={(value) =>
updateField({
fieldType: value as FormFieldConfig["fieldType"],
})
}
>
<SelectTrigger className="h-7 text-xs mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<HelpText> (, , )</HelpText>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Select
value={String(localField.gridSpan || 6)}
onValueChange={(value) => updateField({ gridSpan: parseInt(value) })}
>
<SelectTrigger className="h-7 text-xs mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="3">1/4 </SelectItem>
<SelectItem value="4">1/3 </SelectItem>
<SelectItem value="6">1/2 </SelectItem>
<SelectItem value="8">2/3 </SelectItem>
<SelectItem value="12"> </SelectItem>
</SelectContent>
</Select>
<HelpText> (12 )</HelpText>
</div>
<div>
<Label className="text-[10px]"></Label>
<Input
value={localField.placeholder || ""}
onChange={(e) => updateField({ placeholder: e.target.value })}
placeholder="입력 힌트"
className="h-7 text-xs mt-1"
/>
<HelpText> </HelpText>
</div>
</div>
{/* 옵션 토글 */}
<div className="space-y-2 border rounded-lg p-3 bg-card">
<h3 className="text-xs font-semibold mb-2"> </h3>
<div className="flex items-center justify-between">
<span className="text-[10px]"> </span>
<Switch
checked={localField.required || false}
onCheckedChange={(checked) => updateField({ required: checked })}
/>
</div>
<HelpText> </HelpText>
<Separator className="my-2" />
<div className="flex items-center justify-between">
<span className="text-[10px]"> ()</span>
<Switch
checked={localField.disabled || false}
onCheckedChange={(checked) => updateField({ disabled: checked })}
/>
</div>
<HelpText> </HelpText>
<Separator className="my-2" />
<div className="flex items-center justify-between">
<span className="text-[10px]"> ( )</span>
<Switch
checked={localField.hidden || false}
onCheckedChange={(checked) => updateField({ hidden: checked })}
/>
</div>
<HelpText> </HelpText>
<Separator className="my-2" />
<div className="flex items-center justify-between">
<span className="text-[10px]"> </span>
<Switch
checked={localField.receiveFromParent || false}
onCheckedChange={(checked) => updateField({ receiveFromParent: checked })}
/>
</div>
<HelpText> </HelpText>
</div>
{/* Accordion으로 고급 설정 */}
<Accordion type="single" collapsible className="space-y-2">
{/* Select 옵션 설정 */}
{localField.fieldType === "select" && (
<AccordionItem value="select-options" className="border rounded-lg">
<AccordionTrigger className="px-3 py-2 text-xs font-medium hover:no-underline bg-green-50/50">
<div className="flex items-center gap-2">
<SettingsIcon className="h-3.5 w-3.5 text-green-600" />
<span>Select </span>
{localField.selectOptions?.type && (
<span className="text-[9px] text-muted-foreground">
({localField.selectOptions.type === "table" ? "테이블 참조" : localField.selectOptions.type === "code" ? "공통코드" : "직접 입력"})
</span>
)}
</div>
</AccordionTrigger>
<AccordionContent className="px-3 pb-3 space-y-3">
<HelpText> .</HelpText>
<div>
<Label className="text-[10px]"> </Label>
<Select
value={localField.selectOptions?.type || "static"}
onValueChange={(value) =>
updateField({
selectOptions: {
...localField.selectOptions,
type: value as "static" | "table" | "code",
},
})
}
>
<SelectTrigger className="h-7 text-xs mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SELECT_OPTION_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{localField.selectOptions?.type === "table" && (
<div className="space-y-3 pt-2 border-t">
<HelpText> 참조: DB .</HelpText>
<div>
<Label className="text-[10px]"> </Label>
<Select
value={localField.selectOptions?.tableName || ""}
onValueChange={(value) => {
updateField({
selectOptions: {
...localField.selectOptions,
tableName: value,
},
});
onLoadTableColumns(value);
}}
>
<SelectTrigger className="h-7 text-xs mt-1">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{tables.map((t) => (
<SelectItem key={t.name} value={t.name}>
{t.label || t.name}
</SelectItem>
))}
</SelectContent>
</Select>
<HelpText> </HelpText>
</div>
<div>
<Label className="text-[10px]"> ()</Label>
{selectTableColumns.length > 0 ? (
<Select
value={localField.selectOptions?.valueColumn || ""}
onValueChange={(value) =>
updateField({
selectOptions: {
...localField.selectOptions,
valueColumn: value,
},
})
}
>
<SelectTrigger className="h-7 text-xs mt-1">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{selectTableColumns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
{col.label !== col.name && ` (${col.label})`}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={localField.selectOptions?.valueColumn || ""}
onChange={(e) =>
updateField({
selectOptions: {
...localField.selectOptions,
valueColumn: e.target.value,
},
})
}
placeholder="customer_code"
className="h-7 text-xs mt-1"
/>
)}
<HelpText>
()
<br />
: customer_code, customer_id
</HelpText>
</div>
<div>
<Label className="text-[10px]"> ()</Label>
{selectTableColumns.length > 0 ? (
<Select
value={localField.selectOptions?.labelColumn || ""}
onValueChange={(value) =>
updateField({
selectOptions: {
...localField.selectOptions,
labelColumn: value,
},
})
}
>
<SelectTrigger className="h-7 text-xs mt-1">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{selectTableColumns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
{col.label !== col.name && ` (${col.label})`}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={localField.selectOptions?.labelColumn || ""}
onChange={(e) =>
updateField({
selectOptions: {
...localField.selectOptions,
labelColumn: e.target.value,
},
})
}
placeholder="customer_name"
className="h-7 text-xs mt-1"
/>
)}
<HelpText>
()
<br />
: customer_name, dept_name
</HelpText>
</div>
<div>
<Label className="text-[10px]"> </Label>
{selectTableColumns.length > 0 ? (
<Select
value={localField.selectOptions?.saveColumn || ""}
onValueChange={(value) =>
updateField({
selectOptions: {
...localField.selectOptions,
saveColumn: value,
},
})
}
>
<SelectTrigger className="h-7 text-xs mt-1">
<SelectValue placeholder="컬럼 선택 (미선택 시 조인 컬럼 저장)" />
</SelectTrigger>
<SelectContent>
<SelectItem value=""> ()</SelectItem>
{selectTableColumns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
{col.label !== col.name && ` (${col.label})`}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={localField.selectOptions?.saveColumn || ""}
onChange={(e) =>
updateField({
selectOptions: {
...localField.selectOptions,
saveColumn: e.target.value,
},
})
}
placeholder="비워두면 조인 컬럼 저장"
className="h-7 text-xs mt-1"
/>
)}
<HelpText>
DB에
<br />
: customer_name ( customer_code )
</HelpText>
</div>
</div>
)}
{localField.selectOptions?.type === "code" && (
<div className="space-y-2 pt-2 border-t">
<HelpText>공통코드: 시스템 .</HelpText>
<div>
<Label className="text-[10px]"> </Label>
<Input
value={localField.selectOptions?.codeCategory || ""}
onChange={(e) =>
updateField({
selectOptions: {
...localField.selectOptions,
codeCategory: e.target.value,
},
})
}
placeholder="DEPT_TYPE"
className="h-7 text-xs mt-1"
/>
<HelpText> (: DEPT_TYPE, USER_STATUS)</HelpText>
</div>
</div>
)}
</AccordionContent>
</AccordionItem>
)}
{/* 연결 필드 설정 */}
<AccordionItem value="linked-fields" className="border rounded-lg">
<AccordionTrigger className="px-3 py-2 text-xs font-medium hover:no-underline bg-orange-50/50">
<div className="flex items-center gap-2">
<SettingsIcon className="h-3.5 w-3.5 text-orange-600" />
<span> ( )</span>
{localField.linkedFieldGroup?.enabled && (
<span className="text-[9px] text-muted-foreground">
({(localField.linkedFieldGroup?.mappings || []).length})
</span>
)}
</div>
</AccordionTrigger>
<AccordionContent className="px-3 pb-3 space-y-3">
<div className="flex items-center justify-between">
<span className="text-[10px] font-medium"> </span>
<Switch
checked={localField.linkedFieldGroup?.enabled || false}
onCheckedChange={(checked) =>
updateField({
linkedFieldGroup: {
...localField.linkedFieldGroup,
enabled: checked,
},
})
}
/>
</div>
<HelpText>
.
<br />
: 고객 , ,
</HelpText>
{localField.linkedFieldGroup?.enabled && (
<div className="space-y-3 pt-2 border-t">
<div>
<Label className="text-[10px]"> </Label>
<Select
value={localField.linkedFieldGroup?.sourceTable || ""}
onValueChange={(value) => {
updateField({
linkedFieldGroup: {
...localField.linkedFieldGroup,
sourceTable: value,
},
});
onLoadTableColumns(value);
}}
>
<SelectTrigger className="h-7 text-xs mt-1">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{tables.map((t) => (
<SelectItem key={t.name} value={t.name}>
{t.label || t.name}
</SelectItem>
))}
</SelectContent>
</Select>
<HelpText> (: customer_mng)</HelpText>
</div>
<div>
<Label className="text-[10px]"> </Label>
{sourceTableColumns.length > 0 ? (
<Select
value={localField.linkedFieldGroup?.displayColumn || ""}
onValueChange={(value) =>
updateField({
linkedFieldGroup: {
...localField.linkedFieldGroup,
displayColumn: value,
},
})
}
>
<SelectTrigger className="h-7 text-xs mt-1">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{sourceTableColumns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
{col.label !== col.name && ` (${col.label})`}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={localField.linkedFieldGroup?.displayColumn || ""}
onChange={(e) =>
updateField({
linkedFieldGroup: {
...localField.linkedFieldGroup,
displayColumn: e.target.value,
},
})
}
placeholder="customer_name"
className="h-7 text-xs mt-1"
/>
)}
<HelpText> (: customer_name)</HelpText>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Select
value={localField.linkedFieldGroup?.displayFormat || "name_only"}
onValueChange={(value) =>
updateField({
linkedFieldGroup: {
...localField.linkedFieldGroup,
displayFormat: value as "name_only" | "code_name" | "name_code",
},
})
}
>
<SelectTrigger className="h-7 text-xs mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LINKED_FIELD_DISPLAY_FORMAT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<HelpText> </HelpText>
</div>
<Separator />
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-[10px] font-medium"> </Label>
<Button size="sm" variant="outline" onClick={addLinkedFieldMapping} className="h-6 text-[9px] px-2">
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<HelpText>
.
<br />
: customer_code partner_id, customer_name partner_name
</HelpText>
{(localField.linkedFieldGroup?.mappings || []).length === 0 ? (
<div className="text-center py-4 border border-dashed rounded-lg">
<p className="text-[10px] text-muted-foreground"> </p>
<p className="text-[9px] text-muted-foreground"> "매핑 추가" </p>
</div>
) : (
<div className="space-y-2">
{(localField.linkedFieldGroup?.mappings || []).map((mapping, index) => (
<div key={index} className="border rounded-lg p-2 space-y-2 bg-muted/30">
<div className="flex items-center justify-between">
<span className="text-[9px] font-medium text-muted-foreground"> {index + 1}</span>
<Button
size="sm"
variant="ghost"
onClick={() => removeLinkedFieldMapping(index)}
className="h-5 w-5 p-0 text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<div>
<Label className="text-[9px]"> ( )</Label>
{sourceTableColumns.length > 0 ? (
<Select
value={mapping.sourceColumn || ""}
onValueChange={(value) =>
updateLinkedFieldMapping(index, { sourceColumn: value })
}
>
<SelectTrigger className="h-6 text-[9px] mt-0.5">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{sourceTableColumns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
{col.label !== col.name && ` (${col.label})`}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={mapping.sourceColumn || ""}
onChange={(e) =>
updateLinkedFieldMapping(index, { sourceColumn: e.target.value })
}
placeholder="customer_code"
className="h-6 text-[9px] mt-0.5"
/>
)}
</div>
<div className="text-center text-[9px] text-muted-foreground"></div>
<div>
<Label className="text-[9px]"> ( )</Label>
<Input
value={mapping.targetColumn || ""}
onChange={(e) =>
updateLinkedFieldMapping(index, { targetColumn: e.target.value })
}
placeholder="partner_id"
className="h-6 text-[9px] mt-0.5"
/>
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
</AccordionContent>
</AccordionItem>
{/* 채번규칙 설정 */}
<AccordionItem value="numbering-rule" className="border rounded-lg">
<AccordionTrigger className="px-3 py-2 text-xs font-medium hover:no-underline bg-blue-50/50">
<div className="flex items-center gap-2">
<SettingsIcon className="h-3.5 w-3.5 text-blue-600" />
<span> </span>
{localField.numberingRule?.enabled && (
<span className="text-[9px] text-muted-foreground">()</span>
)}
</div>
</AccordionTrigger>
<AccordionContent className="px-3 pb-3 space-y-3">
<div className="flex items-center justify-between">
<span className="text-[10px] font-medium"> </span>
<Switch
checked={localField.numberingRule?.enabled || false}
onCheckedChange={(checked) =>
updateField({
numberingRule: {
...localField.numberingRule,
enabled: checked,
},
})
}
/>
</div>
<HelpText>
/ .
<br />
: EMP-001, ORD-20240101-001
</HelpText>
{localField.numberingRule?.enabled && (
<div className="space-y-2 pt-2 border-t">
<div>
<Label className="text-[10px]"> </Label>
<Select
value={localField.numberingRule?.ruleId || ""}
onValueChange={(value) =>
updateField({
numberingRule: {
...localField.numberingRule,
ruleId: value,
},
})
}
>
<SelectTrigger className="h-7 text-xs mt-1">
<SelectValue placeholder="규칙 선택" />
</SelectTrigger>
<SelectContent>
{numberingRules.length === 0 ? (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
</div>
) : (
numberingRules.map((rule) => (
<SelectItem key={rule.id} value={rule.id}>
{rule.name}
</SelectItem>
))
)}
</SelectContent>
</Select>
<HelpText> </HelpText>
</div>
<Separator className="my-2" />
<div className="flex items-center justify-between">
<span className="text-[10px]"> </span>
<Switch
checked={localField.numberingRule?.editable || false}
onCheckedChange={(checked) =>
updateField({
numberingRule: {
...localField.numberingRule,
editable: checked,
},
})
}
/>
</div>
<HelpText> </HelpText>
<Separator className="my-2" />
<div className="flex items-center justify-between">
<span className="text-[10px]"> </span>
<Switch
checked={localField.numberingRule?.generateOnSave || false}
onCheckedChange={(checked) =>
updateField({
numberingRule: {
...localField.numberingRule,
generateOnSave: checked,
generateOnOpen: !checked,
},
})
}
/>
</div>
<HelpText>OFF: 모달 / ON: 저장 </HelpText>
</div>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</ScrollArea>
</div>
<DialogFooter className="px-4 py-3 border-t shrink-0">
<Button variant="outline" onClick={() => onOpenChange(false)} className="h-9 text-sm">
</Button>
<Button onClick={handleSave} className="h-9 text-sm">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,796 @@
"use client";
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Plus, Trash2, Database, Layers } from "lucide-react";
import { cn } from "@/lib/utils";
import { SaveConfig, SubTableSaveConfig, SubTableFieldMapping, FormSectionConfig, FormFieldConfig } from "../types";
// 도움말 텍스트 컴포넌트
const HelpText = ({ children }: { children: React.ReactNode }) => (
<p className="text-[10px] text-muted-foreground mt-0.5">{children}</p>
);
interface SaveSettingsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
saveConfig: SaveConfig;
sections: FormSectionConfig[];
onSave: (updates: SaveConfig) => void;
tables: { name: string; label: string }[];
tableColumns: { [tableName: string]: { name: string; type: string; label: string }[] };
onLoadTableColumns: (tableName: string) => void;
}
export function SaveSettingsModal({
open,
onOpenChange,
saveConfig,
sections,
onSave,
tables,
tableColumns,
onLoadTableColumns,
}: SaveSettingsModalProps) {
// 로컬 상태로 저장 설정 관리
const [localSaveConfig, setLocalSaveConfig] = useState<SaveConfig>(saveConfig);
// 저장 모드 (단일 테이블 vs 다중 테이블)
const [saveMode, setSaveMode] = useState<"single" | "multi">(
saveConfig.customApiSave?.enabled && saveConfig.customApiSave?.multiTable?.enabled ? "multi" : "single"
);
// open이 변경될 때마다 데이터 동기화
useEffect(() => {
if (open) {
setLocalSaveConfig(saveConfig);
setSaveMode(saveConfig.customApiSave?.enabled && saveConfig.customApiSave?.multiTable?.enabled ? "multi" : "single");
}
}, [open, saveConfig]);
// 저장 설정 업데이트 함수
const updateSaveConfig = (updates: Partial<SaveConfig>) => {
setLocalSaveConfig((prev) => ({ ...prev, ...updates }));
};
// 저장 함수
const handleSave = () => {
// 저장 모드에 따라 설정 조정
let finalConfig = { ...localSaveConfig };
if (saveMode === "single") {
// 단일 테이블 모드: customApiSave 비활성화
finalConfig = {
...finalConfig,
customApiSave: {
enabled: false,
apiType: "custom",
},
};
} else {
// 다중 테이블 모드: customApiSave 활성화
finalConfig = {
...finalConfig,
customApiSave: {
...finalConfig.customApiSave,
enabled: true,
apiType: "multi-table",
multiTable: {
...finalConfig.customApiSave?.multiTable,
enabled: true,
},
},
};
}
onSave(finalConfig);
onOpenChange(false);
};
// 서브 테이블 추가
const addSubTable = () => {
const newSubTable: SubTableSaveConfig = {
enabled: true,
tableName: "",
repeatSectionId: "",
linkColumn: {
mainField: "",
subColumn: "",
},
fieldMappings: [],
};
const subTables = [...(localSaveConfig.customApiSave?.multiTable?.subTables || []), newSubTable];
updateSaveConfig({
customApiSave: {
...localSaveConfig.customApiSave,
apiType: "multi-table",
multiTable: {
...localSaveConfig.customApiSave?.multiTable,
enabled: true,
subTables,
},
},
});
};
// 서브 테이블 삭제
const removeSubTable = (index: number) => {
const subTables = [...(localSaveConfig.customApiSave?.multiTable?.subTables || [])];
subTables.splice(index, 1);
updateSaveConfig({
customApiSave: {
...localSaveConfig.customApiSave,
multiTable: {
...localSaveConfig.customApiSave?.multiTable,
subTables,
},
},
});
};
// 서브 테이블 업데이트
const updateSubTable = (index: number, updates: Partial<SubTableSaveConfig>) => {
const subTables = [...(localSaveConfig.customApiSave?.multiTable?.subTables || [])];
subTables[index] = { ...subTables[index], ...updates };
updateSaveConfig({
customApiSave: {
...localSaveConfig.customApiSave,
multiTable: {
...localSaveConfig.customApiSave?.multiTable,
subTables,
},
},
});
};
// 필드 매핑 추가
const addFieldMapping = (subTableIndex: number) => {
const newMapping: SubTableFieldMapping = {
formField: "",
targetColumn: "",
};
const subTables = [...(localSaveConfig.customApiSave?.multiTable?.subTables || [])];
const fieldMappings = [...(subTables[subTableIndex].fieldMappings || []), newMapping];
subTables[subTableIndex] = { ...subTables[subTableIndex], fieldMappings };
updateSaveConfig({
customApiSave: {
...localSaveConfig.customApiSave,
multiTable: {
...localSaveConfig.customApiSave?.multiTable,
subTables,
},
},
});
};
// 필드 매핑 삭제
const removeFieldMapping = (subTableIndex: number, mappingIndex: number) => {
const subTables = [...(localSaveConfig.customApiSave?.multiTable?.subTables || [])];
const fieldMappings = [...(subTables[subTableIndex].fieldMappings || [])];
fieldMappings.splice(mappingIndex, 1);
subTables[subTableIndex] = { ...subTables[subTableIndex], fieldMappings };
updateSaveConfig({
customApiSave: {
...localSaveConfig.customApiSave,
multiTable: {
...localSaveConfig.customApiSave?.multiTable,
subTables,
},
},
});
};
// 필드 매핑 업데이트
const updateFieldMapping = (subTableIndex: number, mappingIndex: number, updates: Partial<SubTableFieldMapping>) => {
const subTables = [...(localSaveConfig.customApiSave?.multiTable?.subTables || [])];
const fieldMappings = [...(subTables[subTableIndex].fieldMappings || [])];
fieldMappings[mappingIndex] = { ...fieldMappings[mappingIndex], ...updates };
subTables[subTableIndex] = { ...subTables[subTableIndex], fieldMappings };
updateSaveConfig({
customApiSave: {
...localSaveConfig.customApiSave,
multiTable: {
...localSaveConfig.customApiSave?.multiTable,
subTables,
},
},
});
};
// 메인 테이블 컬럼 목록
const mainTableColumns = localSaveConfig.customApiSave?.multiTable?.mainTable?.tableName
? tableColumns[localSaveConfig.customApiSave.multiTable.mainTable.tableName] || []
: [];
// 반복 섹션 목록
const repeatSections = sections.filter((s) => s.repeatable);
// 모든 필드 목록 (반복 섹션 포함)
const getAllFields = (): { columnName: string; label: string; sectionTitle: string }[] => {
const fields: { columnName: string; label: string; sectionTitle: string }[] = [];
sections.forEach((section) => {
section.fields.forEach((field) => {
fields.push({
columnName: field.columnName,
label: field.label,
sectionTitle: section.title,
});
});
});
return fields;
};
const allFields = getAllFields();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] flex flex-col p-0">
<DialogHeader className="px-4 pt-4 pb-2 border-b shrink-0">
<DialogTitle className="text-base"> </DialogTitle>
<DialogDescription className="text-xs">
.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden px-4">
<ScrollArea className="h-[calc(90vh-180px)]">
<div className="space-y-4 py-3 pr-3">
{/* 저장 모드 선택 */}
<div className="space-y-3 border rounded-lg p-3 bg-card">
<Label className="text-xs font-semibold"> </Label>
<RadioGroup value={saveMode} onValueChange={(value) => setSaveMode(value as "single" | "multi")}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="single" id="mode-single" />
<Label htmlFor="mode-single" className="text-[10px] cursor-pointer">
</Label>
</div>
<HelpText> ( )</HelpText>
<div className="flex items-center space-x-2 pt-2">
<RadioGroupItem value="multi" id="mode-multi" />
<Label htmlFor="mode-multi" className="text-[10px] cursor-pointer">
</Label>
</div>
<HelpText>
+
<br />
: 주문(orders) + (order_items), (user_info) + (user_dept)
</HelpText>
</RadioGroup>
</div>
{/* 단일 테이블 저장 설정 */}
{saveMode === "single" && (
<div className="space-y-3 border rounded-lg p-3 bg-card">
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-blue-600" />
<h3 className="text-xs font-semibold"> </h3>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Select
value={localSaveConfig.tableName || ""}
onValueChange={(value) => {
updateSaveConfig({ tableName: value });
onLoadTableColumns(value);
}}
>
<SelectTrigger className="h-7 text-xs mt-1">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{tables.map((t) => (
<SelectItem key={t.name} value={t.name}>
{t.label || t.name}
</SelectItem>
))}
</SelectContent>
</Select>
<HelpText> </HelpText>
</div>
<div>
<Label className="text-[10px]"> (Primary Key)</Label>
<Input
value={localSaveConfig.primaryKeyColumn || ""}
onChange={(e) => updateSaveConfig({ primaryKeyColumn: e.target.value })}
placeholder="id"
className="h-7 text-xs mt-1"
/>
<HelpText>
<br />
: id, user_id, order_id
</HelpText>
</div>
</div>
)}
{/* 다중 테이블 저장 설정 */}
{saveMode === "multi" && (
<div className="space-y-3">
{/* 메인 테이블 설정 */}
<div className="border rounded-lg p-3 bg-card space-y-3">
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-blue-600" />
<h3 className="text-xs font-semibold"> </h3>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Select
value={localSaveConfig.customApiSave?.multiTable?.mainTable?.tableName || ""}
onValueChange={(value) => {
updateSaveConfig({
customApiSave: {
...localSaveConfig.customApiSave,
apiType: "multi-table",
multiTable: {
...localSaveConfig.customApiSave?.multiTable,
enabled: true,
mainTable: {
...localSaveConfig.customApiSave?.multiTable?.mainTable,
tableName: value,
},
},
},
});
onLoadTableColumns(value);
}}
>
<SelectTrigger className="h-7 text-xs mt-1">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{tables.map((t) => (
<SelectItem key={t.name} value={t.name}>
{t.label || t.name}
</SelectItem>
))}
</SelectContent>
</Select>
<HelpText> (: orders, user_info)</HelpText>
</div>
<div>
<Label className="text-[10px]"> </Label>
{mainTableColumns.length > 0 ? (
<Select
value={localSaveConfig.customApiSave?.multiTable?.mainTable?.primaryKeyColumn || ""}
onValueChange={(value) =>
updateSaveConfig({
customApiSave: {
...localSaveConfig.customApiSave,
multiTable: {
...localSaveConfig.customApiSave?.multiTable,
mainTable: {
...localSaveConfig.customApiSave?.multiTable?.mainTable,
primaryKeyColumn: value,
},
},
},
})
}
>
<SelectTrigger className="h-7 text-xs mt-1">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{mainTableColumns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
{col.label !== col.name && ` (${col.label})`}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={localSaveConfig.customApiSave?.multiTable?.mainTable?.primaryKeyColumn || ""}
onChange={(e) =>
updateSaveConfig({
customApiSave: {
...localSaveConfig.customApiSave,
multiTable: {
...localSaveConfig.customApiSave?.multiTable,
mainTable: {
...localSaveConfig.customApiSave?.multiTable?.mainTable,
primaryKeyColumn: e.target.value,
},
},
},
})
}
placeholder="id"
className="h-7 text-xs mt-1"
/>
)}
<HelpText> (: order_id, user_id)</HelpText>
</div>
</div>
{/* 서브 테이블 목록 */}
<div className="border rounded-lg p-3 bg-card space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-orange-600" />
<h3 className="text-xs font-semibold"> </h3>
<span className="text-[9px] text-muted-foreground">
({(localSaveConfig.customApiSave?.multiTable?.subTables || []).length})
</span>
</div>
<Button size="sm" variant="outline" onClick={addSubTable} className="h-6 text-[9px] px-2">
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<HelpText>
.
<br />
: 주문상세(order_items), (user_dept)
</HelpText>
{(localSaveConfig.customApiSave?.multiTable?.subTables || []).length === 0 ? (
<div className="text-center py-6 border border-dashed rounded-lg">
<p className="text-[10px] text-muted-foreground mb-1"> </p>
<p className="text-[9px] text-muted-foreground"> "서브 테이블 추가" </p>
</div>
) : (
<div className="space-y-3 pt-2">
{(localSaveConfig.customApiSave?.multiTable?.subTables || []).map((subTable, subIndex) => {
const subTableColumns = subTable.tableName ? tableColumns[subTable.tableName] || [] : [];
return (
<Accordion key={subIndex} type="single" collapsible>
<AccordionItem value={`sub-${subIndex}`} className="border rounded-lg bg-orange-50/30">
<AccordionTrigger className="px-3 py-2 text-xs hover:no-underline">
<div className="flex items-center justify-between flex-1">
<div className="flex items-center gap-2">
<span className="font-medium">
{subIndex + 1}: {subTable.tableName || "(미설정)"}
</span>
<span className="text-[9px] text-muted-foreground">
({subTable.fieldMappings?.length || 0} )
</span>
</div>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
removeSubTable(subIndex);
}}
className="h-5 w-5 p-0 text-destructive hover:text-destructive mr-2"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</AccordionTrigger>
<AccordionContent className="px-3 pb-3 space-y-3">
<div>
<Label className="text-[10px]"> </Label>
<Select
value={subTable.tableName || ""}
onValueChange={(value) => {
updateSubTable(subIndex, { tableName: value });
onLoadTableColumns(value);
}}
>
<SelectTrigger className="h-7 text-xs mt-1">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{tables.map((t) => (
<SelectItem key={t.name} value={t.name}>
{t.label || t.name}
</SelectItem>
))}
</SelectContent>
</Select>
<HelpText> </HelpText>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Select
value={subTable.repeatSectionId || ""}
onValueChange={(value) => updateSubTable(subIndex, { repeatSectionId: value })}
>
<SelectTrigger className="h-7 text-xs mt-1">
<SelectValue placeholder="섹션 선택" />
</SelectTrigger>
<SelectContent>
{repeatSections.length === 0 ? (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
</div>
) : (
repeatSections.map((section) => (
<SelectItem key={section.id} value={section.id}>
{section.title}
</SelectItem>
))
)}
</SelectContent>
</Select>
<HelpText> </HelpText>
</div>
<Separator />
<div className="space-y-2">
<Label className="text-[10px] font-medium"> </Label>
<HelpText> </HelpText>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-[9px]"> </Label>
{mainTableColumns.length > 0 ? (
<Select
value={subTable.linkColumn?.mainField || ""}
onValueChange={(value) =>
updateSubTable(subIndex, {
linkColumn: { ...subTable.linkColumn, mainField: value },
})
}
>
<SelectTrigger className="h-6 text-[9px] mt-0.5">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{mainTableColumns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={subTable.linkColumn?.mainField || ""}
onChange={(e) =>
updateSubTable(subIndex, {
linkColumn: { ...subTable.linkColumn, mainField: e.target.value },
})
}
placeholder="order_id"
className="h-6 text-[9px] mt-0.5"
/>
)}
</div>
<div>
<Label className="text-[9px]"> </Label>
{subTableColumns.length > 0 ? (
<Select
value={subTable.linkColumn?.subColumn || ""}
onValueChange={(value) =>
updateSubTable(subIndex, {
linkColumn: { ...subTable.linkColumn, subColumn: value },
})
}
>
<SelectTrigger className="h-6 text-[9px] mt-0.5">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{subTableColumns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={subTable.linkColumn?.subColumn || ""}
onChange={(e) =>
updateSubTable(subIndex, {
linkColumn: { ...subTable.linkColumn, subColumn: e.target.value },
})
}
placeholder="order_id"
className="h-6 text-[9px] mt-0.5"
/>
)}
</div>
</div>
</div>
<Separator />
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-[10px] font-medium"> </Label>
<Button
size="sm"
variant="outline"
onClick={() => addFieldMapping(subIndex)}
className="h-5 text-[8px] px-1.5"
>
<Plus className="h-2.5 w-2.5 mr-0.5" />
</Button>
</div>
<HelpText> </HelpText>
{(subTable.fieldMappings || []).length === 0 ? (
<div className="text-center py-3 border border-dashed rounded-lg">
<p className="text-[9px] text-muted-foreground"> </p>
</div>
) : (
<div className="space-y-2">
{(subTable.fieldMappings || []).map((mapping, mapIndex) => (
<div key={mapIndex} className="border rounded-lg p-2 bg-white space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-[8px] font-medium text-muted-foreground">
{mapIndex + 1}
</span>
<Button
size="sm"
variant="ghost"
onClick={() => removeFieldMapping(subIndex, mapIndex)}
className="h-4 w-4 p-0 text-destructive"
>
<Trash2 className="h-2.5 w-2.5" />
</Button>
</div>
<div>
<Label className="text-[8px]"> </Label>
<Select
value={mapping.formField || ""}
onValueChange={(value) =>
updateFieldMapping(subIndex, mapIndex, { formField: value })
}
>
<SelectTrigger className="h-5 text-[8px] mt-0.5">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{allFields.map((field) => (
<SelectItem key={field.columnName} value={field.columnName}>
{field.label} ({field.sectionTitle})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="text-center text-[8px] text-muted-foreground"></div>
<div>
<Label className="text-[8px]"> </Label>
{subTableColumns.length > 0 ? (
<Select
value={mapping.targetColumn || ""}
onValueChange={(value) =>
updateFieldMapping(subIndex, mapIndex, { targetColumn: value })
}
>
<SelectTrigger className="h-5 text-[8px] mt-0.5">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{subTableColumns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={mapping.targetColumn || ""}
onChange={(e) =>
updateFieldMapping(subIndex, mapIndex, {
targetColumn: e.target.value,
})
}
placeholder="item_name"
className="h-5 text-[8px] mt-0.5"
/>
)}
</div>
</div>
))}
</div>
)}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
);
})}
</div>
)}
</div>
</div>
)}
{/* 저장 후 동작 */}
<div className="space-y-2 border rounded-lg p-3 bg-card">
<h3 className="text-xs font-semibold"> </h3>
<div className="flex items-center justify-between">
<span className="text-[10px]"> </span>
<Switch
checked={localSaveConfig.afterSave?.showToast !== false}
onCheckedChange={(checked) =>
updateSaveConfig({
afterSave: {
...localSaveConfig.afterSave,
showToast: checked,
},
})
}
/>
</div>
<HelpText> "저장되었습니다" </HelpText>
<Separator className="my-2" />
<div className="flex items-center justify-between">
<span className="text-[10px]"> </span>
<Switch
checked={localSaveConfig.afterSave?.closeModal !== false}
onCheckedChange={(checked) =>
updateSaveConfig({
afterSave: {
...localSaveConfig.afterSave,
closeModal: checked,
},
})
}
/>
</div>
<HelpText> </HelpText>
<Separator className="my-2" />
<div className="flex items-center justify-between">
<span className="text-[10px]"> </span>
<Switch
checked={localSaveConfig.afterSave?.refreshParent !== false}
onCheckedChange={(checked) =>
updateSaveConfig({
afterSave: {
...localSaveConfig.afterSave,
refreshParent: checked,
},
})
}
/>
</div>
<HelpText> </HelpText>
</div>
</div>
</ScrollArea>
</div>
<DialogFooter className="px-4 py-3 border-t shrink-0">
<Button variant="outline" onClick={() => onOpenChange(false)} className="h-9 text-sm">
</Button>
<Button onClick={handleSave} className="h-9 text-sm">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,516 @@
"use client";
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Badge } from "@/components/ui/badge";
import { Plus, Trash2, GripVertical, ChevronUp, ChevronDown, Settings as SettingsIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { FormSectionConfig, FormFieldConfig, FIELD_TYPE_OPTIONS } from "../types";
import { defaultFieldConfig, generateFieldId } from "../config";
// 도움말 텍스트 컴포넌트
const HelpText = ({ children }: { children: React.ReactNode }) => (
<p className="text-[10px] text-muted-foreground mt-0.5">{children}</p>
);
interface SectionLayoutModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
section: FormSectionConfig;
onSave: (updates: Partial<FormSectionConfig>) => void;
onOpenFieldDetail: (field: FormFieldConfig) => void;
}
export function SectionLayoutModal({
open,
onOpenChange,
section,
onSave,
onOpenFieldDetail,
}: SectionLayoutModalProps) {
// 로컬 상태로 섹션 관리
const [localSection, setLocalSection] = useState<FormSectionConfig>(section);
// open이 변경될 때마다 데이터 동기화
useEffect(() => {
if (open) {
setLocalSection(section);
}
}, [open, section]);
// 섹션 업데이트 함수
const updateSection = (updates: Partial<FormSectionConfig>) => {
setLocalSection((prev) => ({ ...prev, ...updates }));
};
// 저장 함수
const handleSave = () => {
onSave(localSection);
onOpenChange(false);
};
// 필드 추가
const addField = () => {
const newField: FormFieldConfig = {
...defaultFieldConfig,
id: generateFieldId(),
label: `새 필드 ${localSection.fields.length + 1}`,
columnName: `field_${localSection.fields.length + 1}`,
};
updateSection({
fields: [...localSection.fields, newField],
});
};
// 필드 삭제
const removeField = (fieldId: string) => {
updateSection({
fields: localSection.fields.filter((f) => f.id !== fieldId),
});
};
// 필드 업데이트
const updateField = (fieldId: string, updates: Partial<FormFieldConfig>) => {
updateSection({
fields: localSection.fields.map((f) => (f.id === fieldId ? { ...f, ...updates } : f)),
});
};
// 필드 이동
const moveField = (fieldId: string, direction: "up" | "down") => {
const index = localSection.fields.findIndex((f) => f.id === fieldId);
if (index === -1) return;
if (direction === "up" && index === 0) return;
if (direction === "down" && index === localSection.fields.length - 1) return;
const newFields = [...localSection.fields];
const targetIndex = direction === "up" ? index - 1 : index + 1;
[newFields[index], newFields[targetIndex]] = [newFields[targetIndex], newFields[index]];
updateSection({ fields: newFields });
};
// 필드 타입별 색상
const getFieldTypeColor = (fieldType: FormFieldConfig["fieldType"]): string => {
switch (fieldType) {
case "text":
case "email":
case "password":
case "tel":
return "bg-blue-50 border-blue-200 text-blue-700";
case "number":
return "bg-cyan-50 border-cyan-200 text-cyan-700";
case "date":
case "datetime":
return "bg-purple-50 border-purple-200 text-purple-700";
case "select":
return "bg-green-50 border-green-200 text-green-700";
case "checkbox":
return "bg-pink-50 border-pink-200 text-pink-700";
case "textarea":
return "bg-orange-50 border-orange-200 text-orange-700";
default:
return "bg-gray-50 border-gray-200 text-gray-700";
}
};
// 필드 타입 라벨
const getFieldTypeLabel = (fieldType: FormFieldConfig["fieldType"]): string => {
const option = FIELD_TYPE_OPTIONS.find((opt) => opt.value === fieldType);
return option?.label || fieldType;
};
// 그리드 너비 라벨
const getGridSpanLabel = (span: number): string => {
switch (span) {
case 3:
return "1/4";
case 4:
return "1/3";
case 6:
return "1/2";
case 8:
return "2/3";
case 12:
return "전체";
default:
return `${span}/12`;
}
};
// 필드 상세 설정 열기
const handleOpenFieldDetail = (field: FormFieldConfig) => {
// 먼저 현재 변경사항 저장
onSave(localSection);
// 그 다음 필드 상세 모달 열기
onOpenFieldDetail(field);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-[900px] max-h-[90vh] flex flex-col p-0">
<DialogHeader className="px-4 pt-4 pb-2 border-b shrink-0">
<DialogTitle className="text-base"> : {localSection.title}</DialogTitle>
<DialogDescription className="text-xs">
. .
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden px-4">
<ScrollArea className="h-[calc(90vh-180px)]">
<div className="space-y-4 py-3 pr-3">
{/* 섹션 기본 정보 */}
<div className="space-y-3 border rounded-lg p-3 bg-card">
<h3 className="text-xs font-semibold"> </h3>
<div>
<Label className="text-[10px]"> </Label>
<Input
value={localSection.title}
onChange={(e) => updateSection({ title: e.target.value })}
className="h-7 text-xs mt-1"
/>
<HelpText> (: 기본 , )</HelpText>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Textarea
value={localSection.description || ""}
onChange={(e) => updateSection({ description: e.target.value })}
className="text-xs mt-1 min-h-[50px]"
placeholder="섹션에 대한 설명 (선택사항)"
/>
<HelpText> </HelpText>
</div>
<div>
<Label className="text-[10px]"> ()</Label>
<Select
value={String(localSection.columns || 2)}
onValueChange={(value) => updateSection({ columns: parseInt(value) })}
>
<SelectTrigger className="h-7 text-xs mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 ( )</SelectItem>
<SelectItem value="2">2 ()</SelectItem>
<SelectItem value="3">3</SelectItem>
<SelectItem value="4">4</SelectItem>
</SelectContent>
</Select>
<HelpText> </HelpText>
</div>
<Separator />
<div className="flex items-center justify-between">
<span className="text-[10px]"> </span>
<Switch
checked={localSection.collapsible || false}
onCheckedChange={(checked) => updateSection({ collapsible: checked })}
/>
</div>
<HelpText> </HelpText>
{localSection.collapsible && (
<>
<Separator className="my-2" />
<div className="flex items-center justify-between">
<span className="text-[10px]"> </span>
<Switch
checked={localSection.defaultCollapsed || false}
onCheckedChange={(checked) => updateSection({ defaultCollapsed: checked })}
/>
</div>
<HelpText> </HelpText>
</>
)}
</div>
{/* 반복 섹션 설정 */}
<div className="space-y-3 border rounded-lg p-3 bg-card">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold"> </span>
<Switch
checked={localSection.repeatable || false}
onCheckedChange={(checked) => updateSection({ repeatable: checked })}
/>
</div>
<HelpText>
<br />
: 경력사항, ,
</HelpText>
{localSection.repeatable && (
<div className="space-y-2 pt-2 border-t">
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-[10px]"> </Label>
<Input
type="number"
value={localSection.repeatConfig?.minItems || 0}
onChange={(e) =>
updateSection({
repeatConfig: {
...localSection.repeatConfig,
minItems: parseInt(e.target.value) || 0,
},
})
}
className="h-6 text-[10px] mt-1"
/>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Input
type="number"
value={localSection.repeatConfig?.maxItems || 10}
onChange={(e) =>
updateSection({
repeatConfig: {
...localSection.repeatConfig,
maxItems: parseInt(e.target.value) || 10,
},
})
}
className="h-6 text-[10px] mt-1"
/>
</div>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Input
value={localSection.repeatConfig?.addButtonText || "+ 추가"}
onChange={(e) =>
updateSection({
repeatConfig: {
...localSection.repeatConfig,
addButtonText: e.target.value,
},
})
}
className="h-6 text-[10px] mt-1"
/>
</div>
</div>
)}
</div>
{/* 필드 목록 */}
<div className="space-y-3 border rounded-lg p-3 bg-card">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="text-xs font-semibold"> </h3>
<Badge variant="secondary" className="text-[9px] px-1.5 py-0">
{localSection.fields.length}
</Badge>
</div>
<Button size="sm" variant="outline" onClick={addField} className="h-7 text-[10px] px-2">
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<HelpText>
. "상세 설정" .
</HelpText>
{localSection.fields.length === 0 ? (
<div className="text-center py-8 border border-dashed rounded-lg">
<p className="text-sm text-muted-foreground mb-2"> </p>
<p className="text-xs text-muted-foreground"> "필드 추가" </p>
</div>
) : (
<div className="space-y-2">
{localSection.fields.map((field, index) => (
<div
key={field.id}
className={cn(
"border rounded-lg overflow-hidden",
getFieldTypeColor(field.fieldType)
)}
>
{/* 필드 헤더 */}
<div className="flex items-center justify-between p-2 bg-white/50">
<div className="flex items-center gap-2 flex-1">
{/* 순서 변경 버튼 */}
<div className="flex flex-col">
<Button
size="sm"
variant="ghost"
onClick={() => moveField(field.id, "up")}
disabled={index === 0}
className="h-3 w-5 p-0"
>
<ChevronUp className="h-2.5 w-2.5" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => moveField(field.id, "down")}
disabled={index === localSection.fields.length - 1}
className="h-3 w-5 p-0"
>
<ChevronDown className="h-2.5 w-2.5" />
</Button>
</div>
<GripVertical className="h-3.5 w-3.5 text-muted-foreground" />
{/* 필드 정보 */}
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-[11px] font-medium">{field.label}</span>
<Badge
variant="outline"
className={cn("text-[8px] px-1 py-0", getFieldTypeColor(field.fieldType))}
>
{getFieldTypeLabel(field.fieldType)}
</Badge>
{field.required && (
<Badge variant="destructive" className="text-[8px] px-1 py-0">
</Badge>
)}
{field.hidden && (
<Badge variant="secondary" className="text-[8px] px-1 py-0">
</Badge>
)}
</div>
<p className="text-[9px] text-muted-foreground mt-0.5">
: {field.columnName} | : {getGridSpanLabel(field.gridSpan || 6)}
</p>
</div>
</div>
{/* 액션 버튼 */}
<div className="flex items-center gap-1">
<Button
size="sm"
variant="ghost"
onClick={() => handleOpenFieldDetail(field)}
className="h-6 px-2 text-[9px]"
>
<SettingsIcon className="h-3 w-3 mr-1" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => removeField(field.id)}
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
{/* 간단한 인라인 설정 */}
<div className="px-2 pb-2 space-y-2 bg-white/30">
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-[9px]"></Label>
<Input
value={field.label}
onChange={(e) => updateField(field.id, { label: e.target.value })}
className="h-6 text-[9px] mt-0.5"
/>
</div>
<div>
<Label className="text-[9px]"></Label>
<Input
value={field.columnName}
onChange={(e) => updateField(field.id, { columnName: e.target.value })}
className="h-6 text-[9px] mt-0.5"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-[9px]"> </Label>
<Select
value={field.fieldType}
onValueChange={(value) =>
updateField(field.id, {
fieldType: value as FormFieldConfig["fieldType"],
})
}
>
<SelectTrigger className="h-6 text-[9px] mt-0.5">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[9px]"></Label>
<Select
value={String(field.gridSpan || 6)}
onValueChange={(value) => updateField(field.id, { gridSpan: parseInt(value) })}
>
<SelectTrigger className="h-6 text-[9px] mt-0.5">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="3">1/4</SelectItem>
<SelectItem value="4">1/3</SelectItem>
<SelectItem value="6">1/2</SelectItem>
<SelectItem value="8">2/3</SelectItem>
<SelectItem value="12"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center justify-between pt-1">
<span className="text-[9px]"></span>
<Switch
checked={field.required || false}
onCheckedChange={(checked) => updateField(field.id, { required: checked })}
className="scale-75"
/>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</ScrollArea>
</div>
<DialogFooter className="px-4 py-3 border-t shrink-0">
<Button variant="outline" onClick={() => onOpenChange(false)} className="h-9 text-sm">
</Button>
<Button onClick={handleSave} className="h-9 text-sm">
({localSection.fields.length} )
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -12,8 +12,9 @@ export interface SelectOptionConfig {
staticOptions?: { value: string; label: string }[];
// 테이블 기반 옵션
tableName?: string;
valueColumn?: string;
labelColumn?: string;
valueColumn?: string; // 조인할 컬럼 (조회용 기본키)
labelColumn?: string; // 표시할 컬럼 (화면에 보여줄 텍스트)
saveColumn?: string; // 저장할 컬럼 (실제로 DB에 저장할 값, 미지정 시 valueColumn 사용)
filterCondition?: string;
// 공통코드 기반 옵션
codeCategory?: string;

View File

@ -269,7 +269,7 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
if (loading) {
return (
<div className="rounded-md border border-dashed border-gray-300 bg-gray-50 p-4">
<div className="rounded-md border border-dashed border-gray-300 bg-gray-50 p-4 w-full">
<div className="flex items-center gap-2 text-gray-600">
<span className="text-sm font-medium"> ...</span>
</div>
@ -280,7 +280,7 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
if (error) {
return (
<div className="rounded-md border border-dashed border-red-300 bg-red-50 p-4">
<div className="rounded-md border border-dashed border-red-300 bg-red-50 p-4 w-full">
<div className="flex items-center gap-2 text-red-600">
<span className="text-sm font-medium"> </span>
</div>
@ -292,7 +292,7 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
if (!ConfigPanelComponent) {
console.warn(`⚠️ DynamicComponentConfigPanel: ${componentId} ConfigPanelComponent가 null`);
return (
<div className="rounded-md border border-dashed border-yellow-300 bg-yellow-50 p-4">
<div className="rounded-md border border-dashed border-yellow-300 bg-yellow-50 p-4 w-full">
<div className="flex items-center gap-2 text-yellow-600">
<span className="text-sm font-medium"> </span>
</div>