Files
wace_rps/frontend/app/(main)/COMPANY_30/logistics/outbound/page.tsx
T
DDD1542 623cbc0b61 feat: add report preset management API
- 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.
2026-04-16 12:08:28 +09:00

1792 lines
77 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}