Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node

This commit is contained in:
kmh
2026-02-26 16:51:12 +09:00
9 changed files with 915 additions and 31 deletions

View File

@@ -0,0 +1,50 @@
"use client";
import { useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
import { apiClient } from "@/lib/api/client";
/**
* /screen/COMPANY_7_167 → /screens/4153 리다이렉트
* 메뉴 URL이 screenCode 기반이므로, screenId로 변환 후 이동
*/
export default function ScreenCodeRedirectPage() {
const params = useParams();
const router = useRouter();
const screenCode = params.screenCode as string;
useEffect(() => {
if (!screenCode) return;
const numericId = parseInt(screenCode);
if (!isNaN(numericId)) {
router.replace(`/screens/${numericId}`);
return;
}
const resolve = async () => {
try {
const res = await apiClient.get("/screen-management/screens", {
params: { screenCode },
});
const screens = res.data?.data || [];
if (screens.length > 0) {
const id = screens[0].screenId || screens[0].screen_id;
router.replace(`/screens/${id}`);
} else {
router.replace("/");
}
} catch {
router.replace("/");
}
};
resolve();
}, [screenCode, router]);
return (
<div className="flex h-full items-center justify-center">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
);
}

View File

@@ -93,9 +93,12 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
[config, component.config, component.id],
);
// 소스 테이블의 키 필드명 (기본값: "item_id" → 하위 호환)
// 예: item_info 기반이면 "item_id", customer_mng 기반이면 "customer_id"
const sourceKeyField = componentConfig.sourceKeyField || "item_id";
// 소스 테이블의 키 필드명
// 우선순위: 1) config에서 명시적 설정 → 2) additionalFields에서 autoFillFrom:"id" 필드 감지 → 3) 하위 호환 "item_id"
const sourceKeyField = useMemo(() => {
// sourceKeyField는 config에서 직접 지정 (ConfigPanel 자동 감지에서 설정됨)
return componentConfig.sourceKeyField || "item_id";
}, [componentConfig.sourceKeyField]);
// 🆕 dataSourceId 우선순위: URL 파라미터 > 컴포넌트 설정 > component.id
const dataSourceId = useMemo(
@@ -472,10 +475,16 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
if (allGroupsEmpty) {
// 디테일 데이터가 없어도 기본 레코드 생성 (품목-거래처 매핑 유지)
// autoFillFrom 필드 (item_id 등)는 반드시 포함시켜야 나중에 식별 가능
const baseRecord: Record<string, any> = {};
// sourceKeyField 자동 매핑 (item_id = originalData.id)
if (sourceKeyField && item.originalData?.id) {
baseRecord[sourceKeyField] = item.originalData.id;
}
// 나머지 autoFillFrom 필드 (sourceKeyField 제외)
additionalFields.forEach((f) => {
if (f.autoFillFrom && item.originalData) {
if (f.name !== sourceKeyField && f.autoFillFrom && item.originalData) {
const value = item.originalData[f.autoFillFrom];
if (value !== undefined && value !== null) {
baseRecord[f.name] = value;
@@ -530,7 +539,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
return allRecords;
},
[componentConfig.fieldGroups, componentConfig.additionalFields],
[componentConfig.fieldGroups, componentConfig.additionalFields, sourceKeyField],
);
// 🆕 저장 요청 시에만 데이터 전달 (이벤트 리스너 방식)
@@ -677,27 +686,14 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
for (const item of items) {
// sourceKeyField 값 추출 (예: item_id 또는 customer_id)
// (수정 모드에서 autoFillFrom:"id"가 가격 레코드 PK를 반환하는 문제 방지)
let sourceKeyValue: string | null = null;
// 1순위: originalData에 sourceKeyField가 직접 있으면 사용 (수정 모드에서 정확한 값)
// 1순위: originalData에 sourceKeyField가 직접 있으면 사용 (수정 모드)
if (item.originalData && item.originalData[sourceKeyField]) {
sourceKeyValue = item.originalData[sourceKeyField];
}
// 2순위: autoFillFrom 로직 (신규 등록 모드에서 사용)
if (!sourceKeyValue) {
mainGroups.forEach((group) => {
const groupFields = additionalFields.filter((f) => f.groupId === group.id);
groupFields.forEach((field) => {
if (field.name === sourceKeyField && field.autoFillFrom && item.originalData) {
sourceKeyValue = item.originalData[field.autoFillFrom] || null;
}
});
});
}
// 3순위: fallback (최후의 수단)
// 2순위: 원본 데이터의 id를 sourceKeyField 값으로 사용 (신규 등록 모드)
if (!sourceKeyValue && item.originalData) {
sourceKeyValue = item.originalData.id || null;
}

View File

@@ -1,6 +1,6 @@
"use client";
import React, { useState, useMemo, useEffect } from "react";
import React, { useState, useMemo, useEffect, useRef } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
@@ -10,7 +10,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Card, CardContent } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Plus, X, ChevronDown, ChevronRight } from "lucide-react";
import { SelectedItemsDetailInputConfig, AdditionalFieldDefinition, FieldGroup, DisplayItem, DisplayItemType, EmptyBehavior, DisplayFieldFormat } from "./types";
import { SelectedItemsDetailInputConfig, AdditionalFieldDefinition, FieldGroup, DisplayItem, DisplayItemType, EmptyBehavior, DisplayFieldFormat, AutoDetectedFk } from "./types";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Check, ChevronsUpDown } from "lucide-react";
@@ -97,7 +97,10 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
// 🆕 원본/대상 테이블 컬럼 상태 (내부에서 로드)
const [loadedSourceTableColumns, setLoadedSourceTableColumns] = useState<Array<{ columnName: string; columnLabel?: string; dataType?: string; inputType?: string }>>([]);
const [loadedTargetTableColumns, setLoadedTargetTableColumns] = useState<Array<{ columnName: string; columnLabel?: string; dataType?: string; inputType?: string; codeCategory?: string }>>([]);
const [loadedTargetTableColumns, setLoadedTargetTableColumns] = useState<Array<{ columnName: string; columnLabel?: string; dataType?: string; inputType?: string; codeCategory?: string; referenceTable?: string; referenceColumn?: string }>>([]);
// FK 자동 감지 결과
const [autoDetectedFks, setAutoDetectedFks] = useState<AutoDetectedFk[]>([]);
// 🆕 원본 테이블 컬럼 로드
useEffect(() => {
@@ -130,10 +133,11 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
loadColumns();
}, [config.sourceTable]);
// 🆕 대상 테이블 컬럼 로드
// 🆕 대상 테이블 컬럼 로드 (referenceTable/referenceColumn 포함)
useEffect(() => {
if (!config.targetTable) {
setLoadedTargetTableColumns([]);
setAutoDetectedFks([]);
return;
}
@@ -149,7 +153,10 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
columnName: col.columnName,
columnLabel: col.displayName || col.columnLabel || col.columnName,
dataType: col.dataType,
inputType: col.inputType, // 🔧 inputType 추가
inputType: col.inputType,
codeCategory: col.codeCategory,
referenceTable: col.referenceTable,
referenceColumn: col.referenceColumn,
})));
console.log("✅ 대상 테이블 컬럼 로드 성공:", columns.length);
}
@@ -161,6 +168,76 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
loadColumns();
}, [config.targetTable]);
// FK 자동 감지 (ref로 무한 루프 방지)
const fkAutoAppliedRef = useRef(false);
// targetTable 컬럼이 로드되면 entity FK 컬럼 감지
const detectedFks = useMemo<AutoDetectedFk[]>(() => {
if (!config.targetTable || loadedTargetTableColumns.length === 0) return [];
const entityFkColumns = loadedTargetTableColumns.filter(
(col) => col.inputType === "entity" && col.referenceTable
);
if (entityFkColumns.length === 0) return [];
return entityFkColumns.map((col) => {
let mappingType: "source" | "parent" | "unknown" = "unknown";
if (config.sourceTable && col.referenceTable === config.sourceTable) {
mappingType = "source";
} else if (config.sourceTable && col.referenceTable !== config.sourceTable) {
mappingType = "parent";
}
return {
columnName: col.columnName,
columnLabel: col.columnLabel,
referenceTable: col.referenceTable!,
referenceColumn: col.referenceColumn || "id",
mappingType,
};
});
}, [config.targetTable, config.sourceTable, loadedTargetTableColumns]);
// 감지 결과를 state에 반영
useEffect(() => {
setAutoDetectedFks(detectedFks);
}, [detectedFks]);
// 자동 매핑 적용 (최초 1회만, targetTable 변경 시 리셋)
useEffect(() => {
fkAutoAppliedRef.current = false;
}, [config.targetTable]);
useEffect(() => {
if (fkAutoAppliedRef.current || detectedFks.length === 0) return;
const sourceFk = detectedFks.find((fk) => fk.mappingType === "source");
const parentFks = detectedFks.filter((fk) => fk.mappingType === "parent");
let changed = false;
// sourceKeyField 자동 설정
if (sourceFk && !config.sourceKeyField) {
console.log("🔗 sourceKeyField 자동 설정:", sourceFk.columnName);
handleChange("sourceKeyField", sourceFk.columnName);
changed = true;
}
// parentDataMapping 자동 생성 (기존에 없을 때만)
if (parentFks.length > 0 && (!config.parentDataMapping || config.parentDataMapping.length === 0)) {
const autoMappings = parentFks.map((fk) => ({
sourceTable: fk.referenceTable,
sourceField: "id",
targetField: fk.columnName,
}));
console.log("🔗 parentDataMapping 자동 생성:", autoMappings);
handleChange("parentDataMapping", autoMappings);
changed = true;
}
if (changed) {
fkAutoAppliedRef.current = true;
}
}, [detectedFks]);
// 🆕 필드 그룹 변경 시 로컬 입력 상태 동기화
useEffect(() => {
setLocalFieldGroups(config.fieldGroups || []);
@@ -898,6 +975,37 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
<p className="text-[10px] text-gray-500 sm:text-xs"> </p>
</div>
{/* FK 자동 감지 결과 표시 */}
{autoDetectedFks.length > 0 && (
<div className="rounded-md border border-blue-200 bg-blue-50 p-3 dark:border-blue-800 dark:bg-blue-950">
<p className="mb-2 text-xs font-medium text-blue-700 dark:text-blue-300">
FK ({autoDetectedFks.length})
</p>
<div className="space-y-1">
{autoDetectedFks.map((fk) => (
<div key={fk.columnName} className="flex items-center gap-2 text-[10px] sm:text-xs">
<span className={cn(
"rounded px-1.5 py-0.5 font-mono text-[9px]",
fk.mappingType === "source"
? "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300"
: fk.mappingType === "parent"
? "bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300"
: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
)}>
{fk.mappingType === "source" ? "원본" : fk.mappingType === "parent" ? "부모" : "미분류"}
</span>
<span className="font-mono text-muted-foreground">{fk.columnName}</span>
<span className="text-muted-foreground">-&gt;</span>
<span className="font-mono">{fk.referenceTable}</span>
</div>
))}
</div>
<p className="mt-2 text-[9px] text-blue-600 dark:text-blue-400">
. sourceKeyField와 parentDataMapping이 .
</p>
</div>
)}
{/* 표시할 원본 데이터 컬럼 */}
<div className="space-y-2">
<Label className="text-xs font-semibold sm:text-sm"> </Label>
@@ -961,7 +1069,8 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
<div className="space-y-2">
<Label className="text-xs font-semibold sm:text-sm"> </Label>
{localFields.map((field, index) => (
{localFields.map((field, index) => {
return (
<Card key={index} className="border-2">
<CardContent className="space-y-2 pt-3 sm:space-y-3 sm:pt-4">
<div className="flex items-center justify-between">
@@ -1255,7 +1364,8 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
</div>
</CardContent>
</Card>
))}
);
})}
<Button
type="button"
@@ -2392,9 +2502,18 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
</p>
<div className="space-y-2">
{(config.parentDataMapping || []).map((mapping, index) => (
<Card key={index} className="p-3">
{(config.parentDataMapping || []).map((mapping, index) => {
const isAutoDetected = autoDetectedFks.some(
(fk) => fk.mappingType === "parent" && fk.columnName === mapping.targetField
);
return (
<Card key={index} className={cn("p-3", isAutoDetected && "border-orange-200 bg-orange-50/30 dark:border-orange-800 dark:bg-orange-950/30")}>
<div className="space-y-2">
{isAutoDetected && (
<span className="inline-block rounded bg-orange-100 px-1.5 py-0.5 text-[9px] font-medium text-orange-700 dark:bg-orange-900 dark:text-orange-300">
FK
</span>
)}
{/* 소스 테이블 선택 */}
<div className="space-y-1">
<Label className="text-[9px] sm:text-[10px]"> </Label>
@@ -2637,7 +2756,8 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
</div>
</div>
</Card>
))}
);
})}
</div>
</div>

View File

@@ -139,6 +139,23 @@ export interface ParentDataMapping {
defaultValue?: any;
}
/**
* 자동 감지된 FK 매핑 정보
* table_type_columns의 entity 설정을 기반으로 자동 감지
*/
export interface AutoDetectedFk {
/** 대상 테이블의 FK 컬럼명 (예: item_id, customer_id) */
columnName: string;
/** 컬럼 라벨 (예: 품목 ID) */
columnLabel?: string;
/** 참조 테이블명 (예: item_info, customer_mng) */
referenceTable: string;
/** 참조 컬럼명 (예: item_number, customer_code) */
referenceColumn: string;
/** 매핑 유형: source(원본 데이터 FK) 또는 parent(부모 화면 FK) */
mappingType: "source" | "parent" | "unknown";
}
/**
* SelectedItemsDetailInput 컴포넌트 설정 타입
*/
@@ -155,6 +172,13 @@ export interface SelectedItemsDetailInputConfig extends ComponentConfig {
*/
sourceTable?: string;
/**
* 원본 데이터의 키 필드명 (대상 테이블에서 원본을 참조하는 FK 컬럼)
* 예: item_info 기반이면 "item_id", customer_mng 기반이면 "customer_id"
* 미설정 시 엔티티 설정에서 자동 감지
*/
sourceKeyField?: string;
/**
* 표시할 원본 데이터 컬럼들 (name, label, width)
* 원본 데이터 테이블의 컬럼을 표시