623cbc0b61
- Implemented CRUD operations for report presets in reportPresetController. - Added routes for listing, creating, updating, and deleting report presets. - Ensured authentication is required for all preset operations. - Enhanced MaterialData interface to include optional width, height, and thickness properties.
1792 lines
77 KiB
TypeScript
1792 lines
77 KiB
TypeScript
"use client";
|
||
|
||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||
import { apiClient } from "@/lib/api/client";
|
||
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,
|
||
PackageOpen,
|
||
X,
|
||
Save,
|
||
ChevronRight,
|
||
ChevronLeft,
|
||
ChevronsLeft,
|
||
ChevronsRight,
|
||
Inbox,
|
||
Settings2,
|
||
Filter,
|
||
Check,
|
||
ArrowUp,
|
||
ArrowDown,
|
||
} from "lucide-react";
|
||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||
import { cn } from "@/lib/utils";
|
||
import { toast } from "sonner";
|
||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||
// API: /outbound/*
|
||
import {
|
||
getOutboundList,
|
||
createOutbound,
|
||
updateOutbound,
|
||
deleteOutbound,
|
||
generateOutboundNumber,
|
||
getOutboundWarehouses,
|
||
getOutboundLocations,
|
||
getShipmentInstructionSources,
|
||
getPurchaseOrderSources,
|
||
getItemSources,
|
||
type OutboundItem,
|
||
type ShipmentInstructionSource,
|
||
type PurchaseOrderSource,
|
||
type ItemSource,
|
||
type LocationOption,
|
||
type WarehouseOption,
|
||
} from "@/lib/api/outbound";
|
||
|
||
// item_info 테이블에서 가로/세로/두께를 join하여 보강 (소스 raw 데이터에 없을 수 있음)
|
||
async function enrichWithItemDimensions<T extends Record<string, any>>(rows: T[]): Promise<T[]> {
|
||
const codes = [...new Set(rows.map((r) => r.item_code || r.item_number).filter(Boolean) as string[])];
|
||
if (codes.length === 0) return rows;
|
||
try {
|
||
const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||
page: 1, size: codes.length + 10,
|
||
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: codes }] },
|
||
autoFilter: true,
|
||
});
|
||
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
|
||
const dimMap: Record<string, { width: string; height: string; thickness: string }> = {};
|
||
for (const i of items) {
|
||
dimMap[i.item_number] = { width: i.width || "", height: i.height || "", thickness: i.thickness || "" };
|
||
}
|
||
return rows.map((r) => {
|
||
const code = r.item_code || r.item_number;
|
||
const dim = code ? dimMap[code] : undefined;
|
||
return {
|
||
...r,
|
||
width: r.width || dim?.width || "",
|
||
height: r.height || dim?.height || "",
|
||
thickness: r.thickness || dim?.thickness || "",
|
||
};
|
||
});
|
||
} catch {
|
||
return rows;
|
||
}
|
||
}
|
||
|
||
// 출고유형 옵션
|
||
const OUTBOUND_TYPES = [
|
||
{ value: "판매출고", label: "판매출고", color: "bg-primary/10 text-primary" },
|
||
{ value: "반품출고", label: "반품출고", color: "bg-destructive/10 text-destructive" },
|
||
{ value: "기타출고", label: "기타출고", color: "bg-muted text-muted-foreground" },
|
||
];
|
||
|
||
const OUTBOUND_STATUS_OPTIONS = [
|
||
{ value: "대기", label: "대기", color: "bg-secondary text-secondary-foreground" },
|
||
{ value: "출고완료", label: "출고완료", color: "bg-primary/10 text-primary" },
|
||
{ value: "부분출고", label: "부분출고", color: "bg-accent text-accent-foreground" },
|
||
{ value: "출고취소", label: "출고취소", color: "bg-destructive/10 text-destructive" },
|
||
];
|
||
|
||
const getTypeColor = (type: string) => OUTBOUND_TYPES.find((t) => t.value === type)?.color || "bg-muted text-muted-foreground";
|
||
const getStatusColor = (status: string) => OUTBOUND_STATUS_OPTIONS.find((s) => s.value === status)?.color || "bg-muted text-muted-foreground";
|
||
|
||
// 소스 테이블 한글명 매핑
|
||
const SOURCE_TYPE_LABEL: Record<string, string> = {
|
||
shipment_instruction_detail: "출하지시",
|
||
purchase_order_mng: "발주",
|
||
item_info: "품목",
|
||
};
|
||
|
||
const GRID_COLUMNS = [
|
||
{ key: "outbound_number", label: "출고번호" },
|
||
{ key: "outbound_type", label: "출고유형" },
|
||
{ key: "outbound_date", label: "출고일" },
|
||
{ key: "reference_number", label: "참조번호" },
|
||
{ key: "source_type", label: "데이터출처" },
|
||
{ key: "customer_name", label: "거래처" },
|
||
{ key: "item_number", label: "품목코드" },
|
||
{ key: "item_name", label: "품목명" },
|
||
{ key: "width", label: "가로" },
|
||
{ key: "height", label: "세로" },
|
||
{ key: "thickness", label: "두께" },
|
||
{ key: "spec", label: "규격" },
|
||
{ key: "outbound_qty", label: "출고수량" },
|
||
{ key: "unit_price", label: "단가" },
|
||
{ key: "total_amount", label: "금액" },
|
||
{ key: "warehouse_name", label: "창고" },
|
||
{ key: "outbound_status", label: "출고상태" },
|
||
{ key: "remark", label: "비고" },
|
||
];
|
||
|
||
// 총 컬럼 수: 체크박스(1) + GRID_COLUMNS(18) = 19
|
||
const TOTAL_COLS = 19;
|
||
|
||
// 헤더 필터 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>
|
||
);
|
||
}
|
||
|
||
// 선택된 소스 아이템 (등록 모달에서 사용)
|
||
interface SelectedSourceItem {
|
||
key: string;
|
||
outbound_type: string;
|
||
reference_number: string;
|
||
customer_code: string;
|
||
customer_name: string;
|
||
item_number: string;
|
||
item_name: string;
|
||
spec: string;
|
||
material: string;
|
||
unit: string;
|
||
outbound_qty: number;
|
||
unit_price: number;
|
||
total_amount: number;
|
||
source_type: string;
|
||
source_id: string;
|
||
}
|
||
|
||
export default function OutboundPage() {
|
||
const ts = useTableSettings("c16-outbound", "outbound_mng", GRID_COLUMNS);
|
||
|
||
// 목록 데이터
|
||
const [data, setData] = useState<OutboundItem[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
||
|
||
// 페이지네이션
|
||
const [currentPage, setCurrentPage] = useState(1);
|
||
const [pageSize, setPageSize] = useState(20);
|
||
const [pageSizeInput, setPageSizeInput] = useState("20");
|
||
|
||
// 검색 필터
|
||
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 [modalOutboundType, setModalOutboundType] = useState("판매출고");
|
||
const [modalOutboundNo, setModalOutboundNo] = useState("");
|
||
const [modalOutboundDate, setModalOutboundDate] = useState("");
|
||
const [modalWarehouse, setModalWarehouse] = useState("");
|
||
const [modalLocation, setModalLocation] = 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 [catMap, setCatMap] = useState<Record<string, Record<string, string>>>({});
|
||
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;
|
||
};
|
||
const map: Record<string, Record<string, string>> = {};
|
||
Promise.all([
|
||
...["material", "inventory_unit"].map(async (col) => {
|
||
try {
|
||
const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_30`);
|
||
const items = flatten(res.data?.data || []);
|
||
map[col] = {};
|
||
for (const item of items) map[col][item.code] = item.label;
|
||
} catch { /* skip */ }
|
||
}),
|
||
(async () => {
|
||
try {
|
||
const res = await apiClient.get(`/table-categories/outbound_mng/outbound_type/values`);
|
||
const items = flatten(res.data?.data || []);
|
||
map["outbound_type"] = {};
|
||
for (const item of items) map["outbound_type"][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 [sourceKeyword, setSourceKeyword] = useState("");
|
||
const [sourceLoading, setSourceLoading] = useState(false);
|
||
const [shipmentInstructions, setShipmentInstructions] = useState<ShipmentInstructionSource[]>([]);
|
||
const [purchaseOrders, setPurchaseOrders] = useState<PurchaseOrderSource[]>([]);
|
||
const [items, setItems] = useState<ItemSource[]>([]);
|
||
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
|
||
const [locations, setLocations] = useState<LocationOption[]>([]);
|
||
|
||
// 소스 데이터 페이징 (클라이언트 사이드)
|
||
const [sourcePage, setSourcePage] = useState(1);
|
||
const [sourcePageSize, setSourcePageSize] = useState(20);
|
||
|
||
// 목록 조회
|
||
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 === "outbound_type") params.outbound_type = f.value;
|
||
else if (f.columnName === "outbound_status") params.outbound_status = f.value;
|
||
else if (f.columnName === "outbound_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 getOutboundList(params);
|
||
if (res.success) setData(res.data);
|
||
} catch {
|
||
// ignore
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [searchFilters]);
|
||
|
||
useEffect(() => {
|
||
fetchList();
|
||
}, [fetchList]);
|
||
|
||
// 창고 목록 로드
|
||
useEffect(() => {
|
||
(async () => {
|
||
try {
|
||
const res = await getOutboundWarehouses();
|
||
if (res.success) setWarehouses(res.data);
|
||
} catch {
|
||
// ignore
|
||
}
|
||
})();
|
||
}, []);
|
||
|
||
// 플랫 행 생성 (출고유형 코드→라벨 변환, source_type→한글 등)
|
||
const flatRows = useMemo(() => {
|
||
return data.map((row) => ({
|
||
...row,
|
||
_raw_outbound_type: row.outbound_type,
|
||
outbound_type: resolveCat("outbound_type", row.outbound_type) || row.outbound_type || "",
|
||
source_type: row.source_type ? (SOURCE_TYPE_LABEL[row.source_type] || row.source_type) : "",
|
||
item_number: row.item_code || (row as any).item_number || "",
|
||
spec: row.specification || (row as any).spec || "",
|
||
}));
|
||
}, [data, resolveCat]);
|
||
|
||
// 컬럼별 고유값 (헤더 필터용)
|
||
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 totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize));
|
||
const safePage = Math.min(Math.max(1, currentPage), totalPages);
|
||
const paginatedRows = useMemo(() => {
|
||
const start = (safePage - 1) * pageSize;
|
||
return filteredRows.slice(start, start + pageSize);
|
||
}, [filteredRows, safePage, pageSize]);
|
||
|
||
const applyPageSize = () => {
|
||
const n = parseInt(pageSizeInput, 10);
|
||
if (!isNaN(n) && n >= 1) { setPageSize(n); setCurrentPage(1); }
|
||
else setPageSizeInput(String(pageSize));
|
||
};
|
||
|
||
const getPageNumbers = (): (number | "...")[] => {
|
||
const pages: (number | "...")[] = [];
|
||
if (totalPages <= 7) {
|
||
for (let i = 1; i <= totalPages; i++) pages.push(i);
|
||
} else {
|
||
pages.push(1);
|
||
if (safePage > 3) pages.push("...");
|
||
for (let i = Math.max(2, safePage - 1); i <= Math.min(totalPages - 1, safePage + 1); i++) pages.push(i);
|
||
if (safePage < totalPages - 2) pages.push("...");
|
||
pages.push(totalPages);
|
||
}
|
||
return pages;
|
||
};
|
||
|
||
// 필터 변경 시 첫 페이지로 이동
|
||
useEffect(() => { setCurrentPage(1); }, [headerFilters, sortState, filteredRows.length]);
|
||
|
||
// 헤더 필터 토글/초기화
|
||
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 deleteOutbound(id);
|
||
}
|
||
setCheckedIds([]);
|
||
fetchList();
|
||
};
|
||
|
||
// --- 등록 모달 ---
|
||
|
||
const loadSourceData = useCallback(
|
||
async (type: string, keyword?: string) => {
|
||
setSourceLoading(true);
|
||
try {
|
||
if (type === "판매출고") {
|
||
const res = await getShipmentInstructionSources(keyword || undefined);
|
||
if (res.success) {
|
||
const enriched = await enrichWithItemDimensions(res.data);
|
||
setShipmentInstructions(enriched as ShipmentInstructionSource[]);
|
||
}
|
||
} else if (type === "반품출고") {
|
||
const res = await getPurchaseOrderSources(keyword || undefined);
|
||
if (res.success) {
|
||
const enriched = await enrichWithItemDimensions(res.data);
|
||
setPurchaseOrders(enriched as PurchaseOrderSource[]);
|
||
}
|
||
} else {
|
||
const res = await getItemSources(keyword || undefined);
|
||
if (res.success) setItems(res.data);
|
||
}
|
||
} catch {
|
||
// ignore
|
||
} finally {
|
||
setSourceLoading(false);
|
||
}
|
||
},
|
||
[]
|
||
);
|
||
|
||
const openRegisterModal = async () => {
|
||
const defaultType = "판매출고";
|
||
setEditMode(false);
|
||
setEditItemIds([]);
|
||
setModalOutboundType(defaultType);
|
||
setModalOutboundDate(new Date().toISOString().split("T")[0]);
|
||
setModalWarehouse("");
|
||
setModalLocation("");
|
||
setModalManager("");
|
||
setModalMemo("");
|
||
setSelectedItems([]);
|
||
setSourceKeyword("");
|
||
setShipmentInstructions([]);
|
||
setPurchaseOrders([]);
|
||
setItems([]);
|
||
setIsModalOpen(true);
|
||
|
||
try {
|
||
const [numRes] = await Promise.all([
|
||
generateOutboundNumber(),
|
||
loadSourceData(defaultType),
|
||
]);
|
||
if (numRes.success) setModalOutboundNo(numRes.data);
|
||
} catch {
|
||
setModalOutboundNo("");
|
||
}
|
||
};
|
||
|
||
// 수정 모달 열기 (같은 출고번호 묶어서)
|
||
const openEditModal = (row: OutboundItem) => {
|
||
const outNo = row.outbound_number;
|
||
const grouped = data.filter((d) => d.outbound_number === outNo);
|
||
const first = grouped[0] || row;
|
||
|
||
setEditMode(true);
|
||
setEditItemIds(grouped.map((g) => g.id));
|
||
setModalOutboundNo(outNo);
|
||
setModalOutboundType(first.outbound_type || "판매출고");
|
||
setModalOutboundDate(first.outbound_date ? first.outbound_date.slice(0, 10) : "");
|
||
setModalWarehouse(first.warehouse_code || "");
|
||
setModalLocation(first.location_code || "");
|
||
setModalManager(first.manager_id || "");
|
||
setModalMemo(first.memo || "");
|
||
setSelectedItems(
|
||
grouped.map((g) => ({
|
||
key: g.id,
|
||
outbound_type: g.outbound_type || "",
|
||
reference_number: g.reference_number || "",
|
||
customer_code: g.customer_code || "",
|
||
customer_name: g.customer_name || "",
|
||
item_number: g.item_code || "",
|
||
item_name: g.item_name || "",
|
||
spec: g.specification || "",
|
||
material: g.material || "",
|
||
unit: g.unit || "",
|
||
outbound_qty: Number(g.outbound_qty) || 0,
|
||
unit_price: Number(g.unit_price) || 0,
|
||
total_amount: Number(g.total_amount) || 0,
|
||
source_type: g.source_type || "",
|
||
source_id: (g as any).source_id || "",
|
||
}))
|
||
);
|
||
setSourceKeyword("");
|
||
setIsModalOpen(true);
|
||
loadSourceData(first.outbound_type || "판매출고");
|
||
};
|
||
|
||
const searchSourceData = useCallback(async () => {
|
||
setSourcePage(1);
|
||
await loadSourceData(modalOutboundType, sourceKeyword || undefined);
|
||
}, [modalOutboundType, sourceKeyword, loadSourceData]);
|
||
|
||
// 현재 출고유형에 따른 전체 소스 데이터
|
||
const allSourceData = useMemo(() => {
|
||
if (modalOutboundType === "판매출고") return shipmentInstructions;
|
||
if (modalOutboundType === "반품출고") return purchaseOrders;
|
||
return items;
|
||
}, [modalOutboundType, shipmentInstructions, purchaseOrders, items]);
|
||
|
||
const sourceTotalCount = allSourceData.length;
|
||
const sourceTotalPages = Math.max(1, Math.ceil(sourceTotalCount / sourcePageSize));
|
||
|
||
// 현재 페이지에 해당하는 slice
|
||
const pagedShipmentInstructions = useMemo(() => {
|
||
if (modalOutboundType !== "판매출고") return [];
|
||
const start = (sourcePage - 1) * sourcePageSize;
|
||
return shipmentInstructions.slice(start, start + sourcePageSize);
|
||
}, [modalOutboundType, shipmentInstructions, sourcePage, sourcePageSize]);
|
||
|
||
const pagedPurchaseOrders = useMemo(() => {
|
||
if (modalOutboundType !== "반품출고") return [];
|
||
const start = (sourcePage - 1) * sourcePageSize;
|
||
return purchaseOrders.slice(start, start + sourcePageSize);
|
||
}, [modalOutboundType, purchaseOrders, sourcePage, sourcePageSize]);
|
||
|
||
const pagedItems = useMemo(() => {
|
||
if (modalOutboundType !== "기타출고") return [];
|
||
const start = (sourcePage - 1) * sourcePageSize;
|
||
return items.slice(start, start + sourcePageSize);
|
||
}, [modalOutboundType, items, sourcePage, sourcePageSize]);
|
||
|
||
const handleOutboundTypeChange = useCallback(
|
||
(type: string) => {
|
||
setModalOutboundType(type);
|
||
setSourceKeyword("");
|
||
setSourcePage(1);
|
||
setShipmentInstructions([]);
|
||
setPurchaseOrders([]);
|
||
setItems([]);
|
||
setSelectedItems([]);
|
||
loadSourceData(type);
|
||
},
|
||
[loadSourceData]
|
||
);
|
||
|
||
// 출하지시 품목 추가 (판매출고)
|
||
const addShipmentInstruction = (si: ShipmentInstructionSource) => {
|
||
const key = `si-${si.detail_id}`;
|
||
if (selectedItems.some((s) => s.key === key)) return;
|
||
setSelectedItems((prev) => [
|
||
...prev,
|
||
{
|
||
key,
|
||
outbound_type: "판매출고",
|
||
reference_number: si.instruction_no,
|
||
customer_code: si.partner_id,
|
||
customer_name: si.partner_id,
|
||
item_number: si.item_code,
|
||
item_name: si.item_name,
|
||
spec: si.spec || "",
|
||
width: (si as any).width || "",
|
||
height: (si as any).height || "",
|
||
thickness: (si as any).thickness || "",
|
||
material: si.material || "",
|
||
unit: "EA",
|
||
outbound_qty: si.remain_qty,
|
||
unit_price: 0,
|
||
total_amount: 0,
|
||
source_type: "shipment_instruction_detail",
|
||
source_id: String(si.detail_id),
|
||
},
|
||
]);
|
||
};
|
||
|
||
// 발주 품목 추가 (반품출고)
|
||
const addPurchaseOrder = (po: PurchaseOrderSource) => {
|
||
const key = `po-${po.id}`;
|
||
if (selectedItems.some((s) => s.key === key)) return;
|
||
setSelectedItems((prev) => [
|
||
...prev,
|
||
{
|
||
key,
|
||
outbound_type: "반품출고",
|
||
reference_number: po.purchase_no,
|
||
customer_code: po.supplier_code,
|
||
customer_name: po.supplier_name,
|
||
item_number: po.item_code,
|
||
item_name: po.item_name,
|
||
spec: po.spec || "",
|
||
width: (po as any).width || "",
|
||
height: (po as any).height || "",
|
||
thickness: (po as any).thickness || "",
|
||
material: po.material || "",
|
||
unit: "EA",
|
||
outbound_qty: po.received_qty,
|
||
unit_price: po.unit_price,
|
||
total_amount: po.received_qty * po.unit_price,
|
||
source_type: "purchase_order_mng",
|
||
source_id: po.id,
|
||
},
|
||
]);
|
||
};
|
||
|
||
// 품목 추가 (기타출고)
|
||
const addItem = (item: ItemSource) => {
|
||
const key = `item-${item.id}`;
|
||
if (selectedItems.some((s) => s.key === key)) return;
|
||
setSelectedItems((prev) => [
|
||
...prev,
|
||
{
|
||
key,
|
||
outbound_type: "기타출고",
|
||
reference_number: item.item_number,
|
||
customer_code: "",
|
||
customer_name: "",
|
||
item_number: item.item_number,
|
||
item_name: item.item_name,
|
||
spec: item.spec || "",
|
||
width: (item as any).width || "",
|
||
height: (item as any).height || "",
|
||
thickness: (item as any).thickness || "",
|
||
material: item.material || "",
|
||
unit: item.inventory_unit || "EA",
|
||
outbound_qty: 0,
|
||
unit_price: item.standard_price,
|
||
total_amount: 0,
|
||
source_type: "item_info",
|
||
source_id: item.id,
|
||
},
|
||
]);
|
||
};
|
||
|
||
// 선택 품목 수량 변경
|
||
const updateItemQty = (key: string, qty: number) => {
|
||
setSelectedItems((prev) =>
|
||
prev.map((item) =>
|
||
item.key === key
|
||
? { ...item, outbound_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.outbound_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 (!modalOutboundDate) {
|
||
alert("출고일을 입력해주세요.");
|
||
return;
|
||
}
|
||
|
||
const zeroQtyItems = selectedItems.filter((i) => !i.outbound_qty || i.outbound_qty <= 0);
|
||
if (zeroQtyItems.length > 0) {
|
||
alert("출고수량이 0인 품목이 있습니다. 수량을 입력해주세요.");
|
||
return;
|
||
}
|
||
|
||
setSaving(true);
|
||
try {
|
||
if (editMode) {
|
||
const currentKeys = new Set(selectedItems.map((i) => i.key));
|
||
// 삭제: editItemIds에 있지만 selectedItems에 없는 것
|
||
const toDelete = editItemIds.filter((id) => !currentKeys.has(id));
|
||
// 수정: editItemIds에도 있고 selectedItems에도 있는 것
|
||
const toUpdate = selectedItems.filter((i) => editItemIds.includes(i.key));
|
||
// 추가: editItemIds에 없는 새 아이템
|
||
const toCreate = selectedItems.filter((i) => !editItemIds.includes(i.key));
|
||
|
||
await Promise.all([
|
||
...toDelete.map((id) => deleteOutbound(id)),
|
||
...toUpdate.map((item) =>
|
||
updateOutbound(item.key, {
|
||
outbound_date: modalOutboundDate,
|
||
outbound_qty: item.outbound_qty,
|
||
unit_price: item.unit_price,
|
||
total_amount: item.total_amount,
|
||
warehouse_code: modalWarehouse || undefined,
|
||
location_code: modalLocation || undefined,
|
||
manager_id: modalManager || undefined,
|
||
memo: modalMemo || undefined,
|
||
} as any)
|
||
),
|
||
...(toCreate.length > 0
|
||
? [createOutbound({
|
||
outbound_number: modalOutboundNo,
|
||
outbound_date: modalOutboundDate,
|
||
warehouse_code: modalWarehouse || undefined,
|
||
location_code: modalLocation || undefined,
|
||
manager_id: modalManager || undefined,
|
||
memo: modalMemo || undefined,
|
||
items: toCreate.map((item) => ({
|
||
outbound_type: item.outbound_type,
|
||
reference_number: item.reference_number,
|
||
customer_code: item.customer_code,
|
||
customer_name: item.customer_name,
|
||
item_code: item.item_number,
|
||
item_name: item.item_name,
|
||
spec: item.spec,
|
||
material: item.material,
|
||
unit: item.unit,
|
||
outbound_qty: item.outbound_qty,
|
||
unit_price: item.unit_price,
|
||
total_amount: item.total_amount,
|
||
source_type: item.source_type,
|
||
source_id: item.source_id,
|
||
outbound_status: "출고완료",
|
||
})),
|
||
})]
|
||
: []),
|
||
]);
|
||
toast.success("출고 정보를 수정했어요");
|
||
setIsModalOpen(false);
|
||
fetchList();
|
||
} else {
|
||
const res = await createOutbound({
|
||
outbound_number: modalOutboundNo,
|
||
outbound_date: modalOutboundDate,
|
||
warehouse_code: modalWarehouse || undefined,
|
||
location_code: modalLocation || undefined,
|
||
manager_id: modalManager || undefined,
|
||
memo: modalMemo || undefined,
|
||
items: selectedItems.map((item) => ({
|
||
outbound_type: item.outbound_type,
|
||
reference_number: item.reference_number,
|
||
customer_code: item.customer_code,
|
||
customer_name: item.customer_name,
|
||
item_code: item.item_number,
|
||
item_name: item.item_name,
|
||
spec: item.spec,
|
||
material: item.material,
|
||
unit: item.unit,
|
||
outbound_qty: item.outbound_qty,
|
||
unit_price: item.unit_price,
|
||
total_amount: item.total_amount,
|
||
source_type: item.source_type,
|
||
source_id: item.source_id,
|
||
outbound_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.outbound_qty || 0), 0),
|
||
amount: selectedItems.reduce((sum, i) => sum + (i.total_amount || 0), 0),
|
||
};
|
||
}, [selectedItems]);
|
||
|
||
return (
|
||
<div className="flex h-[calc(100vh-4rem)] flex-col gap-3 p-3">
|
||
{/* 검색 영역 */}
|
||
<DynamicSearchFilter
|
||
tableName="outbound_mng"
|
||
filterId="c16-outbound"
|
||
onFilterChange={setSearchFilters}
|
||
externalFilterConfig={ts.filterConfig}
|
||
dataCount={data.length}
|
||
/>
|
||
|
||
{/* 출고 목록 테이블 (플랫 리스트) */}
|
||
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border bg-card">
|
||
{/* 패널 헤더 */}
|
||
<div className="flex items-center justify-between border-b bg-muted/30 px-4 py-2.5 shrink-0">
|
||
<div className="flex items-center gap-2">
|
||
<PackageOpen className="h-4 w-4 text-muted-foreground" />
|
||
<span className="text-sm font-semibold">출고 목록</span>
|
||
<span className="rounded-full border border-primary/15 bg-primary/8 px-2 py-0.5 font-mono text-[11px] font-bold text-primary">
|
||
{filteredRows.length}건
|
||
</span>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<Button size="sm" onClick={openRegisterModal} className="h-8">
|
||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||
출고 등록
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="destructive"
|
||
onClick={handleDelete}
|
||
disabled={checkedIds.length === 0}
|
||
className="h-8"
|
||
>
|
||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||
삭제 ({checkedIds.length})
|
||
</Button>
|
||
<Button variant="ghost" size="sm" className="h-8" onClick={() => ts.setOpen(true)} title="테이블 설정">
|
||
<Settings2 className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex-1 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: "70px" }} />
|
||
<col style={{ width: "70px" }} />
|
||
<col style={{ width: "70px" }} />
|
||
<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 = ["outbound_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">
|
||
<PackageOpen className="h-8 w-8 opacity-30" />
|
||
<span className="text-sm">등록된 출고 내역이 없어요</span>
|
||
</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
) : (
|
||
paginatedRows.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.outbound_number || ""}</TableCell>
|
||
<TableCell className="text-[13px]">
|
||
<Badge variant="outline" className={cn("text-[11px]", getTypeColor(row.outbound_type))}>
|
||
{row.outbound_type || "-"}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell className="whitespace-nowrap text-[13px]">
|
||
{row.outbound_date ? new Date(row.outbound_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.customer_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-right font-mono text-[13px] text-muted-foreground">{(row as any).width || "-"}</TableCell>
|
||
<TableCell className="text-right font-mono text-[13px] text-muted-foreground">{(row as any).height || "-"}</TableCell>
|
||
<TableCell className="text-right font-mono text-[13px] text-muted-foreground">{(row as any).thickness || "-"}</TableCell>
|
||
<TableCell className="text-[13px] text-muted-foreground">{row.spec || ""}</TableCell>
|
||
<TableCell className="text-right font-mono text-[13px]">{row.outbound_qty ? Number(row.outbound_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.warehouse_code || ""}</span></TableCell>
|
||
<TableCell className="text-[13px]">
|
||
<Badge variant="outline" className={cn("text-[11px]", getStatusColor(row.outbound_status))}>
|
||
{row.outbound_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 className="flex items-center justify-between border-t px-4 py-2.5 text-xs text-muted-foreground shrink-0">
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex items-center gap-1">
|
||
<span>전체</span>
|
||
<span className="font-medium text-foreground">{filteredRows.length.toLocaleString()}</span>
|
||
<span>건</span>
|
||
</div>
|
||
<div className="flex items-center gap-1.5">
|
||
<Input
|
||
type="number"
|
||
min={1}
|
||
value={pageSizeInput}
|
||
onChange={(e) => setPageSizeInput(e.target.value)}
|
||
onBlur={applyPageSize}
|
||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); applyPageSize(); } }}
|
||
className="h-7 w-16 text-center text-xs"
|
||
/>
|
||
<span>건씩 보기</span>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-0.5">
|
||
<button onClick={() => setCurrentPage(1)} disabled={safePage === 1}
|
||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
|
||
<ChevronsLeft className="h-3.5 w-3.5" />
|
||
</button>
|
||
<button onClick={() => setCurrentPage((p) => Math.max(1, p - 1))} disabled={safePage === 1}
|
||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
|
||
<ChevronLeft className="h-3.5 w-3.5" />
|
||
</button>
|
||
{getPageNumbers().map((page, idx) =>
|
||
page === "..." ? (
|
||
<span key={`dot-${idx}`} className="h-7 w-7 flex items-center justify-center text-muted-foreground">...</span>
|
||
) : (
|
||
<button key={page} onClick={() => setCurrentPage(page as number)}
|
||
className={cn("h-7 w-7 flex items-center justify-center rounded text-xs",
|
||
page === safePage ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted")}>
|
||
{page}
|
||
</button>
|
||
)
|
||
)}
|
||
<button onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))} disabled={safePage === totalPages}
|
||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
|
||
<ChevronRight className="h-3.5 w-3.5" />
|
||
</button>
|
||
<button onClick={() => setCurrentPage(totalPages)} disabled={safePage === totalPages}
|
||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
|
||
<ChevronsRight className="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
<div className="flex items-center gap-1.5">
|
||
<Input
|
||
type="number"
|
||
min={1}
|
||
max={totalPages}
|
||
placeholder={String(safePage)}
|
||
className="h-7 w-14 text-center text-xs"
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") {
|
||
const val = parseInt((e.target as HTMLInputElement).value, 10);
|
||
if (!isNaN(val) && val >= 1 && val <= totalPages) {
|
||
setCurrentPage(val);
|
||
(e.target as HTMLInputElement).value = "";
|
||
(e.target as HTMLInputElement).blur();
|
||
}
|
||
}
|
||
}}
|
||
onBlur={(e) => {
|
||
const val = parseInt(e.target.value, 10);
|
||
if (!isNaN(val) && val >= 1 && val <= totalPages) {
|
||
setCurrentPage(val);
|
||
}
|
||
e.target.value = "";
|
||
}}
|
||
/>
|
||
<span>/ {totalPages} 페이지</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 출고 등록 모달 */}
|
||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||
<DialogContent className="flex flex-col gap-0 p-0 sm:max-w-[1600px] w-[95vw] h-[90vh] overflow-hidden">
|
||
<DialogHeader className="shrink-0 border-b px-6 py-4">
|
||
<DialogTitle>{editMode ? "출고 수정" : "출고 등록"}</DialogTitle>
|
||
<DialogDescription>{editMode ? "출고 정보를 수정해주세요." : "출고유형을 선택하고, 좌측에서 근거 데이터를 검색하여 추가해주세요."}</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
{/* 출고유형 선택 */}
|
||
<div className="flex shrink-0 items-center gap-4 border-b bg-muted/30 px-6 py-3">
|
||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">출고유형</span>
|
||
<Select value={modalOutboundType} onValueChange={handleOutboundTypeChange}>
|
||
<SelectTrigger className="h-9 w-[160px] text-sm">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{OUTBOUND_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">
|
||
{modalOutboundType === "판매출고"
|
||
? "출하지시 데이터에서 출고 처리해요"
|
||
: modalOutboundType === "반품출고"
|
||
? "발주(입고) 데이터에서 반품 출고 처리해요"
|
||
: "품목 데이터를 직접 선택하여 출고 처리해요"}
|
||
</span>
|
||
</div>
|
||
|
||
{/* 메인 콘텐츠 */}
|
||
<div className="flex-1 overflow-hidden">
|
||
<ResizablePanelGroup direction="horizontal">
|
||
{/* 좌측: 소스 데이터 */}
|
||
<ResizablePanel defaultSize={60} minSize={35}>
|
||
<div className="flex h-full flex-col">
|
||
<div className="flex items-center gap-2 border-b px-4 py-3">
|
||
<Input
|
||
placeholder={
|
||
modalOutboundType === "판매출고"
|
||
? "출하지시번호 / 품목명"
|
||
: modalOutboundType === "반품출고"
|
||
? "발주번호 / 품목명 / 공급처"
|
||
: "품목번호 / 품목명"
|
||
}
|
||
value={sourceKeyword}
|
||
onChange={(e) => setSourceKeyword(e.target.value)}
|
||
onKeyDown={(e) => e.key === "Enter" && searchSourceData()}
|
||
className="h-8 flex-1 text-xs"
|
||
/>
|
||
<Button size="sm" onClick={searchSourceData} className="h-8">
|
||
<Search className="mr-1 h-3 w-3" />
|
||
검색
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between border-b bg-muted/30 px-4 py-2 shrink-0">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||
{modalOutboundType === "판매출고"
|
||
? "미출고 출하지시 목록"
|
||
: modalOutboundType === "반품출고"
|
||
? "입고된 발주 목록"
|
||
: "품목 목록"}
|
||
</span>
|
||
{sourceTotalCount > 0 && (
|
||
<span className="rounded-full border border-primary/15 bg-primary/8 px-2 py-0.5 font-mono text-[11px] font-bold text-primary">
|
||
{sourceTotalCount}건
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-auto">
|
||
{sourceLoading ? (
|
||
<div className="flex h-40 items-center justify-center">
|
||
<Loader2 className="h-5 w-5 animate-spin" />
|
||
</div>
|
||
) : modalOutboundType === "판매출고" ? (
|
||
<SourceShipmentInstructionTable
|
||
data={pagedShipmentInstructions}
|
||
onAdd={addShipmentInstruction}
|
||
selectedKeys={selectedItems.map((s) => s.key)}
|
||
/>
|
||
) : modalOutboundType === "반품출고" ? (
|
||
<SourcePurchaseOrderTable
|
||
data={pagedPurchaseOrders}
|
||
onAdd={addPurchaseOrder}
|
||
selectedKeys={selectedItems.map((s) => s.key)}
|
||
/>
|
||
) : (
|
||
<SourceItemTable
|
||
data={pagedItems}
|
||
onAdd={addItem}
|
||
selectedKeys={selectedItems.map((s) => s.key)}
|
||
resolveCat={resolveCat}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{/* 페이징 바 */}
|
||
{sourceTotalCount > 0 && (
|
||
<div className="flex items-center justify-between border-t bg-muted/10 px-4 py-2 shrink-0">
|
||
<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); }
|
||
}}
|
||
className="h-7 w-[60px] text-center text-[11px]"
|
||
/>
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<Button variant="outline" size="icon" className="h-7 w-7" disabled={sourcePage <= 1}
|
||
onClick={() => setSourcePage(1)}>
|
||
<ChevronsLeft className="h-3.5 w-3.5" />
|
||
</Button>
|
||
<Button variant="outline" size="icon" className="h-7 w-7" disabled={sourcePage <= 1}
|
||
onClick={() => setSourcePage((p) => p - 1)}>
|
||
<ChevronLeft className="h-3.5 w-3.5" />
|
||
</Button>
|
||
<span className="text-xs font-medium px-2">{sourcePage} / {sourceTotalPages}</span>
|
||
<Button variant="outline" size="icon" className="h-7 w-7" disabled={sourcePage >= sourceTotalPages}
|
||
onClick={() => setSourcePage((p) => p + 1)}>
|
||
<ChevronRight className="h-3.5 w-3.5" />
|
||
</Button>
|
||
<Button variant="outline" size="icon" className="h-7 w-7" disabled={sourcePage >= sourceTotalPages}
|
||
onClick={() => setSourcePage(sourceTotalPages)}>
|
||
<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 className="space-y-1">
|
||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">출고번호</span>
|
||
<Input
|
||
value={modalOutboundNo}
|
||
readOnly
|
||
className="bg-muted cursor-not-allowed h-8 font-mono text-xs"
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||
출고일 <span className="text-destructive">*</span>
|
||
</span>
|
||
<Input
|
||
type="date"
|
||
value={modalOutboundDate}
|
||
onChange={(e) => setModalOutboundDate(e.target.value)}
|
||
className="h-8 text-xs"
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">창고</span>
|
||
<Select value={modalWarehouse} onValueChange={(v) => {
|
||
setModalWarehouse(v);
|
||
setModalLocation("");
|
||
if (v) {
|
||
getOutboundLocations(v).then((r) => { if (r.success) setLocations(r.data); }).catch(() => {});
|
||
} else {
|
||
setLocations([]);
|
||
}
|
||
}}>
|
||
<SelectTrigger className="h-8 text-xs">
|
||
<SelectValue placeholder="창고 선택" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{warehouses.map((w) => (
|
||
<SelectItem key={w.warehouse_code} value={w.warehouse_code}>
|
||
{w.warehouse_name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">위치</span>
|
||
<Select value={modalLocation || "__none__"} onValueChange={(v) => setModalLocation(v === "__none__" ? "" : v)}>
|
||
<SelectTrigger className="h-8 text-xs">
|
||
<SelectValue placeholder="위치 선택" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="__none__">위치 선택</SelectItem>
|
||
{locations.map((l) => (
|
||
<SelectItem key={l.location_code} value={l.location_code}>
|
||
{l.location_name || l.location_code}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">담당자</span>
|
||
<Input
|
||
value={modalManager}
|
||
onChange={(e) => setModalManager(e.target.value)}
|
||
placeholder="담당자"
|
||
className="h-8 text-xs"
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">메모</span>
|
||
<Input
|
||
value={modalMemo}
|
||
onChange={(e) => setModalMemo(e.target.value)}
|
||
placeholder="메모"
|
||
className="h-8 text-xs"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-auto px-4 py-2">
|
||
<div className="mb-2 flex items-center gap-2">
|
||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">출고 처리 품목</span>
|
||
<span className="rounded-full border border-primary/15 bg-primary/8 px-2 py-0.5 font-mono text-[11px] font-bold text-primary">
|
||
{selectedItems.length}건
|
||
</span>
|
||
</div>
|
||
|
||
{selectedItems.length === 0 ? (
|
||
<div className="flex h-32 flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border text-xs text-muted-foreground">
|
||
<PackageOpen className="h-8 w-8 text-muted-foreground/30" />
|
||
좌측에서 품목을 선택하여 추가해주세요
|
||
</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="p-2 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>
|
||
<TableHead className="w-[80px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단가</TableHead>
|
||
<TableHead className="w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">금액</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="p-2">
|
||
<Badge variant="outline" className={cn("text-[10px]", getTypeColor(item.outbound_type))}>
|
||
{resolveCat("outbound_type", item.outbound_type) || "-"}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell className="max-w-[220px] 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>
|
||
{((item as any).width || (item as any).height || (item as any).thickness) && (
|
||
<span className="text-muted-foreground truncate text-[10px]">
|
||
{(item as any).width && `W ${(item as any).width}`}
|
||
{(item as any).height && ` × H ${(item as any).height}`}
|
||
{(item as any).thickness && ` × T ${(item as any).thickness}`}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</TableCell>
|
||
<TableCell className="p-2 text-[11px]">{item.reference_number}</TableCell>
|
||
<TableCell className="p-2 text-right">
|
||
<Input
|
||
type="number"
|
||
value={item.outbound_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 className="shrink-0 border-t">
|
||
<div className="flex w-full items-center justify-between px-6 py-3">
|
||
<div className="text-muted-foreground text-xs">
|
||
{selectedItems.length > 0 ? (
|
||
<>
|
||
{totalSummary.count}건 | 수량 합계:{" "}
|
||
{totalSummary.qty.toLocaleString()} | 금액 합계:{" "}
|
||
{totalSummary.amount.toLocaleString()}원
|
||
</>
|
||
) : (
|
||
"품목을 추가해주세요"
|
||
)}
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => setIsModalOpen(false)}
|
||
className="h-9 text-sm"
|
||
>
|
||
취소
|
||
</Button>
|
||
<Button
|
||
onClick={handleSave}
|
||
disabled={saving || selectedItems.length === 0}
|
||
className="h-9 text-sm"
|
||
>
|
||
{saving ? (
|
||
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Save className="mr-1 h-4 w-4" />
|
||
)}
|
||
저장
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<TableSettingsModal
|
||
open={ts.open}
|
||
onOpenChange={ts.setOpen}
|
||
tableName={ts.tableName}
|
||
settingsId={ts.settingsId}
|
||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||
onSave={ts.applySettings}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// --- 소스 데이터 테이블 컴포넌트들 ---
|
||
|
||
function SourceShipmentInstructionTable({
|
||
data,
|
||
onAdd,
|
||
selectedKeys,
|
||
}: {
|
||
data: ShipmentInstructionSource[];
|
||
onAdd: (si: ShipmentInstructionSource) => void;
|
||
selectedKeys: string[];
|
||
}) {
|
||
if (data.length === 0) {
|
||
return (
|
||
<div className="m-4 flex h-32 flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border text-xs text-muted-foreground">
|
||
<Inbox className="h-8 w-8 text-muted-foreground/30" />
|
||
검색 버튼을 눌러 출하지시 데이터를 조회해주세요
|
||
</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((si) => {
|
||
const isSelected = selectedKeys.includes(`si-${si.detail_id}`);
|
||
return (
|
||
<TableRow
|
||
key={si.detail_id}
|
||
className={cn(
|
||
"cursor-pointer text-xs transition-colors",
|
||
isSelected && "bg-primary/5"
|
||
)}
|
||
onClick={() => !isSelected && onAdd(si)}
|
||
>
|
||
<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={si.instruction_no}>{si.instruction_no}</TableCell>
|
||
<TableCell className="p-2">
|
||
{si.instruction_date
|
||
? new Date(si.instruction_date).toLocaleDateString("ko-KR")
|
||
: "-"}
|
||
</TableCell>
|
||
<TableCell className="max-w-[220px] p-2">
|
||
<div className="flex flex-col">
|
||
<span className="truncate font-medium" title={si.item_name}>{si.item_name}</span>
|
||
<span className="text-muted-foreground truncate text-[10px]" title={`${si.item_code}${si.spec ? ` | ${si.spec}` : ""}`}>
|
||
{si.item_code}
|
||
{si.spec ? ` | ${si.spec}` : ""}
|
||
</span>
|
||
{((si as any).width || (si as any).height || (si as any).thickness) && (
|
||
<span className="text-muted-foreground truncate text-[10px]">
|
||
{(si as any).width && `W ${(si as any).width}`}
|
||
{(si as any).height && ` × H ${(si as any).height}`}
|
||
{(si as any).thickness && ` × T ${(si as any).thickness}`}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</TableCell>
|
||
<TableCell className="p-2 text-right">
|
||
{Number(si.plan_qty).toLocaleString()}
|
||
</TableCell>
|
||
<TableCell className="p-2 text-right">
|
||
{Number(si.ship_qty).toLocaleString()}
|
||
</TableCell>
|
||
<TableCell className="p-2 text-right font-semibold text-primary">
|
||
{Number(si.remain_qty).toLocaleString()}
|
||
</TableCell>
|
||
</TableRow>
|
||
);
|
||
})}
|
||
</TableBody>
|
||
</Table>
|
||
);
|
||
}
|
||
|
||
function SourcePurchaseOrderTable({
|
||
data,
|
||
onAdd,
|
||
selectedKeys,
|
||
}: {
|
||
data: PurchaseOrderSource[];
|
||
onAdd: (po: PurchaseOrderSource) => void;
|
||
selectedKeys: string[];
|
||
}) {
|
||
if (data.length === 0) {
|
||
return (
|
||
<div className="m-4 flex h-32 flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border text-xs text-muted-foreground">
|
||
<Inbox className="h-8 w-8 text-muted-foreground/30" />
|
||
검색 버튼을 눌러 발주 데이터를 조회해주세요
|
||
</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>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{data.map((po) => {
|
||
const isSelected = selectedKeys.includes(`po-${po.id}`);
|
||
return (
|
||
<TableRow
|
||
key={po.id}
|
||
className={cn(
|
||
"cursor-pointer text-xs transition-colors",
|
||
isSelected && "bg-primary/5"
|
||
)}
|
||
onClick={() => !isSelected && onAdd(po)}
|
||
>
|
||
<TableCell className="p-2 text-center">
|
||
{isSelected ? (
|
||
<Badge className="bg-primary/20 text-primary text-[10px]">추가됨</Badge>
|
||
) : (
|
||
<ChevronRight className="text-muted-foreground h-4 w-4" />
|
||
)}
|
||
</TableCell>
|
||
<TableCell className="max-w-[120px] truncate p-2 font-medium" title={po.purchase_no}>{po.purchase_no}</TableCell>
|
||
<TableCell className="max-w-[120px] truncate p-2" title={po.supplier_name}>{po.supplier_name}</TableCell>
|
||
<TableCell className="max-w-[220px] 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>
|
||
{((po as any).width || (po as any).height || (po as any).thickness) && (
|
||
<span className="text-muted-foreground truncate text-[10px]">
|
||
{(po as any).width && `W ${(po as any).width}`}
|
||
{(po as any).height && ` × H ${(po as any).height}`}
|
||
{(po as any).thickness && ` × T ${(po as any).thickness}`}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</TableCell>
|
||
<TableCell className="p-2 text-right">
|
||
{Number(po.order_qty).toLocaleString()}
|
||
</TableCell>
|
||
<TableCell className="p-2 text-right font-semibold text-primary">
|
||
{Number(po.received_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="m-4 flex h-32 flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border text-xs text-muted-foreground">
|
||
<Inbox className="h-8 w-8 text-muted-foreground/30" />
|
||
검색 버튼을 눌러 품목 데이터를 조회해주세요
|
||
</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>
|
||
{((item as any).width || (item as any).height || (item as any).thickness) && (
|
||
<span className="text-muted-foreground truncate text-[10px]">
|
||
{(item as any).width && `W ${(item as any).width}`}
|
||
{(item as any).height && ` × H ${(item as any).height}`}
|
||
{(item as any).thickness && ` × T ${(item as any).thickness}`}
|
||
</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("inventory_unit", item.unit) || "-"}</TableCell>
|
||
<TableCell className="p-2 text-right">
|
||
{Number(item.standard_price).toLocaleString()}
|
||
</TableCell>
|
||
</TableRow>
|
||
);
|
||
})}
|
||
</TableBody>
|
||
</Table>
|
||
);
|
||
}
|