4267b42fdf
- Removed unnecessary variables and commented-out code related to master-detail grouping in the outbound and receiving pages. - Simplified the header filter and sorting logic to improve performance and readability. - Updated the column mapping and filtering mechanisms to ensure a more efficient data handling process. - These changes aim to enhance the overall user experience and maintainability of the logistics management interface across multiple company implementations.
1661 lines
68 KiB
TypeScript
1661 lines
68 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,
|
|
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,
|
|
updateReceiving,
|
|
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: "비고" },
|
|
];
|
|
|
|
// 총 컬럼 수: 체크박스(1) + GRID_COLUMNS(15) = 16
|
|
const TOTAL_COLS = 16;
|
|
|
|
// 헤더 필터 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);
|
|
|
|
// 목록 데이터
|
|
const [data, setData] = useState<InboundItem[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
|
|
|
// 검색 필터
|
|
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
|
|
|
// 헤더 필터 & 정렬
|
|
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 [editMode, setEditMode] = useState(false);
|
|
const [editItemIds, setEditItemIds] = useState<string[]>([]);
|
|
|
|
// 소스 데이터
|
|
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>("");
|
|
// 카테고리 코드→라벨 매핑 (재질, 단위)
|
|
const [catMap, setCatMap] = useState<Record<string, Record<string, string>>>({});
|
|
|
|
// 구매관리 division 코드 + 재질/단위 카테고리 로드
|
|
useEffect(() => {
|
|
const flatten = (arr: any[]): { code: string; label: string }[] => {
|
|
const result: { code: string; label: string }[] = [];
|
|
for (const v of arr) {
|
|
result.push({ code: v.valueCode || v.value_code || v.code, label: v.valueLabel || v.value_label || v.label });
|
|
if (v.children?.length) result.push(...flatten(v.children));
|
|
}
|
|
return result;
|
|
};
|
|
// division 카테고리에서 "구매관리" 라벨의 코드 조회
|
|
apiClient.get("/table-categories/item_info/division/values").then((res) => {
|
|
const vals = res.data?.data || [];
|
|
const found = vals.find((v: any) => (v.valueLabel || v.value_label || v.label) === "구매관리");
|
|
if (found) setPurchaseDivisionCode(found.value_code || found.code);
|
|
}).catch(() => {});
|
|
// 재질, 단위 카테고리
|
|
const map: Record<string, Record<string, string>> = {};
|
|
Promise.all(
|
|
["material", "unit"].map(async (col) => {
|
|
try {
|
|
const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_16`);
|
|
const items = flatten(res.data?.data || []);
|
|
map[col] = {};
|
|
for (const item of items) map[col][item.code] = item.label;
|
|
} catch { /* skip */ }
|
|
})
|
|
).then(() => setCatMap(map));
|
|
}, []);
|
|
|
|
// 카테고리 코드→라벨 변환
|
|
const resolveCat = useCallback((col: string, code: string) => {
|
|
if (!code) return "";
|
|
return catMap[col]?.[code] || code;
|
|
}, [catMap]);
|
|
|
|
// 목록 조회
|
|
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
|
|
}
|
|
})();
|
|
}, []);
|
|
|
|
// 플랫 행 생성 (입고유형 코드→라벨 변환, source_table→한글 등)
|
|
const flatRows = useMemo(() => {
|
|
return data.map((row) => ({
|
|
...row,
|
|
inbound_type: resolveInboundType(row.inbound_type),
|
|
source_type: row.source_table ? (SOURCE_TABLE_LABEL[row.source_table] || row.source_table) : (row as any).source_type || "",
|
|
}));
|
|
}, [data]);
|
|
|
|
// 컬럼별 고유값 (헤더 필터용)
|
|
const columnUniqueValues = useMemo(() => {
|
|
const result: Record<string, string[]> = {};
|
|
for (const col of GRID_COLUMNS) {
|
|
const values = new Set<string>();
|
|
flatRows.forEach((row) => {
|
|
const val = (row as any)[col.key];
|
|
if (val !== null && val !== undefined && val !== "") values.add(String(val));
|
|
});
|
|
result[col.key] = Array.from(values).sort();
|
|
}
|
|
return result;
|
|
}, [flatRows]);
|
|
|
|
// 필터 + 정렬 적용된 플랫 데이터
|
|
const filteredRows = useMemo(() => {
|
|
let rows = [...flatRows];
|
|
|
|
// 1차: 헤더 필터 적용
|
|
for (const [colKey, values] of Object.entries(headerFilters)) {
|
|
if (values.size === 0) continue;
|
|
rows = rows.filter((row) => {
|
|
const cellVal = (row as any)[colKey] != null ? String((row as any)[colKey]) : "";
|
|
return values.has(cellVal);
|
|
});
|
|
}
|
|
|
|
// 2차: 정렬
|
|
if (sortState) {
|
|
const { key, direction } = sortState;
|
|
rows.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 rows;
|
|
}, [flatRows, headerFilters, sortState]);
|
|
|
|
// 헤더 필터 토글/초기화
|
|
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 = "구매입고";
|
|
setEditMode(false);
|
|
setEditItemIds([]);
|
|
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 openEditModal = (row: InboundItem) => {
|
|
const inNo = row.inbound_number;
|
|
const grouped = data.filter((d) => d.inbound_number === inNo);
|
|
const first = grouped[0] || row;
|
|
|
|
setEditMode(true);
|
|
setEditItemIds(grouped.map((g) => g.id));
|
|
setModalInboundNo(inNo);
|
|
setModalInboundType(first.inbound_type || "구매입고");
|
|
setModalInboundDate(first.inbound_date ? String(first.inbound_date).slice(0, 10) : "");
|
|
setModalWarehouse(first.warehouse_code || "");
|
|
setModalLocation(first.location_code || "");
|
|
setModalInspector((first as any).inspector || "");
|
|
setModalManager((first as any).manager || "");
|
|
setModalMemo(first.memo || "");
|
|
setSelectedItems(
|
|
grouped.map((g) => ({
|
|
key: g.id,
|
|
inbound_type: g.inbound_type || "",
|
|
reference_number: g.reference_number || "",
|
|
supplier_code: (g as any).supplier_code || "",
|
|
supplier_name: g.supplier_name || "",
|
|
item_number: g.item_number || "",
|
|
item_name: g.item_name || "",
|
|
spec: g.spec || "",
|
|
material: (g as any).material || "",
|
|
unit: (g as any).unit || "",
|
|
inbound_qty: Number(g.inbound_qty) || 0,
|
|
unit_price: Number(g.unit_price) || 0,
|
|
total_amount: Number(g.total_amount) || 0,
|
|
source_table: (g as any).source_table || "",
|
|
source_id: (g as any).source_id || "",
|
|
}))
|
|
);
|
|
setSourceKeyword("");
|
|
setPurchaseOrders([]);
|
|
setShipments([]);
|
|
setItems([]);
|
|
setSourcePage(1);
|
|
setSourceTotalCount(0);
|
|
setIsModalOpen(true);
|
|
loadSourceData(first.inbound_type || "구매입고", undefined, 1);
|
|
};
|
|
|
|
// 검색 버튼 클릭 시
|
|
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 {
|
|
if (editMode) {
|
|
// 기존 item 수정 + 삭제된 item 삭제 + 새 item 추가
|
|
const currentKeys = new Set(selectedItems.map((i) => i.key));
|
|
const toDelete = editItemIds.filter((id) => !currentKeys.has(id));
|
|
const toUpdate = selectedItems.filter((i) => editItemIds.includes(i.key));
|
|
const toCreate = selectedItems.filter((i) => !editItemIds.includes(i.key));
|
|
|
|
await Promise.all([
|
|
...toDelete.map((id) => deleteReceiving(id)),
|
|
...toUpdate.map((item) =>
|
|
updateReceiving(item.key, {
|
|
inbound_date: modalInboundDate,
|
|
inbound_qty: item.inbound_qty,
|
|
unit_price: item.unit_price,
|
|
total_amount: item.total_amount,
|
|
warehouse_code: modalWarehouse || undefined,
|
|
location_code: modalLocation || undefined,
|
|
memo: modalMemo || undefined,
|
|
} as any)
|
|
),
|
|
...(toCreate.length > 0
|
|
? [createReceiving({
|
|
inbound_number: modalInboundNo,
|
|
inbound_date: modalInboundDate,
|
|
warehouse_code: modalWarehouse,
|
|
location_code: modalLocation || undefined,
|
|
inspector: modalInspector || undefined,
|
|
manager: modalManager || undefined,
|
|
memo: modalMemo || undefined,
|
|
items: toCreate.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: "대기",
|
|
})),
|
|
})]
|
|
: []),
|
|
]);
|
|
toast.success("입고 정보를 수정했어요");
|
|
setIsModalOpen(false);
|
|
fetchList();
|
|
} else {
|
|
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) {
|
|
toast.success(res.message || "입고 등록 완료");
|
|
setIsModalOpen(false);
|
|
fetchList();
|
|
}
|
|
}
|
|
} catch (err: any) {
|
|
const msg = err?.response?.data?.message || (editMode ? "수정에 실패했어요" : "입고 등록 중 오류가 발생했습니다.");
|
|
toast.error(msg);
|
|
} 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">
|
|
{filteredRows.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 noWrapper style={{ minWidth: "1500px" }}>
|
|
<colgroup>
|
|
<col style={{ width: "40px" }} />
|
|
<col style={{ width: "140px" }} />
|
|
<col style={{ width: "100px" }} />
|
|
<col style={{ width: "100px" }} />
|
|
<col style={{ width: "120px" }} />
|
|
<col style={{ width: "90px" }} />
|
|
<col style={{ width: "110px" }} />
|
|
<col style={{ width: "100px" }} />
|
|
<col style={{ width: "140px" }} />
|
|
<col style={{ width: "90px" }} />
|
|
<col style={{ width: "80px" }} />
|
|
<col style={{ width: "80px" }} />
|
|
<col style={{ width: "100px" }} />
|
|
<col style={{ width: "100px" }} />
|
|
<col style={{ width: "90px" }} />
|
|
<col style={{ width: "120px" }} />
|
|
</colgroup>
|
|
<TableHeader className="sticky top-0 z-10">
|
|
<TableRow className="bg-muted hover:bg-muted">
|
|
<TableHead
|
|
className="text-center cursor-pointer"
|
|
onClick={() => {
|
|
const allFilteredIds = filteredRows.map((r) => r.id);
|
|
const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
|
|
setCheckedIds(allChecked ? [] : allFilteredIds);
|
|
}}
|
|
>
|
|
<Checkbox
|
|
checked={(() => {
|
|
const allFilteredIds = filteredRows.map((r) => r.id);
|
|
return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
|
|
})()}
|
|
onCheckedChange={() => {}}
|
|
/>
|
|
</TableHead>
|
|
{GRID_COLUMNS.map((col) => {
|
|
const isRight = ["inbound_qty", "unit_price", "total_amount"].includes(col.key);
|
|
return (
|
|
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground 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>
|
|
{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>
|
|
{(columnUniqueValues[col.key] || []).length > 0 && (
|
|
<HeaderFilterPopover
|
|
colKey={col.key} colLabel={col.label}
|
|
uniqueValues={columnUniqueValues[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>
|
|
) : filteredRows.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>
|
|
) : (
|
|
filteredRows.map((row) => {
|
|
const isChecked = checkedIds.includes(row.id);
|
|
return (
|
|
<TableRow
|
|
key={row.id}
|
|
className={cn(
|
|
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
|
|
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]
|
|
);
|
|
}}
|
|
onDoubleClick={() => openEditModal(row as any)}
|
|
>
|
|
<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="font-mono whitespace-nowrap text-[13px]">{row.inbound_number || ""}</TableCell>
|
|
<TableCell className="text-[13px]">
|
|
<Badge variant={getTypeVariant(row.inbound_type)} className="text-[11px]">
|
|
{row.inbound_type || "-"}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell className="whitespace-nowrap text-[13px]">
|
|
{row.inbound_date ? new Date(row.inbound_date).toLocaleDateString("ko-KR") : ""}
|
|
</TableCell>
|
|
<TableCell className="text-[13px] truncate max-w-[120px]"><span className="block truncate">{row.reference_number || ""}</span></TableCell>
|
|
<TableCell className="text-[13px]">{row.source_type || ""}</TableCell>
|
|
<TableCell className="text-[13px] truncate max-w-[110px]"><span className="block truncate">{row.supplier_name || ""}</span></TableCell>
|
|
<TableCell className="font-mono text-[13px]">{row.item_number || ""}</TableCell>
|
|
<TableCell className="text-[13px] max-w-[140px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>
|
|
<TableCell className="text-[13px] text-muted-foreground">{row.spec || ""}</TableCell>
|
|
<TableCell className="text-right font-mono text-[13px]">{row.inbound_qty ? Number(row.inbound_qty).toLocaleString() : ""}</TableCell>
|
|
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
|
|
<TableCell className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>
|
|
<TableCell className="text-[13px] truncate max-w-[100px]"><span className="block truncate">{row.warehouse_name || (row as any).warehouse_code || ""}</span></TableCell>
|
|
<TableCell className="text-[13px]">
|
|
<Badge variant={getStatusVariant(row.inbound_status)} className="text-[11px]">
|
|
{row.inbound_status || "-"}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell className="text-muted-foreground"><span className="block truncate max-w-[120px]">{row.remark || row.memo || ""}</span></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>{editMode ? "입고 수정" : "입고 등록"}</DialogTitle>
|
|
<DialogDescription>
|
|
{editMode ? "입고 정보를 수정해 주세요." : "입고유형을 선택하고, 좌측에서 근거 데이터를 검색하여 추가해 주세요."}
|
|
</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)}
|
|
resolveCat={resolveCat}
|
|
/>
|
|
)}
|
|
</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,
|
|
resolveCat,
|
|
}: {
|
|
data: ItemSource[];
|
|
onAdd: (item: ItemSource) => void;
|
|
selectedKeys: string[];
|
|
resolveCat: (col: string, code: string) => 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={resolveCat("material", item.material) || "-"}>{resolveCat("material", item.material) || "-"}</TableCell>
|
|
<TableCell className="p-2">{resolveCat("unit", item.unit) || "-"}</TableCell>
|
|
<TableCell className="p-2 text-right">
|
|
{Number(item.standard_price).toLocaleString()}
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
);
|
|
}
|