Compare commits

..

12 Commits

Author SHA1 Message Date
leeheejin 2bf81a6e24 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-21 09:25:36 +09:00
SeongHyun Kim 430723df59 Merge origin/main into ksh 2026-01-20 17:44:00 +09:00
SeongHyun Kim 0907d318eb fix: 수정 모드에서 채번 코드 재할당 방지
handleSave(): formData.id 체크로 수정 모드 판별, 기존 번호 유지
handleUniversalFormModalTableSectionSave(): formData.id 및 originalGroupedData 체크로 수정 모드 판별
신규 등록 시에만 allocateCode 호출하여 채번 코드 할당
입고관리 화면에서 수정 시 입고번호 증가 문제 해결
2026-01-20 17:05:36 +09:00
SeongHyun Kim c31b0540aa fix: 분할패널 연결 필터에 operator equals 누락으로 인한 조회 실패 수정 : 우측 패널에 연관 데이터(부서인원)가 조회되지 않던 문제 수정
- 재고이력에서 품번으로 출력안되는 문제 해결 : 엔티티 조인 쿼리 생성 시 동일한 컬럼 별칭이 중복 생성되어 SQL 에러 발생하던 문제 방지
2026-01-20 16:08:38 +09:00
SeongHyun Kim 585febfb52 make: RepeaterFieldGroup 컴포넌트
- 하위 데이터 조회 연동 방식 개선
- 필드 정의 레벨에서 subDataSource 설정 추가
- 필드별 숨김(isHidden) 옵션 추가
- 기존 fieldMappings 방식 제거, 필드별 연동으로 변경
_repeaterFieldsConfig 메타데이터로 설정 전달 : "이 필드들의 하위 조회 결과에서 값 가져와서 추가로 저장해줘"라는 주문서 역할
2026-01-19 18:58:23 +09:00
SeongHyun Kim b62a0b7e3b fix: 분할패널 화면 복구 2026-01-19 18:48:18 +09:00
SeongHyun Kim d4b5bdd835 feat: RepeaterInput 하위 데이터 조회 컬럼 설정 기능 개선
- 표시 컬럼 순서 변경 기능 추가 (columnOrder)
- 조회 컬럼 -> 저장 컬럼 매핑 기능 추가 (fieldMappings)
- 컬럼별 라벨, 순서, 저장 여부 통합 설정 UI 구현
- 하위 호환성 유지 (fieldMappings 없으면 기존 로직 사용)
2026-01-19 13:18:17 +09:00
SeongHyun Kim 0f9e91050e Merge branch 'main' into ksh 2026-01-16 15:48:36 +09:00
SeongHyun Kim e609f624bb Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into ksh 2026-01-15 17:05:13 +09:00
SeongHyun Kim 9d5e3f7bd6 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into ksh 2026-01-15 16:56:55 +09:00
SeongHyun Kim 6b9dc4e19d Merge branch 'main' into ksh 2026-01-15 16:56:18 +09:00
SeongHyun Kim de7fa7a71b fix: 발주/입고관리 그룹 편집 시 단건만 저장되던 문제 수정
EditModal.tsx: conditional-container 존재 시 onSave 미전달 로직 수정
ModalRepeaterTableComponent.tsx: groupedData prop 우선 사용하도록 변경
2026-01-15 14:36:00 +09:00
9 changed files with 904 additions and 131 deletions

View File

