refactor(UniversalFormModal): 다중 컬럼 저장 기능을 필드 레벨로 이동
- 섹션 레벨 linkedFieldGroups 제거, 필드 레벨 linkedFieldGroup으로 변경 - FormFieldConfig에 linkedFieldGroup 속성 추가 (enabled, sourceTable, displayColumn, displayFormat, mappings) - select 필드 렌더링에서 linkedFieldGroup 활성화 시 다중 컬럼 저장 처리 - API 응답 파싱 개선 (responseData.data 구조 지원) - 저장 실패 시 상세 에러 메시지 표시 - ConfigPanel에 다중 컬럼 저장 설정 UI 및 HelpText 추가
This commit is contained in:
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user