Compare commits
9 Commits
16c9c71a23
...
ac334db0b1
| Author | SHA1 | Date |
|---|---|---|
|
|
ac334db0b1 | |
|
|
ca73685bc2 | |
|
|
cf97db7fbf | |
|
|
ef27e0e38f | |
|
|
d7d7dabe84 | |
|
|
d22fd078be | |
|
|
28fe908704 | |
|
|
1b5ae5fe1c | |
|
|
c74e97d66e |
|
|
@ -19,7 +19,9 @@ import {
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
import { ChevronDown, ChevronUp, ChevronRight, Plus, Trash2, Loader2 } from "lucide-react";
|
import { ChevronDown, ChevronUp, ChevronRight, Plus, Trash2, Loader2, 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 { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
@ -42,6 +44,7 @@ import { TableSectionRenderer } from "./TableSectionRenderer";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔗 연쇄 드롭다운 Select 필드 컴포넌트
|
* 🔗 연쇄 드롭다운 Select 필드 컴포넌트
|
||||||
|
* allowCustomInput이 true이면 Combobox 형태로 직접 입력 가능
|
||||||
*/
|
*/
|
||||||
interface CascadingSelectFieldProps {
|
interface CascadingSelectFieldProps {
|
||||||
fieldId: string;
|
fieldId: string;
|
||||||
|
|
@ -51,6 +54,7 @@ interface CascadingSelectFieldProps {
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
allowCustomInput?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CascadingSelectField: React.FC<CascadingSelectFieldProps> = ({
|
const CascadingSelectField: React.FC<CascadingSelectFieldProps> = ({
|
||||||
|
|
@ -61,12 +65,20 @@ const CascadingSelectField: React.FC<CascadingSelectFieldProps> = ({
|
||||||
onChange,
|
onChange,
|
||||||
placeholder,
|
placeholder,
|
||||||
disabled,
|
disabled,
|
||||||
|
allowCustomInput = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [inputValue, setInputValue] = useState(value || "");
|
||||||
const { options, loading } = useCascadingDropdown({
|
const { options, loading } = useCascadingDropdown({
|
||||||
config,
|
config,
|
||||||
parentValue,
|
parentValue,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// value가 외부에서 변경되면 inputValue도 동기화
|
||||||
|
useEffect(() => {
|
||||||
|
setInputValue(value || "");
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
const getPlaceholder = () => {
|
const getPlaceholder = () => {
|
||||||
if (!parentValue) {
|
if (!parentValue) {
|
||||||
return config.emptyParentMessage || "상위 항목을 먼저 선택하세요";
|
return config.emptyParentMessage || "상위 항목을 먼저 선택하세요";
|
||||||
|
|
@ -82,9 +94,82 @@ const CascadingSelectField: React.FC<CascadingSelectFieldProps> = ({
|
||||||
|
|
||||||
const isDisabled = disabled || !parentValue || loading;
|
const isDisabled = disabled || !parentValue || loading;
|
||||||
|
|
||||||
|
// Combobox 형태 (직접 입력 허용)
|
||||||
|
if (allowCustomInput) {
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Input
|
||||||
|
id={fieldId}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
setInputValue(e.target.value);
|
||||||
|
onChange(e.target.value);
|
||||||
|
}}
|
||||||
|
placeholder={getPlaceholder()}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className="w-full pr-8"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="absolute right-0 top-0 h-full px-2 hover:bg-transparent"
|
||||||
|
onClick={() => !isDisabled && setOpen(!open)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronsUpDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="검색..." className="h-9" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>
|
||||||
|
{!parentValue
|
||||||
|
? config.emptyParentMessage || "상위 항목을 먼저 선택하세요"
|
||||||
|
: config.noOptionsMessage || "선택 가능한 항목이 없습니다"}
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{options
|
||||||
|
.filter((option) => option.value && option.value !== "")
|
||||||
|
.map((option) => (
|
||||||
|
<CommandItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.label}
|
||||||
|
onSelect={() => {
|
||||||
|
setInputValue(option.label);
|
||||||
|
onChange(option.value);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
value === option.value ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{option.label}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기본 Select 형태 (목록에서만 선택)
|
||||||
return (
|
return (
|
||||||
<Select value={value || ""} onValueChange={onChange} disabled={isDisabled}>
|
<Select value={value || ""} onValueChange={onChange} disabled={isDisabled}>
|
||||||
<SelectTrigger id={fieldId} className="w-full">
|
<SelectTrigger id={fieldId} className="w-full" size="default">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
|
@ -308,6 +393,14 @@ export function UniversalFormModalComponent({
|
||||||
console.log("[UniversalFormModal] beforeFormSave 이벤트 수신");
|
console.log("[UniversalFormModal] beforeFormSave 이벤트 수신");
|
||||||
console.log("[UniversalFormModal] 설정된 필드 목록:", Array.from(configuredFields));
|
console.log("[UniversalFormModal] 설정된 필드 목록:", Array.from(configuredFields));
|
||||||
|
|
||||||
|
// 🆕 시스템 필드 병합: id는 설정 여부와 관계없이 항상 전달 (UPDATE/INSERT 판단용)
|
||||||
|
// - 신규 등록: formData.id가 없으므로 영향 없음
|
||||||
|
// - 편집 모드: formData.id가 있으면 메인 테이블 UPDATE에 사용
|
||||||
|
if (formData.id !== undefined && formData.id !== null && formData.id !== "") {
|
||||||
|
event.detail.formData.id = formData.id;
|
||||||
|
console.log(`[UniversalFormModal] 시스템 필드 병합: id =`, formData.id);
|
||||||
|
}
|
||||||
|
|
||||||
// UniversalFormModal에 설정된 필드만 병합 (채번 규칙 포함)
|
// UniversalFormModal에 설정된 필드만 병합 (채번 규칙 포함)
|
||||||
// 외부 formData에 이미 값이 있어도 UniversalFormModal 값으로 덮어씀
|
// 외부 formData에 이미 값이 있어도 UniversalFormModal 값으로 덮어씀
|
||||||
// (UniversalFormModal이 해당 필드의 주인이므로)
|
// (UniversalFormModal이 해당 필드의 주인이므로)
|
||||||
|
|
@ -958,6 +1051,26 @@ export function UniversalFormModalComponent({
|
||||||
if (fieldConfig) break;
|
if (fieldConfig) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 연쇄 드롭다운: 부모 필드 변경 시 자식 필드 초기화
|
||||||
|
const childFieldsToReset: string[] = [];
|
||||||
|
for (const section of config.sections) {
|
||||||
|
if (section.type === "table" || section.repeatable) continue;
|
||||||
|
for (const field of section.fields || []) {
|
||||||
|
// field.cascading 방식 체크
|
||||||
|
if (field.cascading?.enabled && field.cascading?.parentField === columnName) {
|
||||||
|
if (field.cascading.clearOnParentChange !== false) {
|
||||||
|
childFieldsToReset.push(field.columnName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// selectOptions.cascading 방식 체크
|
||||||
|
if (field.selectOptions?.type === "cascading" && field.selectOptions?.cascading?.parentField === columnName) {
|
||||||
|
if (field.selectOptions.cascading.clearOnParentChange !== false) {
|
||||||
|
childFieldsToReset.push(field.columnName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setFormData((prev) => {
|
setFormData((prev) => {
|
||||||
const newData = { ...prev, [columnName]: value };
|
const newData = { ...prev, [columnName]: value };
|
||||||
|
|
||||||
|
|
@ -976,6 +1089,12 @@ export function UniversalFormModalComponent({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 연쇄 드롭다운 자식 필드 초기화
|
||||||
|
for (const childField of childFieldsToReset) {
|
||||||
|
newData[childField] = "";
|
||||||
|
console.log(`[연쇄 드롭다운] 부모 ${columnName} 변경 → 자식 ${childField} 초기화`);
|
||||||
|
}
|
||||||
|
|
||||||
// onChange는 렌더링 외부에서 호출해야 함 (setTimeout 사용)
|
// onChange는 렌더링 외부에서 호출해야 함 (setTimeout 사용)
|
||||||
if (onChange) {
|
if (onChange) {
|
||||||
setTimeout(() => onChange(newData), 0);
|
setTimeout(() => onChange(newData), 0);
|
||||||
|
|
@ -1463,7 +1582,7 @@ export function UniversalFormModalComponent({
|
||||||
);
|
);
|
||||||
|
|
||||||
case "select": {
|
case "select": {
|
||||||
// 🆕 연쇄 드롭다운 처리
|
// 🆕 연쇄 드롭다운 처리 (기존 field.cascading 방식)
|
||||||
if (field.cascading?.enabled) {
|
if (field.cascading?.enabled) {
|
||||||
const cascadingConfig = field.cascading;
|
const cascadingConfig = field.cascading;
|
||||||
const parentValue = formData[cascadingConfig.parentField];
|
const parentValue = formData[cascadingConfig.parentField];
|
||||||
|
|
@ -1477,6 +1596,39 @@ export function UniversalFormModalComponent({
|
||||||
onChange={onChangeHandler}
|
onChange={onChangeHandler}
|
||||||
placeholder={field.placeholder || "선택하세요"}
|
placeholder={field.placeholder || "선택하세요"}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
|
allowCustomInput={field.selectOptions?.allowCustomInput}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 연쇄 드롭다운 처리 (selectOptions.type === "cascading" 방식)
|
||||||
|
if (field.selectOptions?.type === "cascading" && field.selectOptions?.cascading?.parentField) {
|
||||||
|
const cascadingOpts = field.selectOptions.cascading;
|
||||||
|
const parentValue = formData[cascadingOpts.parentField];
|
||||||
|
|
||||||
|
// selectOptions 기반 cascading config를 CascadingDropdownConfig 형태로 변환
|
||||||
|
const cascadingConfig: CascadingDropdownConfig = {
|
||||||
|
enabled: true,
|
||||||
|
parentField: cascadingOpts.parentField,
|
||||||
|
sourceTable: cascadingOpts.sourceTable || field.selectOptions.tableName || "",
|
||||||
|
parentKeyColumn: cascadingOpts.parentKeyColumn || "",
|
||||||
|
valueColumn: field.selectOptions.valueColumn || "",
|
||||||
|
labelColumn: field.selectOptions.labelColumn || "",
|
||||||
|
emptyParentMessage: cascadingOpts.emptyParentMessage,
|
||||||
|
noOptionsMessage: cascadingOpts.noOptionsMessage,
|
||||||
|
clearOnParentChange: cascadingOpts.clearOnParentChange !== false,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CascadingSelectField
|
||||||
|
fieldId={fieldKey}
|
||||||
|
config={cascadingConfig}
|
||||||
|
parentValue={parentValue}
|
||||||
|
value={value}
|
||||||
|
onChange={onChangeHandler}
|
||||||
|
placeholder={field.placeholder || "선택하세요"}
|
||||||
|
disabled={isDisabled}
|
||||||
|
allowCustomInput={field.selectOptions?.allowCustomInput}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -2186,6 +2338,7 @@ export function UniversalFormModalComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select 필드 컴포넌트 (옵션 로딩 포함)
|
// Select 필드 컴포넌트 (옵션 로딩 포함)
|
||||||
|
// allowCustomInput이 true이면 Combobox 형태로 직접 입력 가능
|
||||||
interface SelectFieldProps {
|
interface SelectFieldProps {
|
||||||
fieldId: string;
|
fieldId: string;
|
||||||
value: any;
|
value: any;
|
||||||
|
|
@ -2199,6 +2352,10 @@ interface SelectFieldProps {
|
||||||
function SelectField({ fieldId, value, onChange, optionConfig, placeholder, disabled, loadOptions }: SelectFieldProps) {
|
function SelectField({ fieldId, value, onChange, optionConfig, placeholder, disabled, loadOptions }: SelectFieldProps) {
|
||||||
const [options, setOptions] = useState<{ value: string; label: string }[]>([]);
|
const [options, setOptions] = useState<{ value: string; label: string }[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [inputValue, setInputValue] = useState(value || "");
|
||||||
|
|
||||||
|
const allowCustomInput = optionConfig?.allowCustomInput || false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (optionConfig) {
|
if (optionConfig) {
|
||||||
|
|
@ -2209,6 +2366,82 @@ function SelectField({ fieldId, value, onChange, optionConfig, placeholder, disa
|
||||||
}
|
}
|
||||||
}, [fieldId, optionConfig, loadOptions]);
|
}, [fieldId, optionConfig, loadOptions]);
|
||||||
|
|
||||||
|
// value가 외부에서 변경되면 inputValue도 동기화
|
||||||
|
useEffect(() => {
|
||||||
|
// 선택된 값이 있으면 해당 라벨을 표시, 없으면 value 그대로 표시
|
||||||
|
const selectedOption = options.find((opt) => opt.value === value);
|
||||||
|
setInputValue(selectedOption ? selectedOption.label : value || "");
|
||||||
|
}, [value, options]);
|
||||||
|
|
||||||
|
// Combobox 형태 (직접 입력 허용)
|
||||||
|
if (allowCustomInput) {
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Input
|
||||||
|
id={fieldId}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
setInputValue(e.target.value);
|
||||||
|
onChange(e.target.value);
|
||||||
|
}}
|
||||||
|
placeholder={loading ? "로딩 중..." : placeholder}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
className="w-full pr-8"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="absolute right-0 top-0 h-full px-2 hover:bg-transparent"
|
||||||
|
onClick={() => !disabled && !loading && setOpen(!open)}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronsUpDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="검색..." className="h-9" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>선택 가능한 항목이 없습니다</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{options
|
||||||
|
.filter((option) => option.value && option.value !== "")
|
||||||
|
.map((option) => (
|
||||||
|
<CommandItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.label}
|
||||||
|
onSelect={() => {
|
||||||
|
setInputValue(option.label);
|
||||||
|
onChange(option.value);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
value === option.value ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{option.label}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기본 Select 형태 (목록에서만 선택)
|
||||||
return (
|
return (
|
||||||
<Select value={value || ""} onValueChange={onChange} disabled={disabled || loading}>
|
<Select value={value || ""} onValueChange={onChange} disabled={disabled || loading}>
|
||||||
<SelectTrigger size="default">
|
<SelectTrigger size="default">
|
||||||
|
|
|
||||||
|
|
@ -870,6 +870,14 @@ export function UniversalFormModalConfigPanel({
|
||||||
onLoadTableColumns={loadTableColumns}
|
onLoadTableColumns={loadTableColumns}
|
||||||
targetTableName={config.saveConfig?.tableName}
|
targetTableName={config.saveConfig?.tableName}
|
||||||
targetTableColumns={config.saveConfig?.tableName ? tableColumns[config.saveConfig.tableName] || [] : []}
|
targetTableColumns={config.saveConfig?.tableName ? tableColumns[config.saveConfig.tableName] || [] : []}
|
||||||
|
allFieldsWithSections={config.sections
|
||||||
|
.filter(s => s.type !== "table" && !s.repeatable)
|
||||||
|
.map(s => ({
|
||||||
|
sectionId: s.id,
|
||||||
|
sectionTitle: s.title,
|
||||||
|
fields: s.fields || []
|
||||||
|
}))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import {
|
||||||
LINKED_FIELD_DISPLAY_FORMAT_OPTIONS,
|
LINKED_FIELD_DISPLAY_FORMAT_OPTIONS,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { getCascadingRelations, getCascadingRelationByCode, CascadingRelation } from "@/lib/api/cascadingRelation";
|
||||||
|
|
||||||
// 카테고리 컬럼 타입 (table_column_category_values 용)
|
// 카테고리 컬럼 타입 (table_column_category_values 용)
|
||||||
interface CategoryColumnOption {
|
interface CategoryColumnOption {
|
||||||
|
|
@ -56,6 +57,13 @@ export interface AvailableParentField {
|
||||||
sourceTable?: string; // 출처 테이블명
|
sourceTable?: string; // 출처 테이블명
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 섹션별 필드 그룹
|
||||||
|
interface SectionFieldGroup {
|
||||||
|
sectionId: string;
|
||||||
|
sectionTitle: string;
|
||||||
|
fields: FormFieldConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
interface FieldDetailSettingsModalProps {
|
interface FieldDetailSettingsModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
|
|
@ -68,6 +76,8 @@ interface FieldDetailSettingsModalProps {
|
||||||
// 저장 테이블 정보 (타겟 컬럼 선택용)
|
// 저장 테이블 정보 (타겟 컬럼 선택용)
|
||||||
targetTableName?: string;
|
targetTableName?: string;
|
||||||
targetTableColumns?: { name: string; type: string; label: string }[];
|
targetTableColumns?: { name: string; type: string; label: string }[];
|
||||||
|
// 연쇄 드롭다운 부모 필드 선택용 - 모든 섹션의 필드 목록 (섹션별 그룹핑)
|
||||||
|
allFieldsWithSections?: SectionFieldGroup[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FieldDetailSettingsModal({
|
export function FieldDetailSettingsModal({
|
||||||
|
|
@ -82,6 +92,7 @@ export function FieldDetailSettingsModal({
|
||||||
// targetTableName은 타겟 컬럼 선택 시 참고용으로 전달됨 (현재 targetTableColumns만 사용)
|
// targetTableName은 타겟 컬럼 선택 시 참고용으로 전달됨 (현재 targetTableColumns만 사용)
|
||||||
targetTableName: _targetTableName,
|
targetTableName: _targetTableName,
|
||||||
targetTableColumns = [],
|
targetTableColumns = [],
|
||||||
|
allFieldsWithSections = [],
|
||||||
}: FieldDetailSettingsModalProps) {
|
}: FieldDetailSettingsModalProps) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
void _targetTableName; // 향후 사용 가능성을 위해 유지
|
void _targetTableName; // 향후 사용 가능성을 위해 유지
|
||||||
|
|
@ -92,6 +103,12 @@ export function FieldDetailSettingsModal({
|
||||||
const [categoryColumns, setCategoryColumns] = useState<CategoryColumnOption[]>([]);
|
const [categoryColumns, setCategoryColumns] = useState<CategoryColumnOption[]>([]);
|
||||||
const [loadingCategoryColumns, setLoadingCategoryColumns] = useState(false);
|
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 열림 상태
|
// Combobox 열림 상태
|
||||||
const [sourceTableOpen, setSourceTableOpen] = useState(false);
|
const [sourceTableOpen, setSourceTableOpen] = useState(false);
|
||||||
const [targetColumnOpenMap, setTargetColumnOpenMap] = useState<Record<number, boolean>>({});
|
const [targetColumnOpenMap, setTargetColumnOpenMap] = useState<Record<number, boolean>>({});
|
||||||
|
|
@ -116,6 +133,16 @@ export function FieldDetailSettingsModal({
|
||||||
}
|
}
|
||||||
}, [open, field.linkedFieldGroup?.sourceTable, tableColumns, onLoadTableColumns]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const loadAllCategoryColumns = async () => {
|
const loadAllCategoryColumns = async () => {
|
||||||
|
|
@ -159,6 +186,66 @@ export function FieldDetailSettingsModal({
|
||||||
loadAllCategoryColumns();
|
loadAllCategoryColumns();
|
||||||
}, [open]);
|
}, [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>) => {
|
const updateField = (updates: Partial<FormFieldConfig>) => {
|
||||||
setLocalField((prev) => ({ ...prev, ...updates }));
|
setLocalField((prev) => ({ ...prev, ...updates }));
|
||||||
|
|
@ -352,14 +439,28 @@ export function FieldDetailSettingsModal({
|
||||||
<Label className="text-[10px]">옵션 타입</Label>
|
<Label className="text-[10px]">옵션 타입</Label>
|
||||||
<Select
|
<Select
|
||||||
value={localField.selectOptions?.type || "static"}
|
value={localField.selectOptions?.type || "static"}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) => {
|
||||||
|
// 타입 변경 시 관련 설정 초기화
|
||||||
|
if (value === "cascading") {
|
||||||
|
updateField({
|
||||||
|
selectOptions: {
|
||||||
|
type: "cascading",
|
||||||
|
cascading: {
|
||||||
|
parentField: "",
|
||||||
|
clearOnParentChange: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
updateField({
|
updateField({
|
||||||
selectOptions: {
|
selectOptions: {
|
||||||
...localField.selectOptions,
|
...localField.selectOptions,
|
||||||
type: value as "static" | "code",
|
type: value as "static" | "table" | "code",
|
||||||
|
cascading: undefined,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-7 text-xs mt-1">
|
<SelectTrigger className="h-7 text-xs mt-1">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
|
|
@ -372,8 +473,38 @@ export function FieldDetailSettingsModal({
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<HelpText>
|
||||||
|
{localField.selectOptions?.type === "cascading"
|
||||||
|
? "연쇄 드롭다운: 부모 필드 선택에 따라 옵션이 동적으로 변경됩니다"
|
||||||
|
: "테이블 참조: DB 테이블에서 옵션 목록을 가져옵니다."}
|
||||||
|
</HelpText>
|
||||||
</div>
|
</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" && (
|
{localField.selectOptions?.type === "table" && (
|
||||||
<div className="space-y-3 pt-2 border-t">
|
<div className="space-y-3 pt-2 border-t">
|
||||||
<HelpText>테이블 참조: DB 테이블에서 옵션 목록을 가져옵니다.</HelpText>
|
<HelpText>테이블 참조: DB 테이블에서 옵션 목록을 가져옵니다.</HelpText>
|
||||||
|
|
@ -584,6 +715,472 @@ export function FieldDetailSettingsModal({
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
)}
|
)}
|
||||||
|
|
@ -919,7 +1516,7 @@ export function FieldDetailSettingsModal({
|
||||||
preview = `${subLabel} - ${mainLabel}`;
|
preview = `${subLabel} - ${mainLabel}`;
|
||||||
} else if (format === "name_code" && subCol) {
|
} else if (format === "name_code" && subCol) {
|
||||||
preview = `${mainLabel} (${subLabel})`;
|
preview = `${mainLabel} (${subLabel})`;
|
||||||
} else if (format !== "name_only" && !subCol) {
|
} else if (!subCol) {
|
||||||
preview = `${mainLabel} (서브 컬럼을 선택하세요)`;
|
preview = `${mainLabel} (서브 컬럼을 선택하세요)`;
|
||||||
} else {
|
} else {
|
||||||
preview = mainLabel;
|
preview = mainLabel;
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
// Select 옵션 설정
|
// Select 옵션 설정
|
||||||
export interface SelectOptionConfig {
|
export interface SelectOptionConfig {
|
||||||
type?: "static" | "table" | "code"; // 옵션 타입 (기본: static)
|
type?: "static" | "table" | "code" | "cascading"; // 옵션 타입 (기본: static)
|
||||||
// 정적 옵션
|
// 정적 옵션
|
||||||
staticOptions?: { value: string; label: string }[];
|
staticOptions?: { value: string; label: string }[];
|
||||||
// 테이블 기반 옵션
|
// 테이블 기반 옵션
|
||||||
|
|
@ -19,6 +19,24 @@ export interface SelectOptionConfig {
|
||||||
// 카테고리 컬럼 기반 옵션 (table_column_category_values 테이블)
|
// 카테고리 컬럼 기반 옵션 (table_column_category_values 테이블)
|
||||||
// 형식: "tableName.columnName" (예: "sales_order_mng.incoterms")
|
// 형식: "tableName.columnName" (예: "sales_order_mng.incoterms")
|
||||||
categoryKey?: string;
|
categoryKey?: string;
|
||||||
|
|
||||||
|
// 연쇄 드롭다운 설정 (type이 "cascading"일 때 사용)
|
||||||
|
cascading?: {
|
||||||
|
parentField?: string; // 부모 필드명 (같은 폼 내)
|
||||||
|
relationCode?: string; // 관계 코드 (cascading_relation 테이블)
|
||||||
|
// 직접 설정 또는 관계 코드에서 가져온 값 수정 시 사용
|
||||||
|
sourceTable?: string; // 옵션을 조회할 테이블
|
||||||
|
parentKeyColumn?: string; // 부모 값과 매칭할 컬럼
|
||||||
|
// valueColumn, labelColumn은 상위 속성 사용
|
||||||
|
emptyParentMessage?: string; // 부모 미선택 시 메시지
|
||||||
|
noOptionsMessage?: string; // 옵션 없음 메시지
|
||||||
|
clearOnParentChange?: boolean; // 부모 변경 시 값 초기화 (기본: true)
|
||||||
|
};
|
||||||
|
|
||||||
|
// 직접 입력 허용 (모든 Select 타입에 공통 적용)
|
||||||
|
// true: Combobox 형태로 목록 선택 + 직접 입력 가능
|
||||||
|
// false/undefined: 기본 Select 형태로 목록에서만 선택 가능
|
||||||
|
allowCustomInput?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 채번규칙 설정
|
// 채번규칙 설정
|
||||||
|
|
@ -873,6 +891,7 @@ export const SELECT_OPTION_TYPE_OPTIONS = [
|
||||||
{ value: "static", label: "직접 입력" },
|
{ value: "static", label: "직접 입력" },
|
||||||
{ value: "table", label: "테이블 참조" },
|
{ value: "table", label: "테이블 참조" },
|
||||||
{ value: "code", label: "공통코드" },
|
{ value: "code", label: "공통코드" },
|
||||||
|
{ value: "cascading", label: "연쇄 드롭다운" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// 연동 필드 표시 형식 옵션
|
// 연동 필드 표시 형식 옵션
|
||||||
|
|
|
||||||
|
|
@ -1971,19 +1971,43 @@ export class ButtonActionExecutor {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("📦 [handleUniversalFormModalTableSectionSave] 메인 테이블 저장 데이터:", mainRowToSave);
|
// 🆕 메인 테이블 UPDATE/INSERT 판단
|
||||||
|
// - formData.id가 있으면 편집 모드 → UPDATE
|
||||||
|
// - formData.id가 없으면 신규 등록 → INSERT
|
||||||
|
const existingMainId = formData.id;
|
||||||
|
const isMainUpdate = existingMainId !== undefined && existingMainId !== null && existingMainId !== "";
|
||||||
|
|
||||||
const mainSaveResult = await DynamicFormApi.saveFormData({
|
console.log("📦 [handleUniversalFormModalTableSectionSave] 메인 테이블 저장 데이터:", mainRowToSave);
|
||||||
|
console.log("📦 [handleUniversalFormModalTableSectionSave] UPDATE/INSERT 판단:", {
|
||||||
|
existingMainId,
|
||||||
|
isMainUpdate,
|
||||||
|
});
|
||||||
|
|
||||||
|
let mainSaveResult: { success: boolean; data?: any; message?: string };
|
||||||
|
|
||||||
|
if (isMainUpdate) {
|
||||||
|
// 🔄 편집 모드: UPDATE 실행
|
||||||
|
console.log("🔄 [handleUniversalFormModalTableSectionSave] 메인 테이블 UPDATE 실행, ID:", existingMainId);
|
||||||
|
mainSaveResult = await DynamicFormApi.updateFormData(existingMainId, {
|
||||||
|
tableName: tableName!,
|
||||||
|
data: mainRowToSave,
|
||||||
|
});
|
||||||
|
mainRecordId = existingMainId;
|
||||||
|
} else {
|
||||||
|
// ➕ 신규 등록: INSERT 실행
|
||||||
|
console.log("➕ [handleUniversalFormModalTableSectionSave] 메인 테이블 INSERT 실행");
|
||||||
|
mainSaveResult = await DynamicFormApi.saveFormData({
|
||||||
screenId: screenId!,
|
screenId: screenId!,
|
||||||
tableName: tableName!,
|
tableName: tableName!,
|
||||||
data: mainRowToSave,
|
data: mainRowToSave,
|
||||||
});
|
});
|
||||||
|
mainRecordId = mainSaveResult.data?.id || null;
|
||||||
|
}
|
||||||
|
|
||||||
if (!mainSaveResult.success) {
|
if (!mainSaveResult.success) {
|
||||||
throw new Error(mainSaveResult.message || "메인 데이터 저장 실패");
|
throw new Error(mainSaveResult.message || "메인 데이터 저장 실패");
|
||||||
}
|
}
|
||||||
|
|
||||||
mainRecordId = mainSaveResult.data?.id || null;
|
|
||||||
console.log("✅ [handleUniversalFormModalTableSectionSave] 메인 테이블 저장 완료, ID:", mainRecordId);
|
console.log("✅ [handleUniversalFormModalTableSectionSave] 메인 테이블 저장 완료, ID:", mainRecordId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-hook-form": "^7.62.0",
|
"react-hook-form": "^7.62.0",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
|
"react-is": "^18.3.1",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
"react-resizable-panels": "^3.0.6",
|
"react-resizable-panels": "^3.0.6",
|
||||||
"react-webcam": "^7.2.0",
|
"react-webcam": "^7.2.0",
|
||||||
|
|
@ -13213,11 +13214,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "19.2.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||||
"integrity": "sha512-L7BnWgRbMwzMAubQcS7sXdPdNLmKlucPlopgAzx7FtYbksWZgEWiuYM5x9T6UqS2Ne0rsgQTq5kY2SGqpzUkYA==",
|
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/react-leaflet": {
|
"node_modules/react-leaflet": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
|
|
@ -13454,9 +13454,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/recharts": {
|
"node_modules/recharts": {
|
||||||
"version": "3.3.0",
|
"version": "3.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.2.1.tgz",
|
||||||
"integrity": "sha512-Vi0qmTB0iz1+/Cz9o5B7irVyUjX2ynvEgImbgMt/3sKRREcUM07QiYjS1QpAVrkmVlXqy5gykq4nGWMz9AS4Rg==",
|
"integrity": "sha512-0JKwHRiFZdmLq/6nmilxEZl3pqb4T+aKkOkOi/ZISRZwfBhVMgInxzlYU9D4KnCH3KINScLy68m/OvMXoYGZUw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@reduxjs/toolkit": "1.x.x || 2.x.x",
|
"@reduxjs/toolkit": "1.x.x || 2.x.x",
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,7 @@
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-hook-form": "^7.62.0",
|
"react-hook-form": "^7.62.0",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
|
"react-is": "^18.3.1",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
"react-resizable-panels": "^3.0.6",
|
"react-resizable-panels": "^3.0.6",
|
||||||
"react-webcam": "^7.2.0",
|
"react-webcam": "^7.2.0",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue