렉 구조등록 컴포넌트
This commit is contained in:
parent
09d2d7573d
commit
e05af3c6f9
|
|
@ -580,3 +580,4 @@ const result = await executeNodeFlow(flowId, {
|
||||||
- 프론트엔드 플로우 에디터: `frontend/components/dataflow/node-editor/FlowEditor.tsx`
|
- 프론트엔드 플로우 에디터: `frontend/components/dataflow/node-editor/FlowEditor.tsx`
|
||||||
- 프론트엔드 플로우 API: `frontend/lib/api/nodeFlows.ts`
|
- 프론트엔드 플로우 API: `frontend/lib/api/nodeFlows.ts`
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,9 @@ import "./screen-split-panel/ScreenSplitPanelRenderer"; // 화면 분할 패널
|
||||||
// 🆕 범용 폼 모달 컴포넌트
|
// 🆕 범용 폼 모달 컴포넌트
|
||||||
import "./universal-form-modal/UniversalFormModalRenderer"; // 섹션 기반 폼, 채번규칙, 다중 행 저장 지원
|
import "./universal-form-modal/UniversalFormModalRenderer"; // 섹션 기반 폼, 채번규칙, 다중 행 저장 지원
|
||||||
|
|
||||||
|
// 🆕 렉 구조 설정 컴포넌트
|
||||||
|
import "./rack-structure/RackStructureRenderer"; // 창고 렉 위치 일괄 생성
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 컴포넌트 초기화 함수
|
* 컴포넌트 초기화 함수
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
# 렉 구조 설정 컴포넌트 (Rack Structure Config)
|
||||||
|
|
||||||
|
창고 렉 위치를 열 범위와 단 수로 일괄 생성하는 컴포넌트입니다.
|
||||||
|
|
||||||
|
## 핵심 개념
|
||||||
|
|
||||||
|
이 컴포넌트는 **상위 폼의 필드 값을 읽어서** 위치 코드를 생성합니다.
|
||||||
|
|
||||||
|
### 작동 방식
|
||||||
|
|
||||||
|
1. 사용자가 화면관리에서 테이블 컬럼(창고코드, 층, 구역 등)을 드래그하여 폼에 배치
|
||||||
|
2. 렉 구조 컴포넌트 설정에서 **필드 매핑** 설정 (어떤 폼 필드가 창고/층/구역인지)
|
||||||
|
3. 런타임에 사용자가 폼 필드에 값을 입력하면, 렉 구조 컴포넌트가 해당 값을 읽어서 사용
|
||||||
|
|
||||||
|
## 기능
|
||||||
|
|
||||||
|
### 1. 렉 라인 구조 설정
|
||||||
|
|
||||||
|
- 조건 추가/삭제
|
||||||
|
- 각 조건: 열 범위(시작~종료) + 단 수
|
||||||
|
- 자동 위치 수 계산 (예: 1열~3열 x 3단 = 9개)
|
||||||
|
- 템플릿 저장/불러오기
|
||||||
|
|
||||||
|
### 2. 등록 미리보기
|
||||||
|
|
||||||
|
- 통계 카드 (총 위치, 열 수, 최대 단)
|
||||||
|
- 미리보기 생성 버튼
|
||||||
|
- 생성될 위치 목록 테이블
|
||||||
|
|
||||||
|
## 설정 방법
|
||||||
|
|
||||||
|
### 1. 화면관리에서 배치
|
||||||
|
|
||||||
|
1. 상위에 테이블 컬럼들을 배치 (창고코드, 층, 구역, 위치유형, 사용여부)
|
||||||
|
2. 컴포넌트 팔레트에서 "렉 구조 설정" 선택
|
||||||
|
3. 캔버스에 드래그하여 배치
|
||||||
|
|
||||||
|
### 2. 필드 매핑 설정
|
||||||
|
|
||||||
|
설정 패널에서 상위 폼의 어떤 필드를 사용할지 매핑합니다:
|
||||||
|
|
||||||
|
| 매핑 항목 | 설명 |
|
||||||
|
| -------------- | ------------------------------------- |
|
||||||
|
| 창고 코드 필드 | 위치 코드 생성에 사용할 창고 코드 |
|
||||||
|
| 층 필드 | 위치 코드 생성에 사용할 층 |
|
||||||
|
| 구역 필드 | 위치 코드 생성에 사용할 구역 |
|
||||||
|
| 위치 유형 필드 | 미리보기 테이블에 표시할 위치 유형 |
|
||||||
|
| 사용 여부 필드 | 미리보기 테이블에 표시할 사용 여부 |
|
||||||
|
|
||||||
|
### 예시
|
||||||
|
|
||||||
|
상위 폼에 다음 필드가 배치되어 있다면:
|
||||||
|
- `창고코드(조인)` → 필드명: `warehouse_code`
|
||||||
|
- `층` → 필드명: `floor`
|
||||||
|
- `구역` → 필드명: `zone`
|
||||||
|
|
||||||
|
설정 패널에서:
|
||||||
|
- 창고 코드 필드: `warehouse_code` 선택
|
||||||
|
- 층 필드: `floor` 선택
|
||||||
|
- 구역 필드: `zone` 선택
|
||||||
|
|
||||||
|
## 위치 코드 생성 규칙
|
||||||
|
|
||||||
|
기본 패턴: `{창고코드}-{층}{구역}-{열:2자리}-{단}`
|
||||||
|
|
||||||
|
예시 (창고: WH001, 층: 1, 구역: A):
|
||||||
|
|
||||||
|
- WH001-1A-01-1 (01열, 1단)
|
||||||
|
- WH001-1A-01-2 (01열, 2단)
|
||||||
|
- WH001-1A-02-1 (02열, 1단)
|
||||||
|
|
||||||
|
## 설정 옵션
|
||||||
|
|
||||||
|
| 옵션 | 타입 | 기본값 | 설명 |
|
||||||
|
| -------------- | ------- | ------ | ---------------- |
|
||||||
|
| maxConditions | number | 10 | 최대 조건 수 |
|
||||||
|
| maxRows | number | 99 | 최대 열 수 |
|
||||||
|
| maxLevels | number | 20 | 최대 단 수 |
|
||||||
|
| showTemplates | boolean | true | 템플릿 기능 표시 |
|
||||||
|
| showPreview | boolean | true | 미리보기 표시 |
|
||||||
|
| showStatistics | boolean | true | 통계 카드 표시 |
|
||||||
|
| readonly | boolean | false | 읽기 전용 |
|
||||||
|
|
||||||
|
## 출력 데이터
|
||||||
|
|
||||||
|
`onChange` 콜백으로 생성된 위치 데이터 배열을 반환합니다:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface GeneratedLocation {
|
||||||
|
rowNum: number; // 열 번호
|
||||||
|
levelNum: number; // 단 번호
|
||||||
|
locationCode: string; // 위치 코드
|
||||||
|
locationName: string; // 위치명
|
||||||
|
locationType?: string; // 위치 유형
|
||||||
|
status?: string; // 사용 여부
|
||||||
|
warehouseCode?: string; // 창고 코드 (매핑된 값)
|
||||||
|
floor?: string; // 층 (매핑된 값)
|
||||||
|
zone?: string; // 구역 (매핑된 값)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 연동 테이블
|
||||||
|
|
||||||
|
`warehouse_location` 테이블과 연동됩니다:
|
||||||
|
|
||||||
|
| 컬럼 | 설명 |
|
||||||
|
| ------------- | --------- |
|
||||||
|
| warehouse_id | 창고 ID |
|
||||||
|
| floor | 층 |
|
||||||
|
| zone | 구역 |
|
||||||
|
| row_num | 열 번호 |
|
||||||
|
| level_num | 단 번호 |
|
||||||
|
| location_code | 위치 코드 |
|
||||||
|
| location_name | 위치명 |
|
||||||
|
| location_type | 위치 유형 |
|
||||||
|
| status | 사용 여부 |
|
||||||
|
|
||||||
|
## 예시 시나리오
|
||||||
|
|
||||||
|
### 시나리오: A구역에 1~3열은 3단, 4~6열은 5단 렉 생성
|
||||||
|
|
||||||
|
1. **상위 폼에서 기본 정보 입력**
|
||||||
|
- 창고: 제1창고 (WH001) - 드래그해서 배치한 필드
|
||||||
|
- 층: 1 - 드래그해서 배치한 필드
|
||||||
|
- 구역: A - 드래그해서 배치한 필드
|
||||||
|
- 위치 유형: 선반 - 드래그해서 배치한 필드
|
||||||
|
- 사용 여부: 사용 - 드래그해서 배치한 필드
|
||||||
|
|
||||||
|
2. **렉 구조 컴포넌트에서 조건 추가**
|
||||||
|
- 조건 1: 1~3열, 3단 → 9개
|
||||||
|
- 조건 2: 4~6열, 5단 → 15개
|
||||||
|
|
||||||
|
3. **미리보기 생성**
|
||||||
|
- 총 위치: 24개
|
||||||
|
- 열 수: 6개
|
||||||
|
- 최대 단: 5단
|
||||||
|
|
||||||
|
4. **저장**
|
||||||
|
- 24개의 위치 데이터가 warehouse_location 테이블에 저장됨
|
||||||
|
|
||||||
|
## 필수 필드 검증
|
||||||
|
|
||||||
|
미리보기 생성 시 다음 필드가 입력되어 있어야 합니다:
|
||||||
|
- 창고 코드
|
||||||
|
- 층
|
||||||
|
- 구역
|
||||||
|
|
||||||
|
필드가 비어있으면 경고 메시지가 표시됩니다.
|
||||||
|
|
@ -0,0 +1,724 @@
|
||||||
|
"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 {
|
||||||
|
RackStructureComponentProps,
|
||||||
|
RackLineCondition,
|
||||||
|
RackStructureTemplate,
|
||||||
|
GeneratedLocation,
|
||||||
|
RackStructureContext,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
// 고유 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,
|
||||||
|
}) => {
|
||||||
|
// 조건 목록
|
||||||
|
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 maxConditions = config.maxConditions || 10;
|
||||||
|
const maxRows = config.maxRows || 99;
|
||||||
|
const maxLevels = config.maxLevels || 20;
|
||||||
|
const readonly = config.readonly || isPreview;
|
||||||
|
const fieldMapping = config.fieldMapping || {};
|
||||||
|
|
||||||
|
// 필드 매핑을 통해 formData에서 컨텍스트 추출
|
||||||
|
const context: RackStructureContext = useMemo(() => {
|
||||||
|
// propContext가 있으면 우선 사용
|
||||||
|
if (propContext) return propContext;
|
||||||
|
|
||||||
|
// formData와 fieldMapping을 사용하여 컨텍스트 생성
|
||||||
|
if (!formData) return {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}, [propContext, formData, fieldMapping]);
|
||||||
|
|
||||||
|
// 필수 필드 검증
|
||||||
|
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 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-1A-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}단`;
|
||||||
|
|
||||||
|
return { code, name };
|
||||||
|
},
|
||||||
|
[context]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 미리보기 생성
|
||||||
|
const generatePreview = useCallback(() => {
|
||||||
|
// 필수 필드 검증
|
||||||
|
if (missingFields.length > 0) {
|
||||||
|
alert(`다음 필드를 먼저 입력해주세요: ${missingFields.join(", ")}`);
|
||||||
|
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({
|
||||||
|
rowNum: row,
|
||||||
|
levelNum: level,
|
||||||
|
locationCode: code,
|
||||||
|
locationName: name,
|
||||||
|
locationType: context?.locationType || "선반",
|
||||||
|
status: context?.status || "사용",
|
||||||
|
// 추가 필드
|
||||||
|
warehouseCode: context?.warehouseCode,
|
||||||
|
floor: context?.floor,
|
||||||
|
zone: context?.zone,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 정렬: 열 -> 단 순서
|
||||||
|
locations.sort((a, b) => {
|
||||||
|
if (a.rowNum !== b.rowNum) return a.rowNum - b.rowNum;
|
||||||
|
return a.levelNum - b.levelNum;
|
||||||
|
});
|
||||||
|
|
||||||
|
setPreviewData(locations);
|
||||||
|
setIsPreviewGenerated(true);
|
||||||
|
onChange?.(locations);
|
||||||
|
}, [conditions, context, generateLocationCode, onChange, missingFields]);
|
||||||
|
|
||||||
|
// 템플릿 저장
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 현재 매핑된 값 표시 */}
|
||||||
|
{(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}
|
||||||
|
className="h-8 gap-1"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
미리보기 생성
|
||||||
|
</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.locationCode}</TableCell>
|
||||||
|
<TableCell>{loc.locationName}</TableCell>
|
||||||
|
<TableCell className="text-center">{context?.floor || "1"}</TableCell>
|
||||||
|
<TableCell className="text-center">{context?.zone || "A"}</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{loc.rowNum.toString().padStart(2, "0")}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">{loc.levelNum}</TableCell>
|
||||||
|
<TableCell className="text-center">{loc.locationType}</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,287 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { RackStructureComponentConfig, FieldMapping } from "./types";
|
||||||
|
|
||||||
|
interface RackStructureConfigPanelProps {
|
||||||
|
config: RackStructureComponentConfig;
|
||||||
|
onChange: (config: RackStructureComponentConfig) => void;
|
||||||
|
// 화면관리에서 전달하는 테이블 컬럼 정보
|
||||||
|
tables?: Array<{
|
||||||
|
tableName: string;
|
||||||
|
tableLabel?: string;
|
||||||
|
columns: Array<{
|
||||||
|
columnName: string;
|
||||||
|
columnLabel?: string;
|
||||||
|
dataType?: string;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> = ({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
tables = [],
|
||||||
|
}) => {
|
||||||
|
// 사용 가능한 컬럼 목록 추출
|
||||||
|
const [availableColumns, setAvailableColumns] = useState<
|
||||||
|
Array<{ value: string; label: string }>
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 모든 테이블의 컬럼을 플랫하게 추출
|
||||||
|
const columns: Array<{ value: string; label: string }> = [];
|
||||||
|
tables.forEach((table) => {
|
||||||
|
table.columns.forEach((col) => {
|
||||||
|
columns.push({
|
||||||
|
value: col.columnName,
|
||||||
|
label: col.columnLabel || col.columnName,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
setAvailableColumns(columns);
|
||||||
|
}, [tables]);
|
||||||
|
|
||||||
|
const handleChange = (key: keyof RackStructureComponentConfig, value: any) => {
|
||||||
|
onChange({ ...config, [key]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFieldMappingChange = (field: keyof FieldMapping, value: string) => {
|
||||||
|
const currentMapping = config.fieldMapping || {};
|
||||||
|
onChange({
|
||||||
|
...config,
|
||||||
|
fieldMapping: {
|
||||||
|
...currentMapping,
|
||||||
|
[field]: value === "__none__" ? undefined : value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const fieldMapping = config.fieldMapping || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 필드 매핑 섹션 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-sm font-medium text-gray-700">필드 매핑</div>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
상위 폼에 배치된 필드 중 어떤 필드를 사용할지 선택하세요
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 창고 코드 필드 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">창고 코드 필드</Label>
|
||||||
|
<Select
|
||||||
|
value={fieldMapping.warehouseCodeField || "__none__"}
|
||||||
|
onValueChange={(v) => handleFieldMappingChange("warehouseCodeField", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8">
|
||||||
|
<SelectValue placeholder="필드 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">선택 안함</SelectItem>
|
||||||
|
{availableColumns.map((col) => (
|
||||||
|
<SelectItem key={col.value} value={col.value}>
|
||||||
|
{col.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 창고명 필드 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">창고명 필드</Label>
|
||||||
|
<Select
|
||||||
|
value={fieldMapping.warehouseNameField || "__none__"}
|
||||||
|
onValueChange={(v) => handleFieldMappingChange("warehouseNameField", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8">
|
||||||
|
<SelectValue placeholder="필드 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">선택 안함</SelectItem>
|
||||||
|
{availableColumns.map((col) => (
|
||||||
|
<SelectItem key={col.value} value={col.value}>
|
||||||
|
{col.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 층 필드 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">층 필드</Label>
|
||||||
|
<Select
|
||||||
|
value={fieldMapping.floorField || "__none__"}
|
||||||
|
onValueChange={(v) => handleFieldMappingChange("floorField", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8">
|
||||||
|
<SelectValue placeholder="필드 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">선택 안함</SelectItem>
|
||||||
|
{availableColumns.map((col) => (
|
||||||
|
<SelectItem key={col.value} value={col.value}>
|
||||||
|
{col.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 구역 필드 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">구역 필드</Label>
|
||||||
|
<Select
|
||||||
|
value={fieldMapping.zoneField || "__none__"}
|
||||||
|
onValueChange={(v) => handleFieldMappingChange("zoneField", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8">
|
||||||
|
<SelectValue placeholder="필드 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">선택 안함</SelectItem>
|
||||||
|
{availableColumns.map((col) => (
|
||||||
|
<SelectItem key={col.value} value={col.value}>
|
||||||
|
{col.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 위치 유형 필드 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">위치 유형 필드</Label>
|
||||||
|
<Select
|
||||||
|
value={fieldMapping.locationTypeField || "__none__"}
|
||||||
|
onValueChange={(v) => handleFieldMappingChange("locationTypeField", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8">
|
||||||
|
<SelectValue placeholder="필드 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">선택 안함</SelectItem>
|
||||||
|
{availableColumns.map((col) => (
|
||||||
|
<SelectItem key={col.value} value={col.value}>
|
||||||
|
{col.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 사용 여부 필드 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">사용 여부 필드</Label>
|
||||||
|
<Select
|
||||||
|
value={fieldMapping.statusField || "__none__"}
|
||||||
|
onValueChange={(v) => handleFieldMappingChange("statusField", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8">
|
||||||
|
<SelectValue placeholder="필드 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">선택 안함</SelectItem>
|
||||||
|
{availableColumns.map((col) => (
|
||||||
|
<SelectItem key={col.value} value={col.value}>
|
||||||
|
{col.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 제한 설정 */}
|
||||||
|
<div className="space-y-3 border-t pt-3">
|
||||||
|
<div className="text-sm font-medium text-gray-700">제한 설정</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">최대 조건 수</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={20}
|
||||||
|
value={config.maxConditions || 10}
|
||||||
|
onChange={(e) => handleChange("maxConditions", parseInt(e.target.value) || 10)}
|
||||||
|
className="h-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">최대 열 수</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={999}
|
||||||
|
value={config.maxRows || 99}
|
||||||
|
onChange={(e) => handleChange("maxRows", parseInt(e.target.value) || 99)}
|
||||||
|
className="h-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">최대 단 수</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={99}
|
||||||
|
value={config.maxLevels || 20}
|
||||||
|
onChange={(e) => handleChange("maxLevels", parseInt(e.target.value) || 20)}
|
||||||
|
className="h-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* UI 설정 */}
|
||||||
|
<div className="space-y-3 border-t pt-3">
|
||||||
|
<div className="text-sm font-medium text-gray-700">UI 설정</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">템플릿 기능</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.showTemplates ?? true}
|
||||||
|
onCheckedChange={(checked) => handleChange("showTemplates", checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">미리보기 표시</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.showPreview ?? true}
|
||||||
|
onCheckedChange={(checked) => handleChange("showPreview", checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">통계 카드 표시</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.showStatistics ?? true}
|
||||||
|
onCheckedChange={(checked) => handleChange("showStatistics", checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">읽기 전용</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.readonly ?? false}
|
||||||
|
onCheckedChange={(checked) => handleChange("readonly", checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||||
|
import { RackStructureDefinition } from "./index";
|
||||||
|
import { RackStructureComponent } from "./RackStructureComponent";
|
||||||
|
import { GeneratedLocation } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 렉 구조 설정 렌더러
|
||||||
|
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||||
|
*/
|
||||||
|
export class RackStructureRenderer extends AutoRegisteringComponentRenderer {
|
||||||
|
static componentDefinition = RackStructureDefinition;
|
||||||
|
|
||||||
|
render(): React.ReactElement {
|
||||||
|
const { formData, isPreview, config } = this.props as any;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RackStructureComponent
|
||||||
|
config={config || {}}
|
||||||
|
formData={formData} // formData 전달 (필드 매핑에서 사용)
|
||||||
|
onChange={this.handleLocationsChange}
|
||||||
|
isPreview={isPreview}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 생성된 위치 데이터 변경 핸들러
|
||||||
|
*/
|
||||||
|
protected handleLocationsChange = (locations: GeneratedLocation[]) => {
|
||||||
|
// 생성된 위치 데이터를 formData에 저장
|
||||||
|
this.updateComponent({ generatedLocations: locations });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 자동 등록 실행
|
||||||
|
RackStructureRenderer.registerSelf();
|
||||||
|
|
||||||
|
// Hot Reload 지원 (개발 모드)
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
RackStructureRenderer.enableHotReload();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
/**
|
||||||
|
* 렉 구조 컴포넌트 기본 설정
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { RackStructureComponentConfig } from "./types";
|
||||||
|
|
||||||
|
export const defaultConfig: RackStructureComponentConfig = {
|
||||||
|
// 기본 제한
|
||||||
|
maxConditions: 10,
|
||||||
|
maxRows: 99,
|
||||||
|
maxLevels: 20,
|
||||||
|
|
||||||
|
// 기본 코드 패턴
|
||||||
|
codePattern: "{warehouseCode}-{floor}{zone}-{row:02d}-{level}",
|
||||||
|
namePattern: "{zone}구역-{row:02d}열-{level}단",
|
||||||
|
|
||||||
|
// UI 설정
|
||||||
|
showTemplates: true,
|
||||||
|
showPreview: true,
|
||||||
|
showStatistics: true,
|
||||||
|
readonly: false,
|
||||||
|
|
||||||
|
// 초기 조건 없음
|
||||||
|
initialConditions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||||
|
import { ComponentCategory } from "@/types/component";
|
||||||
|
import { RackStructureWrapper } from "./RackStructureComponent";
|
||||||
|
import { RackStructureConfigPanel } from "./RackStructureConfigPanel";
|
||||||
|
import { defaultConfig } from "./config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 렉 구조 컴포넌트 정의
|
||||||
|
* 창고 렉 위치를 일괄 생성하기 위한 구조 설정 컴포넌트
|
||||||
|
*/
|
||||||
|
export const RackStructureDefinition = createComponentDefinition({
|
||||||
|
id: "rack-structure",
|
||||||
|
name: "렉 구조 설정",
|
||||||
|
nameEng: "Rack Structure Config",
|
||||||
|
description: "창고 렉 위치를 열 범위와 단 수로 일괄 생성하는 컴포넌트",
|
||||||
|
category: ComponentCategory.INPUT,
|
||||||
|
webType: "component",
|
||||||
|
component: RackStructureWrapper,
|
||||||
|
defaultConfig: defaultConfig,
|
||||||
|
defaultSize: {
|
||||||
|
width: 1200,
|
||||||
|
height: 800,
|
||||||
|
gridColumnSpan: "12",
|
||||||
|
},
|
||||||
|
configPanel: RackStructureConfigPanel,
|
||||||
|
icon: "LayoutGrid",
|
||||||
|
tags: ["창고", "렉", "위치", "구조", "일괄생성", "WMS"],
|
||||||
|
version: "1.0.0",
|
||||||
|
author: "개발팀",
|
||||||
|
documentation: `
|
||||||
|
창고 렉 위치를 일괄 생성하기 위한 구조 설정 컴포넌트입니다.
|
||||||
|
|
||||||
|
## 주요 기능
|
||||||
|
- 조건별 열 범위 및 단 수 설정
|
||||||
|
- 자동 위치 코드/이름 생성
|
||||||
|
- 미리보기 및 통계 표시
|
||||||
|
- 템플릿 저장/불러오기
|
||||||
|
|
||||||
|
## 사용 방법
|
||||||
|
1. 상위 폼에서 창고, 층, 구역 정보 선택
|
||||||
|
2. 조건 추가 버튼으로 렉 라인 조건 생성
|
||||||
|
3. 각 조건의 열 범위와 단 수 입력
|
||||||
|
4. 미리보기 생성으로 결과 확인
|
||||||
|
5. 저장 시 생성된 위치 데이터가 함께 저장됨
|
||||||
|
|
||||||
|
## 컨텍스트 데이터
|
||||||
|
formData에서 다음 필드를 자동으로 읽어옵니다:
|
||||||
|
- warehouse_id / warehouseId: 창고 ID
|
||||||
|
- warehouse_code / warehouseCode: 창고 코드
|
||||||
|
- floor: 층
|
||||||
|
- zone: 구역
|
||||||
|
- location_type / locationType: 위치 유형
|
||||||
|
- status: 사용 여부
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 타입 내보내기
|
||||||
|
export type {
|
||||||
|
RackStructureComponentConfig,
|
||||||
|
RackStructureContext,
|
||||||
|
RackLineCondition,
|
||||||
|
RackStructureTemplate,
|
||||||
|
GeneratedLocation,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
// 컴포넌트 내보내기
|
||||||
|
export { RackStructureComponent, RackStructureWrapper } from "./RackStructureComponent";
|
||||||
|
export { RackStructureRenderer } from "./RackStructureRenderer";
|
||||||
|
export { RackStructureConfigPanel } from "./RackStructureConfigPanel";
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
/**
|
||||||
|
* 렉 구조 컴포넌트 타입 정의
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 렉 라인 조건 (열 범위 + 단 수)
|
||||||
|
export interface RackLineCondition {
|
||||||
|
id: string;
|
||||||
|
startRow: number; // 시작 열
|
||||||
|
endRow: number; // 종료 열
|
||||||
|
levels: number; // 단 수
|
||||||
|
}
|
||||||
|
|
||||||
|
// 렉 구조 템플릿
|
||||||
|
export interface RackStructureTemplate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
conditions: RackLineCondition[];
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 생성될 위치 데이터
|
||||||
|
export interface GeneratedLocation {
|
||||||
|
rowNum: number; // 열 번호
|
||||||
|
levelNum: number; // 단 번호
|
||||||
|
locationCode: string; // 위치 코드 (예: WH001-1A-01-1)
|
||||||
|
locationName: string; // 위치명 (예: A구역-01열-1단)
|
||||||
|
locationType?: string; // 위치 유형
|
||||||
|
status?: string; // 사용 여부
|
||||||
|
// 추가 필드 (상위 폼에서 매핑된 값)
|
||||||
|
warehouseCode?: string;
|
||||||
|
floor?: string;
|
||||||
|
zone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 필드 매핑 설정 (상위 폼의 어떤 필드를 사용할지)
|
||||||
|
export interface FieldMapping {
|
||||||
|
warehouseCodeField?: string; // 창고 코드로 사용할 폼 필드명
|
||||||
|
warehouseNameField?: string; // 창고명으로 사용할 폼 필드명
|
||||||
|
floorField?: string; // 층으로 사용할 폼 필드명
|
||||||
|
zoneField?: string; // 구역으로 사용할 폼 필드명
|
||||||
|
locationTypeField?: string; // 위치 유형으로 사용할 폼 필드명
|
||||||
|
statusField?: string; // 사용 여부로 사용할 폼 필드명
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컴포넌트 설정
|
||||||
|
export interface RackStructureComponentConfig {
|
||||||
|
// 기본 설정
|
||||||
|
maxConditions?: number; // 최대 조건 수 (기본: 10)
|
||||||
|
maxRows?: number; // 최대 열 수 (기본: 99)
|
||||||
|
maxLevels?: number; // 최대 단 수 (기본: 20)
|
||||||
|
|
||||||
|
// 필드 매핑 (상위 폼의 필드와 연결)
|
||||||
|
fieldMapping?: FieldMapping;
|
||||||
|
|
||||||
|
// 위치 코드 생성 규칙
|
||||||
|
codePattern?: string; // 코드 패턴 (예: "{warehouse}-{floor}{zone}-{row:02d}-{level}")
|
||||||
|
namePattern?: string; // 이름 패턴 (예: "{zone}구역-{row:02d}열-{level}단")
|
||||||
|
|
||||||
|
// UI 설정
|
||||||
|
showTemplates?: boolean; // 템플릿 기능 표시
|
||||||
|
showPreview?: boolean; // 미리보기 표시
|
||||||
|
showStatistics?: boolean; // 통계 카드 표시
|
||||||
|
readonly?: boolean; // 읽기 전용
|
||||||
|
|
||||||
|
// 초기값
|
||||||
|
initialConditions?: RackLineCondition[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상위 폼에서 전달받는 컨텍스트 데이터
|
||||||
|
export interface RackStructureContext {
|
||||||
|
warehouseId?: string; // 창고 ID
|
||||||
|
warehouseCode?: string; // 창고 코드 (예: WH001)
|
||||||
|
warehouseName?: string; // 창고명 (예: 제1창고)
|
||||||
|
floor?: string; // 층 (예: 1)
|
||||||
|
zone?: string; // 구역 (예: A)
|
||||||
|
locationType?: string; // 위치 유형 (예: 선반)
|
||||||
|
status?: string; // 사용 여부 (예: 사용)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컴포넌트 Props
|
||||||
|
export interface RackStructureComponentProps {
|
||||||
|
config: RackStructureComponentConfig;
|
||||||
|
context?: RackStructureContext;
|
||||||
|
formData?: Record<string, any>; // 상위 폼 데이터 (필드 매핑에 사용)
|
||||||
|
onChange?: (locations: GeneratedLocation[]) => void;
|
||||||
|
onConditionsChange?: (conditions: RackLineCondition[]) => void;
|
||||||
|
isPreview?: boolean;
|
||||||
|
tableName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1682,3 +1682,4 @@ const 출고등록_설정: ScreenSplitPanel = {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -529,3 +529,4 @@ const { data: config } = await getScreenSplitPanel(screenId);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -516,3 +516,4 @@ function ScreenViewPage() {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue