Files
pipeline/frontend/app/(main)/COMPANY_16/logistics/outbound/page.tsx
T
kjs 31bdbe1331 feat: Enhance inventory and outbound pages with category mapping and user information
- Implemented user mapping to display user names instead of IDs in the inventory and receiving pages.
- Added category mapping for materials and units in the outbound page, improving data representation.
- Updated API calls to fetch user and category data, ensuring accurate and user-friendly displays.
- These enhancements aim to improve the overall user experience by providing clearer information and better data management across multiple company implementations.
2026-04-12 19:34:45 +09:00

1872 lines
81 KiB
TypeScript

"use client";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import {
Search,
Plus,
Trash2,
Loader2,
PackageOpen,
X,
Save,
ChevronRight,
ChevronLeft,
ChevronDown,
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";
// 출고유형 옵션
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: "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: "비고" },
];
// 마스터 헤더 레이아웃 (출고번호 뒤)
const MASTER_BODY_LAYOUT = [
{ key: "outbound_type", label: "출고유형", colSpan: 1 },
{ key: "outbound_date", label: "출고일", colSpan: 1 },
{ key: "reference_number", label: "참조번호", colSpan: 1 },
{ key: "customer_name", label: "거래처", colSpan: 1 },
{ key: "warehouse_name", label: "창고", colSpan: 1 },
{ key: "outbound_status", label: "출고상태", colSpan: 1 },
{ key: "memo", label: "비고", colSpan: 1 },
];
// 디테일 헤더 컬럼
const DETAIL_HEADER_COLS = [
{ key: "source_type", label: "출처" },
{ key: "item_code", label: "품목코드" },
{ key: "item_name", label: "품목명" },
{ key: "specification", label: "규격" },
{ key: "outbound_qty", label: "출고수량" },
{ key: "unit_price", label: "단가" },
{ key: "total_amount", label: "금액" },
];
// 마스터 필드 키 목록 (필터 분류용)
const MASTER_KEYS = new Set(["outbound_number", ...MASTER_BODY_LAYOUT.map((c) => c.key)]);
// 디테일 필드 키 매핑 (GRID_COLUMNS key → DETAIL_HEADER_COLS key)
const DETAIL_KEY_MAP: Record<string, string> = {
source_type: "source_type",
item_number: "item_code",
item_name: "item_name",
spec: "specification",
outbound_qty: "outbound_qty",
unit_price: "unit_price",
total_amount: "total_amount",
};
// 헤더 필터 Popover
function HeaderFilterPopover({
colKey, colLabel, uniqueValues, filterValues, onToggle, onClear,
}: {
colKey: string;
colLabel: string;
uniqueValues: string[];
filterValues: Set<string>;
onToggle: (colKey: string, value: string) => void;
onClear: (colKey: string) => void;
}) {
const [filterSearch, setFilterSearch] = useState("");
const hasFilter = filterValues.size > 0;
const filteredValues = uniqueValues.filter(
(v) => !filterSearch || v.toLowerCase().includes(filterSearch.toLowerCase())
);
return (
<Popover>
<PopoverTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className={cn(
"hover:bg-primary/20 rounded p-0.5 transition-colors shrink-0",
hasFilter && "text-primary bg-primary/10",
)}
title="필터"
>
<Filter className="h-3 w-3" />
</button>
</PopoverTrigger>
<PopoverContent className="w-56 p-2" align="start" onClick={(e) => e.stopPropagation()}>
<div className="space-y-2">
<div className="flex items-center justify-between border-b pb-2">
<span className="text-xs font-medium">: {colLabel}</span>
{hasFilter && (
<button onClick={() => onClear(colKey)} className="text-primary text-xs hover:underline">
</button>
)}
</div>
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground" />
<Input
value={filterSearch}
onChange={(e) => setFilterSearch(e.target.value)}
placeholder="검색..."
className="h-7 text-xs pl-7"
/>
</div>
<div className="max-h-52 space-y-0.5 overflow-y-auto">
{filteredValues.slice(0, 100).map((val) => {
const isSelected = filterValues.has(val);
return (
<div
key={val}
className={cn(
"hover:bg-muted flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs",
isSelected && "bg-primary/10",
)}
onClick={() => onToggle(colKey, val)}
>
<div className={cn(
"flex h-4 w-4 items-center justify-center rounded border shrink-0",
isSelected ? "bg-primary border-primary" : "border-input",
)}>
{isSelected && <Check className="text-primary-foreground h-3 w-3" />}
</div>
<span className="truncate">{val || "(빈 값)"}</span>
</div>
);
})}
{filteredValues.length > 100 && (
<div className="text-muted-foreground px-2 py-1 text-xs">
... {filteredValues.length - 100}
</div>
)}
</div>
</div>
</PopoverContent>
</Popover>
);
}
// 선택된 소스 아이템 (등록 모달에서 사용)
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);
// ts.visibleColumns 기반 마스터/디테일 컬럼 계산
const visibleMasterLayout = useMemo(() => {
const ordered: typeof MASTER_BODY_LAYOUT = [];
for (const vc of ts.visibleColumns) {
const m = MASTER_BODY_LAYOUT.find((ml) => ml.key === vc.key);
if (m) ordered.push(m);
}
return ordered.length > 0 ? ordered : MASTER_BODY_LAYOUT;
}, [ts.visibleColumns]);
const visibleDetailCols = useMemo(() => {
const ordered: typeof DETAIL_HEADER_COLS = [];
for (const vc of ts.visibleColumns) {
const detailKey = DETAIL_KEY_MAP[vc.key];
if (detailKey) {
const d = DETAIL_HEADER_COLS.find((dc) => dc.key === detailKey);
if (d) ordered.push(d);
}
}
return ordered.length > 0 ? ordered : DETAIL_HEADER_COLS;
}, [ts.visibleColumns]);
const TOTAL_COLS = 3 + visibleMasterLayout.length;
// 목록 데이터
const [data, setData] = useState<OutboundItem[]>([]);
const [loading, setLoading] = useState(false);
const [checkedIds, setCheckedIds] = useState<string[]>([]);
// 검색 필터
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 마스터-디테일 그룹 테이블 state
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
const [closingOrders, setClosingOrders] = useState<Set<string>>(new Set());
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
const [sortState, setSortState] = useState<{ key: string; direction: "asc" | "desc" } | null>(null);
// 등록 모달
const [isModalOpen, setIsModalOpen] = useState(false);
const [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", "unit"].map(async (col) => {
try {
const res = await apiClient.get(`/table-categories/item_info/${col}/values`);
const items = flatten(res.data?.data || []);
map[col] = {};
for (const item of items) map[col][item.code] = item.label;
} catch { /* skip */ }
})
).then(() => setCatMap(map));
}, []);
const resolveCat = useCallback((col: string, code: string) => {
if (!code) return "";
return catMap[col]?.[code] || code;
}, [catMap]);
// 소스 데이터
const [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
}
})();
}, []);
// --- 마스터-디테일 그룹핑, 필터, 정렬 ---
// outbound_number 기준 그룹핑 + 필터 + 정렬
const filteredGroups = useMemo(() => {
// 1차: outbound_number 기준 그룹핑
const allGroups: Record<string, { master: OutboundItem; details: OutboundItem[] }> = {};
for (const row of data) {
const key = row.outbound_number || "_no_number";
if (!allGroups[key]) {
allGroups[key] = { master: row, details: [] };
}
allGroups[key].details.push(row);
}
// 마스터 필터 / 디테일 필터 분리
const masterFilters: Record<string, Set<string>> = {};
const detailFilters: Record<string, Set<string>> = {};
for (const [colKey, values] of Object.entries(headerFilters)) {
if (values.size === 0) continue;
if (MASTER_KEYS.has(colKey)) masterFilters[colKey] = values;
else detailFilters[colKey] = values;
}
// 2차: 마스터 필터 적용 (그룹 단위)
let entries = Object.entries(allGroups);
if (Object.keys(masterFilters).length > 0) {
entries = entries.filter(([, group]) =>
Object.entries(masterFilters).every(([colKey, values]) => {
const raw = (group.master as any)?.[colKey] ?? "";
return values.has(String(raw));
})
);
}
// 3차: 디테일 필터 적용 (행 단위)
if (Object.keys(detailFilters).length > 0) {
entries = entries
.map(([outNo, group]) => {
const filtered = group.details.filter((row) =>
Object.entries(detailFilters).every(([colKey, values]) => {
let cellVal = (row as any)[colKey] != null ? String((row as any)[colKey]) : "";
if (colKey === "source_type") cellVal = SOURCE_TYPE_LABEL[cellVal] || cellVal;
return values.has(cellVal);
})
);
return [outNo, { ...group, details: filtered }] as [string, typeof group];
})
.filter(([, group]) => group.details.length > 0);
}
// 4차: 정렬
if (sortState) {
const { key, direction } = sortState;
if (MASTER_KEYS.has(key)) {
entries.sort(([, a], [, b]) => {
const av = (a.master as any)?.[key] ?? "";
const bv = (b.master 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));
});
} else {
entries.forEach(([, group]) => {
group.details.sort((a, b) => {
let av: any = (a as any)[key] ?? "";
let bv: any = (b as any)[key] ?? "";
if (key === "source_type") { av = SOURCE_TYPE_LABEL[av] || av; bv = SOURCE_TYPE_LABEL[bv] || bv; }
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
});
}
}
return Object.fromEntries(entries);
}, [data, headerFilters, sortState]);
// 마스터 컬럼별 고유값 (헤더 필터용)
const masterUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
const seenMasters = new Map<string, OutboundItem>();
data.forEach((row) => {
if (row.outbound_number && !seenMasters.has(row.outbound_number)) {
seenMasters.set(row.outbound_number, row);
}
});
const masters = Array.from(seenMasters.values());
for (const col of [{ key: "outbound_number", label: "출고번호" }, ...MASTER_BODY_LAYOUT.map(({ key, label }) => ({ key, label }))]) {
const values = new Set<string>();
masters.forEach((m) => {
const val = (m as any)?.[col.key];
if (val !== null && val !== undefined && val !== "") values.add(String(val));
});
result[col.key] = Array.from(values).sort();
}
return result;
}, [data]);
// 디테일 컬럼별 고유값 (디테일 서브헤더 필터용)
const columnUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
for (const col of DETAIL_HEADER_COLS) {
const values = new Set<string>();
data.forEach((row) => {
let val = (row as any)[col.key];
if (val !== null && val !== undefined && val !== "") {
if (col.key === "source_type") val = SOURCE_TYPE_LABEL[val] || val;
values.add(String(val));
}
});
result[col.key] = Array.from(values).sort();
}
return result;
}, [data]);
// 헤더 필터 토글/초기화
const toggleHeaderFilter = (colKey: string, value: string) => {
setHeaderFilters((prev) => {
const next = { ...prev };
const set = new Set(next[colKey] || []);
if (set.has(value)) set.delete(value); else set.add(value);
if (set.size === 0) delete next[colKey]; else next[colKey] = set;
return next;
});
};
const clearHeaderFilter = (colKey: string) => {
setHeaderFilters((prev) => {
const next = { ...prev };
delete next[colKey];
return next;
});
};
const handleSort = (key: string) => {
setSortState((prev) =>
prev?.key === key
? prev.direction === "asc" ? { key, direction: "desc" } : null
: { key, direction: "asc" }
);
};
// 삭제
const handleDelete = async () => {
if (checkedIds.length === 0) return;
if (!confirm(`선택한 ${checkedIds.length}건을 삭제하시겠습니까?`)) return;
for (const id of checkedIds) {
await 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) setShipmentInstructions(res.data);
} else if (type === "반품출고") {
const res = await getPurchaseOrderSources(keyword || undefined);
if (res.success) setPurchaseOrders(res.data);
} 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 || "",
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 || "",
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 || "",
material: item.material || "",
unit: item.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 {
toast.error(editMode ? "수정에 실패했어요" : "출고 등록 중 오류가 발생했습니다.");
} 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">
{data.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="h-full overflow-auto">
<Table style={{ minWidth: `${216 + visibleMasterLayout.length * 120}px` }}>
<colgroup>
<col style={{ width: "40px" }} />
<col style={{ width: "36px" }} />
<col style={{ width: "140px" }} />
{visibleMasterLayout.map((col) => (
<col key={col.key} style={{ width: ts.getWidth(col.key) ? `${ts.getWidth(col.key)}px` : undefined }} />
))}
</colgroup>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead
className="text-center cursor-pointer"
onClick={() => {
const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id));
const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
setCheckedIds(allChecked ? [] : allFilteredIds);
}}
>
<Checkbox
checked={(() => {
const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id));
return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
})()}
onCheckedChange={() => {}}
/>
</TableHead>
<TableHead />
{/* 출고번호 */}
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort("outbound_number")}>
<span className="truncate"></span>
{sortState?.key === "outbound_number" && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(masterUniqueValues["outbound_number"] || []).length > 0 && (
<HeaderFilterPopover
colKey="outbound_number" colLabel="출고번호"
uniqueValues={masterUniqueValues["outbound_number"] || []}
filterValues={headerFilters["outbound_number"] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
{/* 마스터 필드 헤더 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => (
<TableHead key={col.key} colSpan={col.colSpan} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
<span className="truncate">{col.label}</span>
{sortState?.key === col.key && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(masterUniqueValues[col.key] || []).length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={masterUniqueValues[col.key] || []}
filterValues={headerFilters[col.key] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={TOTAL_COLS} className="py-16 text-center">
<Loader2 className="mx-auto h-6 w-6 animate-spin text-primary" />
</TableCell>
</TableRow>
) : Object.keys(filteredGroups).length === 0 ? (
<TableRow>
<TableCell colSpan={TOTAL_COLS} className="py-16 text-center">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<PackageOpen className="h-8 w-8 opacity-30" />
<span className="text-sm"> </span>
</div>
</TableCell>
</TableRow>
) : (
Object.entries(filteredGroups).map(([outboundNo, group]) => {
const isExpanded = expandedOrders.has(outboundNo);
const detailIds = group.details.map((d) => d.id);
const allDetailChecked = detailIds.length > 0 && detailIds.every((id) => checkedIds.includes(id));
const someDetailChecked = detailIds.some((id) => checkedIds.includes(id));
const master = group.master;
return (
<React.Fragment key={outboundNo}>
{/* 마스터 행 */}
<TableRow
style={{ borderTop: "2px solid hsl(var(--border))" }}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent font-semibold tree-master-row",
allDetailChecked && "border-l-primary bg-primary/5"
)}
onClick={() => {
if (expandedOrders.has(outboundNo)) {
setClosingOrders((prev) => new Set(prev).add(outboundNo));
setTimeout(() => {
setExpandedOrders((prev) => { const next = new Set(prev); next.delete(outboundNo); return next; });
setClosingOrders((prev) => { const next = new Set(prev); next.delete(outboundNo); return next; });
}, 200);
} else {
setExpandedOrders((prev) => new Set(prev).add(outboundNo));
}
}}
onDoubleClick={() => openEditModal(group.details[0])}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) => {
if (allDetailChecked) return prev.filter((id) => !detailIds.includes(id));
return [...new Set([...prev, ...detailIds])];
});
}}
>
<Checkbox
checked={allDetailChecked}
data-state={someDetailChecked && !allDetailChecked ? "indeterminate" : undefined}
onCheckedChange={() => {}}
/>
</TableCell>
<TableCell className="text-center">
{isExpanded
? <ChevronDown className="h-4 w-4 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 text-muted-foreground" />
}
</TableCell>
{/* 출고번호 */}
<TableCell className="font-mono whitespace-nowrap">
{outboundNo}
<span className="ml-1.5 text-xs font-normal text-muted-foreground opacity-60">({group.details.length})</span>
</TableCell>
{/* 마스터 필드 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => {
switch (col.key) {
case "outbound_type": return (
<TableCell key={col.key}>
<Badge variant="outline" className={cn("text-[11px]", getTypeColor(master.outbound_type))}>
{master.outbound_type || "-"}
</Badge>
</TableCell>
);
case "outbound_date": return (
<TableCell key={col.key} className="whitespace-nowrap text-[13px]">
{master.outbound_date ? new Date(master.outbound_date).toLocaleDateString("ko-KR") : "-"}
</TableCell>
);
case "reference_number": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.reference_number || ""}</span>
</TableCell>
);
case "customer_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.customer_name || ""}</span>
</TableCell>
);
case "warehouse_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.warehouse_name || master.warehouse_code || ""}</span>
</TableCell>
);
case "outbound_status": return (
<TableCell key={col.key}>
<Badge variant="outline" className={cn("text-[11px]", getStatusColor(master.outbound_status))}>
{master.outbound_status || "-"}
</Badge>
</TableCell>
);
case "memo": return (
<TableCell key={col.key} className="text-muted-foreground">
<span className="block truncate max-w-[120px]">{master.memo || ""}</span>
</TableCell>
);
default: return <TableCell key={col.key} className="text-[13px]">{(master as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
{/* 디테일 서브 헤더 (펼쳤을 때만) */}
{isExpanded && (
<TableRow
className={cn(
"border-l-[3px] border-l-primary/30 bg-muted/60",
closingOrders.has(outboundNo) ? "tree-detail-row-closing" : "tree-detail-row",
)}
>
<TableCell />
<TableCell />
<TableCell />
{visibleDetailCols.map((col) => {
const isRight = ["outbound_qty", "unit_price", "total_amount"].includes(col.key);
const isSorted = sortState?.key === col.key;
const uniqueVals = Array.from(new Set(
group.details.map((d) => {
let v = (d as any)[col.key];
if (col.key === "source_type") v = SOURCE_TYPE_LABEL[v] || v;
return v;
}).filter((v: any) => v != null && v !== "").map(String)
)).sort();
const filterVals = headerFilters[col.key] || new Set<string>();
return (
<TableCell
key={col.key}
className={cn(
"text-[10px] font-bold uppercase tracking-wider text-muted-foreground/70 select-none",
isRight && "text-right",
)}
>
<div className={cn("inline-flex items-center gap-1", isRight && "justify-end w-full")}>
<div
className="flex items-center gap-1 cursor-pointer min-w-0"
onClick={() => handleSort(col.key)}
>
<span className="truncate">{col.label}</span>
{isSorted && (
sortState!.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{uniqueVals.length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={uniqueVals} filterValues={filterVals}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableCell>
);
})}
</TableRow>
)}
{/* 디테일 행 (펼쳤을 때만) */}
{isExpanded && group.details.map((row, detailIdx) => {
const isClosing = closingOrders.has(outboundNo);
const isChecked = checkedIds.includes(row.id);
return (
<TableRow
key={`${row.id}-${detailIdx}`}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isClosing ? "tree-detail-row-closing" : "tree-detail-row",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
onDoubleClick={() => openEditModal(row)}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
<TableCell className="relative">
<div className="tree-connector" data-last={detailIdx === group.details.length - 1 ? "true" : "false"} />
</TableCell>
<TableCell />
{visibleDetailCols.map((col) => {
switch (col.key) {
case "source_type": return <TableCell key={col.key} className="text-[13px]">{row.source_type ? (SOURCE_TYPE_LABEL[row.source_type] || row.source_type) : "-"}</TableCell>;
case "item_code": return <TableCell key={col.key} className="font-mono text-[13px]">{row.item_code || ""}</TableCell>;
case "item_name": return <TableCell key={col.key} className="text-[13px] max-w-[150px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>;
case "specification": return <TableCell key={col.key} className="text-[13px] text-muted-foreground">{row.specification || ""}</TableCell>;
case "outbound_qty": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.outbound_qty ? Number(row.outbound_qty).toLocaleString() : ""}</TableCell>;
case "unit_price": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>;
case "total_amount": return <TableCell key={col.key} className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>;
default: return <TableCell key={col.key} className="text-[13px]">{(row as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
);
})}
</React.Fragment>
);
})
)}
</TableBody>
</Table>
</div>
</div>
{/* 출고 등록 모달 */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="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))}>
{item.outbound_type || "-"}
</Badge>
</TableCell>
<TableCell className="max-w-[180px] p-2">
<div className="flex flex-col">
<span className="truncate font-medium" title={item.item_name}>
{item.item_name}
</span>
<span className="text-muted-foreground truncate text-[10px]" title={`${item.item_number}${item.spec ? ` | ${item.spec}` : ""}`}>
{item.item_number}
{item.spec ? ` | ${item.spec}` : ""}
</span>
</div>
</TableCell>
<TableCell className="p-2 text-[11px]">{item.reference_number}</TableCell>
<TableCell className="p-2 text-right">
<Input
type="number"
value={item.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-[200px] 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>
</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-[200px] p-2">
<div className="flex flex-col">
<span className="truncate font-medium" title={po.item_name}>{po.item_name}</span>
<span className="text-muted-foreground truncate text-[10px]" title={`${po.item_code}${po.spec ? ` | ${po.spec}` : ""}`}>
{po.item_code}
{po.spec ? ` | ${po.spec}` : ""}
</span>
</div>
</TableCell>
<TableCell className="p-2 text-right">
{Number(po.order_qty).toLocaleString()}
</TableCell>
<TableCell className="p-2 text-right 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>
</div>
</TableCell>
<TableCell className="max-w-[100px] truncate p-2" title={item.spec || "-"}>{item.spec || "-"}</TableCell>
<TableCell className="max-w-[100px] truncate p-2" title={resolveCat("material", item.material) || "-"}>{resolveCat("material", item.material) || "-"}</TableCell>
<TableCell className="p-2">{resolveCat("unit", item.unit) || "-"}</TableCell>
<TableCell className="p-2 text-right">
{Number(item.standard_price).toLocaleString()}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
);
}