refactor(UniversalFormModal): 다중 컬럼 저장 기능을 필드 레벨로 이동

- 섹션 레벨 linkedFieldGroups 제거, 필드 레벨 linkedFieldGroup으로 변경

- FormFieldConfig에 linkedFieldGroup 속성 추가 (enabled, sourceTable, displayColumn, displayFormat, mappings)

- select 필드 렌더링에서 linkedFieldGroup 활성화 시 다중 컬럼 저장 처리

- API 응답 파싱 개선 (responseData.data 구조 지원)

- 저장 실패 시 상세 에러 메시지 표시

- ConfigPanel에 다중 컬럼 저장 설정 UI 및 HelpText 추가
This commit is contained in:
SeongHyun Kim 2025-12-05 17:25:12 +09:00
parent 0c57609ee9
commit de1fe9865a
3 changed files with 384 additions and 530 deletions

View File

@ -33,7 +33,6 @@ import {
FormDataState,
RepeatSectionItem,
SelectOptionConfig,
LinkedFieldGroup,
} from "./types";
import { defaultConfig, generateUniqueId } from "./config";
@ -121,6 +120,33 @@ export function UniversalFormModalComponent({
initializeForm();
}, [config, initialData]);
// 필드 레벨 linkedFieldGroup 데이터 로드
useEffect(() => {
const loadData = async () => {
const tablesToLoad = new Set<string>();
// 모든 섹션의 필드에서 linkedFieldGroup.sourceTable 수집
config.sections.forEach((section) => {
section.fields.forEach((field) => {
if (field.linkedFieldGroup?.enabled && field.linkedFieldGroup?.sourceTable) {
tablesToLoad.add(field.linkedFieldGroup.sourceTable);
}
});
});
// 각 테이블 데이터 로드
for (const tableName of tablesToLoad) {
if (!linkedFieldDataCache[tableName]) {
console.log(`[UniversalFormModal] linkedFieldGroup 데이터 로드: ${tableName}`);
await loadLinkedFieldData(tableName);
}
}
};
loadData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config.sections]);
// 폼 초기화
const initializeForm = useCallback(async () => {
const newFormData: FormDataState = {};
@ -364,18 +390,22 @@ export function UniversalFormModalComponent({
const response = await apiClient.post(`/table-management/tables/${sourceTable}/data`, {
page: 1,
size: 1000,
autoFilter: true, // 현재 회사 기준 자동 필터링
autoFilter: { enabled: true, filterColumn: "company_code" }, // 현재 회사 기준 자동 필터링
});
console.log(`[연동필드] ${sourceTable} API 응답:`, response.data);
if (response.data?.success) {
// data가 배열인지 확인
// data 구조 확인: { data: { data: [...], total, page, ... } } 또는 { data: [...] }
const responseData = response.data?.data;
if (Array.isArray(responseData)) {
// 직접 배열인 경우
data = responseData;
} else if (responseData?.data && Array.isArray(responseData.data)) {
// { data: [...], total: ... } 형태 (tableManagementService 응답)
data = responseData.data;
} else if (responseData?.rows && Array.isArray(responseData.rows)) {
// { rows: [...], total: ... } 형태일 수 있음
// { rows: [...], total: ... } 형태 (다른 API 응답)
data = responseData.rows;
}
console.log(`[연동필드] ${sourceTable} 파싱된 데이터 ${data.length}개:`, data.slice(0, 3));
@ -394,79 +424,6 @@ export function UniversalFormModalComponent({
[linkedFieldDataCache],
);
// 연동 필드 그룹 선택 시 매핑된 필드에 값 설정
const handleLinkedFieldSelect = useCallback(
(
group: LinkedFieldGroup,
selectedValue: string,
sectionId: string,
repeatItemId?: string
) => {
// 캐시에서 데이터 찾기
const sourceData = linkedFieldDataCache[group.sourceTable] || [];
const selectedRow = sourceData.find(
(row) => String(row[group.valueColumn]) === selectedValue
);
if (!selectedRow) {
console.warn("선택된 항목을 찾을 수 없습니다:", selectedValue);
return;
}
// 매핑된 필드에 값 설정
if (repeatItemId) {
// 반복 섹션 내 아이템 업데이트
setRepeatSections((prev) => {
const sectionItems = prev[sectionId] || [];
const updatedItems = sectionItems.map((item) => {
if (item._id === repeatItemId) {
const updatedItem = { ...item };
for (const mapping of group.mappings) {
updatedItem[mapping.targetColumn] = selectedRow[mapping.sourceColumn];
}
return updatedItem;
}
return item;
});
return { ...prev, [sectionId]: updatedItems };
});
} else {
// 일반 섹션 필드 업데이트
setFormData((prev) => {
const newData = { ...prev };
for (const mapping of group.mappings) {
newData[mapping.targetColumn] = selectedRow[mapping.sourceColumn];
}
if (onChange) {
setTimeout(() => onChange(newData), 0);
}
return newData;
});
}
},
[linkedFieldDataCache, onChange],
);
// 연동 필드 그룹 표시 텍스트 생성
const getLinkedFieldDisplayText = useCallback(
(group: LinkedFieldGroup, row: Record<string, any>): string => {
const code = row[group.valueColumn] || "";
const name = row[group.displayColumn] || "";
switch (group.displayFormat) {
case "name_only":
return name;
case "code_name":
return `${code} - ${name}`;
case "name_code":
return `${name} (${code})`;
default:
return name;
}
},
[],
);
// 필수 필드 검증
const validateRequiredFields = useCallback((): { valid: boolean; missingFields: string[] } => {
const missingFields: string[] = [];
@ -532,7 +489,13 @@ export function UniversalFormModalComponent({
}
} catch (error: any) {
console.error("저장 실패:", error);
toast.error(error.message || "저장에 실패했습니다.");
// axios 에러의 경우 서버 응답 메시지 추출
const errorMessage =
error.response?.data?.message ||
error.response?.data?.error?.details ||
error.message ||
"저장에 실패했습니다.";
toast.error(errorMessage);
} finally {
setSaving(false);
}
@ -749,7 +712,88 @@ export function UniversalFormModalComponent({
</div>
);
case "select":
case "select": {
// 다중 컬럼 저장이 활성화된 경우
const lfgMappings = field.linkedFieldGroup?.mappings;
if (field.linkedFieldGroup?.enabled && field.linkedFieldGroup?.sourceTable && lfgMappings && lfgMappings.length > 0) {
const lfg = field.linkedFieldGroup;
const sourceTableName = lfg.sourceTable as string;
const cachedData = linkedFieldDataCache[sourceTableName];
const sourceData = Array.isArray(cachedData) ? cachedData : [];
// 첫 번째 매핑의 sourceColumn을 드롭다운 값으로 사용
const valueColumn = lfgMappings[0].sourceColumn || "";
// 데이터 로드 (아직 없으면)
if (!cachedData && sourceTableName) {
loadLinkedFieldData(sourceTableName);
}
// 표시 텍스트 생성 함수
const getDisplayText = (row: Record<string, unknown>): string => {
const displayVal = row[lfg.displayColumn || ""] || "";
const valueVal = row[valueColumn] || "";
switch (lfg.displayFormat) {
case "code_name":
return `${valueVal} - ${displayVal}`;
case "name_code":
return `${displayVal} (${valueVal})`;
case "name_only":
default:
return String(displayVal);
}
};
return (
<Select
value={value || ""}
onValueChange={(selectedValue) => {
// 선택된 값에 해당하는 행 찾기
const selectedRow = sourceData.find((row) => String(row[valueColumn]) === selectedValue);
// 기본 필드 값 변경 (첫 번째 매핑의 값)
onChangeHandler(selectedValue);
// 매핑된 컬럼들도 함께 저장
if (selectedRow && lfg.mappings) {
lfg.mappings.forEach((mapping) => {
if (mapping.sourceColumn && mapping.targetColumn) {
const mappedValue = selectedRow[mapping.sourceColumn];
// formData에 직접 저장
setFormData((prev) => ({
...prev,
[mapping.targetColumn]: mappedValue,
}));
}
});
}
}}
disabled={isDisabled}
>
<SelectTrigger id={fieldKey} className="w-full">
<SelectValue placeholder={field.placeholder || "선택하세요"} />
</SelectTrigger>
<SelectContent>
{sourceData.length > 0 ? (
sourceData.map((row, index) => (
<SelectItem
key={`${row[valueColumn] || index}_${index}`}
value={String(row[valueColumn] || "")}
>
{getDisplayText(row)}
</SelectItem>
))
) : (
<SelectItem value="_empty" disabled>
{cachedData === undefined ? "데이터를 불러오는 중..." : "데이터가 없습니다"}
</SelectItem>
)}
</SelectContent>
</Select>
);
}
// 일반 select 필드
return (
<SelectField
fieldId={fieldKey}
@ -761,6 +805,7 @@ export function UniversalFormModalComponent({
loadOptions={loadSelectOptions}
/>
);
}
case "date":
return (
@ -854,64 +899,6 @@ export function UniversalFormModalComponent({
})();
};
// 연동 필드 그룹 드롭다운 렌더링
const renderLinkedFieldGroup = (
group: LinkedFieldGroup,
sectionId: string,
repeatItemId?: string,
currentValue?: string,
sectionColumns: number = 2,
) => {
const fieldKey = `linked_${group.id}_${repeatItemId || "main"}`;
const cachedData = linkedFieldDataCache[group.sourceTable];
// 배열인지 확인하고, 아니면 빈 배열 사용
const sourceData = Array.isArray(cachedData) ? cachedData : [];
const defaultSpan = Math.floor(12 / sectionColumns);
const actualGridSpan = sectionColumns === 1 ? 12 : group.gridSpan || defaultSpan;
// 데이터 로드 (아직 없으면, 그리고 캐시에 없을 때만)
if (!cachedData && group.sourceTable) {
loadLinkedFieldData(group.sourceTable);
}
return (
<div
key={fieldKey}
className="space-y-2"
style={{ gridColumn: `span ${actualGridSpan}` }}
>
<Label htmlFor={fieldKey} className="text-sm font-medium">
{group.label}
{group.required && <span className="text-destructive ml-1">*</span>}
</Label>
<Select
value={currentValue || ""}
onValueChange={(value) => handleLinkedFieldSelect(group, value, sectionId, repeatItemId)}
>
<SelectTrigger id={fieldKey} className="w-full">
<SelectValue placeholder={group.placeholder || "선택하세요"} />
</SelectTrigger>
<SelectContent>
{sourceData.length > 0 ? (
sourceData.map((row, index) => (
<SelectItem
key={`${row[group.valueColumn] || index}_${index}`}
value={String(row[group.valueColumn] || "")}
>
{getLinkedFieldDisplayText(group, row)}
</SelectItem>
))
) : (
<SelectItem value="_empty" disabled>
{cachedData === undefined ? "데이터를 불러오는 중..." : "데이터가 없습니다"}
</SelectItem>
)}
</SelectContent>
</Select>
</div>
);
};
// 섹션의 열 수에 따른 기본 gridSpan 계산
const getDefaultGridSpan = (sectionColumns: number = 2): number => {
// 12칸 그리드 기준: 1열=12, 2열=6, 3열=4, 4열=3
@ -999,18 +986,6 @@ export function UniversalFormModalComponent({
sectionColumns,
),
)}
{/* 연동 필드 그룹 렌더링 */}
{(section.linkedFieldGroups || []).map((group) => {
const firstMapping = group.mappings?.[0];
const currentValue = firstMapping ? formData[firstMapping.targetColumn] : undefined;
return renderLinkedFieldGroup(
group,
section.id,
undefined,
currentValue ? String(currentValue) : undefined,
sectionColumns,
);
})}
</div>
</CardContent>
</CollapsibleContent>
@ -1033,19 +1008,6 @@ export function UniversalFormModalComponent({
sectionColumns,
),
)}
{/* 연동 필드 그룹 렌더링 */}
{(section.linkedFieldGroups || []).map((group) => {
// 매핑된 첫 번째 타겟 컬럼의 현재 값을 찾아서 선택 상태 표시
const firstMapping = group.mappings?.[0];
const currentValue = firstMapping ? formData[firstMapping.targetColumn] : undefined;
return renderLinkedFieldGroup(
group,
section.id,
undefined,
currentValue ? String(currentValue) : undefined,
sectionColumns,
);
})}
</div>
</CardContent>
</>
@ -1105,19 +1067,6 @@ export function UniversalFormModalComponent({
sectionColumns,
),
)}
{/* 연동 필드 그룹 렌더링 (반복 섹션 내) */}
{(section.linkedFieldGroups || []).map((group) => {
// 반복 섹션 아이템 내의 매핑된 첫 번째 타겟 컬럼 값
const firstMapping = group.mappings?.[0];
const currentValue = firstMapping ? item[firstMapping.targetColumn] : undefined;
return renderLinkedFieldGroup(
group,
section.id,
item._id,
currentValue ? String(currentValue) : undefined,
sectionColumns,
);
})}
</div>
</div>
))}

View File

@ -37,7 +37,6 @@ import {
UniversalFormModalConfigPanelProps,
FormSectionConfig,
FormFieldConfig,
LinkedFieldGroup,
LinkedFieldMapping,
FIELD_TYPE_OPTIONS,
MODAL_SIZE_OPTIONS,
@ -49,11 +48,8 @@ import {
defaultSectionConfig,
defaultNumberingRuleConfig,
defaultSelectOptionsConfig,
defaultLinkedFieldGroupConfig,
defaultLinkedFieldMappingConfig,
generateSectionId,
generateFieldId,
generateLinkedFieldGroupId,
} from "./config";
// 도움말 텍스트 컴포넌트
@ -93,13 +89,14 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config.saveConfig.tableName]);
// 연동 필드 그룹의 소스 테이블 컬럼 로드
// 다중 컬럼 저장의 소스 테이블 컬럼 로드
useEffect(() => {
const allSourceTables = new Set<string>();
config.sections.forEach((section) => {
(section.linkedFieldGroups || []).forEach((group) => {
if (group.sourceTable) {
allSourceTables.add(group.sourceTable);
// 필드 레벨의 linkedFieldGroup 확인
section.fields.forEach((field) => {
if (field.linkedFieldGroup?.sourceTable) {
allSourceTables.add(field.linkedFieldGroup.sourceTable);
}
});
});
@ -578,47 +575,6 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
</Select>
<HelpText> </HelpText>
</div>
<Separator />
<div>
<Label className="text-[10px]"> </Label>
<Input
value={config.saveConfig.multiRowSave?.typeColumn || "employment_type"}
onChange={(e) =>
updateSaveConfig({
multiRowSave: { ...config.saveConfig.multiRowSave, typeColumn: e.target.value },
})
}
placeholder="employment_type"
className="h-6 text-[10px] mt-1"
/>
<HelpText>/ </HelpText>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Input
value={config.saveConfig.multiRowSave?.mainTypeValue || "main"}
onChange={(e) =>
updateSaveConfig({
multiRowSave: { ...config.saveConfig.multiRowSave, mainTypeValue: e.target.value },
})
}
className="h-6 text-[10px] mt-1"
/>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Input
value={config.saveConfig.multiRowSave?.subTypeValue || "concurrent"}
onChange={(e) =>
updateSaveConfig({
multiRowSave: { ...config.saveConfig.multiRowSave, subTypeValue: e.target.value },
})
}
className="h-6 text-[10px] mt-1"
/>
</div>
</div>
)}
</div>
@ -683,7 +639,7 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
<Card
key={section.id}
className={cn(
"cursor-pointer transition-colors",
"cursor-pointer transition-colors !p-0",
selectedSectionId === section.id && "ring-2 ring-primary",
)}
onClick={() => {
@ -866,305 +822,6 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
)}
</div>
{/* 연동 필드 그룹 설정 */}
<div className="border rounded-md p-2 space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-[10px] font-medium"> </span>
<Button
onClick={() => {
const newGroup: LinkedFieldGroup = {
...defaultLinkedFieldGroupConfig,
id: generateLinkedFieldGroupId(),
};
updateSection(selectedSection.id, {
linkedFieldGroups: [...(selectedSection.linkedFieldGroups || []), newGroup],
});
}}
variant="outline"
size="icon"
className="h-5 w-5"
>
<Plus className="h-3 w-3" />
</Button>
</div>
<p className="text-[9px] text-muted-foreground">
/
</p>
{(selectedSection.linkedFieldGroups || []).length > 0 && (
<div className="space-y-1.5 pt-1.5 border-t">
{(selectedSection.linkedFieldGroups || []).map((group, groupIndex) => (
<div key={group.id} className="border rounded p-1.5 space-y-1 bg-muted/30">
<div className="flex items-center justify-between">
<span className="text-[9px] font-medium text-muted-foreground">
#{groupIndex + 1}
</span>
<Button
variant="ghost"
size="icon"
className="h-4 w-4 text-destructive"
onClick={() => {
const updatedGroups = (selectedSection.linkedFieldGroups || []).filter(
(g) => g.id !== group.id
);
updateSection(selectedSection.id, { linkedFieldGroups: updatedGroups });
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* 라벨 */}
<div>
<Label className="text-[9px]"></Label>
<Input
value={group.label}
onChange={(e) => {
const updatedGroups = (selectedSection.linkedFieldGroups || []).map((g) =>
g.id === group.id ? { ...g, label: e.target.value } : g
);
updateSection(selectedSection.id, { linkedFieldGroups: updatedGroups });
}}
placeholder="예: 겸직부서"
className="h-5 text-[9px] mt-0.5"
/>
</div>
{/* 소스 테이블 */}
<div>
<Label className="text-[9px]"> </Label>
<Select
value={group.sourceTable}
onValueChange={(value) => {
const updatedGroups = (selectedSection.linkedFieldGroups || []).map((g) =>
g.id === group.id ? { ...g, sourceTable: value } : g
);
updateSection(selectedSection.id, { linkedFieldGroups: updatedGroups });
if (value && !tableColumns[value]) {
loadTableColumns(value);
}
}}
>
<SelectTrigger className="h-5 text-[9px] mt-0.5">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{tables.map((table) => (
<SelectItem key={table.name} value={table.name} className="text-xs">
{table.label || table.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 표시 형식 */}
<div>
<Label className="text-[9px]"> </Label>
<Select
value={group.displayFormat}
onValueChange={(value: "name_only" | "code_name" | "name_code") => {
const updatedGroups = (selectedSection.linkedFieldGroups || []).map((g) =>
g.id === group.id ? { ...g, displayFormat: value } : g
);
updateSection(selectedSection.id, { linkedFieldGroups: updatedGroups });
}}
>
<SelectTrigger className="h-5 text-[9px] mt-0.5">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LINKED_FIELD_DISPLAY_FORMAT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 표시 컬럼 / 값 컬럼 */}
<div className="grid grid-cols-2 gap-1">
<div className="min-w-0">
<Label className="text-[9px]"></Label>
<Select
value={group.displayColumn}
onValueChange={(value) => {
const updatedGroups = (selectedSection.linkedFieldGroups || []).map((g) =>
g.id === group.id ? { ...g, displayColumn: value } : g
);
updateSection(selectedSection.id, { linkedFieldGroups: updatedGroups });
}}
>
<SelectTrigger className="h-5 text-[9px] mt-0.5">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{(tableColumns[group.sourceTable] || []).map((col) => (
<SelectItem key={col.name} value={col.name} className="text-xs">
{col.label || col.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="min-w-0">
<Label className="text-[9px]"></Label>
<Select
value={group.valueColumn}
onValueChange={(value) => {
const updatedGroups = (selectedSection.linkedFieldGroups || []).map((g) =>
g.id === group.id ? { ...g, valueColumn: value } : g
);
updateSection(selectedSection.id, { linkedFieldGroups: updatedGroups });
}}
>
<SelectTrigger className="h-5 text-[9px] mt-0.5">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{(tableColumns[group.sourceTable] || []).map((col) => (
<SelectItem key={col.name} value={col.name} className="text-xs">
{col.label || col.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 필드 매핑 */}
<div className="space-y-0.5">
<div className="flex items-center justify-between">
<Label className="text-[9px]"> </Label>
<Button
onClick={() => {
const newMapping: LinkedFieldMapping = { ...defaultLinkedFieldMappingConfig };
const updatedGroups = (selectedSection.linkedFieldGroups || []).map((g) =>
g.id === group.id
? { ...g, mappings: [...(g.mappings || []), newMapping] }
: g
);
updateSection(selectedSection.id, { linkedFieldGroups: updatedGroups });
}}
variant="outline"
size="icon"
className="h-4 w-4"
>
<Plus className="h-2.5 w-2.5" />
</Button>
</div>
{(group.mappings || []).map((mapping, mappingIndex) => (
<div key={mappingIndex} className="flex items-center gap-0.5 bg-background p-0.5 rounded">
<Select
value={mapping.sourceColumn}
onValueChange={(value) => {
const updatedMappings = (group.mappings || []).map((m, i) =>
i === mappingIndex ? { ...m, sourceColumn: value } : m
);
const updatedGroups = (selectedSection.linkedFieldGroups || []).map((g) =>
g.id === group.id ? { ...g, mappings: updatedMappings } : g
);
updateSection(selectedSection.id, { linkedFieldGroups: updatedGroups });
}}
>
<SelectTrigger className="h-5 text-[8px] flex-1 min-w-0">
<SelectValue placeholder="소스" />
</SelectTrigger>
<SelectContent>
{(tableColumns[group.sourceTable] || []).map((col) => (
<SelectItem key={col.name} value={col.name} className="text-xs">
{col.label || col.name}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-[8px] text-muted-foreground">-&gt;</span>
<Select
value={mapping.targetColumn}
onValueChange={(value) => {
const updatedMappings = (group.mappings || []).map((m, i) =>
i === mappingIndex ? { ...m, targetColumn: value } : m
);
const updatedGroups = (selectedSection.linkedFieldGroups || []).map((g) =>
g.id === group.id ? { ...g, mappings: updatedMappings } : g
);
updateSection(selectedSection.id, { linkedFieldGroups: updatedGroups });
}}
>
<SelectTrigger className="h-5 text-[8px] flex-1 min-w-0">
<SelectValue placeholder="대상" />
</SelectTrigger>
<SelectContent>
{(tableColumns[config.saveConfig.tableName] || []).map((col) => (
<SelectItem key={col.name} value={col.name} className="text-xs">
{col.label || col.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon"
className="h-4 w-4 text-destructive shrink-0"
onClick={() => {
const updatedMappings = (group.mappings || []).filter(
(_, i) => i !== mappingIndex
);
const updatedGroups = (selectedSection.linkedFieldGroups || []).map((g) =>
g.id === group.id ? { ...g, mappings: updatedMappings } : g
);
updateSection(selectedSection.id, { linkedFieldGroups: updatedGroups });
}}
>
<Trash2 className="h-2.5 w-2.5" />
</Button>
</div>
))}
</div>
{/* 기타 옵션 */}
<div className="flex items-center justify-between pt-1">
<div className="flex items-center gap-1">
<Checkbox
id={`required-${group.id}`}
checked={group.required || false}
onCheckedChange={(checked) => {
const updatedGroups = (selectedSection.linkedFieldGroups || []).map((g) =>
g.id === group.id ? { ...g, required: !!checked } : g
);
updateSection(selectedSection.id, { linkedFieldGroups: updatedGroups });
}}
className="h-3 w-3"
/>
<Label htmlFor={`required-${group.id}`} className="text-[8px]">
</Label>
</div>
<div className="flex items-center gap-0.5">
<Label className="text-[8px]"></Label>
<Input
type="number"
min={1}
max={12}
value={group.gridSpan || 6}
onChange={(e) => {
const updatedGroups = (selectedSection.linkedFieldGroups || []).map((g) =>
g.id === group.id ? { ...g, gridSpan: parseInt(e.target.value) || 6 } : g
);
updateSection(selectedSection.id, { linkedFieldGroups: updatedGroups });
}}
className="h-4 w-8 text-[8px] px-1"
/>
</div>
</div>
</div>
))}
</div>
)}
</div>
<Separator />
{/* 필드 목록 */}
@ -1467,7 +1124,8 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
{/* Select 옵션 설정 */}
{selectedField.fieldType === "select" && (
<div className="border rounded-md p-2 space-y-2">
<Label className="text-[10px] font-medium"> </Label>
<Label className="text-[10px] font-medium"> </Label>
<HelpText> .</HelpText>
<Select
value={selectedField.selectOptions?.type || "static"}
onValueChange={(value) =>
@ -1491,10 +1149,15 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
</SelectContent>
</Select>
{selectedField.selectOptions?.type === "static" && (
<HelpText> 입력: 옵션을 . ( - )</HelpText>
)}
{selectedField.selectOptions?.type === "table" && (
<div className="space-y-2 pt-2 border-t">
<HelpText> 참조: DB .</HelpText>
<div>
<Label className="text-[10px]"> </Label>
<Label className="text-[10px]"> ( )</Label>
<Select
value={selectedField.selectOptions?.tableName || ""}
onValueChange={(value) =>
@ -1517,9 +1180,10 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
))}
</SelectContent>
</Select>
<HelpText>: dept_info ( )</HelpText>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Label className="text-[10px]"> ( )</Label>
<Input
value={selectedField.selectOptions?.valueColumn || ""}
onChange={(e) =>
@ -1530,12 +1194,13 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
},
})
}
placeholder="code"
placeholder="dept_code"
className="h-6 text-[10px] mt-1"
/>
<HelpText> (: D001)</HelpText>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Label className="text-[10px]"> ( )</Label>
<Input
value={selectedField.selectOptions?.labelColumn || ""}
onChange={(e) =>
@ -1546,15 +1211,17 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
},
})
}
placeholder="name"
placeholder="dept_name"
className="h-6 text-[10px] mt-1"
/>
<HelpText> (: 영업부)</HelpText>
</div>
</div>
)}
{selectedField.selectOptions?.type === "code" && (
<div className="pt-2 border-t">
<HelpText>공통코드: 공통코드 .</HelpText>
<Label className="text-[10px]"> </Label>
<Input
value={selectedField.selectOptions?.codeCategory || ""}
@ -1569,6 +1236,235 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
placeholder="POSITION_CODE"
className="h-6 text-[10px] mt-1"
/>
<HelpText>: POSITION_CODE (), STATUS_CODE () </HelpText>
</div>
)}
</div>
)}
{/* 다중 컬럼 저장 (select 타입만) */}
{selectedField.fieldType === "select" && (
<div className="border rounded-md p-2 space-y-2">
<div className="flex items-center justify-between">
<span className="text-[10px] font-medium"> </span>
<Switch
checked={selectedField.linkedFieldGroup?.enabled || false}
onCheckedChange={(checked) =>
updateField(selectedSection.id, selectedField.id, {
linkedFieldGroup: {
...selectedField.linkedFieldGroup,
enabled: checked,
},
})
}
/>
</div>
<HelpText>
.
<br />: 부서 +
</HelpText>
{selectedField.linkedFieldGroup?.enabled && (
<div className="space-y-2 pt-2 border-t">
{/* 소스 테이블 */}
<div>
<Label className="text-[10px]"> </Label>
<Select
value={selectedField.linkedFieldGroup?.sourceTable || ""}
onValueChange={(value) => {
updateField(selectedSection.id, selectedField.id, {
linkedFieldGroup: {
...selectedField.linkedFieldGroup,
sourceTable: value,
},
});
if (value && !tableColumns[value]) {
loadTableColumns(value);
}
}}
>
<SelectTrigger className="h-6 text-[10px] mt-1">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{tables.map((table) => (
<SelectItem key={table.name} value={table.name} className="text-xs">
{table.label || table.name}
</SelectItem>
))}
</SelectContent>
</Select>
<HelpText> </HelpText>
</div>
{/* 표시 형식 */}
<div>
<Label className="text-[10px]"> </Label>
<Select
value={selectedField.linkedFieldGroup?.displayFormat || "name_only"}
onValueChange={(value: "name_only" | "code_name" | "name_code") =>
updateField(selectedSection.id, selectedField.id, {
linkedFieldGroup: {
...selectedField.linkedFieldGroup,
displayFormat: value,
},
})
}
>
<SelectTrigger className="h-6 text-[10px] mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LINKED_FIELD_DISPLAY_FORMAT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 표시 컬럼 / 값 컬럼 */}
<div className="space-y-1">
<div>
<Label className="text-[10px]"> ( )</Label>
<Select
value={selectedField.linkedFieldGroup?.displayColumn || ""}
onValueChange={(value) =>
updateField(selectedSection.id, selectedField.id, {
linkedFieldGroup: {
...selectedField.linkedFieldGroup,
displayColumn: value,
},
})
}
>
<SelectTrigger className="h-6 text-[10px] mt-1">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{(tableColumns[selectedField.linkedFieldGroup?.sourceTable || ""] || []).map((col) => (
<SelectItem key={col.name} value={col.name} className="text-xs">
{col.label || col.name}
</SelectItem>
))}
</SelectContent>
</Select>
<HelpText> (: 영업부, )</HelpText>
</div>
</div>
{/* 저장할 컬럼 매핑 */}
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-[10px]"> </Label>
<Button
onClick={() => {
const newMapping: LinkedFieldMapping = { sourceColumn: "", targetColumn: "" };
updateField(selectedSection.id, selectedField.id, {
linkedFieldGroup: {
...selectedField.linkedFieldGroup,
mappings: [...(selectedField.linkedFieldGroup?.mappings || []), newMapping],
},
});
}}
variant="outline"
size="icon"
className="h-5 w-5"
>
<Plus className="h-3 w-3" />
</Button>
</div>
<HelpText> </HelpText>
{(selectedField.linkedFieldGroup?.mappings || []).map((mapping, mappingIndex) => (
<div key={mappingIndex} className="bg-muted/30 p-1.5 rounded space-y-1 border">
<div className="flex items-center justify-between">
<span className="text-[9px] text-muted-foreground"> #{mappingIndex + 1}</span>
<Button
variant="ghost"
size="icon"
className="h-4 w-4 text-destructive"
onClick={() => {
const updatedMappings = (selectedField.linkedFieldGroup?.mappings || []).filter(
(_, i) => i !== mappingIndex
);
updateField(selectedSection.id, selectedField.id, {
linkedFieldGroup: {
...selectedField.linkedFieldGroup,
mappings: updatedMappings,
},
});
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<div>
<Label className="text-[9px]"> ( )</Label>
<Select
value={mapping.sourceColumn}
onValueChange={(value) => {
const updatedMappings = (selectedField.linkedFieldGroup?.mappings || []).map((m, i) =>
i === mappingIndex ? { ...m, sourceColumn: value } : m
);
updateField(selectedSection.id, selectedField.id, {
linkedFieldGroup: {
...selectedField.linkedFieldGroup,
mappings: updatedMappings,
},
});
}}
>
<SelectTrigger className="h-5 text-[9px] mt-0.5">
<SelectValue placeholder="소스 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{(tableColumns[selectedField.linkedFieldGroup?.sourceTable || ""] || []).map((col) => (
<SelectItem key={col.name} value={col.name} className="text-xs">
{col.label || col.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[9px]"> ( )</Label>
<Select
value={mapping.targetColumn}
onValueChange={(value) => {
const updatedMappings = (selectedField.linkedFieldGroup?.mappings || []).map((m, i) =>
i === mappingIndex ? { ...m, targetColumn: value } : m
);
updateField(selectedSection.id, selectedField.id, {
linkedFieldGroup: {
...selectedField.linkedFieldGroup,
mappings: updatedMappings,
},
});
}}
>
<SelectTrigger className="h-5 text-[9px] mt-0.5">
<SelectValue placeholder="저장할 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{(tableColumns[config.saveConfig.tableName] || []).map((col) => (
<SelectItem key={col.name} value={col.name} className="text-xs">
{col.label || col.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
))}
{(selectedField.linkedFieldGroup?.mappings || []).length === 0 && (
<p className="text-[9px] text-muted-foreground text-center py-2">
+
</p>
)}
</div>
</div>
)}
</div>

View File

@ -74,6 +74,15 @@ export interface FormFieldConfig {
// Select 옵션
selectOptions?: SelectOptionConfig;
// 다중 컬럼 저장 (드롭다운 선택 시 여러 컬럼에 동시 저장)
linkedFieldGroup?: {
enabled?: boolean; // 사용 여부
sourceTable?: string; // 소스 테이블 (예: dept_info)
displayColumn?: string; // 표시할 컬럼 (예: dept_name) - 드롭다운에 보여줄 텍스트
displayFormat?: "name_only" | "code_name" | "name_code"; // 표시 형식
mappings?: LinkedFieldMapping[]; // 저장할 컬럼 매핑 (첫 번째 매핑의 sourceColumn이 드롭다운 값으로 사용됨)
};
// 유효성 검사
validation?: FieldValidationConfig;