ERP-node/frontend/app/(main)/logistics/receiving/page.tsx

1247 lines
47 KiB
TypeScript

"use client";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import {
Search,
Plus,
Trash2,
RotateCcw,
Loader2,
Package,
X,
Save,
ChevronRight,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
getReceivingList,
createReceiving,
deleteReceiving,
generateReceivingNumber,
getReceivingWarehouses,
getPurchaseOrderSources,
getShipmentSources,
getItemSources,
type InboundItem,
type PurchaseOrderSource,
type ShipmentSource,
type ItemSource,
type WarehouseOption,
} from "@/lib/api/receiving";
// 입고유형 옵션
const INBOUND_TYPES = [
{ value: "구매입고", label: "구매입고", color: "bg-blue-100 text-blue-800" },
{ value: "반품입고", label: "반품입고", color: "bg-pink-100 text-pink-800" },
{ value: "기타입고", label: "기타입고", color: "bg-gray-100 text-gray-800" },
];
const INBOUND_STATUS_OPTIONS = [
{ value: "대기", label: "대기", color: "bg-amber-100 text-amber-800" },
{ value: "입고완료", label: "입고완료", color: "bg-emerald-100 text-emerald-800" },
{ value: "부분입고", label: "부분입고", color: "bg-amber-100 text-amber-800" },
{ value: "입고취소", label: "입고취소", color: "bg-red-100 text-red-800" },
];
const getTypeColor = (type: string) => INBOUND_TYPES.find((t) => t.value === type)?.color || "bg-gray-100 text-gray-800";
const getStatusColor = (status: string) => INBOUND_STATUS_OPTIONS.find((s) => s.value === status)?.color || "bg-gray-100 text-gray-800";
// 소스 테이블 한글명 매핑
const SOURCE_TABLE_LABEL: Record<string, string> = {
purchase_order_mng: "발주",
shipment_instruction_detail: "출하",
item_info: "품목",
};
// 선택된 소스 아이템 (등록 모달에서 사용)
interface SelectedSourceItem {
key: string;
inbound_type: string;
reference_number: string;
supplier_code: string;
supplier_name: string;
item_number: string;
item_name: string;
spec: string;
material: string;
unit: string;
inbound_qty: number;
unit_price: number;
total_amount: number;
source_table: string;
source_id: string;
}
export default function ReceivingPage() {
// 목록 데이터
const [data, setData] = useState<InboundItem[]>([]);
const [loading, setLoading] = useState(false);
const [checkedIds, setCheckedIds] = useState<string[]>([]);
// 검색 필터
const [searchType, setSearchType] = useState("all");
const [searchStatus, setSearchStatus] = useState("all");
const [searchKeyword, setSearchKeyword] = useState("");
const [searchDateFrom, setSearchDateFrom] = useState("");
const [searchDateTo, setSearchDateTo] = useState("");
// 등록 모달
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalInboundType, setModalInboundType] = useState("구매입고");
const [modalInboundNo, setModalInboundNo] = useState("");
const [modalInboundDate, setModalInboundDate] = useState("");
const [modalWarehouse, setModalWarehouse] = useState("");
const [modalLocation, setModalLocation] = useState("");
const [modalInspector, setModalInspector] = useState("");
const [modalManager, setModalManager] = useState("");
const [modalMemo, setModalMemo] = useState("");
const [selectedItems, setSelectedItems] = useState<SelectedSourceItem[]>([]);
const [saving, setSaving] = useState(false);
// 소스 데이터
const [sourceKeyword, setSourceKeyword] = useState("");
const [sourceLoading, setSourceLoading] = useState(false);
const [purchaseOrders, setPurchaseOrders] = useState<PurchaseOrderSource[]>([]);
const [shipments, setShipments] = useState<ShipmentSource[]>([]);
const [items, setItems] = useState<ItemSource[]>([]);
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
// 날짜 초기화
useEffect(() => {
const today = new Date();
const threeMonthsAgo = new Date(today);
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
setSearchDateFrom(threeMonthsAgo.toISOString().split("T")[0]);
setSearchDateTo(today.toISOString().split("T")[0]);
}, []);
// 목록 조회
const fetchList = useCallback(async () => {
setLoading(true);
try {
const res = await getReceivingList({
inbound_type: searchType !== "all" ? searchType : undefined,
inbound_status: searchStatus !== "all" ? searchStatus : undefined,
search_keyword: searchKeyword || undefined,
date_from: searchDateFrom || undefined,
date_to: searchDateTo || undefined,
});
if (res.success) setData(res.data);
} catch {
// 에러 무시
} finally {
setLoading(false);
}
}, [searchType, searchStatus, searchKeyword, searchDateFrom, searchDateTo]);
useEffect(() => {
fetchList();
}, [fetchList]);
// 창고 목록 로드
useEffect(() => {
(async () => {
try {
const res = await getReceivingWarehouses();
if (res.success) setWarehouses(res.data);
} catch {
// ignore
}
})();
}, []);
// 검색 초기화
const handleReset = () => {
setSearchType("all");
setSearchStatus("all");
setSearchKeyword("");
const today = new Date();
const threeMonthsAgo = new Date(today);
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
setSearchDateFrom(threeMonthsAgo.toISOString().split("T")[0]);
setSearchDateTo(today.toISOString().split("T")[0]);
};
// 체크박스
const allChecked = data.length > 0 && checkedIds.length === data.length;
const toggleCheckAll = () => {
setCheckedIds(allChecked ? [] : data.map((d) => d.id));
};
const toggleCheck = (id: string) => {
setCheckedIds((prev) =>
prev.includes(id) ? prev.filter((v) => v !== id) : [...prev, id]
);
};
// 삭제
const handleDelete = async () => {
if (checkedIds.length === 0) return;
if (!confirm(`선택한 ${checkedIds.length}건을 삭제하시겠습니까?`)) return;
for (const id of checkedIds) {
await deleteReceiving(id);
}
setCheckedIds([]);
fetchList();
};
// --- 등록 모달 ---
// 소스 데이터 로드 함수
const loadSourceData = useCallback(
async (type: string, keyword?: string) => {
setSourceLoading(true);
try {
if (type === "구매입고") {
const res = await getPurchaseOrderSources(keyword || undefined);
if (res.success) setPurchaseOrders(res.data);
} else if (type === "반품입고") {
const res = await getShipmentSources(keyword || undefined);
if (res.success) setShipments(res.data);
} else {
const res = await getItemSources(keyword || undefined);
if (res.success) setItems(res.data);
}
} catch {
// ignore
} finally {
setSourceLoading(false);
}
},
[]
);
const openRegisterModal = async () => {
const defaultType = "구매입고";
setModalInboundType(defaultType);
setModalInboundDate(new Date().toISOString().split("T")[0]);
setModalWarehouse("");
setModalLocation("");
setModalInspector("");
setModalManager("");
setModalMemo("");
setSelectedItems([]);
setSourceKeyword("");
setPurchaseOrders([]);
setShipments([]);
setItems([]);
setIsModalOpen(true);
// 입고번호 생성 + 발주 데이터 동시 로드
try {
const [numRes] = await Promise.all([
generateReceivingNumber(),
loadSourceData(defaultType),
]);
if (numRes.success) setModalInboundNo(numRes.data);
} catch {
setModalInboundNo("");
}
};
// 검색 버튼 클릭 시
const searchSourceData = useCallback(async () => {
await loadSourceData(modalInboundType, sourceKeyword || undefined);
}, [modalInboundType, sourceKeyword, loadSourceData]);
// 입고유형 변경 시 소스 데이터 자동 리로드
const handleInboundTypeChange = useCallback(
(type: string) => {
setModalInboundType(type);
setSourceKeyword("");
setPurchaseOrders([]);
setShipments([]);
setItems([]);
setSelectedItems([]);
loadSourceData(type);
},
[loadSourceData]
);
// 발주 품목 추가
const addPurchaseOrder = (po: PurchaseOrderSource) => {
const key = `po-${po.id}`;
if (selectedItems.some((s) => s.key === key)) return;
setSelectedItems((prev) => [
...prev,
{
key,
inbound_type: "구매입고",
reference_number: po.purchase_no,
supplier_code: po.supplier_code,
supplier_name: po.supplier_name,
item_number: po.item_code,
item_name: po.item_name,
spec: po.spec || "",
material: po.material || "",
unit: "EA",
inbound_qty: po.remain_qty,
unit_price: po.unit_price,
total_amount: po.remain_qty * po.unit_price,
source_table: "purchase_order_mng",
source_id: po.id,
},
]);
};
// 출하 품목 추가
const addShipment = (sh: ShipmentSource) => {
const key = `sh-${sh.detail_id}`;
if (selectedItems.some((s) => s.key === key)) return;
setSelectedItems((prev) => [
...prev,
{
key,
inbound_type: "반품입고",
reference_number: sh.instruction_no,
supplier_code: "",
supplier_name: sh.partner_id,
item_number: sh.item_code,
item_name: sh.item_name,
spec: sh.spec || "",
material: sh.material || "",
unit: "EA",
inbound_qty: sh.ship_qty,
unit_price: 0,
total_amount: 0,
source_table: "shipment_instruction_detail",
source_id: String(sh.detail_id),
},
]);
};
// 품목 추가
const addItem = (item: ItemSource) => {
const key = `item-${item.id}`;
if (selectedItems.some((s) => s.key === key)) return;
setSelectedItems((prev) => [
...prev,
{
key,
inbound_type: "기타입고",
reference_number: item.item_number,
supplier_code: "",
supplier_name: "",
item_number: item.item_number,
item_name: item.item_name,
spec: item.spec || "",
material: item.material || "",
unit: item.unit || "EA",
inbound_qty: 0,
unit_price: item.standard_price,
total_amount: 0,
source_table: "item_info",
source_id: item.id,
},
]);
};
// 선택 품목 수량 변경
const updateItemQty = (key: string, qty: number) => {
setSelectedItems((prev) =>
prev.map((item) =>
item.key === key
? { ...item, inbound_qty: qty, total_amount: qty * item.unit_price }
: item
)
);
};
// 선택 품목 단가 변경
const updateItemPrice = (key: string, price: number) => {
setSelectedItems((prev) =>
prev.map((item) =>
item.key === key
? { ...item, unit_price: price, total_amount: item.inbound_qty * price }
: item
)
);
};
// 선택 품목 삭제
const removeItem = (key: string) => {
setSelectedItems((prev) => prev.filter((item) => item.key !== key));
};
// 저장
const handleSave = async () => {
if (selectedItems.length === 0) {
alert("입고할 품목을 선택해주세요.");
return;
}
if (!modalInboundDate) {
alert("입고일을 입력해주세요.");
return;
}
const zeroQtyItems = selectedItems.filter((i) => !i.inbound_qty || i.inbound_qty <= 0);
if (zeroQtyItems.length > 0) {
alert("입고수량이 0인 품목이 있습니다. 수량을 입력해주세요.");
return;
}
setSaving(true);
try {
const res = await createReceiving({
inbound_number: modalInboundNo,
inbound_date: modalInboundDate,
warehouse_code: modalWarehouse || undefined,
location_code: modalLocation || undefined,
inspector: modalInspector || undefined,
manager: modalManager || undefined,
memo: modalMemo || undefined,
items: selectedItems.map((item) => ({
inbound_type: item.inbound_type,
reference_number: item.reference_number,
supplier_code: item.supplier_code,
supplier_name: item.supplier_name,
item_number: item.item_number,
item_name: item.item_name,
spec: item.spec,
material: item.material,
unit: item.unit,
inbound_qty: item.inbound_qty,
unit_price: item.unit_price,
total_amount: item.total_amount,
source_table: item.source_table,
source_id: item.source_id,
inbound_status: "입고완료",
inspection_status: "대기",
})),
});
if (res.success) {
alert(res.message || "입고 등록 완료");
setIsModalOpen(false);
fetchList();
}
} catch {
alert("입고 등록 중 오류가 발생했습니다.");
} finally {
setSaving(false);
}
};
// 합계 계산
const totalSummary = useMemo(() => {
return {
count: selectedItems.length,
qty: selectedItems.reduce((sum, i) => sum + (i.inbound_qty || 0), 0),
amount: selectedItems.reduce((sum, i) => sum + (i.total_amount || 0), 0),
};
}, [selectedItems]);
return (
<div className="flex h-full flex-col gap-4 p-4">
{/* 검색 영역 */}
<div className="flex flex-wrap items-center gap-2 rounded-lg border bg-card p-4">
<Select value={searchType} onValueChange={setSearchType}>
<SelectTrigger className="h-9 w-[130px] text-xs">
<SelectValue placeholder="입고유형" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> </SelectItem>
{INBOUND_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={searchStatus} onValueChange={setSearchStatus}>
<SelectTrigger className="h-9 w-[130px] text-xs">
<SelectValue placeholder="입고상태" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> </SelectItem>
{INBOUND_STATUS_OPTIONS.map((s) => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
placeholder="입고번호 / 품목명 / 참조번호 검색"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && fetchList()}
className="h-9 w-[240px] text-xs"
/>
<div className="flex items-center gap-1">
<Input
type="date"
value={searchDateFrom}
onChange={(e) => setSearchDateFrom(e.target.value)}
className="h-9 w-[140px] text-xs"
/>
<span className="text-muted-foreground text-xs">~</span>
<Input
type="date"
value={searchDateTo}
onChange={(e) => setSearchDateTo(e.target.value)}
className="h-9 w-[140px] text-xs"
/>
</div>
<Button size="sm" onClick={fetchList} className="h-9">
<Search className="mr-1 h-4 w-4" />
</Button>
<Button size="sm" variant="outline" onClick={handleReset} className="h-9">
<RotateCcw className="mr-1 h-4 w-4" />
</Button>
<div className="ml-auto flex gap-2">
<Button size="sm" onClick={openRegisterModal} className="h-9">
<Plus className="mr-1 h-4 w-4" />
</Button>
<Button
size="sm"
variant="destructive"
onClick={handleDelete}
disabled={checkedIds.length === 0}
className="h-9"
>
<Trash2 className="mr-1 h-4 w-4" />
({checkedIds.length})
</Button>
</div>
</div>
{/* 입고 목록 테이블 */}
<div className="flex-1 overflow-hidden rounded-lg border bg-card">
<div className="flex items-center justify-between border-b px-4 py-3">
<div className="flex items-center gap-2">
<Package className="text-muted-foreground h-5 w-5" />
<h3 className="text-base font-semibold"> </h3>
<span className="text-muted-foreground text-sm">
{data.length}
</span>
</div>
</div>
<div className="h-[calc(100%-52px)] overflow-auto">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="w-[40px] text-center">
<Checkbox
checked={allChecked}
onCheckedChange={toggleCheckAll}
/>
</TableHead>
<TableHead className="w-[130px]"></TableHead>
<TableHead className="w-[90px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="min-w-[150px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[80px] text-right"></TableHead>
<TableHead className="w-[90px] text-right"></TableHead>
<TableHead className="w-[100px] text-right"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[90px] text-center"></TableHead>
<TableHead className="w-[100px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={16} className="h-40 text-center">
<Loader2 className="mx-auto h-6 w-6 animate-spin" />
</TableCell>
</TableRow>
) : data.length === 0 ? (
<TableRow>
<TableCell
colSpan={16}
className="text-muted-foreground h-40 text-center"
>
<div className="flex flex-col items-center gap-2">
<Package className="h-10 w-10 opacity-30" />
<p> </p>
<p className="text-xs">
&apos; &apos;
</p>
</div>
</TableCell>
</TableRow>
) : (
data.map((row) => (
<TableRow
key={row.id}
className={cn(
"cursor-pointer transition-colors",
checkedIds.includes(row.id) && "bg-primary/5"
)}
onClick={() => toggleCheck(row.id)}
>
<TableCell
className="text-center"
onClick={(e) => e.stopPropagation()}
>
<Checkbox
checked={checkedIds.includes(row.id)}
onCheckedChange={() => toggleCheck(row.id)}
/>
</TableCell>
<TableCell className="max-w-[130px] truncate font-medium" title={row.inbound_number}>
{row.inbound_number}
</TableCell>
<TableCell>
<Badge
variant="outline"
className={cn("text-[11px]", getTypeColor(row.inbound_type))}
>
{row.inbound_type || "-"}
</Badge>
</TableCell>
<TableCell className="text-xs">
{row.inbound_date
? new Date(row.inbound_date).toLocaleDateString("ko-KR")
: "-"}
</TableCell>
<TableCell className="max-w-[120px] truncate text-xs" title={row.reference_number || "-"}>
{row.reference_number || "-"}
</TableCell>
<TableCell className="text-xs">
{row.source_table
? SOURCE_TABLE_LABEL[row.source_table] || row.source_table
: "-"}
</TableCell>
<TableCell className="max-w-[120px] truncate text-xs" title={row.supplier_name || "-"}>
{row.supplier_name || "-"}
</TableCell>
<TableCell className="max-w-[130px] truncate text-xs" title={row.item_number || "-"}>
{row.item_number || "-"}
</TableCell>
<TableCell className="max-w-[150px] truncate text-xs" title={row.item_name || "-"}>{row.item_name || "-"}</TableCell>
<TableCell className="max-w-[100px] truncate text-xs" title={row.spec || "-"}>{row.spec || "-"}</TableCell>
<TableCell className="text-right text-xs font-semibold">
{Number(row.inbound_qty || 0).toLocaleString()}
</TableCell>
<TableCell className="text-right text-xs">
{Number(row.unit_price || 0).toLocaleString()}
</TableCell>
<TableCell className="text-right text-xs font-semibold">
{Number(row.total_amount || 0).toLocaleString()}
</TableCell>
<TableCell className="text-xs">
{row.warehouse_name || row.warehouse_code || "-"}
</TableCell>
<TableCell className="text-center">
<Badge
variant="outline"
className={cn(
"text-[11px]",
getStatusColor(row.inbound_status)
)}
>
{row.inbound_status || "-"}
</Badge>
</TableCell>
<TableCell className="max-w-[120px] truncate text-xs" title={row.memo || "-"}>
{row.memo || "-"}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
{/* 입고 등록 모달 */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="flex h-[90vh] max-w-[95vw] flex-col p-0 sm:max-w-[1600px]">
<DialogHeader className="border-b px-6 py-4">
<DialogTitle className="text-lg"> </DialogTitle>
<DialogDescription className="text-xs">
, .
</DialogDescription>
</DialogHeader>
{/* 입고유형 선택 */}
<div className="flex items-center gap-4 border-b px-6 py-3">
<Label className="text-sm font-semibold"></Label>
<Select value={modalInboundType} onValueChange={handleInboundTypeChange}>
<SelectTrigger className="h-9 w-[160px] text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{INBOUND_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-muted-foreground ml-auto text-xs italic">
{modalInboundType === "구매입고"
? "발주 데이터에서 입고 처리합니다."
: modalInboundType === "반품입고"
? "출하 데이터에서 반품 입고 처리합니다."
: "품목 데이터를 직접 선택하여 입고 처리합니다."}
</span>
</div>
{/* 메인 콘텐츠: 좌측 소스 데이터 / 우측 선택 품목 */}
<div className="flex-1 overflow-hidden">
<ResizablePanelGroup direction="horizontal">
{/* 좌측: 근거 데이터 검색 */}
<ResizablePanel defaultSize={60} minSize={35}>
<div className="flex h-full flex-col">
{/* 소스 검색 바 */}
<div className="flex items-center gap-2 border-b px-4 py-3">
<Input
placeholder={
modalInboundType === "구매입고"
? "발주번호 / 품목명 / 공급처"
: modalInboundType === "반품입고"
? "출하번호 / 품목명"
: "품목번호 / 품목명"
}
value={sourceKeyword}
onChange={(e) => setSourceKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && searchSourceData()}
className="h-8 flex-1 text-xs"
/>
<Button size="sm" onClick={searchSourceData} className="h-8">
<Search className="mr-1 h-3 w-3" />
</Button>
</div>
{/* 소스 데이터 테이블 */}
<div className="flex-1 overflow-auto px-4 py-2">
<h4 className="text-muted-foreground mb-2 text-xs font-semibold">
{modalInboundType === "구매입고"
? "미입고 발주 목록"
: modalInboundType === "반품입고"
? "출하 목록"
: "품목 목록"}
</h4>
{sourceLoading ? (
<div className="flex h-40 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : modalInboundType === "구매입고" ? (
<SourcePurchaseOrderTable
data={purchaseOrders}
onAdd={addPurchaseOrder}
selectedKeys={selectedItems.map((s) => s.key)}
/>
) : modalInboundType === "반품입고" ? (
<SourceShipmentTable
data={shipments}
onAdd={addShipment}
selectedKeys={selectedItems.map((s) => s.key)}
/>
) : (
<SourceItemTable
data={items}
onAdd={addItem}
selectedKeys={selectedItems.map((s) => s.key)}
/>
)}
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 입고 정보 + 선택 품목 */}
<ResizablePanel defaultSize={40} minSize={25}>
<div className="flex h-full flex-col">
{/* 입고 정보 입력 */}
<div className="space-y-3 border-b bg-muted/30 px-4 py-3">
<h4 className="text-sm font-semibold"> </h4>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-[11px]"></Label>
<Input
value={modalInboundNo}
readOnly
className="bg-muted h-8 text-xs"
/>
</div>
<div>
<Label className="text-[11px]">
<span className="text-destructive">*</span>
</Label>
<Input
type="date"
value={modalInboundDate}
onChange={(e) => setModalInboundDate(e.target.value)}
className="h-8 text-xs"
/>
</div>
<div>
<Label className="text-[11px]"></Label>
<Select value={modalWarehouse} onValueChange={setModalWarehouse}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="창고 선택" />
</SelectTrigger>
<SelectContent>
{warehouses.map((w) => (
<SelectItem key={w.warehouse_code} value={w.warehouse_code}>
{w.warehouse_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[11px]"></Label>
<Input
value={modalLocation}
onChange={(e) => setModalLocation(e.target.value)}
placeholder="위치 입력"
className="h-8 text-xs"
/>
</div>
<div>
<Label className="text-[11px]"></Label>
<Input
value={modalInspector}
onChange={(e) => setModalInspector(e.target.value)}
placeholder="검수자"
className="h-8 text-xs"
/>
</div>
<div>
<Label className="text-[11px]"></Label>
<Input
value={modalManager}
onChange={(e) => setModalManager(e.target.value)}
placeholder="담당자"
className="h-8 text-xs"
/>
</div>
<div className="col-span-2">
<Label className="text-[11px]"></Label>
<Input
value={modalMemo}
onChange={(e) => setModalMemo(e.target.value)}
placeholder="메모"
className="h-8 text-xs"
/>
</div>
</div>
</div>
{/* 선택된 품목 테이블 */}
<div className="flex-1 overflow-auto px-4 py-2">
<h4 className="text-muted-foreground mb-2 text-xs font-semibold">
({selectedItems.length})
</h4>
{selectedItems.length === 0 ? (
<div className="text-muted-foreground flex h-32 flex-col items-center justify-center text-xs">
<Package className="mb-2 h-8 w-8 opacity-30" />
</div>
) : (
<Table>
<TableHeader>
<TableRow className="text-[11px]">
<TableHead className="w-[30px] p-2">No</TableHead>
<TableHead className="p-2"></TableHead>
<TableHead className="p-2"></TableHead>
<TableHead className="w-[80px] p-2 text-right">
</TableHead>
<TableHead className="w-[80px] p-2 text-right">
</TableHead>
<TableHead className="w-[90px] p-2 text-right">
</TableHead>
<TableHead className="w-[30px] p-2" />
</TableRow>
</TableHeader>
<TableBody>
{selectedItems.map((item, idx) => (
<TableRow key={item.key} className="text-xs">
<TableCell className="p-2 text-center">
{idx + 1}
</TableCell>
<TableCell className="max-w-[180px] p-2">
<div className="flex flex-col">
<span className="truncate font-medium" title={item.item_name}>
{item.item_name}
</span>
<span className="text-muted-foreground truncate text-[10px]" title={`${item.item_number}${item.spec ? ` | ${item.spec}` : ""}`}>
{item.item_number}
{item.spec ? ` | ${item.spec}` : ""}
</span>
</div>
</TableCell>
<TableCell className="p-2 text-[11px]">
{item.reference_number}
</TableCell>
<TableCell className="p-2 text-right">
<Input
type="number"
value={item.inbound_qty || ""}
onChange={(e) =>
updateItemQty(
item.key,
Number(e.target.value) || 0
)
}
className="h-7 w-[70px] text-right text-xs"
min={0}
/>
</TableCell>
<TableCell className="p-2 text-right">
<Input
type="number"
value={item.unit_price || ""}
onChange={(e) =>
updateItemPrice(
item.key,
Number(e.target.value) || 0
)
}
className="h-7 w-[70px] text-right text-xs"
min={0}
/>
</TableCell>
<TableCell className="p-2 text-right text-xs font-semibold">
{item.total_amount.toLocaleString()}
</TableCell>
<TableCell className="p-2 text-center">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => removeItem(item.key)}
>
<X className="h-3 w-3" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* 푸터 */}
<DialogFooter className="flex items-center justify-between border-t px-6 py-3">
<div className="text-muted-foreground text-xs">
{selectedItems.length > 0 ? (
<>
{totalSummary.count} | :{" "}
{totalSummary.qty.toLocaleString()} | :{" "}
{totalSummary.amount.toLocaleString()}
</>
) : (
"품목을 추가해주세요"
)}
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setIsModalOpen(false)}
className="h-9 text-sm"
>
</Button>
<Button
onClick={handleSave}
disabled={saving || selectedItems.length === 0}
className="h-9 text-sm"
>
{saving ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<Save className="mr-1 h-4 w-4" />
)}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// --- 소스 데이터 테이블 컴포넌트들 ---
function SourcePurchaseOrderTable({
data,
onAdd,
selectedKeys,
}: {
data: PurchaseOrderSource[];
onAdd: (po: PurchaseOrderSource) => void;
selectedKeys: string[];
}) {
if (data.length === 0) {
return (
<div className="text-muted-foreground flex h-32 items-center justify-center text-xs">
</div>
);
}
return (
<Table>
<TableHeader>
<TableRow className="text-[11px]">
<TableHead className="w-[40px] p-2" />
<TableHead className="p-2"></TableHead>
<TableHead className="p-2"></TableHead>
<TableHead className="p-2"></TableHead>
<TableHead className="w-[70px] p-2 text-right"></TableHead>
<TableHead className="w-[70px] p-2 text-right"></TableHead>
<TableHead className="w-[70px] p-2 text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((po) => {
const isSelected = selectedKeys.includes(`po-${po.id}`);
return (
<TableRow
key={po.id}
className={cn(
"cursor-pointer text-xs transition-colors",
isSelected && "bg-primary/5"
)}
onClick={() => !isSelected && onAdd(po)}
>
<TableCell className="p-2 text-center">
{isSelected ? (
<Badge className="bg-primary/20 text-primary text-[10px]">
</Badge>
) : (
<ChevronRight className="text-muted-foreground h-4 w-4" />
)}
</TableCell>
<TableCell className="max-w-[120px] truncate p-2 font-medium" title={po.purchase_no}>{po.purchase_no}</TableCell>
<TableCell className="max-w-[120px] truncate p-2" title={po.supplier_name}>{po.supplier_name}</TableCell>
<TableCell className="max-w-[200px] p-2">
<div className="flex flex-col">
<span className="truncate font-medium" title={po.item_name}>{po.item_name}</span>
<span className="text-muted-foreground truncate text-[10px]" title={`${po.item_code}${po.spec ? ` | ${po.spec}` : ""}`}>
{po.item_code}
{po.spec ? ` | ${po.spec}` : ""}
</span>
</div>
</TableCell>
<TableCell className="p-2 text-right">
{Number(po.order_qty).toLocaleString()}
</TableCell>
<TableCell className="p-2 text-right">
{Number(po.received_qty).toLocaleString()}
</TableCell>
<TableCell className="p-2 text-right font-semibold text-blue-600">
{Number(po.remain_qty).toLocaleString()}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
);
}
function SourceShipmentTable({
data,
onAdd,
selectedKeys,
}: {
data: ShipmentSource[];
onAdd: (sh: ShipmentSource) => void;
selectedKeys: string[];
}) {
if (data.length === 0) {
return (
<div className="text-muted-foreground flex h-32 items-center justify-center text-xs">
</div>
);
}
return (
<Table>
<TableHeader>
<TableRow className="text-[11px]">
<TableHead className="w-[40px] p-2" />
<TableHead className="p-2"></TableHead>
<TableHead className="p-2"></TableHead>
<TableHead className="p-2"></TableHead>
<TableHead className="p-2"></TableHead>
<TableHead className="w-[70px] p-2 text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((sh) => {
const isSelected = selectedKeys.includes(`sh-${sh.detail_id}`);
return (
<TableRow
key={sh.detail_id}
className={cn(
"cursor-pointer text-xs transition-colors",
isSelected && "bg-primary/5"
)}
onClick={() => !isSelected && onAdd(sh)}
>
<TableCell className="p-2 text-center">
{isSelected ? (
<Badge className="bg-primary/20 text-primary text-[10px]">
</Badge>
) : (
<ChevronRight className="text-muted-foreground h-4 w-4" />
)}
</TableCell>
<TableCell className="max-w-[130px] truncate p-2 font-medium" title={sh.instruction_no}>{sh.instruction_no}</TableCell>
<TableCell className="p-2">
{sh.instruction_date
? new Date(sh.instruction_date).toLocaleDateString("ko-KR")
: "-"}
</TableCell>
<TableCell className="max-w-[100px] truncate p-2" title={sh.partner_id}>{sh.partner_id}</TableCell>
<TableCell className="max-w-[200px] p-2">
<div className="flex flex-col">
<span className="truncate font-medium" title={sh.item_name}>{sh.item_name}</span>
<span className="text-muted-foreground truncate text-[10px]" title={`${sh.item_code}${sh.spec ? ` | ${sh.spec}` : ""}`}>
{sh.item_code}
{sh.spec ? ` | ${sh.spec}` : ""}
</span>
</div>
</TableCell>
<TableCell className="p-2 text-right font-semibold">
{Number(sh.ship_qty).toLocaleString()}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
);
}
function SourceItemTable({
data,
onAdd,
selectedKeys,
}: {
data: ItemSource[];
onAdd: (item: ItemSource) => void;
selectedKeys: string[];
}) {
if (data.length === 0) {
return (
<div className="text-muted-foreground flex h-32 items-center justify-center text-xs">
</div>
);
}
return (
<Table>
<TableHeader>
<TableRow className="text-[11px]">
<TableHead className="w-[40px] p-2" />
<TableHead className="p-2"></TableHead>
<TableHead className="w-[80px] p-2"></TableHead>
<TableHead className="w-[80px] p-2"></TableHead>
<TableHead className="w-[60px] p-2"></TableHead>
<TableHead className="w-[80px] p-2 text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((item) => {
const isSelected = selectedKeys.includes(`item-${item.id}`);
return (
<TableRow
key={item.id}
className={cn(
"cursor-pointer text-xs transition-colors",
isSelected && "bg-primary/5"
)}
onClick={() => !isSelected && onAdd(item)}
>
<TableCell className="p-2 text-center">
{isSelected ? (
<Badge className="bg-primary/20 text-primary text-[10px]">
</Badge>
) : (
<ChevronRight className="text-muted-foreground h-4 w-4" />
)}
</TableCell>
<TableCell className="max-w-[250px] p-2">
<div className="flex flex-col">
<span className="truncate font-medium" title={item.item_name}>{item.item_name}</span>
<span className="text-muted-foreground truncate text-[10px]" title={item.item_number}>
{item.item_number}
</span>
</div>
</TableCell>
<TableCell className="max-w-[100px] truncate p-2" title={item.spec || "-"}>{item.spec || "-"}</TableCell>
<TableCell className="max-w-[100px] truncate p-2" title={item.material || "-"}>{item.material || "-"}</TableCell>
<TableCell className="p-2">{item.unit || "-"}</TableCell>
<TableCell className="p-2 text-right">
{Number(item.standard_price).toLocaleString()}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
);
}