feat(UniversalFormModal): 연동 필드 그룹 기능 추가
- LinkedFieldGroup, LinkedFieldMapping 타입 정의 - 소스 테이블 데이터 캐싱 및 드롭다운 렌더링 - 선택 시 여러 컬럼에 자동 값 매핑 처리 - 설정 패널에 연동 필드 그룹 관리 UI 추가 - 일반 섹션/반복 섹션 모두 지원
This commit is contained in:
parent
5d3b3ea76e
commit
0c57609ee9
|
|
@ -33,6 +33,7 @@ import {
|
|||
FormDataState,
|
||||
RepeatSectionItem,
|
||||
SelectOptionConfig,
|
||||
LinkedFieldGroup,
|
||||
} from "./types";
|
||||
import { defaultConfig, generateUniqueId } from "./config";
|
||||
|
||||
|
|
@ -100,6 +101,11 @@ export function UniversalFormModalComponent({
|
|||
[key: string]: { value: string; label: string }[];
|
||||
}>({});
|
||||
|
||||
// 연동 필드 그룹 데이터 캐시 (테이블별 데이터)
|
||||
const [linkedFieldDataCache, setLinkedFieldDataCache] = useState<{
|
||||
[tableKey: string]: Record<string, any>[];
|
||||
}>({});
|
||||
|
||||
// 로딩 상태
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
|
|
@ -342,6 +348,125 @@ export function UniversalFormModalComponent({
|
|||
[selectOptionsCache],
|
||||
);
|
||||
|
||||
// 연동 필드 그룹 데이터 로드
|
||||
const loadLinkedFieldData = useCallback(
|
||||
async (sourceTable: string): Promise<Record<string, any>[]> => {
|
||||
// 캐시 확인 - 이미 배열로 캐시되어 있으면 반환
|
||||
if (Array.isArray(linkedFieldDataCache[sourceTable]) && linkedFieldDataCache[sourceTable].length > 0) {
|
||||
return linkedFieldDataCache[sourceTable];
|
||||
}
|
||||
|
||||
let data: Record<string, any>[] = [];
|
||||
|
||||
try {
|
||||
console.log(`[연동필드] ${sourceTable} 데이터 로드 시작`);
|
||||
// 현재 회사 기준으로 데이터 조회 (POST 메서드, autoFilter 사용)
|
||||
const response = await apiClient.post(`/table-management/tables/${sourceTable}/data`, {
|
||||
page: 1,
|
||||
size: 1000,
|
||||
autoFilter: true, // 현재 회사 기준 자동 필터링
|
||||
});
|
||||
|
||||
console.log(`[연동필드] ${sourceTable} API 응답:`, response.data);
|
||||
|
||||
if (response.data?.success) {
|
||||
// data가 배열인지 확인
|
||||
const responseData = response.data?.data;
|
||||
if (Array.isArray(responseData)) {
|
||||
data = responseData;
|
||||
} else if (responseData?.rows && Array.isArray(responseData.rows)) {
|
||||
// { rows: [...], total: ... } 형태일 수 있음
|
||||
data = responseData.rows;
|
||||
}
|
||||
console.log(`[연동필드] ${sourceTable} 파싱된 데이터 ${data.length}개:`, data.slice(0, 3));
|
||||
}
|
||||
|
||||
// 캐시 저장 (빈 배열이라도 저장하여 중복 요청 방지)
|
||||
setLinkedFieldDataCache((prev) => ({ ...prev, [sourceTable]: data }));
|
||||
} catch (error) {
|
||||
console.error(`연동 필드 데이터 로드 실패 (${sourceTable}):`, error);
|
||||
// 실패해도 빈 배열로 캐시하여 무한 요청 방지
|
||||
setLinkedFieldDataCache((prev) => ({ ...prev, [sourceTable]: [] }));
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
[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[] = [];
|
||||
|
|
@ -729,6 +854,64 @@ 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
|
||||
|
|
@ -806,6 +989,7 @@ export function UniversalFormModalComponent({
|
|||
<CollapsibleContent>
|
||||
<CardContent>
|
||||
<div className="grid gap-4" style={{ gridTemplateColumns: `repeat(12, 1fr)` }}>
|
||||
{/* 일반 필드 렌더링 */}
|
||||
{section.fields.map((field) =>
|
||||
renderFieldWithColumns(
|
||||
field,
|
||||
|
|
@ -815,6 +999,18 @@ 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>
|
||||
|
|
@ -827,6 +1023,7 @@ export function UniversalFormModalComponent({
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4" style={{ gridTemplateColumns: `repeat(12, 1fr)` }}>
|
||||
{/* 일반 필드 렌더링 */}
|
||||
{section.fields.map((field) =>
|
||||
renderFieldWithColumns(
|
||||
field,
|
||||
|
|
@ -836,6 +1033,19 @@ 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>
|
||||
</>
|
||||
|
|
@ -885,6 +1095,7 @@ export function UniversalFormModalComponent({
|
|||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-4" style={{ gridTemplateColumns: `repeat(12, 1fr)` }}>
|
||||
{/* 일반 필드 렌더링 */}
|
||||
{section.fields.map((field) =>
|
||||
renderFieldWithColumns(
|
||||
field,
|
||||
|
|
@ -894,6 +1105,19 @@ 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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -37,17 +37,23 @@ import {
|
|||
UniversalFormModalConfigPanelProps,
|
||||
FormSectionConfig,
|
||||
FormFieldConfig,
|
||||
LinkedFieldGroup,
|
||||
LinkedFieldMapping,
|
||||
FIELD_TYPE_OPTIONS,
|
||||
MODAL_SIZE_OPTIONS,
|
||||
SELECT_OPTION_TYPE_OPTIONS,
|
||||
LINKED_FIELD_DISPLAY_FORMAT_OPTIONS,
|
||||
} from "./types";
|
||||
import {
|
||||
defaultFieldConfig,
|
||||
defaultSectionConfig,
|
||||
defaultNumberingRuleConfig,
|
||||
defaultSelectOptionsConfig,
|
||||
defaultLinkedFieldGroupConfig,
|
||||
defaultLinkedFieldMappingConfig,
|
||||
generateSectionId,
|
||||
generateFieldId,
|
||||
generateLinkedFieldGroupId,
|
||||
} from "./config";
|
||||
|
||||
// 도움말 텍스트 컴포넌트
|
||||
|
|
@ -87,6 +93,24 @@ 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
allSourceTables.forEach((tableName) => {
|
||||
if (!tableColumns[tableName]) {
|
||||
loadTableColumns(tableName);
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config.sections]);
|
||||
|
||||
const loadTables = async () => {
|
||||
try {
|
||||
const response = await apiClient.get("/table-management/tables");
|
||||
|
|
@ -842,6 +866,305 @@ 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">-></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 />
|
||||
|
||||
{/* 필드 목록 */}
|
||||
|
|
|
|||
|
|
@ -90,6 +90,27 @@ export const defaultSectionConfig = {
|
|||
itemTitle: "항목 {index}",
|
||||
confirmRemove: false,
|
||||
},
|
||||
linkedFieldGroups: [],
|
||||
};
|
||||
|
||||
// 기본 연동 필드 그룹 설정
|
||||
export const defaultLinkedFieldGroupConfig = {
|
||||
id: "",
|
||||
label: "연동 필드",
|
||||
sourceTable: "dept_info",
|
||||
displayFormat: "code_name" as const,
|
||||
displayColumn: "dept_name",
|
||||
valueColumn: "dept_code",
|
||||
mappings: [],
|
||||
required: false,
|
||||
placeholder: "선택하세요",
|
||||
gridSpan: 6,
|
||||
};
|
||||
|
||||
// 기본 연동 필드 매핑 설정
|
||||
export const defaultLinkedFieldMappingConfig = {
|
||||
sourceColumn: "",
|
||||
targetColumn: "",
|
||||
};
|
||||
|
||||
// 기본 채번규칙 설정
|
||||
|
|
@ -136,3 +157,8 @@ export const generateSectionId = (): string => {
|
|||
export const generateFieldId = (): string => {
|
||||
return generateUniqueId("field");
|
||||
};
|
||||
|
||||
// 유틸리티: 연동 필드 그룹 ID 생성
|
||||
export const generateLinkedFieldGroupId = (): string => {
|
||||
return generateUniqueId("linked");
|
||||
};
|
||||
|
|
|
|||
|
|
@ -96,6 +96,27 @@ export interface FormFieldConfig {
|
|||
};
|
||||
}
|
||||
|
||||
// 연동 필드 매핑 설정
|
||||
export interface LinkedFieldMapping {
|
||||
sourceColumn: string; // 소스 테이블 컬럼 (예: "dept_code")
|
||||
targetColumn: string; // 저장할 컬럼 (예: "position_code")
|
||||
}
|
||||
|
||||
// 연동 필드 그룹 설정 (섹션 레벨)
|
||||
// 하나의 드롭다운에서 선택 시 여러 컬럼에 자동 저장
|
||||
export interface LinkedFieldGroup {
|
||||
id: string;
|
||||
label: string; // 드롭다운 라벨 (예: "겸직부서")
|
||||
sourceTable: string; // 소스 테이블 (예: "dept_info")
|
||||
displayFormat: "name_only" | "code_name" | "name_code"; // 표시 형식
|
||||
displayColumn: string; // 표시할 컬럼 (예: "dept_name")
|
||||
valueColumn: string; // 값으로 사용할 컬럼 (예: "dept_code")
|
||||
mappings: LinkedFieldMapping[]; // 필드 매핑 목록
|
||||
required?: boolean; // 필수 여부
|
||||
placeholder?: string; // 플레이스홀더
|
||||
gridSpan?: number; // 그리드 스팬 (1-12)
|
||||
}
|
||||
|
||||
// 반복 섹션 설정
|
||||
export interface RepeatSectionConfig {
|
||||
minItems?: number; // 최소 항목 수 (기본: 0)
|
||||
|
|
@ -119,6 +140,9 @@ export interface FormSectionConfig {
|
|||
repeatable?: boolean;
|
||||
repeatConfig?: RepeatSectionConfig;
|
||||
|
||||
// 연동 필드 그룹 (부서코드/부서명 등 연동 저장)
|
||||
linkedFieldGroups?: LinkedFieldGroup[];
|
||||
|
||||
// 섹션 레이아웃
|
||||
columns?: number; // 필드 배치 컬럼 수 (기본: 2)
|
||||
gap?: string; // 필드 간 간격
|
||||
|
|
@ -257,3 +281,10 @@ export const SELECT_OPTION_TYPE_OPTIONS = [
|
|||
{ value: "table", label: "테이블 참조" },
|
||||
{ value: "code", label: "공통코드" },
|
||||
] as const;
|
||||
|
||||
// 연동 필드 표시 형식 옵션
|
||||
export const LINKED_FIELD_DISPLAY_FORMAT_OPTIONS = [
|
||||
{ value: "name_only", label: "이름만 (예: 영업부)" },
|
||||
{ value: "code_name", label: "코드 - 이름 (예: SALES - 영업부)" },
|
||||
{ value: "name_code", label: "이름 (코드) (예: 영업부 (SALES))" },
|
||||
] as const;
|
||||
|
|
|
|||
Loading…
Reference in New Issue