f179a575ab
- Implemented the ShippingPlanPage component for managing shipment plans. - Added search filters for date range, status, customer, and keywords. - Integrated table for displaying shipment plans with grouping and selection features. - Included detail panel for editing plan quantity, date, and memo with validation. - Enhanced table readability with CSS adjustments for cell padding and hover effects. style: improve global styles for table readability - Adjusted padding and font sizes for table cells and headers. - Added striped background for even rows and hover effects for better visibility. fix: update TableSettingsModal for better overflow handling - Modified modal layout to ensure proper scrolling for content overflow. - Ensured drag-and-drop functionality for column settings remains intact. chore: register new routes for COMPANY_7 and COMPANY_16 features - Added dynamic imports for new pages related to purchase, logistics, quality, and design for COMPANY_7 and COMPANY_16.
1124 lines
41 KiB
TypeScript
1124 lines
41 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* 창고정보관리 — 하드코딩 페이지 (Type B 마스터-디테일)
|
|
*
|
|
* 좌측: 창고 목록 (warehouse_info)
|
|
* 우측: 선택 창고의 로케이션 목록 (warehouse_location)
|
|
*
|
|
* ★ 하위 위치가 있으면 창고 삭제 불가
|
|
*/
|
|
|
|
import React, { useState, useEffect, useCallback, useMemo } 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 {
|
|
Plus,
|
|
Trash2,
|
|
Loader2,
|
|
Download,
|
|
MapPin,
|
|
Building2,
|
|
Search,
|
|
RotateCcw,
|
|
Settings2,
|
|
} 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 { 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 [searchKeyword, setSearchKeyword] = useState("");
|
|
const [searchWarehouseType, setSearchWarehouseType] = useState("all");
|
|
const [searchStatus, setSearchStatus] = useState("all");
|
|
|
|
// 우측: 로케이션 목록
|
|
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 [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"]) {
|
|
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 res = await apiClient.post(
|
|
`/table-management/tables/${WAREHOUSE_TABLE}/data`,
|
|
{
|
|
page: 1,
|
|
size: 500,
|
|
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]);
|
|
|
|
useEffect(() => {
|
|
fetchWarehouses();
|
|
}, [fetchWarehouses]);
|
|
|
|
// 클라이언트 사이드 필터링
|
|
const filteredWarehouses = useMemo(() => {
|
|
return warehouses.filter((w) => {
|
|
const kw = searchKeyword.trim().toLowerCase();
|
|
if (
|
|
kw &&
|
|
!w.warehouse_code?.toLowerCase().includes(kw) &&
|
|
!w.warehouse_name?.toLowerCase().includes(kw)
|
|
)
|
|
return false;
|
|
if (searchWarehouseType !== "all" && w.warehouse_type !== searchWarehouseType) return false;
|
|
if (searchStatus !== "all" && w.status !== searchStatus) return false;
|
|
return true;
|
|
});
|
|
}, [warehouses, searchKeyword, searchWarehouseType, searchStatus]);
|
|
|
|
// 선택된 창고
|
|
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 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,
|
|
})),
|
|
"창고정보"
|
|
);
|
|
};
|
|
|
|
const warehouseTypeOptions = categoryOptions["warehouse_type"] || [];
|
|
const statusOptions = categoryOptions["status"] || [];
|
|
|
|
return (
|
|
<div className="flex flex-col h-full gap-3 p-3">
|
|
{ConfirmDialogComponent}
|
|
|
|
{/* 검색 바 */}
|
|
<div className="shrink-0 flex items-center gap-2 px-1">
|
|
<div className="relative flex-1 max-w-xs">
|
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
|
<Input
|
|
className="pl-8 h-8 text-xs"
|
|
placeholder="창고코드, 창고명 검색"
|
|
value={searchKeyword}
|
|
onChange={(e) => setSearchKeyword(e.target.value)}
|
|
/>
|
|
</div>
|
|
<Select value={searchWarehouseType} onValueChange={setSearchWarehouseType}>
|
|
<SelectTrigger className="h-8 w-[130px] text-xs">
|
|
<SelectValue placeholder="유형 전체" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">유형 전체</SelectItem>
|
|
{warehouseTypeOptions.map((o) => (
|
|
<SelectItem key={o.code} value={o.label}>
|
|
{o.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Select value={searchStatus} onValueChange={setSearchStatus}>
|
|
<SelectTrigger className="h-8 w-[130px] text-xs">
|
|
<SelectValue placeholder="상태 전체" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">상태 전체</SelectItem>
|
|
{statusOptions.map((o) => (
|
|
<SelectItem key={o.code} value={o.label}>
|
|
{o.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 px-2"
|
|
onClick={() => {
|
|
setSearchKeyword("");
|
|
setSearchWarehouseType("all");
|
|
setSearchStatus("all");
|
|
}}
|
|
>
|
|
<RotateCcw className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<div className="ml-auto flex items-center gap-2">
|
|
<span className="text-xs text-muted-foreground">
|
|
{filteredWarehouses.length}건
|
|
</span>
|
|
<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>
|
|
</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]">
|
|
{filteredWarehouses.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>
|
|
|
|
<div className="flex-1 overflow-auto">
|
|
{warehouseLoading ? (
|
|
<div className="flex items-center justify-center h-32">
|
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : filteredWarehouses.length === 0 ? (
|
|
<div className="flex items-center justify-center h-32 text-sm text-muted-foreground">
|
|
등록된 창고가 없어요
|
|
</div>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="text-[11px]">
|
|
<TableHead className="w-8 text-center">#</TableHead>
|
|
{ts.visibleColumns.map((col) => (
|
|
<TableHead key={col.key}>{col.label}</TableHead>
|
|
))}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{filteredWarehouses.map((w, idx) => (
|
|
<TableRow
|
|
key={w.id}
|
|
className={cn(
|
|
"cursor-pointer text-xs",
|
|
selectedWarehouseId === w.id && "bg-primary/10"
|
|
)}
|
|
onClick={() => setSelectedWarehouseId(w.id)}
|
|
onDoubleClick={() => openWarehouseEditModal(w)}
|
|
>
|
|
<TableCell className="text-center text-muted-foreground">
|
|
{idx + 1}
|
|
</TableCell>
|
|
{ts.visibleColumns.map((col) => {
|
|
if (col.key === "warehouse_type") {
|
|
return (
|
|
<TableCell key={col.key}>
|
|
<Badge variant={getTypeVariant(w.warehouse_type)} className="text-[10px]">
|
|
{w.warehouse_type}
|
|
</Badge>
|
|
</TableCell>
|
|
);
|
|
}
|
|
if (col.key === "status") {
|
|
return (
|
|
<TableCell key={col.key}>
|
|
<Badge variant={getStatusVariant(w.status)} className="text-[10px]">
|
|
{w.status}
|
|
</Badge>
|
|
</TableCell>
|
|
);
|
|
}
|
|
return (
|
|
<TableCell key={col.key} className="truncate max-w-[150px]">
|
|
{w[col.key] ?? ""}
|
|
</TableCell>
|
|
);
|
|
})}
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</div>
|
|
</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 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>
|
|
<TableRow className="text-[11px]">
|
|
<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">#</TableHead>
|
|
<TableHead className="w-[110px]">위치코드</TableHead>
|
|
<TableHead>위치명</TableHead>
|
|
<TableHead className="w-[50px] text-center">층</TableHead>
|
|
<TableHead className="w-[70px]">구역</TableHead>
|
|
<TableHead className="w-[50px] text-center">열</TableHead>
|
|
<TableHead className="w-[50px] text-center">단</TableHead>
|
|
<TableHead className="w-[80px]">유형</TableHead>
|
|
<TableHead className="w-[70px]">상태</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>
|
|
|
|
<TableSettingsModal
|
|
open={ts.open}
|
|
onOpenChange={ts.setOpen}
|
|
tableName={ts.tableName}
|
|
settingsId={ts.settingsId}
|
|
onSave={ts.applySettings}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|