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:
kjs
2026-04-23 09:44:50 +09:00
parent ad1180daa5
commit 1282955d15

View File

@@ -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>