1860 lines
97 KiB
TypeScript
1860 lines
97 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 { 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, Check, ChevronsUpDown } from "lucide-react";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import {
|
|
Command,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
} from "@/components/ui/command";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
FormFieldConfig,
|
|
LinkedFieldMapping,
|
|
FIELD_TYPE_OPTIONS,
|
|
SELECT_OPTION_TYPE_OPTIONS,
|
|
LINKED_FIELD_DISPLAY_FORMAT_OPTIONS,
|
|
} from "../types";
|
|
import { apiClient } from "@/lib/api/client";
|
|
import { getCascadingRelations, getCascadingRelationByCode, CascadingRelation } from "@/lib/api/cascadingRelation";
|
|
|
|
// 카테고리 컬럼 타입 (table_column_category_values 용)
|
|
interface CategoryColumnOption {
|
|
tableName: string;
|
|
columnName: string;
|
|
columnLabel: string;
|
|
valueCount: number;
|
|
// 조합키: tableName.columnName
|
|
key: string;
|
|
}
|
|
|
|
// 도움말 텍스트 컴포넌트
|
|
const HelpText = ({ children }: { children: React.ReactNode }) => (
|
|
<p className="text-[10px] text-muted-foreground mt-0.5">{children}</p>
|
|
);
|
|
|
|
/**
|
|
* 부모 화면에서 전달 가능한 필드 타입
|
|
* 유니버셜 폼 모달에서 "부모에서 값 받기" 설정 시 선택 가능한 필드 목록
|
|
*/
|
|
export interface AvailableParentField {
|
|
name: string; // 필드명 (columnName)
|
|
label: string; // 표시 라벨
|
|
sourceComponent?: string; // 출처 컴포넌트 (예: "TableList", "SplitPanelLayout2")
|
|
sourceTable?: string; // 출처 테이블명
|
|
}
|
|
|
|
// 섹션별 필드 그룹
|
|
interface SectionFieldGroup {
|
|
sectionId: string;
|
|
sectionTitle: string;
|
|
fields: FormFieldConfig[];
|
|
}
|
|
|
|
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;
|
|
// 저장 테이블 정보 (타겟 컬럼 선택용)
|
|
targetTableName?: string;
|
|
targetTableColumns?: { name: string; type: string; label: string }[];
|
|
// 연쇄 드롭다운 부모 필드 선택용 - 모든 섹션의 필드 목록 (섹션별 그룹핑)
|
|
allFieldsWithSections?: SectionFieldGroup[];
|
|
}
|
|
|
|
export function FieldDetailSettingsModal({
|
|
open,
|
|
onOpenChange,
|
|
field,
|
|
onSave,
|
|
tables,
|
|
tableColumns,
|
|
numberingRules,
|
|
onLoadTableColumns,
|
|
// targetTableName은 타겟 컬럼 선택 시 참고용으로 전달됨 (현재 targetTableColumns만 사용)
|
|
targetTableName: _targetTableName,
|
|
targetTableColumns = [],
|
|
allFieldsWithSections = [],
|
|
}: FieldDetailSettingsModalProps) {
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
void _targetTableName; // 향후 사용 가능성을 위해 유지
|
|
// 로컬 상태로 필드 설정 관리
|
|
const [localField, setLocalField] = useState<FormFieldConfig>(field);
|
|
|
|
// 전체 카테고리 컬럼 목록 상태
|
|
const [categoryColumns, setCategoryColumns] = useState<CategoryColumnOption[]>([]);
|
|
const [loadingCategoryColumns, setLoadingCategoryColumns] = useState(false);
|
|
|
|
// 연쇄 관계 목록 상태
|
|
const [cascadingRelations, setCascadingRelations] = useState<CascadingRelation[]>([]);
|
|
const [loadingCascadingRelations, setLoadingCascadingRelations] = useState(false);
|
|
const [cascadingRelationOpen, setCascadingRelationOpen] = useState(false);
|
|
const [parentFieldOpen, setParentFieldOpen] = useState(false);
|
|
|
|
// Combobox 열림 상태
|
|
const [sourceTableOpen, setSourceTableOpen] = useState(false);
|
|
const [targetColumnOpenMap, setTargetColumnOpenMap] = useState<Record<number, boolean>>({});
|
|
const [displayColumnOpen, setDisplayColumnOpen] = useState(false);
|
|
const [subDisplayColumnOpen, setSubDisplayColumnOpen] = useState(false); // 서브 표시 컬럼 Popover 상태
|
|
const [sourceColumnOpenMap, setSourceColumnOpenMap] = useState<Record<number, boolean>>({});
|
|
|
|
// open이 변경될 때마다 필드 데이터 동기화
|
|
useEffect(() => {
|
|
if (open) {
|
|
setLocalField(field);
|
|
}
|
|
}, [open, field]);
|
|
|
|
// 모달이 열릴 때 소스 테이블 컬럼 자동 로드
|
|
useEffect(() => {
|
|
if (open && field.linkedFieldGroup?.sourceTable) {
|
|
// tableColumns에 해당 테이블 컬럼이 없으면 로드
|
|
if (!tableColumns[field.linkedFieldGroup.sourceTable] || tableColumns[field.linkedFieldGroup.sourceTable].length === 0) {
|
|
onLoadTableColumns(field.linkedFieldGroup.sourceTable);
|
|
}
|
|
}
|
|
}, [open, field.linkedFieldGroup?.sourceTable, tableColumns, onLoadTableColumns]);
|
|
|
|
// 모달이 열릴 때 Select 옵션의 참조 테이블 컬럼 자동 로드
|
|
useEffect(() => {
|
|
if (open && field.selectOptions?.tableName) {
|
|
// tableColumns에 해당 테이블 컬럼이 없으면 로드
|
|
if (!tableColumns[field.selectOptions.tableName] || tableColumns[field.selectOptions.tableName].length === 0) {
|
|
onLoadTableColumns(field.selectOptions.tableName);
|
|
}
|
|
}
|
|
}, [open, field.selectOptions?.tableName, tableColumns, onLoadTableColumns]);
|
|
|
|
// 모든 카테고리 컬럼 목록 로드 (모달 열릴 때)
|
|
useEffect(() => {
|
|
const loadAllCategoryColumns = async () => {
|
|
if (!open) return;
|
|
|
|
setLoadingCategoryColumns(true);
|
|
try {
|
|
// /api/table-categories/all-columns API 호출
|
|
const response = await apiClient.get("/table-categories/all-columns");
|
|
if (response.data?.success && response.data?.data) {
|
|
// 중복 제거를 위해 Map 사용
|
|
const uniqueMap = new Map<string, CategoryColumnOption>();
|
|
response.data.data.forEach((col: any) => {
|
|
const tableName = col.tableName || col.table_name;
|
|
const columnName = col.columnName || col.column_name;
|
|
const key = `${tableName}.${columnName}`;
|
|
|
|
// 이미 존재하는 경우 valueCount가 더 큰 것을 유지
|
|
if (!uniqueMap.has(key)) {
|
|
uniqueMap.set(key, {
|
|
tableName,
|
|
columnName,
|
|
columnLabel: col.columnLabel || col.column_label || columnName,
|
|
valueCount: parseInt(col.valueCount || col.value_count || "0"),
|
|
key,
|
|
});
|
|
}
|
|
});
|
|
|
|
setCategoryColumns(Array.from(uniqueMap.values()));
|
|
} else {
|
|
setCategoryColumns([]);
|
|
}
|
|
} catch (error) {
|
|
setCategoryColumns([]);
|
|
} finally {
|
|
setLoadingCategoryColumns(false);
|
|
}
|
|
};
|
|
|
|
loadAllCategoryColumns();
|
|
}, [open]);
|
|
|
|
// 연쇄 관계 목록 로드 (모달 열릴 때)
|
|
useEffect(() => {
|
|
const loadCascadingRelations = async () => {
|
|
if (!open) return;
|
|
|
|
setLoadingCascadingRelations(true);
|
|
try {
|
|
const result = await getCascadingRelations("Y"); // 활성화된 것만
|
|
if (result?.success && result?.data) {
|
|
setCascadingRelations(result.data);
|
|
} else {
|
|
setCascadingRelations([]);
|
|
}
|
|
} catch (error) {
|
|
setCascadingRelations([]);
|
|
} finally {
|
|
setLoadingCascadingRelations(false);
|
|
}
|
|
};
|
|
|
|
loadCascadingRelations();
|
|
}, [open]);
|
|
|
|
// 관계 코드 선택 시 상세 설정 자동 채움
|
|
const handleRelationCodeSelect = async (relationCode: string) => {
|
|
if (!relationCode) return;
|
|
|
|
try {
|
|
const result = await getCascadingRelationByCode(relationCode);
|
|
if (result?.success && result?.data) {
|
|
const relation = result.data as CascadingRelation;
|
|
updateField({
|
|
selectOptions: {
|
|
...localField.selectOptions,
|
|
type: "cascading",
|
|
tableName: relation.child_table,
|
|
valueColumn: relation.child_value_column,
|
|
labelColumn: relation.child_label_column,
|
|
cascading: {
|
|
...localField.selectOptions?.cascading,
|
|
relationCode: relation.relation_code,
|
|
sourceTable: relation.child_table,
|
|
parentKeyColumn: relation.child_filter_column,
|
|
emptyParentMessage: relation.empty_parent_message,
|
|
noOptionsMessage: relation.no_options_message,
|
|
clearOnParentChange: relation.clear_on_parent_change === "Y",
|
|
},
|
|
},
|
|
});
|
|
|
|
// 소스 테이블 컬럼 로드
|
|
if (relation.child_table) {
|
|
onLoadTableColumns(relation.child_table);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("관계 코드 조회 실패:", error);
|
|
}
|
|
};
|
|
|
|
// 필드 업데이트 함수
|
|
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>
|
|
</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 === "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) => {
|
|
// 타입 변경 시 관련 설정 초기화
|
|
if (value === "cascading") {
|
|
updateField({
|
|
selectOptions: {
|
|
type: "cascading",
|
|
cascading: {
|
|
parentField: "",
|
|
clearOnParentChange: true,
|
|
},
|
|
},
|
|
});
|
|
} else {
|
|
updateField({
|
|
selectOptions: {
|
|
...localField.selectOptions,
|
|
type: value as "static" | "table" | "code",
|
|
cascading: undefined,
|
|
},
|
|
});
|
|
}
|
|
}}
|
|
>
|
|
<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>
|
|
<HelpText>
|
|
{localField.selectOptions?.type === "cascading"
|
|
? "연쇄 드롭다운: 부모 필드 선택에 따라 옵션이 동적으로 변경됩니다"
|
|
: "테이블 참조: DB 테이블에서 옵션 목록을 가져옵니다."}
|
|
</HelpText>
|
|
</div>
|
|
|
|
{/* 직접 입력 허용 - 모든 Select 타입에 공통 적용 */}
|
|
<div className="flex items-center justify-between pt-2 border-t">
|
|
<div className="flex flex-col">
|
|
<span className="text-[10px] font-medium">직접 입력 허용</span>
|
|
<span className="text-[9px] text-muted-foreground">
|
|
목록 선택 + 직접 타이핑 가능
|
|
</span>
|
|
</div>
|
|
<Switch
|
|
checked={localField.selectOptions?.allowCustomInput || false}
|
|
onCheckedChange={(checked) =>
|
|
updateField({
|
|
selectOptions: {
|
|
...localField.selectOptions,
|
|
allowCustomInput: checked,
|
|
},
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
<HelpText>
|
|
활성화 시 드롭다운 목록에서 선택하거나, 직접 값을 입력할 수 있습니다.
|
|
목록에 없는 새로운 값도 입력 가능합니다.
|
|
</HelpText>
|
|
|
|
{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 || "__default__"}
|
|
onValueChange={(value) =>
|
|
updateField({
|
|
selectOptions: {
|
|
...localField.selectOptions,
|
|
saveColumn: value === "__default__" ? "" : value,
|
|
},
|
|
})
|
|
}
|
|
>
|
|
<SelectTrigger className="h-7 text-xs mt-1">
|
|
<SelectValue placeholder="컬럼 선택 (미선택 시 조인 컬럼 저장)" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__default__">조인 컬럼 사용 (기본)</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>
|
|
<Select
|
|
value={localField.selectOptions?.categoryKey || ""}
|
|
onValueChange={(value) =>
|
|
updateField({
|
|
selectOptions: {
|
|
...localField.selectOptions,
|
|
categoryKey: value,
|
|
},
|
|
})
|
|
}
|
|
>
|
|
<SelectTrigger className="h-7 text-xs mt-1">
|
|
<SelectValue placeholder={loadingCategoryColumns ? "로딩 중..." : "카테고리 선택"} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{categoryColumns.map((col, idx) => (
|
|
<SelectItem key={`${col.key}-${idx}`} value={col.key}>
|
|
{col.columnLabel} - {col.tableName} ({col.valueCount}개)
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<HelpText>코드설정에서 등록한 카테고리를 선택하세요</HelpText>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{localField.selectOptions?.type === "cascading" && (
|
|
<div className="space-y-3 pt-2 border-t">
|
|
<HelpText>
|
|
연쇄 드롭다운: 부모 필드의 값에 따라 옵션이 동적으로 필터링됩니다.
|
|
<br />
|
|
예: 거래처 선택 → 해당 거래처의 납품처만 표시
|
|
</HelpText>
|
|
|
|
{/* 부모 필드 선택 - 콤보박스 (섹션별 그룹핑) */}
|
|
<div>
|
|
<Label className="text-[10px]">부모 필드명 *</Label>
|
|
{allFieldsWithSections.length > 0 ? (
|
|
<Popover open={parentFieldOpen} onOpenChange={setParentFieldOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={parentFieldOpen}
|
|
className="h-7 w-full justify-between text-xs mt-1 font-normal"
|
|
>
|
|
{localField.selectOptions?.cascading?.parentField
|
|
? (() => {
|
|
// 모든 섹션에서 선택된 필드 찾기
|
|
for (const section of allFieldsWithSections) {
|
|
const selectedField = section.fields.find(
|
|
(f) => f.columnName === localField.selectOptions?.cascading?.parentField
|
|
);
|
|
if (selectedField) {
|
|
return `${selectedField.label} (${selectedField.columnName})`;
|
|
}
|
|
}
|
|
return localField.selectOptions?.cascading?.parentField;
|
|
})()
|
|
: "부모 필드 선택..."}
|
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-[350px] p-0" align="start">
|
|
<Command>
|
|
<CommandInput placeholder="필드 검색..." className="h-8 text-xs" />
|
|
<CommandList className="max-h-[300px]">
|
|
<CommandEmpty className="py-2 text-xs text-center">
|
|
선택 가능한 필드가 없습니다.
|
|
</CommandEmpty>
|
|
{allFieldsWithSections.map((section) => {
|
|
// 자기 자신 제외한 필드 목록
|
|
const availableFields = section.fields.filter(
|
|
(f) => f.columnName !== field.columnName
|
|
);
|
|
if (availableFields.length === 0) return null;
|
|
|
|
return (
|
|
<CommandGroup
|
|
key={section.sectionId}
|
|
heading={section.sectionTitle}
|
|
className="[&_[cmdk-group-heading]]:text-[10px] [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:text-primary [&_[cmdk-group-heading]]:bg-muted/50 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1"
|
|
>
|
|
{availableFields.map((f) => (
|
|
<CommandItem
|
|
key={f.id}
|
|
value={`${section.sectionTitle} ${f.columnName} ${f.label}`}
|
|
onSelect={() => {
|
|
updateField({
|
|
selectOptions: {
|
|
...localField.selectOptions,
|
|
cascading: {
|
|
...localField.selectOptions?.cascading,
|
|
parentField: f.columnName,
|
|
},
|
|
},
|
|
});
|
|
setParentFieldOpen(false);
|
|
}}
|
|
className="text-xs"
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-3 w-3",
|
|
localField.selectOptions?.cascading?.parentField === f.columnName
|
|
? "opacity-100"
|
|
: "opacity-0"
|
|
)}
|
|
/>
|
|
<div className="flex flex-col">
|
|
<span className="font-medium">{f.label}</span>
|
|
<span className="text-[9px] text-muted-foreground">
|
|
{f.columnName} ({f.fieldType})
|
|
</span>
|
|
</div>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
);
|
|
})}
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
) : (
|
|
<Input
|
|
value={localField.selectOptions?.cascading?.parentField || ""}
|
|
onChange={(e) =>
|
|
updateField({
|
|
selectOptions: {
|
|
...localField.selectOptions,
|
|
cascading: {
|
|
...localField.selectOptions?.cascading,
|
|
parentField: e.target.value,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
placeholder="customer_code"
|
|
className="h-7 text-xs mt-1"
|
|
/>
|
|
)}
|
|
<HelpText>
|
|
이 드롭다운의 옵션을 결정할 부모 필드를 선택하세요
|
|
<br />
|
|
예: 거래처 선택 → 납품처 필터링
|
|
</HelpText>
|
|
</div>
|
|
|
|
{/* 관계 코드 선택 */}
|
|
<div>
|
|
<Label className="text-[10px]">관계 코드 (선택)</Label>
|
|
<Popover open={cascadingRelationOpen} onOpenChange={setCascadingRelationOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={cascadingRelationOpen}
|
|
className="h-7 w-full justify-between text-xs mt-1 font-normal"
|
|
>
|
|
{localField.selectOptions?.cascading?.relationCode
|
|
? (() => {
|
|
const selectedRelation = cascadingRelations.find(
|
|
(r) => r.relation_code === localField.selectOptions?.cascading?.relationCode
|
|
);
|
|
return selectedRelation
|
|
? `${selectedRelation.relation_name} (${selectedRelation.relation_code})`
|
|
: localField.selectOptions?.cascading?.relationCode;
|
|
})()
|
|
: loadingCascadingRelations
|
|
? "로딩 중..."
|
|
: "관계 선택 (또는 직접 설정)"}
|
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-[350px] p-0" align="start">
|
|
<Command>
|
|
<CommandInput placeholder="관계 검색..." className="h-8 text-xs" />
|
|
<CommandList>
|
|
<CommandEmpty className="py-2 text-xs text-center">
|
|
등록된 연쇄 관계가 없습니다.
|
|
</CommandEmpty>
|
|
<CommandGroup>
|
|
{/* 직접 설정 옵션 */}
|
|
<CommandItem
|
|
value="__direct__"
|
|
onSelect={() => {
|
|
updateField({
|
|
selectOptions: {
|
|
...localField.selectOptions,
|
|
cascading: {
|
|
...localField.selectOptions?.cascading,
|
|
relationCode: undefined,
|
|
},
|
|
},
|
|
});
|
|
setCascadingRelationOpen(false);
|
|
}}
|
|
className="text-xs"
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-3 w-3",
|
|
!localField.selectOptions?.cascading?.relationCode
|
|
? "opacity-100"
|
|
: "opacity-0"
|
|
)}
|
|
/>
|
|
<span className="text-muted-foreground">직접 설정</span>
|
|
</CommandItem>
|
|
<Separator className="my-1" />
|
|
{cascadingRelations.map((relation) => (
|
|
<CommandItem
|
|
key={relation.relation_id}
|
|
value={`${relation.relation_code} ${relation.relation_name}`}
|
|
onSelect={() => {
|
|
handleRelationCodeSelect(relation.relation_code);
|
|
setCascadingRelationOpen(false);
|
|
}}
|
|
className="text-xs"
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-3 w-3",
|
|
localField.selectOptions?.cascading?.relationCode === relation.relation_code
|
|
? "opacity-100"
|
|
: "opacity-0"
|
|
)}
|
|
/>
|
|
<div className="flex flex-col">
|
|
<span className="font-medium">{relation.relation_name}</span>
|
|
<span className="text-[9px] text-muted-foreground">
|
|
{relation.parent_table} → {relation.child_table}
|
|
</span>
|
|
</div>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
<HelpText>
|
|
미리 등록된 관계를 선택하면 설정이 자동으로 채워집니다.
|
|
<br />
|
|
직접 설정을 선택하면 아래에서 수동으로 입력할 수 있습니다.
|
|
</HelpText>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* 상세 설정 (수정 가능) */}
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<SettingsIcon className="h-3 w-3 text-muted-foreground" />
|
|
<span className="text-[10px] font-medium">상세 설정 (수정 가능)</span>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-[10px]">소스 테이블</Label>
|
|
<Select
|
|
value={localField.selectOptions?.cascading?.sourceTable || localField.selectOptions?.tableName || ""}
|
|
onValueChange={(value) => {
|
|
updateField({
|
|
selectOptions: {
|
|
...localField.selectOptions,
|
|
tableName: value,
|
|
cascading: {
|
|
...localField.selectOptions?.cascading,
|
|
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>옵션을 가져올 테이블 (예: delivery_destination)</HelpText>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-[10px]">부모 키 컬럼</Label>
|
|
{selectTableColumns.length > 0 ? (
|
|
<Select
|
|
value={localField.selectOptions?.cascading?.parentKeyColumn || ""}
|
|
onValueChange={(value) =>
|
|
updateField({
|
|
selectOptions: {
|
|
...localField.selectOptions,
|
|
cascading: {
|
|
...localField.selectOptions?.cascading,
|
|
parentKeyColumn: 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?.cascading?.parentKeyColumn || ""}
|
|
onChange={(e) =>
|
|
updateField({
|
|
selectOptions: {
|
|
...localField.selectOptions,
|
|
cascading: {
|
|
...localField.selectOptions?.cascading,
|
|
parentKeyColumn: e.target.value,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
placeholder="customer_code"
|
|
className="h-7 text-xs mt-1"
|
|
/>
|
|
)}
|
|
<HelpText>부모 값과 매칭할 컬럼 (예: customer_code)</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="destination_code"
|
|
className="h-7 text-xs mt-1"
|
|
/>
|
|
)}
|
|
<HelpText>드롭다운 value로 사용할 컬럼</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="destination_name"
|
|
className="h-7 text-xs mt-1"
|
|
/>
|
|
)}
|
|
<HelpText>드롭다운에 표시할 컬럼</HelpText>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
<div>
|
|
<Label className="text-[10px]">부모 미선택 시 메시지</Label>
|
|
<Input
|
|
value={localField.selectOptions?.cascading?.emptyParentMessage || ""}
|
|
onChange={(e) =>
|
|
updateField({
|
|
selectOptions: {
|
|
...localField.selectOptions,
|
|
cascading: {
|
|
...localField.selectOptions?.cascading,
|
|
emptyParentMessage: e.target.value,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
placeholder="상위 항목을 먼저 선택하세요"
|
|
className="h-7 text-xs mt-1"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-[10px]">옵션 없음 메시지</Label>
|
|
<Input
|
|
value={localField.selectOptions?.cascading?.noOptionsMessage || ""}
|
|
onChange={(e) =>
|
|
updateField({
|
|
selectOptions: {
|
|
...localField.selectOptions,
|
|
cascading: {
|
|
...localField.selectOptions?.cascading,
|
|
noOptionsMessage: e.target.value,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
placeholder="선택 가능한 항목이 없습니다"
|
|
className="h-7 text-xs mt-1"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-[10px]">부모 변경 시 값 초기화</span>
|
|
<Switch
|
|
checked={localField.selectOptions?.cascading?.clearOnParentChange !== false}
|
|
onCheckedChange={(checked) =>
|
|
updateField({
|
|
selectOptions: {
|
|
...localField.selectOptions,
|
|
cascading: {
|
|
...localField.selectOptions?.cascading,
|
|
clearOnParentChange: checked,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
<HelpText>부모 필드 값이 변경되면 이 필드의 값을 자동으로 초기화합니다</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>
|
|
<Popover open={sourceTableOpen} onOpenChange={setSourceTableOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={sourceTableOpen}
|
|
className="h-7 w-full justify-between text-xs mt-1 font-normal"
|
|
>
|
|
{localField.linkedFieldGroup?.sourceTable
|
|
? (() => {
|
|
const selectedTable = tables.find(
|
|
(t) => t.name === localField.linkedFieldGroup?.sourceTable
|
|
);
|
|
return selectedTable
|
|
? `${selectedTable.label || selectedTable.name} (${selectedTable.name})`
|
|
: localField.linkedFieldGroup?.sourceTable;
|
|
})()
|
|
: "테이블 선택..."}
|
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-[300px] p-0" align="start">
|
|
<Command>
|
|
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
|
|
<CommandList>
|
|
<CommandEmpty className="py-2 text-xs text-center">
|
|
테이블을 찾을 수 없습니다.
|
|
</CommandEmpty>
|
|
<CommandGroup>
|
|
{tables.map((t) => (
|
|
<CommandItem
|
|
key={t.name}
|
|
value={`${t.name} ${t.label || ""}`}
|
|
onSelect={() => {
|
|
updateField({
|
|
linkedFieldGroup: {
|
|
...localField.linkedFieldGroup,
|
|
sourceTable: t.name,
|
|
},
|
|
});
|
|
onLoadTableColumns(t.name);
|
|
setSourceTableOpen(false);
|
|
}}
|
|
className="text-xs"
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-3 w-3",
|
|
localField.linkedFieldGroup?.sourceTable === t.name
|
|
? "opacity-100"
|
|
: "opacity-0"
|
|
)}
|
|
/>
|
|
<span className="font-medium">{t.label || t.name}</span>
|
|
<span className="ml-1 text-muted-foreground">({t.name})</span>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
<HelpText>값을 가져올 소스 테이블 (예: customer_mng)</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",
|
|
// name_only 선택 시 서브 컬럼 초기화
|
|
...(value === "name_only" ? { subDisplayColumn: undefined } : {}),
|
|
},
|
|
})
|
|
}
|
|
>
|
|
<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}>
|
|
<div className="flex flex-col">
|
|
<span>{opt.label}</span>
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{opt.value === "name_only" && "메인 컬럼만 표시"}
|
|
{opt.value === "code_name" && "서브 - 메인 형식"}
|
|
{opt.value === "name_code" && "메인 (서브) 형식"}
|
|
</span>
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<HelpText>드롭다운에 표시할 형식을 선택합니다</HelpText>
|
|
</div>
|
|
|
|
{/* 메인 표시 컬럼 */}
|
|
<div>
|
|
<Label className="text-[10px]">메인 표시 컬럼</Label>
|
|
{sourceTableColumns.length > 0 ? (
|
|
<Popover open={displayColumnOpen} onOpenChange={setDisplayColumnOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={displayColumnOpen}
|
|
className="h-7 w-full justify-between text-xs mt-1 font-normal"
|
|
>
|
|
{localField.linkedFieldGroup?.displayColumn
|
|
? (() => {
|
|
const selectedCol = sourceTableColumns.find(
|
|
(c) => c.name === localField.linkedFieldGroup?.displayColumn
|
|
);
|
|
return selectedCol
|
|
? `${selectedCol.name} (${selectedCol.label})`
|
|
: localField.linkedFieldGroup?.displayColumn;
|
|
})()
|
|
: "컬럼 선택..."}
|
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-[280px] p-0" align="start">
|
|
<Command>
|
|
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
|
<CommandList>
|
|
<CommandEmpty className="py-2 text-xs text-center">
|
|
컬럼을 찾을 수 없습니다.
|
|
</CommandEmpty>
|
|
<CommandGroup>
|
|
{sourceTableColumns.map((col) => (
|
|
<CommandItem
|
|
key={col.name}
|
|
value={`${col.name} ${col.label}`}
|
|
onSelect={() => {
|
|
updateField({
|
|
linkedFieldGroup: {
|
|
...localField.linkedFieldGroup,
|
|
displayColumn: col.name,
|
|
},
|
|
});
|
|
setDisplayColumnOpen(false);
|
|
}}
|
|
className="text-xs"
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-3 w-3",
|
|
localField.linkedFieldGroup?.displayColumn === col.name
|
|
? "opacity-100"
|
|
: "opacity-0"
|
|
)}
|
|
/>
|
|
<span className="font-medium">{col.name}</span>
|
|
<span className="ml-1 text-muted-foreground">({col.label})</span>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
) : (
|
|
<Input
|
|
value={localField.linkedFieldGroup?.displayColumn || ""}
|
|
onChange={(e) =>
|
|
updateField({
|
|
linkedFieldGroup: {
|
|
...localField.linkedFieldGroup,
|
|
displayColumn: e.target.value,
|
|
},
|
|
})
|
|
}
|
|
placeholder="item_name"
|
|
className="h-7 text-xs mt-1"
|
|
/>
|
|
)}
|
|
<HelpText>드롭다운에 표시할 메인 컬럼 (예: item_name)</HelpText>
|
|
</div>
|
|
|
|
{/* 서브 표시 컬럼 - 표시 형식이 name_only가 아닌 경우에만 표시 */}
|
|
{localField.linkedFieldGroup?.displayFormat &&
|
|
localField.linkedFieldGroup.displayFormat !== "name_only" && (
|
|
<div>
|
|
<Label className="text-[10px]">서브 표시 컬럼</Label>
|
|
{sourceTableColumns.length > 0 ? (
|
|
<Popover open={subDisplayColumnOpen} onOpenChange={setSubDisplayColumnOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={subDisplayColumnOpen}
|
|
className="h-7 w-full justify-between text-xs mt-1 font-normal"
|
|
>
|
|
{localField.linkedFieldGroup?.subDisplayColumn
|
|
? (() => {
|
|
const selectedCol = sourceTableColumns.find(
|
|
(c) => c.name === localField.linkedFieldGroup?.subDisplayColumn
|
|
);
|
|
return selectedCol
|
|
? `${selectedCol.name} (${selectedCol.label})`
|
|
: localField.linkedFieldGroup?.subDisplayColumn;
|
|
})()
|
|
: "컬럼 선택..."}
|
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-[280px] p-0" align="start">
|
|
<Command>
|
|
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
|
<CommandList>
|
|
<CommandEmpty className="py-2 text-xs text-center">
|
|
컬럼을 찾을 수 없습니다.
|
|
</CommandEmpty>
|
|
<CommandGroup>
|
|
{sourceTableColumns.map((col) => (
|
|
<CommandItem
|
|
key={col.name}
|
|
value={`${col.name} ${col.label}`}
|
|
onSelect={() => {
|
|
updateField({
|
|
linkedFieldGroup: {
|
|
...localField.linkedFieldGroup,
|
|
subDisplayColumn: col.name,
|
|
},
|
|
});
|
|
setSubDisplayColumnOpen(false);
|
|
}}
|
|
className="text-xs"
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-3 w-3",
|
|
localField.linkedFieldGroup?.subDisplayColumn === col.name
|
|
? "opacity-100"
|
|
: "opacity-0"
|
|
)}
|
|
/>
|
|
<span className="font-medium">{col.name}</span>
|
|
<span className="ml-1 text-muted-foreground">({col.label})</span>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
) : (
|
|
<Input
|
|
value={localField.linkedFieldGroup?.subDisplayColumn || ""}
|
|
onChange={(e) =>
|
|
updateField({
|
|
linkedFieldGroup: {
|
|
...localField.linkedFieldGroup,
|
|
subDisplayColumn: e.target.value,
|
|
},
|
|
})
|
|
}
|
|
placeholder="item_code"
|
|
className="h-7 text-xs mt-1"
|
|
/>
|
|
)}
|
|
<HelpText>
|
|
{localField.linkedFieldGroup?.displayFormat === "code_name"
|
|
? "메인 앞에 표시될 서브 컬럼 (예: 서브 - 메인)"
|
|
: "메인 뒤에 표시될 서브 컬럼 (예: 메인 (서브))"}
|
|
</HelpText>
|
|
</div>
|
|
)}
|
|
|
|
{/* 미리보기 - 메인 컬럼이 선택된 경우에만 표시 */}
|
|
{localField.linkedFieldGroup?.displayColumn && (
|
|
<div className="p-3 bg-muted/50 rounded-lg border border-dashed">
|
|
<p className="text-[10px] text-muted-foreground mb-2">미리보기:</p>
|
|
{(() => {
|
|
const mainCol = localField.linkedFieldGroup?.displayColumn || "";
|
|
const subCol = localField.linkedFieldGroup?.subDisplayColumn || "";
|
|
const mainLabel = sourceTableColumns.find(c => c.name === mainCol)?.label || mainCol;
|
|
const subLabel = sourceTableColumns.find(c => c.name === subCol)?.label || subCol;
|
|
const format = localField.linkedFieldGroup?.displayFormat || "name_only";
|
|
|
|
let preview = "";
|
|
if (format === "name_only") {
|
|
preview = mainLabel;
|
|
} else if (format === "code_name" && subCol) {
|
|
preview = `${subLabel} - ${mainLabel}`;
|
|
} else if (format === "name_code" && subCol) {
|
|
preview = `${mainLabel} (${subLabel})`;
|
|
} else if (!subCol) {
|
|
preview = `${mainLabel} (서브 컬럼을 선택하세요)`;
|
|
} else {
|
|
preview = mainLabel;
|
|
}
|
|
|
|
return (
|
|
<p className="text-sm font-medium">{preview}</p>
|
|
);
|
|
})()}
|
|
</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 ? (
|
|
<Popover
|
|
open={sourceColumnOpenMap[index] || false}
|
|
onOpenChange={(open) =>
|
|
setSourceColumnOpenMap((prev) => ({ ...prev, [index]: open }))
|
|
}
|
|
>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={sourceColumnOpenMap[index] || false}
|
|
className="h-6 w-full justify-between text-[9px] mt-0.5 font-normal"
|
|
>
|
|
{mapping.sourceColumn
|
|
? (() => {
|
|
const selectedCol = sourceTableColumns.find(
|
|
(c) => c.name === mapping.sourceColumn
|
|
);
|
|
return selectedCol
|
|
? `${selectedCol.name} (${selectedCol.label})`
|
|
: mapping.sourceColumn;
|
|
})()
|
|
: "컬럼 선택..."}
|
|
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-[250px] p-0" align="start">
|
|
<Command>
|
|
<CommandInput placeholder="컬럼 검색..." className="h-7 text-[9px]" />
|
|
<CommandList>
|
|
<CommandEmpty className="py-2 text-[9px] text-center">
|
|
컬럼을 찾을 수 없습니다.
|
|
</CommandEmpty>
|
|
<CommandGroup>
|
|
{sourceTableColumns.map((col) => (
|
|
<CommandItem
|
|
key={col.name}
|
|
value={`${col.name} ${col.label}`}
|
|
onSelect={() => {
|
|
updateLinkedFieldMapping(index, { sourceColumn: col.name });
|
|
setSourceColumnOpenMap((prev) => ({ ...prev, [index]: false }));
|
|
}}
|
|
className="text-[9px]"
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-3 w-3",
|
|
mapping.sourceColumn === col.name
|
|
? "opacity-100"
|
|
: "opacity-0"
|
|
)}
|
|
/>
|
|
<span className="font-medium">{col.name}</span>
|
|
<span className="ml-1 text-muted-foreground">({col.label})</span>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
) : (
|
|
<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>
|
|
{targetTableColumns.length > 0 ? (
|
|
<Popover
|
|
open={targetColumnOpenMap[index] || false}
|
|
onOpenChange={(open) =>
|
|
setTargetColumnOpenMap((prev) => ({ ...prev, [index]: open }))
|
|
}
|
|
>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={targetColumnOpenMap[index] || false}
|
|
className="h-6 w-full justify-between text-[9px] mt-0.5 font-normal"
|
|
>
|
|
{mapping.targetColumn
|
|
? (() => {
|
|
const selectedCol = targetTableColumns.find(
|
|
(c) => c.name === mapping.targetColumn
|
|
);
|
|
return selectedCol
|
|
? `${selectedCol.name} (${selectedCol.label})`
|
|
: mapping.targetColumn;
|
|
})()
|
|
: "컬럼 선택..."}
|
|
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-[250px] p-0" align="start">
|
|
<Command>
|
|
<CommandInput placeholder="컬럼 검색..." className="h-7 text-[9px]" />
|
|
<CommandList>
|
|
<CommandEmpty className="py-2 text-[9px] text-center">
|
|
컬럼을 찾을 수 없습니다.
|
|
</CommandEmpty>
|
|
<CommandGroup>
|
|
{targetTableColumns.map((col) => (
|
|
<CommandItem
|
|
key={col.name}
|
|
value={`${col.name} ${col.label}`}
|
|
onSelect={() => {
|
|
updateLinkedFieldMapping(index, { targetColumn: col.name });
|
|
setTargetColumnOpenMap((prev) => ({ ...prev, [index]: false }));
|
|
}}
|
|
className="text-[9px]"
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-3 w-3",
|
|
mapping.targetColumn === col.name
|
|
? "opacity-100"
|
|
: "opacity-0"
|
|
)}
|
|
/>
|
|
<span className="font-medium">{col.name}</span>
|
|
<span className="ml-1 text-muted-foreground">({col.label})</span>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
) : (
|
|
<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>
|
|
);
|
|
}
|
|
|
|
|
|
|
|
|