4267b42fdf
- Removed unnecessary variables and commented-out code related to master-detail grouping in the outbound and receiving pages. - Simplified the header filter and sorting logic to improve performance and readability. - Updated the column mapping and filtering mechanisms to ensure a more efficient data handling process. - These changes aim to enhance the overall user experience and maintainability of the logistics management interface across multiple company implementations.
1595 lines
67 KiB
TypeScript
1595 lines
67 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
|
import { apiClient } from "@/lib/api/client";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
ResizableHandle,
|
|
ResizablePanel,
|
|
ResizablePanelGroup,
|
|
} from "@/components/ui/resizable";
|
|
import {
|
|
Search,
|
|
Plus,
|
|
Trash2,
|
|
Loader2,
|
|
PackageOpen,
|
|
X,
|
|
Save,
|
|
ChevronRight,
|
|
ChevronLeft,
|
|
ChevronsLeft,
|
|
ChevronsRight,
|
|
Inbox,
|
|
Settings2,
|
|
Filter,
|
|
Check,
|
|
ArrowUp,
|
|
ArrowDown,
|
|
} from "lucide-react";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { cn } from "@/lib/utils";
|
|
import { toast } from "sonner";
|
|
import { useTableSettings } from "@/hooks/useTableSettings";
|
|
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
|
// API: /outbound/*
|
|
import {
|
|
getOutboundList,
|
|
createOutbound,
|
|
updateOutbound,
|
|
deleteOutbound,
|
|
generateOutboundNumber,
|
|
getOutboundWarehouses,
|
|
getOutboundLocations,
|
|
getShipmentInstructionSources,
|
|
getPurchaseOrderSources,
|
|
getItemSources,
|
|
type OutboundItem,
|
|
type ShipmentInstructionSource,
|
|
type PurchaseOrderSource,
|
|
type ItemSource,
|
|
type LocationOption,
|
|
type WarehouseOption,
|
|
} from "@/lib/api/outbound";
|
|
|
|
// 출고유형 옵션
|
|
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: "비고" },
|
|
];
|
|
|
|
// 총 컬럼 수: 체크박스(1) + GRID_COLUMNS(15) = 16
|
|
const TOTAL_COLS = 16;
|
|
|
|
// 헤더 필터 Popover
|
|
function HeaderFilterPopover({
|
|
colKey, colLabel, uniqueValues, filterValues, onToggle, onClear,
|
|
}: {
|
|
colKey: string;
|
|
colLabel: string;
|
|
uniqueValues: string[];
|
|
filterValues: Set<string>;
|
|
onToggle: (colKey: string, value: string) => void;
|
|
onClear: (colKey: string) => void;
|
|
}) {
|
|
const [filterSearch, setFilterSearch] = useState("");
|
|
const hasFilter = filterValues.size > 0;
|
|
const filteredValues = uniqueValues.filter(
|
|
(v) => !filterSearch || v.toLowerCase().includes(filterSearch.toLowerCase())
|
|
);
|
|
|
|
return (
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
onClick={(e) => e.stopPropagation()}
|
|
className={cn(
|
|
"hover:bg-primary/20 rounded p-0.5 transition-colors shrink-0",
|
|
hasFilter && "text-primary bg-primary/10",
|
|
)}
|
|
title="필터"
|
|
>
|
|
<Filter className="h-3 w-3" />
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-56 p-2" align="start" onClick={(e) => e.stopPropagation()}>
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between border-b pb-2">
|
|
<span className="text-xs font-medium">필터: {colLabel}</span>
|
|
{hasFilter && (
|
|
<button onClick={() => onClear(colKey)} className="text-primary text-xs hover:underline">
|
|
초기화
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="relative">
|
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground" />
|
|
<Input
|
|
value={filterSearch}
|
|
onChange={(e) => setFilterSearch(e.target.value)}
|
|
placeholder="검색..."
|
|
className="h-7 text-xs pl-7"
|
|
/>
|
|
</div>
|
|
<div className="max-h-52 space-y-0.5 overflow-y-auto">
|
|
{filteredValues.slice(0, 100).map((val) => {
|
|
const isSelected = filterValues.has(val);
|
|
return (
|
|
<div
|
|
key={val}
|
|
className={cn(
|
|
"hover:bg-muted flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs",
|
|
isSelected && "bg-primary/10",
|
|
)}
|
|
onClick={() => onToggle(colKey, val)}
|
|
>
|
|
<div className={cn(
|
|
"flex h-4 w-4 items-center justify-center rounded border shrink-0",
|
|
isSelected ? "bg-primary border-primary" : "border-input",
|
|
)}>
|
|
{isSelected && <Check className="text-primary-foreground h-3 w-3" />}
|
|
</div>
|
|
<span className="truncate">{val || "(빈 값)"}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
{filteredValues.length > 100 && (
|
|
<div className="text-muted-foreground px-2 py-1 text-xs">
|
|
...외 {filteredValues.length - 100}개
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|
|
|
|
// 선택된 소스 아이템 (등록 모달에서 사용)
|
|
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 [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", "unit"].map(async (col) => {
|
|
try {
|
|
const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_7`);
|
|
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 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 (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="h-full overflow-auto">
|
|
<Table noWrapper style={{ minWidth: "1500px" }}>
|
|
<colgroup>
|
|
<col style={{ width: "40px" }} />
|
|
<col style={{ width: "140px" }} />
|
|
<col style={{ width: "100px" }} />
|
|
<col style={{ width: "100px" }} />
|
|
<col style={{ width: "120px" }} />
|
|
<col style={{ width: "90px" }} />
|
|
<col style={{ width: "110px" }} />
|
|
<col style={{ width: "100px" }} />
|
|
<col style={{ width: "140px" }} />
|
|
<col style={{ width: "90px" }} />
|
|
<col style={{ width: "80px" }} />
|
|
<col style={{ width: "80px" }} />
|
|
<col style={{ width: "100px" }} />
|
|
<col style={{ width: "100px" }} />
|
|
<col style={{ width: "90px" }} />
|
|
<col style={{ width: "120px" }} />
|
|
</colgroup>
|
|
<TableHeader className="sticky top-0 z-10">
|
|
<TableRow className="bg-muted hover:bg-muted">
|
|
<TableHead
|
|
className="text-center cursor-pointer"
|
|
onClick={() => {
|
|
const allFilteredIds = filteredRows.map((r) => r.id);
|
|
const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
|
|
setCheckedIds(allChecked ? [] : allFilteredIds);
|
|
}}
|
|
>
|
|
<Checkbox
|
|
checked={(() => {
|
|
const allFilteredIds = filteredRows.map((r) => r.id);
|
|
return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
|
|
})()}
|
|
onCheckedChange={() => {}}
|
|
/>
|
|
</TableHead>
|
|
{GRID_COLUMNS.map((col) => {
|
|
const isRight = ["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>
|
|
) : (
|
|
filteredRows.map((row) => {
|
|
const isChecked = checkedIds.includes(row.id);
|
|
return (
|
|
<TableRow
|
|
key={row.id}
|
|
className={cn(
|
|
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
|
|
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
|
|
)}
|
|
onClick={() => {
|
|
setCheckedIds((prev) =>
|
|
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
|
|
);
|
|
}}
|
|
onDoubleClick={() => openEditModal(row as any)}
|
|
>
|
|
<TableCell
|
|
className="text-center cursor-pointer"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setCheckedIds((prev) =>
|
|
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
|
|
);
|
|
}}
|
|
>
|
|
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
|
|
</TableCell>
|
|
<TableCell className="font-mono whitespace-nowrap text-[13px]">{row.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-[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>
|
|
|
|
{/* 출고 등록 모달 */}
|
|
<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-[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>
|
|
);
|
|
}
|