make: RepeaterFieldGroup 컴포넌트

- 하위 데이터 조회 연동 방식 개선
- 필드 정의 레벨에서 subDataSource 설정 추가
- 필드별 숨김(isHidden) 옵션 추가
- 기존 fieldMappings 방식 제거, 필드별 연동으로 변경
_repeaterFieldsConfig 메타데이터로 설정 전달 : "이 필드들의 하위 조회 결과에서 값 가져와서 추가로 저장해줘"라는 주문서 역할
This commit is contained in:
SeongHyun Kim 2026-01-19 18:58:23 +09:00
parent b62a0b7e3b
commit 585febfb52
5 changed files with 182 additions and 91 deletions

View File

@ -908,6 +908,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
// 하위 데이터 조회 설정이 있으면 연결 컬럼 찾기
const linkColumn = subDataLookup?.lookup?.linkColumn;
// hidden이 아닌 필드만 표시
// isHidden이 true이거나 displayMode가 hidden인 필드는 제외 (하위 호환성 유지)
const visibleFields = fields.filter((f) => !f.isHidden && f.displayMode !== "hidden");
return (
<div className="bg-card">
<Table>
@ -919,7 +923,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
{allowReorder && (
<TableHead className="h-10 w-10 px-2.5 py-2 text-center text-sm font-semibold"></TableHead>
)}
{fields.map((field) => (
{visibleFields.map((field) => (
<TableHead key={field.name} className="h-10 px-2.5 py-2 text-sm font-semibold">
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
@ -958,8 +962,8 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
</TableCell>
)}
{/* 필드들 */}
{fields.map((field) => (
{/* 필드들 (hidden 제외) */}
{visibleFields.map((field) => (
<TableCell key={field.name} className="h-12 px-2.5 py-2">
{renderField(field, itemIndex, item[field.name])}
</TableCell>
@ -987,7 +991,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
<TableRow className="bg-gray-50/50">
<TableCell
colSpan={
fields.length + (showIndex ? 1 : 0) + (allowReorder && !readonly && !disabled ? 1 : 0) + 1
visibleFields.length + (showIndex ? 1 : 0) + (allowReorder && !readonly && !disabled ? 1 : 0) + 1
}
className="px-2.5 py-2"
>
@ -1017,6 +1021,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
// 하위 데이터 조회 설정이 있으면 연결 컬럼 찾기
const linkColumn = subDataLookup?.lookup?.linkColumn;
// hidden이 아닌 필드만 표시
// isHidden이 true이거나 displayMode가 hidden인 필드는 제외 (하위 호환성 유지)
const visibleFields = fields.filter((f) => !f.isHidden && f.displayMode !== "hidden");
return (
<>
{items.map((item, itemIndex) => {
@ -1084,7 +1092,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
{!isCollapsed && (
<CardContent>
<div className={getFieldsLayoutClass()}>
{fields.map((field) => (
{visibleFields.map((field) => (
<div key={field.name} className="space-y-1" style={{ width: field.width }}>
<label className="text-foreground text-sm font-medium">
{field.label}

View File

@ -360,32 +360,25 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
const handleDisplayColumnToggleWithOrder = (columnName: string, checked: boolean) => {
const currentColumns = config.subDataLookup?.lookup?.displayColumns || [];
const currentOrder = config.subDataLookup?.lookup?.columnOrder || [];
const currentMappings = config.subDataLookup?.lookup?.fieldMappings || [];
let newColumns: string[];
let newOrder: string[];
let newMappings: { sourceColumn: string; targetField: string }[];
if (checked) {
newColumns = [...currentColumns, columnName];
newOrder = [...currentOrder, columnName];
// 기본 매핑 추가: 동일한 컬럼명이 targetTable에 있으면 자동 매핑, 없으면 빈 문자열
const targetColumn = tableColumns.find((c) => c.columnName === columnName);
newMappings = [...currentMappings, { sourceColumn: columnName, targetField: targetColumn ? columnName : "" }];
} else {
newColumns = currentColumns.filter((c) => c !== columnName);
newOrder = currentOrder.filter((c) => c !== columnName);
newMappings = currentMappings.filter((m) => m.sourceColumn !== columnName);
}
// displayColumns, columnOrder, fieldMappings 함께 업데이트
// displayColumns, columnOrder 함께 업데이트
const newConfig = { ...config.subDataLookup } as SubDataLookupConfig;
if (!newConfig.lookup) {
newConfig.lookup = { tableName: "", linkColumn: "", displayColumns: [] };
}
newConfig.lookup.displayColumns = newColumns;
newConfig.lookup.columnOrder = newOrder;
newConfig.lookup.fieldMappings = newMappings;
onChange({
...config,
@ -393,28 +386,6 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
});
};
// 필드 매핑 변경 핸들러
const handleFieldMappingChange = (sourceColumn: string, targetField: string) => {
const currentMappings = config.subDataLookup?.lookup?.fieldMappings || [];
const existingIndex = currentMappings.findIndex((m) => m.sourceColumn === sourceColumn);
let newMappings: { sourceColumn: string; targetField: string }[];
if (existingIndex >= 0) {
newMappings = [...currentMappings];
newMappings[existingIndex] = { sourceColumn, targetField };
} else {
newMappings = [...currentMappings, { sourceColumn, targetField }];
}
handleSubDataLookupChange("lookup.fieldMappings", newMappings);
};
// 특정 컬럼의 현재 매핑된 타겟 필드 가져오기
const getFieldMapping = (sourceColumn: string): string => {
const mappings = config.subDataLookup?.lookup?.fieldMappings || [];
const mapping = mappings.find((m) => m.sourceColumn === sourceColumn);
return mapping?.targetField || "";
};
return (
<div className="space-y-4">
@ -711,7 +682,6 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
{getOrderedDisplayColumns().map((colName, index) => {
const col = subDataTableColumns.find((c) => c.columnName === colName);
const currentLabel = config.subDataLookup?.lookup?.columnLabels?.[colName] || "";
const currentMapping = getFieldMapping(colName);
const orderedColumns = getOrderedDisplayColumns();
const isFirst = index === 0;
const isLast = index === orderedColumns.length - 1;
@ -765,37 +735,13 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
/>
</div>
{/* 하단: 저장 컬럼 선택 */}
<div className="mt-1.5 flex items-center gap-2">
<span className="text-[10px] text-gray-500 whitespace-nowrap"> :</span>
<Select
value={currentMapping || "__none__"}
onValueChange={(v) => handleFieldMappingChange(colName, v === "__none__" ? "" : v)}
>
<SelectTrigger className="h-6 flex-1 text-xs">
<SelectValue placeholder="선택안함" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-xs">
</SelectItem>
{tableColumns.map((targetCol) => (
<SelectItem key={targetCol.columnName} value={targetCol.columnName} className="text-xs">
{targetCol.columnLabel || targetCol.columnName} ({targetCol.columnName})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
})}
</div>
{config.targetTable && (
<p className="text-[10px] text-purple-500">
* : {config.targetTable}
* "하위 데이터 조회에서 값 가져오기"
</p>
)}
</div>
)}
@ -1545,6 +1491,7 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
{/* 카테고리 타입이 아닐 때만 표시 모드 선택 */}
{field.type !== "category" && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs"> </Label>
@ -1575,6 +1522,76 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
</div>
</div>
</div>
{/* 숨김 체크박스 */}
<div className="flex items-center space-x-2">
<Checkbox
id={`hidden-${index}`}
checked={field.isHidden ?? false}
onCheckedChange={(checked) => updateField(index, { isHidden: checked as boolean })}
/>
<Label htmlFor={`hidden-${index}`} className="cursor-pointer text-xs font-normal">
( )
</Label>
</div>
{/* 하위 데이터 조회에서 값 가져오기 */}
{config.subDataLookup?.enabled && (
<div className="space-y-2 rounded border border-purple-200 bg-purple-50 p-2">
<div className="flex items-center space-x-2">
<Checkbox
id={`subdata-${index}`}
checked={field.subDataSource?.enabled ?? false}
onCheckedChange={(checked) => {
updateField(index, {
subDataSource: {
enabled: checked as boolean,
sourceColumn: field.subDataSource?.sourceColumn || "",
},
});
}}
/>
<Label htmlFor={`subdata-${index}`} className="cursor-pointer text-xs font-normal text-purple-700">
</Label>
</div>
{field.subDataSource?.enabled && (
<div className="ml-5 space-y-1">
<Label className="text-[10px] text-purple-600"> </Label>
<Select
value={field.subDataSource?.sourceColumn || ""}
onValueChange={(value) => {
updateField(index, {
subDataSource: {
enabled: true,
sourceColumn: value,
},
});
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{(config.subDataLookup?.lookup?.displayColumns || []).map((colName) => {
const label = config.subDataLookup?.lookup?.columnLabels?.[colName] || colName;
return (
<SelectItem key={colName} value={colName} className="text-xs">
{label} ({colName})
</SelectItem>
);
})}
</SelectContent>
</Select>
<p className="text-[10px] text-purple-500">
</p>
</div>
)}
</div>
)}
</div>
)}
{/* 카테고리 타입일 때는 필수만 표시 */}

View File

@ -287,12 +287,18 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
if (onChange && items.length > 0) {
// 🆕 RepeaterFieldGroup이 관리하는 필드 목록 추출
const repeaterFieldNames = (configRef.current.fields || []).map((f: any) => f.name);
// 🆕 subDataSource 설정이 있는 필드 목록 (하위 데이터 조회 연동)
const fieldsConfig = (configRef.current.fields || []).map((f: any) => ({
name: f.name,
subDataSource: f.subDataSource,
}));
const dataWithMeta = items.map((item: any) => ({
...item,
_targetTable: targetTable,
_originalItemIds: itemIds, // 🆕 원본 ID 목록도 함께 전달
_existingRecord: !!item.id, // 🆕 기존 레코드 플래그 (id가 있으면 기존 레코드)
_repeaterFields: repeaterFieldNames, // 🆕 품목 고유 필드 목록
_repeaterFieldsConfig: fieldsConfig, // 🆕 필드 설정 (subDataSource 등)
}));
onChange(dataWithMeta);
}
@ -393,11 +399,17 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
if (items.length > 0) {
// 🆕 RepeaterFieldGroup이 관리하는 필드 목록 추출
const repeaterFieldNames = (configRef.current.fields || []).map((f: any) => f.name);
// 🆕 subDataSource 설정이 있는 필드 목록
const fieldsConfig = (configRef.current.fields || []).map((f: any) => ({
name: f.name,
subDataSource: f.subDataSource,
}));
const dataWithMeta = items.map((item: any) => ({
...item,
_targetTable: effectiveTargetTable,
_existingRecord: !!item.id,
_repeaterFields: repeaterFieldNames, // 🆕 품목 고유 필드 목록
_repeaterFieldsConfig: fieldsConfig, // 🆕 필드 설정 (subDataSource 등)
}));
onChange(dataWithMeta);
} else {
@ -681,6 +693,11 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
(newValue: any[]) => {
// 🆕 RepeaterFieldGroup이 관리하는 필드 목록 추출
const repeaterFieldNames = (configRef.current.fields || []).map((f: any) => f.name);
// 🆕 subDataSource 설정이 있는 필드 목록
const fieldsConfig = (configRef.current.fields || []).map((f: any) => ({
name: f.name,
subDataSource: f.subDataSource,
}));
// 🆕 모든 항목에 메타데이터 추가
let valueWithMeta = newValue.map((item: any) => ({
@ -688,6 +705,7 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
_targetTable: effectiveTargetTable || targetTable,
_existingRecord: !!item.id,
_repeaterFields: repeaterFieldNames, // 🆕 품목 고유 필드 목록
_repeaterFieldsConfig: fieldsConfig, // 🆕 필드 설정 (subDataSource 등)
}));
// 🆕 분할 패널에서 우측인 경우, FK 값 추가

View File

@ -803,7 +803,7 @@ export class ButtonActionExecutor {
for (const item of parsedData) {
// 메타 필드 제거
const { _targetTable, _isNewItem, _existingRecord, _originalItemIds, _deletedItemIds, _repeaterFields, ...itemData } = item;
const { _targetTable, _isNewItem, _existingRecord, _originalItemIds, _deletedItemIds, _repeaterFields, _subDataSelection, _subDataMaxValue, ...itemData } = item;
// 🔧 품목 고유 필드만 추출 (RepeaterFieldGroup 설정 기반)
const itemOnlyData: Record<string, any> = {};
@ -813,6 +813,42 @@ export class ButtonActionExecutor {
}
});
// 🆕 하위 데이터 선택에서 값 추출 (subDataSource 설정 기반)
// 필드 정의에서 subDataSource.enabled가 true이고 sourceColumn이 설정된 필드만 처리
if (_subDataSelection && typeof _subDataSelection === 'object') {
// _repeaterFieldsConfig에서 subDataSource 설정 확인
const fieldsConfig = item._repeaterFieldsConfig as Array<{
name: string;
subDataSource?: { enabled: boolean; sourceColumn: string };
}> | undefined;
if (fieldsConfig && Array.isArray(fieldsConfig)) {
fieldsConfig.forEach((fieldConfig) => {
if (fieldConfig.subDataSource?.enabled && fieldConfig.subDataSource?.sourceColumn) {
const targetField = fieldConfig.name; // 필드명 = 저장할 컬럼명
const sourceColumn = fieldConfig.subDataSource.sourceColumn;
const sourceValue = _subDataSelection[sourceColumn];
if (sourceValue !== undefined && sourceValue !== null) {
itemOnlyData[targetField] = sourceValue;
console.log(`📋 [handleSave] 하위 데이터 값 매핑: ${sourceColumn}${targetField} = ${sourceValue}`);
}
}
});
} else {
// 하위 호환성: fieldsConfig가 없으면 기존 방식 사용
Object.keys(_subDataSelection).forEach((subDataKey) => {
if (itemOnlyData[subDataKey] === undefined || itemOnlyData[subDataKey] === null || itemOnlyData[subDataKey] === '') {
const subDataValue = _subDataSelection[subDataKey];
if (subDataValue !== undefined && subDataValue !== null) {
itemOnlyData[subDataKey] = subDataValue;
console.log(`📋 [handleSave] 하위 데이터 선택 값 추가 (레거시): ${subDataKey} = ${subDataValue}`);
}
}
});
}
}
// 🔧 마스터 정보 + 품목 고유 정보 병합
// masterFields: 상단 폼에서 수정한 최신 마스터 정보
// itemOnlyData: 품목 고유 필드만 (품번, 품명, 수량 등)

View File

@ -43,9 +43,19 @@ export interface CalculationFormula {
*
* - input: 입력 ( )
* - readonly:
* - hidden: 숨김 (UI에 )
* - ( )
*/
export type RepeaterFieldDisplayMode = "input" | "readonly";
export type RepeaterFieldDisplayMode = "input" | "readonly" | "hidden";
/**
*
*
*/
export interface SubDataSourceConfig {
enabled: boolean; // 활성화 여부
sourceColumn: string; // 하위 데이터 조회 테이블의 소스 컬럼 (예: lot_number)
}
/**
*
@ -60,6 +70,8 @@ export interface RepeaterFieldDefinition {
options?: Array<{ label: string; value: string }>; // select용
width?: string; // 필드 너비 (예: "200px", "50%")
displayMode?: RepeaterFieldDisplayMode; // 표시 모드: input(입력), readonly(읽기전용)
isHidden?: boolean; // 숨김 여부 (true면 테이블에 표시 안 함, 데이터는 저장)
subDataSource?: SubDataSourceConfig; // 하위 데이터 조회에서 값 가져오기 설정
categoryCode?: string; // category 타입일 때 사용할 카테고리 코드
formula?: CalculationFormula; // 계산식 (type이 "calculated"일 때 사용)
numberFormat?: {