1070 lines
40 KiB
TypeScript
1070 lines
40 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useCallback, useMemo, useEffect } from "react";
|
|
import { Plus, X, Save, FolderOpen, RefreshCw, Eye, AlertCircle } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
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 { apiClient } from "@/lib/api/client";
|
|
import {
|
|
RackStructureComponentProps,
|
|
RackLineCondition,
|
|
RackStructureTemplate,
|
|
GeneratedLocation,
|
|
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)}`;
|
|
|
|
// 조건 카드 컴포넌트
|
|
interface ConditionCardProps {
|
|
condition: RackLineCondition;
|
|
index: number;
|
|
onUpdate: (id: string, updates: Partial<RackLineCondition>) => void;
|
|
onRemove: (id: string) => void;
|
|
maxRows: number;
|
|
maxLevels: number;
|
|
readonly?: boolean;
|
|
}
|
|
|
|
const ConditionCard: React.FC<ConditionCardProps> = ({
|
|
condition,
|
|
index,
|
|
onUpdate,
|
|
onRemove,
|
|
maxRows,
|
|
maxLevels,
|
|
readonly,
|
|
}) => {
|
|
// 로컬 상태로 입력값 관리
|
|
const [localValues, setLocalValues] = useState({
|
|
startRow: condition.startRow.toString(),
|
|
endRow: condition.endRow.toString(),
|
|
levels: condition.levels.toString(),
|
|
});
|
|
|
|
// condition이 변경되면 로컬 상태 동기화
|
|
useEffect(() => {
|
|
setLocalValues({
|
|
startRow: condition.startRow.toString(),
|
|
endRow: condition.endRow.toString(),
|
|
levels: condition.levels.toString(),
|
|
});
|
|
}, [condition.startRow, condition.endRow, condition.levels]);
|
|
|
|
// 계산된 위치 수
|
|
const locationCount = useMemo(() => {
|
|
const start = parseInt(localValues.startRow) || 0;
|
|
const end = parseInt(localValues.endRow) || 0;
|
|
const levels = parseInt(localValues.levels) || 0;
|
|
if (start > 0 && end >= start && levels > 0) {
|
|
return (end - start + 1) * levels;
|
|
}
|
|
return 0;
|
|
}, [localValues]);
|
|
|
|
// 입력값 변경 핸들러
|
|
const handleChange = (field: keyof typeof localValues, value: string) => {
|
|
setLocalValues((prev) => ({ ...prev, [field]: value }));
|
|
};
|
|
|
|
// blur 시 실제 업데이트
|
|
const handleBlur = (field: keyof typeof localValues) => {
|
|
const numValue = parseInt(localValues[field]) || 0;
|
|
const clampedValue = Math.max(0, Math.min(numValue, field === "levels" ? maxLevels : maxRows));
|
|
|
|
setLocalValues((prev) => ({ ...prev, [field]: clampedValue.toString() }));
|
|
|
|
const updateField = field === "startRow" ? "startRow" : field === "endRow" ? "endRow" : "levels";
|
|
onUpdate(condition.id, { [updateField]: clampedValue });
|
|
};
|
|
|
|
return (
|
|
<div className="relative rounded-lg border border-gray-200 bg-white shadow-sm">
|
|
{/* 헤더 */}
|
|
<div className="flex items-center justify-between rounded-t-lg bg-blue-600 px-4 py-2 text-white">
|
|
<span className="font-medium">조건 {index + 1}</span>
|
|
{!readonly && (
|
|
<button
|
|
onClick={() => onRemove(condition.id)}
|
|
className="rounded p-1 transition-colors hover:bg-blue-700"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* 내용 */}
|
|
<div className="space-y-4 p-4">
|
|
{/* 열 범위 */}
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex-1">
|
|
<label className="mb-1 block text-xs font-medium text-gray-700">
|
|
열 범위 <span className="text-red-500">*</span>
|
|
</label>
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
max={maxRows}
|
|
value={localValues.startRow}
|
|
onChange={(e) => handleChange("startRow", e.target.value)}
|
|
onBlur={() => handleBlur("startRow")}
|
|
disabled={readonly}
|
|
className="h-9 text-center"
|
|
/>
|
|
<span className="text-gray-500">~</span>
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
max={maxRows}
|
|
value={localValues.endRow}
|
|
onChange={(e) => handleChange("endRow", e.target.value)}
|
|
onBlur={() => handleBlur("endRow")}
|
|
disabled={readonly}
|
|
className="h-9 text-center"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="w-20">
|
|
<label className="mb-1 block text-xs font-medium text-gray-700">
|
|
단 수 <span className="text-red-500">*</span>
|
|
</label>
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
max={maxLevels}
|
|
value={localValues.levels}
|
|
onChange={(e) => handleChange("levels", e.target.value)}
|
|
onBlur={() => handleBlur("levels")}
|
|
disabled={readonly}
|
|
className="h-9 text-center"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 계산 결과 */}
|
|
<div className="rounded-md bg-blue-50 px-3 py-2 text-center text-sm text-blue-700">
|
|
{locationCount > 0 ? (
|
|
<>
|
|
{localValues.startRow}열 ~ {localValues.endRow}열 x {localValues.levels}단 ={" "}
|
|
<strong>{locationCount}개</strong>
|
|
</>
|
|
) : (
|
|
<span className="text-gray-500">값을 입력하세요</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// 메인 컴포넌트
|
|
export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
|
config,
|
|
context: propContext,
|
|
formData,
|
|
onChange,
|
|
onConditionsChange,
|
|
isPreview = false,
|
|
tableName,
|
|
}) => {
|
|
// 조건 목록
|
|
const [conditions, setConditions] = useState<RackLineCondition[]>(
|
|
config.initialConditions || []
|
|
);
|
|
|
|
// 템플릿 관련 상태
|
|
const [templates, setTemplates] = useState<RackStructureTemplate[]>([]);
|
|
const [isTemplateDialogOpen, setIsTemplateDialogOpen] = useState(false);
|
|
const [templateName, setTemplateName] = useState("");
|
|
const [isSaveMode, setIsSaveMode] = useState(false);
|
|
|
|
// 미리보기 데이터
|
|
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;
|
|
const maxRows = config.maxRows || 99;
|
|
const maxLevels = config.maxLevels || 20;
|
|
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가 있으면 우선 사용
|
|
if (propContext) return propContext;
|
|
|
|
// formData와 fieldMapping을 사용하여 컨텍스트 생성
|
|
if (!formData) 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: getCategoryLabel(rawFloor?.toString()),
|
|
zone: getCategoryLabel(rawZone),
|
|
locationType: getCategoryLabel(rawLocationType),
|
|
status: getCategoryLabel(rawStatus),
|
|
// 카테고리 코드 원본값 (DB 쿼리/저장용)
|
|
floorCode: rawFloor?.toString(),
|
|
zoneCode: rawZone?.toString(),
|
|
locationTypeCode: rawLocationType?.toString(),
|
|
statusCode: rawStatus?.toString(),
|
|
};
|
|
|
|
console.log("🏗️ [RackStructure] context 생성:", {
|
|
fieldMapping,
|
|
rawValues: { rawFloor, rawZone, rawLocationType, rawStatus },
|
|
context: ctx,
|
|
});
|
|
|
|
return ctx;
|
|
}, [propContext, formData, fieldMapping, getCategoryLabel]);
|
|
|
|
// 필수 필드 검증
|
|
const missingFields = useMemo(() => {
|
|
const missing: string[] = [];
|
|
if (!context.warehouseCode) missing.push("창고 코드");
|
|
if (!context.floor) missing.push("층");
|
|
if (!context.zone) missing.push("구역");
|
|
return missing;
|
|
}, [context]);
|
|
|
|
// 조건 변경 시 콜백 호출
|
|
useEffect(() => {
|
|
onConditionsChange?.(conditions);
|
|
setIsPreviewGenerated(false); // 조건 변경 시 미리보기 초기화
|
|
}, [conditions, onConditionsChange]);
|
|
|
|
// 조건 추가
|
|
const addCondition = useCallback(() => {
|
|
if (conditions.length >= maxConditions) return;
|
|
|
|
// 마지막 조건의 다음 열부터 시작
|
|
const lastCondition = conditions[conditions.length - 1];
|
|
const startRow = lastCondition ? lastCondition.endRow + 1 : 1;
|
|
|
|
const newCondition: RackLineCondition = {
|
|
id: generateId(),
|
|
startRow,
|
|
endRow: startRow + 2,
|
|
levels: 3,
|
|
};
|
|
|
|
setConditions((prev) => [...prev, newCondition]);
|
|
}, [conditions, maxConditions]);
|
|
|
|
// 조건 업데이트
|
|
const updateCondition = useCallback((id: string, updates: Partial<RackLineCondition>) => {
|
|
setConditions((prev) =>
|
|
prev.map((cond) => (cond.id === id ? { ...cond, ...updates } : cond))
|
|
);
|
|
}, []);
|
|
|
|
// 조건 삭제
|
|
const removeCondition = useCallback((id: string) => {
|
|
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;
|
|
// DB 쿼리 시에는 카테고리 코드 사용 (코드로 통일)
|
|
const floorForQuery = (context as any).floorCode || context.floor;
|
|
const zoneForQuery = (context as any).zoneCode || context.zone;
|
|
// 화면 표시용 라벨
|
|
const floorLabel = context.floor;
|
|
const zoneLabel = context.zone;
|
|
|
|
// 기존 데이터 조회 (창고/층/구역이 변경될 때마다)
|
|
useEffect(() => {
|
|
const loadExistingLocations = async () => {
|
|
console.log("🏗️ [RackStructure] 기존 데이터 조회 체크:", {
|
|
warehouseCode: warehouseCodeForQuery,
|
|
floor: floorForQuery,
|
|
zone: zoneForQuery,
|
|
});
|
|
|
|
// 필수 조건이 충족되지 않으면 기존 데이터 초기화
|
|
// DB에는 라벨 값(예: "1층", "A구역")으로 저장되어 있으므로 라벨 값 사용
|
|
if (!warehouseCodeForQuery || !floorForQuery || !zoneForQuery) {
|
|
console.log("⚠️ [RackStructure] 필수 조건 미충족 - 조회 스킵");
|
|
setExistingLocations([]);
|
|
setDuplicateErrors([]);
|
|
return;
|
|
}
|
|
|
|
setIsCheckingDuplicates(true);
|
|
try {
|
|
// warehouse_location 테이블에서 해당 창고/층/구역의 기존 데이터 조회
|
|
// DB에는 라벨 값으로 저장되어 있으므로 라벨 값으로 필터링
|
|
// equals 연산자를 사용하여 정확한 일치 검색 (ILIKE가 아닌 = 연산자 사용)
|
|
const searchParams = {
|
|
warehouse_code: { value: warehouseCodeForQuery, operator: "equals" },
|
|
floor: { value: floorForQuery, operator: "equals" },
|
|
zone: { value: zoneForQuery, operator: "equals" },
|
|
};
|
|
console.log("🔍 기존 위치 데이터 조회 시작 (정확한 일치):", searchParams);
|
|
|
|
// 직접 apiClient 사용하여 정확한 형식으로 요청
|
|
// 백엔드는 search를 객체로 받아서 각 필드를 WHERE 조건으로 처리
|
|
const response = await apiClient.post(`/table-management/tables/warehouse_location/data`, {
|
|
page: 1,
|
|
size: 1000, // 충분히 큰 값
|
|
search: searchParams, // 백엔드가 기대하는 형식 (equals 연산자로 정확한 일치)
|
|
});
|
|
|
|
console.log("🔍 기존 위치 데이터 응답:", response.data);
|
|
|
|
// API 응답 구조: { success: true, data: { data: [...], total, ... } }
|
|
const responseData = response.data?.data || response.data;
|
|
const dataArray = Array.isArray(responseData)
|
|
? responseData
|
|
: (responseData?.data || []);
|
|
|
|
if (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;
|
|
let totalRows = 0;
|
|
let maxLevel = 0;
|
|
const rowSet = new Set<number>();
|
|
|
|
conditions.forEach((cond) => {
|
|
if (cond.startRow > 0 && cond.endRow >= cond.startRow && cond.levels > 0) {
|
|
const rowCount = cond.endRow - cond.startRow + 1;
|
|
totalLocations += rowCount * cond.levels;
|
|
for (let r = cond.startRow; r <= cond.endRow; r++) {
|
|
rowSet.add(r);
|
|
}
|
|
maxLevel = Math.max(maxLevel, cond.levels);
|
|
}
|
|
});
|
|
|
|
totalRows = rowSet.size;
|
|
return { totalLocations, totalRows, maxLevel };
|
|
}, [conditions]);
|
|
|
|
// 위치 코드 생성
|
|
const generateLocationCode = useCallback(
|
|
(row: number, level: number): { code: string; name: string } => {
|
|
const warehouseCode = context?.warehouseCode || "WH001";
|
|
const floor = context?.floor || "1";
|
|
const zone = context?.zone || "A";
|
|
|
|
// 코드 생성 (예: WH001-1층D구역-01-1)
|
|
const code = `${warehouseCode}-${floor}${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 };
|
|
},
|
|
[context]
|
|
);
|
|
|
|
// 미리보기 생성
|
|
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) => {
|
|
if (cond.startRow > 0 && cond.endRow >= cond.startRow && cond.levels > 0) {
|
|
for (let row = cond.startRow; row <= cond.endRow; row++) {
|
|
for (let level = 1; level <= cond.levels; level++) {
|
|
const { code, name } = generateLocationCode(row, level);
|
|
// 테이블 컬럼명과 동일하게 생성
|
|
// DB 저장 시에는 카테고리 코드 사용 (코드로 통일)
|
|
const ctxAny = context as any;
|
|
locations.push({
|
|
row_num: String(row),
|
|
level_num: String(level),
|
|
location_code: code,
|
|
location_name: name,
|
|
location_type: ctxAny?.locationTypeCode || context?.locationType || "선반",
|
|
status: ctxAny?.statusCode || context?.status || "사용",
|
|
// 추가 필드 (테이블 컬럼명과 동일) - 카테고리 코드 사용
|
|
warehouse_code: context?.warehouseCode,
|
|
warehouse_name: context?.warehouseName,
|
|
floor: ctxAny?.floorCode || context?.floor,
|
|
zone: ctxAny?.zoneCode || context?.zone,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// 정렬: 열 -> 단 순서
|
|
locations.sort((a, b) => {
|
|
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);
|
|
|
|
console.log("🏗️ [RackStructure] 생성된 위치 데이터:", {
|
|
locationsCount: locations.length,
|
|
firstLocation: locations[0],
|
|
context: {
|
|
warehouseCode: context?.warehouseCode,
|
|
warehouseName: context?.warehouseName,
|
|
floor: context?.floor,
|
|
zone: context?.zone,
|
|
},
|
|
});
|
|
|
|
onChange?.(locations);
|
|
}, [conditions, context, generateLocationCode, onChange, missingFields, hasRowOverlap, duplicateErrors, existingLocations, rowOverlapErrors]);
|
|
|
|
// 템플릿 저장
|
|
const saveTemplate = useCallback(() => {
|
|
if (!templateName.trim()) return;
|
|
|
|
const newTemplate: RackStructureTemplate = {
|
|
id: generateId(),
|
|
name: templateName.trim(),
|
|
conditions: [...conditions],
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
|
|
setTemplates((prev) => [...prev, newTemplate]);
|
|
setTemplateName("");
|
|
setIsTemplateDialogOpen(false);
|
|
}, [templateName, conditions]);
|
|
|
|
// 템플릿 불러오기
|
|
const loadTemplate = useCallback((template: RackStructureTemplate) => {
|
|
setConditions(template.conditions.map((c) => ({ ...c, id: generateId() })));
|
|
setIsTemplateDialogOpen(false);
|
|
}, []);
|
|
|
|
// 템플릿 삭제
|
|
const deleteTemplate = useCallback((templateId: string) => {
|
|
setTemplates((prev) => prev.filter((t) => t.id !== templateId));
|
|
}, []);
|
|
|
|
return (
|
|
<div className="flex h-full flex-col gap-6">
|
|
{/* 렉 라인 구조 설정 섹션 */}
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<div className="h-4 w-1 rounded bg-gradient-to-b from-green-500 to-blue-500" />
|
|
렉 라인 구조 설정
|
|
</CardTitle>
|
|
{!readonly && (
|
|
<div className="flex items-center gap-2">
|
|
{config.showTemplates && (
|
|
<>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
setIsSaveMode(false);
|
|
setIsTemplateDialogOpen(true);
|
|
}}
|
|
className="h-8 gap-1"
|
|
>
|
|
<FolderOpen className="h-4 w-4" />
|
|
템플릿
|
|
</Button>
|
|
</>
|
|
)}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={addCondition}
|
|
disabled={conditions.length >= maxConditions}
|
|
className="h-8 gap-1"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
조건 추가
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</CardHeader>
|
|
<CardContent>
|
|
{/* 필수 필드 경고 */}
|
|
{missingFields.length > 0 && (
|
|
<Alert variant="destructive" className="mb-4">
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertDescription>
|
|
다음 필드를 먼저 입력해주세요: <strong>{missingFields.join(", ")}</strong>
|
|
<br />
|
|
<span className="text-xs">
|
|
(설정 패널에서 필드 매핑을 확인하세요)
|
|
</span>
|
|
</AlertDescription>
|
|
</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">
|
|
{(context.warehouseCode || context.warehouseName) && (
|
|
<span className="rounded bg-blue-100 px-2 py-1 text-xs text-blue-800">
|
|
창고: {context.warehouseName || context.warehouseCode}
|
|
{context.warehouseName && context.warehouseCode && ` (${context.warehouseCode})`}
|
|
</span>
|
|
)}
|
|
{context.floor && (
|
|
<span className="rounded bg-green-100 px-2 py-1 text-xs text-green-800">
|
|
층: {context.floor}
|
|
</span>
|
|
)}
|
|
{context.zone && (
|
|
<span className="rounded bg-purple-100 px-2 py-1 text-xs text-purple-800">
|
|
구역: {context.zone}
|
|
</span>
|
|
)}
|
|
{context.locationType && (
|
|
<span className="rounded bg-orange-100 px-2 py-1 text-xs text-orange-800">
|
|
유형: {context.locationType}
|
|
</span>
|
|
)}
|
|
{context.status && (
|
|
<span className="rounded bg-gray-200 px-2 py-1 text-xs text-gray-800">
|
|
상태: {context.status}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 안내 메시지 */}
|
|
<div className="mb-4 rounded-lg bg-blue-50 p-4">
|
|
<ol className="space-y-1 text-sm text-blue-800">
|
|
<li className="flex items-start gap-2">
|
|
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-blue-600 text-xs font-bold text-white">
|
|
1
|
|
</span>
|
|
조건 추가 버튼을 클릭하여 렉 라인 조건을 생성하세요
|
|
</li>
|
|
<li className="flex items-start gap-2">
|
|
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-blue-600 text-xs font-bold text-white">
|
|
2
|
|
</span>
|
|
각 조건마다 열 범위와 단 수를 입력하세요
|
|
</li>
|
|
<li className="flex items-start gap-2">
|
|
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-blue-600 text-xs font-bold text-white">
|
|
3
|
|
</span>
|
|
예시: 조건1(1~3열, 3단), 조건2(4~6열, 5단)
|
|
</li>
|
|
</ol>
|
|
</div>
|
|
|
|
{/* 조건 목록 또는 빈 상태 */}
|
|
{conditions.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-200 py-12">
|
|
<div className="mb-4 text-6xl text-gray-300">📦</div>
|
|
<p className="mb-4 text-gray-500">조건을 추가하여 렉 구조를 설정하세요</p>
|
|
{!readonly && (
|
|
<Button onClick={addCondition} className="gap-1">
|
|
<Plus className="h-4 w-4" />
|
|
첫 번째 조건 추가
|
|
</Button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-wrap gap-4">
|
|
{conditions.map((condition, index) => (
|
|
<div key={condition.id} className="w-[280px]">
|
|
<ConditionCard
|
|
condition={condition}
|
|
index={index}
|
|
onUpdate={updateCondition}
|
|
onRemove={removeCondition}
|
|
maxRows={maxRows}
|
|
maxLevels={maxLevels}
|
|
readonly={readonly}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 등록 미리보기 섹션 */}
|
|
{config.showPreview && conditions.length > 0 && (
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<Eye className="h-4 w-4 text-amber-600" />
|
|
등록 미리보기
|
|
</CardTitle>
|
|
<Button
|
|
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>
|
|
{/* 통계 카드 */}
|
|
{config.showStatistics && (
|
|
<div className="mb-4 grid grid-cols-3 gap-4">
|
|
<div className="rounded-lg border bg-white p-4 text-center">
|
|
<div className="text-sm text-gray-500">총 위치</div>
|
|
<div className="text-2xl font-bold">{statistics.totalLocations}개</div>
|
|
</div>
|
|
<div className="rounded-lg border bg-white p-4 text-center">
|
|
<div className="text-sm text-gray-500">열 수</div>
|
|
<div className="text-2xl font-bold">{statistics.totalRows}개</div>
|
|
</div>
|
|
<div className="rounded-lg border bg-white p-4 text-center">
|
|
<div className="text-sm text-gray-500">최대 단</div>
|
|
<div className="text-2xl font-bold">{statistics.maxLevel}단</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 미리보기 테이블 */}
|
|
{isPreviewGenerated && previewData.length > 0 ? (
|
|
<div className="rounded-lg border">
|
|
<ScrollArea className="h-[400px]">
|
|
<Table>
|
|
<TableHeader className="sticky top-0 bg-gray-50">
|
|
<TableRow>
|
|
<TableHead className="w-12 text-center">No</TableHead>
|
|
<TableHead>위치코드</TableHead>
|
|
<TableHead>위치명</TableHead>
|
|
<TableHead className="w-16 text-center">층</TableHead>
|
|
<TableHead className="w-16 text-center">구역</TableHead>
|
|
<TableHead className="w-16 text-center">열</TableHead>
|
|
<TableHead className="w-16 text-center">단</TableHead>
|
|
<TableHead className="w-20 text-center">유형</TableHead>
|
|
<TableHead className="w-20 text-center">비고</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{previewData.map((loc, idx) => (
|
|
<TableRow key={idx}>
|
|
<TableCell className="text-center">{idx + 1}</TableCell>
|
|
<TableCell className="font-mono">{loc.location_code}</TableCell>
|
|
<TableCell>{loc.location_name}</TableCell>
|
|
{/* 미리보기에서는 카테고리 코드를 라벨로 변환하여 표시 */}
|
|
<TableCell className="text-center">{getCategoryLabel(loc.floor) || context?.floor || "1"}</TableCell>
|
|
<TableCell className="text-center">{getCategoryLabel(loc.zone) || context?.zone || "A"}</TableCell>
|
|
<TableCell className="text-center">
|
|
{loc.row_num.padStart(2, "0")}
|
|
</TableCell>
|
|
<TableCell className="text-center">{loc.level_num}</TableCell>
|
|
<TableCell className="text-center">{getCategoryLabel(loc.location_type) || loc.location_type}</TableCell>
|
|
<TableCell className="text-center">-</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</ScrollArea>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-200 py-8 text-gray-500">
|
|
<Eye className="mb-2 h-8 w-8 text-gray-300" />
|
|
<p>미리보기 생성 버튼을 클릭하여 결과를 확인하세요</p>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* 템플릿 다이얼로그 */}
|
|
<Dialog open={isTemplateDialogOpen} onOpenChange={setIsTemplateDialogOpen}>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{isSaveMode ? "템플릿 저장" : "템플릿 관리"}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
{isSaveMode ? (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="mb-1 block text-sm font-medium">템플릿 이름</label>
|
|
<Input
|
|
value={templateName}
|
|
onChange={(e) => setTemplateName(e.target.value)}
|
|
placeholder="템플릿 이름을 입력하세요"
|
|
/>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setIsSaveMode(false)}>
|
|
취소
|
|
</Button>
|
|
<Button onClick={saveTemplate} disabled={!templateName.trim()}>
|
|
저장
|
|
</Button>
|
|
</DialogFooter>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{/* 저장 버튼 */}
|
|
{conditions.length > 0 && (
|
|
<Button
|
|
variant="outline"
|
|
className="w-full gap-2"
|
|
onClick={() => setIsSaveMode(true)}
|
|
>
|
|
<Save className="h-4 w-4" />
|
|
현재 조건을 템플릿으로 저장
|
|
</Button>
|
|
)}
|
|
|
|
{/* 템플릿 목록 */}
|
|
{templates.length > 0 ? (
|
|
<div className="space-y-2">
|
|
<div className="text-sm font-medium text-gray-700">저장된 템플릿</div>
|
|
<ScrollArea className="h-[200px]">
|
|
{templates.map((template) => (
|
|
<div
|
|
key={template.id}
|
|
className="flex items-center justify-between rounded-lg border p-3 hover:bg-gray-50"
|
|
>
|
|
<div>
|
|
<div className="font-medium">{template.name}</div>
|
|
<div className="text-xs text-gray-500">
|
|
{template.conditions.length}개 조건
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => loadTemplate(template)}
|
|
>
|
|
불러오기
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => deleteTemplate(template.id)}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</ScrollArea>
|
|
</div>
|
|
) : (
|
|
<div className="py-8 text-center text-gray-500">
|
|
저장된 템플릿이 없습니다
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Wrapper 컴포넌트 (레지스트리용)
|
|
export const RackStructureWrapper: React.FC<RackStructureComponentProps> = (props) => {
|
|
return (
|
|
<div className="h-full w-full overflow-auto p-4">
|
|
<RackStructureComponent {...props} />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
|