refactor(universal-form-modal): ConfigPanel 모달 분리 및 설정 패널 오버플로우 수정
- UniversalFormModalConfigPanel을 3개 모달로 분리 (2300줄 → 300줄) - FieldDetailSettingsModal: 필드 상세 설정 - SaveSettingsModal: 저장 설정 - SectionLayoutModal: 섹션 레이아웃 설정 - FloatingPanel, DetailSettingsPanel 가로 스크롤 오버플로우 수정 - SelectOptionConfig에 saveColumn 필드 추가 (저장 값 별도 지정)
This commit is contained in:
parent
190a677067
commit
6a676dcf5c
|
|
@ -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
|
||||
? {}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue