feat: Enhance warehouse management page with drag-and-drop functionality
- Added GripVertical icon for visual representation of draggable segments. - Introduced state management for rack segment order, allowing users to customize the order of segments (zone, row, level) via drag-and-drop. - Updated location code generation logic to reflect the new segment order, improving the flexibility of location naming. - Simplified modal handling by resetting segment order and labels upon opening the rack modal. - Adjusted validation messages to focus on required fields, enhancing user experience during rack structure registration.
This commit is contained in:
@@ -55,6 +55,7 @@ import {
|
||||
Layers,
|
||||
Info,
|
||||
Eye,
|
||||
GripVertical,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
@@ -149,7 +150,6 @@ export default function WarehouseManagementPage() {
|
||||
|
||||
// 모달: 랙 구조 일괄 등록
|
||||
const [rackModalOpen, setRackModalOpen] = useState(false);
|
||||
const [rackFloor, setRackFloor] = useState("");
|
||||
const [rackZone, setRackZone] = useState("");
|
||||
const [rackConditions, setRackConditions] = useState<
|
||||
{ id: string; startRow: number; endRow: number; levels: number }[]
|
||||
@@ -162,6 +162,10 @@ export default function WarehouseManagementPage() {
|
||||
const [rackZoneLabel, setRackZoneLabel] = useState("구역");
|
||||
const [rackRowLabel, setRackRowLabel] = useState("열");
|
||||
const [rackLevelLabel, setRackLevelLabel] = useState("단");
|
||||
// 위치명 세그먼트 순서 (드래그로 변경 가능)
|
||||
const [rackSegmentOrder, setRackSegmentOrder] = useState<("zone" | "row" | "level")[]>(["zone", "row", "level"]);
|
||||
const [draggedSegment, setDraggedSegment] = useState<string | null>(null);
|
||||
const [dragOverSegment, setDragOverSegment] = useState<string | null>(null);
|
||||
|
||||
// 카테고리 옵션
|
||||
const [categoryOptions, setCategoryOptions] = useState<
|
||||
@@ -199,7 +203,7 @@ export default function WarehouseManagementPage() {
|
||||
setCategoryOptions(whOpts);
|
||||
|
||||
const locOpts: Record<string, { code: string; label: string }[]> = {};
|
||||
for (const col of ["location_type", "status", "floor", "zone"]) {
|
||||
for (const col of ["location_type", "status", "zone"]) {
|
||||
try {
|
||||
const res = await apiClient.get(
|
||||
`/table-categories/${LOCATION_TABLE}/${col}/values`
|
||||
@@ -512,7 +516,7 @@ export default function WarehouseManagementPage() {
|
||||
warehouse_code: locationForm.warehouse_code || selectedWarehouse?.warehouse_code || "",
|
||||
location_code: finalLocationCode,
|
||||
location_name: locationForm.location_name?.trim(),
|
||||
floor: locationForm.floor || "",
|
||||
floor: "",
|
||||
zone: locationForm.zone || "",
|
||||
row_num: locationForm.row_num || "",
|
||||
level_num: locationForm.level_num || "",
|
||||
@@ -570,13 +574,16 @@ export default function WarehouseManagementPage() {
|
||||
// ─── 랙 구조 일괄 등록 ───
|
||||
|
||||
const openRackModal = () => {
|
||||
setRackFloor("");
|
||||
setRackZone("");
|
||||
setRackConditions([]);
|
||||
setRackLocationType("");
|
||||
setRackStatus("");
|
||||
setRackPreview([]);
|
||||
setRackSaving(false);
|
||||
setRackSegmentOrder(["zone", "row", "level"]);
|
||||
setRackZoneLabel("구역");
|
||||
setRackRowLabel("열");
|
||||
setRackLevelLabel("단");
|
||||
setRackModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -601,8 +608,8 @@ export default function WarehouseManagementPage() {
|
||||
};
|
||||
|
||||
const generateRackPreview = () => {
|
||||
if (!rackFloor.trim() || !rackZone.trim()) {
|
||||
toast.error("층과 구역을 입력해주세요");
|
||||
if (!rackZone.trim()) {
|
||||
toast.error("구역을 선택해주세요");
|
||||
return;
|
||||
}
|
||||
if (rackConditions.length === 0) {
|
||||
@@ -623,11 +630,8 @@ export default function WarehouseManagementPage() {
|
||||
|
||||
const whCode = selectedWarehouse?.warehouse_code || "";
|
||||
// 카테고리 코드→라벨 변환 (셀렉트에서 코드가 저장되므로)
|
||||
const floorOpts = locationCategoryOptions["floor"] || [];
|
||||
const zoneOpts = locationCategoryOptions["zone"] || [];
|
||||
const floorLabel = floorOpts.find(o => o.code === rackFloor)?.label || rackFloor.trim();
|
||||
const zoneLabel = zoneOpts.find(o => o.code === rackZone)?.label || rackZone.trim();
|
||||
const floorCode = floorLabel.replace(/층$/, "");
|
||||
const zoneCode = zoneLabel.replace(/구역$/, "");
|
||||
|
||||
// 기존 위치코드 Set (중복 체크용)
|
||||
@@ -640,7 +644,14 @@ export default function WarehouseManagementPage() {
|
||||
for (let row = cond.startRow; row <= cond.endRow; row++) {
|
||||
for (let level = 1; level <= cond.levels; level++) {
|
||||
const rowStr = String(row).padStart(2, "0");
|
||||
const locationCode = `${whCode}-${floorCode}${zoneCode}-${rowStr}-${level}`;
|
||||
// 세그먼트 순서에 따라 위치코드/위치명 조립
|
||||
const segCodeMap: Record<string, string> = { zone: zoneCode, row: rowStr, level: String(level) };
|
||||
const segNameMap: Record<string, string> = {
|
||||
zone: `${zoneCode}${rackZoneLabel}`,
|
||||
row: `${rowStr}${rackRowLabel}`,
|
||||
level: `${level}${rackLevelLabel}`,
|
||||
};
|
||||
const locationCode = `${whCode}-${rackSegmentOrder.map(s => segCodeMap[s]).join("-")}`;
|
||||
// 미리보기 내부 중복 제거
|
||||
if (seen.has(locationCode)) continue;
|
||||
seen.add(locationCode);
|
||||
@@ -649,12 +660,12 @@ export default function WarehouseManagementPage() {
|
||||
duplicates.push(locationCode);
|
||||
continue;
|
||||
}
|
||||
const locationName = `${zoneCode}${rackZoneLabel}-${rowStr}${rackRowLabel}-${level}${rackLevelLabel}`;
|
||||
const locationName = rackSegmentOrder.map(s => segNameMap[s]).join("-");
|
||||
items.push({
|
||||
location_code: locationCode,
|
||||
location_name: locationName,
|
||||
warehouse_code: whCode,
|
||||
floor: floorLabel,
|
||||
floor: "",
|
||||
zone: zoneLabel,
|
||||
row_num: String(row),
|
||||
level_num: String(level),
|
||||
@@ -924,10 +935,9 @@ export default function WarehouseManagementPage() {
|
||||
<TableHead className="w-8 text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">#</TableHead>
|
||||
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">위치코드</TableHead>
|
||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">위치명</TableHead>
|
||||
<TableHead className="w-[50px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">층</TableHead>
|
||||
<TableHead className="w-[70px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">구역</TableHead>
|
||||
<TableHead className="w-[50px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">열</TableHead>
|
||||
<TableHead className="w-[60px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">구역</TableHead>
|
||||
<TableHead className="w-[50px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단</TableHead>
|
||||
<TableHead className="w-[50px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">열</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">유형</TableHead>
|
||||
<TableHead className="w-[70px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">상태</TableHead>
|
||||
</TableRow>
|
||||
@@ -960,10 +970,9 @@ export default function WarehouseManagementPage() {
|
||||
<TableCell className="truncate max-w-[120px]">
|
||||
{loc.location_name}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{loc.floor}</TableCell>
|
||||
<TableCell>{loc.zone}</TableCell>
|
||||
<TableCell className="text-center">{loc.row_num}</TableCell>
|
||||
<TableCell className="text-center">{loc.zone}</TableCell>
|
||||
<TableCell className="text-center">{loc.level_num}</TableCell>
|
||||
<TableCell className="text-center">{loc.row_num}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={getTypeVariant(loc.location_type)}
|
||||
@@ -1174,17 +1183,6 @@ export default function WarehouseManagementPage() {
|
||||
placeholder="위치명을 입력해주세요"
|
||||
/>
|
||||
</div>
|
||||
{/* 층 */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">층</Label>
|
||||
<Input
|
||||
value={locationForm.floor || ""}
|
||||
onChange={(e) =>
|
||||
setLocationForm((prev) => ({ ...prev, floor: e.target.value }))
|
||||
}
|
||||
placeholder="층을 입력해주세요"
|
||||
/>
|
||||
</div>
|
||||
{/* 구역 */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">구역</Label>
|
||||
@@ -1296,7 +1294,7 @@ export default function WarehouseManagementPage() {
|
||||
<h3 className="text-sm font-bold mb-3 flex items-center gap-1.5">
|
||||
📍 기본 정보
|
||||
</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">
|
||||
창고 <span className="text-destructive">*</span>
|
||||
@@ -1306,31 +1304,6 @@ export default function WarehouseManagementPage() {
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">
|
||||
층 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
{(locationCategoryOptions["floor"] || []).length > 0 ? (
|
||||
<Select value={rackFloor} onValueChange={setRackFloor}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="층 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(locationCategoryOptions["floor"] || []).map((o) => (
|
||||
<SelectItem key={o.code} value={o.code}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={rackFloor}
|
||||
onChange={(e) => setRackFloor(e.target.value)}
|
||||
placeholder="예: B1, 1F, 2F"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">
|
||||
구역 <span className="text-destructive">*</span>
|
||||
@@ -1515,35 +1488,83 @@ export default function WarehouseManagementPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 위치명 형식 — 구역/열/단 뒤에 붙일 표현만 자유 입력 */}
|
||||
{/* 위치명 형식 — 드래그로 세그먼트 순서 변경 가능 */}
|
||||
<div className="rounded-lg border p-3 bg-muted/30 space-y-2">
|
||||
<Label className="text-xs font-semibold">위치명 형식</Label>
|
||||
<div className="flex items-center gap-1 text-xs flex-wrap">
|
||||
<span className="font-mono text-muted-foreground">A</span>
|
||||
<Input
|
||||
value={rackZoneLabel}
|
||||
onChange={(e) => setRackZoneLabel(e.target.value)}
|
||||
placeholder="구역"
|
||||
className="h-8 w-20 text-xs"
|
||||
/>
|
||||
<span className="font-mono text-muted-foreground">- 01</span>
|
||||
<Input
|
||||
value={rackRowLabel}
|
||||
onChange={(e) => setRackRowLabel(e.target.value)}
|
||||
placeholder="열"
|
||||
className="h-8 w-20 text-xs"
|
||||
/>
|
||||
<span className="font-mono text-muted-foreground">- 1</span>
|
||||
<Input
|
||||
value={rackLevelLabel}
|
||||
onChange={(e) => setRackLevelLabel(e.target.value)}
|
||||
placeholder="단"
|
||||
className="h-8 w-20 text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">세그먼트를 드래그하여 순서를 변경할 수 있습니다</p>
|
||||
<div className="flex items-center gap-1.5 text-xs flex-wrap">
|
||||
{rackSegmentOrder.map((seg, idx) => {
|
||||
const config: Record<string, { example: string; label: string; setLabel: (v: string) => void; placeholder: string; name: string }> = {
|
||||
zone: { example: "A", label: rackZoneLabel, setLabel: setRackZoneLabel, placeholder: "구역", name: "구역" },
|
||||
row: { example: "01", label: rackRowLabel, setLabel: setRackRowLabel, placeholder: "열", name: "열" },
|
||||
level: { example: "1", label: rackLevelLabel, setLabel: setRackLevelLabel, placeholder: "단", name: "단" },
|
||||
};
|
||||
const c = config[seg];
|
||||
return (
|
||||
<React.Fragment key={seg}>
|
||||
{idx > 0 && <span className="font-mono text-muted-foreground select-none">-</span>}
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
setDraggedSegment(seg);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setDragOverSegment(seg);
|
||||
}}
|
||||
onDragLeave={() => setDragOverSegment(null)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
if (!draggedSegment || draggedSegment === seg) {
|
||||
setDraggedSegment(null);
|
||||
setDragOverSegment(null);
|
||||
return;
|
||||
}
|
||||
setRackSegmentOrder((prev) => {
|
||||
const next = [...prev];
|
||||
const fromIdx = next.indexOf(draggedSegment as any);
|
||||
const toIdx = next.indexOf(seg as any);
|
||||
next.splice(fromIdx, 1);
|
||||
next.splice(toIdx, 0, draggedSegment as any);
|
||||
return next;
|
||||
});
|
||||
setDraggedSegment(null);
|
||||
setDragOverSegment(null);
|
||||
}}
|
||||
onDragEnd={() => { setDraggedSegment(null); setDragOverSegment(null); }}
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-md border px-2 py-1.5 cursor-grab active:cursor-grabbing transition-all",
|
||||
draggedSegment === seg && "opacity-50 scale-95",
|
||||
dragOverSegment === seg && draggedSegment !== seg && "ring-2 ring-primary border-primary bg-primary/5",
|
||||
"bg-card hover:bg-accent/50"
|
||||
)}
|
||||
>
|
||||
<GripVertical className="h-3.5 w-3.5 text-muted-foreground/50 shrink-0" />
|
||||
<span className="font-mono text-muted-foreground font-semibold text-[11px] shrink-0">{c.example}</span>
|
||||
<Input
|
||||
value={c.label}
|
||||
onChange={(e) => c.setLabel(e.target.value)}
|
||||
placeholder={c.placeholder}
|
||||
className="h-6 w-14 text-[11px] px-1.5"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
예시: <span className="font-mono font-semibold">A{rackZoneLabel}-01{rackRowLabel}-1{rackLevelLabel}</span>
|
||||
{" "}— 구역/열/단 번호는 자동 계산되고, 뒤에 붙는 명칭만 수정할 수 있습니다.
|
||||
예시: <span className="font-mono font-semibold">
|
||||
{rackSegmentOrder.map((seg) => {
|
||||
const ex: Record<string, string> = { zone: `A${rackZoneLabel}`, row: `01${rackRowLabel}`, level: `1${rackLevelLabel}` };
|
||||
return ex[seg];
|
||||
}).join("-")}
|
||||
</span>
|
||||
{" "}— 번호는 자동 계산되고, 뒤에 붙는 명칭만 수정할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1590,10 +1611,9 @@ export default function WarehouseManagementPage() {
|
||||
<TableHead className="w-10 text-center text-[11px] font-bold text-muted-foreground">No</TableHead>
|
||||
<TableHead className="text-[11px] font-bold text-muted-foreground">위치코드</TableHead>
|
||||
<TableHead className="text-[11px] font-bold text-muted-foreground">위치명</TableHead>
|
||||
<TableHead className="w-14 text-center text-[11px] font-bold text-muted-foreground">층</TableHead>
|
||||
<TableHead className="w-14 text-center text-[11px] font-bold text-muted-foreground">구역</TableHead>
|
||||
<TableHead className="w-12 text-center text-[11px] font-bold text-muted-foreground">열</TableHead>
|
||||
<TableHead className="w-12 text-center text-[11px] font-bold text-muted-foreground">단</TableHead>
|
||||
<TableHead className="w-12 text-center text-[11px] font-bold text-muted-foreground">열</TableHead>
|
||||
<TableHead className="w-16 text-[11px] font-bold text-muted-foreground">유형</TableHead>
|
||||
<TableHead className="w-16 text-[11px] font-bold text-muted-foreground">비고</TableHead>
|
||||
</TableRow>
|
||||
@@ -1606,10 +1626,9 @@ export default function WarehouseManagementPage() {
|
||||
</TableCell>
|
||||
<TableCell className="font-mono">{item.location_code}</TableCell>
|
||||
<TableCell>{item.location_name}</TableCell>
|
||||
<TableCell className="text-center">{item.floor}</TableCell>
|
||||
<TableCell className="text-center">{item.zone}</TableCell>
|
||||
<TableCell className="text-center">{item.row_num}</TableCell>
|
||||
<TableCell className="text-center">{item.level_num}</TableCell>
|
||||
<TableCell className="text-center">{item.row_num}</TableCell>
|
||||
<TableCell>
|
||||
{resolveCategory(locationCategoryOptions, "location_type", item.location_type) || "-"}
|
||||
</TableCell>
|
||||
|
||||
Reference in New Issue
Block a user