창고 렉 구조 등록 컴포넌트 중복 방지기능 추가

This commit is contained in:
kjs
2025-12-08 17:13:14 +09:00
parent ec65ad6b9e
commit 5609e6353f
10 changed files with 1027 additions and 193 deletions

View File

@@ -167,6 +167,29 @@ export async function reorderCategoryValues(orderedValueIds: number[]) {
}
}
/**
* 카테고리 코드로 라벨 조회
*
* @param valueCodes - 카테고리 코드 배열 (예: ["CATEGORY_767659DCUF", "CATEGORY_8292565608"])
* @returns { [code]: label } 형태의 매핑 객체
*/
export async function getCategoryLabelsByCodes(valueCodes: string[]) {
try {
if (!valueCodes || valueCodes.length === 0) {
return { success: true, data: {} };
}
const response = await apiClient.post<{
success: boolean;
data: Record<string, string>;
}>("/table-categories/labels-by-codes", { valueCodes });
return response.data;
} catch (error: any) {
console.error("카테고리 라벨 조회 실패:", error);
return { success: false, error: error.message, data: {} };
}
}
// ================================================
// 컬럼 매핑 관련 API (논리명 ↔ 물리명)
// ================================================

View File

@@ -23,6 +23,8 @@ import {
import { ScrollArea } from "@/components/ui/scroll-area";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { cn } from "@/lib/utils";
import { getCategoryLabelsByCodes } from "@/lib/api/tableCategoryValue";
import { DynamicFormApi } from "@/lib/api/dynamicForm";
import {
RackStructureComponentProps,
RackLineCondition,
@@ -31,6 +33,13 @@ import {
RackStructureContext,
} from "./types";
// 기존 위치 데이터 타입
interface ExistingLocation {
row_num: string;
level_num: string;
location_code: string;
}
// 고유 ID 생성
const generateId = () => `cond_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
@@ -185,6 +194,7 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
onChange,
onConditionsChange,
isPreview = false,
tableName,
}) => {
// 조건 목록
const [conditions, setConditions] = useState<RackLineCondition[]>(
@@ -200,6 +210,11 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
// 미리보기 데이터
const [previewData, setPreviewData] = useState<GeneratedLocation[]>([]);
const [isPreviewGenerated, setIsPreviewGenerated] = useState(false);
// 기존 데이터 중복 체크 관련 상태
const [existingLocations, setExistingLocations] = useState<ExistingLocation[]>([]);
const [isCheckingDuplicates, setIsCheckingDuplicates] = useState(false);
const [duplicateErrors, setDuplicateErrors] = useState<{ row: number; existingLevels: number[] }[]>([]);
// 설정값
const maxConditions = config.maxConditions || 10;
@@ -208,6 +223,60 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
const readonly = config.readonly || isPreview;
const fieldMapping = config.fieldMapping || {};
// 카테고리 라벨 캐시 상태
const [categoryLabels, setCategoryLabels] = useState<Record<string, string>>({});
// 카테고리 코드인지 확인
const isCategoryCode = (value: string | undefined): boolean => {
return typeof value === "string" && value.startsWith("CATEGORY_");
};
// 카테고리 라벨 조회 (비동기)
useEffect(() => {
const loadCategoryLabels = async () => {
if (!formData) return;
// 카테고리 코드인 값들만 수집
const valuesToLookup: string[] = [];
const fieldsToCheck = [
fieldMapping.floorField ? formData[fieldMapping.floorField] : undefined,
fieldMapping.zoneField ? formData[fieldMapping.zoneField] : undefined,
fieldMapping.locationTypeField ? formData[fieldMapping.locationTypeField] : undefined,
fieldMapping.statusField ? formData[fieldMapping.statusField] : undefined,
];
for (const value of fieldsToCheck) {
if (value && isCategoryCode(value) && !categoryLabels[value]) {
valuesToLookup.push(value);
}
}
if (valuesToLookup.length === 0) return;
try {
// 카테고리 코드로 라벨 일괄 조회
const response = await getCategoryLabelsByCodes(valuesToLookup);
if (response.success && response.data) {
console.log("✅ 카테고리 라벨 조회 완료:", response.data);
setCategoryLabels((prev) => ({ ...prev, ...response.data }));
}
} catch (error) {
console.error("카테고리 라벨 조회 실패:", error);
}
};
loadCategoryLabels();
}, [formData, fieldMapping]);
// 카테고리 코드를 라벨로 변환하는 헬퍼 함수
const getCategoryLabel = useCallback((value: string | undefined): string | undefined => {
if (!value) return undefined;
if (isCategoryCode(value)) {
return categoryLabels[value] || value;
}
return value;
}, [categoryLabels]);
// 필드 매핑을 통해 formData에서 컨텍스트 추출
const context: RackStructureContext = useMemo(() => {
// propContext가 있으면 우선 사용
@@ -216,27 +285,33 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
// formData와 fieldMapping을 사용하여 컨텍스트 생성
if (!formData) return {};
return {
const rawFloor = fieldMapping.floorField ? formData[fieldMapping.floorField] : undefined;
const rawZone = fieldMapping.zoneField ? formData[fieldMapping.zoneField] : undefined;
const rawLocationType = fieldMapping.locationTypeField ? formData[fieldMapping.locationTypeField] : undefined;
const rawStatus = fieldMapping.statusField ? formData[fieldMapping.statusField] : undefined;
const ctx = {
warehouseCode: fieldMapping.warehouseCodeField
? formData[fieldMapping.warehouseCodeField]
: undefined,
warehouseName: fieldMapping.warehouseNameField
? formData[fieldMapping.warehouseNameField]
: undefined,
floor: fieldMapping.floorField
? formData[fieldMapping.floorField]?.toString()
: undefined,
zone: fieldMapping.zoneField
? formData[fieldMapping.zoneField]
: undefined,
locationType: fieldMapping.locationTypeField
? formData[fieldMapping.locationTypeField]
: undefined,
status: fieldMapping.statusField
? formData[fieldMapping.statusField]
: undefined,
// 카테고리 값은 라벨로 변환
floor: getCategoryLabel(rawFloor?.toString()),
zone: getCategoryLabel(rawZone),
locationType: getCategoryLabel(rawLocationType),
status: getCategoryLabel(rawStatus),
};
}, [propContext, formData, fieldMapping]);
console.log("🏗️ [RackStructure] context 생성:", {
fieldMapping,
rawValues: { rawFloor, rawZone, rawLocationType, rawStatus },
context: ctx,
});
return ctx;
}, [propContext, formData, fieldMapping, getCategoryLabel]);
// 필수 필드 검증
const missingFields = useMemo(() => {
@@ -283,6 +358,154 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
setConditions((prev) => prev.filter((cond) => cond.id !== id));
}, []);
// 열 범위 중복 검사
const rowOverlapErrors = useMemo(() => {
const errors: { conditionIndex: number; overlappingWith: number; overlappingRows: number[] }[] = [];
for (let i = 0; i < conditions.length; i++) {
const cond1 = conditions[i];
if (cond1.startRow <= 0 || cond1.endRow < cond1.startRow) continue;
for (let j = i + 1; j < conditions.length; j++) {
const cond2 = conditions[j];
if (cond2.startRow <= 0 || cond2.endRow < cond2.startRow) continue;
// 범위 겹침 확인
const overlapStart = Math.max(cond1.startRow, cond2.startRow);
const overlapEnd = Math.min(cond1.endRow, cond2.endRow);
if (overlapStart <= overlapEnd) {
// 겹치는 열 목록
const overlappingRows: number[] = [];
for (let r = overlapStart; r <= overlapEnd; r++) {
overlappingRows.push(r);
}
errors.push({
conditionIndex: i,
overlappingWith: j,
overlappingRows,
});
}
}
}
return errors;
}, [conditions]);
// 중복 열이 있는지 확인
const hasRowOverlap = rowOverlapErrors.length > 0;
// 기존 데이터 조회를 위한 값 추출 (useMemo 객체 참조 문제 방지)
const warehouseCodeForQuery = context.warehouseCode;
const floorForQuery = context.floor;
const zoneForQuery = context.zone;
// 기존 데이터 조회 (창고/층/구역이 변경될 때마다)
useEffect(() => {
const loadExistingLocations = async () => {
console.log("🏗️ [RackStructure] 기존 데이터 조회 체크:", {
warehouseCode: warehouseCodeForQuery,
floor: floorForQuery,
zone: zoneForQuery,
});
// 필수 조건이 충족되지 않으면 기존 데이터 초기화
if (!warehouseCodeForQuery || !floorForQuery || !zoneForQuery) {
console.log("⚠️ [RackStructure] 필수 조건 미충족 - 조회 스킵");
setExistingLocations([]);
setDuplicateErrors([]);
return;
}
setIsCheckingDuplicates(true);
try {
// warehouse_location 테이블에서 해당 창고/층/구역의 기존 데이터 조회
const filterParams = {
warehouse_id: warehouseCodeForQuery,
floor: floorForQuery,
zone: zoneForQuery,
};
console.log("🔍 기존 위치 데이터 조회 시작:", filterParams);
const response = await DynamicFormApi.getTableData("warehouse_location", {
filters: filterParams,
page: 1,
pageSize: 1000, // 충분히 큰 값
});
console.log("🔍 기존 위치 데이터 응답:", response);
// API 응답 구조: { success: true, data: [...] } 또는 { success: true, data: { data: [...] } }
const dataArray = Array.isArray(response.data)
? response.data
: (response.data?.data || []);
if (response.success && dataArray.length > 0) {
const existing = dataArray.map((item: any) => ({
row_num: item.row_num,
level_num: item.level_num,
location_code: item.location_code,
}));
setExistingLocations(existing);
console.log("✅ 기존 위치 데이터 조회 완료:", existing.length, "개", existing);
} else {
console.log("⚠️ 기존 위치 데이터 없음 또는 조회 실패");
setExistingLocations([]);
}
} catch (error) {
console.error("기존 위치 데이터 조회 실패:", error);
setExistingLocations([]);
} finally {
setIsCheckingDuplicates(false);
}
};
loadExistingLocations();
}, [warehouseCodeForQuery, floorForQuery, zoneForQuery]);
// 조건 변경 시 기존 데이터와 중복 체크
useEffect(() => {
if (existingLocations.length === 0) {
setDuplicateErrors([]);
return;
}
// 현재 조건에서 생성될 열 목록
const plannedRows = new Map<number, number[]>(); // row -> levels
conditions.forEach((cond) => {
if (cond.startRow > 0 && cond.endRow >= cond.startRow && cond.levels > 0) {
for (let row = cond.startRow; row <= cond.endRow; row++) {
const levels: number[] = [];
for (let level = 1; level <= cond.levels; level++) {
levels.push(level);
}
plannedRows.set(row, levels);
}
}
});
// 기존 데이터와 중복 체크
const errors: { row: number; existingLevels: number[] }[] = [];
plannedRows.forEach((levels, row) => {
const existingForRow = existingLocations.filter(
(loc) => parseInt(loc.row_num) === row
);
if (existingForRow.length > 0) {
const existingLevels = existingForRow.map((loc) => parseInt(loc.level_num));
const duplicateLevels = levels.filter((l) => existingLevels.includes(l));
if (duplicateLevels.length > 0) {
errors.push({ row, existingLevels: duplicateLevels });
}
}
});
setDuplicateErrors(errors);
}, [conditions, existingLocations]);
// 기존 데이터와 중복이 있는지 확인
const hasDuplicateWithExisting = duplicateErrors.length > 0;
// 통계 계산
const statistics = useMemo(() => {
let totalLocations = 0;
@@ -312,11 +535,12 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
const floor = context?.floor || "1";
const zone = context?.zone || "A";
// 코드 생성 (예: WH001-1A-01-1)
// 코드 생성 (예: WH001-1층D구역-01-1)
const code = `${warehouseCode}-${floor}${zone}-${row.toString().padStart(2, "0")}-${level}`;
// 이름 생성 (예: A구역-01열-1단)
const name = `${zone}구역-${row.toString().padStart(2, "0")}열-${level}`;
// 이름 생성 - zone에 이미 "구역"이 포함되어 있으면 그대로 사용
const zoneName = zone.includes("구역") ? zone : `${zone}구역`;
const name = `${zoneName}-${row.toString().padStart(2, "0")}열-${level}`;
return { code, name };
},
@@ -325,12 +549,39 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
// 미리보기 생성
const generatePreview = useCallback(() => {
console.log("🔍 [generatePreview] 검증 시작:", {
missingFields,
hasRowOverlap,
hasDuplicateWithExisting,
duplicateErrorsCount: duplicateErrors.length,
existingLocationsCount: existingLocations.length,
});
// 필수 필드 검증
if (missingFields.length > 0) {
alert(`다음 필드를 먼저 입력해주세요: ${missingFields.join(", ")}`);
return;
}
// 열 범위 중복 검증
if (hasRowOverlap) {
const overlapInfo = rowOverlapErrors.map((err) => {
const rows = err.overlappingRows.join(", ");
return `조건 ${err.conditionIndex + 1}과 조건 ${err.overlappingWith + 1}${rows}`;
}).join("\n");
alert(`열 범위가 중복됩니다:\n${overlapInfo}\n\n중복된 열을 수정해주세요.`);
return;
}
// 기존 데이터와 중복 검증 - duplicateErrors 직접 체크
if (duplicateErrors.length > 0) {
const duplicateInfo = duplicateErrors.map((err) => {
return `${err.row}${err.existingLevels.join(", ")}`;
}).join(", ");
alert(`이미 등록된 위치가 있습니다:\n${duplicateInfo}\n\n해당 열/단을 제외하고 등록하거나, 기존 데이터를 삭제해주세요.`);
return;
}
const locations: GeneratedLocation[] = [];
conditions.forEach((cond) => {
@@ -338,15 +589,17 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
for (let row = cond.startRow; row <= cond.endRow; row++) {
for (let level = 1; level <= cond.levels; level++) {
const { code, name } = generateLocationCode(row, level);
// 테이블 컬럼명과 동일하게 생성
locations.push({
rowNum: row,
levelNum: level,
locationCode: code,
locationName: name,
locationType: context?.locationType || "선반",
row_num: String(row),
level_num: String(level),
location_code: code,
location_name: name,
location_type: context?.locationType || "선반",
status: context?.status || "사용",
// 추가 필드
warehouseCode: context?.warehouseCode,
// 추가 필드 (테이블 컬럼명과 동일)
warehouse_id: context?.warehouseCode,
warehouse_name: context?.warehouseName,
floor: context?.floor,
zone: context?.zone,
});
@@ -357,14 +610,14 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
// 정렬: 열 -> 단 순서
locations.sort((a, b) => {
if (a.rowNum !== b.rowNum) return a.rowNum - b.rowNum;
return a.levelNum - b.levelNum;
if (a.row_num !== b.row_num) return parseInt(a.row_num) - parseInt(b.row_num);
return parseInt(a.level_num) - parseInt(b.level_num);
});
setPreviewData(locations);
setIsPreviewGenerated(true);
onChange?.(locations);
}, [conditions, context, generateLocationCode, onChange, missingFields]);
}, [conditions, context, generateLocationCode, onChange, missingFields, hasRowOverlap, duplicateErrors, existingLocations, rowOverlapErrors]);
// 템플릿 저장
const saveTemplate = useCallback(() => {
@@ -448,6 +701,66 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
</Alert>
)}
{/* 열 범위 중복 경고 */}
{hasRowOverlap && (
<Alert variant="destructive" className="mb-4">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
<strong> !</strong>
<ul className="mt-1 list-inside list-disc text-xs">
{rowOverlapErrors.map((err, idx) => (
<li key={idx}>
{err.conditionIndex + 1} {err.overlappingWith + 1}: {err.overlappingRows.join(", ")}
</li>
))}
</ul>
<span className="mt-1 block text-xs">
.
</span>
</AlertDescription>
</Alert>
)}
{/* 기존 데이터 중복 경고 */}
{hasDuplicateWithExisting && (
<Alert variant="destructive" className="mb-4">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
<strong> !</strong>
<ul className="mt-1 list-inside list-disc text-xs">
{duplicateErrors.map((err, idx) => (
<li key={idx}>
{err.row}: {err.existingLevels.join(", ")} ( )
</li>
))}
</ul>
<span className="mt-1 block text-xs">
/ .
</span>
</AlertDescription>
</Alert>
)}
{/* 기존 데이터 로딩 중 표시 */}
{isCheckingDuplicates && (
<Alert className="mb-4">
<AlertCircle className="h-4 w-4 animate-spin" />
<AlertDescription>
...
</AlertDescription>
</Alert>
)}
{/* 기존 데이터 존재 알림 */}
{!isCheckingDuplicates && existingLocations.length > 0 && !hasDuplicateWithExisting && (
<Alert className="mb-4 border-blue-200 bg-blue-50">
<AlertCircle className="h-4 w-4 text-blue-600" />
<AlertDescription className="text-blue-800">
// <strong>{existingLocations.length}</strong> .
</AlertDescription>
</Alert>
)}
{/* 현재 매핑된 값 표시 */}
{(context.warehouseCode || context.warehouseName || context.floor || context.zone) && (
<div className="mb-4 flex flex-wrap gap-2 rounded-lg bg-gray-50 p-3">
@@ -548,10 +861,11 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
variant="outline"
size="sm"
onClick={generatePreview}
disabled={hasDuplicateWithExisting || hasRowOverlap || missingFields.length > 0 || isCheckingDuplicates}
className="h-8 gap-1"
>
<RefreshCw className="h-4 w-4" />
{isCheckingDuplicates ? "확인 중..." : hasDuplicateWithExisting ? "중복 있음" : "미리보기 생성"}
</Button>
</CardHeader>
<CardContent>
@@ -595,15 +909,15 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
{previewData.map((loc, idx) => (
<TableRow key={idx}>
<TableCell className="text-center">{idx + 1}</TableCell>
<TableCell className="font-mono">{loc.locationCode}</TableCell>
<TableCell>{loc.locationName}</TableCell>
<TableCell className="text-center">{context?.floor || "1"}</TableCell>
<TableCell className="text-center">{context?.zone || "A"}</TableCell>
<TableCell className="font-mono">{loc.location_code}</TableCell>
<TableCell>{loc.location_name}</TableCell>
<TableCell className="text-center">{loc.floor || context?.floor || "1"}</TableCell>
<TableCell className="text-center">{loc.zone || context?.zone || "A"}</TableCell>
<TableCell className="text-center">
{loc.rowNum.toString().padStart(2, "0")}
{loc.row_num.padStart(2, "0")}
</TableCell>
<TableCell className="text-center">{loc.levelNum}</TableCell>
<TableCell className="text-center">{loc.locationType}</TableCell>
<TableCell className="text-center">{loc.level_num}</TableCell>
<TableCell className="text-center">{loc.location_type}</TableCell>
<TableCell className="text-center">-</TableCell>
</TableRow>
))}

View File

@@ -14,24 +14,40 @@ export class RackStructureRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = RackStructureDefinition;
render(): React.ReactElement {
const { formData, isPreview, config } = this.props as any;
const { formData, isPreview, config, tableName, onFormDataChange } = this.props as Record<string, unknown>;
return (
<RackStructureComponent
config={config || {}}
formData={formData} // formData 전달 (필드 매핑에서 사용)
onChange={this.handleLocationsChange}
isPreview={isPreview}
config={(config as object) || {}}
formData={formData as Record<string, unknown>}
tableName={tableName as string}
onChange={(locations) =>
this.handleLocationsChange(
locations,
onFormDataChange as ((fieldName: string, value: unknown) => void) | undefined,
)
}
isPreview={isPreview as boolean}
/>
);
}
/**
* 생성된 위치 데이터 변경 핸들러
* formData에 _rackStructureLocations 키로 저장하여 저장 액션에서 감지
*/
protected handleLocationsChange = (locations: GeneratedLocation[]) => {
// 생성된 위치 데이터를 formData에 저장
protected handleLocationsChange = (
locations: GeneratedLocation[],
onFormDataChange?: (fieldName: string, value: unknown) => void,
) => {
// 생성된 위치 데이터를 컴포넌트에 저장
this.updateComponent({ generatedLocations: locations });
// formData에도 저장하여 저장 액션에서 감지할 수 있도록 함
if (onFormDataChange) {
console.log("📦 [RackStructure] 미리보기 데이터를 formData에 저장:", locations.length, "개");
onFormDataChange("_rackStructureLocations", locations);
}
};
}

View File

@@ -18,18 +18,19 @@ export interface RackStructureTemplate {
createdAt?: string;
}
// 생성될 위치 데이터
// 생성될 위치 데이터 (테이블 컬럼명과 동일하게 매핑)
export interface GeneratedLocation {
rowNum: number; // 열 번호
levelNum: number; // 단 번호
locationCode: string; // 위치 코드 (예: WH001-1A-01-1)
locationName: string; // 위치명 (예: A구역-01열-1단)
locationType?: string; // 위치 유형
status?: string; // 사용 여부
row_num: string; // 열 번호 (varchar)
level_num: string; // 단 번호 (varchar)
location_code: string; // 위치 코드 (예: WH001-1A-01-1)
location_name: string; // 위치명 (예: A구역-01열-1단)
location_type?: string; // 위치 유형
status?: string; // 사용 여부
// 추가 필드 (상위 폼에서 매핑된 값)
warehouseCode?: string;
floor?: string;
zone?: string;
warehouse_id?: string; // 창고 ID/코드
warehouse_name?: string; // 창고명
floor?: string; // 층
zone?: string; // 구역
}
// 필드 매핑 설정 (상위 폼의 어떤 필드를 사용할지)

File diff suppressed because it is too large Load Diff