Files
pipeline/frontend/app/(main)/COMPANY_16/logistics/warehouse/page.tsx
T
DDD1542 f179a575ab feat: add shipping plan page with search and detail editing functionality
- 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.
2026-04-03 09:28:43 +09:00

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>
);
}