d03f92947d
- Integrated DynamicSearchFilter component to manage search filters. - Removed individual search state variables and replaced with a single searchFilters state. - Updated fetchData function to handle new filter structure. - Refactored search filter UI to utilize DynamicSearchFilter. - Adjusted table header styles for better visibility and consistency. style: Update global styles for improved UI consistency - Unified font size across the application to 16px, excluding buttons. - Adjusted header padding and font size for better readability. - Enhanced dark mode styles for checkboxes to ensure visibility. feat: Add Options Setting page for category and numbering configurations - Created a new OptionsSettingPage component with tabs for category and numbering settings. - Implemented drag-to-resize functionality for the category column list. - Integrated CategoryColumnList and CategoryValueManager components for managing categories. feat: Introduce useTableSettings hook for table configuration management - Developed useTableSettings hook to manage column visibility, order, and width. - Implemented localStorage persistence for table settings. - Enhanced TableSettingsModal to accept defaultVisibleKeys for initial column visibility. chore: Update AdminPageRenderer to include new COMPANY_16 routes - Added new routes for COMPANY_16 master-data options and other pages.
1329 lines
56 KiB
TypeScript
1329 lines
56 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,
|
|
Loader2,
|
|
Inbox,
|
|
X,
|
|
Save,
|
|
ChevronRight,
|
|
ChevronLeft,
|
|
ChevronsLeft,
|
|
ChevronsRight,
|
|
Settings2,
|
|
} from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { apiClient } from "@/lib/api/client";
|
|
import { useTableSettings } from "@/hooks/useTableSettings";
|
|
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
|
// API: /receiving/*
|
|
import {
|
|
getReceivingList,
|
|
createReceiving,
|
|
deleteReceiving,
|
|
generateReceivingNumber,
|
|
getReceivingWarehouses,
|
|
getPurchaseOrderSources,
|
|
getShipmentSources,
|
|
getItemSources,
|
|
type InboundItem,
|
|
type PurchaseOrderSource,
|
|
type ShipmentSource,
|
|
type ItemSource,
|
|
type WarehouseOption,
|
|
} from "@/lib/api/receiving";
|
|
|
|
const GRID_COLUMNS = [
|
|
{ key: "inbound_number", label: "입고번호" },
|
|
{ key: "inbound_type", label: "입고유형" },
|
|
{ key: "inbound_date", label: "입고일" },
|
|
{ key: "reference_number", label: "참조번호" },
|
|
{ key: "source_type", label: "데이터출처" },
|
|
{ key: "supplier_name", label: "공급처" },
|
|
{ key: "item_number", label: "품목코드" },
|
|
{ key: "item_name", label: "품목명" },
|
|
{ key: "spec", label: "규격" },
|
|
{ key: "inbound_qty", label: "입고수량" },
|
|
{ key: "unit_price", label: "단가" },
|
|
{ key: "total_amount", label: "금액" },
|
|
{ key: "warehouse_name", label: "창고" },
|
|
{ key: "inbound_status", label: "입고상태" },
|
|
{ key: "remark", label: "비고" },
|
|
];
|
|
|
|
// 입고유형 옵션
|
|
const INBOUND_TYPES = [
|
|
{ value: "구매입고", label: "구매입고" },
|
|
{ value: "반품입고", label: "반품입고" },
|
|
{ value: "기타입고", label: "기타입고" },
|
|
];
|
|
|
|
const INBOUND_STATUS_OPTIONS = [
|
|
{ value: "대기", label: "대기" },
|
|
{ value: "입고완료", label: "입고완료" },
|
|
{ value: "부분입고", label: "부분입고" },
|
|
{ value: "입고취소", label: "입고취소" },
|
|
];
|
|
|
|
const getTypeVariant = (type: string): "default" | "secondary" | "outline" => {
|
|
switch (type) {
|
|
case "구매입고": return "default";
|
|
case "반품입고": return "secondary";
|
|
default: return "outline";
|
|
}
|
|
};
|
|
|
|
const getStatusVariant = (status: string): "default" | "secondary" | "outline" | "destructive" => {
|
|
switch (status) {
|
|
case "입고완료": return "default";
|
|
case "부분입고": return "secondary";
|
|
case "입고취소": return "destructive";
|
|
default: return "outline";
|
|
}
|
|
};
|
|
|
|
// 소스 테이블 한글명 매핑
|
|
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 ts = useTableSettings("c16-receiving", "inbound_mng", GRID_COLUMNS);
|
|
// 목록 데이터
|
|
const [data, setData] = useState<InboundItem[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
|
|
|
// 검색 필터
|
|
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
|
|
|
// 등록 모달
|
|
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[]>([]);
|
|
|
|
// 소스 데이터 페이징
|
|
const [sourcePage, setSourcePage] = useState(1);
|
|
const [sourcePageSize, setSourcePageSize] = useState(20);
|
|
const [sourceTotalCount, setSourceTotalCount] = useState(0);
|
|
|
|
// 구매관리 division 코드 (라벨 기준 조회)
|
|
const [purchaseDivisionCode, setPurchaseDivisionCode] = useState<string>("");
|
|
|
|
// 구매관리 division 코드 로드
|
|
useEffect(() => {
|
|
// division 카테고리에서 "구매관리" 라벨의 코드 조회
|
|
apiClient.get("/table-categories/item_info/division/values").then((res) => {
|
|
const vals = res.data?.data || [];
|
|
const found = vals.find((v: any) => (v.value_label || v.label) === "구매관리");
|
|
if (found) setPurchaseDivisionCode(found.value_code || found.code);
|
|
}).catch(() => {});
|
|
}, []);
|
|
|
|
// 목록 조회
|
|
const fetchList = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const params: Record<string, string | undefined> = {};
|
|
for (const f of searchFilters) {
|
|
if (!f.value) continue;
|
|
if (f.columnName === "inbound_type") params.inbound_type = f.value;
|
|
else if (f.columnName === "inbound_status") params.inbound_status = f.value;
|
|
else if (f.columnName === "inbound_date" && f.operator === "between") {
|
|
const [from, to] = f.value.split("~").map((s) => s.trim());
|
|
if (from) params.date_from = from;
|
|
if (to) params.date_to = to;
|
|
} else {
|
|
params.search_keyword = f.value;
|
|
}
|
|
}
|
|
const res = await getReceivingList(params);
|
|
if (res.success) setData(res.data);
|
|
} catch {
|
|
// 에러 무시
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [searchFilters]);
|
|
|
|
useEffect(() => {
|
|
fetchList();
|
|
}, [fetchList]);
|
|
|
|
// 창고 목록 로드
|
|
useEffect(() => {
|
|
(async () => {
|
|
try {
|
|
const res = await getReceivingWarehouses();
|
|
if (res.success) setWarehouses(res.data);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
})();
|
|
}, []);
|
|
|
|
// 체크박스
|
|
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, pageOverride?: number) => {
|
|
setSourceLoading(true);
|
|
try {
|
|
const params = {
|
|
keyword: keyword || undefined,
|
|
page: pageOverride ?? sourcePage,
|
|
pageSize: sourcePageSize,
|
|
};
|
|
if (type === "구매입고") {
|
|
const res = await getPurchaseOrderSources(params);
|
|
if (res.success) {
|
|
setPurchaseOrders(res.data);
|
|
setSourceTotalCount(res.totalCount || 0);
|
|
}
|
|
} else if (type === "반품입고") {
|
|
const res = await getShipmentSources(params);
|
|
if (res.success) {
|
|
setShipments(res.data);
|
|
setSourceTotalCount(res.totalCount || 0);
|
|
}
|
|
} else {
|
|
const res = await getItemSources({ ...params, division: purchaseDivisionCode || undefined });
|
|
if (res.success) {
|
|
setItems(res.data);
|
|
setSourceTotalCount(res.totalCount || 0);
|
|
}
|
|
}
|
|
} catch {
|
|
// ignore
|
|
} finally {
|
|
setSourceLoading(false);
|
|
}
|
|
},
|
|
[sourcePage, sourcePageSize]
|
|
);
|
|
|
|
const openRegisterModal = async () => {
|
|
const defaultType = "구매입고";
|
|
setModalInboundType(defaultType);
|
|
setModalInboundDate(new Date().toISOString().split("T")[0]);
|
|
setModalWarehouse("");
|
|
setModalLocation("");
|
|
setModalInspector("");
|
|
setModalManager("");
|
|
setModalMemo("");
|
|
setSelectedItems([]);
|
|
setSourceKeyword("");
|
|
setPurchaseOrders([]);
|
|
setShipments([]);
|
|
setItems([]);
|
|
setSourcePage(1);
|
|
setSourceTotalCount(0);
|
|
setIsModalOpen(true);
|
|
|
|
// 입고번호 생성 + 발주 데이터 동시 로드
|
|
try {
|
|
const [numRes] = await Promise.all([
|
|
generateReceivingNumber(),
|
|
loadSourceData(defaultType, undefined, 1),
|
|
]);
|
|
if (numRes.success) setModalInboundNo(numRes.data);
|
|
} catch {
|
|
setModalInboundNo("");
|
|
}
|
|
};
|
|
|
|
// 검색 버튼 클릭 시
|
|
const searchSourceData = useCallback(async () => {
|
|
setSourcePage(1);
|
|
await loadSourceData(modalInboundType, sourceKeyword || undefined, 1);
|
|
}, [modalInboundType, sourceKeyword, loadSourceData]);
|
|
|
|
// 입고유형 변경 시 소스 데이터 자동 리로드
|
|
const handleInboundTypeChange = useCallback(
|
|
(type: string) => {
|
|
setModalInboundType(type);
|
|
setSourceKeyword("");
|
|
setPurchaseOrders([]);
|
|
setShipments([]);
|
|
setItems([]);
|
|
setSelectedItems([]);
|
|
setSourcePage(1);
|
|
setSourceTotalCount(0);
|
|
loadSourceData(type, undefined, 1);
|
|
},
|
|
[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: po.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-3 p-3">
|
|
{/* 브레드크럼 */}
|
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground shrink-0">
|
|
<span>물류관리</span>
|
|
<span className="text-muted-foreground/50">/</span>
|
|
<span className="text-foreground font-medium">입고관리</span>
|
|
</div>
|
|
|
|
{/* 검색 영역 */}
|
|
<DynamicSearchFilter
|
|
tableName="inbound_mng"
|
|
filterId="c16-receiving"
|
|
onFilterChange={setSearchFilters}
|
|
externalFilterConfig={ts.filterConfig}
|
|
dataCount={data.length}
|
|
extraActions={
|
|
<div className="flex items-center gap-2">
|
|
<Button size="sm" onClick={openRegisterModal} className="h-9">
|
|
<Plus className="mr-1 h-4 w-4" />
|
|
입고 등록
|
|
</Button>
|
|
<div className="h-4 w-px bg-border" />
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={handleDelete}
|
|
disabled={checkedIds.length === 0}
|
|
className="h-9 text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10"
|
|
>
|
|
<Trash2 className="mr-1 h-4 w-4" />
|
|
삭제 ({checkedIds.length})
|
|
</Button>
|
|
</div>
|
|
}
|
|
/>
|
|
|
|
{/* 입고 목록 테이블 */}
|
|
<div className="flex-1 overflow-hidden rounded-lg border bg-card">
|
|
<div className="flex items-center justify-between border-b bg-muted/50 px-4 py-2.5">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="text-[13px] font-bold">입고 목록</h3>
|
|
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">
|
|
{data.length}건
|
|
</span>
|
|
</div>
|
|
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
|
|
<Settings2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-auto">
|
|
<Table>
|
|
<TableHeader className="sticky top-0 z-10">
|
|
<TableRow className="bg-muted hover:bg-muted">
|
|
<TableHead className="w-[40px] text-center">
|
|
<Checkbox
|
|
checked={allChecked}
|
|
onCheckedChange={toggleCheckAll}
|
|
/>
|
|
</TableHead>
|
|
{ts.isVisible("inbound_number") && <TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">입고번호</TableHead>}
|
|
{ts.isVisible("inbound_type") && <TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">입고유형</TableHead>}
|
|
{ts.isVisible("inbound_date") && <TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">입고일</TableHead>}
|
|
{ts.isVisible("reference_number") && <TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">참조번호</TableHead>}
|
|
{ts.isVisible("source_type") && <TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">데이터출처</TableHead>}
|
|
{ts.isVisible("supplier_name") && <TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">공급처</TableHead>}
|
|
{ts.isVisible("item_number") && <TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목코드</TableHead>}
|
|
{ts.isVisible("item_name") && <TableHead className="min-w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목명</TableHead>}
|
|
{ts.isVisible("spec") && <TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>}
|
|
{ts.isVisible("inbound_qty") && <TableHead className="w-[80px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">입고수량</TableHead>}
|
|
{ts.isVisible("unit_price") && <TableHead className="w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단가</TableHead>}
|
|
{ts.isVisible("total_amount") && <TableHead className="w-[100px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">금액</TableHead>}
|
|
{ts.isVisible("warehouse_name") && <TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">창고</TableHead>}
|
|
{ts.isVisible("inbound_status") && <TableHead className="w-[90px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">입고상태</TableHead>}
|
|
{ts.isVisible("remark") && <TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">비고</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="h-40 text-center"
|
|
>
|
|
<div className="flex flex-col items-center gap-1.5 text-muted-foreground">
|
|
<Inbox className="h-10 w-10 opacity-30" />
|
|
<p className="text-sm font-medium">등록된 입고 내역이 없어요</p>
|
|
<p className="text-xs text-muted-foreground/70">
|
|
입고 등록 버튼을 클릭하여 입고를 추가해 보세요
|
|
</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>
|
|
{ts.isVisible("inbound_number") && <TableCell className="max-w-[130px] truncate font-medium" title={row.inbound_number}>
|
|
{row.inbound_number}
|
|
</TableCell>}
|
|
{ts.isVisible("inbound_type") && <TableCell>
|
|
<Badge
|
|
variant={getTypeVariant(row.inbound_type)}
|
|
className="text-[11px]"
|
|
>
|
|
{row.inbound_type || "-"}
|
|
</Badge>
|
|
</TableCell>}
|
|
{ts.isVisible("inbound_date") && <TableCell className="text-[13px]">
|
|
{row.inbound_date
|
|
? new Date(row.inbound_date).toLocaleDateString("ko-KR")
|
|
: "-"}
|
|
</TableCell>}
|
|
{ts.isVisible("reference_number") && <TableCell className="max-w-[120px] truncate text-[13px]" title={row.reference_number || "-"}>
|
|
{row.reference_number || "-"}
|
|
</TableCell>}
|
|
{ts.isVisible("source_type") && <TableCell className="text-[13px]">
|
|
{row.source_table
|
|
? SOURCE_TABLE_LABEL[row.source_table] || row.source_table
|
|
: "-"}
|
|
</TableCell>}
|
|
{ts.isVisible("supplier_name") && <TableCell className="max-w-[120px] truncate text-[13px]" title={row.supplier_name || "-"}>
|
|
{row.supplier_name || "-"}
|
|
</TableCell>}
|
|
{ts.isVisible("item_number") && <TableCell className="max-w-[130px] truncate text-[13px]" title={row.item_number || "-"}>
|
|
{row.item_number || "-"}
|
|
</TableCell>}
|
|
{ts.isVisible("item_name") && <TableCell className="max-w-[150px] truncate text-[13px]" title={row.item_name || "-"}>{row.item_name || "-"}</TableCell>}
|
|
{ts.isVisible("spec") && <TableCell className="max-w-[100px] truncate text-[13px]" title={row.spec || "-"}>{row.spec || "-"}</TableCell>}
|
|
{ts.isVisible("inbound_qty") && <TableCell className="text-right text-[13px] font-semibold">
|
|
{Number(row.inbound_qty || 0).toLocaleString()}
|
|
</TableCell>}
|
|
{ts.isVisible("unit_price") && <TableCell className="text-right text-[13px]">
|
|
{Number(row.unit_price || 0).toLocaleString()}
|
|
</TableCell>}
|
|
{ts.isVisible("total_amount") && <TableCell className="text-right text-[13px] font-semibold">
|
|
{Number(row.total_amount || 0).toLocaleString()}
|
|
</TableCell>}
|
|
{ts.isVisible("warehouse_name") && <TableCell className="text-[13px]">
|
|
{row.warehouse_name || row.warehouse_code || "-"}
|
|
</TableCell>}
|
|
{ts.isVisible("inbound_status") && <TableCell className="text-center">
|
|
<Badge
|
|
variant={getStatusVariant(row.inbound_status)}
|
|
className="text-[11px]"
|
|
>
|
|
{row.inbound_status || "-"}
|
|
</Badge>
|
|
</TableCell>}
|
|
{ts.isVisible("remark") && <TableCell className="max-w-[120px] truncate text-[13px]" title={row.memo || "-"}>
|
|
{row.memo || "-"}
|
|
</TableCell>}
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 입고 등록 모달 */}
|
|
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
|
<DialogContent className="sm:max-w-[1600px] w-[95vw] h-[90vh] p-0 flex flex-col overflow-hidden">
|
|
<DialogHeader className="shrink-0 px-6 pt-4 pb-2 border-b">
|
|
<DialogTitle>입고 등록</DialogTitle>
|
|
<DialogDescription>
|
|
입고유형을 선택하고, 좌측에서 근거 데이터를 검색하여 추가해 주세요.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="flex-1 overflow-hidden flex flex-col">
|
|
|
|
{/* 입고유형 선택 */}
|
|
<div className="flex items-center gap-4 border-b bg-muted/30 px-6 py-2.5">
|
|
<Label className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wide">입고유형</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-2.5">
|
|
<Input
|
|
placeholder={
|
|
modalInboundType === "구매입고"
|
|
? "발주번호 / 품목명 / 공급처"
|
|
: modalInboundType === "반품입고"
|
|
? "출하번호 / 품목명"
|
|
: "품목번호 / 품목명"
|
|
}
|
|
value={sourceKeyword}
|
|
onChange={(e) => setSourceKeyword(e.target.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && searchSourceData()}
|
|
className="h-9 flex-1 text-xs"
|
|
/>
|
|
<Button size="sm" onClick={searchSourceData} className="h-9">
|
|
<Search className="mr-1 h-3 w-3" />
|
|
검색
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 소스 데이터 테이블 */}
|
|
<div className="flex-1 overflow-auto">
|
|
<div className="flex items-center gap-2 px-4 py-2.5 bg-muted/50 border-b">
|
|
<h4 className="text-[13px] font-bold">
|
|
{modalInboundType === "구매입고"
|
|
? "미입고 발주 목록"
|
|
: modalInboundType === "반품입고"
|
|
? "출하 목록"
|
|
: "품목 목록"}
|
|
</h4>
|
|
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">
|
|
{sourceTotalCount}건
|
|
</span>
|
|
</div>
|
|
|
|
{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>
|
|
|
|
{/* 페이징 */}
|
|
{sourceTotalCount > 0 && (
|
|
<div className="flex shrink-0 items-center justify-between border-t bg-muted/30 px-4 py-2">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-muted-foreground text-[11px]">표시:</span>
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
max={500}
|
|
value={sourcePageSize}
|
|
onChange={(e) => {
|
|
const v = parseInt(e.target.value, 10);
|
|
if (v > 0) {
|
|
setSourcePageSize(v);
|
|
setSourcePage(1);
|
|
loadSourceData(modalInboundType, sourceKeyword || undefined, 1);
|
|
}
|
|
}}
|
|
className="h-7 w-[60px] text-center text-[11px]"
|
|
/>
|
|
<span className="text-muted-foreground text-[11px]">
|
|
총 {sourceTotalCount}건
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Button variant="outline" size="icon" className="h-7 w-7" disabled={sourcePage <= 1}
|
|
onClick={() => { setSourcePage(1); loadSourceData(modalInboundType, sourceKeyword || undefined, 1); }}>
|
|
<ChevronsLeft className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<Button variant="outline" size="icon" className="h-7 w-7" disabled={sourcePage <= 1}
|
|
onClick={() => { const p = sourcePage - 1; setSourcePage(p); loadSourceData(modalInboundType, sourceKeyword || undefined, p); }}>
|
|
<ChevronLeft className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<span className="px-2 text-xs font-medium">{sourcePage} / {Math.max(1, Math.ceil(sourceTotalCount / sourcePageSize))}</span>
|
|
<Button variant="outline" size="icon" className="h-7 w-7" disabled={sourcePage >= Math.ceil(sourceTotalCount / sourcePageSize)}
|
|
onClick={() => { const p = sourcePage + 1; setSourcePage(p); loadSourceData(modalInboundType, sourceKeyword || undefined, p); }}>
|
|
<ChevronRight className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<Button variant="outline" size="icon" className="h-7 w-7" disabled={sourcePage >= Math.ceil(sourceTotalCount / sourcePageSize)}
|
|
onClick={() => { const p = Math.ceil(sourceTotalCount / sourcePageSize); setSourcePage(p); loadSourceData(modalInboundType, sourceKeyword || undefined, p); }}>
|
|
<ChevronsRight className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ResizablePanel>
|
|
|
|
<ResizableHandle withHandle onPointerDown={(e) => e.stopPropagation()} />
|
|
|
|
{/* 우측: 입고 정보 + 선택 품목 */}
|
|
<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-[13px] font-bold text-foreground">입고 정보</h4>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<Label className="text-[11px] font-semibold text-muted-foreground">입고번호</Label>
|
|
<Input
|
|
value={modalInboundNo}
|
|
readOnly
|
|
className="bg-muted h-9 text-xs"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-[11px] font-semibold text-muted-foreground">
|
|
입고일 <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Input
|
|
type="date"
|
|
value={modalInboundDate}
|
|
onChange={(e) => setModalInboundDate(e.target.value)}
|
|
className="h-9 text-xs"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-[11px] font-semibold text-muted-foreground">창고</Label>
|
|
<Select value={modalWarehouse} onValueChange={setModalWarehouse}>
|
|
<SelectTrigger className="h-9 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] font-semibold text-muted-foreground">위치</Label>
|
|
<Input
|
|
value={modalLocation}
|
|
onChange={(e) => setModalLocation(e.target.value)}
|
|
placeholder="위치 입력"
|
|
className="h-9 text-xs"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-[11px] font-semibold text-muted-foreground">검수자</Label>
|
|
<Input
|
|
value={modalInspector}
|
|
onChange={(e) => setModalInspector(e.target.value)}
|
|
placeholder="검수자"
|
|
className="h-9 text-xs"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-[11px] font-semibold text-muted-foreground">담당자</Label>
|
|
<Input
|
|
value={modalManager}
|
|
onChange={(e) => setModalManager(e.target.value)}
|
|
placeholder="담당자"
|
|
className="h-9 text-xs"
|
|
/>
|
|
</div>
|
|
<div className="col-span-2">
|
|
<Label className="text-[11px] font-semibold text-muted-foreground">메모</Label>
|
|
<Input
|
|
value={modalMemo}
|
|
onChange={(e) => setModalMemo(e.target.value)}
|
|
placeholder="메모"
|
|
className="h-9 text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 선택된 품목 테이블 */}
|
|
<div className="flex-1 overflow-auto">
|
|
<div className="flex items-center gap-2 border-b bg-muted/50 px-4 py-2.5">
|
|
<h4 className="text-[13px] font-bold">입고 처리 품목</h4>
|
|
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">
|
|
{selectedItems.length}건
|
|
</span>
|
|
</div>
|
|
|
|
{selectedItems.length === 0 ? (
|
|
<div className="flex h-32 flex-col items-center justify-center gap-1.5 text-muted-foreground border-2 border-dashed rounded-lg m-4">
|
|
<Inbox className="h-8 w-8 opacity-30" />
|
|
<p className="text-xs font-medium">좌측에서 품목을 선택하여 추가해 주세요</p>
|
|
</div>
|
|
) : (
|
|
<Table>
|
|
<TableHeader className="sticky top-0 z-10">
|
|
<TableRow className="bg-muted hover:bg-muted">
|
|
<TableHead className="w-[30px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">No</TableHead>
|
|
<TableHead className="p-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목명</TableHead>
|
|
<TableHead className="p-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">참조번호</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-[13px] 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>
|
|
|
|
</div>
|
|
<DialogFooter className="shrink-0 border-t px-0 py-0">
|
|
<div className="flex w-full items-center justify-between px-6 py-2.5">
|
|
<div className="text-muted-foreground text-xs">
|
|
{selectedItems.length > 0 ? (
|
|
<span className="flex items-center gap-3">
|
|
<span className="font-semibold text-foreground">{totalSummary.count}건</span>
|
|
<span>수량 합계: <span className="font-medium text-foreground">{totalSummary.qty.toLocaleString()}</span></span>
|
|
<span>금액 합계: <span className="font-medium text-foreground">{totalSummary.amount.toLocaleString()}원</span></span>
|
|
</span>
|
|
) : (
|
|
"품목을 추가해 주세요"
|
|
)}
|
|
</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>
|
|
</div>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<TableSettingsModal
|
|
open={ts.open}
|
|
onOpenChange={ts.setOpen}
|
|
tableName={ts.tableName}
|
|
settingsId={ts.settingsId}
|
|
defaultVisibleKeys={ts.defaultVisibleKeys}
|
|
onSave={ts.applySettings}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- 소스 데이터 테이블 컴포넌트들 ---
|
|
|
|
function SourcePurchaseOrderTable({
|
|
data,
|
|
onAdd,
|
|
selectedKeys,
|
|
}: {
|
|
data: PurchaseOrderSource[];
|
|
onAdd: (po: PurchaseOrderSource) => void;
|
|
selectedKeys: string[];
|
|
}) {
|
|
if (data.length === 0) {
|
|
return (
|
|
<div className="flex h-32 flex-col items-center justify-center gap-1.5 text-muted-foreground border-2 border-dashed rounded-lg m-4">
|
|
<Inbox className="h-8 w-8 opacity-30" />
|
|
<p className="text-xs font-medium">검색 버튼을 눌러 발주 데이터를 조회해 주세요</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Table>
|
|
<TableHeader className="sticky top-0 z-10">
|
|
<TableRow className="bg-muted hover:bg-muted">
|
|
<TableHead className="w-[40px] p-2" />
|
|
<TableHead className="p-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">발주번호</TableHead>
|
|
<TableHead className="p-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">공급처</TableHead>
|
|
<TableHead className="p-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목</TableHead>
|
|
<TableHead className="w-[70px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">발주수량</TableHead>
|
|
<TableHead className="w-[70px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">입고수량</TableHead>
|
|
<TableHead className="w-[70px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">미입고</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-primary">
|
|
{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="flex h-32 flex-col items-center justify-center gap-1.5 text-muted-foreground border-2 border-dashed rounded-lg m-4">
|
|
<Inbox className="h-8 w-8 opacity-30" />
|
|
<p className="text-xs font-medium">검색 버튼을 눌러 출하 데이터를 조회해 주세요</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Table>
|
|
<TableHeader className="sticky top-0 z-10">
|
|
<TableRow className="bg-muted hover:bg-muted">
|
|
<TableHead className="w-[40px] p-2" />
|
|
<TableHead className="p-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">출하번호</TableHead>
|
|
<TableHead className="p-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">출하일</TableHead>
|
|
<TableHead className="p-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">거래처</TableHead>
|
|
<TableHead className="p-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목</TableHead>
|
|
<TableHead className="w-[70px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">출하수량</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="flex h-32 flex-col items-center justify-center gap-1.5 text-muted-foreground border-2 border-dashed rounded-lg m-4">
|
|
<Inbox className="h-8 w-8 opacity-30" />
|
|
<p className="text-xs font-medium">검색 버튼을 눌러 품목 데이터를 조회해 주세요</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Table>
|
|
<TableHeader className="sticky top-0 z-10">
|
|
<TableRow className="bg-muted hover:bg-muted">
|
|
<TableHead className="w-[40px] p-2" />
|
|
<TableHead className="p-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목</TableHead>
|
|
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>
|
|
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">재질</TableHead>
|
|
<TableHead className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단위</TableHead>
|
|
<TableHead className="w-[80px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">기준가</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>
|
|
);
|
|
}
|