1129 lines
58 KiB
TypeScript
1129 lines
58 KiB
TypeScript
"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 { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
|
import { Plus, Trash2, GripVertical, ChevronUp, ChevronDown, Settings as SettingsIcon, Check, ChevronsUpDown } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { FormSectionConfig, FormFieldConfig, OptionalFieldGroupConfig, FIELD_TYPE_OPTIONS } from "../types";
|
|
import { defaultFieldConfig, generateFieldId, generateUniqueId } from "../config";
|
|
|
|
// 도움말 텍스트 컴포넌트
|
|
const HelpText = ({ children }: { children: React.ReactNode }) => (
|
|
<p className="text-[10px] text-muted-foreground mt-0.5">{children}</p>
|
|
);
|
|
|
|
// 테이블 컬럼 정보 타입
|
|
interface TableColumnInfo {
|
|
name: string;
|
|
type: string;
|
|
label: string;
|
|
}
|
|
|
|
interface SectionLayoutModalProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
section: FormSectionConfig;
|
|
onSave: (updates: Partial<FormSectionConfig>) => void;
|
|
onOpenFieldDetail: (field: FormFieldConfig) => void;
|
|
// 저장 테이블의 컬럼 정보
|
|
tableName?: string;
|
|
tableColumns?: TableColumnInfo[];
|
|
}
|
|
|
|
export function SectionLayoutModal({
|
|
open,
|
|
onOpenChange,
|
|
section,
|
|
onSave,
|
|
onOpenFieldDetail,
|
|
tableName = "",
|
|
tableColumns = [],
|
|
}: SectionLayoutModalProps) {
|
|
|
|
// 컬럼 선택 Popover 상태 (필드별)
|
|
const [columnSearchOpen, setColumnSearchOpen] = useState<Record<string, boolean>>({});
|
|
|
|
// 로컬 상태로 섹션 관리 (fields가 없으면 빈 배열로 초기화)
|
|
const [localSection, setLocalSection] = useState<FormSectionConfig>(() => ({
|
|
...section,
|
|
fields: section.fields || [],
|
|
}));
|
|
|
|
// open이 변경될 때마다 데이터 동기화
|
|
useEffect(() => {
|
|
if (open) {
|
|
setLocalSection({
|
|
...section,
|
|
fields: section.fields || [],
|
|
});
|
|
}
|
|
}, [open, section]);
|
|
|
|
|
|
// 섹션 업데이트 함수
|
|
const updateSection = (updates: Partial<FormSectionConfig>) => {
|
|
setLocalSection((prev) => ({ ...prev, ...updates }));
|
|
};
|
|
|
|
// 저장 함수
|
|
const handleSave = () => {
|
|
onSave(localSection);
|
|
onOpenChange(false);
|
|
};
|
|
|
|
// fields 배열 (안전한 접근)
|
|
const fields = localSection.fields || [];
|
|
|
|
// 필드 추가
|
|
const addField = () => {
|
|
const newField: FormFieldConfig = {
|
|
...defaultFieldConfig,
|
|
id: generateFieldId(),
|
|
label: `새 필드 ${fields.length + 1}`,
|
|
columnName: `field_${fields.length + 1}`,
|
|
};
|
|
updateSection({
|
|
fields: [...fields, newField],
|
|
});
|
|
};
|
|
|
|
// 필드 삭제
|
|
const removeField = (fieldId: string) => {
|
|
updateSection({
|
|
fields: fields.filter((f) => f.id !== fieldId),
|
|
});
|
|
};
|
|
|
|
// 필드 업데이트
|
|
const updateField = (fieldId: string, updates: Partial<FormFieldConfig>) => {
|
|
updateSection({
|
|
fields: fields.map((f) => (f.id === fieldId ? { ...f, ...updates } : f)),
|
|
});
|
|
};
|
|
|
|
// 필드 이동
|
|
const moveField = (fieldId: string, direction: "up" | "down") => {
|
|
const index = fields.findIndex((f) => f.id === fieldId);
|
|
if (index === -1) return;
|
|
|
|
if (direction === "up" && index === 0) return;
|
|
if (direction === "down" && index === fields.length - 1) return;
|
|
|
|
const newFields = [...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">
|
|
{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>
|
|
|
|
{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">
|
|
{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 === 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>
|
|
{tableColumns.length > 0 ? (
|
|
<Popover
|
|
open={columnSearchOpen[field.id] || false}
|
|
onOpenChange={(open) => setColumnSearchOpen(prev => ({ ...prev, [field.id]: open }))}
|
|
>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={columnSearchOpen[field.id] || false}
|
|
className="h-6 w-full justify-between text-[9px] mt-0.5 font-normal"
|
|
>
|
|
{field.columnName ? (
|
|
<div className="flex flex-col items-start text-left truncate">
|
|
<span className="font-medium truncate">{field.columnName}</span>
|
|
{(() => {
|
|
const col = tableColumns.find(c => c.name === field.columnName);
|
|
return col?.label && col.label !== field.columnName ? (
|
|
<span className="text-[8px] text-muted-foreground truncate">({col.label})</span>
|
|
) : null;
|
|
})()}
|
|
</div>
|
|
) : (
|
|
<span className="text-muted-foreground">컬럼 선택...</span>
|
|
)}
|
|
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="p-0 w-[280px]" align="start">
|
|
<Command>
|
|
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
|
|
<CommandList className="max-h-[200px]">
|
|
<CommandEmpty className="text-xs py-3 text-center">
|
|
컬럼을 찾을 수 없습니다.
|
|
</CommandEmpty>
|
|
<CommandGroup>
|
|
{tableColumns.map((col) => (
|
|
<CommandItem
|
|
key={col.name}
|
|
value={`${col.name} ${col.label}`}
|
|
onSelect={() => {
|
|
updateField(field.id, {
|
|
columnName: col.name,
|
|
// 라벨이 기본값이면 컬럼 라벨로 자동 설정
|
|
...(field.label.startsWith("새 필드") || field.label.startsWith("field_")
|
|
? { label: col.label || col.name }
|
|
: {})
|
|
});
|
|
setColumnSearchOpen(prev => ({ ...prev, [field.id]: false }));
|
|
}}
|
|
className="text-xs"
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-3 w-3",
|
|
field.columnName === col.name ? "opacity-100" : "opacity-0"
|
|
)}
|
|
/>
|
|
<div className="flex flex-col">
|
|
<div className="flex items-center gap-1">
|
|
<span className="font-medium">{col.name}</span>
|
|
{col.label && col.label !== col.name && (
|
|
<span className="text-muted-foreground">({col.label})</span>
|
|
)}
|
|
</div>
|
|
{tableName && (
|
|
<span className="text-[9px] text-muted-foreground">{tableName}</span>
|
|
)}
|
|
</div>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
) : (
|
|
<Input
|
|
value={field.columnName}
|
|
onChange={(e) => updateField(field.id, { columnName: e.target.value })}
|
|
className="h-6 text-[9px] mt-0.5"
|
|
placeholder="저장 테이블을 먼저 설정하세요"
|
|
/>
|
|
)}
|
|
</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 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.optionalFieldGroups?.length || 0}개
|
|
</Badge>
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => {
|
|
const newGroup: OptionalFieldGroupConfig = {
|
|
id: generateUniqueId("optgroup"),
|
|
title: `옵셔널 그룹 ${(localSection.optionalFieldGroups?.length || 0) + 1}`,
|
|
fields: [],
|
|
};
|
|
updateSection({
|
|
optionalFieldGroups: [...(localSection.optionalFieldGroups || []), newGroup],
|
|
});
|
|
}}
|
|
className="h-7 text-[10px] px-2"
|
|
>
|
|
<Plus className="h-3 w-3 mr-1" />
|
|
그룹 추가
|
|
</Button>
|
|
</div>
|
|
|
|
<HelpText>
|
|
섹션 내에서 "추가" 버튼을 눌러야 표시되는 필드 그룹입니다.
|
|
<br />
|
|
예: 해외 판매 정보 (인코텀즈, 결제조건, 통화 등)
|
|
</HelpText>
|
|
|
|
{(!localSection.optionalFieldGroups || localSection.optionalFieldGroups.length === 0) ? (
|
|
<div className="text-center py-6 border border-dashed rounded-lg">
|
|
<p className="text-xs text-muted-foreground">옵셔널 필드 그룹이 없습니다</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{localSection.optionalFieldGroups.map((group, groupIndex) => (
|
|
<div key={group.id} className="border rounded-lg p-3 bg-muted/30">
|
|
{/* 그룹 헤더 */}
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="outline" className="text-[9px]">그룹 {groupIndex + 1}</Badge>
|
|
<span className="text-xs font-medium">{group.title}</span>
|
|
<Badge variant="secondary" className="text-[8px] px-1">
|
|
{group.fields.length}개 필드
|
|
</Badge>
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
updateSection({
|
|
optionalFieldGroups: localSection.optionalFieldGroups?.filter((g) => g.id !== group.id),
|
|
});
|
|
}}
|
|
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 그룹 기본 설정 */}
|
|
<div className="space-y-2">
|
|
{/* 제목 및 설명 */}
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<Label className="text-[9px]">그룹 제목</Label>
|
|
<Input
|
|
value={group.title}
|
|
onChange={(e) => {
|
|
const newGroups = localSection.optionalFieldGroups?.map((g) =>
|
|
g.id === group.id ? { ...g, title: e.target.value } : g
|
|
);
|
|
updateSection({ optionalFieldGroups: newGroups });
|
|
}}
|
|
className="mt-0.5 h-6 text-[9px]"
|
|
placeholder="예: 해외 판매 정보"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-[9px]">그룹 설명</Label>
|
|
<Input
|
|
value={group.description || ""}
|
|
onChange={(e) => {
|
|
const newGroups = localSection.optionalFieldGroups?.map((g) =>
|
|
g.id === group.id ? { ...g, description: e.target.value } : g
|
|
);
|
|
updateSection({ optionalFieldGroups: newGroups });
|
|
}}
|
|
className="mt-0.5 h-6 text-[9px]"
|
|
placeholder="해외 판매 시 추가 정보 입력"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 레이아웃 및 옵션 */}
|
|
<div className="grid grid-cols-4 gap-2">
|
|
<div>
|
|
<Label className="text-[8px]">열 수</Label>
|
|
<Select
|
|
value={String(group.columns || "inherit")}
|
|
onValueChange={(value) => {
|
|
const newGroups = localSection.optionalFieldGroups?.map((g) =>
|
|
g.id === group.id
|
|
? { ...g, columns: value === "inherit" ? undefined : parseInt(value) }
|
|
: g
|
|
);
|
|
updateSection({ optionalFieldGroups: newGroups });
|
|
}}
|
|
>
|
|
<SelectTrigger className="mt-0.5 h-5 text-[8px]">
|
|
<SelectValue placeholder="상속" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="inherit">상속</SelectItem>
|
|
<SelectItem value="1">1열</SelectItem>
|
|
<SelectItem value="2">2열</SelectItem>
|
|
<SelectItem value="3">3열</SelectItem>
|
|
<SelectItem value="4">4열</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="flex items-end gap-1 pb-0.5">
|
|
<Switch
|
|
checked={group.collapsible || false}
|
|
onCheckedChange={(checked) => {
|
|
const newGroups = localSection.optionalFieldGroups?.map((g) =>
|
|
g.id === group.id ? { ...g, collapsible: checked } : g
|
|
);
|
|
updateSection({ optionalFieldGroups: newGroups });
|
|
}}
|
|
className="scale-50"
|
|
/>
|
|
<span className="text-[8px]">접기 가능</span>
|
|
</div>
|
|
<div className="flex items-end gap-1 pb-0.5">
|
|
<Switch
|
|
checked={group.defaultCollapsed || false}
|
|
onCheckedChange={(checked) => {
|
|
const newGroups = localSection.optionalFieldGroups?.map((g) =>
|
|
g.id === group.id ? { ...g, defaultCollapsed: checked } : g
|
|
);
|
|
updateSection({ optionalFieldGroups: newGroups });
|
|
}}
|
|
disabled={!group.collapsible}
|
|
className="scale-50"
|
|
/>
|
|
<span className="text-[8px]">기본 접힘</span>
|
|
</div>
|
|
<div className="flex items-end gap-1 pb-0.5">
|
|
<Switch
|
|
checked={group.confirmRemove || false}
|
|
onCheckedChange={(checked) => {
|
|
const newGroups = localSection.optionalFieldGroups?.map((g) =>
|
|
g.id === group.id ? { ...g, confirmRemove: checked } : g
|
|
);
|
|
updateSection({ optionalFieldGroups: newGroups });
|
|
}}
|
|
className="scale-50"
|
|
/>
|
|
<span className="text-[8px]">제거 확인</span>
|
|
</div>
|
|
</div>
|
|
|
|
<Separator className="my-2" />
|
|
|
|
{/* 버튼 텍스트 설정 */}
|
|
<div>
|
|
<Label className="text-[9px] font-medium">버튼 설정</Label>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<Label className="text-[8px]">추가 버튼 텍스트</Label>
|
|
<Input
|
|
value={group.addButtonText || ""}
|
|
onChange={(e) => {
|
|
const newGroups = localSection.optionalFieldGroups?.map((g) =>
|
|
g.id === group.id ? { ...g, addButtonText: e.target.value } : g
|
|
);
|
|
updateSection({ optionalFieldGroups: newGroups });
|
|
}}
|
|
className="mt-0.5 h-5 text-[8px]"
|
|
placeholder="+ 해외 판매 설정 추가"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-[8px]">제거 버튼 텍스트</Label>
|
|
<Input
|
|
value={group.removeButtonText || ""}
|
|
onChange={(e) => {
|
|
const newGroups = localSection.optionalFieldGroups?.map((g) =>
|
|
g.id === group.id ? { ...g, removeButtonText: e.target.value } : g
|
|
);
|
|
updateSection({ optionalFieldGroups: newGroups });
|
|
}}
|
|
className="mt-0.5 h-5 text-[8px]"
|
|
placeholder="제거"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<Separator className="my-2" />
|
|
|
|
{/* 연동 필드 설정 */}
|
|
<div>
|
|
<Label className="text-[9px] font-medium">연동 필드 설정 (선택)</Label>
|
|
<HelpText>추가/제거 시 다른 필드의 값을 자동으로 변경합니다</HelpText>
|
|
</div>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
<div>
|
|
<Label className="text-[8px]">연동 필드 (컬럼명)</Label>
|
|
<Input
|
|
value={group.triggerField || ""}
|
|
onChange={(e) => {
|
|
const newGroups = localSection.optionalFieldGroups?.map((g) =>
|
|
g.id === group.id ? { ...g, triggerField: e.target.value } : g
|
|
);
|
|
updateSection({ optionalFieldGroups: newGroups });
|
|
}}
|
|
className="mt-0.5 h-5 text-[8px]"
|
|
placeholder="sales_type"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-[8px]">추가 시 값</Label>
|
|
<Input
|
|
value={group.triggerValueOnAdd || ""}
|
|
onChange={(e) => {
|
|
const newGroups = localSection.optionalFieldGroups?.map((g) =>
|
|
g.id === group.id ? { ...g, triggerValueOnAdd: e.target.value } : g
|
|
);
|
|
updateSection({ optionalFieldGroups: newGroups });
|
|
}}
|
|
className="mt-0.5 h-5 text-[8px]"
|
|
placeholder="해외"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-[8px]">제거 시 값</Label>
|
|
<Input
|
|
value={group.triggerValueOnRemove || ""}
|
|
onChange={(e) => {
|
|
const newGroups = localSection.optionalFieldGroups?.map((g) =>
|
|
g.id === group.id ? { ...g, triggerValueOnRemove: e.target.value } : g
|
|
);
|
|
updateSection({ optionalFieldGroups: newGroups });
|
|
}}
|
|
className="mt-0.5 h-5 text-[8px]"
|
|
placeholder="국내"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<Separator className="my-2" />
|
|
|
|
{/* 그룹 내 필드 목록 */}
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-[9px] font-medium">필드 목록</Label>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => {
|
|
const newField: FormFieldConfig = {
|
|
...defaultFieldConfig,
|
|
id: generateFieldId(),
|
|
label: `필드 ${group.fields.length + 1}`,
|
|
columnName: `field_${group.fields.length + 1}`,
|
|
};
|
|
const newGroups = localSection.optionalFieldGroups?.map((g) =>
|
|
g.id === group.id ? { ...g, fields: [...g.fields, newField] } : g
|
|
);
|
|
updateSection({ optionalFieldGroups: newGroups });
|
|
}}
|
|
className="h-5 text-[8px] px-1.5"
|
|
>
|
|
<Plus className="h-2.5 w-2.5 mr-0.5" />
|
|
필드 추가
|
|
</Button>
|
|
</div>
|
|
|
|
{group.fields.length === 0 ? (
|
|
<div className="text-center py-3 border border-dashed rounded">
|
|
<p className="text-[9px] text-muted-foreground">필드를 추가하세요</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-1">
|
|
{group.fields.map((field, fieldIndex) => (
|
|
<div key={field.id} className="flex items-center gap-2 bg-white/50 rounded p-1.5">
|
|
<div className="flex-1 grid grid-cols-4 gap-1">
|
|
<Input
|
|
value={field.label}
|
|
onChange={(e) => {
|
|
const newGroups = localSection.optionalFieldGroups?.map((g) =>
|
|
g.id === group.id
|
|
? {
|
|
...g,
|
|
fields: g.fields.map((f) =>
|
|
f.id === field.id ? { ...f, label: e.target.value } : f
|
|
),
|
|
}
|
|
: g
|
|
);
|
|
updateSection({ optionalFieldGroups: newGroups });
|
|
}}
|
|
className="h-5 text-[8px]"
|
|
placeholder="라벨"
|
|
/>
|
|
{tableColumns.length > 0 ? (
|
|
<Popover
|
|
open={columnSearchOpen[`opt-${field.id}`] || false}
|
|
onOpenChange={(open) => setColumnSearchOpen(prev => ({ ...prev, [`opt-${field.id}`]: open }))}
|
|
>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
className="h-6 w-full justify-between text-[8px] font-normal px-1"
|
|
>
|
|
{field.columnName ? (
|
|
<div className="flex flex-col items-start text-left truncate">
|
|
<span className="font-medium truncate">{field.columnName}</span>
|
|
{(() => {
|
|
const col = tableColumns.find(c => c.name === field.columnName);
|
|
return col?.label && col.label !== field.columnName ? (
|
|
<span className="text-[7px] text-muted-foreground truncate">({col.label})</span>
|
|
) : null;
|
|
})()}
|
|
</div>
|
|
) : (
|
|
<span className="text-muted-foreground">컬럼...</span>
|
|
)}
|
|
<ChevronsUpDown className="h-2.5 w-2.5 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="p-0 w-[250px]" align="start">
|
|
<Command>
|
|
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
|
|
<CommandList className="max-h-[180px]">
|
|
<CommandEmpty className="text-xs py-2 text-center">
|
|
컬럼을 찾을 수 없습니다.
|
|
</CommandEmpty>
|
|
<CommandGroup>
|
|
{tableColumns.map((col) => (
|
|
<CommandItem
|
|
key={col.name}
|
|
value={`${col.name} ${col.label}`}
|
|
onSelect={() => {
|
|
const newGroups = localSection.optionalFieldGroups?.map((g) =>
|
|
g.id === group.id
|
|
? {
|
|
...g,
|
|
fields: g.fields.map((f) =>
|
|
f.id === field.id
|
|
? {
|
|
...f,
|
|
columnName: col.name,
|
|
...(f.label.startsWith("필드 ") ? { label: col.label || col.name } : {})
|
|
}
|
|
: f
|
|
),
|
|
}
|
|
: g
|
|
);
|
|
updateSection({ optionalFieldGroups: newGroups });
|
|
setColumnSearchOpen(prev => ({ ...prev, [`opt-${field.id}`]: false }));
|
|
}}
|
|
className="text-[9px]"
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-1 h-2.5 w-2.5",
|
|
field.columnName === col.name ? "opacity-100" : "opacity-0"
|
|
)}
|
|
/>
|
|
<div className="flex flex-col">
|
|
<span className="font-medium">{col.name}</span>
|
|
{col.label && col.label !== col.name && (
|
|
<span className="text-[8px] text-muted-foreground">({col.label})</span>
|
|
)}
|
|
</div>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
) : (
|
|
<Input
|
|
value={field.columnName}
|
|
onChange={(e) => {
|
|
const newGroups = localSection.optionalFieldGroups?.map((g) =>
|
|
g.id === group.id
|
|
? {
|
|
...g,
|
|
fields: g.fields.map((f) =>
|
|
f.id === field.id ? { ...f, columnName: e.target.value } : f
|
|
),
|
|
}
|
|
: g
|
|
);
|
|
updateSection({ optionalFieldGroups: newGroups });
|
|
}}
|
|
className="h-5 text-[8px]"
|
|
placeholder="컬럼명"
|
|
/>
|
|
)}
|
|
<Select
|
|
value={field.fieldType}
|
|
onValueChange={(value) => {
|
|
const newGroups = localSection.optionalFieldGroups?.map((g) =>
|
|
g.id === group.id
|
|
? {
|
|
...g,
|
|
fields: g.fields.map((f) =>
|
|
f.id === field.id
|
|
? { ...f, fieldType: value as FormFieldConfig["fieldType"] }
|
|
: f
|
|
),
|
|
}
|
|
: g
|
|
);
|
|
updateSection({ optionalFieldGroups: newGroups });
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-5 text-[8px]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{FIELD_TYPE_OPTIONS.map((opt) => (
|
|
<SelectItem key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Select
|
|
value={String(field.gridSpan || 3)}
|
|
onValueChange={(value) => {
|
|
const newGroups = localSection.optionalFieldGroups?.map((g) =>
|
|
g.id === group.id
|
|
? {
|
|
...g,
|
|
fields: g.fields.map((f) =>
|
|
f.id === field.id ? { ...f, gridSpan: parseInt(value) } : f
|
|
),
|
|
}
|
|
: g
|
|
);
|
|
updateSection({ optionalFieldGroups: newGroups });
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-5 text-[8px]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="3">1/4</SelectItem>
|
|
<SelectItem value="4">1/3</SelectItem>
|
|
<SelectItem value="6">1/2</SelectItem>
|
|
<SelectItem value="12">전체</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => handleOpenFieldDetail(field)}
|
|
className="h-5 px-1.5 text-[8px]"
|
|
>
|
|
<SettingsIcon className="h-2.5 w-2.5 mr-0.5" />
|
|
상세
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
const newGroups = localSection.optionalFieldGroups?.map((g) =>
|
|
g.id === group.id
|
|
? { ...g, fields: g.fields.filter((f) => f.id !== field.id) }
|
|
: g
|
|
);
|
|
updateSection({ optionalFieldGroups: newGroups });
|
|
}}
|
|
className="h-5 w-5 p-0 text-destructive hover:text-destructive"
|
|
>
|
|
<Trash2 className="h-2.5 w-2.5" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</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">
|
|
저장 ({fields.length}개 필드)
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
|
|
|