feat: RepeaterInput 하위 데이터 조회 컬럼 설정 기능 개선

- 표시 컬럼 순서 변경 기능 추가 (columnOrder)
- 조회 컬럼 -> 저장 컬럼 매핑 기능 추가 (fieldMappings)
- 컬럼별 라벨, 순서, 저장 여부 통합 설정 UI 구현
- 하위 호환성 유지 (fieldMappings 없으면 기존 로직 사용)
This commit is contained in:
SeongHyun Kim 2026-01-19 13:18:17 +09:00
parent 0f9e91050e
commit d4b5bdd835
5 changed files with 252 additions and 14 deletions

View File

@ -309,18 +309,32 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
_subDataMaxValue: maxValue,
};
// 선택된 하위 데이터의 필드 값을 상위 item에 복사 (설정된 경우)
// 예: warehouse_code, location_code 등
if (subDataLookup.lookup.displayColumns) {
subDataLookup.lookup.displayColumns.forEach((col) => {
if (selectedItem[col] !== undefined) {
// 필드가 정의되어 있으면 복사
const fieldDef = fields.find((f) => f.name === col);
if (fieldDef || col.includes("_code") || col.includes("_id")) {
newItems[itemIndex][col] = selectedItem[col];
// fieldMappings가 설정되어 있으면 매핑에 따라 값 복사
if (subDataLookup.lookup.fieldMappings && subDataLookup.lookup.fieldMappings.length > 0) {
subDataLookup.lookup.fieldMappings.forEach((mapping) => {
if (mapping.targetField && mapping.targetField !== "") {
// 매핑된 타겟 필드에 소스 컬럼 값 복사
const sourceValue = selectedItem[mapping.sourceColumn];
if (sourceValue !== undefined) {
newItems[itemIndex][mapping.targetField] = sourceValue;
}
}
});
} else {
// fieldMappings가 없으면 기존 로직 (하위 호환성)
// 선택된 하위 데이터의 필드 값을 상위 item에 복사 (설정된 경우)
// 예: warehouse_code, location_code 등
if (subDataLookup.lookup.displayColumns) {
subDataLookup.lookup.displayColumns.forEach((col) => {
if (selectedItem[col] !== undefined) {
// 필드가 정의되어 있으면 복사
const fieldDef = fields.find((f) => f.name === col);
if (fieldDef || col.includes("_code") || col.includes("_id")) {
newItems[itemIndex][col] = selectedItem[col];
}
}
});
}
}
setItems(newItems);

View File

@ -319,6 +319,103 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
});
};
// 표시 컬럼 순서 가져오기 (columnOrder가 있으면 사용, 없으면 displayColumns 순서)
const getOrderedDisplayColumns = (): string[] => {
const displayColumns = config.subDataLookup?.lookup?.displayColumns || [];
const columnOrder = config.subDataLookup?.lookup?.columnOrder;
if (columnOrder && columnOrder.length > 0) {
// columnOrder에 있는 컬럼만, 순서대로 반환 (displayColumns에 있는 것만)
const orderedCols = columnOrder.filter(col => displayColumns.includes(col));
// columnOrder에 없지만 displayColumns에 있는 컬럼 추가
const remainingCols = displayColumns.filter(col => !columnOrder.includes(col));
return [...orderedCols, ...remainingCols];
}
return displayColumns;
};
// 표시 컬럼 순서 변경 핸들러 (위로)
const handleDisplayColumnMoveUp = (columnName: string) => {
const orderedColumns = getOrderedDisplayColumns();
const index = orderedColumns.indexOf(columnName);
if (index <= 0) return;
const newOrder = [...orderedColumns];
[newOrder[index - 1], newOrder[index]] = [newOrder[index], newOrder[index - 1]];
handleSubDataLookupChange("lookup.columnOrder", newOrder);
};
// 표시 컬럼 순서 변경 핸들러 (아래로)
const handleDisplayColumnMoveDown = (columnName: string) => {
const orderedColumns = getOrderedDisplayColumns();
const index = orderedColumns.indexOf(columnName);
if (index < 0 || index >= orderedColumns.length - 1) return;
const newOrder = [...orderedColumns];
[newOrder[index], newOrder[index + 1]] = [newOrder[index + 1], newOrder[index]];
handleSubDataLookupChange("lookup.columnOrder", newOrder);
};
// 표시 컬럼 토글 시 columnOrder도 업데이트
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 함께 업데이트
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,
subDataLookup: newConfig,
});
};
// 필드 매핑 변경 핸들러
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">
{/* 대상 테이블 선택 */}
@ -588,7 +685,7 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<Checkbox
id={`display-col-${col.columnName}`}
checked={isSelected}
onCheckedChange={(checked) => handleDisplayColumnToggle(col.columnName, checked as boolean)}
onCheckedChange={(checked) => handleDisplayColumnToggleWithOrder(col.columnName, checked as boolean)}
/>
<Label
htmlFor={`display-col-${col.columnName}`}
@ -605,6 +702,103 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
</div>
)}
{/* 컬럼 설정 (순서 + 라벨 + 저장 컬럼) */}
{(config.subDataLookup?.lookup?.displayColumns?.length || 0) > 0 && (
<div className="space-y-2">
<Label className="text-xs font-medium text-purple-700"> </Label>
<p className="text-[10px] text-purple-500">, , </p>
<div className="space-y-1.5 rounded border bg-white p-2">
{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;
return (
<div key={colName} className="rounded bg-purple-50 p-2">
{/* 상단: 순서 버튼 + 번호 + 컬럼명 */}
<div className="flex items-center gap-2">
{/* 순서 변경 버튼 */}
<div className="flex items-center gap-0.5">
<Button
type="button"
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
onClick={() => handleDisplayColumnMoveUp(colName)}
disabled={isFirst}
>
<ArrowUp className={cn("h-3 w-3", isFirst ? "text-gray-300" : "text-purple-600")} />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
onClick={() => handleDisplayColumnMoveDown(colName)}
disabled={isLast}
>
<ArrowDown className={cn("h-3 w-3", isLast ? "text-gray-300" : "text-purple-600")} />
</Button>
</div>
{/* 순서 번호 */}
<span className="w-4 text-center text-xs font-medium text-purple-600">{index + 1}</span>
{/* 컬럼명 */}
<div className="flex-1 text-xs">
<span className="font-medium">{col?.columnLabel || colName}</span>
<span className="ml-1 text-gray-400">({colName})</span>
</div>
</div>
{/* 중단: 라벨 입력 */}
<div className="mt-1.5 flex items-center gap-2">
<span className="text-[10px] text-gray-500 whitespace-nowrap"> :</span>
<Input
value={currentLabel}
onChange={(e) => handleColumnLabelChange(colName, e.target.value)}
placeholder={col?.columnLabel || colName}
className="h-6 flex-1 text-xs"
/>
</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>
)}
{/* 선택 설정 */}
{(config.subDataLookup?.lookup?.displayColumns?.length || 0) > 0 && (
<div className="space-y-3 border-t border-purple-200 pt-3">

View File

@ -78,8 +78,20 @@ export const SubDataLookupPanel: React.FC<SubDataLookupPanelProps> = ({
return config.lookup.columnLabels?.[columnName] || columnName;
};
// 표시할 컬럼 목록
const displayColumns = config.lookup.displayColumns || [];
// 표시할 컬럼 목록 (columnOrder가 있으면 순서 적용)
const displayColumns = useMemo(() => {
const columns = config.lookup.displayColumns || [];
const columnOrder = config.lookup.columnOrder;
if (columnOrder && columnOrder.length > 0) {
// columnOrder 순서대로 정렬 (displayColumns에 있는 것만)
const orderedCols = columnOrder.filter(col => columns.includes(col));
// columnOrder에 없지만 displayColumns에 있는 컬럼 추가
const remainingCols = columns.filter(col => !columnOrder.includes(col));
return [...orderedCols, ...remainingCols];
}
return columns;
}, [config.lookup.displayColumns, config.lookup.columnOrder]);
// 요약 정보 표시용 선택 상태
const summaryText = useMemo(() => {

View File

@ -197,10 +197,18 @@ export function useSubDataLookup(props: UseSubDataLookupProps): UseSubDataLookup
return "선택 안됨";
}
const { displayColumns, columnLabels } = config.lookup;
const { displayColumns, columnLabels, columnOrder } = config.lookup;
const parts: string[] = [];
displayColumns.forEach((col) => {
// columnOrder가 있으면 순서 적용, 없으면 displayColumns 순서
let orderedColumns = displayColumns;
if (columnOrder && columnOrder.length > 0) {
const orderedCols = columnOrder.filter(col => displayColumns.includes(col));
const remainingCols = displayColumns.filter(col => !columnOrder.includes(col));
orderedColumns = [...orderedCols, ...remainingCols];
}
orderedColumns.forEach((col) => {
const value = selectedItem[col];
if (value !== undefined && value !== null && value !== "") {
const label = columnLabels?.[col] || col;

View File

@ -113,6 +113,14 @@ export type RepeaterData = RepeaterItemData[];
// 품목 선택 시 재고/단가 등 관련 데이터를 조회하고 선택하는 기능
// ============================================================
/**
*
*/
export interface SubDataFieldMapping {
sourceColumn: string; // 조회 테이블 컬럼 (예: lot_number)
targetField: string; // 저장 테이블 컬럼 (예: lot_number) 또는 "" (선택안함)
}
/**
*
*/
@ -121,6 +129,8 @@ export interface SubDataLookupSettings {
linkColumn: string; // 상위 데이터와 연결할 컬럼 (예: item_code)
displayColumns: string[]; // 표시할 컬럼들 (예: ["warehouse_code", "location_code", "quantity"])
columnLabels?: Record<string, string>; // 컬럼 라벨 (예: { warehouse_code: "창고" })
columnOrder?: string[]; // 컬럼 표시 순서 (없으면 displayColumns 순서 사용)
fieldMappings?: SubDataFieldMapping[]; // 선택 데이터 저장 매핑 (조회 컬럼 → 저장 컬럼)
additionalFilters?: Record<string, any>; // 추가 필터 조건
}