Files
wace_rps/frontend/app/(main)/COMPANY_16/logistics/warehouse/page.tsx
T

1530 lines
60 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/**
* 창고정보관리 — 하드코딩 페이지 (Type B 마스터-디테일)
*
* 좌측: 창고 목록 (warehouse_info)
* 우측: 선택 창고의 로케이션 목록 (warehouse_location)
*
* ★ 하위 위치가 있으면 창고 삭제 불가
*/
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Plus,
Trash2,
Loader2,
Download,
MapPin,
Building2,
Settings2,
Layers,
Info,
Eye,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
import { exportToExcel } from "@/lib/utils/excelExport";
const WAREHOUSE_TABLE = "warehouse_info";
const WAREHOUSE_COLUMNS = [
{ key: "warehouse_code", label: "창고코드" },
{ key: "warehouse_name", label: "창고명" },
{ key: "warehouse_type", label: "유형" },
{ key: "manager", label: "관리자" },
{ key: "status", label: "상태" },
];
const LOCATION_TABLE = "warehouse_location";
const getStatusVariant = (
status: string
): "default" | "secondary" | "outline" | "destructive" => {
switch (status) {
case "사용":
case "사용중":
return "default";
case "미사용":
return "secondary";
case "폐쇄":
return "destructive";
default:
return "outline";
}
};
const getTypeVariant = (
type: string
): "default" | "secondary" | "outline" => {
switch (type) {
case "원자재":
case "일반":
return "default";
case "완제품":
case "보관":
return "secondary";
default:
return "outline";
}
};
export default function WarehouseManagementPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
const ts = useTableSettings("c16-warehouse", WAREHOUSE_TABLE, WAREHOUSE_COLUMNS);
// 좌측: 창고 목록
const [warehouses, setWarehouses] = useState<any[]>([]);
const [warehouseLoading, setWarehouseLoading] = useState(false);
const [warehouseCount, setWarehouseCount] = useState(0);
const [selectedWarehouseId, setSelectedWarehouseId] = useState<string | null>(null);
// 검색 필터
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 우측: 로케이션 목록
const [locations, setLocations] = useState<any[]>([]);
const [locationLoading, setLocationLoading] = useState(false);
const [locationCheckedIds, setLocationCheckedIds] = useState<string[]>([]);
// 모달: 창고 등록/수정
const [warehouseModalOpen, setWarehouseModalOpen] = useState(false);
const [warehouseEditMode, setWarehouseEditMode] = useState(false);
const [warehouseForm, setWarehouseForm] = useState<Record<string, any>>({});
const [warehouseSaving, setWarehouseSaving] = useState(false);
// 모달: 위치 등록/수정
const [locationModalOpen, setLocationModalOpen] = useState(false);
const [locationEditMode, setLocationEditMode] = useState(false);
const [locationForm, setLocationForm] = useState<Record<string, any>>({});
const [locationSaving, setLocationSaving] = useState(false);
// 모달: 랙 구조 일괄 등록
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 }[]
>([]);
const [rackLocationType, setRackLocationType] = useState("");
const [rackStatus, setRackStatus] = useState("");
const [rackPreview, setRackPreview] = useState<any[]>([]);
const [rackSaving, setRackSaving] = useState(false);
// 카테고리 옵션
const [categoryOptions, setCategoryOptions] = useState<
Record<string, { code: string; label: string }[]>
>({});
const [locationCategoryOptions, setLocationCategoryOptions] = useState<
Record<string, { code: string; label: string }[]>
>({});
// 카테고리 로드
useEffect(() => {
const load = async () => {
const flatten = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
const whOpts: Record<string, { code: string; label: string }[]> = {};
for (const col of ["warehouse_type", "status"]) {
try {
const res = await apiClient.get(
`/table-categories/${WAREHOUSE_TABLE}/${col}/values`
);
if (res.data?.success) whOpts[col] = flatten(res.data.data || []);
} catch {
/* skip */
}
}
setCategoryOptions(whOpts);
const locOpts: Record<string, { code: string; label: string }[]> = {};
for (const col of ["location_type", "status", "floor", "zone"]) {
try {
const res = await apiClient.get(
`/table-categories/${LOCATION_TABLE}/${col}/values`
);
if (res.data?.success) locOpts[col] = flatten(res.data.data || []);
} catch {
/* skip */
}
}
setLocationCategoryOptions(locOpts);
};
load();
}, []);
// 카테고리 코드→라벨 변환 헬퍼
const resolveCategory = useCallback(
(
optMap: Record<string, { code: string; label: string }[]>,
col: string,
code: string
) => {
if (!code) return "";
return optMap[col]?.find((o) => o.code === code)?.label || code;
},
[]
);
// 창고 목록 조회
const fetchWarehouses = useCallback(async () => {
setWarehouseLoading(true);
try {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const res = await apiClient.post(
`/table-management/tables/${WAREHOUSE_TABLE}/data`,
{
page: 1,
size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
sort: { columnName: "warehouse_code", order: "asc" },
}
);
const raw = res.data?.data?.data || res.data?.data?.rows || [];
const data = raw.map((r: any) => ({
...r,
warehouse_type: resolveCategory(categoryOptions, "warehouse_type", r.warehouse_type),
status: resolveCategory(categoryOptions, "status", r.status),
}));
setWarehouses(data);
setWarehouseCount(res.data?.data?.total || raw.length);
} catch {
toast.error("창고 목록을 불러오지 못했어요");
} finally {
setWarehouseLoading(false);
}
}, [categoryOptions, resolveCategory, searchFilters]);
useEffect(() => {
fetchWarehouses();
}, [fetchWarehouses]);
// 선택된 창고
const selectedWarehouse = warehouses.find((w) => w.id === selectedWarehouseId);
// 로케이션 조회
const fetchLocations = useCallback(async () => {
if (!selectedWarehouse?.warehouse_code) {
setLocations([]);
return;
}
setLocationLoading(true);
try {
const res = await apiClient.post(
`/table-management/tables/${LOCATION_TABLE}/data`,
{
page: 1,
size: 500,
dataFilter: {
enabled: true,
filters: [
{
columnName: "warehouse_code",
operator: "equals",
value: selectedWarehouse.warehouse_code,
},
],
},
autoFilter: true,
sort: { columnName: "location_code", order: "asc" },
}
);
const raw = res.data?.data?.data || res.data?.data?.rows || [];
const data = raw.map((r: any) => ({
...r,
location_type: resolveCategory(locationCategoryOptions, "location_type", r.location_type),
status: resolveCategory(locationCategoryOptions, "status", r.status),
}));
setLocations(data);
} catch {
toast.error("위치 목록을 불러오지 못했어요");
} finally {
setLocationLoading(false);
}
}, [selectedWarehouse?.warehouse_code, locationCategoryOptions, resolveCategory]);
useEffect(() => {
setLocationCheckedIds([]);
fetchLocations();
}, [fetchLocations]);
// 체크박스 헬퍼
const allLocationChecked =
locations.length > 0 && locationCheckedIds.length === locations.length;
const someLocationChecked =
locationCheckedIds.length > 0 && locationCheckedIds.length < locations.length;
const toggleAllLocations = (checked: boolean) => {
setLocationCheckedIds(checked ? locations.map((l) => l.id) : []);
};
const toggleLocation = (id: string, checked: boolean) => {
setLocationCheckedIds((prev) =>
checked ? [...prev, id] : prev.filter((i) => i !== id)
);
};
// ─── 창고 CRUD ───
const openWarehouseCreateModal = () => {
setWarehouseEditMode(false);
setWarehouseForm({});
setWarehouseModalOpen(true);
};
const openWarehouseEditModal = (row: any) => {
setWarehouseEditMode(true);
setWarehouseForm({ ...row });
setWarehouseModalOpen(true);
};
const handleWarehouseSave = async () => {
if (!warehouseForm.warehouse_code?.trim()) {
toast.error("창고코드를 입력해주세요");
return;
}
if (!warehouseForm.warehouse_name?.trim()) {
toast.error("창고명을 입력해주세요");
return;
}
setWarehouseSaving(true);
try {
const fields = {
warehouse_code: warehouseForm.warehouse_code?.trim(),
warehouse_name: warehouseForm.warehouse_name?.trim(),
warehouse_type: warehouseForm.warehouse_type || "",
manager: warehouseForm.manager || "",
address: warehouseForm.address || "",
status: warehouseForm.status || "",
description: warehouseForm.description || "",
};
if (warehouseEditMode && warehouseForm.id) {
await apiClient.put(
`/table-management/tables/${WAREHOUSE_TABLE}/edit`,
{
originalData: { id: warehouseForm.id },
updatedData: fields,
}
);
toast.success("창고 정보가 수정되었어요");
} else {
await apiClient.post(
`/table-management/tables/${WAREHOUSE_TABLE}/add`,
{ id: crypto.randomUUID(), ...fields }
);
toast.success("창고가 등록되었어요");
}
setWarehouseModalOpen(false);
fetchWarehouses();
} catch {
toast.error(warehouseEditMode ? "창고 수정에 실패했어요" : "창고 등록에 실패했어요");
} finally {
setWarehouseSaving(false);
}
};
const handleWarehouseDelete = async () => {
if (!selectedWarehouse) return;
if (locations.length > 0) {
toast.error("하위 위치가 있어 삭제할 수 없어요. 위치를 먼저 삭제해주세요.");
return;
}
const ok = await confirm(
`"${selectedWarehouse.warehouse_name}" 창고를 삭제할까요?`
);
if (!ok) return;
try {
await apiClient.delete(
`/table-management/tables/${WAREHOUSE_TABLE}/delete`,
{ data: [{ id: selectedWarehouse.id }] }
);
toast.success("창고가 삭제되었어요");
setSelectedWarehouseId(null);
fetchWarehouses();
} catch {
toast.error("창고 삭제에 실패했어요");
}
};
// ─── 위치 CRUD ───
const openLocationCreateModal = () => {
setLocationEditMode(false);
setLocationForm({ warehouse_code: selectedWarehouse?.warehouse_code || "" });
setLocationModalOpen(true);
};
const openLocationEditModal = (row: any) => {
setLocationEditMode(true);
setLocationForm({ ...row });
setLocationModalOpen(true);
};
const handleLocationSave = async () => {
if (!locationForm.location_code?.trim()) {
toast.error("위치코드를 입력해주세요");
return;
}
if (!locationForm.location_name?.trim()) {
toast.error("위치명을 입력해주세요");
return;
}
setLocationSaving(true);
try {
const fields = {
warehouse_code: locationForm.warehouse_code || selectedWarehouse?.warehouse_code || "",
location_code: locationForm.location_code?.trim(),
location_name: locationForm.location_name?.trim(),
floor: locationForm.floor || "",
zone: locationForm.zone || "",
row_num: locationForm.row_num || "",
level_num: locationForm.level_num || "",
location_type: locationForm.location_type || "",
status: locationForm.status || "",
};
if (locationEditMode && locationForm.id) {
await apiClient.put(
`/table-management/tables/${LOCATION_TABLE}/edit`,
{
originalData: { id: locationForm.id },
updatedData: fields,
}
);
toast.success("위치 정보가 수정되었어요");
} else {
await apiClient.post(
`/table-management/tables/${LOCATION_TABLE}/add`,
{ id: crypto.randomUUID(), ...fields }
);
toast.success("위치가 등록되었어요");
}
setLocationModalOpen(false);
fetchLocations();
} catch {
toast.error(locationEditMode ? "위치 수정에 실패했어요" : "위치 등록에 실패했어요");
} finally {
setLocationSaving(false);
}
};
const handleLocationDelete = async () => {
if (locationCheckedIds.length === 0) {
toast.error("삭제할 위치를 선택해주세요");
return;
}
const ok = await confirm(`${locationCheckedIds.length}건의 위치를 삭제할까요?`);
if (!ok) return;
try {
await apiClient.delete(
`/table-management/tables/${LOCATION_TABLE}/delete`,
{ data: locationCheckedIds.map((id) => ({ id })) }
);
toast.success("위치가 삭제되었어요");
setLocationCheckedIds([]);
fetchLocations();
} catch {
toast.error("위치 삭제에 실패했어요");
}
};
// ─── 랙 구조 일괄 등록 ───
const openRackModal = () => {
setRackFloor("");
setRackZone("");
setRackConditions([]);
setRackLocationType("");
setRackStatus("");
setRackPreview([]);
setRackSaving(false);
setRackModalOpen(true);
};
const addRackCondition = () => {
const lastEnd = rackConditions.length > 0
? rackConditions[rackConditions.length - 1].endRow
: 0;
setRackConditions((prev) => [
...prev,
{ id: crypto.randomUUID(), startRow: lastEnd + 1, endRow: lastEnd + 1, levels: 1 },
]);
};
const updateRackCondition = (id: string, field: string, value: number) => {
setRackConditions((prev) =>
prev.map((c) => (c.id === id ? { ...c, [field]: value } : c))
);
};
const removeRackCondition = (id: string) => {
setRackConditions((prev) => prev.filter((c) => c.id !== id));
};
const generateRackPreview = () => {
if (!rackFloor.trim() || !rackZone.trim()) {
toast.error("층과 구역을 입력해주세요");
return;
}
if (rackConditions.length === 0) {
toast.error("조건을 1개 이상 추가해주세요");
return;
}
// 조건 유효성 검사
for (const cond of rackConditions) {
if (cond.startRow < 1 || cond.endRow < 1 || cond.levels < 1) {
toast.error("열 범위와 단 수는 1 이상이어야 합니다");
return;
}
if (cond.endRow < cond.startRow) {
toast.error("끝 열은 시작 열보다 크거나 같아야 합니다");
return;
}
}
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(/구역$/, "");
const items: any[] = [];
for (const cond of rackConditions) {
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 locationName = `${zoneCode}구역-${rowStr}열-${level}`;
items.push({
location_code: locationCode,
location_name: locationName,
warehouse_code: whCode,
floor: floorLabel,
zone: zoneLabel,
row_num: String(row),
level_num: String(level),
location_type: rackLocationType,
status: rackStatus,
});
}
}
}
setRackPreview(items);
};
const handleRackBulkSave = async () => {
if (rackPreview.length === 0) {
toast.error("미리보기를 먼저 생성해주세요");
return;
}
setRackSaving(true);
try {
let successCount = 0;
for (const item of rackPreview) {
await apiClient.post(
`/table-management/tables/${LOCATION_TABLE}/add`,
{ id: crypto.randomUUID(), ...item }
);
successCount++;
}
toast.success(`${successCount}개의 위치가 등록되었어요`);
setRackModalOpen(false);
fetchLocations();
} catch (err) {
toast.error("일괄 등록 중 오류가 발생했어요");
} finally {
setRackSaving(false);
}
};
// 랙 조건별 통계
const getRackConditionCount = (cond: { startRow: number; endRow: number; levels: number }) => {
if (cond.endRow < cond.startRow || cond.startRow < 1 || cond.levels < 1) return 0;
return (cond.endRow - cond.startRow + 1) * cond.levels;
};
// 랙 미리보기 통계
const rackStats = {
totalLocations: rackPreview.length,
totalRows: rackConditions.reduce((acc, c) => {
if (c.endRow >= c.startRow && c.startRow >= 1) return acc + (c.endRow - c.startRow + 1);
return acc;
}, 0),
maxLevels: rackConditions.reduce((acc, c) => Math.max(acc, c.levels || 0), 0),
};
// EDataTable 컬럼 정의
const warehouseColumns: EDataTableColumn[] = ts.visibleColumns.map((col) => {
const base: EDataTableColumn = { key: col.key, label: col.label };
if (col.key === "warehouse_type") {
return {
...base,
render: (val: any) => (
<Badge variant={getTypeVariant(val)} className="text-[10px]">
{val}
</Badge>
),
};
}
if (col.key === "status") {
return {
...base,
render: (val: any) => (
<Badge variant={getStatusVariant(val)} className="text-[10px]">
{val}
</Badge>
),
};
}
return base;
});
// 엑셀 내보내기
const handleExcelExport = () => {
if (warehouses.length === 0) {
toast.error("내보낼 데이터가 없어요");
return;
}
exportToExcel(
warehouses.map((r) => ({
창고코드: r.warehouse_code,
창고명: r.warehouse_name,
유형: r.warehouse_type,
관리자: r.manager,
상태: r.status,
})),
"창고정보"
);
};
return (
<div className="flex flex-col h-full gap-3 p-3">
{ConfirmDialogComponent}
{/* 검색 바 */}
<DynamicSearchFilter
tableName={WAREHOUSE_TABLE}
filterId="c16-warehouse"
onFilterChange={setSearchFilters}
externalFilterConfig={ts.filterConfig}
dataCount={warehouses.length}
extraActions={
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-8 gap-1 text-xs"
onClick={handleExcelExport}
>
<Download className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="sm" className="h-8 px-2" onClick={() => ts.setOpen(true)} title="테이블 설정">
<Settings2 className="h-3.5 w-3.5" />
</Button>
</div>
}
/>
{/* 마스터-디테일 패널 */}
<ResizablePanelGroup
direction="horizontal"
className="flex-1 rounded-lg border bg-card"
>
{/* 좌측: 창고 목록 */}
<ResizablePanel defaultSize={45} minSize={25}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50">
<div className="flex items-center gap-2">
<span className="text-[13px] font-bold"> </span>
<Badge variant="default" className="rounded-full text-[11px]">
{warehouses.length}
</Badge>
</div>
<div className="flex items-center gap-1.5">
<Button
size="sm"
className="h-7 gap-1 text-xs"
onClick={openWarehouseCreateModal}
>
<Plus className="h-3.5 w-3.5" />
</Button>
<Button
variant="outline"
size="sm"
className="h-7 gap-1 text-xs text-destructive hover:text-destructive"
onClick={handleWarehouseDelete}
disabled={!selectedWarehouse}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<EDataTable
columns={warehouseColumns}
data={ts.groupData(warehouses)}
rowKey={(row) => row.id}
loading={warehouseLoading}
emptyMessage="등록된 창고가 없어요"
selectedId={selectedWarehouseId}
onSelect={(id) => setSelectedWarehouseId(id)}
onRowDoubleClick={(row) => openWarehouseEditModal(row)}
showRowNumber
showPagination={false}
draggableColumns={false}
columnOrderKey="c16-warehouse"
/>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 로케이션 */}
<ResizablePanel defaultSize={55} minSize={30}>
<div className="flex flex-col h-full">
{!selectedWarehouse ? (
<div className="flex flex-col items-center justify-center flex-1 m-5 border-2 border-dashed rounded-lg border-border">
<Building2 className="h-12 w-12 text-muted-foreground/40 mb-4" />
<p className="text-sm font-semibold text-muted-foreground">
</p>
<p className="text-xs text-muted-foreground/70 mt-1">
</p>
</div>
) : (
<>
{/* 패널 헤더 */}
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50">
<div className="flex items-center gap-2">
<MapPin className="h-4 w-4 text-muted-foreground" />
<span className="text-[13px] font-bold">
{selectedWarehouse.warehouse_name}
</span>
<Badge
variant="outline"
className="rounded-full text-[11px] font-mono"
>
{selectedWarehouse.warehouse_code}
</Badge>
<Badge variant="secondary" className="rounded-full text-[10px]">
{locations.length}
</Badge>
</div>
<div className="flex items-center gap-1.5">
<Button
size="sm"
className="h-7 gap-1 text-xs"
onClick={openLocationCreateModal}
>
<Plus className="h-3.5 w-3.5" />
</Button>
<Button
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
onClick={openRackModal}
>
<Layers className="h-3.5 w-3.5" />
</Button>
<Button
variant="outline"
size="sm"
className="h-7 gap-1 text-xs text-destructive hover:text-destructive"
onClick={handleLocationDelete}
disabled={locationCheckedIds.length === 0}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* 로케이션 테이블 */}
<div className="flex-1 overflow-auto">
{locationLoading ? (
<div className="flex items-center justify-center h-32">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : locations.length === 0 ? (
<div className="flex items-center justify-center h-32 text-sm text-muted-foreground">
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-10 text-center">
<Checkbox
checked={
someLocationChecked
? "indeterminate"
: allLocationChecked
}
onCheckedChange={(v) =>
toggleAllLocations(v === true)
}
aria-label="전체 선택"
/>
</TableHead>
<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-[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>
</TableHeader>
<TableBody>
{locations.map((loc, idx) => (
<TableRow
key={loc.id}
className={cn(
"cursor-pointer text-xs",
locationCheckedIds.includes(loc.id) && "bg-primary/5"
)}
onDoubleClick={() => openLocationEditModal(loc)}
>
<TableCell className="text-center">
<Checkbox
checked={locationCheckedIds.includes(loc.id)}
onCheckedChange={(v) =>
toggleLocation(loc.id, v === true)
}
aria-label="행 선택"
/>
</TableCell>
<TableCell className="text-center text-muted-foreground">
{idx + 1}
</TableCell>
<TableCell className="font-mono truncate max-w-[110px]">
{loc.location_code}
</TableCell>
<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.level_num}</TableCell>
<TableCell>
<Badge
variant={getTypeVariant(loc.location_type)}
className="text-[10px]"
>
{loc.location_type}
</Badge>
</TableCell>
<TableCell>
<Badge
variant={getStatusVariant(loc.status)}
className="text-[10px]"
>
{loc.status}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
{/* 창고 등록/수정 Dialog */}
<Dialog open={warehouseModalOpen} onOpenChange={setWarehouseModalOpen}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>
{warehouseEditMode ? "창고 수정" : "창고 등록"}
</DialogTitle>
<DialogDescription>
{warehouseEditMode
? "창고 정보를 수정해주세요"
: "새로운 창고를 등록해주세요"}
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 py-2">
{/* 창고코드 */}
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
<span className="text-destructive">*</span>
</Label>
<Input
value={warehouseForm.warehouse_code || ""}
onChange={(e) =>
setWarehouseForm((prev) => ({ ...prev, warehouse_code: e.target.value }))
}
placeholder="창고코드를 입력해주세요"
disabled={warehouseEditMode}
/>
</div>
{/* 창고명 */}
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
<span className="text-destructive">*</span>
</Label>
<Input
value={warehouseForm.warehouse_name || ""}
onChange={(e) =>
setWarehouseForm((prev) => ({ ...prev, warehouse_name: e.target.value }))
}
placeholder="창고명을 입력해주세요"
/>
</div>
{/* 유형 */}
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Select
value={warehouseForm.warehouse_type || ""}
onValueChange={(v) =>
setWarehouseForm((prev) => ({ ...prev, warehouse_type: v }))
}
>
<SelectTrigger>
<SelectValue placeholder="유형 선택" />
</SelectTrigger>
<SelectContent>
{(categoryOptions["warehouse_type"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 관리자 */}
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Input
value={warehouseForm.manager || ""}
onChange={(e) =>
setWarehouseForm((prev) => ({ ...prev, manager: e.target.value }))
}
placeholder="관리자를 입력해주세요"
/>
</div>
{/* 상태 */}
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Select
value={warehouseForm.status || ""}
onValueChange={(v) =>
setWarehouseForm((prev) => ({ ...prev, status: v }))
}
>
<SelectTrigger>
<SelectValue placeholder="상태 선택" />
</SelectTrigger>
<SelectContent>
{(categoryOptions["status"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 주소 (전체 너비) */}
<div className="grid gap-1.5 col-span-2">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Input
value={warehouseForm.address || ""}
onChange={(e) =>
setWarehouseForm((prev) => ({ ...prev, address: e.target.value }))
}
placeholder="주소를 입력해주세요"
/>
</div>
{/* 비고 (전체 너비) */}
<div className="grid gap-1.5 col-span-2">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Input
value={warehouseForm.description || ""}
onChange={(e) =>
setWarehouseForm((prev) => ({ ...prev, description: e.target.value }))
}
placeholder="비고를 입력해주세요"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setWarehouseModalOpen(false)}>
</Button>
<Button onClick={handleWarehouseSave} disabled={warehouseSaving}>
{warehouseSaving && (
<Loader2 className="h-4 w-4 animate-spin mr-1" />
)}
{warehouseEditMode ? "수정하기" : "등록하기"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 위치 등록/수정 Dialog */}
<Dialog open={locationModalOpen} onOpenChange={setLocationModalOpen}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>
{locationEditMode ? "위치 수정" : "위치 등록"}
</DialogTitle>
<DialogDescription>
{locationEditMode
? "위치 정보를 수정해주세요"
: `${selectedWarehouse?.warehouse_name || ""} 창고에 새 위치를 등록해주세요`}
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 py-2">
{/* 창고코드 */}
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Input value={locationForm.warehouse_code || ""} disabled />
</div>
{/* 위치코드 */}
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
<span className="text-destructive">*</span>
</Label>
<Input
value={locationForm.location_code || ""}
onChange={(e) =>
setLocationForm((prev) => ({ ...prev, location_code: e.target.value }))
}
placeholder="위치코드를 입력해주세요"
disabled={locationEditMode}
/>
</div>
{/* 위치명 */}
<div className="grid gap-1.5 col-span-2">
<Label className="text-[11px] font-semibold text-muted-foreground">
<span className="text-destructive">*</span>
</Label>
<Input
value={locationForm.location_name || ""}
onChange={(e) =>
setLocationForm((prev) => ({ ...prev, location_name: e.target.value }))
}
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>
<Input
value={locationForm.zone || ""}
onChange={(e) =>
setLocationForm((prev) => ({ ...prev, zone: e.target.value }))
}
placeholder="구역을 입력해주세요"
/>
</div>
{/* 열 */}
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Input
value={locationForm.row_num || ""}
onChange={(e) =>
setLocationForm((prev) => ({ ...prev, row_num: e.target.value }))
}
placeholder="열을 입력해주세요"
/>
</div>
{/* 단 */}
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Input
value={locationForm.level_num || ""}
onChange={(e) =>
setLocationForm((prev) => ({ ...prev, level_num: e.target.value }))
}
placeholder="단을 입력해주세요"
/>
</div>
{/* 위치유형 */}
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Select
value={locationForm.location_type || ""}
onValueChange={(v) =>
setLocationForm((prev) => ({ ...prev, location_type: v }))
}
>
<SelectTrigger>
<SelectValue placeholder="위치유형 선택" />
</SelectTrigger>
<SelectContent>
{(locationCategoryOptions["location_type"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 상태 */}
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Select
value={locationForm.status || ""}
onValueChange={(v) =>
setLocationForm((prev) => ({ ...prev, status: v }))
}
>
<SelectTrigger>
<SelectValue placeholder="상태 선택" />
</SelectTrigger>
<SelectContent>
{(locationCategoryOptions["status"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setLocationModalOpen(false)}>
</Button>
<Button onClick={handleLocationSave} disabled={locationSaving}>
{locationSaving && (
<Loader2 className="h-4 w-4 animate-spin mr-1" />
)}
{locationEditMode ? "수정하기" : "등록하기"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 랙 구조 일괄 등록 Dialog */}
<Dialog open={rackModalOpen} onOpenChange={setRackModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-3xl max-h-[90vh] overflow-y-auto p-0">
<DialogHeader className="px-6 pt-6 pb-0">
<DialogTitle className="flex items-center gap-2">
<Layers className="h-5 w-5" />
</DialogTitle>
<DialogDescription>
{selectedWarehouse?.warehouse_name} ({selectedWarehouse?.warehouse_code})
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[calc(90vh-160px)]">
<div className="space-y-6 px-6 py-4">
{/* 기본 정보 */}
<div>
<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 gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
<span className="text-destructive">*</span>
</Label>
<Input
value={`${selectedWarehouse?.warehouse_name || ""} (${selectedWarehouse?.warehouse_code || ""})`}
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>
</Label>
{(locationCategoryOptions["zone"] || []).length > 0 ? (
<Select value={rackZone} onValueChange={setRackZone}>
<SelectTrigger>
<SelectValue placeholder="구역 선택" />
</SelectTrigger>
<SelectContent>
{(locationCategoryOptions["zone"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={rackZone}
onChange={(e) => setRackZone(e.target.value)}
placeholder="예: A, B, C"
/>
)}
</div>
</div>
</div>
{/* 랙 라인 구조 설정 */}
<div>
<h3 className="text-sm font-bold mb-3 flex items-center gap-1.5">
📊
</h3>
{/* 안내 박스 */}
<div className="rounded-lg bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 p-3 mb-3">
<div className="flex items-start gap-2">
<Info className="h-4 w-4 text-blue-600 dark:text-blue-400 mt-0.5 shrink-0" />
<div className="text-xs text-blue-700 dark:text-blue-300 space-y-0.5">
<p>1. </p>
<p>2. </p>
<p>3. 예시: 조건1(1~3, 3), 2(4~6, 5)</p>
</div>
</div>
</div>
<div className="flex items-center justify-between mb-3">
<span className="text-xs text-muted-foreground">
{rackConditions.length}
</span>
<Button
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
onClick={addRackCondition}
>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
{rackConditions.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 border-2 border-dashed rounded-lg border-border">
<Layers className="h-8 w-8 text-muted-foreground/40 mb-2" />
<p className="text-sm text-muted-foreground mb-2">
</p>
<Button
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
onClick={addRackCondition}
>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
) : (
<div className="space-y-2">
{rackConditions.map((cond, idx) => (
<div
key={cond.id}
className="flex items-center gap-3 rounded-lg border p-3 bg-card"
>
<span className="text-xs font-bold text-muted-foreground w-10 shrink-0">
{idx + 1}
</span>
<div className="flex items-center gap-1.5 flex-1">
<Input
type="number"
min={1}
className="h-8 w-20 text-center text-xs"
value={cond.startRow || ""}
onChange={(e) =>
updateRackCondition(cond.id, "startRow", Number(e.target.value) || 0)
}
placeholder="시작"
/>
<span className="text-xs text-muted-foreground">~</span>
<Input
type="number"
min={1}
className="h-8 w-20 text-center text-xs"
value={cond.endRow || ""}
onChange={(e) =>
updateRackCondition(cond.id, "endRow", Number(e.target.value) || 0)
}
placeholder="끝"
/>
<span className="text-xs text-muted-foreground">,</span>
<Input
type="number"
min={1}
className="h-8 w-16 text-center text-xs"
value={cond.levels || ""}
onChange={(e) =>
updateRackCondition(cond.id, "levels", Number(e.target.value) || 0)
}
placeholder="단"
/>
<span className="text-xs text-muted-foreground"></span>
</div>
{getRackConditionCount(cond) > 0 && (
<Badge variant="secondary" className="text-[10px] shrink-0">
{cond.startRow} ~ {cond.endRow} × {cond.levels} = {getRackConditionCount(cond)}
</Badge>
)}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-destructive hover:text-destructive shrink-0"
onClick={() => removeRackCondition(cond.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
)}
</div>
{/* 공통 설정 */}
<div>
<h3 className="text-sm font-bold mb-3 flex items-center gap-1.5">
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
</Label>
<Select value={rackLocationType} onValueChange={setRackLocationType}>
<SelectTrigger>
<SelectValue placeholder="위치유형 선택" />
</SelectTrigger>
<SelectContent>
{(locationCategoryOptions["location_type"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
</Label>
<Select value={rackStatus} onValueChange={setRackStatus}>
<SelectTrigger>
<SelectValue placeholder="상태 선택" />
</SelectTrigger>
<SelectContent>
{(locationCategoryOptions["status"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{/* 등록 미리보기 */}
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-bold flex items-center gap-1.5">
👁
</h3>
<Button
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
onClick={generateRackPreview}
>
<Eye className="h-3.5 w-3.5" />
</Button>
</div>
{rackPreview.length > 0 && (
<>
{/* 통계 카드 */}
<div className="grid grid-cols-3 gap-3 mb-3">
<div className="rounded-lg border bg-muted/50 p-3 text-center">
<p className="text-[11px] text-muted-foreground"> </p>
<p className="text-lg font-bold">{rackStats.totalLocations}</p>
</div>
<div className="rounded-lg border bg-muted/50 p-3 text-center">
<p className="text-[11px] text-muted-foreground"> </p>
<p className="text-lg font-bold">{rackStats.totalRows}</p>
</div>
<div className="rounded-lg border bg-muted/50 p-3 text-center">
<p className="text-[11px] text-muted-foreground"> </p>
<p className="text-lg font-bold">{rackStats.maxLevels}</p>
</div>
</div>
{/* 미리보기 테이블 */}
<div className="rounded-lg border overflow-hidden max-h-[300px] overflow-y-auto">
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<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-16 text-[11px] font-bold text-muted-foreground"></TableHead>
<TableHead className="w-16 text-[11px] font-bold text-muted-foreground"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rackPreview.map((item, idx) => (
<TableRow key={idx} className="text-xs">
<TableCell className="text-center text-muted-foreground">
{idx + 1}
</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>
{resolveCategory(locationCategoryOptions, "location_type", item.location_type) || "-"}
</TableCell>
<TableCell>-</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
)}
{rackPreview.length === 0 && (
<div className="flex flex-col items-center justify-center py-6 border-2 border-dashed rounded-lg border-border text-sm text-muted-foreground">
</div>
)}
</div>
</div>
</ScrollArea>
<DialogFooter className="px-6 pb-6 pt-2 border-t">
<Button variant="outline" onClick={() => setRackModalOpen(false)}>
</Button>
<Button
onClick={handleRackBulkSave}
disabled={rackSaving || rackPreview.length === 0}
>
{rackSaving && <Loader2 className="h-4 w-4 animate-spin mr-1" />}
({rackPreview.length})
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<TableSettingsModal
open={ts.open}
onOpenChange={ts.setOpen}
tableName={ts.tableName}
settingsId={ts.settingsId}
defaultVisibleKeys={ts.defaultVisibleKeys}
onSave={ts.applySettings}
/>
</div>
);
}