@ -334,6 +334,10 @@ export class EntityJoinService {
);
});
// 🔧 _label 별칭 중복 방지를 위한 Set
// 같은 sourceColumn에서 여러 조인 설정이 있을 때 _label은 첫 번째만 생성
const generatedLabelAliases = new Set<string>();
const joinColumns = joinConfigs
.map((config) => {
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
@ -368,16 +372,26 @@ export class EntityJoinService {
// _label 필드도 함께 SELECT (프론트엔드 getColumnUniqueValues용)
// sourceColumn_label 형식으로 추가
resultColumns.push(
`COALESCE(${alias}.${col}::TEXT, '') AS ${config.sourceColumn}_label`
);
// 🔧 중복 방지: 같은 sourceColumn에서 _label은 첫 번째만 생성
const labelAlias = `${config.sourceColumn}_label`;
if (!generatedLabelAliases.has(labelAlias)) {
resultColumns.push(
`COALESCE(${alias}.${col}::TEXT, '') AS ${labelAlias}`
);
generatedLabelAliases.add(labelAlias);
}
// 🆕 referenceColumn (PK)도 항상 SELECT (parentDataMapping용)
// 예: customer_code, item_number 등
// col과 동일해도 별도의 alias로 추가 (customer_code as customer_code)
resultColumns.push(
`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.referenceColumn}`
);
// 🔧 중복 방지: referenceColumn도 한 번만 추가
const refColAlias = config.referenceColumn;
if (!generatedLabelAliases.has(refColAlias)) {
resultColumns.push(
`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${refColAlias}`
);
generatedLabelAliases.add(refColAlias);
}
} else {
resultColumns.push(
`COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`
@ -392,6 +406,11 @@ export class EntityJoinService {
const individualAlias = `${config.sourceColumn}_${col}`;
// 🔧 중복 방지: 같은 alias가 이미 생성되었으면 스킵
if (generatedLabelAliases.has(individualAlias)) {
return;
}
if (isJoinTableColumn) {
// 조인 테이블 컬럼은 조인 별칭 사용
resultColumns.push(
@ -403,6 +422,7 @@ export class EntityJoinService {
`COALESCE(main.${col}::TEXT, '') AS ${individualAlias}`
);
}
generatedLabelAliases.add(individualAlias);
});
// 🆕 referenceColumn (PK)도 함께 SELECT (parentDataMapping용)
@ -410,11 +430,13 @@ export class EntityJoinService {
config.referenceTable && config.referenceTable !== tableName;
if (
isJoinTableColumn &&
!displayColumns.includes(config.referenceColumn)
!displayColumns.includes(config.referenceColumn) &&
!generatedLabelAliases.has(config.referenceColumn) // 🔧 중복 방지
) {
resultColumns.push(
`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.referenceColumn}`
);
generatedLabelAliases.add(config.referenceColumn);
}
}

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);
@ -893,6 +907,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
const renderGridLayout = () => {
// 하위 데이터 조회 설정이 있으면 연결 컬럼 찾기
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">
@ -905,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>}
@ -944,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>
@ -973,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"
>
@ -1002,6 +1020,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
const renderCardLayout = () => {
// 하위 데이터 조회 설정이 있으면 연결 컬럼 찾기
const linkColumn = subDataLookup?.lookup?.linkColumn;
// hidden이 아닌 필드만 표시
// isHidden이 true이거나 displayMode가 hidden인 필드는 제외 (하위 호환성 유지)
const visibleFields = fields.filter((f) => !f.isHidden && f.displayMode !== "hidden");
return (
<>
@ -1070,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

@ -319,6 +319,74 @@ 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 || [];
let newColumns: string[];
let newOrder: string[];
if (checked) {
newColumns = [...currentColumns, columnName];
newOrder = [...currentOrder, columnName];
} else {
newColumns = currentColumns.filter((c) => c !== columnName);
newOrder = currentOrder.filter((c) => c !== columnName);
}
// displayColumns, columnOrder 함께 업데이트
const newConfig = { ...config.subDataLookup } as SubDataLookupConfig;
if (!newConfig.lookup) {
newConfig.lookup = { tableName: "", linkColumn: "", displayColumns: [] };
}
newConfig.lookup.displayColumns = newColumns;
newConfig.lookup.columnOrder = newOrder;
onChange({
...config,
subDataLookup: newConfig,
});
};
return (
<div className="space-y-4">
{/* 대상 테이블 선택 */}
@ -588,7 +656,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 +673,78 @@ 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 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>
);
})}
</div>
<p className="text-[10px] text-purple-500">
* "하위 데이터 조회에서 값 가져오기"
</p>
</div>
)}
{/* 선택 설정 */}
{(config.subDataLookup?.lookup?.displayColumns?.length || 0) > 0 && (
<div className="space-y-3 border-t border-purple-200 pt-3">
@ -1351,35 +1491,106 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
{/* 카테고리 타입이 아닐 때만 표시 모드 선택 */}
{field.type !== "category" && (
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={field.displayMode || "input"}
onValueChange={(value) => updateField(index, { displayMode: value as any })}
>
<SelectTrigger className="h-8 w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="input"> ( )</SelectItem>
<SelectItem value="readonly"> ()</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={field.displayMode || "input"}
onValueChange={(value) => updateField(index, { displayMode: value as any })}
>
<SelectTrigger className="h-8 w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="input"> ( )</SelectItem>
<SelectItem value="readonly"> ()</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center space-x-4 pt-5">
<div className="flex items-center space-x-2">
<Checkbox
id={`required-${index}`}
checked={field.required ?? false}
onCheckedChange={(checked) => updateField(index, { required: checked as boolean })}
/>
<Label htmlFor={`required-${index}`} className="cursor-pointer text-xs font-normal">
</Label>
<div className="flex items-center space-x-4 pt-5">
<div className="flex items-center space-x-2">
<Checkbox
id={`required-${index}`}
checked={field.required ?? false}
onCheckedChange={(checked) => updateField(index, { required: checked as boolean })}
/>
<Label htmlFor={`required-${index}`} className="cursor-pointer text-xs font-normal">
</Label>
</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

@ -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

@ -33,6 +33,7 @@ import {
DialogDescription,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useTableOptions } from "@/contexts/TableOptionsContext";
import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-options";
import { useAuth } from "@/hooks/useAuth";
@ -171,6 +172,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const [rightSearchQuery, setRightSearchQuery] = useState("");
const [isLoadingLeft, setIsLoadingLeft] = useState(false);
const [isLoadingRight, setIsLoadingRight] = useState(false);
// 🆕 추가 탭 관련 상태
const [activeTabIndex, setActiveTabIndex] = useState(0); // 0 = 기본 탭 (우측 패널), 1+ = 추가 탭
const [tabsData, setTabsData] = useState<Record<number, any[]>>({}); // 탭별 데이터 캐시
const [tabsLoading, setTabsLoading] = useState<Record<number, boolean>>({}); // 탭별 로딩 상태
const [rightTableColumns, setRightTableColumns] = useState<any[]>([]); // 우측 테이블 컬럼 정보
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
const [leftColumnLabels, setLeftColumnLabels] = useState<Record<string, string>>({}); // 좌측 컬럼 라벨
@ -917,11 +924,11 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const { entityJoinApi } = await import("@/lib/api/entityJoin");
// 복합키 조건 생성
// 🔧 관계 필터링은 정확한 값 매칭이 필요하므로 equals 연산자 사용
// (entity 타입 컬럼의 경우 기본 contains 연산자가 참조 테이블의 표시 컬럼으로 검색하여 실패함)
// 🔧 entity 타입 컬럼은 코드 값으로 정확히 매칭해야 하므로 operator: 'equals' 사용
const searchConditions: Record<string, any> = {};
keys.forEach((key) => {
if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) {
// 연결 필터는 정확한 값 매칭이 필요하므로 equals 연산자 사용
searchConditions[key.rightColumn] = {
value: leftItem[key.leftColumn],
operator: "equals",
@ -1006,12 +1013,145 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
],
);
// 🆕 추가 탭 데이터 로딩 함수
const loadTabData = useCallback(
async (tabIndex: number, leftItem: any) => {
const tabConfig = componentConfig.rightPanel?.additionalTabs?.[tabIndex - 1];
if (!tabConfig || !leftItem || isDesignMode) return;
const tabTableName = tabConfig.tableName;
if (!tabTableName) return;
setTabsLoading((prev) => ({ ...prev, [tabIndex]: true }));
try {
// 조인 키 확인
const keys = tabConfig.relation?.keys;
const leftColumn = tabConfig.relation?.leftColumn || keys?.[0]?.leftColumn;
const rightColumn = tabConfig.relation?.foreignKey || keys?.[0]?.rightColumn;
let resultData: any[] = [];
if (leftColumn && rightColumn) {
// 조인 조건이 있는 경우
const { entityJoinApi } = await import("@/lib/api/entityJoin");
const searchConditions: Record<string, any> = {};
if (keys && keys.length > 0) {
// 복합키
// 🔧 entity 타입 컬럼은 코드 값으로 정확히 매칭해야 하므로 operator: 'equals' 사용
keys.forEach((key) => {
if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) {
searchConditions[key.rightColumn] = {
value: leftItem[key.leftColumn],
operator: "equals",
};
}
});
} else {
// 단일키
// 🔧 entity 타입 컬럼은 코드 값으로 정확히 매칭해야 하므로 operator: 'equals' 사용
const leftValue = leftItem[leftColumn];
if (leftValue !== undefined) {
searchConditions[rightColumn] = {
value: leftValue,
operator: "equals",
};
}
}
console.log(`🔗 [추가탭 ${tabIndex}] 조회 조건:`, searchConditions);
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, {
search: searchConditions,
enableEntityJoin: true,
size: 1000,
});
resultData = result.data || [];
} else {
// 조인 조건이 없는 경우: 전체 데이터 조회 (독립 탭)
const { entityJoinApi } = await import("@/lib/api/entityJoin");
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, {
enableEntityJoin: true,
size: 1000,
});
resultData = result.data || [];
}
// 데이터 필터 적용
const dataFilter = tabConfig.dataFilter;
if (dataFilter?.enabled && dataFilter.conditions?.length > 0) {
resultData = resultData.filter((item: any) => {
return dataFilter.conditions.every((cond: any) => {
const value = item[cond.column];
const condValue = cond.value;
switch (cond.operator) {
case "equals":
return value === condValue;
case "notEquals":
return value !== condValue;
case "contains":
return String(value).includes(String(condValue));
default:
return true;
}
});
});
}
// 중복 제거 적용
const deduplication = tabConfig.deduplication;
if (deduplication?.enabled && deduplication.groupByColumn) {
const groupedMap = new Map<string, any>();
resultData.forEach((item) => {
const key = String(item[deduplication.groupByColumn] || "");
const existing = groupedMap.get(key);
if (!existing) {
groupedMap.set(key, item);
} else {
// keepStrategy에 따라 유지할 항목 결정
const sortCol = deduplication.sortColumn || "start_date";
const existingVal = existing[sortCol];
const newVal = item[sortCol];
if (deduplication.keepStrategy === "latest" && newVal > existingVal) {
groupedMap.set(key, item);
} else if (deduplication.keepStrategy === "earliest" && newVal < existingVal) {
groupedMap.set(key, item);
}
}
});
resultData = Array.from(groupedMap.values());
}
console.log(`🔗 [추가탭 ${tabIndex}] 결과 데이터:`, resultData.length);
setTabsData((prev) => ({ ...prev, [tabIndex]: resultData }));
} catch (error) {
console.error(`추가탭 ${tabIndex} 데이터 로드 실패:`, error);
toast({
title: "데이터 로드 실패",
description: `탭 데이터를 불러올 수 없습니다.`,
variant: "destructive",
});
} finally {
setTabsLoading((prev) => ({ ...prev, [tabIndex]: false }));
}
},
[componentConfig.rightPanel?.additionalTabs, isDesignMode, toast],
);
// 좌측 항목 선택 핸들러
const handleLeftItemSelect = useCallback(
(item: any) => {
setSelectedLeftItem(item);
setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화
loadRightData(item);
setTabsData({}); // 🆕 모든 탭 데이터 초기화
// 🆕 현재 활성 탭에 따라 데이터 로드
if (activeTabIndex === 0) {
loadRightData(item);
} else {
loadTabData(activeTabIndex, item);
}
// 🆕 modalDataStore에 선택된 좌측 항목 저장 (단일 선택)
const leftTableName = componentConfig.leftPanel?.tableName;
@ -1022,7 +1162,30 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
});
}
},
[loadRightData, componentConfig.leftPanel?.tableName, isDesignMode],
[loadRightData, loadTabData, activeTabIndex, componentConfig.leftPanel?.tableName, isDesignMode],
);
// 🆕 탭 변경 핸들러
const handleTabChange = useCallback(
(newTabIndex: number) => {
setActiveTabIndex(newTabIndex);
// 선택된 좌측 항목이 있으면 해당 탭의 데이터 로드
if (selectedLeftItem) {
if (newTabIndex === 0) {
// 기본 탭: 우측 패널 데이터가 없으면 로드
if (!rightData || (Array.isArray(rightData) && rightData.length === 0)) {
loadRightData(selectedLeftItem);
}
} else {
// 추가 탭: 해당 탭 데이터가 없으면 로드
if (!tabsData[newTabIndex]) {
loadTabData(newTabIndex, selectedLeftItem);
}
}
}
},
[selectedLeftItem, rightData, tabsData, loadRightData, loadTabData],
);
// 우측 항목 확장/축소 토글
@ -1418,7 +1581,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 수정 버튼 핸들러
const handleEditClick = useCallback(
async (panel: "left" | "right", item: any) => {
(panel: "left" | "right", item: any) => {
// 🆕 우측 패널 수정 버튼 설정 확인
if (panel === "right" && componentConfig.rightPanel?.editButton?.mode === "modal") {
const modalScreenId = componentConfig.rightPanel?.editButton?.modalScreenId;
@ -1427,40 +1590,18 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 커스텀 모달 화면 열기
const rightTableName = componentConfig.rightPanel?.tableName || "";
// Primary Key 찾기: 테이블 메타데이터에서 실제 PK 컬럼 조회
// Primary Key 찾기 (우선순위: id > ID > 첫 번째 필드)
let primaryKeyName = "id";
let primaryKeyValue: any;
// 1. 테이블 컬럼 메타데이터에서 실제 PK 찾기
let pkColumn = rightTableColumns.find(
(col) => col.isPrimaryKey === true || col.is_primary_key === true || col.is_primary_key === "YES"
);
// 2. rightTableColumns가 비어있으면 API로 직접 조회
if (!pkColumn && rightTableColumns.length === 0 && rightTableName) {
try {
const columnsResponse = await tableTypeApi.getColumns(rightTableName);
pkColumn = columnsResponse?.find(
(col: any) => col.isPrimaryKey === true || col.is_primary_key === true || col.is_primary_key === "YES"
);
} catch (error) {
console.error("PK 컬럼 조회 실패:", error);
}
}
if (pkColumn) {
const pkName = pkColumn.columnName || pkColumn.column_name;
primaryKeyName = pkName;
primaryKeyValue = item[pkName];
} else if (item.id !== undefined && item.id !== null) {
// 3. 폴백: id 컬럼
if (item.id !== undefined && item.id !== null) {
primaryKeyName = "id";
primaryKeyValue = item.id;
} else if (item.ID !== undefined && item.ID !== null) {
primaryKeyName = "ID";
primaryKeyValue = item.ID;
} else {
// 4. 최후의 폴백: 첫 번째 필드
// 첫 번째 필드를 Primary Key로 간주
const firstKey = Object.keys(item)[0];
primaryKeyName = firstKey;
primaryKeyValue = item[firstKey];
@ -2561,6 +2702,34 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
className="flex flex-shrink-0 flex-col"
>
<Card className="flex flex-col border-0 shadow-none" style={{ height: "100%" }}>
{/* 🆕 탭 바 (추가 탭이 있을 때만 표시) */}
{(componentConfig.rightPanel?.additionalTabs?.length || 0) > 0 && (
<div className="flex-shrink-0 border-b">
<Tabs
value={String(activeTabIndex)}
onValueChange={(value) => handleTabChange(Number(value))}
className="w-full"
>
<TabsList className="h-9 w-full justify-start rounded-none border-b-0 bg-transparent p-0 px-2">
<TabsTrigger
value="0"
className="h-8 rounded-none border-b-2 border-transparent px-4 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
>
{componentConfig.rightPanel?.title || "기본"}
</TabsTrigger>
{componentConfig.rightPanel?.additionalTabs?.map((tab, index) => (
<TabsTrigger
key={tab.tabId}
value={String(index + 1)}
className="h-8 rounded-none border-b-2 border-transparent px-4 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
>
{tab.label || `${index + 1}`}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
)}
<CardHeader
className="flex-shrink-0 border-b"
style={{
@ -2573,16 +2742,28 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
>
<div className="flex w-full items-center justify-between">
<CardTitle className="text-base font-semibold">
{componentConfig.rightPanel?.title || "우측 패널"}
{activeTabIndex === 0
? componentConfig.rightPanel?.title || "우측 패널"
: componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]?.title ||
componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]?.label ||
"우측 패널"}
</CardTitle>
{!isDesignMode && (
<div className="flex items-center gap-2">
{componentConfig.rightPanel?.showAdd && (
<Button size="sm" variant="outline" onClick={() => handleAddClick("right")}>
<Plus className="mr-1 h-4 w-4" />
</Button>
)}
{/* 🆕 현재 활성 탭에 따른 추가 버튼 */}
{activeTabIndex === 0
? componentConfig.rightPanel?.showAdd && (
<Button size="sm" variant="outline" onClick={() => handleAddClick("right")}>
<Plus className="mr-1 h-4 w-4" />
</Button>
)
: componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]?.showAdd && (
<Button size="sm" variant="outline" onClick={() => handleAddClick("right")}>
<Plus className="mr-1 h-4 w-4" />
</Button>
)}
{/* 우측 패널 수정/삭제는 각 카드에서 처리 */}
</div>
)}
@ -2602,20 +2783,231 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</div>
)}
<CardContent className="flex-1 overflow-auto p-4">
{/* 우측 데이터 */}
{isLoadingRight ? (
// 로딩 중
<div className="flex h-full items-center justify-center">
<div className="text-center">
<Loader2 className="text-primary mx-auto h-8 w-8 animate-spin" />
<p className="text-muted-foreground mt-2 text-sm"> ...</p>
</div>
</div>
) : rightData ? (
// 실제 데이터 표시
Array.isArray(rightData) ? (
// 조인 모드: 여러 데이터를 테이블/리스트로 표시
(() => {
{/* 🆕 추가 탭 데이터 렌더링 */}
{activeTabIndex > 0 ? (
// 추가 탭 컨텐츠
(() => {
const currentTabConfig = componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1];
const currentTabData = tabsData[activeTabIndex] || [];
const isTabLoading = tabsLoading[activeTabIndex];
if (isTabLoading) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<Loader2 className="text-primary mx-auto h-8 w-8 animate-spin" />
<p className="text-muted-foreground mt-2 text-sm"> ...</p>
</div>
</div>
);
}
if (!selectedLeftItem) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm"> </p>
</div>
);
}
if (currentTabData.length === 0) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm"> </p>
</div>
);
}
// 탭 데이터 렌더링 (목록/테이블 모드)
const isTableMode = currentTabConfig?.displayMode === "table";
if (isTableMode) {
// 테이블 모드
const displayColumns = currentTabConfig?.columns || [];
const columnsToShow =
displayColumns.length > 0
? displayColumns.map((col) => ({
...col,
label: col.label || col.name,
}))
: Object.keys(currentTabData[0] || {})
.filter(shouldShowField)
.slice(0, 8)
.map((key) => ({ name: key, label: key }));
return (
<div className="overflow-auto rounded-lg border">
<table className="w-full text-sm">
<thead className="bg-muted/50 sticky top-0">
<tr>
{columnsToShow.map((col: any) => (
<th
key={col.name}
className="px-3 py-2 text-left font-medium"
style={{ width: col.width ? `${col.width}px` : "auto" }}
>
{col.label}
</th>
))}
{(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && (
<th className="w-20 px-3 py-2 text-center font-medium"></th>
)}
</tr>
</thead>
<tbody>
{currentTabData.map((item: any, idx: number) => (
<tr key={item.id || idx} className="hover:bg-muted/30 border-t">
{columnsToShow.map((col: any) => (
<td key={col.name} className="px-3 py-2">
{formatCellValue(col.name, item[col.name], {}, col.format)}
</td>
))}
{(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && (
<td className="px-3 py-2 text-center">
<div className="flex items-center justify-center gap-1">
{currentTabConfig?.showEdit && (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => handleEditClick("right", item)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
)}
{currentTabConfig?.showDelete && (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-red-500 hover:text-red-600"
onClick={() => handleDeleteClick("right", item)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
);
} else {
// 목록 (카드) 모드
const displayColumns = currentTabConfig?.columns || [];
const summaryCount = currentTabConfig?.summaryColumnCount ?? 3;
const showLabel = currentTabConfig?.summaryShowLabel ?? true;
return (
<div className="space-y-2">
{currentTabData.map((item: any, idx: number) => {
const itemId = item.id || idx;
const isExpanded = expandedRightItems.has(itemId);
// 표시할 컬럼 결정
const columnsToShow =
displayColumns.length > 0
? displayColumns
: Object.keys(item)
.filter(shouldShowField)
.slice(0, 8)
.map((key) => ({ name: key, label: key }));
const summaryColumns = columnsToShow.slice(0, summaryCount);
const detailColumns = columnsToShow.slice(summaryCount);
return (
<div key={itemId} className="rounded-lg border bg-white p-3">
<div
className="flex cursor-pointer items-start justify-between"
onClick={() => toggleRightItemExpansion(itemId)}
>
<div className="flex-1">
<div className="flex flex-wrap gap-x-4 gap-y-1">
{summaryColumns.map((col: any) => (
<div key={col.name} className="text-sm">
{showLabel && (
<span className="text-muted-foreground mr-1">{col.label}:</span>
)}
<span className={col.bold ? "font-semibold" : ""}>
{formatCellValue(col.name, item[col.name], {}, col.format)}
</span>
</div>
))}
</div>
</div>
<div className="ml-2 flex items-center gap-1">
{currentTabConfig?.showEdit && (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={(e) => {
e.stopPropagation();
handleEditClick("right", item);
}}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
)}
{currentTabConfig?.showDelete && (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-red-500 hover:text-red-600"
onClick={(e) => {
e.stopPropagation();
handleDeleteClick("right", item);
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
{detailColumns.length > 0 &&
(isExpanded ? (
<ChevronUp className="h-4 w-4 text-gray-400" />
) : (
<ChevronDown className="h-4 w-4 text-gray-400" />
))}
</div>
</div>
{isExpanded && detailColumns.length > 0 && (
<div className="mt-2 border-t pt-2">
<div className="grid grid-cols-2 gap-2">
{detailColumns.map((col: any) => (
<div key={col.name} className="text-sm">
<span className="text-muted-foreground">{col.label}:</span>
<span className="ml-1">{formatCellValue(col.name, item[col.name], {}, col.format)}</span>
</div>
))}
</div>
</div>
)}
</div>
);
})}
</div>
);
}
})()
) : (
/* 기본 탭 (우측 패널) 데이터 */
<>
{isLoadingRight ? (
// 로딩 중
<div className="flex h-full items-center justify-center">
<div className="text-center">
<Loader2 className="text-primary mx-auto h-8 w-8 animate-spin" />
<p className="text-muted-foreground mt-2 text-sm"> ...</p>
</div>
</div>
) : rightData ? (
// 실제 데이터 표시
Array.isArray(rightData) ? (
// 조인 모드: 여러 데이터를 테이블/리스트로 표시
(() => {
// 검색 필터링
const filteredData = rightSearchQuery
? rightData.filter((item) => {
@ -3045,14 +3437,16 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</div>
</div>
</div>
) : (
// 선택 없음
<div className="flex h-full items-center justify-center">
<div className="text-muted-foreground text-center text-sm">
<p className="mb-2"> </p>
<p className="text-xs"> </p>
</div>
</div>
) : (
// 선택 없음
<div className="flex h-full items-center justify-center">
<div className="text-muted-foreground text-center text-sm">
<p className="mb-2"> </p>
<p className="text-xs"> </p>
</div>
</div>
)}
</>
)}
</CardContent>
</Card>

View File

@ -707,12 +707,19 @@ export class ButtonActionExecutor {
if (repeaterJsonKeys.length > 0) {
console.log("🔄 [handleSave] RepeaterFieldGroup JSON 문자열 감지:", repeaterJsonKeys);
// 🎯 채번 규칙 할당 처리 (RepeaterFieldGroup 저장 전에 실행)
console.log("🔍 [handleSave-RepeaterFieldGroup] 채번 규칙 할당 체크 시작");
// 🔧 수정 모드 체크: formData.id가 존재하면 UPDATE 모드이므로 채번 코드 재할당 금지
const isEditModeRepeater =
context.formData.id !== undefined && context.formData.id !== null && context.formData.id !== "";
console.log("🔍 [handleSave-RepeaterFieldGroup] 채번 규칙 할당 체크 시작", {
isEditMode: isEditModeRepeater,
formDataId: context.formData.id,
});
const fieldsWithNumberingRepeater: Record<string, string> = {};
// formData에서 채번 규칙이 설정된 필드 찾기
for (const [key, value] of Object.entries(context.formData)) {
if (key.endsWith("_numberingRuleId") && value) {
@ -721,22 +728,27 @@ export class ButtonActionExecutor {
console.log(`🎯 [handleSave-RepeaterFieldGroup] 채번 필드 발견: ${fieldName} → 규칙 ${value}`);
}
}
console.log("📋 [handleSave-RepeaterFieldGroup] 채번 규칙이 설정된 필드:", fieldsWithNumberingRepeater);
// 채번 규칙이 있는 필드에 대해 allocateCode 호출
if (Object.keys(fieldsWithNumberingRepeater).length > 0) {
console.log("🎯 [handleSave-RepeaterFieldGroup] 채번 규칙 할당 시작 (allocateCode 호출)");
// 🔧 수정 모드에서는 채번 코드 할당 건너뛰기 (기존 번호 유지)
// 신규 등록 모드에서만 allocateCode 호출하여 새 번호 할당
if (Object.keys(fieldsWithNumberingRepeater).length > 0 && !isEditModeRepeater) {
console.log(
"🎯 [handleSave-RepeaterFieldGroup] 신규 등록 모드 - 채번 규칙 할당 시작 (allocateCode 호출)",
);
const { allocateNumberingCode } = await import("@/lib/api/numberingRule");
for (const [fieldName, ruleId] of Object.entries(fieldsWithNumberingRepeater)) {
try {
console.log(`🔄 [handleSave-RepeaterFieldGroup] ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}`);
const allocateResult = await allocateNumberingCode(ruleId);
if (allocateResult.success && allocateResult.data?.generatedCode) {
const newCode = allocateResult.data.generatedCode;
console.log(`✅ [handleSave-RepeaterFieldGroup] ${fieldName} 새 코드 할당: ${context.formData[fieldName]}${newCode}`);
console.log(
`✅ [handleSave-RepeaterFieldGroup] ${fieldName} 새 코드 할당: ${context.formData[fieldName]}${newCode}`,
);
context.formData[fieldName] = newCode;
} else {
console.warn(`⚠️ [handleSave-RepeaterFieldGroup] ${fieldName} 코드 할당 실패:`, allocateResult.error);
@ -745,9 +757,11 @@ export class ButtonActionExecutor {
console.error(`❌ [handleSave-RepeaterFieldGroup] ${fieldName} 코드 할당 오류:`, allocateError);
}
}
} else if (isEditModeRepeater) {
console.log("⏭️ [handleSave-RepeaterFieldGroup] 수정 모드 - 채번 코드 할당 건너뜀 (기존 번호 유지)");
}
console.log("✅ [handleSave-RepeaterFieldGroup] 채번 규칙 할당 완료");
console.log("✅ [handleSave-RepeaterFieldGroup] 채번 규칙 할당 처리 완료");
// 🆕 상단 폼 데이터(마스터 정보) 추출
// RepeaterFieldGroup JSON과 컴포넌트 키를 제외한 나머지가 마스터 정보
@ -803,7 +817,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> = {};
@ -812,6 +826,42 @@ export class ButtonActionExecutor {
itemOnlyData[field] = itemData[field];
}
});
// 🆕 하위 데이터 선택에서 값 추출 (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: 상단 폼에서 수정한 최신 마스터 정보
@ -1915,7 +1965,16 @@ export class ButtonActionExecutor {
}
// 🎯 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가)
console.log("🔍 [handleUniversalFormModalTableSectionSave] 채번 규칙 할당 체크 시작");
// 🔧 수정 모드 체크: formData.id 또는 originalGroupedData가 있으면 UPDATE 모드
const isEditModeUniversal =
(formData.id !== undefined && formData.id !== null && formData.id !== "") ||
originalGroupedData.length > 0;
console.log("🔍 [handleUniversalFormModalTableSectionSave] 채번 규칙 할당 체크 시작", {
isEditMode: isEditModeUniversal,
formDataId: formData.id,
originalGroupedDataCount: originalGroupedData.length,
});
const fieldsWithNumbering: Record<string, string> = {};
@ -1941,9 +2000,12 @@ export class ButtonActionExecutor {
console.log("📋 [handleUniversalFormModalTableSectionSave] 채번 규칙이 설정된 필드:", fieldsWithNumbering);
// 🔥 저장 시점에 allocateCode 호출하여 실제 순번 증가
if (Object.keys(fieldsWithNumbering).length > 0) {
console.log("🎯 [handleUniversalFormModalTableSectionSave] 채번 규칙 할당 시작 (allocateCode 호출)");
// 🔧 수정 모드에서는 채번 코드 할당 건너뛰기 (기존 번호 유지)
// 신규 등록 모드에서만 allocateCode 호출하여 새 번호 할당
if (Object.keys(fieldsWithNumbering).length > 0 && !isEditModeUniversal) {
console.log(
"🎯 [handleUniversalFormModalTableSectionSave] 신규 등록 모드 - 채번 규칙 할당 시작 (allocateCode 호출)",
);
const { allocateNumberingCode } = await import("@/lib/api/numberingRule");
for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) {
@ -1970,6 +2032,8 @@ export class ButtonActionExecutor {
// 오류 시 기존 값 유지
}
}
} else if (isEditModeUniversal) {
console.log("⏭️ [handleUniversalFormModalTableSectionSave] 수정 모드 - 채번 코드 할당 건너뜀 (기존 번호 유지)");
}
console.log("✅ [handleUniversalFormModalTableSectionSave] 채번 규칙 할당 완료");

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?: {
@ -113,6 +125,14 @@ export type RepeaterData = RepeaterItemData[];
// 품목 선택 시 재고/단가 등 관련 데이터를 조회하고 선택하는 기능
// ============================================================
/**
*
*/
export interface SubDataFieldMapping {
sourceColumn: string; // 조회 테이블 컬럼 (예: lot_number)
targetField: string; // 저장 테이블 컬럼 (예: lot_number) 또는 "" (선택안함)
}
/**
*
*/
@ -121,6 +141,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>; // 추가 필터 조건
}