Files
pipeline/frontend/app/(main)/COMPANY_8/logistics/receiving/page.tsx
T
kjs 2f50d7d809 fix: Enhance file handling and inspection method mapping
- Updated fileController to include Cross-Origin-Resource-Policy headers for improved security and file handling.
- Added error handling for file streams to ensure robust responses in case of read errors.
- Modified materialStatusController to correctly map material IDs to their respective codes for inventory stock queries.
- Enhanced moldController to include warranty shot count in mold creation and update processes.
- Improved item inspection page by adding inspection method category loading and mapping, ensuring accurate display of method labels in the UI.

These changes aim to enhance the overall functionality and user experience across multiple companies by ensuring proper file handling, data mapping, and error management.
2026-04-10 15:59:38 +09:00

1813 lines
77 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,
ChevronDown,
ChevronLeft,
ChevronsLeft,
ChevronsRight,
Settings2,
Filter,
Check,
ArrowUp,
ArrowDown,
} from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
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";
// EDataTable 제거 — 마스터-디테일 그룹 테이블로 교체
// 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: "비고" },
];
// 마스터 헤더 레이아웃 (입고번호 뒤, 디테일 7컬럼 위에 colSpan으로 맵핑)
const MASTER_BODY_LAYOUT = [
{ key: "inbound_type", label: "입고유형", colSpan: 1 },
{ key: "inbound_date", label: "입고일", colSpan: 1 },
{ key: "reference_number", label: "참조번호", colSpan: 1 },
{ key: "supplier_name", label: "공급처", colSpan: 1 },
{ key: "warehouse_name", label: "창고", colSpan: 1 },
{ key: "inbound_status", label: "입고상태", colSpan: 1 },
{ key: "memo", label: "비고", colSpan: 1 },
];
// 디테일 헤더 컬럼
const DETAIL_HEADER_COLS = [
{ key: "source_table", label: "출처" },
{ key: "item_number", label: "품목코드" },
{ key: "item_name", label: "품목명" },
{ key: "spec", label: "규격" },
{ key: "inbound_qty", label: "입고수량" },
{ key: "unit_price", label: "단가" },
{ key: "total_amount", label: "금액" },
];
// 마스터 필드 키 목록 (필터 분류용)
const MASTER_KEYS = new Set(["inbound_number", ...MASTER_BODY_LAYOUT.map((c) => c.key)]);
// 디테일 필드 키 매핑 (GRID_COLUMNS key → DETAIL_HEADER_COLS key)
const DETAIL_KEY_MAP: Record<string, string> = {
source_type: "source_table",
item_number: "item_number",
item_name: "item_name",
spec: "spec",
inbound_qty: "inbound_qty",
unit_price: "unit_price",
total_amount: "total_amount",
};
// 헤더 필터 Popover
function HeaderFilterPopover({
colKey, colLabel, uniqueValues, filterValues, onToggle, onClear,
}: {
colKey: string;
colLabel: string;
uniqueValues: string[];
filterValues: Set<string>;
onToggle: (colKey: string, value: string) => void;
onClear: (colKey: string) => void;
}) {
const [filterSearch, setFilterSearch] = useState("");
const hasFilter = filterValues.size > 0;
const filteredValues = uniqueValues.filter(
(v) => !filterSearch || v.toLowerCase().includes(filterSearch.toLowerCase())
);
return (
<Popover>
<PopoverTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className={cn(
"hover:bg-primary/20 rounded p-0.5 transition-colors shrink-0",
hasFilter && "text-primary bg-primary/10",
)}
title="필터"
>
<Filter className="h-3 w-3" />
</button>
</PopoverTrigger>
<PopoverContent className="w-56 p-2" align="start" onClick={(e) => e.stopPropagation()}>
<div className="space-y-2">
<div className="flex items-center justify-between border-b pb-2">
<span className="text-xs font-medium">: {colLabel}</span>
{hasFilter && (
<button onClick={() => onClear(colKey)} className="text-primary text-xs hover:underline">
</button>
)}
</div>
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground" />
<Input
value={filterSearch}
onChange={(e) => setFilterSearch(e.target.value)}
placeholder="검색..."
className="h-7 text-xs pl-7"
/>
</div>
<div className="max-h-52 space-y-0.5 overflow-y-auto">
{filteredValues.slice(0, 100).map((val) => {
const isSelected = filterValues.has(val);
return (
<div
key={val}
className={cn(
"hover:bg-muted flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs",
isSelected && "bg-primary/10",
)}
onClick={() => onToggle(colKey, val)}
>
<div className={cn(
"flex h-4 w-4 items-center justify-center rounded border shrink-0",
isSelected ? "bg-primary border-primary" : "border-input",
)}>
{isSelected && <Check className="text-primary-foreground h-3 w-3" />}
</div>
<span className="truncate">{val || "(빈 값)"}</span>
</div>
);
})}
{filteredValues.length > 100 && (
<div className="text-muted-foreground px-2 py-1 text-xs">
... {filteredValues.length - 100}
</div>
)}
</div>
</div>
</PopoverContent>
</Popover>
);
}
// 입고유형 옵션
const INBOUND_TYPES = [
{ value: "구매입고", label: "구매입고" },
{ value: "외주입고", label: "외주입고" },
{ value: "사급자재입고", label: "사급자재입고" },
{ value: "반품입고", label: "반품입고" },
{ value: "기타입고", label: "기타입고" },
];
// 입고유형 카테고리 코드→라벨 매핑
const INBOUND_TYPE_CODE_MAP: Record<string, string> = {
CAT_MLYTB8ON_A3AU: "구매입고",
CAT_MLYTBMH6_9AB7: "외주입고",
CAT_MLYTBSLW_5N81: "사급자재입고",
CAT_MLYTBGEV_N23U: "반품입고",
CAT_MLYTBYLU_0Z5T: "기타입고",
};
const resolveInboundType = (v: string) => INBOUND_TYPE_CODE_MAP[v] || v || "-";
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);
// ts.visibleColumns 기반 마스터/디테일 컬럼 계산
const visibleMasterLayout = useMemo(() => {
const ordered: typeof MASTER_BODY_LAYOUT = [];
for (const vc of ts.visibleColumns) {
const m = MASTER_BODY_LAYOUT.find((ml) => ml.key === vc.key);
if (m) ordered.push(m);
}
return ordered.length > 0 ? ordered : MASTER_BODY_LAYOUT;
}, [ts.visibleColumns]);
const visibleDetailCols = useMemo(() => {
const ordered: typeof DETAIL_HEADER_COLS = [];
for (const vc of ts.visibleColumns) {
const detailKey = DETAIL_KEY_MAP[vc.key];
if (detailKey) {
const d = DETAIL_HEADER_COLS.find((dc) => dc.key === detailKey);
if (d) ordered.push(d);
}
}
return ordered.length > 0 ? ordered : DETAIL_HEADER_COLS;
}, [ts.visibleColumns]);
const TOTAL_COLS = 3 + visibleMasterLayout.length;
// 목록 데이터
const [data, setData] = useState<InboundItem[]>([]);
const [loading, setLoading] = useState(false);
const [checkedIds, setCheckedIds] = useState<string[]>([]);
// 검색 필터
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 마스터-디테일 그룹 테이블
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
const [closingOrders, setClosingOrders] = useState<Set<string>>(new Set());
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
const [sortState, setSortState] = useState<{ key: string; direction: "asc" | "desc" } | null>(null);
// 등록 모달
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 filteredGroups = useMemo(() => {
// 1차: inbound_number 기준 그룹핑
const allGroups: Record<string, { master: InboundItem; details: InboundItem[] }> = {};
for (const row of data) {
const key = row.inbound_number || "_no_inbound";
if (!allGroups[key]) {
allGroups[key] = { master: row, details: [] };
}
allGroups[key].details.push(row);
}
// 마스터 필터 / 디테일 필터 분리
const masterFilters: Record<string, Set<string>> = {};
const detailFilters: Record<string, Set<string>> = {};
for (const [colKey, values] of Object.entries(headerFilters)) {
if (values.size === 0) continue;
if (MASTER_KEYS.has(colKey)) masterFilters[colKey] = values;
else detailFilters[colKey] = values;
}
// 2차: 마스터 필터 적용 (그룹 단위)
let entries = Object.entries(allGroups);
if (Object.keys(masterFilters).length > 0) {
entries = entries.filter(([, group]) =>
Object.entries(masterFilters).every(([colKey, values]) => {
let raw = (group.master as any)?.[colKey] ?? "";
// 입고유형은 코드→라벨 변환된 값으로 비교
if (colKey === "inbound_type") raw = resolveInboundType(String(raw));
return values.has(String(raw));
})
);
}
// 3차: 디테일 필터 적용 (행 단위)
if (Object.keys(detailFilters).length > 0) {
entries = entries
.map(([inboundNo, group]) => {
const filtered = group.details.filter((row) =>
Object.entries(detailFilters).every(([colKey, values]) => {
let cellVal = (row as any)[colKey] != null ? String((row as any)[colKey]) : "";
if (colKey === "source_table") cellVal = SOURCE_TABLE_LABEL[cellVal] || cellVal;
return values.has(cellVal);
})
);
return [inboundNo, { ...group, details: filtered }] as [string, typeof group];
})
.filter(([, group]) => group.details.length > 0);
}
// 4차: 정렬
if (sortState) {
const { key, direction } = sortState;
if (MASTER_KEYS.has(key)) {
entries.sort(([, a], [, b]) => {
let av: any = (a.master as any)?.[key] ?? "";
let bv: any = (b.master as any)?.[key] ?? "";
if (key === "inbound_type") { av = resolveInboundType(String(av)); bv = resolveInboundType(String(bv)); }
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
} else {
entries.forEach(([, group]) => {
group.details.sort((a, b) => {
const av = (a as any)[key] ?? "";
const bv = (b as any)[key] ?? "";
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
});
}
}
return Object.fromEntries(entries);
}, [data, headerFilters, sortState]);
// 마스터 컬럼별 고유값 (마스터 헤더 필터용)
const masterUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
const seenMasters = new Map<string, InboundItem>();
data.forEach((row) => {
if (row.inbound_number && !seenMasters.has(row.inbound_number)) {
seenMasters.set(row.inbound_number, row);
}
});
const masters = Array.from(seenMasters.values());
for (const col of [{ key: "inbound_number", label: "입고번호" }, ...MASTER_BODY_LAYOUT.map(({ key, label }) => ({ key, label }))]) {
const values = new Set<string>();
masters.forEach((m) => {
let val = (m as any)?.[col.key];
if (val !== null && val !== undefined && val !== "") {
if (col.key === "inbound_type") val = resolveInboundType(String(val));
values.add(String(val));
}
});
result[col.key] = Array.from(values).sort();
}
return result;
}, [data]);
// 디테일 컬럼별 고유값 (디테일 서브헤더 필터용)
const columnUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
for (const col of DETAIL_HEADER_COLS) {
const values = new Set<string>();
data.forEach((row) => {
let val = (row as any)[col.key];
if (val !== null && val !== undefined && val !== "") {
if (col.key === "source_table") val = SOURCE_TABLE_LABEL[val] || val;
values.add(String(val));
}
});
result[col.key] = Array.from(values).sort();
}
return result;
}, [data]);
// 헤더 필터 토글/초기화
const toggleHeaderFilter = (colKey: string, value: string) => {
setHeaderFilters((prev) => {
const next = { ...prev };
const set = new Set(next[colKey] || []);
if (set.has(value)) set.delete(value); else set.add(value);
if (set.size === 0) delete next[colKey]; else next[colKey] = set;
return next;
});
};
const clearHeaderFilter = (colKey: string) => {
setHeaderFilters((prev) => {
const next = { ...prev };
delete next[colKey];
return next;
});
};
const handleSort = (key: string) => {
setSortState((prev) =>
prev?.key === key
? prev.direction === "asc" ? { key, direction: "desc" } : null
: { key, direction: "asc" }
);
};
// 삭제
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;
}
if (!modalWarehouse) {
toast.error("창고를 선택해주세요.");
return;
}
setSaving(true);
try {
const res = await createReceiving({
inbound_number: modalInboundNo,
inbound_date: modalInboundDate,
warehouse_code: modalWarehouse,
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 border-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">
{Object.keys(filteredGroups).length}
</span>
</div>
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
<Settings2 className="h-4 w-4" />
</Button>
</div>
<div className="h-[calc(100%-44px)] overflow-auto">
<Table style={{ minWidth: `${216 + visibleMasterLayout.length * 120}px` }}>
<colgroup>
<col style={{ width: "40px" }} />
<col style={{ width: "36px" }} />
<col style={{ width: "140px" }} />
{visibleMasterLayout.map((col) => (
<col key={col.key} style={{ width: ts.getWidth(col.key) ? `${ts.getWidth(col.key)}px` : undefined }} />
))}
</colgroup>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead
className="text-center cursor-pointer"
onClick={() => {
const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id));
const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
setCheckedIds(allChecked ? [] : allFilteredIds);
}}
>
<Checkbox
checked={(() => {
const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id));
return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
})()}
onCheckedChange={() => {}}
/>
</TableHead>
<TableHead />
{/* 입고번호 (별도 컬럼) */}
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort("inbound_number")}>
<span className="truncate"></span>
{sortState?.key === "inbound_number" && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(masterUniqueValues["inbound_number"] || []).length > 0 && (
<HeaderFilterPopover
colKey="inbound_number" colLabel="입고번호"
uniqueValues={masterUniqueValues["inbound_number"] || []}
filterValues={headerFilters["inbound_number"] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
{/* 마스터 필드 헤더 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => (
<TableHead key={col.key} colSpan={col.colSpan} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
<span className="truncate">{col.label}</span>
{sortState?.key === col.key && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(masterUniqueValues[col.key] || []).length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={masterUniqueValues[col.key] || []}
filterValues={headerFilters[col.key] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={TOTAL_COLS} className="py-16 text-center">
<Loader2 className="mx-auto h-6 w-6 animate-spin text-primary" />
</TableCell>
</TableRow>
) : Object.keys(filteredGroups).length === 0 ? (
<TableRow>
<TableCell colSpan={TOTAL_COLS} className="py-16 text-center">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Inbox className="h-8 w-8 opacity-30" />
<span className="text-sm"> </span>
</div>
</TableCell>
</TableRow>
) : (
Object.entries(filteredGroups).map(([inboundNo, group]) => {
const isExpanded = expandedOrders.has(inboundNo);
const detailIds = group.details.map((d) => d.id);
const allDetailChecked = detailIds.length > 0 && detailIds.every((id) => checkedIds.includes(id));
const someDetailChecked = detailIds.some((id) => checkedIds.includes(id));
const master = group.master;
return (
<React.Fragment key={inboundNo}>
{/* 마스터 행 */}
<TableRow
style={{ borderTop: "2px solid hsl(var(--border))" }}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent font-semibold tree-master-row",
allDetailChecked && "border-l-primary bg-primary/5"
)}
onClick={() => {
if (expandedOrders.has(inboundNo)) {
setClosingOrders((prev) => new Set(prev).add(inboundNo));
setTimeout(() => {
setExpandedOrders((prev) => { const next = new Set(prev); next.delete(inboundNo); return next; });
setClosingOrders((prev) => { const next = new Set(prev); next.delete(inboundNo); return next; });
}, 200);
} else {
setExpandedOrders((prev) => new Set(prev).add(inboundNo));
}
}}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) => {
if (allDetailChecked) return prev.filter((id) => !detailIds.includes(id));
return [...new Set([...prev, ...detailIds])];
});
}}
>
<Checkbox
checked={allDetailChecked}
data-state={someDetailChecked && !allDetailChecked ? "indeterminate" : undefined}
onCheckedChange={() => {}}
/>
</TableCell>
<TableCell className="text-center">
{isExpanded
? <ChevronDown className="h-4 w-4 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 text-muted-foreground" />
}
</TableCell>
{/* 입고번호 */}
<TableCell className="font-mono whitespace-nowrap">
{inboundNo}
<span className="ml-1.5 text-xs font-normal text-muted-foreground opacity-60">({group.details.length})</span>
</TableCell>
{/* 마스터 필드 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => {
switch (col.key) {
case "inbound_type": return (
<TableCell key={col.key} className="text-[13px]">
<Badge variant={getTypeVariant(resolveInboundType(master.inbound_type))} className="text-[11px]">
{resolveInboundType(master.inbound_type)}
</Badge>
</TableCell>
);
case "inbound_date": return (
<TableCell key={col.key} className="text-[13px] whitespace-nowrap">
{master.inbound_date ? new Date(master.inbound_date).toLocaleDateString("ko-KR") : "-"}
</TableCell>
);
case "reference_number": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.reference_number || ""}</span>
</TableCell>
);
case "supplier_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.supplier_name || ""}</span>
</TableCell>
);
case "warehouse_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.warehouse_name || master.warehouse_code || ""}</span>
</TableCell>
);
case "inbound_status": return (
<TableCell key={col.key} className="text-[13px]">
<Badge variant={getStatusVariant(master.inbound_status)} className="text-[11px]">
{master.inbound_status || "-"}
</Badge>
</TableCell>
);
case "memo": return (
<TableCell key={col.key} className="text-muted-foreground text-[13px]">
<span className="block truncate max-w-[110px]">{master.memo || ""}</span>
</TableCell>
);
default: return <TableCell key={col.key} className="text-[13px]">{(master as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
{/* 디테일 서브 헤더 (펼쳤을 때만) */}
{isExpanded && (
<TableRow
className={cn(
"border-l-[3px] border-l-primary/30 bg-muted/60",
closingOrders.has(inboundNo) ? "tree-detail-row-closing" : "tree-detail-row",
)}
>
<TableCell />
<TableCell />
<TableCell />
{visibleDetailCols.map((col) => {
const isRight = ["inbound_qty", "unit_price", "total_amount"].includes(col.key);
const isSorted = sortState?.key === col.key;
const uniqueVals = Array.from(new Set(
group.details.map((d) => {
let v = (d as any)[col.key];
if (col.key === "source_table") v = SOURCE_TABLE_LABEL[v] || v;
return v;
}).filter((v: any) => v != null && v !== "").map(String)
)).sort();
const filterVals = headerFilters[col.key] || new Set<string>();
return (
<TableCell
key={col.key}
className={cn(
"text-[10px] font-bold uppercase tracking-wider text-muted-foreground/70 select-none",
isRight && "text-right",
)}
>
<div className={cn("inline-flex items-center gap-1", isRight && "justify-end w-full")}>
<div
className="flex items-center gap-1 cursor-pointer min-w-0"
onClick={() => handleSort(col.key)}
>
<span className="truncate">{col.label}</span>
{isSorted && (
sortState!.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{uniqueVals.length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={uniqueVals} filterValues={filterVals}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableCell>
);
})}
</TableRow>
)}
{/* 디테일 행 (펼쳤을 때만) */}
{isExpanded && group.details.map((row, detailIdx) => {
const isClosing = closingOrders.has(inboundNo);
const isChecked = checkedIds.includes(row.id);
return (
<TableRow
key={`${row.id}-${detailIdx}`}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isClosing ? "tree-detail-row-closing" : "tree-detail-row",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
<TableCell className="relative">
<div className="tree-connector" data-last={detailIdx === group.details.length - 1 ? "true" : "false"} />
</TableCell>
<TableCell />
{visibleDetailCols.map((col) => {
switch (col.key) {
case "source_table": return <TableCell key={col.key} className="text-[13px]">{row.source_table ? SOURCE_TABLE_LABEL[row.source_table] || row.source_table : "-"}</TableCell>;
case "item_number": return <TableCell key={col.key} className="font-mono text-[13px]">{row.item_number || ""}</TableCell>;
case "item_name": return <TableCell key={col.key} className="text-[13px] max-w-[160px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>;
case "spec": return <TableCell key={col.key} className="text-[13px] text-muted-foreground">{row.spec || ""}</TableCell>;
case "inbound_qty": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.inbound_qty ? Number(row.inbound_qty).toLocaleString() : ""}</TableCell>;
case "unit_price": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>;
case "total_amount": return <TableCell key={col.key} className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>;
default: return <TableCell key={col.key} className="text-[13px]">{(row as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
);
})}
</React.Fragment>
);
})
)}
</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, idx) => {
const isSelected = selectedKeys.includes(`po-${po.id}`);
return (
<TableRow
key={`${po.source_table || 'po'}-${po.id}-${idx}`}
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>
);
}