ERP-node/frontend/lib/registry/components/rack-structure/RackStructureComponent.tsx

725 lines
26 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 {
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>
);
};