1609 lines
64 KiB
TypeScript
1609 lines
64 KiB
TypeScript
"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";
|
||
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
|
||
|
||
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 [locNumberingRuleId, setLocNumberingRuleId] = useState<string | null>(null);
|
||
const [locPreviewCode, setLocPreviewCode] = useState<string | null>(null);
|
||
const [whNumberingRuleId, setWhNumberingRuleId] = useState<string | null>(null);
|
||
const [whPreviewCode, setWhPreviewCode] = useState<string | null>(null);
|
||
|
||
// 모달: 랙 구조 일괄 등록
|
||
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 = async () => {
|
||
setWarehouseEditMode(false);
|
||
setWarehouseForm({});
|
||
setWhNumberingRuleId(null);
|
||
setWhPreviewCode(null);
|
||
setWarehouseModalOpen(true);
|
||
try {
|
||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/warehouse_info/warehouse_code`);
|
||
const ruleData = ruleRes.data;
|
||
if (ruleData?.success && ruleData?.data?.ruleId) {
|
||
const ruleId = ruleData.data.ruleId;
|
||
setWhNumberingRuleId(ruleId);
|
||
const previewRes = await previewNumberingCode(ruleId);
|
||
if (previewRes.success && previewRes.data?.generatedCode) {
|
||
setWhPreviewCode(previewRes.data.generatedCode);
|
||
}
|
||
}
|
||
} catch { /* 채번 규칙 없으면 무시 */ }
|
||
};
|
||
|
||
const openWarehouseEditModal = (row: any) => {
|
||
setWarehouseEditMode(true);
|
||
setWarehouseForm({ ...row });
|
||
setWarehouseModalOpen(true);
|
||
};
|
||
|
||
const handleWarehouseSave = async () => {
|
||
if (!whNumberingRuleId && !warehouseForm.warehouse_code?.trim()) {
|
||
toast.error("창고코드를 입력해주세요");
|
||
return;
|
||
}
|
||
if (!warehouseForm.warehouse_name?.trim()) {
|
||
toast.error("창고명을 입력해주세요");
|
||
return;
|
||
}
|
||
setWarehouseSaving(true);
|
||
try {
|
||
let finalWarehouseCode = warehouseForm.warehouse_code?.trim() || "";
|
||
if (!warehouseEditMode && whNumberingRuleId) {
|
||
const allocRes = await allocateNumberingCode(whNumberingRuleId);
|
||
if (allocRes.success && allocRes.data?.generatedCode) {
|
||
finalWarehouseCode = allocRes.data.generatedCode;
|
||
} else {
|
||
toast.error("채번 코드 할당에 실패했습니다.");
|
||
setWarehouseSaving(false);
|
||
return;
|
||
}
|
||
}
|
||
const fields = {
|
||
warehouse_code: finalWarehouseCode,
|
||
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) {
|
||
const dup = warehouses.find(w => w.warehouse_code === fields.warehouse_code);
|
||
if (dup) {
|
||
toast.error(`창고코드 "${fields.warehouse_code}"가 이미 존재해요`);
|
||
setWarehouseSaving(false);
|
||
return;
|
||
}
|
||
}
|
||
|
||
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 = async () => {
|
||
setLocationEditMode(false);
|
||
setLocationForm({ warehouse_code: selectedWarehouse?.warehouse_code || "" });
|
||
setLocNumberingRuleId(null);
|
||
setLocPreviewCode(null);
|
||
setLocationModalOpen(true);
|
||
try {
|
||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/warehouse_location/location_code`);
|
||
const ruleData = ruleRes.data;
|
||
if (ruleData?.success && ruleData?.data?.ruleId) {
|
||
const ruleId = ruleData.data.ruleId;
|
||
setLocNumberingRuleId(ruleId);
|
||
const previewRes = await previewNumberingCode(ruleId);
|
||
if (previewRes.success && previewRes.data?.generatedCode) {
|
||
setLocPreviewCode(previewRes.data.generatedCode);
|
||
}
|
||
}
|
||
} catch { /* 채번 규칙 없으면 무시 */ }
|
||
};
|
||
|
||
const openLocationEditModal = (row: any) => {
|
||
setLocationEditMode(true);
|
||
setLocationForm({ ...row });
|
||
setLocationModalOpen(true);
|
||
};
|
||
|
||
const handleLocationSave = async () => {
|
||
if (!locNumberingRuleId && !locationForm.location_code?.trim()) {
|
||
toast.error("위치코드를 입력해주세요");
|
||
return;
|
||
}
|
||
if (!locationForm.location_name?.trim()) {
|
||
toast.error("위치명을 입력해주세요");
|
||
return;
|
||
}
|
||
setLocationSaving(true);
|
||
try {
|
||
let finalLocationCode = locationForm.location_code?.trim() || "";
|
||
if (!locationEditMode && locNumberingRuleId) {
|
||
const allocRes = await allocateNumberingCode(locNumberingRuleId);
|
||
if (allocRes.success && allocRes.data?.generatedCode) {
|
||
finalLocationCode = allocRes.data.generatedCode;
|
||
} else {
|
||
toast.error("채번 코드 할당에 실패했습니다.");
|
||
setLocationSaving(false);
|
||
return;
|
||
}
|
||
}
|
||
|
||
const fields = {
|
||
warehouse_code: locationForm.warehouse_code || selectedWarehouse?.warehouse_code || "",
|
||
location_code: finalLocationCode,
|
||
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 {
|
||
for (const id of locationCheckedIds) {
|
||
await apiClient.delete(
|
||
`/table-management/tables/${LOCATION_TABLE}/delete`,
|
||
{ data: [{ id }] }
|
||
);
|
||
}
|
||
toast.success(`${locationCheckedIds.length}건의 위치가 삭제되었어요`);
|
||
setLocationCheckedIds([]);
|
||
fetchLocations();
|
||
} catch (err: any) {
|
||
toast.error(err?.response?.data?.message || "위치 삭제에 실패했어요");
|
||
}
|
||
};
|
||
|
||
// ─── 랙 구조 일괄 등록 ───
|
||
|
||
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(/구역$/, "");
|
||
|
||
// 기존 위치코드 Set (중복 체크용)
|
||
const existingCodes = new Set(locations.map((l: any) => l.location_code));
|
||
const seen = new Set<string>();
|
||
const items: any[] = [];
|
||
const duplicates: string[] = [];
|
||
|
||
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}`;
|
||
// 미리보기 내부 중복 제거
|
||
if (seen.has(locationCode)) continue;
|
||
seen.add(locationCode);
|
||
// 기존 DB 데이터와 중복 체크
|
||
if (existingCodes.has(locationCode)) {
|
||
duplicates.push(locationCode);
|
||
continue;
|
||
}
|
||
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,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
if (duplicates.length > 0) {
|
||
toast.error(`이미 등록된 위치 ${duplicates.length}건이 제외되었어요 (예: ${duplicates.slice(0, 3).join(", ")})`);
|
||
}
|
||
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={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={warehouseEditMode ? (warehouseForm.warehouse_code || "") : (whPreviewCode || "")}
|
||
onChange={(e) =>
|
||
setWarehouseForm((prev) => ({ ...prev, warehouse_code: e.target.value }))
|
||
}
|
||
placeholder={warehouseEditMode ? "" : (whNumberingRuleId ? "채번 조회 중..." : "자동 생성돼요")}
|
||
disabled={warehouseEditMode}
|
||
readOnly={!warehouseEditMode && !!whNumberingRuleId}
|
||
/>
|
||
</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={locationEditMode ? (locationForm.location_code || "") : (locPreviewCode || "")}
|
||
onChange={(e) =>
|
||
setLocationForm((prev) => ({ ...prev, location_code: e.target.value }))
|
||
}
|
||
placeholder={locationEditMode ? "" : (locNumberingRuleId ? "채번 조회 중..." : "자동 생성돼요")}
|
||
disabled={locationEditMode}
|
||
readOnly={!locationEditMode && !!locNumberingRuleId}
|
||
/>
|
||
</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 p-0 !gap-0" style={{ maxHeight: "90vh", display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
||
<div className="px-6 pt-6 pb-3 shrink-0">
|
||
<DialogTitle className="flex items-center gap-2">
|
||
<Layers className="h-5 w-5" />
|
||
랙 구조 일괄 등록
|
||
</DialogTitle>
|
||
<DialogDescription className="mt-1">
|
||
{selectedWarehouse?.warehouse_name} ({selectedWarehouse?.warehouse_code}) 창고에 랙 구조를 일괄 등록합니다
|
||
</DialogDescription>
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-y-auto min-h-0">
|
||
<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>
|
||
</div>
|
||
|
||
<div className="flex justify-end gap-2 px-6 py-4 border-t shrink-0 bg-background">
|
||
<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>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<TableSettingsModal
|
||
open={ts.open}
|
||
onOpenChange={ts.setOpen}
|
||
tableName={ts.tableName}
|
||
settingsId={ts.settingsId}
|
||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||
onSave={ts.applySettings}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|