"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) => void; onRemove: (id: string) => void; maxRows: number; maxLevels: number; readonly?: boolean; } const ConditionCard: React.FC = ({ 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 (
{/* 헤더 */}
조건 {index + 1} {!readonly && ( )}
{/* 내용 */}
{/* 열 범위 */}
handleChange("startRow", e.target.value)} onBlur={() => handleBlur("startRow")} disabled={readonly} className="h-9 text-center" /> ~ handleChange("endRow", e.target.value)} onBlur={() => handleBlur("endRow")} disabled={readonly} className="h-9 text-center" />
handleChange("levels", e.target.value)} onBlur={() => handleBlur("levels")} disabled={readonly} className="h-9 text-center" />
{/* 계산 결과 */}
{locationCount > 0 ? ( <> {localValues.startRow}열 ~ {localValues.endRow}열 x {localValues.levels}단 ={" "} {locationCount}개 ) : ( 값을 입력하세요 )}
); }; // 메인 컴포넌트 export const RackStructureComponent: React.FC = ({ config, context: propContext, formData, onChange, onConditionsChange, isPreview = false, tableName, }) => { // 조건 목록 const [conditions, setConditions] = useState(config.initialConditions || []); // 템플릿 관련 상태 const [templates, setTemplates] = useState([]); const [isTemplateDialogOpen, setIsTemplateDialogOpen] = useState(false); const [templateName, setTemplateName] = useState(""); const [isSaveMode, setIsSaveMode] = useState(false); // 미리보기 데이터 const [previewData, setPreviewData] = useState([]); const [isPreviewGenerated, setIsPreviewGenerated] = useState(false); // 기존 데이터 중복 체크 관련 상태 const [existingLocations, setExistingLocations] = useState([]); 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>({}); // 카테고리 코드인지 확인 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), }; 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) => { 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; const floorForQuery = context.floor; // 라벨 값 (예: "1층") const zoneForQuery = context.zone; // 라벨 값 (예: "A구역") // 기존 데이터 조회 (창고/층/구역이 변경될 때마다) 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 조건으로 처리 // autoFilter: true로 회사별 데이터 필터링 적용 const response = await apiClient.post("/table-management/tables/warehouse_location/data", { page: 1, size: 1000, // 충분히 큰 값 search: searchParams, // 백엔드가 기대하는 형식 (equals 연산자로 정확한 일치) autoFilter: true, // 회사별 데이터 필터링 (멀티테넌시) }); 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(); // 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(); 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); // 테이블 컬럼명과 동일하게 생성 locations.push({ row_num: String(row), level_num: String(level), location_code: code, location_name: name, location_type: context?.locationType || "선반", status: context?.status || "사용", // 추가 필드 (테이블 컬럼명과 동일) warehouse_code: context?.warehouseCode, warehouse_name: context?.warehouseName, floor: context?.floor, zone: 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 (
{/* 렉 라인 구조 설정 섹션 */}
렉 라인 구조 설정 {!readonly && (
{config.showTemplates && ( <> )}
)} {/* 필수 필드 경고 */} {missingFields.length > 0 && ( 다음 필드를 먼저 입력해주세요: {missingFields.join(", ")}
(설정 패널에서 필드 매핑을 확인하세요)
)} {/* 열 범위 중복 경고 */} {hasRowOverlap && ( 열 범위가 중복됩니다!
    {rowOverlapErrors.map((err, idx) => (
  • 조건 {err.conditionIndex + 1}과 조건 {err.overlappingWith + 1}: {err.overlappingRows.join(", ")}열 중복
  • ))}
중복된 열 범위를 수정해주세요.
)} {/* 기존 데이터 중복 경고 */} {hasDuplicateWithExisting && ( 이미 등록된 위치가 있습니다!
    {duplicateErrors.map((err, idx) => (
  • {err.row}열: {err.existingLevels.join(", ")}단 (이미 등록됨)
  • ))}
해당 열/단을 제외하거나 기존 데이터를 삭제해주세요.
)} {/* 기존 데이터 로딩 중 표시 */} {isCheckingDuplicates && ( 기존 위치 데이터를 확인하는 중... )} {/* 기존 데이터 존재 알림 */} {!isCheckingDuplicates && existingLocations.length > 0 && !hasDuplicateWithExisting && ( 해당 창고/층/구역에 {existingLocations.length}개의 위치가 이미 등록되어 있습니다. )} {/* 현재 매핑된 값 표시 */} {(context.warehouseCode || context.warehouseName || context.floor || context.zone) && (
{(context.warehouseCode || context.warehouseName) && ( 창고: {context.warehouseName || context.warehouseCode} {context.warehouseName && context.warehouseCode && ` (${context.warehouseCode})`} )} {context.floor && ( 층: {context.floor} )} {context.zone && ( 구역: {context.zone} )} {context.locationType && ( 유형: {context.locationType} )} {context.status && ( 상태: {context.status} )}
)} {/* 안내 메시지 */}
  1. 1 조건 추가 버튼을 클릭하여 렉 라인 조건을 생성하세요
  2. 2 각 조건마다 열 범위와 단 수를 입력하세요
  3. 3 예시: 조건1(1~3열, 3단), 조건2(4~6열, 5단)
{/* 조건 목록 또는 빈 상태 */} {conditions.length === 0 ? (
📦

조건을 추가하여 렉 구조를 설정하세요

{!readonly && ( )}
) : (
{conditions.map((condition, index) => (
))}
)}
{/* 등록 미리보기 섹션 */} {config.showPreview && conditions.length > 0 && ( 등록 미리보기 {/* 통계 카드 */} {config.showStatistics && (
총 위치
{statistics.totalLocations}개
열 수
{statistics.totalRows}개
최대 단
{statistics.maxLevel}단
)} {/* 미리보기 테이블 */} {isPreviewGenerated && previewData.length > 0 ? (
No 위치코드 위치명 구역 유형 비고 {previewData.map((loc, idx) => ( {idx + 1} {loc.location_code} {loc.location_name} {loc.floor || context?.floor || "1"} {loc.zone || context?.zone || "A"} {loc.row_num.padStart(2, "0")} {loc.level_num} {loc.location_type} - ))}
) : (

미리보기 생성 버튼을 클릭하여 결과를 확인하세요

)}
)} {/* 템플릿 다이얼로그 */} {isSaveMode ? "템플릿 저장" : "템플릿 관리"} {isSaveMode ? (
setTemplateName(e.target.value)} placeholder="템플릿 이름을 입력하세요" />
) : (
{/* 저장 버튼 */} {conditions.length > 0 && ( )} {/* 템플릿 목록 */} {templates.length > 0 ? (
저장된 템플릿
{templates.map((template) => (
{template.name}
{template.conditions.length}개 조건
))}
) : (
저장된 템플릿이 없습니다
)}
)}
); }; // Wrapper 컴포넌트 (레지스트리용) export const RackStructureWrapper: React.FC = (props) => { return (
); };