From 4267b42fdf7be8b20574c545efc49c138c8fc499 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 13 Apr 2026 13:15:28 +0900 Subject: [PATCH] refactor: Streamline logistics pages by removing unused variables and enhancing header filters - 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. --- .../COMPANY_10/logistics/outbound/page.tsx | 563 ++++--------- .../COMPANY_10/logistics/receiving/page.tsx | 741 +++++++----------- .../(main)/COMPANY_10/purchase/order/page.tsx | 163 ++-- .../COMPANY_16/logistics/outbound/page.tsx | 563 ++++--------- .../COMPANY_16/logistics/receiving/page.tsx | 739 +++++++---------- .../(main)/COMPANY_16/purchase/order/page.tsx | 163 ++-- .../COMPANY_29/logistics/outbound/page.tsx | 563 ++++--------- .../COMPANY_29/logistics/receiving/page.tsx | 741 +++++++----------- .../(main)/COMPANY_29/purchase/order/page.tsx | 163 ++-- .../COMPANY_30/logistics/outbound/page.tsx | 563 ++++--------- .../COMPANY_30/logistics/receiving/page.tsx | 741 +++++++----------- .../(main)/COMPANY_30/purchase/order/page.tsx | 163 ++-- .../purchase/purchase-item/page.tsx | 30 +- .../COMPANY_7/logistics/outbound/page.tsx | 563 ++++--------- .../COMPANY_7/logistics/receiving/page.tsx | 741 +++++++----------- .../(main)/COMPANY_7/purchase/order/page.tsx | 163 ++-- .../COMPANY_8/logistics/outbound/page.tsx | 563 ++++--------- .../COMPANY_8/logistics/receiving/page.tsx | 741 +++++++----------- .../(main)/COMPANY_8/purchase/order/page.tsx | 163 ++-- .../COMPANY_9/logistics/outbound/page.tsx | 563 ++++--------- .../COMPANY_9/logistics/receiving/page.tsx | 741 +++++++----------- .../(main)/COMPANY_9/purchase/order/page.tsx | 163 ++-- .../COMPANY_9/purchase/purchase-item/page.tsx | 30 +- 23 files changed, 3343 insertions(+), 6984 deletions(-) diff --git a/frontend/app/(main)/COMPANY_10/logistics/outbound/page.tsx b/frontend/app/(main)/COMPANY_10/logistics/outbound/page.tsx index 824e2e86..457e2723 100644 --- a/frontend/app/(main)/COMPANY_10/logistics/outbound/page.tsx +++ b/frontend/app/(main)/COMPANY_10/logistics/outbound/page.tsx @@ -38,7 +38,6 @@ import { Save, ChevronRight, ChevronLeft, - ChevronDown, ChevronsLeft, ChevronsRight, Inbox, @@ -116,41 +115,8 @@ const GRID_COLUMNS = [ { 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 = { - 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", -}; +// 총 컬럼 수: 체크박스(1) + GRID_COLUMNS(15) = 16 +const TOTAL_COLS = 16; // 헤더 필터 Popover function HeaderFilterPopover({ @@ -258,30 +224,6 @@ interface SelectedSourceItem { 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([]); const [loading, setLoading] = useState(false); @@ -290,9 +232,7 @@ export default function OutboundPage() { // 검색 필터 const [searchFilters, setSearchFilters] = useState([]); - // 마스터-디테일 그룹 테이블 state - const [expandedOrders, setExpandedOrders] = useState>(new Set()); - const [closingOrders, setClosingOrders] = useState>(new Set()); + // 헤더 필터 & 정렬 const [headerFilters, setHeaderFilters] = useState>>({}); const [sortState, setSortState] = useState<{ key: string; direction: "asc" | "desc" } | null>(null); @@ -403,121 +343,59 @@ export default function OutboundPage() { })(); }, []); - // --- 마스터-디테일 그룹핑, 필터, 정렬 --- + // 플랫 행 생성 (출고유형 코드→라벨 변환, 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]); - // outbound_number 기준 그룹핑 + 필터 + 정렬 - const filteredGroups = useMemo(() => { - // 1차: outbound_number 기준 그룹핑 - const allGroups: Record = {}; - 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> = {}; - const detailFilters: Record> = {}; - 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 columnUniqueValues = useMemo(() => { const result: Record = {}; - const seenMasters = new Map(); - 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 }))]) { + for (const col of GRID_COLUMNS) { const values = new Set(); - masters.forEach((m) => { - const val = (m as any)?.[col.key]; + 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; - }, [data]); + }, [flatRows]); - // 디테일 컬럼별 고유값 (디테일 서브헤더 필터용) - const columnUniqueValues = useMemo(() => { - const result: Record = {}; - for (const col of DETAIL_HEADER_COLS) { - const values = new Set(); - 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)); - } + // 필터 + 정렬 적용된 플랫 데이터 + 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); }); - result[col.key] = Array.from(values).sort(); } - return result; - }, [data]); + + // 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) => { @@ -938,7 +816,7 @@ export default function OutboundPage() { dataCount={data.length} /> - {/* 출고 목록 테이블 */} + {/* 출고 목록 테이블 (플랫 리스트) */}
{/* 패널 헤더 */}
@@ -946,7 +824,7 @@ export default function OutboundPage() { 출고 목록 - {data.length}건 + {filteredRows.length}건
@@ -971,78 +849,68 @@ export default function OutboundPage() {
- +
- - {visibleMasterLayout.map((col) => ( - - ))} + + + + + + + + + + + + + + { - const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id)); + const allFilteredIds = filteredRows.map((r) => r.id); const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id)); setCheckedIds(allChecked ? [] : allFilteredIds); }} > { - const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id)); + const allFilteredIds = filteredRows.map((r) => r.id); return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id)); })()} onCheckedChange={() => {}} /> - - {/* 출고번호 */} - -
-
handleSort("outbound_number")}> - 출고번호 - {sortState?.key === "outbound_number" && ( - sortState.direction === "asc" - ? - : - )} -
- {(masterUniqueValues["outbound_number"] || []).length > 0 && ( - ()} - onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} - /> - )} -
-
- {/* 마스터 필드 헤더 (ts.visibleColumns 순서) */} - {visibleMasterLayout.map((col) => ( - -
-
handleSort(col.key)}> - {col.label} - {sortState?.key === col.key && ( - sortState.direction === "asc" - ? - : + {GRID_COLUMNS.map((col) => { + const isRight = ["outbound_qty", "unit_price", "total_amount"].includes(col.key); + return ( + +
+
handleSort(col.key)}> + {col.label} + {sortState?.key === col.key && ( + sortState.direction === "asc" + ? + : + )} +
+ {(columnUniqueValues[col.key] || []).length > 0 && ( + ()} + onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} + /> )}
- {(masterUniqueValues[col.key] || []).length > 0 && ( - ()} - onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} - /> - )} -
- - ))} + + ); + })} @@ -1052,7 +920,7 @@ export default function OutboundPage() { - ) : Object.keys(filteredGroups).length === 0 ? ( + ) : filteredRows.length === 0 ? (
@@ -1062,214 +930,59 @@ export default function OutboundPage() { ) : ( - 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; + filteredRows.map((row) => { + const isChecked = checkedIds.includes(row.id); return ( - - {/* 마스터 행 */} - { - 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])} - > - { - e.stopPropagation(); - setCheckedIds((prev) => { - if (allDetailChecked) return prev.filter((id) => !detailIds.includes(id)); - return [...new Set([...prev, ...detailIds])]; - }); - }} - > - {}} - /> - - - {isExpanded - ? - : - } - - {/* 출고번호 */} - - {outboundNo} - ({group.details.length}) - - {/* 마스터 필드 (ts.visibleColumns 순서) */} - {visibleMasterLayout.map((col) => { - switch (col.key) { - case "outbound_type": return ( - - - {resolveCat("outbound_type", master.outbound_type) || "-"} - - - ); - case "outbound_date": return ( - - {master.outbound_date ? new Date(master.outbound_date).toLocaleDateString("ko-KR") : "-"} - - ); - case "reference_number": return ( - - {master.reference_number || ""} - - ); - case "customer_name": return ( - - {master.customer_name || ""} - - ); - case "warehouse_name": return ( - - {master.warehouse_name || master.warehouse_code || ""} - - ); - case "outbound_status": return ( - - - {master.outbound_status || "-"} - - - ); - case "memo": return ( - - {master.memo || ""} - - ); - default: return {(master as any)[col.key] ?? ""}; - } - })} - - - {/* 디테일 서브 헤더 (펼쳤을 때만) */} - {isExpanded && ( - - - - - {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(); - return ( - -
-
handleSort(col.key)} - > - {col.label} - {isSorted && ( - sortState!.direction === "asc" - ? - : - )} -
- {uniqueVals.length > 0 && ( - - )} -
-
- ); - })} -
+ { - const isClosing = closingOrders.has(outboundNo); - const isChecked = checkedIds.includes(row.id); - return ( - { - setCheckedIds((prev) => - prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] - ); - }} - onDoubleClick={() => openEditModal(row)} - > - { - e.stopPropagation(); - setCheckedIds((prev) => - prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] - ); - }} - > - {}} /> - - -
- - - {visibleDetailCols.map((col) => { - switch (col.key) { - case "source_type": return {row.source_type ? (SOURCE_TYPE_LABEL[row.source_type] || row.source_type) : "-"}; - case "item_code": return {row.item_code || ""}; - case "item_name": return {row.item_name || ""}; - case "specification": return {row.specification || ""}; - case "outbound_qty": return {row.outbound_qty ? Number(row.outbound_qty).toLocaleString() : ""}; - case "unit_price": return {row.unit_price ? Number(row.unit_price).toLocaleString() : ""}; - case "total_amount": return {row.total_amount ? Number(row.total_amount).toLocaleString() : ""}; - default: return {(row as any)[col.key] ?? ""}; - } - })} - + onClick={() => { + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] ); - })} - + }} + onDoubleClick={() => openEditModal(row as any)} + > + { + e.stopPropagation(); + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] + ); + }} + > + {}} /> + + {row.outbound_number || ""} + + + {row.outbound_type || "-"} + + + + {row.outbound_date ? new Date(row.outbound_date).toLocaleDateString("ko-KR") : ""} + + {row.reference_number || ""} + {row.source_type || ""} + {row.customer_name || ""} + {row.item_number || ""} + {row.item_name || ""} + {row.spec || ""} + {row.outbound_qty ? Number(row.outbound_qty).toLocaleString() : ""} + {row.unit_price ? Number(row.unit_price).toLocaleString() : ""} + {row.total_amount ? Number(row.total_amount).toLocaleString() : ""} + {row.warehouse_name || row.warehouse_code || ""} + + + {row.outbound_status || "-"} + + + {row.remark || row.memo || ""} + ); }) )} diff --git a/frontend/app/(main)/COMPANY_10/logistics/receiving/page.tsx b/frontend/app/(main)/COMPANY_10/logistics/receiving/page.tsx index ac25af56..77d21e7b 100644 --- a/frontend/app/(main)/COMPANY_10/logistics/receiving/page.tsx +++ b/frontend/app/(main)/COMPANY_10/logistics/receiving/page.tsx @@ -43,7 +43,6 @@ import { X, Save, ChevronRight, - ChevronDown, ChevronLeft, ChevronsLeft, ChevronsRight, @@ -64,6 +63,7 @@ import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSea import { getReceivingList, createReceiving, + updateReceiving, deleteReceiving, generateReceivingNumber, getReceivingWarehouses, @@ -95,41 +95,8 @@ const GRID_COLUMNS = [ { key: "remark", label: "비고" }, ]; -// 마스터 헤더 레이아웃 (입고번호 뒤, 디테일 7컬럼 위에 colSpan으로 맵핑) -const MASTER_BODY_LAYOUT = [ - { key: "inbound_type", label: "입고유형", colSpan: 1 }, - { key: "inbound_date", label: "입고일", colSpan: 1 }, - { key: "reference_number", label: "참조번호", colSpan: 1 }, - { key: "supplier_name", label: "공급처", colSpan: 1 }, - { key: "warehouse_name", label: "창고", colSpan: 1 }, - { key: "inbound_status", label: "입고상태", colSpan: 1 }, - { key: "memo", label: "비고", colSpan: 1 }, -]; - -// 디테일 헤더 컬럼 -const DETAIL_HEADER_COLS = [ - { key: "source_table", label: "출처" }, - { key: "item_number", label: "품목코드" }, - { key: "item_name", label: "품목명" }, - { key: "spec", label: "규격" }, - { key: "inbound_qty", label: "입고수량" }, - { key: "unit_price", label: "단가" }, - { key: "total_amount", label: "금액" }, -]; - -// 마스터 필드 키 목록 (필터 분류용) -const MASTER_KEYS = new Set(["inbound_number", ...MASTER_BODY_LAYOUT.map((c) => c.key)]); - -// 디테일 필드 키 매핑 (GRID_COLUMNS key → DETAIL_HEADER_COLS key) -const DETAIL_KEY_MAP: Record = { - source_type: "source_table", - item_number: "item_number", - item_name: "item_name", - spec: "spec", - inbound_qty: "inbound_qty", - unit_price: "unit_price", - total_amount: "total_amount", -}; +// 총 컬럼 수: 체크박스(1) + GRID_COLUMNS(15) = 16 +const TOTAL_COLS = 16; // 헤더 필터 Popover function HeaderFilterPopover({ @@ -287,30 +254,6 @@ interface SelectedSourceItem { export default function ReceivingPage() { const ts = useTableSettings("c16-receiving", "inbound_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([]); const [loading, setLoading] = useState(false); @@ -319,9 +262,7 @@ export default function ReceivingPage() { // 검색 필터 const [searchFilters, setSearchFilters] = useState([]); - // 마스터-디테일 그룹 테이블 - const [expandedOrders, setExpandedOrders] = useState>(new Set()); - const [closingOrders, setClosingOrders] = useState>(new Set()); + // 헤더 필터 & 정렬 const [headerFilters, setHeaderFilters] = useState>>({}); const [sortState, setSortState] = useState<{ key: string; direction: "asc" | "desc" } | null>(null); @@ -338,6 +279,10 @@ export default function ReceivingPage() { const [selectedItems, setSelectedItems] = useState([]); const [saving, setSaving] = useState(false); + // 수정 모드 + const [editMode, setEditMode] = useState(false); + const [editItemIds, setEditItemIds] = useState([]); + // 소스 데이터 const [sourceKeyword, setSourceKeyword] = useState(""); const [sourceLoading, setSourceLoading] = useState(false); @@ -377,7 +322,7 @@ export default function ReceivingPage() { Promise.all( ["material", "unit"].map(async (col) => { try { - const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_10`); + const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_16`); const items = flatten(res.data?.data || []); map[col] = {}; for (const item of items) map[col][item.code] = item.label; @@ -434,124 +379,56 @@ export default function ReceivingPage() { })(); }, []); - // 필터 + 정렬 적용된 데이터 -> 그룹핑 - const filteredGroups = useMemo(() => { - // 1차: inbound_number 기준 그룹핑 - const allGroups: Record = {}; - for (const row of data) { - const key = row.inbound_number || "_no_inbound"; - if (!allGroups[key]) { - allGroups[key] = { master: row, details: [] }; - } - allGroups[key].details.push(row); - } - - // 마스터 필터 / 디테일 필터 분리 - const masterFilters: Record> = {}; - const detailFilters: Record> = {}; - 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]) => { - let raw = (group.master as any)?.[colKey] ?? ""; - // 입고유형은 코드→라벨 변환된 값으로 비교 - if (colKey === "inbound_type") raw = resolveInboundType(String(raw)); - return values.has(String(raw)); - }) - ); - } - - // 3차: 디테일 필터 적용 (행 단위) - if (Object.keys(detailFilters).length > 0) { - entries = entries - .map(([inboundNo, 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_table") cellVal = SOURCE_TABLE_LABEL[cellVal] || cellVal; - return values.has(cellVal); - }) - ); - return [inboundNo, { ...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]) => { - let av: any = (a.master as any)?.[key] ?? ""; - let bv: any = (b.master as any)?.[key] ?? ""; - if (key === "inbound_type") { av = resolveInboundType(String(av)); bv = resolveInboundType(String(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)); - }); - } else { - entries.forEach(([, group]) => { - group.details.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 Object.fromEntries(entries); - }, [data, headerFilters, sortState]); - - // 마스터 컬럼별 고유값 (마스터 헤더 필터용) - const masterUniqueValues = useMemo(() => { - const result: Record = {}; - const seenMasters = new Map(); - data.forEach((row) => { - if (row.inbound_number && !seenMasters.has(row.inbound_number)) { - seenMasters.set(row.inbound_number, row); - } - }); - const masters = Array.from(seenMasters.values()); - for (const col of [{ key: "inbound_number", label: "입고번호" }, ...MASTER_BODY_LAYOUT.map(({ key, label }) => ({ key, label }))]) { - const values = new Set(); - masters.forEach((m) => { - let val = (m as any)?.[col.key]; - if (val !== null && val !== undefined && val !== "") { - if (col.key === "inbound_type") val = resolveInboundType(String(val)); - values.add(String(val)); - } - }); - result[col.key] = Array.from(values).sort(); - } - return result; + // 플랫 행 생성 (입고유형 코드→라벨 변환, source_table→한글 등) + const flatRows = useMemo(() => { + return data.map((row) => ({ + ...row, + inbound_type: resolveInboundType(row.inbound_type), + source_type: row.source_table ? (SOURCE_TABLE_LABEL[row.source_table] || row.source_table) : (row as any).source_type || "", + })); }, [data]); - // 디테일 컬럼별 고유값 (디테일 서브헤더 필터용) + // 컬럼별 고유값 (헤더 필터용) const columnUniqueValues = useMemo(() => { const result: Record = {}; - for (const col of DETAIL_HEADER_COLS) { + for (const col of GRID_COLUMNS) { const values = new Set(); - data.forEach((row) => { - let val = (row as any)[col.key]; - if (val !== null && val !== undefined && val !== "") { - if (col.key === "source_table") val = SOURCE_TABLE_LABEL[val] || val; - values.add(String(val)); - } + 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; - }, [data]); + }, [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) => { @@ -633,6 +510,8 @@ export default function ReceivingPage() { const openRegisterModal = async () => { const defaultType = "구매입고"; + setEditMode(false); + setEditItemIds([]); setModalInboundType(defaultType); setModalInboundDate(new Date().toISOString().split("T")[0]); setModalWarehouse(""); @@ -661,6 +540,51 @@ export default function ReceivingPage() { } }; + // 수정 모달 열기 + const openEditModal = (row: InboundItem) => { + const inNo = row.inbound_number; + const grouped = data.filter((d) => d.inbound_number === inNo); + const first = grouped[0] || row; + + setEditMode(true); + setEditItemIds(grouped.map((g) => g.id)); + setModalInboundNo(inNo); + setModalInboundType(first.inbound_type || "구매입고"); + setModalInboundDate(first.inbound_date ? String(first.inbound_date).slice(0, 10) : ""); + setModalWarehouse(first.warehouse_code || ""); + setModalLocation(first.location_code || ""); + setModalInspector((first as any).inspector || ""); + setModalManager((first as any).manager || ""); + setModalMemo(first.memo || ""); + setSelectedItems( + grouped.map((g) => ({ + key: g.id, + inbound_type: g.inbound_type || "", + reference_number: g.reference_number || "", + supplier_code: (g as any).supplier_code || "", + supplier_name: g.supplier_name || "", + item_number: g.item_number || "", + item_name: g.item_name || "", + spec: g.spec || "", + material: (g as any).material || "", + unit: (g as any).unit || "", + inbound_qty: Number(g.inbound_qty) || 0, + unit_price: Number(g.unit_price) || 0, + total_amount: Number(g.total_amount) || 0, + source_table: (g as any).source_table || "", + source_id: (g as any).source_id || "", + })) + ); + setSourceKeyword(""); + setPurchaseOrders([]); + setShipments([]); + setItems([]); + setSourcePage(1); + setSourceTotalCount(0); + setIsModalOpen(true); + loadSourceData(first.inbound_type || "구매입고", undefined, 1); + }; + // 검색 버튼 클릭 시 const searchSourceData = useCallback(async () => { setSourcePage(1); @@ -811,41 +735,97 @@ export default function ReceivingPage() { } setSaving(true); try { - const res = await createReceiving({ - inbound_number: modalInboundNo, - inbound_date: modalInboundDate, - warehouse_code: modalWarehouse, - location_code: modalLocation || undefined, - inspector: modalInspector || undefined, - manager: modalManager || undefined, - memo: modalMemo || undefined, - items: selectedItems.map((item) => ({ - inbound_type: item.inbound_type, - reference_number: item.reference_number, - supplier_code: item.supplier_code, - supplier_name: item.supplier_name, - item_number: item.item_number, - item_name: item.item_name, - spec: item.spec, - material: item.material, - unit: item.unit, - inbound_qty: item.inbound_qty, - unit_price: item.unit_price, - total_amount: item.total_amount, - source_table: item.source_table, - source_id: item.source_id, - inbound_status: "입고완료", - inspection_status: "대기", - })), - }); + if (editMode) { + // 기존 item 수정 + 삭제된 item 삭제 + 새 item 추가 + const currentKeys = new Set(selectedItems.map((i) => i.key)); + const toDelete = editItemIds.filter((id) => !currentKeys.has(id)); + const toUpdate = selectedItems.filter((i) => editItemIds.includes(i.key)); + const toCreate = selectedItems.filter((i) => !editItemIds.includes(i.key)); - if (res.success) { - alert(res.message || "입고 등록 완료"); + await Promise.all([ + ...toDelete.map((id) => deleteReceiving(id)), + ...toUpdate.map((item) => + updateReceiving(item.key, { + inbound_date: modalInboundDate, + inbound_qty: item.inbound_qty, + unit_price: item.unit_price, + total_amount: item.total_amount, + warehouse_code: modalWarehouse || undefined, + location_code: modalLocation || undefined, + memo: modalMemo || undefined, + } as any) + ), + ...(toCreate.length > 0 + ? [createReceiving({ + inbound_number: modalInboundNo, + inbound_date: modalInboundDate, + warehouse_code: modalWarehouse, + location_code: modalLocation || undefined, + inspector: modalInspector || undefined, + manager: modalManager || undefined, + memo: modalMemo || undefined, + items: toCreate.map((item) => ({ + inbound_type: item.inbound_type, + reference_number: item.reference_number, + supplier_code: item.supplier_code, + supplier_name: item.supplier_name, + item_number: item.item_number, + item_name: item.item_name, + spec: item.spec, + material: item.material, + unit: item.unit, + inbound_qty: item.inbound_qty, + unit_price: item.unit_price, + total_amount: item.total_amount, + source_table: item.source_table, + source_id: item.source_id, + inbound_status: "입고완료", + inspection_status: "대기", + })), + })] + : []), + ]); + toast.success("입고 정보를 수정했어요"); setIsModalOpen(false); fetchList(); + } else { + const res = await createReceiving({ + inbound_number: modalInboundNo, + inbound_date: modalInboundDate, + warehouse_code: modalWarehouse, + location_code: modalLocation || undefined, + inspector: modalInspector || undefined, + manager: modalManager || undefined, + memo: modalMemo || undefined, + items: selectedItems.map((item) => ({ + inbound_type: item.inbound_type, + reference_number: item.reference_number, + supplier_code: item.supplier_code, + supplier_name: item.supplier_name, + item_number: item.item_number, + item_name: item.item_name, + spec: item.spec, + material: item.material, + unit: item.unit, + inbound_qty: item.inbound_qty, + unit_price: item.unit_price, + total_amount: item.total_amount, + source_table: item.source_table, + source_id: item.source_id, + inbound_status: "입고완료", + inspection_status: "대기", + })), + }); + + if (res.success) { + toast.success(res.message || "입고 등록 완료"); + setIsModalOpen(false); + fetchList(); + } } - } catch { - alert("입고 등록 중 오류가 발생했습니다."); + } catch (err: any) { + const msg = err?.response?.data?.message || (editMode ? "수정에 실패했어요" : "입고 등록 중 오류가 발생했습니다."); + toast.error(msg); } finally { setSaving(false); } @@ -897,13 +877,13 @@ export default function ReceivingPage() { } /> - {/* 입고 목록 테이블 (마스터-디테일 그룹) */} + {/* 입고 목록 테이블 (플랫 리스트) */}

입고 목록

- {Object.keys(filteredGroups).length}건 + {filteredRows.length}건
-
+
- - {visibleMasterLayout.map((col) => ( - - ))} + + + + + + + + + + + + + + { - const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id)); + const allFilteredIds = filteredRows.map((r) => r.id); const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id)); setCheckedIds(allChecked ? [] : allFilteredIds); }} > { - const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id)); + const allFilteredIds = filteredRows.map((r) => r.id); return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id)); })()} onCheckedChange={() => {}} /> - - {/* 입고번호 (별도 컬럼) */} - -
-
handleSort("inbound_number")}> - 입고번호 - {sortState?.key === "inbound_number" && ( - sortState.direction === "asc" - ? - : - )} -
- {(masterUniqueValues["inbound_number"] || []).length > 0 && ( - ()} - onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} - /> - )} -
-
- {/* 마스터 필드 헤더 (ts.visibleColumns 순서) */} - {visibleMasterLayout.map((col) => ( - -
-
handleSort(col.key)}> - {col.label} - {sortState?.key === col.key && ( - sortState.direction === "asc" - ? - : + {GRID_COLUMNS.map((col) => { + const isRight = ["inbound_qty", "unit_price", "total_amount"].includes(col.key); + return ( + +
+
handleSort(col.key)}> + {col.label} + {sortState?.key === col.key && ( + sortState.direction === "asc" + ? + : + )} +
+ {(columnUniqueValues[col.key] || []).length > 0 && ( + ()} + onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} + /> )}
- {(masterUniqueValues[col.key] || []).length > 0 && ( - ()} - onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} - /> - )} -
- - ))} + + ); + })} @@ -993,7 +963,7 @@ export default function ReceivingPage() { - ) : Object.keys(filteredGroups).length === 0 ? ( + ) : filteredRows.length === 0 ? (
@@ -1003,212 +973,59 @@ export default function ReceivingPage() { ) : ( - Object.entries(filteredGroups).map(([inboundNo, group]) => { - const isExpanded = expandedOrders.has(inboundNo); - 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; + filteredRows.map((row) => { + const isChecked = checkedIds.includes(row.id); return ( - - {/* 마스터 행 */} - { - if (expandedOrders.has(inboundNo)) { - setClosingOrders((prev) => new Set(prev).add(inboundNo)); - setTimeout(() => { - setExpandedOrders((prev) => { const next = new Set(prev); next.delete(inboundNo); return next; }); - setClosingOrders((prev) => { const next = new Set(prev); next.delete(inboundNo); return next; }); - }, 200); - } else { - setExpandedOrders((prev) => new Set(prev).add(inboundNo)); - } + { + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] + ); + }} + onDoubleClick={() => openEditModal(row as any)} + > + { + e.stopPropagation(); + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] + ); }} > - { - e.stopPropagation(); - setCheckedIds((prev) => { - if (allDetailChecked) return prev.filter((id) => !detailIds.includes(id)); - return [...new Set([...prev, ...detailIds])]; - }); - }} - > - {}} - /> - - - {isExpanded - ? - : - } - - {/* 입고번호 */} - - {inboundNo} - ({group.details.length}) - - {/* 마스터 필드 (ts.visibleColumns 순서) */} - {visibleMasterLayout.map((col) => { - switch (col.key) { - case "inbound_type": return ( - - - {resolveInboundType(master.inbound_type)} - - - ); - case "inbound_date": return ( - - {master.inbound_date ? new Date(master.inbound_date).toLocaleDateString("ko-KR") : "-"} - - ); - case "reference_number": return ( - - {master.reference_number || ""} - - ); - case "supplier_name": return ( - - {master.supplier_name || ""} - - ); - case "warehouse_name": return ( - - {master.warehouse_name || master.warehouse_code || ""} - - ); - case "inbound_status": return ( - - - {master.inbound_status || "-"} - - - ); - case "memo": return ( - - {master.memo || ""} - - ); - default: return {(master as any)[col.key] ?? ""}; - } - })} - - - {/* 디테일 서브 헤더 (펼쳤을 때만) */} - {isExpanded && ( - - - - - {visibleDetailCols.map((col) => { - const isRight = ["inbound_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_table") v = SOURCE_TABLE_LABEL[v] || v; - return v; - }).filter((v: any) => v != null && v !== "").map(String) - )).sort(); - const filterVals = headerFilters[col.key] || new Set(); - return ( - -
-
handleSort(col.key)} - > - {col.label} - {isSorted && ( - sortState!.direction === "asc" - ? - : - )} -
- {uniqueVals.length > 0 && ( - - )} -
-
- ); - })} -
- )} - - {/* 디테일 행 (펼쳤을 때만) */} - {isExpanded && group.details.map((row, detailIdx) => { - const isClosing = closingOrders.has(inboundNo); - const isChecked = checkedIds.includes(row.id); - return ( - { - setCheckedIds((prev) => - prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] - ); - }} - > - { - e.stopPropagation(); - setCheckedIds((prev) => - prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] - ); - }} - > - {}} /> - - -
- - - {visibleDetailCols.map((col) => { - switch (col.key) { - case "source_table": return {row.source_table ? SOURCE_TABLE_LABEL[row.source_table] || row.source_table : "-"}; - case "item_number": return {row.item_number || ""}; - case "item_name": return {row.item_name || ""}; - case "spec": return {row.spec || ""}; - case "inbound_qty": return {row.inbound_qty ? Number(row.inbound_qty).toLocaleString() : ""}; - case "unit_price": return {row.unit_price ? Number(row.unit_price).toLocaleString() : ""}; - case "total_amount": return {row.total_amount ? Number(row.total_amount).toLocaleString() : ""}; - default: return {(row as any)[col.key] ?? ""}; - } - })} - - ); - })} - + {}} /> + + {row.inbound_number || ""} + + + {row.inbound_type || "-"} + + + + {row.inbound_date ? new Date(row.inbound_date).toLocaleDateString("ko-KR") : ""} + + {row.reference_number || ""} + {row.source_type || ""} + {row.supplier_name || ""} + {row.item_number || ""} + {row.item_name || ""} + {row.spec || ""} + {row.inbound_qty ? Number(row.inbound_qty).toLocaleString() : ""} + {row.unit_price ? Number(row.unit_price).toLocaleString() : ""} + {row.total_amount ? Number(row.total_amount).toLocaleString() : ""} + {row.warehouse_name || (row as any).warehouse_code || ""} + + + {row.inbound_status || "-"} + + + {row.remark || row.memo || ""} + ); }) )} @@ -1221,9 +1038,9 @@ export default function ReceivingPage() { - 입고 등록 + {editMode ? "입고 수정" : "입고 등록"} - 입고유형을 선택하고, 좌측에서 근거 데이터를 검색하여 추가해 주세요. + {editMode ? "입고 정보를 수정해 주세요." : "입고유형을 선택하고, 좌측에서 근거 데이터를 검색하여 추가해 주세요."}
diff --git a/frontend/app/(main)/COMPANY_10/purchase/order/page.tsx b/frontend/app/(main)/COMPANY_10/purchase/order/page.tsx index 143a84a9..90116bac 100644 --- a/frontend/app/(main)/COMPANY_10/purchase/order/page.tsx +++ b/frontend/app/(main)/COMPANY_10/purchase/order/page.tsx @@ -13,7 +13,7 @@ import { Badge } from "@/components/ui/badge"; import { Label } from "@/components/ui/label"; import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, - ClipboardList, Pencil, Search, X, Package, ChevronDown, + ClipboardList, Pencil, Search, X, Package, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Settings2, GripVertical, } from "lucide-react"; @@ -162,7 +162,6 @@ export default function PurchaseOrderPage() { const [excelUploadOpen, setExcelUploadOpen] = useState(false); const [categoryOptions, setCategoryOptions] = useState>({}); const [checkedIds, setCheckedIds] = useState([]); - const [expandedOrders, setExpandedOrders] = useState>(new Set()); // 테이블 설정 const ts = useTableSettings("c16-purchase-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG); @@ -372,20 +371,6 @@ export default function PurchaseOrderPage() { useEffect(() => { fetchOrders(); }, [fetchOrders]); // purchase_no 기준 그룹핑 - const orderGroups = useMemo(() => { - const map: Record = {}; - for (const row of orders) { - const key = row.purchase_no || row.id; - if (!map[key]) { - map[key] = { - master: { purchase_no: row.purchase_no, order_date: row.order_date, supplier_name: row.supplier_name, status: row.status, memo: row.memo }, - details: [], - }; - } - map[key].details.push(row); - } - return map; - }, [orders]); const getCategoryLabel = (col: string, code: string) => { if (!code) return ""; @@ -811,125 +796,83 @@ export default function PurchaseOrderPage() {
- {/* 데이터 테이블 — 아코디언 그룹핑 */} + {/* 데이터 테이블 — 플랫 리스트 */}
{loading ? (
- ) : Object.keys(orderGroups).length === 0 ? ( + ) : orders.length === 0 ? (

등록된 발주가 없어요

) : ( (() => { - const MASTER_KEYS = new Set(["purchase_no", "order_date", "supplier_name", "status", "memo"]); const numCols = new Set(["order_qty", "received_qty", "remain_qty", "unit_price", "amount"]); - - // ts.visibleColumns 순서를 따르되, 마스터/디테일 컬럼을 분리 - // 고정 컬럼(품목수)은 마스터 선행 컬럼 뒤에 배치 - const leadingMaster: typeof ts.visibleColumns = []; - const detailCols: typeof ts.visibleColumns = []; - const trailingMaster: typeof ts.visibleColumns = []; - let passedFirstDetail = false; - for (const col of ts.visibleColumns) { - if (MASTER_KEYS.has(col.key)) { - if (passedFirstDetail) trailingMaster.push(col); - else leadingMaster.push(col); - } else { - passedFirstDetail = true; - detailCols.push(col); - } - } - - const renderDetailCell = (row: any, key: string) => { + const renderCell = (row: any, key: string) => { const val = row[key]; if (key === "status") return val ? {val} : "-"; - if (numCols.has(key)) return {val ? Number(val).toLocaleString() : "0"}; - return val || "-"; + if (key === "order_date" || key === "due_date") return val ? new Date(val).toLocaleDateString("ko-KR") : "-"; + if (numCols.has(key)) return {val ? Number(val).toLocaleString() : ""}; + return val || ""; }; - - const renderMasterHead = (col: { key: string; label: string }) => ( - - {col.label} - - ); - - const renderMasterCell = (col: { key: string }, m: any, purchaseNo: string) => { - if (col.key === "purchase_no") return {purchaseNo}; - if (col.key === "order_date") return {m.order_date ? new Date(m.order_date).toLocaleDateString("ko-KR") : "-"}; - if (col.key === "supplier_name") return {m.supplier_name || "-"}; - if (col.key === "status") return {m.status && {m.status}}; - if (col.key === "memo") return {m.memo || ""}; - return ; - }; - return (
- - - {leadingMaster.map(renderMasterHead)} - 품목수 - {detailCols.map(col => ( - - {col.label}{col.key === "order_qty" || col.key === "amount" ? " 합계" : ""} + { + const allIds = orders.map((r) => r.id); + const allChecked = allIds.length > 0 && allIds.every((id) => checkedIds.includes(id)); + setCheckedIds(allChecked ? [] : allIds); + }} + > + 0 && orders.every((r) => checkedIds.includes(r.id))} + onCheckedChange={() => {}} + /> + + {ts.visibleColumns.map((col) => ( + + {col.label} ))} - {trailingMaster.map(renderMasterHead)} - {Object.entries(orderGroups).map(([purchaseNo, group]) => { - const isExpanded = expandedOrders.has(purchaseNo); - const detailIds = group.details.map(d => d.id); - const allChecked = detailIds.length > 0 && detailIds.every(id => checkedIds.includes(id)); - const someChecked = detailIds.some(id => checkedIds.includes(id)); - const m = group.master; - const totalQty = group.details.reduce((s, d) => s + (Number(d.order_qty) || 0), 0); - const totalAmt = group.details.reduce((s, d) => s + (Number(d.amount) || 0), 0); + {orders.map((row) => { + const isChecked = checkedIds.includes(row.id); return ( - - setExpandedOrders(prev => { const next = new Set(prev); if (next.has(purchaseNo)) next.delete(purchaseNo); else next.add(purchaseNo); return next; })} - onDoubleClick={() => openEditModal(purchaseNo)} + { + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] + ); + }} + onDoubleClick={() => openEditModal(row.purchase_no)} + > + { + e.stopPropagation(); + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] + ); + }} > - - + {}} /> + + {ts.visibleColumns.map((col) => ( + + {renderCell(row, col.key)} - { e.stopPropagation(); setCheckedIds(prev => allChecked ? prev.filter(id => !detailIds.includes(id)) : [...new Set([...prev, ...detailIds])]); }}> - {}} /> - - {leadingMaster.map(col => renderMasterCell(col, m, purchaseNo))} - {group.details.length}건 - {detailCols.map(col => ( - - {col.key === "order_qty" ? totalQty.toLocaleString() - : col.key === "amount" ? totalAmt.toLocaleString() - : ""} - - ))} - {trailingMaster.map(col => renderMasterCell(col, m, purchaseNo))} - - {isExpanded && group.details.map((row) => ( - - - { e.stopPropagation(); setCheckedIds(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id]); }}> - {}} /> - - {leadingMaster.map(col => )} - - {detailCols.map(col => ( - - {renderDetailCell(row, col.key)} - - ))} - {trailingMaster.map(col => )} - ))} - + ); })} @@ -938,7 +881,7 @@ export default function PurchaseOrderPage() { })() )}
- 전체 {Object.keys(orderGroups).length}건 (발주 기준) / {orders.length}건 (품목 기준) + 전체 {orders.length}건
diff --git a/frontend/app/(main)/COMPANY_16/logistics/outbound/page.tsx b/frontend/app/(main)/COMPANY_16/logistics/outbound/page.tsx index 824e2e86..457e2723 100644 --- a/frontend/app/(main)/COMPANY_16/logistics/outbound/page.tsx +++ b/frontend/app/(main)/COMPANY_16/logistics/outbound/page.tsx @@ -38,7 +38,6 @@ import { Save, ChevronRight, ChevronLeft, - ChevronDown, ChevronsLeft, ChevronsRight, Inbox, @@ -116,41 +115,8 @@ const GRID_COLUMNS = [ { 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 = { - 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", -}; +// 총 컬럼 수: 체크박스(1) + GRID_COLUMNS(15) = 16 +const TOTAL_COLS = 16; // 헤더 필터 Popover function HeaderFilterPopover({ @@ -258,30 +224,6 @@ interface SelectedSourceItem { 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([]); const [loading, setLoading] = useState(false); @@ -290,9 +232,7 @@ export default function OutboundPage() { // 검색 필터 const [searchFilters, setSearchFilters] = useState([]); - // 마스터-디테일 그룹 테이블 state - const [expandedOrders, setExpandedOrders] = useState>(new Set()); - const [closingOrders, setClosingOrders] = useState>(new Set()); + // 헤더 필터 & 정렬 const [headerFilters, setHeaderFilters] = useState>>({}); const [sortState, setSortState] = useState<{ key: string; direction: "asc" | "desc" } | null>(null); @@ -403,121 +343,59 @@ export default function OutboundPage() { })(); }, []); - // --- 마스터-디테일 그룹핑, 필터, 정렬 --- + // 플랫 행 생성 (출고유형 코드→라벨 변환, 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]); - // outbound_number 기준 그룹핑 + 필터 + 정렬 - const filteredGroups = useMemo(() => { - // 1차: outbound_number 기준 그룹핑 - const allGroups: Record = {}; - 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> = {}; - const detailFilters: Record> = {}; - 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 columnUniqueValues = useMemo(() => { const result: Record = {}; - const seenMasters = new Map(); - 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 }))]) { + for (const col of GRID_COLUMNS) { const values = new Set(); - masters.forEach((m) => { - const val = (m as any)?.[col.key]; + 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; - }, [data]); + }, [flatRows]); - // 디테일 컬럼별 고유값 (디테일 서브헤더 필터용) - const columnUniqueValues = useMemo(() => { - const result: Record = {}; - for (const col of DETAIL_HEADER_COLS) { - const values = new Set(); - 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)); - } + // 필터 + 정렬 적용된 플랫 데이터 + 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); }); - result[col.key] = Array.from(values).sort(); } - return result; - }, [data]); + + // 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) => { @@ -938,7 +816,7 @@ export default function OutboundPage() { dataCount={data.length} /> - {/* 출고 목록 테이블 */} + {/* 출고 목록 테이블 (플랫 리스트) */}
{/* 패널 헤더 */}
@@ -946,7 +824,7 @@ export default function OutboundPage() { 출고 목록 - {data.length}건 + {filteredRows.length}건
@@ -971,78 +849,68 @@ export default function OutboundPage() {
-
+
- - {visibleMasterLayout.map((col) => ( - - ))} + + + + + + + + + + + + + + { - const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id)); + const allFilteredIds = filteredRows.map((r) => r.id); const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id)); setCheckedIds(allChecked ? [] : allFilteredIds); }} > { - const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id)); + const allFilteredIds = filteredRows.map((r) => r.id); return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id)); })()} onCheckedChange={() => {}} /> - - {/* 출고번호 */} - -
-
handleSort("outbound_number")}> - 출고번호 - {sortState?.key === "outbound_number" && ( - sortState.direction === "asc" - ? - : - )} -
- {(masterUniqueValues["outbound_number"] || []).length > 0 && ( - ()} - onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} - /> - )} -
-
- {/* 마스터 필드 헤더 (ts.visibleColumns 순서) */} - {visibleMasterLayout.map((col) => ( - -
-
handleSort(col.key)}> - {col.label} - {sortState?.key === col.key && ( - sortState.direction === "asc" - ? - : + {GRID_COLUMNS.map((col) => { + const isRight = ["outbound_qty", "unit_price", "total_amount"].includes(col.key); + return ( + +
+
handleSort(col.key)}> + {col.label} + {sortState?.key === col.key && ( + sortState.direction === "asc" + ? + : + )} +
+ {(columnUniqueValues[col.key] || []).length > 0 && ( + ()} + onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} + /> )}
- {(masterUniqueValues[col.key] || []).length > 0 && ( - ()} - onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} - /> - )} -
- - ))} + + ); + })} @@ -1052,7 +920,7 @@ export default function OutboundPage() { - ) : Object.keys(filteredGroups).length === 0 ? ( + ) : filteredRows.length === 0 ? (
@@ -1062,214 +930,59 @@ export default function OutboundPage() { ) : ( - 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; + filteredRows.map((row) => { + const isChecked = checkedIds.includes(row.id); return ( - - {/* 마스터 행 */} - { - 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])} - > - { - e.stopPropagation(); - setCheckedIds((prev) => { - if (allDetailChecked) return prev.filter((id) => !detailIds.includes(id)); - return [...new Set([...prev, ...detailIds])]; - }); - }} - > - {}} - /> - - - {isExpanded - ? - : - } - - {/* 출고번호 */} - - {outboundNo} - ({group.details.length}) - - {/* 마스터 필드 (ts.visibleColumns 순서) */} - {visibleMasterLayout.map((col) => { - switch (col.key) { - case "outbound_type": return ( - - - {resolveCat("outbound_type", master.outbound_type) || "-"} - - - ); - case "outbound_date": return ( - - {master.outbound_date ? new Date(master.outbound_date).toLocaleDateString("ko-KR") : "-"} - - ); - case "reference_number": return ( - - {master.reference_number || ""} - - ); - case "customer_name": return ( - - {master.customer_name || ""} - - ); - case "warehouse_name": return ( - - {master.warehouse_name || master.warehouse_code || ""} - - ); - case "outbound_status": return ( - - - {master.outbound_status || "-"} - - - ); - case "memo": return ( - - {master.memo || ""} - - ); - default: return {(master as any)[col.key] ?? ""}; - } - })} - - - {/* 디테일 서브 헤더 (펼쳤을 때만) */} - {isExpanded && ( - - - - - {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(); - return ( - -
-
handleSort(col.key)} - > - {col.label} - {isSorted && ( - sortState!.direction === "asc" - ? - : - )} -
- {uniqueVals.length > 0 && ( - - )} -
-
- ); - })} -
+ { - const isClosing = closingOrders.has(outboundNo); - const isChecked = checkedIds.includes(row.id); - return ( - { - setCheckedIds((prev) => - prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] - ); - }} - onDoubleClick={() => openEditModal(row)} - > - { - e.stopPropagation(); - setCheckedIds((prev) => - prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] - ); - }} - > - {}} /> - - -
- - - {visibleDetailCols.map((col) => { - switch (col.key) { - case "source_type": return {row.source_type ? (SOURCE_TYPE_LABEL[row.source_type] || row.source_type) : "-"}; - case "item_code": return {row.item_code || ""}; - case "item_name": return {row.item_name || ""}; - case "specification": return {row.specification || ""}; - case "outbound_qty": return {row.outbound_qty ? Number(row.outbound_qty).toLocaleString() : ""}; - case "unit_price": return {row.unit_price ? Number(row.unit_price).toLocaleString() : ""}; - case "total_amount": return {row.total_amount ? Number(row.total_amount).toLocaleString() : ""}; - default: return {(row as any)[col.key] ?? ""}; - } - })} - + onClick={() => { + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] ); - })} - + }} + onDoubleClick={() => openEditModal(row as any)} + > + { + e.stopPropagation(); + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] + ); + }} + > + {}} /> + + {row.outbound_number || ""} + + + {row.outbound_type || "-"} + + + + {row.outbound_date ? new Date(row.outbound_date).toLocaleDateString("ko-KR") : ""} + + {row.reference_number || ""} + {row.source_type || ""} + {row.customer_name || ""} + {row.item_number || ""} + {row.item_name || ""} + {row.spec || ""} + {row.outbound_qty ? Number(row.outbound_qty).toLocaleString() : ""} + {row.unit_price ? Number(row.unit_price).toLocaleString() : ""} + {row.total_amount ? Number(row.total_amount).toLocaleString() : ""} + {row.warehouse_name || row.warehouse_code || ""} + + + {row.outbound_status || "-"} + + + {row.remark || row.memo || ""} + ); }) )} diff --git a/frontend/app/(main)/COMPANY_16/logistics/receiving/page.tsx b/frontend/app/(main)/COMPANY_16/logistics/receiving/page.tsx index 2933d64b..77d21e7b 100644 --- a/frontend/app/(main)/COMPANY_16/logistics/receiving/page.tsx +++ b/frontend/app/(main)/COMPANY_16/logistics/receiving/page.tsx @@ -43,7 +43,6 @@ import { X, Save, ChevronRight, - ChevronDown, ChevronLeft, ChevronsLeft, ChevronsRight, @@ -64,6 +63,7 @@ import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSea import { getReceivingList, createReceiving, + updateReceiving, deleteReceiving, generateReceivingNumber, getReceivingWarehouses, @@ -95,41 +95,8 @@ const GRID_COLUMNS = [ { key: "remark", label: "비고" }, ]; -// 마스터 헤더 레이아웃 (입고번호 뒤, 디테일 7컬럼 위에 colSpan으로 맵핑) -const MASTER_BODY_LAYOUT = [ - { key: "inbound_type", label: "입고유형", colSpan: 1 }, - { key: "inbound_date", label: "입고일", colSpan: 1 }, - { key: "reference_number", label: "참조번호", colSpan: 1 }, - { key: "supplier_name", label: "공급처", colSpan: 1 }, - { key: "warehouse_name", label: "창고", colSpan: 1 }, - { key: "inbound_status", label: "입고상태", colSpan: 1 }, - { key: "memo", label: "비고", colSpan: 1 }, -]; - -// 디테일 헤더 컬럼 -const DETAIL_HEADER_COLS = [ - { key: "source_table", label: "출처" }, - { key: "item_number", label: "품목코드" }, - { key: "item_name", label: "품목명" }, - { key: "spec", label: "규격" }, - { key: "inbound_qty", label: "입고수량" }, - { key: "unit_price", label: "단가" }, - { key: "total_amount", label: "금액" }, -]; - -// 마스터 필드 키 목록 (필터 분류용) -const MASTER_KEYS = new Set(["inbound_number", ...MASTER_BODY_LAYOUT.map((c) => c.key)]); - -// 디테일 필드 키 매핑 (GRID_COLUMNS key → DETAIL_HEADER_COLS key) -const DETAIL_KEY_MAP: Record = { - source_type: "source_table", - item_number: "item_number", - item_name: "item_name", - spec: "spec", - inbound_qty: "inbound_qty", - unit_price: "unit_price", - total_amount: "total_amount", -}; +// 총 컬럼 수: 체크박스(1) + GRID_COLUMNS(15) = 16 +const TOTAL_COLS = 16; // 헤더 필터 Popover function HeaderFilterPopover({ @@ -287,30 +254,6 @@ interface SelectedSourceItem { export default function ReceivingPage() { const ts = useTableSettings("c16-receiving", "inbound_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([]); const [loading, setLoading] = useState(false); @@ -319,9 +262,7 @@ export default function ReceivingPage() { // 검색 필터 const [searchFilters, setSearchFilters] = useState([]); - // 마스터-디테일 그룹 테이블 - const [expandedOrders, setExpandedOrders] = useState>(new Set()); - const [closingOrders, setClosingOrders] = useState>(new Set()); + // 헤더 필터 & 정렬 const [headerFilters, setHeaderFilters] = useState>>({}); const [sortState, setSortState] = useState<{ key: string; direction: "asc" | "desc" } | null>(null); @@ -338,6 +279,10 @@ export default function ReceivingPage() { const [selectedItems, setSelectedItems] = useState([]); const [saving, setSaving] = useState(false); + // 수정 모드 + const [editMode, setEditMode] = useState(false); + const [editItemIds, setEditItemIds] = useState([]); + // 소스 데이터 const [sourceKeyword, setSourceKeyword] = useState(""); const [sourceLoading, setSourceLoading] = useState(false); @@ -434,124 +379,56 @@ export default function ReceivingPage() { })(); }, []); - // 필터 + 정렬 적용된 데이터 -> 그룹핑 - const filteredGroups = useMemo(() => { - // 1차: inbound_number 기준 그룹핑 - const allGroups: Record = {}; - for (const row of data) { - const key = row.inbound_number || "_no_inbound"; - if (!allGroups[key]) { - allGroups[key] = { master: row, details: [] }; - } - allGroups[key].details.push(row); - } - - // 마스터 필터 / 디테일 필터 분리 - const masterFilters: Record> = {}; - const detailFilters: Record> = {}; - 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]) => { - let raw = (group.master as any)?.[colKey] ?? ""; - // 입고유형은 코드→라벨 변환된 값으로 비교 - if (colKey === "inbound_type") raw = resolveInboundType(String(raw)); - return values.has(String(raw)); - }) - ); - } - - // 3차: 디테일 필터 적용 (행 단위) - if (Object.keys(detailFilters).length > 0) { - entries = entries - .map(([inboundNo, 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_table") cellVal = SOURCE_TABLE_LABEL[cellVal] || cellVal; - return values.has(cellVal); - }) - ); - return [inboundNo, { ...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]) => { - let av: any = (a.master as any)?.[key] ?? ""; - let bv: any = (b.master as any)?.[key] ?? ""; - if (key === "inbound_type") { av = resolveInboundType(String(av)); bv = resolveInboundType(String(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)); - }); - } else { - entries.forEach(([, group]) => { - group.details.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 Object.fromEntries(entries); - }, [data, headerFilters, sortState]); - - // 마스터 컬럼별 고유값 (마스터 헤더 필터용) - const masterUniqueValues = useMemo(() => { - const result: Record = {}; - const seenMasters = new Map(); - data.forEach((row) => { - if (row.inbound_number && !seenMasters.has(row.inbound_number)) { - seenMasters.set(row.inbound_number, row); - } - }); - const masters = Array.from(seenMasters.values()); - for (const col of [{ key: "inbound_number", label: "입고번호" }, ...MASTER_BODY_LAYOUT.map(({ key, label }) => ({ key, label }))]) { - const values = new Set(); - masters.forEach((m) => { - let val = (m as any)?.[col.key]; - if (val !== null && val !== undefined && val !== "") { - if (col.key === "inbound_type") val = resolveInboundType(String(val)); - values.add(String(val)); - } - }); - result[col.key] = Array.from(values).sort(); - } - return result; + // 플랫 행 생성 (입고유형 코드→라벨 변환, source_table→한글 등) + const flatRows = useMemo(() => { + return data.map((row) => ({ + ...row, + inbound_type: resolveInboundType(row.inbound_type), + source_type: row.source_table ? (SOURCE_TABLE_LABEL[row.source_table] || row.source_table) : (row as any).source_type || "", + })); }, [data]); - // 디테일 컬럼별 고유값 (디테일 서브헤더 필터용) + // 컬럼별 고유값 (헤더 필터용) const columnUniqueValues = useMemo(() => { const result: Record = {}; - for (const col of DETAIL_HEADER_COLS) { + for (const col of GRID_COLUMNS) { const values = new Set(); - data.forEach((row) => { - let val = (row as any)[col.key]; - if (val !== null && val !== undefined && val !== "") { - if (col.key === "source_table") val = SOURCE_TABLE_LABEL[val] || val; - values.add(String(val)); - } + 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; - }, [data]); + }, [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) => { @@ -633,6 +510,8 @@ export default function ReceivingPage() { const openRegisterModal = async () => { const defaultType = "구매입고"; + setEditMode(false); + setEditItemIds([]); setModalInboundType(defaultType); setModalInboundDate(new Date().toISOString().split("T")[0]); setModalWarehouse(""); @@ -661,6 +540,51 @@ export default function ReceivingPage() { } }; + // 수정 모달 열기 + const openEditModal = (row: InboundItem) => { + const inNo = row.inbound_number; + const grouped = data.filter((d) => d.inbound_number === inNo); + const first = grouped[0] || row; + + setEditMode(true); + setEditItemIds(grouped.map((g) => g.id)); + setModalInboundNo(inNo); + setModalInboundType(first.inbound_type || "구매입고"); + setModalInboundDate(first.inbound_date ? String(first.inbound_date).slice(0, 10) : ""); + setModalWarehouse(first.warehouse_code || ""); + setModalLocation(first.location_code || ""); + setModalInspector((first as any).inspector || ""); + setModalManager((first as any).manager || ""); + setModalMemo(first.memo || ""); + setSelectedItems( + grouped.map((g) => ({ + key: g.id, + inbound_type: g.inbound_type || "", + reference_number: g.reference_number || "", + supplier_code: (g as any).supplier_code || "", + supplier_name: g.supplier_name || "", + item_number: g.item_number || "", + item_name: g.item_name || "", + spec: g.spec || "", + material: (g as any).material || "", + unit: (g as any).unit || "", + inbound_qty: Number(g.inbound_qty) || 0, + unit_price: Number(g.unit_price) || 0, + total_amount: Number(g.total_amount) || 0, + source_table: (g as any).source_table || "", + source_id: (g as any).source_id || "", + })) + ); + setSourceKeyword(""); + setPurchaseOrders([]); + setShipments([]); + setItems([]); + setSourcePage(1); + setSourceTotalCount(0); + setIsModalOpen(true); + loadSourceData(first.inbound_type || "구매입고", undefined, 1); + }; + // 검색 버튼 클릭 시 const searchSourceData = useCallback(async () => { setSourcePage(1); @@ -811,41 +735,97 @@ export default function ReceivingPage() { } setSaving(true); try { - const res = await createReceiving({ - inbound_number: modalInboundNo, - inbound_date: modalInboundDate, - warehouse_code: modalWarehouse, - location_code: modalLocation || undefined, - inspector: modalInspector || undefined, - manager: modalManager || undefined, - memo: modalMemo || undefined, - items: selectedItems.map((item) => ({ - inbound_type: item.inbound_type, - reference_number: item.reference_number, - supplier_code: item.supplier_code, - supplier_name: item.supplier_name, - item_number: item.item_number, - item_name: item.item_name, - spec: item.spec, - material: item.material, - unit: item.unit, - inbound_qty: item.inbound_qty, - unit_price: item.unit_price, - total_amount: item.total_amount, - source_table: item.source_table, - source_id: item.source_id, - inbound_status: "입고완료", - inspection_status: "대기", - })), - }); + if (editMode) { + // 기존 item 수정 + 삭제된 item 삭제 + 새 item 추가 + const currentKeys = new Set(selectedItems.map((i) => i.key)); + const toDelete = editItemIds.filter((id) => !currentKeys.has(id)); + const toUpdate = selectedItems.filter((i) => editItemIds.includes(i.key)); + const toCreate = selectedItems.filter((i) => !editItemIds.includes(i.key)); - if (res.success) { - alert(res.message || "입고 등록 완료"); + await Promise.all([ + ...toDelete.map((id) => deleteReceiving(id)), + ...toUpdate.map((item) => + updateReceiving(item.key, { + inbound_date: modalInboundDate, + inbound_qty: item.inbound_qty, + unit_price: item.unit_price, + total_amount: item.total_amount, + warehouse_code: modalWarehouse || undefined, + location_code: modalLocation || undefined, + memo: modalMemo || undefined, + } as any) + ), + ...(toCreate.length > 0 + ? [createReceiving({ + inbound_number: modalInboundNo, + inbound_date: modalInboundDate, + warehouse_code: modalWarehouse, + location_code: modalLocation || undefined, + inspector: modalInspector || undefined, + manager: modalManager || undefined, + memo: modalMemo || undefined, + items: toCreate.map((item) => ({ + inbound_type: item.inbound_type, + reference_number: item.reference_number, + supplier_code: item.supplier_code, + supplier_name: item.supplier_name, + item_number: item.item_number, + item_name: item.item_name, + spec: item.spec, + material: item.material, + unit: item.unit, + inbound_qty: item.inbound_qty, + unit_price: item.unit_price, + total_amount: item.total_amount, + source_table: item.source_table, + source_id: item.source_id, + inbound_status: "입고완료", + inspection_status: "대기", + })), + })] + : []), + ]); + toast.success("입고 정보를 수정했어요"); setIsModalOpen(false); fetchList(); + } else { + const res = await createReceiving({ + inbound_number: modalInboundNo, + inbound_date: modalInboundDate, + warehouse_code: modalWarehouse, + location_code: modalLocation || undefined, + inspector: modalInspector || undefined, + manager: modalManager || undefined, + memo: modalMemo || undefined, + items: selectedItems.map((item) => ({ + inbound_type: item.inbound_type, + reference_number: item.reference_number, + supplier_code: item.supplier_code, + supplier_name: item.supplier_name, + item_number: item.item_number, + item_name: item.item_name, + spec: item.spec, + material: item.material, + unit: item.unit, + inbound_qty: item.inbound_qty, + unit_price: item.unit_price, + total_amount: item.total_amount, + source_table: item.source_table, + source_id: item.source_id, + inbound_status: "입고완료", + inspection_status: "대기", + })), + }); + + if (res.success) { + toast.success(res.message || "입고 등록 완료"); + setIsModalOpen(false); + fetchList(); + } } - } catch { - alert("입고 등록 중 오류가 발생했습니다."); + } catch (err: any) { + const msg = err?.response?.data?.message || (editMode ? "수정에 실패했어요" : "입고 등록 중 오류가 발생했습니다."); + toast.error(msg); } finally { setSaving(false); } @@ -897,13 +877,13 @@ export default function ReceivingPage() { } /> - {/* 입고 목록 테이블 (마스터-디테일 그룹) */} + {/* 입고 목록 테이블 (플랫 리스트) */}

입고 목록

- {Object.keys(filteredGroups).length}건 + {filteredRows.length}건
-
+
- - {visibleMasterLayout.map((col) => ( - - ))} + + + + + + + + + + + + + + { - const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id)); + const allFilteredIds = filteredRows.map((r) => r.id); const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id)); setCheckedIds(allChecked ? [] : allFilteredIds); }} > { - const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id)); + const allFilteredIds = filteredRows.map((r) => r.id); return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id)); })()} onCheckedChange={() => {}} /> - - {/* 입고번호 (별도 컬럼) */} - -
-
handleSort("inbound_number")}> - 입고번호 - {sortState?.key === "inbound_number" && ( - sortState.direction === "asc" - ? - : - )} -
- {(masterUniqueValues["inbound_number"] || []).length > 0 && ( - ()} - onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} - /> - )} -
-
- {/* 마스터 필드 헤더 (ts.visibleColumns 순서) */} - {visibleMasterLayout.map((col) => ( - -
-
handleSort(col.key)}> - {col.label} - {sortState?.key === col.key && ( - sortState.direction === "asc" - ? - : + {GRID_COLUMNS.map((col) => { + const isRight = ["inbound_qty", "unit_price", "total_amount"].includes(col.key); + return ( + +
+
handleSort(col.key)}> + {col.label} + {sortState?.key === col.key && ( + sortState.direction === "asc" + ? + : + )} +
+ {(columnUniqueValues[col.key] || []).length > 0 && ( + ()} + onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} + /> )}
- {(masterUniqueValues[col.key] || []).length > 0 && ( - ()} - onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} - /> - )} -
- - ))} + + ); + })} @@ -993,7 +963,7 @@ export default function ReceivingPage() { - ) : Object.keys(filteredGroups).length === 0 ? ( + ) : filteredRows.length === 0 ? (
@@ -1003,212 +973,59 @@ export default function ReceivingPage() { ) : ( - Object.entries(filteredGroups).map(([inboundNo, group]) => { - const isExpanded = expandedOrders.has(inboundNo); - 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; + filteredRows.map((row) => { + const isChecked = checkedIds.includes(row.id); return ( - - {/* 마스터 행 */} - { - if (expandedOrders.has(inboundNo)) { - setClosingOrders((prev) => new Set(prev).add(inboundNo)); - setTimeout(() => { - setExpandedOrders((prev) => { const next = new Set(prev); next.delete(inboundNo); return next; }); - setClosingOrders((prev) => { const next = new Set(prev); next.delete(inboundNo); return next; }); - }, 200); - } else { - setExpandedOrders((prev) => new Set(prev).add(inboundNo)); - } + { + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] + ); + }} + onDoubleClick={() => openEditModal(row as any)} + > + { + e.stopPropagation(); + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] + ); }} > - { - e.stopPropagation(); - setCheckedIds((prev) => { - if (allDetailChecked) return prev.filter((id) => !detailIds.includes(id)); - return [...new Set([...prev, ...detailIds])]; - }); - }} - > - {}} - /> - - - {isExpanded - ? - : - } - - {/* 입고번호 */} - - {inboundNo} - ({group.details.length}) - - {/* 마스터 필드 (ts.visibleColumns 순서) */} - {visibleMasterLayout.map((col) => { - switch (col.key) { - case "inbound_type": return ( - - - {resolveInboundType(master.inbound_type)} - - - ); - case "inbound_date": return ( - - {master.inbound_date ? new Date(master.inbound_date).toLocaleDateString("ko-KR") : "-"} - - ); - case "reference_number": return ( - - {master.reference_number || ""} - - ); - case "supplier_name": return ( - - {master.supplier_name || ""} - - ); - case "warehouse_name": return ( - - {master.warehouse_name || master.warehouse_code || ""} - - ); - case "inbound_status": return ( - - - {master.inbound_status || "-"} - - - ); - case "memo": return ( - - {master.memo || ""} - - ); - default: return {(master as any)[col.key] ?? ""}; - } - })} - - - {/* 디테일 서브 헤더 (펼쳤을 때만) */} - {isExpanded && ( - - - - - {visibleDetailCols.map((col) => { - const isRight = ["inbound_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_table") v = SOURCE_TABLE_LABEL[v] || v; - return v; - }).filter((v: any) => v != null && v !== "").map(String) - )).sort(); - const filterVals = headerFilters[col.key] || new Set(); - return ( - -
-
handleSort(col.key)} - > - {col.label} - {isSorted && ( - sortState!.direction === "asc" - ? - : - )} -
- {uniqueVals.length > 0 && ( - - )} -
-
- ); - })} -
- )} - - {/* 디테일 행 (펼쳤을 때만) */} - {isExpanded && group.details.map((row, detailIdx) => { - const isClosing = closingOrders.has(inboundNo); - const isChecked = checkedIds.includes(row.id); - return ( - { - setCheckedIds((prev) => - prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] - ); - }} - > - { - e.stopPropagation(); - setCheckedIds((prev) => - prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] - ); - }} - > - {}} /> - - -
- - - {visibleDetailCols.map((col) => { - switch (col.key) { - case "source_table": return {row.source_table ? SOURCE_TABLE_LABEL[row.source_table] || row.source_table : "-"}; - case "item_number": return {row.item_number || ""}; - case "item_name": return {row.item_name || ""}; - case "spec": return {row.spec || ""}; - case "inbound_qty": return {row.inbound_qty ? Number(row.inbound_qty).toLocaleString() : ""}; - case "unit_price": return {row.unit_price ? Number(row.unit_price).toLocaleString() : ""}; - case "total_amount": return {row.total_amount ? Number(row.total_amount).toLocaleString() : ""}; - default: return {(row as any)[col.key] ?? ""}; - } - })} - - ); - })} - + {}} /> + + {row.inbound_number || ""} + + + {row.inbound_type || "-"} + + + + {row.inbound_date ? new Date(row.inbound_date).toLocaleDateString("ko-KR") : ""} + + {row.reference_number || ""} + {row.source_type || ""} + {row.supplier_name || ""} + {row.item_number || ""} + {row.item_name || ""} + {row.spec || ""} + {row.inbound_qty ? Number(row.inbound_qty).toLocaleString() : ""} + {row.unit_price ? Number(row.unit_price).toLocaleString() : ""} + {row.total_amount ? Number(row.total_amount).toLocaleString() : ""} + {row.warehouse_name || (row as any).warehouse_code || ""} + + + {row.inbound_status || "-"} + + + {row.remark || row.memo || ""} + ); }) )} @@ -1221,9 +1038,9 @@ export default function ReceivingPage() { - 입고 등록 + {editMode ? "입고 수정" : "입고 등록"} - 입고유형을 선택하고, 좌측에서 근거 데이터를 검색하여 추가해 주세요. + {editMode ? "입고 정보를 수정해 주세요." : "입고유형을 선택하고, 좌측에서 근거 데이터를 검색하여 추가해 주세요."}
diff --git a/frontend/app/(main)/COMPANY_16/purchase/order/page.tsx b/frontend/app/(main)/COMPANY_16/purchase/order/page.tsx index 143a84a9..90116bac 100644 --- a/frontend/app/(main)/COMPANY_16/purchase/order/page.tsx +++ b/frontend/app/(main)/COMPANY_16/purchase/order/page.tsx @@ -13,7 +13,7 @@ import { Badge } from "@/components/ui/badge"; import { Label } from "@/components/ui/label"; import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, - ClipboardList, Pencil, Search, X, Package, ChevronDown, + ClipboardList, Pencil, Search, X, Package, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Settings2, GripVertical, } from "lucide-react"; @@ -162,7 +162,6 @@ export default function PurchaseOrderPage() { const [excelUploadOpen, setExcelUploadOpen] = useState(false); const [categoryOptions, setCategoryOptions] = useState>({}); const [checkedIds, setCheckedIds] = useState([]); - const [expandedOrders, setExpandedOrders] = useState>(new Set()); // 테이블 설정 const ts = useTableSettings("c16-purchase-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG); @@ -372,20 +371,6 @@ export default function PurchaseOrderPage() { useEffect(() => { fetchOrders(); }, [fetchOrders]); // purchase_no 기준 그룹핑 - const orderGroups = useMemo(() => { - const map: Record = {}; - for (const row of orders) { - const key = row.purchase_no || row.id; - if (!map[key]) { - map[key] = { - master: { purchase_no: row.purchase_no, order_date: row.order_date, supplier_name: row.supplier_name, status: row.status, memo: row.memo }, - details: [], - }; - } - map[key].details.push(row); - } - return map; - }, [orders]); const getCategoryLabel = (col: string, code: string) => { if (!code) return ""; @@ -811,125 +796,83 @@ export default function PurchaseOrderPage() {
- {/* 데이터 테이블 — 아코디언 그룹핑 */} + {/* 데이터 테이블 — 플랫 리스트 */}
{loading ? (
- ) : Object.keys(orderGroups).length === 0 ? ( + ) : orders.length === 0 ? (

등록된 발주가 없어요

) : ( (() => { - const MASTER_KEYS = new Set(["purchase_no", "order_date", "supplier_name", "status", "memo"]); const numCols = new Set(["order_qty", "received_qty", "remain_qty", "unit_price", "amount"]); - - // ts.visibleColumns 순서를 따르되, 마스터/디테일 컬럼을 분리 - // 고정 컬럼(품목수)은 마스터 선행 컬럼 뒤에 배치 - const leadingMaster: typeof ts.visibleColumns = []; - const detailCols: typeof ts.visibleColumns = []; - const trailingMaster: typeof ts.visibleColumns = []; - let passedFirstDetail = false; - for (const col of ts.visibleColumns) { - if (MASTER_KEYS.has(col.key)) { - if (passedFirstDetail) trailingMaster.push(col); - else leadingMaster.push(col); - } else { - passedFirstDetail = true; - detailCols.push(col); - } - } - - const renderDetailCell = (row: any, key: string) => { + const renderCell = (row: any, key: string) => { const val = row[key]; if (key === "status") return val ? {val} : "-"; - if (numCols.has(key)) return {val ? Number(val).toLocaleString() : "0"}; - return val || "-"; + if (key === "order_date" || key === "due_date") return val ? new Date(val).toLocaleDateString("ko-KR") : "-"; + if (numCols.has(key)) return {val ? Number(val).toLocaleString() : ""}; + return val || ""; }; - - const renderMasterHead = (col: { key: string; label: string }) => ( - - {col.label} - - ); - - const renderMasterCell = (col: { key: string }, m: any, purchaseNo: string) => { - if (col.key === "purchase_no") return {purchaseNo}; - if (col.key === "order_date") return {m.order_date ? new Date(m.order_date).toLocaleDateString("ko-KR") : "-"}; - if (col.key === "supplier_name") return {m.supplier_name || "-"}; - if (col.key === "status") return {m.status && {m.status}}; - if (col.key === "memo") return {m.memo || ""}; - return ; - }; - return (
- - - {leadingMaster.map(renderMasterHead)} - 품목수 - {detailCols.map(col => ( - - {col.label}{col.key === "order_qty" || col.key === "amount" ? " 합계" : ""} + { + const allIds = orders.map((r) => r.id); + const allChecked = allIds.length > 0 && allIds.every((id) => checkedIds.includes(id)); + setCheckedIds(allChecked ? [] : allIds); + }} + > + 0 && orders.every((r) => checkedIds.includes(r.id))} + onCheckedChange={() => {}} + /> + + {ts.visibleColumns.map((col) => ( + + {col.label} ))} - {trailingMaster.map(renderMasterHead)} - {Object.entries(orderGroups).map(([purchaseNo, group]) => { - const isExpanded = expandedOrders.has(purchaseNo); - const detailIds = group.details.map(d => d.id); - const allChecked = detailIds.length > 0 && detailIds.every(id => checkedIds.includes(id)); - const someChecked = detailIds.some(id => checkedIds.includes(id)); - const m = group.master; - const totalQty = group.details.reduce((s, d) => s + (Number(d.order_qty) || 0), 0); - const totalAmt = group.details.reduce((s, d) => s + (Number(d.amount) || 0), 0); + {orders.map((row) => { + const isChecked = checkedIds.includes(row.id); return ( - - setExpandedOrders(prev => { const next = new Set(prev); if (next.has(purchaseNo)) next.delete(purchaseNo); else next.add(purchaseNo); return next; })} - onDoubleClick={() => openEditModal(purchaseNo)} + { + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] + ); + }} + onDoubleClick={() => openEditModal(row.purchase_no)} + > + { + e.stopPropagation(); + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] + ); + }} > - - + {}} /> + + {ts.visibleColumns.map((col) => ( + + {renderCell(row, col.key)} - { e.stopPropagation(); setCheckedIds(prev => allChecked ? prev.filter(id => !detailIds.includes(id)) : [...new Set([...prev, ...detailIds])]); }}> - {}} /> - - {leadingMaster.map(col => renderMasterCell(col, m, purchaseNo))} - {group.details.length}건 - {detailCols.map(col => ( - - {col.key === "order_qty" ? totalQty.toLocaleString() - : col.key === "amount" ? totalAmt.toLocaleString() - : ""} - - ))} - {trailingMaster.map(col => renderMasterCell(col, m, purchaseNo))} - - {isExpanded && group.details.map((row) => ( - - - { e.stopPropagation(); setCheckedIds(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id]); }}> - {}} /> - - {leadingMaster.map(col => )} - - {detailCols.map(col => ( - - {renderDetailCell(row, col.key)} - - ))} - {trailingMaster.map(col => )} - ))} - + ); })} @@ -938,7 +881,7 @@ export default function PurchaseOrderPage() { })() )}
- 전체 {Object.keys(orderGroups).length}건 (발주 기준) / {orders.length}건 (품목 기준) + 전체 {orders.length}건
diff --git a/frontend/app/(main)/COMPANY_29/logistics/outbound/page.tsx b/frontend/app/(main)/COMPANY_29/logistics/outbound/page.tsx index 824e2e86..457e2723 100644 --- a/frontend/app/(main)/COMPANY_29/logistics/outbound/page.tsx +++ b/frontend/app/(main)/COMPANY_29/logistics/outbound/page.tsx @@ -38,7 +38,6 @@ import { Save, ChevronRight, ChevronLeft, - ChevronDown, ChevronsLeft, ChevronsRight, Inbox, @@ -116,41 +115,8 @@ const GRID_COLUMNS = [ { 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 = { - 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", -}; +// 총 컬럼 수: 체크박스(1) + GRID_COLUMNS(15) = 16 +const TOTAL_COLS = 16; // 헤더 필터 Popover function HeaderFilterPopover({ @@ -258,30 +224,6 @@ interface SelectedSourceItem { 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([]); const [loading, setLoading] = useState(false); @@ -290,9 +232,7 @@ export default function OutboundPage() { // 검색 필터 const [searchFilters, setSearchFilters] = useState([]); - // 마스터-디테일 그룹 테이블 state - const [expandedOrders, setExpandedOrders] = useState>(new Set()); - const [closingOrders, setClosingOrders] = useState>(new Set()); + // 헤더 필터 & 정렬 const [headerFilters, setHeaderFilters] = useState>>({}); const [sortState, setSortState] = useState<{ key: string; direction: "asc" | "desc" } | null>(null); @@ -403,121 +343,59 @@ export default function OutboundPage() { })(); }, []); - // --- 마스터-디테일 그룹핑, 필터, 정렬 --- + // 플랫 행 생성 (출고유형 코드→라벨 변환, 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]); - // outbound_number 기준 그룹핑 + 필터 + 정렬 - const filteredGroups = useMemo(() => { - // 1차: outbound_number 기준 그룹핑 - const allGroups: Record = {}; - 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> = {}; - const detailFilters: Record> = {}; - 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 columnUniqueValues = useMemo(() => { const result: Record = {}; - const seenMasters = new Map(); - 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 }))]) { + for (const col of GRID_COLUMNS) { const values = new Set(); - masters.forEach((m) => { - const val = (m as any)?.[col.key]; + 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; - }, [data]); + }, [flatRows]); - // 디테일 컬럼별 고유값 (디테일 서브헤더 필터용) - const columnUniqueValues = useMemo(() => { - const result: Record = {}; - for (const col of DETAIL_HEADER_COLS) { - const values = new Set(); - 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)); - } + // 필터 + 정렬 적용된 플랫 데이터 + 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); }); - result[col.key] = Array.from(values).sort(); } - return result; - }, [data]); + + // 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) => { @@ -938,7 +816,7 @@ export default function OutboundPage() { dataCount={data.length} /> - {/* 출고 목록 테이블 */} + {/* 출고 목록 테이블 (플랫 리스트) */}
{/* 패널 헤더 */}
@@ -946,7 +824,7 @@ export default function OutboundPage() { 출고 목록 - {data.length}건 + {filteredRows.length}건
@@ -971,78 +849,68 @@ export default function OutboundPage() {
-
+
- - {visibleMasterLayout.map((col) => ( - - ))} + + + + + + + + + + + + + + { - const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id)); + const allFilteredIds = filteredRows.map((r) => r.id); const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id)); setCheckedIds(allChecked ? [] : allFilteredIds); }} > { - const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id)); + const allFilteredIds = filteredRows.map((r) => r.id); return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id)); })()} onCheckedChange={() => {}} /> - - {/* 출고번호 */} - -
-
handleSort("outbound_number")}> - 출고번호 - {sortState?.key === "outbound_number" && ( - sortState.direction === "asc" - ? - : - )} -
- {(masterUniqueValues["outbound_number"] || []).length > 0 && ( - ()} - onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} - /> - )} -
-
- {/* 마스터 필드 헤더 (ts.visibleColumns 순서) */} - {visibleMasterLayout.map((col) => ( - -
-
handleSort(col.key)}> - {col.label} - {sortState?.key === col.key && ( - sortState.direction === "asc" - ? - : + {GRID_COLUMNS.map((col) => { + const isRight = ["outbound_qty", "unit_price", "total_amount"].includes(col.key); + return ( + +
+
handleSort(col.key)}> + {col.label} + {sortState?.key === col.key && ( + sortState.direction === "asc" + ? + : + )} +
+ {(columnUniqueValues[col.key] || []).length > 0 && ( + ()} + onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} + /> )}
- {(masterUniqueValues[col.key] || []).length > 0 && ( - ()} - onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} - /> - )} -
- - ))} + + ); + })} @@ -1052,7 +920,7 @@ export default function OutboundPage() { - ) : Object.keys(filteredGroups).length === 0 ? ( + ) : filteredRows.length === 0 ? (
@@ -1062,214 +930,59 @@ export default function OutboundPage() { ) : ( - 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; + filteredRows.map((row) => { + const isChecked = checkedIds.includes(row.id); return ( - - {/* 마스터 행 */} - { - 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])} - > - { - e.stopPropagation(); - setCheckedIds((prev) => { - if (allDetailChecked) return prev.filter((id) => !detailIds.includes(id)); - return [...new Set([...prev, ...detailIds])]; - }); - }} - > - {}} - /> - - - {isExpanded - ? - : - } - - {/* 출고번호 */} - - {outboundNo} - ({group.details.length}) - - {/* 마스터 필드 (ts.visibleColumns 순서) */} - {visibleMasterLayout.map((col) => { - switch (col.key) { - case "outbound_type": return ( - - - {resolveCat("outbound_type", master.outbound_type) || "-"} - - - ); - case "outbound_date": return ( - - {master.outbound_date ? new Date(master.outbound_date).toLocaleDateString("ko-KR") : "-"} - - ); - case "reference_number": return ( - - {master.reference_number || ""} - - ); - case "customer_name": return ( - - {master.customer_name || ""} - - ); - case "warehouse_name": return ( - - {master.warehouse_name || master.warehouse_code || ""} - - ); - case "outbound_status": return ( - - - {master.outbound_status || "-"} - - - ); - case "memo": return ( - - {master.memo || ""} - - ); - default: return {(master as any)[col.key] ?? ""}; - } - })} - - - {/* 디테일 서브 헤더 (펼쳤을 때만) */} - {isExpanded && ( - - - - - {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(); - return ( - -
-
handleSort(col.key)} - > - {col.label} - {isSorted && ( - sortState!.direction === "asc" - ? - : - )} -
- {uniqueVals.length > 0 && ( - - )} -
-
- ); - })} -
+ { - const isClosing = closingOrders.has(outboundNo); - const isChecked = checkedIds.includes(row.id); - return ( - { - setCheckedIds((prev) => - prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] - ); - }} - onDoubleClick={() => openEditModal(row)} - > - { - e.stopPropagation(); - setCheckedIds((prev) => - prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] - ); - }} - > - {}} /> - - -
- - - {visibleDetailCols.map((col) => { - switch (col.key) { - case "source_type": return {row.source_type ? (SOURCE_TYPE_LABEL[row.source_type] || row.source_type) : "-"}; - case "item_code": return {row.item_code || ""}; - case "item_name": return {row.item_name || ""}; - case "specification": return {row.specification || ""}; - case "outbound_qty": return {row.outbound_qty ? Number(row.outbound_qty).toLocaleString() : ""}; - case "unit_price": return {row.unit_price ? Number(row.unit_price).toLocaleString() : ""}; - case "total_amount": return {row.total_amount ? Number(row.total_amount).toLocaleString() : ""}; - default: return {(row as any)[col.key] ?? ""}; - } - })} - + onClick={() => { + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] ); - })} - + }} + onDoubleClick={() => openEditModal(row as any)} + > + { + e.stopPropagation(); + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] + ); + }} + > + {}} /> + + {row.outbound_number || ""} + + + {row.outbound_type || "-"} + + + + {row.outbound_date ? new Date(row.outbound_date).toLocaleDateString("ko-KR") : ""} + + {row.reference_number || ""} + {row.source_type || ""} + {row.customer_name || ""} + {row.item_number || ""} + {row.item_name || ""} + {row.spec || ""} + {row.outbound_qty ? Number(row.outbound_qty).toLocaleString() : ""} + {row.unit_price ? Number(row.unit_price).toLocaleString() : ""} + {row.total_amount ? Number(row.total_amount).toLocaleString() : ""} + {row.warehouse_name || row.warehouse_code || ""} + + + {row.outbound_status || "-"} + + + {row.remark || row.memo || ""} + ); }) )} diff --git a/frontend/app/(main)/COMPANY_29/logistics/receiving/page.tsx b/frontend/app/(main)/COMPANY_29/logistics/receiving/page.tsx index 4ec40482..77d21e7b 100644 --- a/frontend/app/(main)/COMPANY_29/logistics/receiving/page.tsx +++ b/frontend/app/(main)/COMPANY_29/logistics/receiving/page.tsx @@ -43,7 +43,6 @@ import { X, Save, ChevronRight, - ChevronDown, ChevronLeft, ChevronsLeft, ChevronsRight, @@ -64,6 +63,7 @@ import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSea import { getReceivingList, createReceiving, + updateReceiving, deleteReceiving, generateReceivingNumber, getReceivingWarehouses, @@ -95,41 +95,8 @@ const GRID_COLUMNS = [ { key: "remark", label: "비고" }, ]; -// 마스터 헤더 레이아웃 (입고번호 뒤, 디테일 7컬럼 위에 colSpan으로 맵핑) -const MASTER_BODY_LAYOUT = [ - { key: "inbound_type", label: "입고유형", colSpan: 1 }, - { key: "inbound_date", label: "입고일", colSpan: 1 }, - { key: "reference_number", label: "참조번호", colSpan: 1 }, - { key: "supplier_name", label: "공급처", colSpan: 1 }, - { key: "warehouse_name", label: "창고", colSpan: 1 }, - { key: "inbound_status", label: "입고상태", colSpan: 1 }, - { key: "memo", label: "비고", colSpan: 1 }, -]; - -// 디테일 헤더 컬럼 -const DETAIL_HEADER_COLS = [ - { key: "source_table", label: "출처" }, - { key: "item_number", label: "품목코드" }, - { key: "item_name", label: "품목명" }, - { key: "spec", label: "규격" }, - { key: "inbound_qty", label: "입고수량" }, - { key: "unit_price", label: "단가" }, - { key: "total_amount", label: "금액" }, -]; - -// 마스터 필드 키 목록 (필터 분류용) -const MASTER_KEYS = new Set(["inbound_number", ...MASTER_BODY_LAYOUT.map((c) => c.key)]); - -// 디테일 필드 키 매핑 (GRID_COLUMNS key → DETAIL_HEADER_COLS key) -const DETAIL_KEY_MAP: Record = { - source_type: "source_table", - item_number: "item_number", - item_name: "item_name", - spec: "spec", - inbound_qty: "inbound_qty", - unit_price: "unit_price", - total_amount: "total_amount", -}; +// 총 컬럼 수: 체크박스(1) + GRID_COLUMNS(15) = 16 +const TOTAL_COLS = 16; // 헤더 필터 Popover function HeaderFilterPopover({ @@ -287,30 +254,6 @@ interface SelectedSourceItem { export default function ReceivingPage() { const ts = useTableSettings("c16-receiving", "inbound_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([]); const [loading, setLoading] = useState(false); @@ -319,9 +262,7 @@ export default function ReceivingPage() { // 검색 필터 const [searchFilters, setSearchFilters] = useState([]); - // 마스터-디테일 그룹 테이블 - const [expandedOrders, setExpandedOrders] = useState>(new Set()); - const [closingOrders, setClosingOrders] = useState>(new Set()); + // 헤더 필터 & 정렬 const [headerFilters, setHeaderFilters] = useState>>({}); const [sortState, setSortState] = useState<{ key: string; direction: "asc" | "desc" } | null>(null); @@ -338,6 +279,10 @@ export default function ReceivingPage() { const [selectedItems, setSelectedItems] = useState([]); const [saving, setSaving] = useState(false); + // 수정 모드 + const [editMode, setEditMode] = useState(false); + const [editItemIds, setEditItemIds] = useState([]); + // 소스 데이터 const [sourceKeyword, setSourceKeyword] = useState(""); const [sourceLoading, setSourceLoading] = useState(false); @@ -377,7 +322,7 @@ export default function ReceivingPage() { Promise.all( ["material", "unit"].map(async (col) => { try { - const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_29`); + const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_16`); const items = flatten(res.data?.data || []); map[col] = {}; for (const item of items) map[col][item.code] = item.label; @@ -434,124 +379,56 @@ export default function ReceivingPage() { })(); }, []); - // 필터 + 정렬 적용된 데이터 -> 그룹핑 - const filteredGroups = useMemo(() => { - // 1차: inbound_number 기준 그룹핑 - const allGroups: Record = {}; - for (const row of data) { - const key = row.inbound_number || "_no_inbound"; - if (!allGroups[key]) { - allGroups[key] = { master: row, details: [] }; - } - allGroups[key].details.push(row); - } - - // 마스터 필터 / 디테일 필터 분리 - const masterFilters: Record> = {}; - const detailFilters: Record> = {}; - 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]) => { - let raw = (group.master as any)?.[colKey] ?? ""; - // 입고유형은 코드→라벨 변환된 값으로 비교 - if (colKey === "inbound_type") raw = resolveInboundType(String(raw)); - return values.has(String(raw)); - }) - ); - } - - // 3차: 디테일 필터 적용 (행 단위) - if (Object.keys(detailFilters).length > 0) { - entries = entries - .map(([inboundNo, 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_table") cellVal = SOURCE_TABLE_LABEL[cellVal] || cellVal; - return values.has(cellVal); - }) - ); - return [inboundNo, { ...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]) => { - let av: any = (a.master as any)?.[key] ?? ""; - let bv: any = (b.master as any)?.[key] ?? ""; - if (key === "inbound_type") { av = resolveInboundType(String(av)); bv = resolveInboundType(String(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)); - }); - } else { - entries.forEach(([, group]) => { - group.details.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 Object.fromEntries(entries); - }, [data, headerFilters, sortState]); - - // 마스터 컬럼별 고유값 (마스터 헤더 필터용) - const masterUniqueValues = useMemo(() => { - const result: Record = {}; - const seenMasters = new Map(); - data.forEach((row) => { - if (row.inbound_number && !seenMasters.has(row.inbound_number)) { - seenMasters.set(row.inbound_number, row); - } - }); - const masters = Array.from(seenMasters.values()); - for (const col of [{ key: "inbound_number", label: "입고번호" }, ...MASTER_BODY_LAYOUT.map(({ key, label }) => ({ key, label }))]) { - const values = new Set(); - masters.forEach((m) => { - let val = (m as any)?.[col.key]; - if (val !== null && val !== undefined && val !== "") { - if (col.key === "inbound_type") val = resolveInboundType(String(val)); - values.add(String(val)); - } - }); - result[col.key] = Array.from(values).sort(); - } - return result; + // 플랫 행 생성 (입고유형 코드→라벨 변환, source_table→한글 등) + const flatRows = useMemo(() => { + return data.map((row) => ({ + ...row, + inbound_type: resolveInboundType(row.inbound_type), + source_type: row.source_table ? (SOURCE_TABLE_LABEL[row.source_table] || row.source_table) : (row as any).source_type || "", + })); }, [data]); - // 디테일 컬럼별 고유값 (디테일 서브헤더 필터용) + // 컬럼별 고유값 (헤더 필터용) const columnUniqueValues = useMemo(() => { const result: Record = {}; - for (const col of DETAIL_HEADER_COLS) { + for (const col of GRID_COLUMNS) { const values = new Set(); - data.forEach((row) => { - let val = (row as any)[col.key]; - if (val !== null && val !== undefined && val !== "") { - if (col.key === "source_table") val = SOURCE_TABLE_LABEL[val] || val; - values.add(String(val)); - } + 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; - }, [data]); + }, [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) => { @@ -633,6 +510,8 @@ export default function ReceivingPage() { const openRegisterModal = async () => { const defaultType = "구매입고"; + setEditMode(false); + setEditItemIds([]); setModalInboundType(defaultType); setModalInboundDate(new Date().toISOString().split("T")[0]); setModalWarehouse(""); @@ -661,6 +540,51 @@ export default function ReceivingPage() { } }; + // 수정 모달 열기 + const openEditModal = (row: InboundItem) => { + const inNo = row.inbound_number; + const grouped = data.filter((d) => d.inbound_number === inNo); + const first = grouped[0] || row; + + setEditMode(true); + setEditItemIds(grouped.map((g) => g.id)); + setModalInboundNo(inNo); + setModalInboundType(first.inbound_type || "구매입고"); + setModalInboundDate(first.inbound_date ? String(first.inbound_date).slice(0, 10) : ""); + setModalWarehouse(first.warehouse_code || ""); + setModalLocation(first.location_code || ""); + setModalInspector((first as any).inspector || ""); + setModalManager((first as any).manager || ""); + setModalMemo(first.memo || ""); + setSelectedItems( + grouped.map((g) => ({ + key: g.id, + inbound_type: g.inbound_type || "", + reference_number: g.reference_number || "", + supplier_code: (g as any).supplier_code || "", + supplier_name: g.supplier_name || "", + item_number: g.item_number || "", + item_name: g.item_name || "", + spec: g.spec || "", + material: (g as any).material || "", + unit: (g as any).unit || "", + inbound_qty: Number(g.inbound_qty) || 0, + unit_price: Number(g.unit_price) || 0, + total_amount: Number(g.total_amount) || 0, + source_table: (g as any).source_table || "", + source_id: (g as any).source_id || "", + })) + ); + setSourceKeyword(""); + setPurchaseOrders([]); + setShipments([]); + setItems([]); + setSourcePage(1); + setSourceTotalCount(0); + setIsModalOpen(true); + loadSourceData(first.inbound_type || "구매입고", undefined, 1); + }; + // 검색 버튼 클릭 시 const searchSourceData = useCallback(async () => { setSourcePage(1); @@ -811,41 +735,97 @@ export default function ReceivingPage() { } setSaving(true); try { - const res = await createReceiving({ - inbound_number: modalInboundNo, - inbound_date: modalInboundDate, - warehouse_code: modalWarehouse, - location_code: modalLocation || undefined, - inspector: modalInspector || undefined, - manager: modalManager || undefined, - memo: modalMemo || undefined, - items: selectedItems.map((item) => ({ - inbound_type: item.inbound_type, - reference_number: item.reference_number, - supplier_code: item.supplier_code, - supplier_name: item.supplier_name, - item_number: item.item_number, - item_name: item.item_name, - spec: item.spec, - material: item.material, - unit: item.unit, - inbound_qty: item.inbound_qty, - unit_price: item.unit_price, - total_amount: item.total_amount, - source_table: item.source_table, - source_id: item.source_id, - inbound_status: "입고완료", - inspection_status: "대기", - })), - }); + if (editMode) { + // 기존 item 수정 + 삭제된 item 삭제 + 새 item 추가 + const currentKeys = new Set(selectedItems.map((i) => i.key)); + const toDelete = editItemIds.filter((id) => !currentKeys.has(id)); + const toUpdate = selectedItems.filter((i) => editItemIds.includes(i.key)); + const toCreate = selectedItems.filter((i) => !editItemIds.includes(i.key)); - if (res.success) { - alert(res.message || "입고 등록 완료"); + await Promise.all([ + ...toDelete.map((id) => deleteReceiving(id)), + ...toUpdate.map((item) => + updateReceiving(item.key, { + inbound_date: modalInboundDate, + inbound_qty: item.inbound_qty, + unit_price: item.unit_price, + total_amount: item.total_amount, + warehouse_code: modalWarehouse || undefined, + location_code: modalLocation || undefined, + memo: modalMemo || undefined, + } as any) + ), + ...(toCreate.length > 0 + ? [createReceiving({ + inbound_number: modalInboundNo, + inbound_date: modalInboundDate, + warehouse_code: modalWarehouse, + location_code: modalLocation || undefined, + inspector: modalInspector || undefined, + manager: modalManager || undefined, + memo: modalMemo || undefined, + items: toCreate.map((item) => ({ + inbound_type: item.inbound_type, + reference_number: item.reference_number, + supplier_code: item.supplier_code, + supplier_name: item.supplier_name, + item_number: item.item_number, + item_name: item.item_name, + spec: item.spec, + material: item.material, + unit: item.unit, + inbound_qty: item.inbound_qty, + unit_price: item.unit_price, + total_amount: item.total_amount, + source_table: item.source_table, + source_id: item.source_id, + inbound_status: "입고완료", + inspection_status: "대기", + })), + })] + : []), + ]); + toast.success("입고 정보를 수정했어요"); setIsModalOpen(false); fetchList(); + } else { + const res = await createReceiving({ + inbound_number: modalInboundNo, + inbound_date: modalInboundDate, + warehouse_code: modalWarehouse, + location_code: modalLocation || undefined, + inspector: modalInspector || undefined, + manager: modalManager || undefined, + memo: modalMemo || undefined, + items: selectedItems.map((item) => ({ + inbound_type: item.inbound_type, + reference_number: item.reference_number, + supplier_code: item.supplier_code, + supplier_name: item.supplier_name, + item_number: item.item_number, + item_name: item.item_name, + spec: item.spec, + material: item.material, + unit: item.unit, + inbound_qty: item.inbound_qty, + unit_price: item.unit_price, + total_amount: item.total_amount, + source_table: item.source_table, + source_id: item.source_id, + inbound_status: "입고완료", + inspection_status: "대기", + })), + }); + + if (res.success) { + toast.success(res.message || "입고 등록 완료"); + setIsModalOpen(false); + fetchList(); + } } - } catch { - alert("입고 등록 중 오류가 발생했습니다."); + } catch (err: any) { + const msg = err?.response?.data?.message || (editMode ? "수정에 실패했어요" : "입고 등록 중 오류가 발생했습니다."); + toast.error(msg); } finally { setSaving(false); } @@ -897,13 +877,13 @@ export default function ReceivingPage() { } /> - {/* 입고 목록 테이블 (마스터-디테일 그룹) */} + {/* 입고 목록 테이블 (플랫 리스트) */}

입고 목록

- {Object.keys(filteredGroups).length}건 + {filteredRows.length}건
-
+
- - {visibleMasterLayout.map((col) => ( - - ))} + + + + + + + + + + + + + + { - const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id)); + const allFilteredIds = filteredRows.map((r) => r.id); const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id)); setCheckedIds(allChecked ? [] : allFilteredIds); }} > { - const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id)); + const allFilteredIds = filteredRows.map((r) => r.id); return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id)); })()} onCheckedChange={() => {}} /> - - {/* 입고번호 (별도 컬럼) */} - -
-
handleSort("inbound_number")}> - 입고번호 - {sortState?.key === "inbound_number" && ( - sortState.direction === "asc" - ? - : - )} -
- {(masterUniqueValues["inbound_number"] || []).length > 0 && ( - ()} - onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} - /> - )} -
-
- {/* 마스터 필드 헤더 (ts.visibleColumns 순서) */} - {visibleMasterLayout.map((col) => ( - -
-
handleSort(col.key)}> - {col.label} - {sortState?.key === col.key && ( - sortState.direction === "asc" - ? - : + {GRID_COLUMNS.map((col) => { + const isRight = ["inbound_qty", "unit_price", "total_amount"].includes(col.key); + return ( + +
+
handleSort(col.key)}> + {col.label} + {sortState?.key === col.key && ( + sortState.direction === "asc" + ? + : + )} +
+ {(columnUniqueValues[col.key] || []).length > 0 && ( + ()} + onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} + /> )}
- {(masterUniqueValues[col.key] || []).length > 0 && ( - ()} - onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} - /> - )} -
- - ))} + + ); + })} @@ -993,7 +963,7 @@ export default function ReceivingPage() { - ) : Object.keys(filteredGroups).length === 0 ? ( + ) : filteredRows.length === 0 ? (
@@ -1003,212 +973,59 @@ export default function ReceivingPage() { ) : ( - Object.entries(filteredGroups).map(([inboundNo, group]) => { - const isExpanded = expandedOrders.has(inboundNo); - 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; + filteredRows.map((row) => { + const isChecked = checkedIds.includes(row.id); return ( - - {/* 마스터 행 */} - { - if (expandedOrders.has(inboundNo)) { - setClosingOrders((prev) => new Set(prev).add(inboundNo)); - setTimeout(() => { - setExpandedOrders((prev) => { const next = new Set(prev); next.delete(inboundNo); return next; }); - setClosingOrders((prev) => { const next = new Set(prev); next.delete(inboundNo); return next; }); - }, 200); - } else { - setExpandedOrders((prev) => new Set(prev).add(inboundNo)); - } + { + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] + ); + }} + onDoubleClick={() => openEditModal(row as any)} + > + { + e.stopPropagation(); + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] + ); }} > - { - e.stopPropagation(); - setCheckedIds((prev) => { - if (allDetailChecked) return prev.filter((id) => !detailIds.includes(id)); - return [...new Set([...prev, ...detailIds])]; - }); - }} - > - {}} - /> - - - {isExpanded - ? - : - } - - {/* 입고번호 */} - - {inboundNo} - ({group.details.length}) - - {/* 마스터 필드 (ts.visibleColumns 순서) */} - {visibleMasterLayout.map((col) => { - switch (col.key) { - case "inbound_type": return ( - - - {resolveInboundType(master.inbound_type)} - - - ); - case "inbound_date": return ( - - {master.inbound_date ? new Date(master.inbound_date).toLocaleDateString("ko-KR") : "-"} - - ); - case "reference_number": return ( - - {master.reference_number || ""} - - ); - case "supplier_name": return ( - - {master.supplier_name || ""} - - ); - case "warehouse_name": return ( - - {master.warehouse_name || master.warehouse_code || ""} - - ); - case "inbound_status": return ( - - - {master.inbound_status || "-"} - - - ); - case "memo": return ( - - {master.memo || ""} - - ); - default: return {(master as any)[col.key] ?? ""}; - } - })} - - - {/* 디테일 서브 헤더 (펼쳤을 때만) */} - {isExpanded && ( - - - - - {visibleDetailCols.map((col) => { - const isRight = ["inbound_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_table") v = SOURCE_TABLE_LABEL[v] || v; - return v; - }).filter((v: any) => v != null && v !== "").map(String) - )).sort(); - const filterVals = headerFilters[col.key] || new Set(); - return ( - -
-
handleSort(col.key)} - > - {col.label} - {isSorted && ( - sortState!.direction === "asc" - ? - : - )} -
- {uniqueVals.length > 0 && ( - - )} -
-
- ); - })} -
- )} - - {/* 디테일 행 (펼쳤을 때만) */} - {isExpanded && group.details.map((row, detailIdx) => { - const isClosing = closingOrders.has(inboundNo); - const isChecked = checkedIds.includes(row.id); - return ( - { - setCheckedIds((prev) => - prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] - ); - }} - > - { - e.stopPropagation(); - setCheckedIds((prev) => - prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] - ); - }} - > - {}} /> - - -
- - - {visibleDetailCols.map((col) => { - switch (col.key) { - case "source_table": return {row.source_table ? SOURCE_TABLE_LABEL[row.source_table] || row.source_table : "-"}; - case "item_number": return {row.item_number || ""}; - case "item_name": return {row.item_name || ""}; - case "spec": return {row.spec || ""}; - case "inbound_qty": return {row.inbound_qty ? Number(row.inbound_qty).toLocaleString() : ""}; - case "unit_price": return {row.unit_price ? Number(row.unit_price).toLocaleString() : ""}; - case "total_amount": return {row.total_amount ? Number(row.total_amount).toLocaleString() : ""}; - default: return {(row as any)[col.key] ?? ""}; - } - })} - - ); - })} - + {}} /> + + {row.inbound_number || ""} + + + {row.inbound_type || "-"} + + + + {row.inbound_date ? new Date(row.inbound_date).toLocaleDateString("ko-KR") : ""} + + {row.reference_number || ""} + {row.source_type || ""} + {row.supplier_name || ""} + {row.item_number || ""} + {row.item_name || ""} + {row.spec || ""} + {row.inbound_qty ? Number(row.inbound_qty).toLocaleString() : ""} + {row.unit_price ? Number(row.unit_price).toLocaleString() : ""} + {row.total_amount ? Number(row.total_amount).toLocaleString() : ""} + {row.warehouse_name || (row as any).warehouse_code || ""} + + + {row.inbound_status || "-"} + + + {row.remark || row.memo || ""} + ); }) )} @@ -1221,9 +1038,9 @@ export default function ReceivingPage() { - 입고 등록 + {editMode ? "입고 수정" : "입고 등록"} - 입고유형을 선택하고, 좌측에서 근거 데이터를 검색하여 추가해 주세요. + {editMode ? "입고 정보를 수정해 주세요." : "입고유형을 선택하고, 좌측에서 근거 데이터를 검색하여 추가해 주세요."}
diff --git a/frontend/app/(main)/COMPANY_29/purchase/order/page.tsx b/frontend/app/(main)/COMPANY_29/purchase/order/page.tsx index 143a84a9..90116bac 100644 --- a/frontend/app/(main)/COMPANY_29/purchase/order/page.tsx +++ b/frontend/app/(main)/COMPANY_29/purchase/order/page.tsx @@ -13,7 +13,7 @@ import { Badge } from "@/components/ui/badge"; import { Label } from "@/components/ui/label"; import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, - ClipboardList, Pencil, Search, X, Package, ChevronDown, + ClipboardList, Pencil, Search, X, Package, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Settings2, GripVertical, } from "lucide-react"; @@ -162,7 +162,6 @@ export default function PurchaseOrderPage() { const [excelUploadOpen, setExcelUploadOpen] = useState(false); const [categoryOptions, setCategoryOptions] = useState>({}); const [checkedIds, setCheckedIds] = useState([]); - const [expandedOrders, setExpandedOrders] = useState>(new Set()); // 테이블 설정 const ts = useTableSettings("c16-purchase-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG); @@ -372,20 +371,6 @@ export default function PurchaseOrderPage() { useEffect(() => { fetchOrders(); }, [fetchOrders]); // purchase_no 기준 그룹핑 - const orderGroups = useMemo(() => { - const map: Record = {}; - for (const row of orders) { - const key = row.purchase_no || row.id; - if (!map[key]) { - map[key] = { - master: { purchase_no: row.purchase_no, order_date: row.order_date, supplier_name: row.supplier_name, status: row.status, memo: row.memo }, - details: [], - }; - } - map[key].details.push(row); - } - return map; - }, [orders]); const getCategoryLabel = (col: string, code: string) => { if (!code) return ""; @@ -811,125 +796,83 @@ export default function PurchaseOrderPage() {
- {/* 데이터 테이블 — 아코디언 그룹핑 */} + {/* 데이터 테이블 — 플랫 리스트 */}
{loading ? (
- ) : Object.keys(orderGroups).length === 0 ? ( + ) : orders.length === 0 ? (

등록된 발주가 없어요

) : ( (() => { - const MASTER_KEYS = new Set(["purchase_no", "order_date", "supplier_name", "status", "memo"]); const numCols = new Set(["order_qty", "received_qty", "remain_qty", "unit_price", "amount"]); - - // ts.visibleColumns 순서를 따르되, 마스터/디테일 컬럼을 분리 - // 고정 컬럼(품목수)은 마스터 선행 컬럼 뒤에 배치 - const leadingMaster: typeof ts.visibleColumns = []; - const detailCols: typeof ts.visibleColumns = []; - const trailingMaster: typeof ts.visibleColumns = []; - let passedFirstDetail = false; - for (const col of ts.visibleColumns) { - if (MASTER_KEYS.has(col.key)) { - if (passedFirstDetail) trailingMaster.push(col); - else leadingMaster.push(col); - } else { - passedFirstDetail = true; - detailCols.push(col); - } - } - - const renderDetailCell = (row: any, key: string) => { + const renderCell = (row: any, key: string) => { const val = row[key]; if (key === "status") return val ? {val} : "-"; - if (numCols.has(key)) return {val ? Number(val).toLocaleString() : "0"}; - return val || "-"; + if (key === "order_date" || key === "due_date") return val ? new Date(val).toLocaleDateString("ko-KR") : "-"; + if (numCols.has(key)) return {val ? Number(val).toLocaleString() : ""}; + return val || ""; }; - - const renderMasterHead = (col: { key: string; label: string }) => ( - - {col.label} - - ); - - const renderMasterCell = (col: { key: string }, m: any, purchaseNo: string) => { - if (col.key === "purchase_no") return {purchaseNo}; - if (col.key === "order_date") return {m.order_date ? new Date(m.order_date).toLocaleDateString("ko-KR") : "-"}; - if (col.key === "supplier_name") return {m.supplier_name || "-"}; - if (col.key === "status") return {m.status && {m.status}}; - if (col.key === "memo") return {m.memo || ""}; - return ; - }; - return (
- - - {leadingMaster.map(renderMasterHead)} - 품목수 - {detailCols.map(col => ( - - {col.label}{col.key === "order_qty" || col.key === "amount" ? " 합계" : ""} + { + const allIds = orders.map((r) => r.id); + const allChecked = allIds.length > 0 && allIds.every((id) => checkedIds.includes(id)); + setCheckedIds(allChecked ? [] : allIds); + }} + > + 0 && orders.every((r) => checkedIds.includes(r.id))} + onCheckedChange={() => {}} + /> + + {ts.visibleColumns.map((col) => ( + + {col.label} ))} - {trailingMaster.map(renderMasterHead)} - {Object.entries(orderGroups).map(([purchaseNo, group]) => { - const isExpanded = expandedOrders.has(purchaseNo); - const detailIds = group.details.map(d => d.id); - const allChecked = detailIds.length > 0 && detailIds.every(id => checkedIds.includes(id)); - const someChecked = detailIds.some(id => checkedIds.includes(id)); - const m = group.master; - const totalQty = group.details.reduce((s, d) => s + (Number(d.order_qty) || 0), 0); - const totalAmt = group.details.reduce((s, d) => s + (Number(d.amount) || 0), 0); + {orders.map((row) => { + const isChecked = checkedIds.includes(row.id); return ( - - setExpandedOrders(prev => { const next = new Set(prev); if (next.has(purchaseNo)) next.delete(purchaseNo); else next.add(purchaseNo); return next; })} - onDoubleClick={() => openEditModal(purchaseNo)} + { + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] + ); + }} + onDoubleClick={() => openEditModal(row.purchase_no)} + > + { + e.stopPropagation(); + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] + ); + }} > - - + {}} /> + + {ts.visibleColumns.map((col) => ( + + {renderCell(row, col.key)} - { e.stopPropagation(); setCheckedIds(prev => allChecked ? prev.filter(id => !detailIds.includes(id)) : [...new Set([...prev, ...detailIds])]); }}> - {}} /> - - {leadingMaster.map(col => renderMasterCell(col, m, purchaseNo))} - {group.details.length}건 - {detailCols.map(col => ( - - {col.key === "order_qty" ? totalQty.toLocaleString() - : col.key === "amount" ? totalAmt.toLocaleString() - : ""} - - ))} - {trailingMaster.map(col => renderMasterCell(col, m, purchaseNo))} - - {isExpanded && group.details.map((row) => ( - - - { e.stopPropagation(); setCheckedIds(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id]); }}> - {}} /> - - {leadingMaster.map(col => )} - - {detailCols.map(col => ( - - {renderDetailCell(row, col.key)} - - ))} - {trailingMaster.map(col => )} - ))} - + ); })} @@ -938,7 +881,7 @@ export default function PurchaseOrderPage() { })() )}
- 전체 {Object.keys(orderGroups).length}건 (발주 기준) / {orders.length}건 (품목 기준) + 전체 {orders.length}건
diff --git a/frontend/app/(main)/COMPANY_30/logistics/outbound/page.tsx b/frontend/app/(main)/COMPANY_30/logistics/outbound/page.tsx index 824e2e86..457e2723 100644 --- a/frontend/app/(main)/COMPANY_30/logistics/outbound/page.tsx +++ b/frontend/app/(main)/COMPANY_30/logistics/outbound/page.tsx @@ -38,7 +38,6 @@ import { Save, ChevronRight, ChevronLeft, - ChevronDown, ChevronsLeft, ChevronsRight, Inbox, @@ -116,41 +115,8 @@ const GRID_COLUMNS = [ { 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 = { - 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", -}; +// 총 컬럼 수: 체크박스(1) + GRID_COLUMNS(15) = 16 +const TOTAL_COLS = 16; // 헤더 필터 Popover function HeaderFilterPopover({ @@ -258,30 +224,6 @@ interface SelectedSourceItem { 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([]); const [loading, setLoading] = useState(false); @@ -290,9 +232,7 @@ export default function OutboundPage() { // 검색 필터 const [searchFilters, setSearchFilters] = useState([]); - // 마스터-디테일 그룹 테이블 state - const [expandedOrders, setExpandedOrders] = useState>(new Set()); - const [closingOrders, setClosingOrders] = useState>(new Set()); + // 헤더 필터 & 정렬 const [headerFilters, setHeaderFilters] = useState>>({}); const [sortState, setSortState] = useState<{ key: string; direction: "asc" | "desc" } | null>(null); @@ -403,121 +343,59 @@ export default function OutboundPage() { })(); }, []); - // --- 마스터-디테일 그룹핑, 필터, 정렬 --- + // 플랫 행 생성 (출고유형 코드→라벨 변환, 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]); - // outbound_number 기준 그룹핑 + 필터 + 정렬 - const filteredGroups = useMemo(() => { - // 1차: outbound_number 기준 그룹핑 - const allGroups: Record = {}; - 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> = {}; - const detailFilters: Record> = {}; - 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 columnUniqueValues = useMemo(() => { const result: Record = {}; - const seenMasters = new Map(); - 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 }))]) { + for (const col of GRID_COLUMNS) { const values = new Set(); - masters.forEach((m) => { - const val = (m as any)?.[col.key]; + 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; - }, [data]); + }, [flatRows]); - // 디테일 컬럼별 고유값 (디테일 서브헤더 필터용) - const columnUniqueValues = useMemo(() => { - const result: Record = {}; - for (const col of DETAIL_HEADER_COLS) { - const values = new Set(); - 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)); - } + // 필터 + 정렬 적용된 플랫 데이터 + 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); }); - result[col.key] = Array.from(values).sort(); } - return result; - }, [data]); + + // 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) => { @@ -938,7 +816,7 @@ export default function OutboundPage() { dataCount={data.length} /> - {/* 출고 목록 테이블 */} + {/* 출고 목록 테이블 (플랫 리스트) */}
{/* 패널 헤더 */}
@@ -946,7 +824,7 @@ export default function OutboundPage() { 출고 목록 - {data.length}건 + {filteredRows.length}건
@@ -971,78 +849,68 @@ export default function OutboundPage() {
-
+
- - {visibleMasterLayout.map((col) => ( - - ))} + + + + + + + + + + + + + + { - const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id)); + const allFilteredIds = filteredRows.map((r) => r.id); const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id)); setCheckedIds(allChecked ? [] : allFilteredIds); }} > { - const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id)); + const allFilteredIds = filteredRows.map((r) => r.id); return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id)); })()} onCheckedChange={() => {}} /> - - {/* 출고번호 */} - -
-
handleSort("outbound_number")}> - 출고번호 - {sortState?.key === "outbound_number" && ( - sortState.direction === "asc" - ? - : - )} -
- {(masterUniqueValues["outbound_number"] || []).length > 0 && ( - ()} - onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} - /> - )} -
-
- {/* 마스터 필드 헤더 (ts.visibleColumns 순서) */} - {visibleMasterLayout.map((col) => ( - -
-
handleSort(col.key)}> - {col.label} - {sortState?.key === col.key && ( - sortState.direction === "asc" - ? - : + {GRID_COLUMNS.map((col) => { + const isRight = ["outbound_qty", "unit_price", "total_amount"].includes(col.key); + return ( + +
+
handleSort(col.key)}> + {col.label} + {sortState?.key === col.key && ( + sortState.direction === "asc" + ? + : + )} +
+ {(columnUniqueValues[col.key] || []).length > 0 && ( + ()} + onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} + /> )}
- {(masterUniqueValues[col.key] || []).length > 0 && ( - ()} - onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} - /> - )} -
- - ))} + + ); + })} @@ -1052,7 +920,7 @@ export default function OutboundPage() { - ) : Object.keys(filteredGroups).length === 0 ? ( + ) : filteredRows.length === 0 ? (
@@ -1062,214 +930,59 @@ export default function OutboundPage() { ) : ( - 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; + filteredRows.map((row) => { + const isChecked = checkedIds.includes(row.id); return ( - - {/* 마스터 행 */} - { - 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])} - > - { - e.stopPropagation(); - setCheckedIds((prev) => { - if (allDetailChecked) return prev.filter((id) => !detailIds.includes(id)); - return [...new Set([...prev, ...detailIds])]; - }); - }} - > - {}} - /> - - - {isExpanded - ? - : - } - - {/* 출고번호 */} - - {outboundNo} - ({group.details.length}) - - {/* 마스터 필드 (ts.visibleColumns 순서) */} - {visibleMasterLayout.map((col) => { - switch (col.key) { - case "outbound_type": return ( - - - {resolveCat("outbound_type", master.outbound_type) || "-"} - - - ); - case "outbound_date": return ( - - {master.outbound_date ? new Date(master.outbound_date).toLocaleDateString("ko-KR") : "-"} - - ); - case "reference_number": return ( - - {master.reference_number || ""} - - ); - case "customer_name": return ( - - {master.customer_name || ""} - - ); - case "warehouse_name": return ( - - {master.warehouse_name || master.warehouse_code || ""} - - ); - case "outbound_status": return ( - - - {master.outbound_status || "-"} - - - ); - case "memo": return ( - - {master.memo || ""} - - ); - default: return {(master as any)[col.key] ?? ""}; - } - })} - - - {/* 디테일 서브 헤더 (펼쳤을 때만) */} - {isExpanded && ( - - - - - {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(); - return ( - -
-
handleSort(col.key)} - > - {col.label} - {isSorted && ( - sortState!.direction === "asc" - ? - : - )} -
- {uniqueVals.length > 0 && ( - - )} -
-
- ); - })} -
+ { - const isClosing = closingOrders.has(outboundNo); - const isChecked = checkedIds.includes(row.id); - return ( - { - setCheckedIds((prev) => - prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] - ); - }} - onDoubleClick={() => openEditModal(row)} - > - { - e.stopPropagation(); - setCheckedIds((prev) => - prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] - ); - }} - > - {}} /> - - -
- - - {visibleDetailCols.map((col) => { - switch (col.key) { - case "source_type": return {row.source_type ? (SOURCE_TYPE_LABEL[row.source_type] || row.source_type) : "-"}; - case "item_code": return {row.item_code || ""}; - case "item_name": return {row.item_name || ""}; - case "specification": return {row.specification || ""}; - case "outbound_qty": return {row.outbound_qty ? Number(row.outbound_qty).toLocaleString() : ""}; - case "unit_price": return {row.unit_price ? Number(row.unit_price).toLocaleString() : ""}; - case "total_amount": return {row.total_amount ? Number(row.total_amount).toLocaleString() : ""}; - default: return {(row as any)[col.key] ?? ""}; - } - })} - + onClick={() => { + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] ); - })} - + }} + onDoubleClick={() => openEditModal(row as any)} + > + { + e.stopPropagation(); + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] + ); + }} + > + {}} /> + + {row.outbound_number || ""} + + + {row.outbound_type || "-"} + + + + {row.outbound_date ? new Date(row.outbound_date).toLocaleDateString("ko-KR") : ""} + + {row.reference_number || ""} + {row.source_type || ""} + {row.customer_name || ""} + {row.item_number || ""} + {row.item_name || ""} + {row.spec || ""} + {row.outbound_qty ? Number(row.outbound_qty).toLocaleString() : ""} + {row.unit_price ? Number(row.unit_price).toLocaleString() : ""} + {row.total_amount ? Number(row.total_amount).toLocaleString() : ""} + {row.warehouse_name || row.warehouse_code || ""} + + + {row.outbound_status || "-"} + + + {row.remark || row.memo || ""} + ); }) )} diff --git a/frontend/app/(main)/COMPANY_30/logistics/receiving/page.tsx b/frontend/app/(main)/COMPANY_30/logistics/receiving/page.tsx index 701ae489..77d21e7b 100644 --- a/frontend/app/(main)/COMPANY_30/logistics/receiving/page.tsx +++ b/frontend/app/(main)/COMPANY_30/logistics/receiving/page.tsx @@ -43,7 +43,6 @@ import { X, Save, ChevronRight, - ChevronDown, ChevronLeft, ChevronsLeft, ChevronsRight, @@ -64,6 +63,7 @@ import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSea import { getReceivingList, createReceiving, + updateReceiving, deleteReceiving, generateReceivingNumber, getReceivingWarehouses, @@ -95,41 +95,8 @@ const GRID_COLUMNS = [ { key: "remark", label: "비고" }, ]; -// 마스터 헤더 레이아웃 (입고번호 뒤, 디테일 7컬럼 위에 colSpan으로 맵핑) -const MASTER_BODY_LAYOUT = [ - { key: "inbound_type", label: "입고유형", colSpan: 1 }, - { key: "inbound_date", label: "입고일", colSpan: 1 }, - { key: "reference_number", label: "참조번호", colSpan: 1 }, - { key: "supplier_name", label: "공급처", colSpan: 1 }, - { key: "warehouse_name", label: "창고", colSpan: 1 }, - { key: "inbound_status", label: "입고상태", colSpan: 1 }, - { key: "memo", label: "비고", colSpan: 1 }, -]; - -// 디테일 헤더 컬럼 -const DETAIL_HEADER_COLS = [ - { key: "source_table", label: "출처" }, - { key: "item_number", label: "품목코드" }, - { key: "item_name", label: "품목명" }, - { key: "spec", label: "규격" }, - { key: "inbound_qty", label: "입고수량" }, - { key: "unit_price", label: "단가" }, - { key: "total_amount", label: "금액" }, -]; - -// 마스터 필드 키 목록 (필터 분류용) -const MASTER_KEYS = new Set(["inbound_number", ...MASTER_BODY_LAYOUT.map((c) => c.key)]); - -// 디테일 필드 키 매핑 (GRID_COLUMNS key → DETAIL_HEADER_COLS key) -const DETAIL_KEY_MAP: Record = { - source_type: "source_table", - item_number: "item_number", - item_name: "item_name", - spec: "spec", - inbound_qty: "inbound_qty", - unit_price: "unit_price", - total_amount: "total_amount", -}; +// 총 컬럼 수: 체크박스(1) + GRID_COLUMNS(15) = 16 +const TOTAL_COLS = 16; // 헤더 필터 Popover function HeaderFilterPopover({ @@ -287,30 +254,6 @@ interface SelectedSourceItem { export default function ReceivingPage() { const ts = useTableSettings("c16-receiving", "inbound_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([]); const [loading, setLoading] = useState(false); @@ -319,9 +262,7 @@ export default function ReceivingPage() { // 검색 필터 const [searchFilters, setSearchFilters] = useState([]); - // 마스터-디테일 그룹 테이블 - const [expandedOrders, setExpandedOrders] = useState>(new Set()); - const [closingOrders, setClosingOrders] = useState>(new Set()); + // 헤더 필터 & 정렬 const [headerFilters, setHeaderFilters] = useState>>({}); const [sortState, setSortState] = useState<{ key: string; direction: "asc" | "desc" } | null>(null); @@ -338,6 +279,10 @@ export default function ReceivingPage() { const [selectedItems, setSelectedItems] = useState([]); const [saving, setSaving] = useState(false); + // 수정 모드 + const [editMode, setEditMode] = useState(false); + const [editItemIds, setEditItemIds] = useState([]); + // 소스 데이터 const [sourceKeyword, setSourceKeyword] = useState(""); const [sourceLoading, setSourceLoading] = useState(false); @@ -377,7 +322,7 @@ export default function ReceivingPage() { Promise.all( ["material", "unit"].map(async (col) => { try { - const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_30`); + const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_16`); const items = flatten(res.data?.data || []); map[col] = {}; for (const item of items) map[col][item.code] = item.label; @@ -434,124 +379,56 @@ export default function ReceivingPage() { })(); }, []); - // 필터 + 정렬 적용된 데이터 -> 그룹핑 - const filteredGroups = useMemo(() => { - // 1차: inbound_number 기준 그룹핑 - const allGroups: Record = {}; - for (const row of data) { - const key = row.inbound_number || "_no_inbound"; - if (!allGroups[key]) { - allGroups[key] = { master: row, details: [] }; - } - allGroups[key].details.push(row); - } - - // 마스터 필터 / 디테일 필터 분리 - const masterFilters: Record> = {}; - const detailFilters: Record> = {}; - 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]) => { - let raw = (group.master as any)?.[colKey] ?? ""; - // 입고유형은 코드→라벨 변환된 값으로 비교 - if (colKey === "inbound_type") raw = resolveInboundType(String(raw)); - return values.has(String(raw)); - }) - ); - } - - // 3차: 디테일 필터 적용 (행 단위) - if (Object.keys(detailFilters).length > 0) { - entries = entries - .map(([inboundNo, 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_table") cellVal = SOURCE_TABLE_LABEL[cellVal] || cellVal; - return values.has(cellVal); - }) - ); - return [inboundNo, { ...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]) => { - let av: any = (a.master as any)?.[key] ?? ""; - let bv: any = (b.master as any)?.[key] ?? ""; - if (key === "inbound_type") { av = resolveInboundType(String(av)); bv = resolveInboundType(String(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)); - }); - } else { - entries.forEach(([, group]) => { - group.details.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 Object.fromEntries(entries); - }, [data, headerFilters, sortState]); - - // 마스터 컬럼별 고유값 (마스터 헤더 필터용) - const masterUniqueValues = useMemo(() => { - const result: Record = {}; - const seenMasters = new Map(); - data.forEach((row) => { - if (row.inbound_number && !seenMasters.has(row.inbound_number)) { - seenMasters.set(row.inbound_number, row); - } - }); - const masters = Array.from(seenMasters.values()); - for (const col of [{ key: "inbound_number", label: "입고번호" }, ...MASTER_BODY_LAYOUT.map(({ key, label }) => ({ key, label }))]) { - const values = new Set(); - masters.forEach((m) => { - let val = (m as any)?.[col.key]; - if (val !== null && val !== undefined && val !== "") { - if (col.key === "inbound_type") val = resolveInboundType(String(val)); - values.add(String(val)); - } - }); - result[col.key] = Array.from(values).sort(); - } - return result; + // 플랫 행 생성 (입고유형 코드→라벨 변환, source_table→한글 등) + const flatRows = useMemo(() => { + return data.map((row) => ({ + ...row, + inbound_type: resolveInboundType(row.inbound_type), + source_type: row.source_table ? (SOURCE_TABLE_LABEL[row.source_table] || row.source_table) : (row as any).source_type || "", + })); }, [data]); - // 디테일 컬럼별 고유값 (디테일 서브헤더 필터용) + // 컬럼별 고유값 (헤더 필터용) const columnUniqueValues = useMemo(() => { const result: Record = {}; - for (const col of DETAIL_HEADER_COLS) { + for (const col of GRID_COLUMNS) { const values = new Set(); - data.forEach((row) => { - let val = (row as any)[col.key]; - if (val !== null && val !== undefined && val !== "") { - if (col.key === "source_table") val = SOURCE_TABLE_LABEL[val] || val; - values.add(String(val)); - } + 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; - }, [data]); + }, [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) => { @@ -633,6 +510,8 @@ export default function ReceivingPage() { const openRegisterModal = async () => { const defaultType = "구매입고"; + setEditMode(false); + setEditItemIds([]); setModalInboundType(defaultType); setModalInboundDate(new Date().toISOString().split("T")[0]); setModalWarehouse(""); @@ -661,6 +540,51 @@ export default function ReceivingPage() { } }; + // 수정 모달 열기 + const openEditModal = (row: InboundItem) => { + const inNo = row.inbound_number; + const grouped = data.filter((d) => d.inbound_number === inNo); + const first = grouped[0] || row; + + setEditMode(true); + setEditItemIds(grouped.map((g) => g.id)); + setModalInboundNo(inNo); + setModalInboundType(first.inbound_type || "구매입고"); + setModalInboundDate(first.inbound_date ? String(first.inbound_date).slice(0, 10) : ""); + setModalWarehouse(first.warehouse_code || ""); + setModalLocation(first.location_code || ""); + setModalInspector((first as any).inspector || ""); + setModalManager((first as any).manager || ""); + setModalMemo(first.memo || ""); + setSelectedItems( + grouped.map((g) => ({ + key: g.id, + inbound_type: g.inbound_type || "", + reference_number: g.reference_number || "", + supplier_code: (g as any).supplier_code || "", + supplier_name: g.supplier_name || "", + item_number: g.item_number || "", + item_name: g.item_name || "", + spec: g.spec || "", + material: (g as any).material || "", + unit: (g as any).unit || "", + inbound_qty: Number(g.inbound_qty) || 0, + unit_price: Number(g.unit_price) || 0, + total_amount: Number(g.total_amount) || 0, + source_table: (g as any).source_table || "", + source_id: (g as any).source_id || "", + })) + ); + setSourceKeyword(""); + setPurchaseOrders([]); + setShipments([]); + setItems([]); + setSourcePage(1); + setSourceTotalCount(0); + setIsModalOpen(true); + loadSourceData(first.inbound_type || "구매입고", undefined, 1); + }; + // 검색 버튼 클릭 시 const searchSourceData = useCallback(async () => { setSourcePage(1); @@ -811,41 +735,97 @@ export default function ReceivingPage() { } setSaving(true); try { - const res = await createReceiving({ - inbound_number: modalInboundNo, - inbound_date: modalInboundDate, - warehouse_code: modalWarehouse, - location_code: modalLocation || undefined, - inspector: modalInspector || undefined, - manager: modalManager || undefined, - memo: modalMemo || undefined, - items: selectedItems.map((item) => ({ - inbound_type: item.inbound_type, - reference_number: item.reference_number, - supplier_code: item.supplier_code, - supplier_name: item.supplier_name, - item_number: item.item_number, - item_name: item.item_name, - spec: item.spec, - material: item.material, - unit: item.unit, - inbound_qty: item.inbound_qty, - unit_price: item.unit_price, - total_amount: item.total_amount, - source_table: item.source_table, - source_id: item.source_id, - inbound_status: "입고완료", - inspection_status: "대기", - })), - }); + if (editMode) { + // 기존 item 수정 + 삭제된 item 삭제 + 새 item 추가 + const currentKeys = new Set(selectedItems.map((i) => i.key)); + const toDelete = editItemIds.filter((id) => !currentKeys.has(id)); + const toUpdate = selectedItems.filter((i) => editItemIds.includes(i.key)); + const toCreate = selectedItems.filter((i) => !editItemIds.includes(i.key)); - if (res.success) { - alert(res.message || "입고 등록 완료"); + await Promise.all([ + ...toDelete.map((id) => deleteReceiving(id)), + ...toUpdate.map((item) => + updateReceiving(item.key, { + inbound_date: modalInboundDate, + inbound_qty: item.inbound_qty, + unit_price: item.unit_price, + total_amount: item.total_amount, + warehouse_code: modalWarehouse || undefined, + location_code: modalLocation || undefined, + memo: modalMemo || undefined, + } as any) + ), + ...(toCreate.length > 0 + ? [createReceiving({ + inbound_number: modalInboundNo, + inbound_date: modalInboundDate, + warehouse_code: modalWarehouse, + location_code: modalLocation || undefined, + inspector: modalInspector || undefined, + manager: modalManager || undefined, + memo: modalMemo || undefined, + items: toCreate.map((item) => ({ + inbound_type: item.inbound_type, + reference_number: item.reference_number, + supplier_code: item.supplier_code, + supplier_name: item.supplier_name, + item_number: item.item_number, + item_name: item.item_name, + spec: item.spec, + material: item.material, + unit: item.unit, + inbound_qty: item.inbound_qty, + unit_price: item.unit_price, + total_amount: item.total_amount, + source_table: item.source_table, + source_id: item.source_id, + inbound_status: "입고완료", + inspection_status: "대기", + })), + })] + : []), + ]); + toast.success("입고 정보를 수정했어요"); setIsModalOpen(false); fetchList(); + } else { + const res = await createReceiving({ + inbound_number: modalInboundNo, + inbound_date: modalInboundDate, + warehouse_code: modalWarehouse, + location_code: modalLocation || undefined, + inspector: modalInspector || undefined, + manager: modalManager || undefined, + memo: modalMemo || undefined, + items: selectedItems.map((item) => ({ + inbound_type: item.inbound_type, + reference_number: item.reference_number, + supplier_code: item.supplier_code, + supplier_name: item.supplier_name, + item_number: item.item_number, + item_name: item.item_name, + spec: item.spec, + material: item.material, + unit: item.unit, + inbound_qty: item.inbound_qty, + unit_price: item.unit_price, + total_amount: item.total_amount, + source_table: item.source_table, + source_id: item.source_id, + inbound_status: "입고완료", + inspection_status: "대기", + })), + }); + + if (res.success) { + toast.success(res.message || "입고 등록 완료"); + setIsModalOpen(false); + fetchList(); + } } - } catch { - alert("입고 등록 중 오류가 발생했습니다."); + } catch (err: any) { + const msg = err?.response?.data?.message || (editMode ? "수정에 실패했어요" : "입고 등록 중 오류가 발생했습니다."); + toast.error(msg); } finally { setSaving(false); } @@ -897,13 +877,13 @@ export default function ReceivingPage() { } /> - {/* 입고 목록 테이블 (마스터-디테일 그룹) */} + {/* 입고 목록 테이블 (플랫 리스트) */}

입고 목록

- {Object.keys(filteredGroups).length}건 + {filteredRows.length}건
-
+
- - {visibleMasterLayout.map((col) => ( - - ))} + + + + + + + + + + + + + + { - const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id)); + const allFilteredIds = filteredRows.map((r) => r.id); const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id)); setCheckedIds(allChecked ? [] : allFilteredIds); }} > { - const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id)); + const allFilteredIds = filteredRows.map((r) => r.id); return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id)); })()} onCheckedChange={() => {}} /> - - {/* 입고번호 (별도 컬럼) */} - -
-
handleSort("inbound_number")}> - 입고번호 - {sortState?.key === "inbound_number" && ( - sortState.direction === "asc" - ? - : - )} -
- {(masterUniqueValues["inbound_number"] || []).length > 0 && ( - ()} - onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} - /> - )} -
-
- {/* 마스터 필드 헤더 (ts.visibleColumns 순서) */} - {visibleMasterLayout.map((col) => ( - -
-
handleSort(col.key)}> - {col.label} - {sortState?.key === col.key && ( - sortState.direction === "asc" - ? - : + {GRID_COLUMNS.map((col) => { + const isRight = ["inbound_qty", "unit_price", "total_amount"].includes(col.key); + return ( + +
+
handleSort(col.key)}> + {col.label} + {sortState?.key === col.key && ( + sortState.direction === "asc" + ? + : + )} +
+ {(columnUniqueValues[col.key] || []).length > 0 && ( + ()} + onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} + /> )}
- {(masterUniqueValues[col.key] || []).length > 0 && ( - ()} - onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} - /> - )} -
- - ))} + + ); + })} @@ -993,7 +963,7 @@ export default function ReceivingPage() { - ) : Object.keys(filteredGroups).length === 0 ? ( + ) : filteredRows.length === 0 ? (
@@ -1003,212 +973,59 @@ export default function ReceivingPage() { ) : ( - Object.entries(filteredGroups).map(([inboundNo, group]) => { - const isExpanded = expandedOrders.has(inboundNo); - 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; + filteredRows.map((row) => { + const isChecked = checkedIds.includes(row.id); return ( - - {/* 마스터 행 */} - { - if (expandedOrders.has(inboundNo)) { - setClosingOrders((prev) => new Set(prev).add(inboundNo)); - setTimeout(() => { - setExpandedOrders((prev) => { const next = new Set(prev); next.delete(inboundNo); return next; }); - setClosingOrders((prev) => { const next = new Set(prev); next.delete(inboundNo); return next; }); - }, 200); - } else { - setExpandedOrders((prev) => new Set(prev).add(inboundNo)); - } + { + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] + ); + }} + onDoubleClick={() => openEditModal(row as any)} + > + { + e.stopPropagation(); + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] + ); }} > - { - e.stopPropagation(); - setCheckedIds((prev) => { - if (allDetailChecked) return prev.filter((id) => !detailIds.includes(id)); - return [...new Set([...prev, ...detailIds])]; - }); - }} - > - {}} - /> - - - {isExpanded - ? - : - } - - {/* 입고번호 */} - - {inboundNo} - ({group.details.length}) - - {/* 마스터 필드 (ts.visibleColumns 순서) */} - {visibleMasterLayout.map((col) => { - switch (col.key) { - case "inbound_type": return ( - - - {resolveInboundType(master.inbound_type)} - - - ); - case "inbound_date": return ( - - {master.inbound_date ? new Date(master.inbound_date).toLocaleDateString("ko-KR") : "-"} - - ); - case "reference_number": return ( - - {master.reference_number || ""} - - ); - case "supplier_name": return ( - - {master.supplier_name || ""} - - ); - case "warehouse_name": return ( - - {master.warehouse_name || master.warehouse_code || ""} - - ); - case "inbound_status": return ( - - - {master.inbound_status || "-"} - - - ); - case "memo": return ( - - {master.memo || ""} - - ); - default: return {(master as any)[col.key] ?? ""}; - } - })} - - - {/* 디테일 서브 헤더 (펼쳤을 때만) */} - {isExpanded && ( - - - - - {visibleDetailCols.map((col) => { - const isRight = ["inbound_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_table") v = SOURCE_TABLE_LABEL[v] || v; - return v; - }).filter((v: any) => v != null && v !== "").map(String) - )).sort(); - const filterVals = headerFilters[col.key] || new Set(); - return ( - -
-
handleSort(col.key)} - > - {col.label} - {isSorted && ( - sortState!.direction === "asc" - ? - : - )} -
- {uniqueVals.length > 0 && ( - - )} -
-
- ); - })} -
- )} - - {/* 디테일 행 (펼쳤을 때만) */} - {isExpanded && group.details.map((row, detailIdx) => { - const isClosing = closingOrders.has(inboundNo); - const isChecked = checkedIds.includes(row.id); - return ( - { - setCheckedIds((prev) => - prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] - ); - }} - > - { - e.stopPropagation(); - setCheckedIds((prev) => - prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] - ); - }} - > - {}} /> - - -
- - - {visibleDetailCols.map((col) => { - switch (col.key) { - case "source_table": return {row.source_table ? SOURCE_TABLE_LABEL[row.source_table] || row.source_table : "-"}; - case "item_number": return {row.item_number || ""}; - case "item_name": return {row.item_name || ""}; - case "spec": return {row.spec || ""}; - case "inbound_qty": return {row.inbound_qty ? Number(row.inbound_qty).toLocaleString() : ""}; - case "unit_price": return {row.unit_price ? Number(row.unit_price).toLocaleString() : ""}; - case "total_amount": return {row.total_amount ? Number(row.total_amount).toLocaleString() : ""}; - default: return {(row as any)[col.key] ?? ""}; - } - })} - - ); - })} - + {}} /> + + {row.inbound_number || ""} + + + {row.inbound_type || "-"} + + + + {row.inbound_date ? new Date(row.inbound_date).toLocaleDateString("ko-KR") : ""} + + {row.reference_number || ""} + {row.source_type || ""} + {row.supplier_name || ""} + {row.item_number || ""} + {row.item_name || ""} + {row.spec || ""} + {row.inbound_qty ? Number(row.inbound_qty).toLocaleString() : ""} + {row.unit_price ? Number(row.unit_price).toLocaleString() : ""} + {row.total_amount ? Number(row.total_amount).toLocaleString() : ""} + {row.warehouse_name || (row as any).warehouse_code || ""} + + + {row.inbound_status || "-"} + + + {row.remark || row.memo || ""} + ); }) )} @@ -1221,9 +1038,9 @@ export default function ReceivingPage() { - 입고 등록 + {editMode ? "입고 수정" : "입고 등록"} - 입고유형을 선택하고, 좌측에서 근거 데이터를 검색하여 추가해 주세요. + {editMode ? "입고 정보를 수정해 주세요." : "입고유형을 선택하고, 좌측에서 근거 데이터를 검색하여 추가해 주세요."}
diff --git a/frontend/app/(main)/COMPANY_30/purchase/order/page.tsx b/frontend/app/(main)/COMPANY_30/purchase/order/page.tsx index 143a84a9..90116bac 100644 --- a/frontend/app/(main)/COMPANY_30/purchase/order/page.tsx +++ b/frontend/app/(main)/COMPANY_30/purchase/order/page.tsx @@ -13,7 +13,7 @@ import { Badge } from "@/components/ui/badge"; import { Label } from "@/components/ui/label"; import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, - ClipboardList, Pencil, Search, X, Package, ChevronDown, + ClipboardList, Pencil, Search, X, Package, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Settings2, GripVertical, } from "lucide-react"; @@ -162,7 +162,6 @@ export default function PurchaseOrderPage() { const [excelUploadOpen, setExcelUploadOpen] = useState(false); const [categoryOptions, setCategoryOptions] = useState>({}); const [checkedIds, setCheckedIds] = useState([]); - const [expandedOrders, setExpandedOrders] = useState>(new Set()); // 테이블 설정 const ts = useTableSettings("c16-purchase-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG); @@ -372,20 +371,6 @@ export default function PurchaseOrderPage() { useEffect(() => { fetchOrders(); }, [fetchOrders]); // purchase_no 기준 그룹핑 - const orderGroups = useMemo(() => { - const map: Record = {}; - for (const row of orders) { - const key = row.purchase_no || row.id; - if (!map[key]) { - map[key] = { - master: { purchase_no: row.purchase_no, order_date: row.order_date, supplier_name: row.supplier_name, status: row.status, memo: row.memo }, - details: [], - }; - } - map[key].details.push(row); - } - return map; - }, [orders]); const getCategoryLabel = (col: string, code: string) => { if (!code) return ""; @@ -811,125 +796,83 @@ export default function PurchaseOrderPage() {
- {/* 데이터 테이블 — 아코디언 그룹핑 */} + {/* 데이터 테이블 — 플랫 리스트 */}
{loading ? (
- ) : Object.keys(orderGroups).length === 0 ? ( + ) : orders.length === 0 ? (

등록된 발주가 없어요

) : ( (() => { - const MASTER_KEYS = new Set(["purchase_no", "order_date", "supplier_name", "status", "memo"]); const numCols = new Set(["order_qty", "received_qty", "remain_qty", "unit_price", "amount"]); - - // ts.visibleColumns 순서를 따르되, 마스터/디테일 컬럼을 분리 - // 고정 컬럼(품목수)은 마스터 선행 컬럼 뒤에 배치 - const leadingMaster: typeof ts.visibleColumns = []; - const detailCols: typeof ts.visibleColumns = []; - const trailingMaster: typeof ts.visibleColumns = []; - let passedFirstDetail = false; - for (const col of ts.visibleColumns) { - if (MASTER_KEYS.has(col.key)) { - if (passedFirstDetail) trailingMaster.push(col); - else leadingMaster.push(col); - } else { - passedFirstDetail = true; - detailCols.push(col); - } - } - - const renderDetailCell = (row: any, key: string) => { + const renderCell = (row: any, key: string) => { const val = row[key]; if (key === "status") return val ? {val} : "-"; - if (numCols.has(key)) return {val ? Number(val).toLocaleString() : "0"}; - return val || "-"; + if (key === "order_date" || key === "due_date") return val ? new Date(val).toLocaleDateString("ko-KR") : "-"; + if (numCols.has(key)) return {val ? Number(val).toLocaleString() : ""}; + return val || ""; }; - - const renderMasterHead = (col: { key: string; label: string }) => ( - - {col.label} - - ); - - const renderMasterCell = (col: { key: string }, m: any, purchaseNo: string) => { - if (col.key === "purchase_no") return {purchaseNo}; - if (col.key === "order_date") return {m.order_date ? new Date(m.order_date).toLocaleDateString("ko-KR") : "-"}; - if (col.key === "supplier_name") return {m.supplier_name || "-"}; - if (col.key === "status") return {m.status && {m.status}}; - if (col.key === "memo") return {m.memo || ""}; - return ; - }; - return (
- - - {leadingMaster.map(renderMasterHead)} - 품목수 - {detailCols.map(col => ( - - {col.label}{col.key === "order_qty" || col.key === "amount" ? " 합계" : ""} + { + const allIds = orders.map((r) => r.id); + const allChecked = allIds.length > 0 && allIds.every((id) => checkedIds.includes(id)); + setCheckedIds(allChecked ? [] : allIds); + }} + > + 0 && orders.every((r) => checkedIds.includes(r.id))} + onCheckedChange={() => {}} + /> + + {ts.visibleColumns.map((col) => ( + + {col.label} ))} - {trailingMaster.map(renderMasterHead)} - {Object.entries(orderGroups).map(([purchaseNo, group]) => { - const isExpanded = expandedOrders.has(purchaseNo); - const detailIds = group.details.map(d => d.id); - const allChecked = detailIds.length > 0 && detailIds.every(id => checkedIds.includes(id)); - const someChecked = detailIds.some(id => checkedIds.includes(id)); - const m = group.master; - const totalQty = group.details.reduce((s, d) => s + (Number(d.order_qty) || 0), 0); - const totalAmt = group.details.reduce((s, d) => s + (Number(d.amount) || 0), 0); + {orders.map((row) => { + const isChecked = checkedIds.includes(row.id); return ( - - setExpandedOrders(prev => { const next = new Set(prev); if (next.has(purchaseNo)) next.delete(purchaseNo); else next.add(purchaseNo); return next; })} - onDoubleClick={() => openEditModal(purchaseNo)} + { + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] + ); + }} + onDoubleClick={() => openEditModal(row.purchase_no)} + > + { + e.stopPropagation(); + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] + ); + }} > - - + {}} /> + + {ts.visibleColumns.map((col) => ( + + {renderCell(row, col.key)} - { e.stopPropagation(); setCheckedIds(prev => allChecked ? prev.filter(id => !detailIds.includes(id)) : [...new Set([...prev, ...detailIds])]); }}> - {}} /> - - {leadingMaster.map(col => renderMasterCell(col, m, purchaseNo))} - {group.details.length}건 - {detailCols.map(col => ( - - {col.key === "order_qty" ? totalQty.toLocaleString() - : col.key === "amount" ? totalAmt.toLocaleString() - : ""} - - ))} - {trailingMaster.map(col => renderMasterCell(col, m, purchaseNo))} - - {isExpanded && group.details.map((row) => ( - - - { e.stopPropagation(); setCheckedIds(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id]); }}> - {}} /> - - {leadingMaster.map(col => )} - - {detailCols.map(col => ( - - {renderDetailCell(row, col.key)} - - ))} - {trailingMaster.map(col => )} - ))} - + ); })} @@ -938,7 +881,7 @@ export default function PurchaseOrderPage() { })() )}
- 전체 {Object.keys(orderGroups).length}건 (발주 기준) / {orders.length}건 (품목 기준) + 전체 {orders.length}건
diff --git a/frontend/app/(main)/COMPANY_30/purchase/purchase-item/page.tsx b/frontend/app/(main)/COMPANY_30/purchase/purchase-item/page.tsx index ce6e8198..31ca38f4 100644 --- a/frontend/app/(main)/COMPANY_30/purchase/purchase-item/page.tsx +++ b/frontend/app/(main)/COMPANY_30/purchase/purchase-item/page.tsx @@ -142,6 +142,10 @@ const FORM_FIELDS = [ { key: "division", label: "관리품목", type: "multi-category" }, { key: "type", label: "품목구분", type: "category" }, { key: "size", label: "규격", type: "text" }, + { key: "width", label: "가로", type: "text", placeholder: "숫자 입력 (mm)" }, + { key: "height", label: "세로", type: "text", placeholder: "숫자 입력 (mm)" }, + { key: "thickness", label: "두께", type: "text", placeholder: "숫자 입력 (mm)" }, + { key: "area", label: "면적", type: "text", placeholder: "숫자 입력 (㎡)" }, { key: "unit", label: "단위", type: "category" }, { key: "material", label: "재질", type: "category" }, { key: "status", label: "상태", type: "category" }, @@ -175,6 +179,10 @@ const ITEM_GRID_COLUMNS = [ { key: "item_number", label: "품번" }, { key: "item_name", label: "품명" }, { key: "size", label: "규격" }, + { key: "width", label: "가로" }, + { key: "height", label: "세로" }, + { key: "thickness", label: "두께" }, + { key: "area", label: "면적" }, { key: "unit", label: "단위" }, { key: "standard_price", label: "기준단가/구매단가" }, { key: "currency_code", label: "통화" }, @@ -1605,9 +1613,25 @@ export default function PurchaseItemPage() { ) : ( setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))} - placeholder={"placeholder" in field ? field.placeholder : field.label} - className="h-9" + readOnly={field.key === "area"} + onChange={(e) => { + if (field.key === "area") return; + const v = e.target.value; + setFormData((prev) => { + const next = { ...prev, [field.key]: v }; + // 가로/세로 변경 시 면적(㎡) 자동 계산: (가로mm × 세로mm) / 1,000,000 + if (field.key === "width" || field.key === "height") { + const w = Number(field.key === "width" ? v : prev.width); + const h = Number(field.key === "height" ? v : prev.height); + if (!isNaN(w) && !isNaN(h) && w > 0 && h > 0) { + next.area = ((w * h) / 1_000_000).toFixed(4); + } + } + return next; + }); + }} + placeholder={field.key === "area" ? "자동 계산" : ("placeholder" in field ? field.placeholder : field.label)} + className={cn("h-9", field.key === "area" && "bg-muted cursor-not-allowed")} /> )} diff --git a/frontend/app/(main)/COMPANY_7/logistics/outbound/page.tsx b/frontend/app/(main)/COMPANY_7/logistics/outbound/page.tsx index 824e2e86..457e2723 100644 --- a/frontend/app/(main)/COMPANY_7/logistics/outbound/page.tsx +++ b/frontend/app/(main)/COMPANY_7/logistics/outbound/page.tsx @@ -38,7 +38,6 @@ import { Save, ChevronRight, ChevronLeft, - ChevronDown, ChevronsLeft, ChevronsRight, Inbox, @@ -116,41 +115,8 @@ const GRID_COLUMNS = [ { 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 = { - 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", -}; +// 총 컬럼 수: 체크박스(1) + GRID_COLUMNS(15) = 16 +const TOTAL_COLS = 16; // 헤더 필터 Popover function HeaderFilterPopover({ @@ -258,30 +224,6 @@ interface SelectedSourceItem { 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([]); const [loading, setLoading] = useState(false); @@ -290,9 +232,7 @@ export default function OutboundPage() { // 검색 필터 const [searchFilters, setSearchFilters] = useState([]); - // 마스터-디테일 그룹 테이블 state - const [expandedOrders, setExpandedOrders] = useState>(new Set()); - const [closingOrders, setClosingOrders] = useState>(new Set()); + // 헤더 필터 & 정렬 const [headerFilters, setHeaderFilters] = useState>>({}); const [sortState, setSortState] = useState<{ key: string; direction: "asc" | "desc" } | null>(null); @@ -403,121 +343,59 @@ export default function OutboundPage() { })(); }, []); - // --- 마스터-디테일 그룹핑, 필터, 정렬 --- + // 플랫 행 생성 (출고유형 코드→라벨 변환, 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]); - // outbound_number 기준 그룹핑 + 필터 + 정렬 - const filteredGroups = useMemo(() => { - // 1차: outbound_number 기준 그룹핑 - const allGroups: Record = {}; - 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> = {}; - const detailFilters: Record> = {}; - 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 columnUniqueValues = useMemo(() => { const result: Record = {}; - const seenMasters = new Map(); - 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 }))]) { + for (const col of GRID_COLUMNS) { const values = new Set(); - masters.forEach((m) => { - const val = (m as any)?.[col.key]; + 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; - }, [data]); + }, [flatRows]); - // 디테일 컬럼별 고유값 (디테일 서브헤더 필터용) - const columnUniqueValues = useMemo(() => { - const result: Record = {}; - for (const col of DETAIL_HEADER_COLS) { - const values = new Set(); - 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)); - } + // 필터 + 정렬 적용된 플랫 데이터 + 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); }); - result[col.key] = Array.from(values).sort(); } - return result; - }, [data]); + + // 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) => { @@ -938,7 +816,7 @@ export default function OutboundPage() { dataCount={data.length} /> - {/* 출고 목록 테이블 */} + {/* 출고 목록 테이블 (플랫 리스트) */}
{/* 패널 헤더 */}
@@ -946,7 +824,7 @@ export default function OutboundPage() { 출고 목록 - {data.length}건 + {filteredRows.length}건
@@ -971,78 +849,68 @@ export default function OutboundPage() {
-
+
- - {visibleMasterLayout.map((col) => ( - - ))} + + + + + + + + + + + + + + { - const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id)); + const allFilteredIds = filteredRows.map((r) => r.id); const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id)); setCheckedIds(allChecked ? [] : allFilteredIds); }} > { - const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id)); + const allFilteredIds = filteredRows.map((r) => r.id); return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id)); })()} onCheckedChange={() => {}} /> - - {/* 출고번호 */} - -
-
handleSort("outbound_number")}> - 출고번호 - {sortState?.key === "outbound_number" && ( - sortState.direction === "asc" - ? - : - )} -
- {(masterUniqueValues["outbound_number"] || []).length > 0 && ( - ()} - onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} - /> - )} -
-
- {/* 마스터 필드 헤더 (ts.visibleColumns 순서) */} - {visibleMasterLayout.map((col) => ( - -
-
handleSort(col.key)}> - {col.label} - {sortState?.key === col.key && ( - sortState.direction === "asc" - ? - : + {GRID_COLUMNS.map((col) => { + const isRight = ["outbound_qty", "unit_price", "total_amount"].includes(col.key); + return ( + +
+
handleSort(col.key)}> + {col.label} + {sortState?.key === col.key && ( + sortState.direction === "asc" + ? + : + )} +
+ {(columnUniqueValues[col.key] || []).length > 0 && ( + ()} + onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} + /> )}
- {(masterUniqueValues[col.key] || []).length > 0 && ( - ()} - onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} - /> - )} -
- - ))} + + ); + })} @@ -1052,7 +920,7 @@ export default function OutboundPage() { - ) : Object.keys(filteredGroups).length === 0 ? ( + ) : filteredRows.length === 0 ? (
@@ -1062,214 +930,59 @@ export default function OutboundPage() { ) : ( - 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; + filteredRows.map((row) => { + const isChecked = checkedIds.includes(row.id); return ( - - {/* 마스터 행 */} - { - 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])} - > - { - e.stopPropagation(); - setCheckedIds((prev) => { - if (allDetailChecked) return prev.filter((id) => !detailIds.includes(id)); - return [...new Set([...prev, ...detailIds])]; - }); - }} - > - {}} - /> - - - {isExpanded - ? - : - } - - {/* 출고번호 */} - - {outboundNo} - ({group.details.length}) - - {/* 마스터 필드 (ts.visibleColumns 순서) */} - {visibleMasterLayout.map((col) => { - switch (col.key) { - case "outbound_type": return ( - - - {resolveCat("outbound_type", master.outbound_type) || "-"} - - - ); - case "outbound_date": return ( - - {master.outbound_date ? new Date(master.outbound_date).toLocaleDateString("ko-KR") : "-"} - - ); - case "reference_number": return ( - - {master.reference_number || ""} - - ); - case "customer_name": return ( - - {master.customer_name || ""} - - ); - case "warehouse_name": return ( - - {master.warehouse_name || master.warehouse_code || ""} - - ); - case "outbound_status": return ( - - - {master.outbound_status || "-"} - - - ); - case "memo": return ( - - {master.memo || ""} - - ); - default: return {(master as any)[col.key] ?? ""}; - } - })} - - - {/* 디테일 서브 헤더 (펼쳤을 때만) */} - {isExpanded && ( - - - - - {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(); - return ( - -
-
handleSort(col.key)} - > - {col.label} - {isSorted && ( - sortState!.direction === "asc" - ? - : - )} -
- {uniqueVals.length > 0 && ( - - )} -
-
- ); - })} -
+ { - const isClosing = closingOrders.has(outboundNo); - const isChecked = checkedIds.includes(row.id); - return ( - { - setCheckedIds((prev) => - prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] - ); - }} - onDoubleClick={() => openEditModal(row)} - > - { - e.stopPropagation(); - setCheckedIds((prev) => - prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] - ); - }} - > - {}} /> - - -
- - - {visibleDetailCols.map((col) => { - switch (col.key) { - case "source_type": return {row.source_type ? (SOURCE_TYPE_LABEL[row.source_type] || row.source_type) : "-"}; - case "item_code": return {row.item_code || ""}; - case "item_name": return {row.item_name || ""}; - case "specification": return {row.specification || ""}; - case "outbound_qty": return {row.outbound_qty ? Number(row.outbound_qty).toLocaleString() : ""}; - case "unit_price": return {row.unit_price ? Number(row.unit_price).toLocaleString() : ""}; - case "total_amount": return {row.total_amount ? Number(row.total_amount).toLocaleString() : ""}; - default: return {(row as any)[col.key] ?? ""}; - } - })} - + onClick={() => { + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] ); - })} - + }} + onDoubleClick={() => openEditModal(row as any)} + > + { + e.stopPropagation(); + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] + ); + }} + > + {}} /> + + {row.outbound_number || ""} + + + {row.outbound_type || "-"} + + + + {row.outbound_date ? new Date(row.outbound_date).toLocaleDateString("ko-KR") : ""} + + {row.reference_number || ""} + {row.source_type || ""} + {row.customer_name || ""} + {row.item_number || ""} + {row.item_name || ""} + {row.spec || ""} + {row.outbound_qty ? Number(row.outbound_qty).toLocaleString() : ""} + {row.unit_price ? Number(row.unit_price).toLocaleString() : ""} + {row.total_amount ? Number(row.total_amount).toLocaleString() : ""} + {row.warehouse_name || row.warehouse_code || ""} + + + {row.outbound_status || "-"} + + + {row.remark || row.memo || ""} + ); }) )} diff --git a/frontend/app/(main)/COMPANY_7/logistics/receiving/page.tsx b/frontend/app/(main)/COMPANY_7/logistics/receiving/page.tsx index d3d1e955..77d21e7b 100644 --- a/frontend/app/(main)/COMPANY_7/logistics/receiving/page.tsx +++ b/frontend/app/(main)/COMPANY_7/logistics/receiving/page.tsx @@ -43,7 +43,6 @@ import { X, Save, ChevronRight, - ChevronDown, ChevronLeft, ChevronsLeft, ChevronsRight, @@ -64,6 +63,7 @@ import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSea import { getReceivingList, createReceiving, + updateReceiving, deleteReceiving, generateReceivingNumber, getReceivingWarehouses, @@ -95,41 +95,8 @@ const GRID_COLUMNS = [ { key: "remark", label: "비고" }, ]; -// 마스터 헤더 레이아웃 (입고번호 뒤, 디테일 7컬럼 위에 colSpan으로 맵핑) -const MASTER_BODY_LAYOUT = [ - { key: "inbound_type", label: "입고유형", colSpan: 1 }, - { key: "inbound_date", label: "입고일", colSpan: 1 }, - { key: "reference_number", label: "참조번호", colSpan: 1 }, - { key: "supplier_name", label: "공급처", colSpan: 1 }, - { key: "warehouse_name", label: "창고", colSpan: 1 }, - { key: "inbound_status", label: "입고상태", colSpan: 1 }, - { key: "memo", label: "비고", colSpan: 1 }, -]; - -// 디테일 헤더 컬럼 -const DETAIL_HEADER_COLS = [ - { key: "source_table", label: "출처" }, - { key: "item_number", label: "품목코드" }, - { key: "item_name", label: "품목명" }, - { key: "spec", label: "규격" }, - { key: "inbound_qty", label: "입고수량" }, - { key: "unit_price", label: "단가" }, - { key: "total_amount", label: "금액" }, -]; - -// 마스터 필드 키 목록 (필터 분류용) -const MASTER_KEYS = new Set(["inbound_number", ...MASTER_BODY_LAYOUT.map((c) => c.key)]); - -// 디테일 필드 키 매핑 (GRID_COLUMNS key → DETAIL_HEADER_COLS key) -const DETAIL_KEY_MAP: Record = { - source_type: "source_table", - item_number: "item_number", - item_name: "item_name", - spec: "spec", - inbound_qty: "inbound_qty", - unit_price: "unit_price", - total_amount: "total_amount", -}; +// 총 컬럼 수: 체크박스(1) + GRID_COLUMNS(15) = 16 +const TOTAL_COLS = 16; // 헤더 필터 Popover function HeaderFilterPopover({ @@ -287,30 +254,6 @@ interface SelectedSourceItem { export default function ReceivingPage() { const ts = useTableSettings("c16-receiving", "inbound_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([]); const [loading, setLoading] = useState(false); @@ -319,9 +262,7 @@ export default function ReceivingPage() { // 검색 필터 const [searchFilters, setSearchFilters] = useState([]); - // 마스터-디테일 그룹 테이블 - const [expandedOrders, setExpandedOrders] = useState>(new Set()); - const [closingOrders, setClosingOrders] = useState>(new Set()); + // 헤더 필터 & 정렬 const [headerFilters, setHeaderFilters] = useState>>({}); const [sortState, setSortState] = useState<{ key: string; direction: "asc" | "desc" } | null>(null); @@ -338,6 +279,10 @@ export default function ReceivingPage() { const [selectedItems, setSelectedItems] = useState([]); const [saving, setSaving] = useState(false); + // 수정 모드 + const [editMode, setEditMode] = useState(false); + const [editItemIds, setEditItemIds] = useState([]); + // 소스 데이터 const [sourceKeyword, setSourceKeyword] = useState(""); const [sourceLoading, setSourceLoading] = useState(false); @@ -377,7 +322,7 @@ export default function ReceivingPage() { Promise.all( ["material", "unit"].map(async (col) => { try { - const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_7`); + const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_16`); const items = flatten(res.data?.data || []); map[col] = {}; for (const item of items) map[col][item.code] = item.label; @@ -434,124 +379,56 @@ export default function ReceivingPage() { })(); }, []); - // 필터 + 정렬 적용된 데이터 -> 그룹핑 - const filteredGroups = useMemo(() => { - // 1차: inbound_number 기준 그룹핑 - const allGroups: Record = {}; - for (const row of data) { - const key = row.inbound_number || "_no_inbound"; - if (!allGroups[key]) { - allGroups[key] = { master: row, details: [] }; - } - allGroups[key].details.push(row); - } - - // 마스터 필터 / 디테일 필터 분리 - const masterFilters: Record> = {}; - const detailFilters: Record> = {}; - 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]) => { - let raw = (group.master as any)?.[colKey] ?? ""; - // 입고유형은 코드→라벨 변환된 값으로 비교 - if (colKey === "inbound_type") raw = resolveInboundType(String(raw)); - return values.has(String(raw)); - }) - ); - } - - // 3차: 디테일 필터 적용 (행 단위) - if (Object.keys(detailFilters).length > 0) { - entries = entries - .map(([inboundNo, 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_table") cellVal = SOURCE_TABLE_LABEL[cellVal] || cellVal; - return values.has(cellVal); - }) - ); - return [inboundNo, { ...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]) => { - let av: any = (a.master as any)?.[key] ?? ""; - let bv: any = (b.master as any)?.[key] ?? ""; - if (key === "inbound_type") { av = resolveInboundType(String(av)); bv = resolveInboundType(String(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)); - }); - } else { - entries.forEach(([, group]) => { - group.details.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 Object.fromEntries(entries); - }, [data, headerFilters, sortState]); - - // 마스터 컬럼별 고유값 (마스터 헤더 필터용) - const masterUniqueValues = useMemo(() => { - const result: Record = {}; - const seenMasters = new Map(); - data.forEach((row) => { - if (row.inbound_number && !seenMasters.has(row.inbound_number)) { - seenMasters.set(row.inbound_number, row); - } - }); - const masters = Array.from(seenMasters.values()); - for (const col of [{ key: "inbound_number", label: "입고번호" }, ...MASTER_BODY_LAYOUT.map(({ key, label }) => ({ key, label }))]) { - const values = new Set(); - masters.forEach((m) => { - let val = (m as any)?.[col.key]; - if (val !== null && val !== undefined && val !== "") { - if (col.key === "inbound_type") val = resolveInboundType(String(val)); - values.add(String(val)); - } - }); - result[col.key] = Array.from(values).sort(); - } - return result; + // 플랫 행 생성 (입고유형 코드→라벨 변환, source_table→한글 등) + const flatRows = useMemo(() => { + return data.map((row) => ({ + ...row, + inbound_type: resolveInboundType(row.inbound_type), + source_type: row.source_table ? (SOURCE_TABLE_LABEL[row.source_table] || row.source_table) : (row as any).source_type || "", + })); }, [data]); - // 디테일 컬럼별 고유값 (디테일 서브헤더 필터용) + // 컬럼별 고유값 (헤더 필터용) const columnUniqueValues = useMemo(() => { const result: Record = {}; - for (const col of DETAIL_HEADER_COLS) { + for (const col of GRID_COLUMNS) { const values = new Set(); - data.forEach((row) => { - let val = (row as any)[col.key]; - if (val !== null && val !== undefined && val !== "") { - if (col.key === "source_table") val = SOURCE_TABLE_LABEL[val] || val; - values.add(String(val)); - } + 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; - }, [data]); + }, [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) => { @@ -633,6 +510,8 @@ export default function ReceivingPage() { const openRegisterModal = async () => { const defaultType = "구매입고"; + setEditMode(false); + setEditItemIds([]); setModalInboundType(defaultType); setModalInboundDate(new Date().toISOString().split("T")[0]); setModalWarehouse(""); @@ -661,6 +540,51 @@ export default function ReceivingPage() { } }; + // 수정 모달 열기 + const openEditModal = (row: InboundItem) => { + const inNo = row.inbound_number; + const grouped = data.filter((d) => d.inbound_number === inNo); + const first = grouped[0] || row; + + setEditMode(true); + setEditItemIds(grouped.map((g) => g.id)); + setModalInboundNo(inNo); + setModalInboundType(first.inbound_type || "구매입고"); + setModalInboundDate(first.inbound_date ? String(first.inbound_date).slice(0, 10) : ""); + setModalWarehouse(first.warehouse_code || ""); + setModalLocation(first.location_code || ""); + setModalInspector((first as any).inspector || ""); + setModalManager((first as any).manager || ""); + setModalMemo(first.memo || ""); + setSelectedItems( + grouped.map((g) => ({ + key: g.id, + inbound_type: g.inbound_type || "", + reference_number: g.reference_number || "", + supplier_code: (g as any).supplier_code || "", + supplier_name: g.supplier_name || "", + item_number: g.item_number || "", + item_name: g.item_name || "", + spec: g.spec || "", + material: (g as any).material || "", + unit: (g as any).unit || "", + inbound_qty: Number(g.inbound_qty) || 0, + unit_price: Number(g.unit_price) || 0, + total_amount: Number(g.total_amount) || 0, + source_table: (g as any).source_table || "", + source_id: (g as any).source_id || "", + })) + ); + setSourceKeyword(""); + setPurchaseOrders([]); + setShipments([]); + setItems([]); + setSourcePage(1); + setSourceTotalCount(0); + setIsModalOpen(true); + loadSourceData(first.inbound_type || "구매입고", undefined, 1); + }; + // 검색 버튼 클릭 시 const searchSourceData = useCallback(async () => { setSourcePage(1); @@ -811,41 +735,97 @@ export default function ReceivingPage() { } setSaving(true); try { - const res = await createReceiving({ - inbound_number: modalInboundNo, - inbound_date: modalInboundDate, - warehouse_code: modalWarehouse, - location_code: modalLocation || undefined, - inspector: modalInspector || undefined, - manager: modalManager || undefined, - memo: modalMemo || undefined, - items: selectedItems.map((item) => ({ - inbound_type: item.inbound_type, - reference_number: item.reference_number, - supplier_code: item.supplier_code, - supplier_name: item.supplier_name, - item_number: item.item_number, - item_name: item.item_name, - spec: item.spec, - material: item.material, - unit: item.unit, - inbound_qty: item.inbound_qty, - unit_price: item.unit_price, - total_amount: item.total_amount, - source_table: item.source_table, - source_id: item.source_id, - inbound_status: "입고완료", - inspection_status: "대기", - })), - }); + if (editMode) { + // 기존 item 수정 + 삭제된 item 삭제 + 새 item 추가 + const currentKeys = new Set(selectedItems.map((i) => i.key)); + const toDelete = editItemIds.filter((id) => !currentKeys.has(id)); + const toUpdate = selectedItems.filter((i) => editItemIds.includes(i.key)); + const toCreate = selectedItems.filter((i) => !editItemIds.includes(i.key)); - if (res.success) { - alert(res.message || "입고 등록 완료"); + await Promise.all([ + ...toDelete.map((id) => deleteReceiving(id)), + ...toUpdate.map((item) => + updateReceiving(item.key, { + inbound_date: modalInboundDate, + inbound_qty: item.inbound_qty, + unit_price: item.unit_price, + total_amount: item.total_amount, + warehouse_code: modalWarehouse || undefined, + location_code: modalLocation || undefined, + memo: modalMemo || undefined, + } as any) + ), + ...(toCreate.length > 0 + ? [createReceiving({ + inbound_number: modalInboundNo, + inbound_date: modalInboundDate, + warehouse_code: modalWarehouse, + location_code: modalLocation || undefined, + inspector: modalInspector || undefined, + manager: modalManager || undefined, + memo: modalMemo || undefined, + items: toCreate.map((item) => ({ + inbound_type: item.inbound_type, + reference_number: item.reference_number, + supplier_code: item.supplier_code, + supplier_name: item.supplier_name, + item_number: item.item_number, + item_name: item.item_name, + spec: item.spec, + material: item.material, + unit: item.unit, + inbound_qty: item.inbound_qty, + unit_price: item.unit_price, + total_amount: item.total_amount, + source_table: item.source_table, + source_id: item.source_id, + inbound_status: "입고완료", + inspection_status: "대기", + })), + })] + : []), + ]); + toast.success("입고 정보를 수정했어요"); setIsModalOpen(false); fetchList(); + } else { + const res = await createReceiving({ + inbound_number: modalInboundNo, + inbound_date: modalInboundDate, + warehouse_code: modalWarehouse, + location_code: modalLocation || undefined, + inspector: modalInspector || undefined, + manager: modalManager || undefined, + memo: modalMemo || undefined, + items: selectedItems.map((item) => ({ + inbound_type: item.inbound_type, + reference_number: item.reference_number, + supplier_code: item.supplier_code, + supplier_name: item.supplier_name, + item_number: item.item_number, + item_name: item.item_name, + spec: item.spec, + material: item.material, + unit: item.unit, + inbound_qty: item.inbound_qty, + unit_price: item.unit_price, + total_amount: item.total_amount, + source_table: item.source_table, + source_id: item.source_id, + inbound_status: "입고완료", + inspection_status: "대기", + })), + }); + + if (res.success) { + toast.success(res.message || "입고 등록 완료"); + setIsModalOpen(false); + fetchList(); + } } - } catch { - alert("입고 등록 중 오류가 발생했습니다."); + } catch (err: any) { + const msg = err?.response?.data?.message || (editMode ? "수정에 실패했어요" : "입고 등록 중 오류가 발생했습니다."); + toast.error(msg); } finally { setSaving(false); } @@ -897,13 +877,13 @@ export default function ReceivingPage() { } /> - {/* 입고 목록 테이블 (마스터-디테일 그룹) */} + {/* 입고 목록 테이블 (플랫 리스트) */}

입고 목록

- {Object.keys(filteredGroups).length}건 + {filteredRows.length}건
-
+
- - {visibleMasterLayout.map((col) => ( - - ))} + + + + + + + + + + + + + + { - const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id)); + const allFilteredIds = filteredRows.map((r) => r.id); const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id)); setCheckedIds(allChecked ? [] : allFilteredIds); }} > { - const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id)); + const allFilteredIds = filteredRows.map((r) => r.id); return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id)); })()} onCheckedChange={() => {}} /> - - {/* 입고번호 (별도 컬럼) */} - -
-
handleSort("inbound_number")}> - 입고번호 - {sortState?.key === "inbound_number" && ( - sortState.direction === "asc" - ? - : - )} -
- {(masterUniqueValues["inbound_number"] || []).length > 0 && ( - ()} - onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} - /> - )} -
-
- {/* 마스터 필드 헤더 (ts.visibleColumns 순서) */} - {visibleMasterLayout.map((col) => ( - -
-
handleSort(col.key)}> - {col.label} - {sortState?.key === col.key && ( - sortState.direction === "asc" - ? - : + {GRID_COLUMNS.map((col) => { + const isRight = ["inbound_qty", "unit_price", "total_amount"].includes(col.key); + return ( + +
+
handleSort(col.key)}> + {col.label} + {sortState?.key === col.key && ( + sortState.direction === "asc" + ? + : + )} +
+ {(columnUniqueValues[col.key] || []).length > 0 && ( + ()} + onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} + /> )}
- {(masterUniqueValues[col.key] || []).length > 0 && ( - ()} - onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} - /> - )} -
- - ))} + + ); + })} @@ -993,7 +963,7 @@ export default function ReceivingPage() { - ) : Object.keys(filteredGroups).length === 0 ? ( + ) : filteredRows.length === 0 ? (
@@ -1003,212 +973,59 @@ export default function ReceivingPage() { ) : ( - Object.entries(filteredGroups).map(([inboundNo, group]) => { - const isExpanded = expandedOrders.has(inboundNo); - 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; + filteredRows.map((row) => { + const isChecked = checkedIds.includes(row.id); return ( - - {/* 마스터 행 */} - { - if (expandedOrders.has(inboundNo)) { - setClosingOrders((prev) => new Set(prev).add(inboundNo)); - setTimeout(() => { - setExpandedOrders((prev) => { const next = new Set(prev); next.delete(inboundNo); return next; }); - setClosingOrders((prev) => { const next = new Set(prev); next.delete(inboundNo); return next; }); - }, 200); - } else { - setExpandedOrders((prev) => new Set(prev).add(inboundNo)); - } + { + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] + ); + }} + onDoubleClick={() => openEditModal(row as any)} + > + { + e.stopPropagation(); + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] + ); }} > - { - e.stopPropagation(); - setCheckedIds((prev) => { - if (allDetailChecked) return prev.filter((id) => !detailIds.includes(id)); - return [...new Set([...prev, ...detailIds])]; - }); - }} - > - {}} - /> - - - {isExpanded - ? - : - } - - {/* 입고번호 */} - - {inboundNo} - ({group.details.length}) - - {/* 마스터 필드 (ts.visibleColumns 순서) */} - {visibleMasterLayout.map((col) => { - switch (col.key) { - case "inbound_type": return ( - - - {resolveInboundType(master.inbound_type)} - - - ); - case "inbound_date": return ( - - {master.inbound_date ? new Date(master.inbound_date).toLocaleDateString("ko-KR") : "-"} - - ); - case "reference_number": return ( - - {master.reference_number || ""} - - ); - case "supplier_name": return ( - - {master.supplier_name || ""} - - ); - case "warehouse_name": return ( - - {master.warehouse_name || master.warehouse_code || ""} - - ); - case "inbound_status": return ( - - - {master.inbound_status || "-"} - - - ); - case "memo": return ( - - {master.memo || ""} - - ); - default: return {(master as any)[col.key] ?? ""}; - } - })} - - - {/* 디테일 서브 헤더 (펼쳤을 때만) */} - {isExpanded && ( - - - - - {visibleDetailCols.map((col) => { - const isRight = ["inbound_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_table") v = SOURCE_TABLE_LABEL[v] || v; - return v; - }).filter((v: any) => v != null && v !== "").map(String) - )).sort(); - const filterVals = headerFilters[col.key] || new Set(); - return ( - -
-
handleSort(col.key)} - > - {col.label} - {isSorted && ( - sortState!.direction === "asc" - ? - : - )} -
- {uniqueVals.length > 0 && ( - - )} -
-
- ); - })} -
- )} - - {/* 디테일 행 (펼쳤을 때만) */} - {isExpanded && group.details.map((row, detailIdx) => { - const isClosing = closingOrders.has(inboundNo); - const isChecked = checkedIds.includes(row.id); - return ( - { - setCheckedIds((prev) => - prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] - ); - }} - > - { - e.stopPropagation(); - setCheckedIds((prev) => - prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] - ); - }} - > - {}} /> - - -
- - - {visibleDetailCols.map((col) => { - switch (col.key) { - case "source_table": return {row.source_table ? SOURCE_TABLE_LABEL[row.source_table] || row.source_table : "-"}; - case "item_number": return {row.item_number || ""}; - case "item_name": return {row.item_name || ""}; - case "spec": return {row.spec || ""}; - case "inbound_qty": return {row.inbound_qty ? Number(row.inbound_qty).toLocaleString() : ""}; - case "unit_price": return {row.unit_price ? Number(row.unit_price).toLocaleString() : ""}; - case "total_amount": return {row.total_amount ? Number(row.total_amount).toLocaleString() : ""}; - default: return {(row as any)[col.key] ?? ""}; - } - })} - - ); - })} - + {}} /> + + {row.inbound_number || ""} + + + {row.inbound_type || "-"} + + + + {row.inbound_date ? new Date(row.inbound_date).toLocaleDateString("ko-KR") : ""} + + {row.reference_number || ""} + {row.source_type || ""} + {row.supplier_name || ""} + {row.item_number || ""} + {row.item_name || ""} + {row.spec || ""} + {row.inbound_qty ? Number(row.inbound_qty).toLocaleString() : ""} + {row.unit_price ? Number(row.unit_price).toLocaleString() : ""} + {row.total_amount ? Number(row.total_amount).toLocaleString() : ""} + {row.warehouse_name || (row as any).warehouse_code || ""} + + + {row.inbound_status || "-"} + + + {row.remark || row.memo || ""} + ); }) )} @@ -1221,9 +1038,9 @@ export default function ReceivingPage() { - 입고 등록 + {editMode ? "입고 수정" : "입고 등록"} - 입고유형을 선택하고, 좌측에서 근거 데이터를 검색하여 추가해 주세요. + {editMode ? "입고 정보를 수정해 주세요." : "입고유형을 선택하고, 좌측에서 근거 데이터를 검색하여 추가해 주세요."}
diff --git a/frontend/app/(main)/COMPANY_7/purchase/order/page.tsx b/frontend/app/(main)/COMPANY_7/purchase/order/page.tsx index 143a84a9..90116bac 100644 --- a/frontend/app/(main)/COMPANY_7/purchase/order/page.tsx +++ b/frontend/app/(main)/COMPANY_7/purchase/order/page.tsx @@ -13,7 +13,7 @@ import { Badge } from "@/components/ui/badge"; import { Label } from "@/components/ui/label"; import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, - ClipboardList, Pencil, Search, X, Package, ChevronDown, + ClipboardList, Pencil, Search, X, Package, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Settings2, GripVertical, } from "lucide-react"; @@ -162,7 +162,6 @@ export default function PurchaseOrderPage() { const [excelUploadOpen, setExcelUploadOpen] = useState(false); const [categoryOptions, setCategoryOptions] = useState>({}); const [checkedIds, setCheckedIds] = useState([]); - const [expandedOrders, setExpandedOrders] = useState>(new Set()); // 테이블 설정 const ts = useTableSettings("c16-purchase-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG); @@ -372,20 +371,6 @@ export default function PurchaseOrderPage() { useEffect(() => { fetchOrders(); }, [fetchOrders]); // purchase_no 기준 그룹핑 - const orderGroups = useMemo(() => { - const map: Record = {}; - for (const row of orders) { - const key = row.purchase_no || row.id; - if (!map[key]) { - map[key] = { - master: { purchase_no: row.purchase_no, order_date: row.order_date, supplier_name: row.supplier_name, status: row.status, memo: row.memo }, - details: [], - }; - } - map[key].details.push(row); - } - return map; - }, [orders]); const getCategoryLabel = (col: string, code: string) => { if (!code) return ""; @@ -811,125 +796,83 @@ export default function PurchaseOrderPage() {
- {/* 데이터 테이블 — 아코디언 그룹핑 */} + {/* 데이터 테이블 — 플랫 리스트 */}
{loading ? (
- ) : Object.keys(orderGroups).length === 0 ? ( + ) : orders.length === 0 ? (

등록된 발주가 없어요

) : ( (() => { - const MASTER_KEYS = new Set(["purchase_no", "order_date", "supplier_name", "status", "memo"]); const numCols = new Set(["order_qty", "received_qty", "remain_qty", "unit_price", "amount"]); - - // ts.visibleColumns 순서를 따르되, 마스터/디테일 컬럼을 분리 - // 고정 컬럼(품목수)은 마스터 선행 컬럼 뒤에 배치 - const leadingMaster: typeof ts.visibleColumns = []; - const detailCols: typeof ts.visibleColumns = []; - const trailingMaster: typeof ts.visibleColumns = []; - let passedFirstDetail = false; - for (const col of ts.visibleColumns) { - if (MASTER_KEYS.has(col.key)) { - if (passedFirstDetail) trailingMaster.push(col); - else leadingMaster.push(col); - } else { - passedFirstDetail = true; - detailCols.push(col); - } - } - - const renderDetailCell = (row: any, key: string) => { + const renderCell = (row: any, key: string) => { const val = row[key]; if (key === "status") return val ? {val} : "-"; - if (numCols.has(key)) return {val ? Number(val).toLocaleString() : "0"}; - return val || "-"; + if (key === "order_date" || key === "due_date") return val ? new Date(val).toLocaleDateString("ko-KR") : "-"; + if (numCols.has(key)) return {val ? Number(val).toLocaleString() : ""}; + return val || ""; }; - - const renderMasterHead = (col: { key: string; label: string }) => ( - - {col.label} - - ); - - const renderMasterCell = (col: { key: string }, m: any, purchaseNo: string) => { - if (col.key === "purchase_no") return {purchaseNo}; - if (col.key === "order_date") return {m.order_date ? new Date(m.order_date).toLocaleDateString("ko-KR") : "-"}; - if (col.key === "supplier_name") return {m.supplier_name || "-"}; - if (col.key === "status") return {m.status && {m.status}}; - if (col.key === "memo") return {m.memo || ""}; - return ; - }; - return (
- - - {leadingMaster.map(renderMasterHead)} - 품목수 - {detailCols.map(col => ( - - {col.label}{col.key === "order_qty" || col.key === "amount" ? " 합계" : ""} + { + const allIds = orders.map((r) => r.id); + const allChecked = allIds.length > 0 && allIds.every((id) => checkedIds.includes(id)); + setCheckedIds(allChecked ? [] : allIds); + }} + > + 0 && orders.every((r) => checkedIds.includes(r.id))} + onCheckedChange={() => {}} + /> + + {ts.visibleColumns.map((col) => ( + + {col.label} ))} - {trailingMaster.map(renderMasterHead)} - {Object.entries(orderGroups).map(([purchaseNo, group]) => { - const isExpanded = expandedOrders.has(purchaseNo); - const detailIds = group.details.map(d => d.id); - const allChecked = detailIds.length > 0 && detailIds.every(id => checkedIds.includes(id)); - const someChecked = detailIds.some(id => checkedIds.includes(id)); - const m = group.master; - const totalQty = group.details.reduce((s, d) => s + (Number(d.order_qty) || 0), 0); - const totalAmt = group.details.reduce((s, d) => s + (Number(d.amount) || 0), 0); + {orders.map((row) => { + const isChecked = checkedIds.includes(row.id); return ( - - setExpandedOrders(prev => { const next = new Set(prev); if (next.has(purchaseNo)) next.delete(purchaseNo); else next.add(purchaseNo); return next; })} - onDoubleClick={() => openEditModal(purchaseNo)} + { + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] + ); + }} + onDoubleClick={() => openEditModal(row.purchase_no)} + > + { + e.stopPropagation(); + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] + ); + }} > - - + {}} /> + + {ts.visibleColumns.map((col) => ( + + {renderCell(row, col.key)} - { e.stopPropagation(); setCheckedIds(prev => allChecked ? prev.filter(id => !detailIds.includes(id)) : [...new Set([...prev, ...detailIds])]); }}> - {}} /> - - {leadingMaster.map(col => renderMasterCell(col, m, purchaseNo))} - {group.details.length}건 - {detailCols.map(col => ( - - {col.key === "order_qty" ? totalQty.toLocaleString() - : col.key === "amount" ? totalAmt.toLocaleString() - : ""} - - ))} - {trailingMaster.map(col => renderMasterCell(col, m, purchaseNo))} - - {isExpanded && group.details.map((row) => ( - - - { e.stopPropagation(); setCheckedIds(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id]); }}> - {}} /> - - {leadingMaster.map(col => )} - - {detailCols.map(col => ( - - {renderDetailCell(row, col.key)} - - ))} - {trailingMaster.map(col => )} - ))} - + ); })} @@ -938,7 +881,7 @@ export default function PurchaseOrderPage() { })() )}
- 전체 {Object.keys(orderGroups).length}건 (발주 기준) / {orders.length}건 (품목 기준) + 전체 {orders.length}건
diff --git a/frontend/app/(main)/COMPANY_8/logistics/outbound/page.tsx b/frontend/app/(main)/COMPANY_8/logistics/outbound/page.tsx index 824e2e86..457e2723 100644 --- a/frontend/app/(main)/COMPANY_8/logistics/outbound/page.tsx +++ b/frontend/app/(main)/COMPANY_8/logistics/outbound/page.tsx @@ -38,7 +38,6 @@ import { Save, ChevronRight, ChevronLeft, - ChevronDown, ChevronsLeft, ChevronsRight, Inbox, @@ -116,41 +115,8 @@ const GRID_COLUMNS = [ { 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 = { - 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", -}; +// 총 컬럼 수: 체크박스(1) + GRID_COLUMNS(15) = 16 +const TOTAL_COLS = 16; // 헤더 필터 Popover function HeaderFilterPopover({ @@ -258,30 +224,6 @@ interface SelectedSourceItem { 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([]); const [loading, setLoading] = useState(false); @@ -290,9 +232,7 @@ export default function OutboundPage() { // 검색 필터 const [searchFilters, setSearchFilters] = useState([]); - // 마스터-디테일 그룹 테이블 state - const [expandedOrders, setExpandedOrders] = useState>(new Set()); - const [closingOrders, setClosingOrders] = useState>(new Set()); + // 헤더 필터 & 정렬 const [headerFilters, setHeaderFilters] = useState>>({}); const [sortState, setSortState] = useState<{ key: string; direction: "asc" | "desc" } | null>(null); @@ -403,121 +343,59 @@ export default function OutboundPage() { })(); }, []); - // --- 마스터-디테일 그룹핑, 필터, 정렬 --- + // 플랫 행 생성 (출고유형 코드→라벨 변환, 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]); - // outbound_number 기준 그룹핑 + 필터 + 정렬 - const filteredGroups = useMemo(() => { - // 1차: outbound_number 기준 그룹핑 - const allGroups: Record = {}; - 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> = {}; - const detailFilters: Record> = {}; - 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 columnUniqueValues = useMemo(() => { const result: Record = {}; - const seenMasters = new Map(); - 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 }))]) { + for (const col of GRID_COLUMNS) { const values = new Set(); - masters.forEach((m) => { - const val = (m as any)?.[col.key]; + 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; - }, [data]); + }, [flatRows]); - // 디테일 컬럼별 고유값 (디테일 서브헤더 필터용) - const columnUniqueValues = useMemo(() => { - const result: Record = {}; - for (const col of DETAIL_HEADER_COLS) { - const values = new Set(); - 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)); - } + // 필터 + 정렬 적용된 플랫 데이터 + 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); }); - result[col.key] = Array.from(values).sort(); } - return result; - }, [data]); + + // 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) => { @@ -938,7 +816,7 @@ export default function OutboundPage() { dataCount={data.length} /> - {/* 출고 목록 테이블 */} + {/* 출고 목록 테이블 (플랫 리스트) */}
{/* 패널 헤더 */}
@@ -946,7 +824,7 @@ export default function OutboundPage() { 출고 목록 - {data.length}건 + {filteredRows.length}건
@@ -971,78 +849,68 @@ export default function OutboundPage() {
-
+
- - {visibleMasterLayout.map((col) => ( - - ))} + + + + + + + + + + + + + + { - const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id)); + const allFilteredIds = filteredRows.map((r) => r.id); const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id)); setCheckedIds(allChecked ? [] : allFilteredIds); }} > { - const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id)); + const allFilteredIds = filteredRows.map((r) => r.id); return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id)); })()} onCheckedChange={() => {}} /> - - {/* 출고번호 */} - -
-
handleSort("outbound_number")}> - 출고번호 - {sortState?.key === "outbound_number" && ( - sortState.direction === "asc" - ? - : - )} -
- {(masterUniqueValues["outbound_number"] || []).length > 0 && ( - ()} - onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} - /> - )} -
-
- {/* 마스터 필드 헤더 (ts.visibleColumns 순서) */} - {visibleMasterLayout.map((col) => ( - -
-
handleSort(col.key)}> - {col.label} - {sortState?.key === col.key && ( - sortState.direction === "asc" - ? - : + {GRID_COLUMNS.map((col) => { + const isRight = ["outbound_qty", "unit_price", "total_amount"].includes(col.key); + return ( + +
+
handleSort(col.key)}> + {col.label} + {sortState?.key === col.key && ( + sortState.direction === "asc" + ? + : + )} +
+ {(columnUniqueValues[col.key] || []).length > 0 && ( + ()} + onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} + /> )}
- {(masterUniqueValues[col.key] || []).length > 0 && ( - ()} - onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} - /> - )} -
- - ))} + + ); + })} @@ -1052,7 +920,7 @@ export default function OutboundPage() { - ) : Object.keys(filteredGroups).length === 0 ? ( + ) : filteredRows.length === 0 ? (
@@ -1062,214 +930,59 @@ export default function OutboundPage() { ) : ( - 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; + filteredRows.map((row) => { + const isChecked = checkedIds.includes(row.id); return ( - - {/* 마스터 행 */} - { - 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])} - > - { - e.stopPropagation(); - setCheckedIds((prev) => { - if (allDetailChecked) return prev.filter((id) => !detailIds.includes(id)); - return [...new Set([...prev, ...detailIds])]; - }); - }} - > - {}} - /> - - - {isExpanded - ? - : - } - - {/* 출고번호 */} - - {outboundNo} - ({group.details.length}) - - {/* 마스터 필드 (ts.visibleColumns 순서) */} - {visibleMasterLayout.map((col) => { - switch (col.key) { - case "outbound_type": return ( - - - {resolveCat("outbound_type", master.outbound_type) || "-"} - - - ); - case "outbound_date": return ( - - {master.outbound_date ? new Date(master.outbound_date).toLocaleDateString("ko-KR") : "-"} - - ); - case "reference_number": return ( - - {master.reference_number || ""} - - ); - case "customer_name": return ( - - {master.customer_name || ""} - - ); - case "warehouse_name": return ( - - {master.warehouse_name || master.warehouse_code || ""} - - ); - case "outbound_status": return ( - - - {master.outbound_status || "-"} - - - ); - case "memo": return ( - - {master.memo || ""} - - ); - default: return {(master as any)[col.key] ?? ""}; - } - })} - - - {/* 디테일 서브 헤더 (펼쳤을 때만) */} - {isExpanded && ( - - - - - {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(); - return ( - -
-
handleSort(col.key)} - > - {col.label} - {isSorted && ( - sortState!.direction === "asc" - ? - : - )} -
- {uniqueVals.length > 0 && ( - - )} -
-
- ); - })} -
+ { - const isClosing = closingOrders.has(outboundNo); - const isChecked = checkedIds.includes(row.id); - return ( - { - setCheckedIds((prev) => - prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] - ); - }} - onDoubleClick={() => openEditModal(row)} - > - { - e.stopPropagation(); - setCheckedIds((prev) => - prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] - ); - }} - > - {}} /> - - -
- - - {visibleDetailCols.map((col) => { - switch (col.key) { - case "source_type": return {row.source_type ? (SOURCE_TYPE_LABEL[row.source_type] || row.source_type) : "-"}; - case "item_code": return {row.item_code || ""}; - case "item_name": return {row.item_name || ""}; - case "specification": return {row.specification || ""}; - case "outbound_qty": return {row.outbound_qty ? Number(row.outbound_qty).toLocaleString() : ""}; - case "unit_price": return {row.unit_price ? Number(row.unit_price).toLocaleString() : ""}; - case "total_amount": return {row.total_amount ? Number(row.total_amount).toLocaleString() : ""}; - default: return {(row as any)[col.key] ?? ""}; - } - })} - + onClick={() => { + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] ); - })} - + }} + onDoubleClick={() => openEditModal(row as any)} + > + { + e.stopPropagation(); + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] + ); + }} + > + {}} /> + + {row.outbound_number || ""} + + + {row.outbound_type || "-"} + + + + {row.outbound_date ? new Date(row.outbound_date).toLocaleDateString("ko-KR") : ""} + + {row.reference_number || ""} + {row.source_type || ""} + {row.customer_name || ""} + {row.item_number || ""} + {row.item_name || ""} + {row.spec || ""} + {row.outbound_qty ? Number(row.outbound_qty).toLocaleString() : ""} + {row.unit_price ? Number(row.unit_price).toLocaleString() : ""} + {row.total_amount ? Number(row.total_amount).toLocaleString() : ""} + {row.warehouse_name || row.warehouse_code || ""} + + + {row.outbound_status || "-"} + + + {row.remark || row.memo || ""} + ); }) )} diff --git a/frontend/app/(main)/COMPANY_8/logistics/receiving/page.tsx b/frontend/app/(main)/COMPANY_8/logistics/receiving/page.tsx index 5e692c46..77d21e7b 100644 --- a/frontend/app/(main)/COMPANY_8/logistics/receiving/page.tsx +++ b/frontend/app/(main)/COMPANY_8/logistics/receiving/page.tsx @@ -43,7 +43,6 @@ import { X, Save, ChevronRight, - ChevronDown, ChevronLeft, ChevronsLeft, ChevronsRight, @@ -64,6 +63,7 @@ import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSea import { getReceivingList, createReceiving, + updateReceiving, deleteReceiving, generateReceivingNumber, getReceivingWarehouses, @@ -95,41 +95,8 @@ const GRID_COLUMNS = [ { key: "remark", label: "비고" }, ]; -// 마스터 헤더 레이아웃 (입고번호 뒤, 디테일 7컬럼 위에 colSpan으로 맵핑) -const MASTER_BODY_LAYOUT = [ - { key: "inbound_type", label: "입고유형", colSpan: 1 }, - { key: "inbound_date", label: "입고일", colSpan: 1 }, - { key: "reference_number", label: "참조번호", colSpan: 1 }, - { key: "supplier_name", label: "공급처", colSpan: 1 }, - { key: "warehouse_name", label: "창고", colSpan: 1 }, - { key: "inbound_status", label: "입고상태", colSpan: 1 }, - { key: "memo", label: "비고", colSpan: 1 }, -]; - -// 디테일 헤더 컬럼 -const DETAIL_HEADER_COLS = [ - { key: "source_table", label: "출처" }, - { key: "item_number", label: "품목코드" }, - { key: "item_name", label: "품목명" }, - { key: "spec", label: "규격" }, - { key: "inbound_qty", label: "입고수량" }, - { key: "unit_price", label: "단가" }, - { key: "total_amount", label: "금액" }, -]; - -// 마스터 필드 키 목록 (필터 분류용) -const MASTER_KEYS = new Set(["inbound_number", ...MASTER_BODY_LAYOUT.map((c) => c.key)]); - -// 디테일 필드 키 매핑 (GRID_COLUMNS key → DETAIL_HEADER_COLS key) -const DETAIL_KEY_MAP: Record = { - source_type: "source_table", - item_number: "item_number", - item_name: "item_name", - spec: "spec", - inbound_qty: "inbound_qty", - unit_price: "unit_price", - total_amount: "total_amount", -}; +// 총 컬럼 수: 체크박스(1) + GRID_COLUMNS(15) = 16 +const TOTAL_COLS = 16; // 헤더 필터 Popover function HeaderFilterPopover({ @@ -287,30 +254,6 @@ interface SelectedSourceItem { export default function ReceivingPage() { const ts = useTableSettings("c16-receiving", "inbound_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([]); const [loading, setLoading] = useState(false); @@ -319,9 +262,7 @@ export default function ReceivingPage() { // 검색 필터 const [searchFilters, setSearchFilters] = useState([]); - // 마스터-디테일 그룹 테이블 - const [expandedOrders, setExpandedOrders] = useState>(new Set()); - const [closingOrders, setClosingOrders] = useState>(new Set()); + // 헤더 필터 & 정렬 const [headerFilters, setHeaderFilters] = useState>>({}); const [sortState, setSortState] = useState<{ key: string; direction: "asc" | "desc" } | null>(null); @@ -338,6 +279,10 @@ export default function ReceivingPage() { const [selectedItems, setSelectedItems] = useState([]); const [saving, setSaving] = useState(false); + // 수정 모드 + const [editMode, setEditMode] = useState(false); + const [editItemIds, setEditItemIds] = useState([]); + // 소스 데이터 const [sourceKeyword, setSourceKeyword] = useState(""); const [sourceLoading, setSourceLoading] = useState(false); @@ -377,7 +322,7 @@ export default function ReceivingPage() { Promise.all( ["material", "unit"].map(async (col) => { try { - const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_8`); + const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_16`); const items = flatten(res.data?.data || []); map[col] = {}; for (const item of items) map[col][item.code] = item.label; @@ -434,124 +379,56 @@ export default function ReceivingPage() { })(); }, []); - // 필터 + 정렬 적용된 데이터 -> 그룹핑 - const filteredGroups = useMemo(() => { - // 1차: inbound_number 기준 그룹핑 - const allGroups: Record = {}; - for (const row of data) { - const key = row.inbound_number || "_no_inbound"; - if (!allGroups[key]) { - allGroups[key] = { master: row, details: [] }; - } - allGroups[key].details.push(row); - } - - // 마스터 필터 / 디테일 필터 분리 - const masterFilters: Record> = {}; - const detailFilters: Record> = {}; - 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]) => { - let raw = (group.master as any)?.[colKey] ?? ""; - // 입고유형은 코드→라벨 변환된 값으로 비교 - if (colKey === "inbound_type") raw = resolveInboundType(String(raw)); - return values.has(String(raw)); - }) - ); - } - - // 3차: 디테일 필터 적용 (행 단위) - if (Object.keys(detailFilters).length > 0) { - entries = entries - .map(([inboundNo, 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_table") cellVal = SOURCE_TABLE_LABEL[cellVal] || cellVal; - return values.has(cellVal); - }) - ); - return [inboundNo, { ...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]) => { - let av: any = (a.master as any)?.[key] ?? ""; - let bv: any = (b.master as any)?.[key] ?? ""; - if (key === "inbound_type") { av = resolveInboundType(String(av)); bv = resolveInboundType(String(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)); - }); - } else { - entries.forEach(([, group]) => { - group.details.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 Object.fromEntries(entries); - }, [data, headerFilters, sortState]); - - // 마스터 컬럼별 고유값 (마스터 헤더 필터용) - const masterUniqueValues = useMemo(() => { - const result: Record = {}; - const seenMasters = new Map(); - data.forEach((row) => { - if (row.inbound_number && !seenMasters.has(row.inbound_number)) { - seenMasters.set(row.inbound_number, row); - } - }); - const masters = Array.from(seenMasters.values()); - for (const col of [{ key: "inbound_number", label: "입고번호" }, ...MASTER_BODY_LAYOUT.map(({ key, label }) => ({ key, label }))]) { - const values = new Set(); - masters.forEach((m) => { - let val = (m as any)?.[col.key]; - if (val !== null && val !== undefined && val !== "") { - if (col.key === "inbound_type") val = resolveInboundType(String(val)); - values.add(String(val)); - } - }); - result[col.key] = Array.from(values).sort(); - } - return result; + // 플랫 행 생성 (입고유형 코드→라벨 변환, source_table→한글 등) + const flatRows = useMemo(() => { + return data.map((row) => ({ + ...row, + inbound_type: resolveInboundType(row.inbound_type), + source_type: row.source_table ? (SOURCE_TABLE_LABEL[row.source_table] || row.source_table) : (row as any).source_type || "", + })); }, [data]); - // 디테일 컬럼별 고유값 (디테일 서브헤더 필터용) + // 컬럼별 고유값 (헤더 필터용) const columnUniqueValues = useMemo(() => { const result: Record = {}; - for (const col of DETAIL_HEADER_COLS) { + for (const col of GRID_COLUMNS) { const values = new Set(); - data.forEach((row) => { - let val = (row as any)[col.key]; - if (val !== null && val !== undefined && val !== "") { - if (col.key === "source_table") val = SOURCE_TABLE_LABEL[val] || val; - values.add(String(val)); - } + 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; - }, [data]); + }, [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) => { @@ -633,6 +510,8 @@ export default function ReceivingPage() { const openRegisterModal = async () => { const defaultType = "구매입고"; + setEditMode(false); + setEditItemIds([]); setModalInboundType(defaultType); setModalInboundDate(new Date().toISOString().split("T")[0]); setModalWarehouse(""); @@ -661,6 +540,51 @@ export default function ReceivingPage() { } }; + // 수정 모달 열기 + const openEditModal = (row: InboundItem) => { + const inNo = row.inbound_number; + const grouped = data.filter((d) => d.inbound_number === inNo); + const first = grouped[0] || row; + + setEditMode(true); + setEditItemIds(grouped.map((g) => g.id)); + setModalInboundNo(inNo); + setModalInboundType(first.inbound_type || "구매입고"); + setModalInboundDate(first.inbound_date ? String(first.inbound_date).slice(0, 10) : ""); + setModalWarehouse(first.warehouse_code || ""); + setModalLocation(first.location_code || ""); + setModalInspector((first as any).inspector || ""); + setModalManager((first as any).manager || ""); + setModalMemo(first.memo || ""); + setSelectedItems( + grouped.map((g) => ({ + key: g.id, + inbound_type: g.inbound_type || "", + reference_number: g.reference_number || "", + supplier_code: (g as any).supplier_code || "", + supplier_name: g.supplier_name || "", + item_number: g.item_number || "", + item_name: g.item_name || "", + spec: g.spec || "", + material: (g as any).material || "", + unit: (g as any).unit || "", + inbound_qty: Number(g.inbound_qty) || 0, + unit_price: Number(g.unit_price) || 0, + total_amount: Number(g.total_amount) || 0, + source_table: (g as any).source_table || "", + source_id: (g as any).source_id || "", + })) + ); + setSourceKeyword(""); + setPurchaseOrders([]); + setShipments([]); + setItems([]); + setSourcePage(1); + setSourceTotalCount(0); + setIsModalOpen(true); + loadSourceData(first.inbound_type || "구매입고", undefined, 1); + }; + // 검색 버튼 클릭 시 const searchSourceData = useCallback(async () => { setSourcePage(1); @@ -811,41 +735,97 @@ export default function ReceivingPage() { } setSaving(true); try { - const res = await createReceiving({ - inbound_number: modalInboundNo, - inbound_date: modalInboundDate, - warehouse_code: modalWarehouse, - location_code: modalLocation || undefined, - inspector: modalInspector || undefined, - manager: modalManager || undefined, - memo: modalMemo || undefined, - items: selectedItems.map((item) => ({ - inbound_type: item.inbound_type, - reference_number: item.reference_number, - supplier_code: item.supplier_code, - supplier_name: item.supplier_name, - item_number: item.item_number, - item_name: item.item_name, - spec: item.spec, - material: item.material, - unit: item.unit, - inbound_qty: item.inbound_qty, - unit_price: item.unit_price, - total_amount: item.total_amount, - source_table: item.source_table, - source_id: item.source_id, - inbound_status: "입고완료", - inspection_status: "대기", - })), - }); + if (editMode) { + // 기존 item 수정 + 삭제된 item 삭제 + 새 item 추가 + const currentKeys = new Set(selectedItems.map((i) => i.key)); + const toDelete = editItemIds.filter((id) => !currentKeys.has(id)); + const toUpdate = selectedItems.filter((i) => editItemIds.includes(i.key)); + const toCreate = selectedItems.filter((i) => !editItemIds.includes(i.key)); - if (res.success) { - alert(res.message || "입고 등록 완료"); + await Promise.all([ + ...toDelete.map((id) => deleteReceiving(id)), + ...toUpdate.map((item) => + updateReceiving(item.key, { + inbound_date: modalInboundDate, + inbound_qty: item.inbound_qty, + unit_price: item.unit_price, + total_amount: item.total_amount, + warehouse_code: modalWarehouse || undefined, + location_code: modalLocation || undefined, + memo: modalMemo || undefined, + } as any) + ), + ...(toCreate.length > 0 + ? [createReceiving({ + inbound_number: modalInboundNo, + inbound_date: modalInboundDate, + warehouse_code: modalWarehouse, + location_code: modalLocation || undefined, + inspector: modalInspector || undefined, + manager: modalManager || undefined, + memo: modalMemo || undefined, + items: toCreate.map((item) => ({ + inbound_type: item.inbound_type, + reference_number: item.reference_number, + supplier_code: item.supplier_code, + supplier_name: item.supplier_name, + item_number: item.item_number, + item_name: item.item_name, + spec: item.spec, + material: item.material, + unit: item.unit, + inbound_qty: item.inbound_qty, + unit_price: item.unit_price, + total_amount: item.total_amount, + source_table: item.source_table, + source_id: item.source_id, + inbound_status: "입고완료", + inspection_status: "대기", + })), + })] + : []), + ]); + toast.success("입고 정보를 수정했어요"); setIsModalOpen(false); fetchList(); + } else { + const res = await createReceiving({ + inbound_number: modalInboundNo, + inbound_date: modalInboundDate, + warehouse_code: modalWarehouse, + location_code: modalLocation || undefined, + inspector: modalInspector || undefined, + manager: modalManager || undefined, + memo: modalMemo || undefined, + items: selectedItems.map((item) => ({ + inbound_type: item.inbound_type, + reference_number: item.reference_number, + supplier_code: item.supplier_code, + supplier_name: item.supplier_name, + item_number: item.item_number, + item_name: item.item_name, + spec: item.spec, + material: item.material, + unit: item.unit, + inbound_qty: item.inbound_qty, + unit_price: item.unit_price, + total_amount: item.total_amount, + source_table: item.source_table, + source_id: item.source_id, + inbound_status: "입고완료", + inspection_status: "대기", + })), + }); + + if (res.success) { + toast.success(res.message || "입고 등록 완료"); + setIsModalOpen(false); + fetchList(); + } } - } catch { - alert("입고 등록 중 오류가 발생했습니다."); + } catch (err: any) { + const msg = err?.response?.data?.message || (editMode ? "수정에 실패했어요" : "입고 등록 중 오류가 발생했습니다."); + toast.error(msg); } finally { setSaving(false); } @@ -897,13 +877,13 @@ export default function ReceivingPage() { } /> - {/* 입고 목록 테이블 (마스터-디테일 그룹) */} + {/* 입고 목록 테이블 (플랫 리스트) */}

입고 목록

- {Object.keys(filteredGroups).length}건 + {filteredRows.length}건
-
+
- - {visibleMasterLayout.map((col) => ( - - ))} + + + + + + + + + + + + + + { - const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id)); + const allFilteredIds = filteredRows.map((r) => r.id); const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id)); setCheckedIds(allChecked ? [] : allFilteredIds); }} > { - const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id)); + const allFilteredIds = filteredRows.map((r) => r.id); return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id)); })()} onCheckedChange={() => {}} /> - - {/* 입고번호 (별도 컬럼) */} - -
-
handleSort("inbound_number")}> - 입고번호 - {sortState?.key === "inbound_number" && ( - sortState.direction === "asc" - ? - : - )} -
- {(masterUniqueValues["inbound_number"] || []).length > 0 && ( - ()} - onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} - /> - )} -
-
- {/* 마스터 필드 헤더 (ts.visibleColumns 순서) */} - {visibleMasterLayout.map((col) => ( - -
-
handleSort(col.key)}> - {col.label} - {sortState?.key === col.key && ( - sortState.direction === "asc" - ? - : + {GRID_COLUMNS.map((col) => { + const isRight = ["inbound_qty", "unit_price", "total_amount"].includes(col.key); + return ( + +
+
handleSort(col.key)}> + {col.label} + {sortState?.key === col.key && ( + sortState.direction === "asc" + ? + : + )} +
+ {(columnUniqueValues[col.key] || []).length > 0 && ( + ()} + onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} + /> )}
- {(masterUniqueValues[col.key] || []).length > 0 && ( - ()} - onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} - /> - )} -
- - ))} + + ); + })} @@ -993,7 +963,7 @@ export default function ReceivingPage() { - ) : Object.keys(filteredGroups).length === 0 ? ( + ) : filteredRows.length === 0 ? (
@@ -1003,212 +973,59 @@ export default function ReceivingPage() { ) : ( - Object.entries(filteredGroups).map(([inboundNo, group]) => { - const isExpanded = expandedOrders.has(inboundNo); - 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; + filteredRows.map((row) => { + const isChecked = checkedIds.includes(row.id); return ( - - {/* 마스터 행 */} - { - if (expandedOrders.has(inboundNo)) { - setClosingOrders((prev) => new Set(prev).add(inboundNo)); - setTimeout(() => { - setExpandedOrders((prev) => { const next = new Set(prev); next.delete(inboundNo); return next; }); - setClosingOrders((prev) => { const next = new Set(prev); next.delete(inboundNo); return next; }); - }, 200); - } else { - setExpandedOrders((prev) => new Set(prev).add(inboundNo)); - } + { + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] + ); + }} + onDoubleClick={() => openEditModal(row as any)} + > + { + e.stopPropagation(); + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] + ); }} > - { - e.stopPropagation(); - setCheckedIds((prev) => { - if (allDetailChecked) return prev.filter((id) => !detailIds.includes(id)); - return [...new Set([...prev, ...detailIds])]; - }); - }} - > - {}} - /> - - - {isExpanded - ? - : - } - - {/* 입고번호 */} - - {inboundNo} - ({group.details.length}) - - {/* 마스터 필드 (ts.visibleColumns 순서) */} - {visibleMasterLayout.map((col) => { - switch (col.key) { - case "inbound_type": return ( - - - {resolveInboundType(master.inbound_type)} - - - ); - case "inbound_date": return ( - - {master.inbound_date ? new Date(master.inbound_date).toLocaleDateString("ko-KR") : "-"} - - ); - case "reference_number": return ( - - {master.reference_number || ""} - - ); - case "supplier_name": return ( - - {master.supplier_name || ""} - - ); - case "warehouse_name": return ( - - {master.warehouse_name || master.warehouse_code || ""} - - ); - case "inbound_status": return ( - - - {master.inbound_status || "-"} - - - ); - case "memo": return ( - - {master.memo || ""} - - ); - default: return {(master as any)[col.key] ?? ""}; - } - })} - - - {/* 디테일 서브 헤더 (펼쳤을 때만) */} - {isExpanded && ( - - - - - {visibleDetailCols.map((col) => { - const isRight = ["inbound_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_table") v = SOURCE_TABLE_LABEL[v] || v; - return v; - }).filter((v: any) => v != null && v !== "").map(String) - )).sort(); - const filterVals = headerFilters[col.key] || new Set(); - return ( - -
-
handleSort(col.key)} - > - {col.label} - {isSorted && ( - sortState!.direction === "asc" - ? - : - )} -
- {uniqueVals.length > 0 && ( - - )} -
-
- ); - })} -
- )} - - {/* 디테일 행 (펼쳤을 때만) */} - {isExpanded && group.details.map((row, detailIdx) => { - const isClosing = closingOrders.has(inboundNo); - const isChecked = checkedIds.includes(row.id); - return ( - { - setCheckedIds((prev) => - prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] - ); - }} - > - { - e.stopPropagation(); - setCheckedIds((prev) => - prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] - ); - }} - > - {}} /> - - -
- - - {visibleDetailCols.map((col) => { - switch (col.key) { - case "source_table": return {row.source_table ? SOURCE_TABLE_LABEL[row.source_table] || row.source_table : "-"}; - case "item_number": return {row.item_number || ""}; - case "item_name": return {row.item_name || ""}; - case "spec": return {row.spec || ""}; - case "inbound_qty": return {row.inbound_qty ? Number(row.inbound_qty).toLocaleString() : ""}; - case "unit_price": return {row.unit_price ? Number(row.unit_price).toLocaleString() : ""}; - case "total_amount": return {row.total_amount ? Number(row.total_amount).toLocaleString() : ""}; - default: return {(row as any)[col.key] ?? ""}; - } - })} - - ); - })} - + {}} /> + + {row.inbound_number || ""} + + + {row.inbound_type || "-"} + + + + {row.inbound_date ? new Date(row.inbound_date).toLocaleDateString("ko-KR") : ""} + + {row.reference_number || ""} + {row.source_type || ""} + {row.supplier_name || ""} + {row.item_number || ""} + {row.item_name || ""} + {row.spec || ""} + {row.inbound_qty ? Number(row.inbound_qty).toLocaleString() : ""} + {row.unit_price ? Number(row.unit_price).toLocaleString() : ""} + {row.total_amount ? Number(row.total_amount).toLocaleString() : ""} + {row.warehouse_name || (row as any).warehouse_code || ""} + + + {row.inbound_status || "-"} + + + {row.remark || row.memo || ""} + ); }) )} @@ -1221,9 +1038,9 @@ export default function ReceivingPage() { - 입고 등록 + {editMode ? "입고 수정" : "입고 등록"} - 입고유형을 선택하고, 좌측에서 근거 데이터를 검색하여 추가해 주세요. + {editMode ? "입고 정보를 수정해 주세요." : "입고유형을 선택하고, 좌측에서 근거 데이터를 검색하여 추가해 주세요."}
diff --git a/frontend/app/(main)/COMPANY_8/purchase/order/page.tsx b/frontend/app/(main)/COMPANY_8/purchase/order/page.tsx index 143a84a9..90116bac 100644 --- a/frontend/app/(main)/COMPANY_8/purchase/order/page.tsx +++ b/frontend/app/(main)/COMPANY_8/purchase/order/page.tsx @@ -13,7 +13,7 @@ import { Badge } from "@/components/ui/badge"; import { Label } from "@/components/ui/label"; import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, - ClipboardList, Pencil, Search, X, Package, ChevronDown, + ClipboardList, Pencil, Search, X, Package, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Settings2, GripVertical, } from "lucide-react"; @@ -162,7 +162,6 @@ export default function PurchaseOrderPage() { const [excelUploadOpen, setExcelUploadOpen] = useState(false); const [categoryOptions, setCategoryOptions] = useState>({}); const [checkedIds, setCheckedIds] = useState([]); - const [expandedOrders, setExpandedOrders] = useState>(new Set()); // 테이블 설정 const ts = useTableSettings("c16-purchase-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG); @@ -372,20 +371,6 @@ export default function PurchaseOrderPage() { useEffect(() => { fetchOrders(); }, [fetchOrders]); // purchase_no 기준 그룹핑 - const orderGroups = useMemo(() => { - const map: Record = {}; - for (const row of orders) { - const key = row.purchase_no || row.id; - if (!map[key]) { - map[key] = { - master: { purchase_no: row.purchase_no, order_date: row.order_date, supplier_name: row.supplier_name, status: row.status, memo: row.memo }, - details: [], - }; - } - map[key].details.push(row); - } - return map; - }, [orders]); const getCategoryLabel = (col: string, code: string) => { if (!code) return ""; @@ -811,125 +796,83 @@ export default function PurchaseOrderPage() {
- {/* 데이터 테이블 — 아코디언 그룹핑 */} + {/* 데이터 테이블 — 플랫 리스트 */}
{loading ? (
- ) : Object.keys(orderGroups).length === 0 ? ( + ) : orders.length === 0 ? (

등록된 발주가 없어요

) : ( (() => { - const MASTER_KEYS = new Set(["purchase_no", "order_date", "supplier_name", "status", "memo"]); const numCols = new Set(["order_qty", "received_qty", "remain_qty", "unit_price", "amount"]); - - // ts.visibleColumns 순서를 따르되, 마스터/디테일 컬럼을 분리 - // 고정 컬럼(품목수)은 마스터 선행 컬럼 뒤에 배치 - const leadingMaster: typeof ts.visibleColumns = []; - const detailCols: typeof ts.visibleColumns = []; - const trailingMaster: typeof ts.visibleColumns = []; - let passedFirstDetail = false; - for (const col of ts.visibleColumns) { - if (MASTER_KEYS.has(col.key)) { - if (passedFirstDetail) trailingMaster.push(col); - else leadingMaster.push(col); - } else { - passedFirstDetail = true; - detailCols.push(col); - } - } - - const renderDetailCell = (row: any, key: string) => { + const renderCell = (row: any, key: string) => { const val = row[key]; if (key === "status") return val ? {val} : "-"; - if (numCols.has(key)) return {val ? Number(val).toLocaleString() : "0"}; - return val || "-"; + if (key === "order_date" || key === "due_date") return val ? new Date(val).toLocaleDateString("ko-KR") : "-"; + if (numCols.has(key)) return {val ? Number(val).toLocaleString() : ""}; + return val || ""; }; - - const renderMasterHead = (col: { key: string; label: string }) => ( - - {col.label} - - ); - - const renderMasterCell = (col: { key: string }, m: any, purchaseNo: string) => { - if (col.key === "purchase_no") return {purchaseNo}; - if (col.key === "order_date") return {m.order_date ? new Date(m.order_date).toLocaleDateString("ko-KR") : "-"}; - if (col.key === "supplier_name") return {m.supplier_name || "-"}; - if (col.key === "status") return {m.status && {m.status}}; - if (col.key === "memo") return {m.memo || ""}; - return ; - }; - return (
- - - {leadingMaster.map(renderMasterHead)} - 품목수 - {detailCols.map(col => ( - - {col.label}{col.key === "order_qty" || col.key === "amount" ? " 합계" : ""} + { + const allIds = orders.map((r) => r.id); + const allChecked = allIds.length > 0 && allIds.every((id) => checkedIds.includes(id)); + setCheckedIds(allChecked ? [] : allIds); + }} + > + 0 && orders.every((r) => checkedIds.includes(r.id))} + onCheckedChange={() => {}} + /> + + {ts.visibleColumns.map((col) => ( + + {col.label} ))} - {trailingMaster.map(renderMasterHead)} - {Object.entries(orderGroups).map(([purchaseNo, group]) => { - const isExpanded = expandedOrders.has(purchaseNo); - const detailIds = group.details.map(d => d.id); - const allChecked = detailIds.length > 0 && detailIds.every(id => checkedIds.includes(id)); - const someChecked = detailIds.some(id => checkedIds.includes(id)); - const m = group.master; - const totalQty = group.details.reduce((s, d) => s + (Number(d.order_qty) || 0), 0); - const totalAmt = group.details.reduce((s, d) => s + (Number(d.amount) || 0), 0); + {orders.map((row) => { + const isChecked = checkedIds.includes(row.id); return ( - - setExpandedOrders(prev => { const next = new Set(prev); if (next.has(purchaseNo)) next.delete(purchaseNo); else next.add(purchaseNo); return next; })} - onDoubleClick={() => openEditModal(purchaseNo)} + { + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] + ); + }} + onDoubleClick={() => openEditModal(row.purchase_no)} + > + { + e.stopPropagation(); + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] + ); + }} > - - + {}} /> + + {ts.visibleColumns.map((col) => ( + + {renderCell(row, col.key)} - { e.stopPropagation(); setCheckedIds(prev => allChecked ? prev.filter(id => !detailIds.includes(id)) : [...new Set([...prev, ...detailIds])]); }}> - {}} /> - - {leadingMaster.map(col => renderMasterCell(col, m, purchaseNo))} - {group.details.length}건 - {detailCols.map(col => ( - - {col.key === "order_qty" ? totalQty.toLocaleString() - : col.key === "amount" ? totalAmt.toLocaleString() - : ""} - - ))} - {trailingMaster.map(col => renderMasterCell(col, m, purchaseNo))} - - {isExpanded && group.details.map((row) => ( - - - { e.stopPropagation(); setCheckedIds(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id]); }}> - {}} /> - - {leadingMaster.map(col => )} - - {detailCols.map(col => ( - - {renderDetailCell(row, col.key)} - - ))} - {trailingMaster.map(col => )} - ))} - + ); })} @@ -938,7 +881,7 @@ export default function PurchaseOrderPage() { })() )}
- 전체 {Object.keys(orderGroups).length}건 (발주 기준) / {orders.length}건 (품목 기준) + 전체 {orders.length}건
diff --git a/frontend/app/(main)/COMPANY_9/logistics/outbound/page.tsx b/frontend/app/(main)/COMPANY_9/logistics/outbound/page.tsx index 824e2e86..457e2723 100644 --- a/frontend/app/(main)/COMPANY_9/logistics/outbound/page.tsx +++ b/frontend/app/(main)/COMPANY_9/logistics/outbound/page.tsx @@ -38,7 +38,6 @@ import { Save, ChevronRight, ChevronLeft, - ChevronDown, ChevronsLeft, ChevronsRight, Inbox, @@ -116,41 +115,8 @@ const GRID_COLUMNS = [ { 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 = { - 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", -}; +// 총 컬럼 수: 체크박스(1) + GRID_COLUMNS(15) = 16 +const TOTAL_COLS = 16; // 헤더 필터 Popover function HeaderFilterPopover({ @@ -258,30 +224,6 @@ interface SelectedSourceItem { 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([]); const [loading, setLoading] = useState(false); @@ -290,9 +232,7 @@ export default function OutboundPage() { // 검색 필터 const [searchFilters, setSearchFilters] = useState([]); - // 마스터-디테일 그룹 테이블 state - const [expandedOrders, setExpandedOrders] = useState>(new Set()); - const [closingOrders, setClosingOrders] = useState>(new Set()); + // 헤더 필터 & 정렬 const [headerFilters, setHeaderFilters] = useState>>({}); const [sortState, setSortState] = useState<{ key: string; direction: "asc" | "desc" } | null>(null); @@ -403,121 +343,59 @@ export default function OutboundPage() { })(); }, []); - // --- 마스터-디테일 그룹핑, 필터, 정렬 --- + // 플랫 행 생성 (출고유형 코드→라벨 변환, 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]); - // outbound_number 기준 그룹핑 + 필터 + 정렬 - const filteredGroups = useMemo(() => { - // 1차: outbound_number 기준 그룹핑 - const allGroups: Record = {}; - 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> = {}; - const detailFilters: Record> = {}; - 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 columnUniqueValues = useMemo(() => { const result: Record = {}; - const seenMasters = new Map(); - 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 }))]) { + for (const col of GRID_COLUMNS) { const values = new Set(); - masters.forEach((m) => { - const val = (m as any)?.[col.key]; + 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; - }, [data]); + }, [flatRows]); - // 디테일 컬럼별 고유값 (디테일 서브헤더 필터용) - const columnUniqueValues = useMemo(() => { - const result: Record = {}; - for (const col of DETAIL_HEADER_COLS) { - const values = new Set(); - 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)); - } + // 필터 + 정렬 적용된 플랫 데이터 + 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); }); - result[col.key] = Array.from(values).sort(); } - return result; - }, [data]); + + // 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) => { @@ -938,7 +816,7 @@ export default function OutboundPage() { dataCount={data.length} /> - {/* 출고 목록 테이블 */} + {/* 출고 목록 테이블 (플랫 리스트) */}
{/* 패널 헤더 */}
@@ -946,7 +824,7 @@ export default function OutboundPage() { 출고 목록 - {data.length}건 + {filteredRows.length}건
@@ -971,78 +849,68 @@ export default function OutboundPage() {
-
+
- - {visibleMasterLayout.map((col) => ( - - ))} + + + + + + + + + + + + + + { - const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id)); + const allFilteredIds = filteredRows.map((r) => r.id); const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id)); setCheckedIds(allChecked ? [] : allFilteredIds); }} > { - const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id)); + const allFilteredIds = filteredRows.map((r) => r.id); return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id)); })()} onCheckedChange={() => {}} /> - - {/* 출고번호 */} - -
-
handleSort("outbound_number")}> - 출고번호 - {sortState?.key === "outbound_number" && ( - sortState.direction === "asc" - ? - : - )} -
- {(masterUniqueValues["outbound_number"] || []).length > 0 && ( - ()} - onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} - /> - )} -
-
- {/* 마스터 필드 헤더 (ts.visibleColumns 순서) */} - {visibleMasterLayout.map((col) => ( - -
-
handleSort(col.key)}> - {col.label} - {sortState?.key === col.key && ( - sortState.direction === "asc" - ? - : + {GRID_COLUMNS.map((col) => { + const isRight = ["outbound_qty", "unit_price", "total_amount"].includes(col.key); + return ( + +
+
handleSort(col.key)}> + {col.label} + {sortState?.key === col.key && ( + sortState.direction === "asc" + ? + : + )} +
+ {(columnUniqueValues[col.key] || []).length > 0 && ( + ()} + onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} + /> )}
- {(masterUniqueValues[col.key] || []).length > 0 && ( - ()} - onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} - /> - )} -
- - ))} + + ); + })} @@ -1052,7 +920,7 @@ export default function OutboundPage() { - ) : Object.keys(filteredGroups).length === 0 ? ( + ) : filteredRows.length === 0 ? (
@@ -1062,214 +930,59 @@ export default function OutboundPage() { ) : ( - 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; + filteredRows.map((row) => { + const isChecked = checkedIds.includes(row.id); return ( - - {/* 마스터 행 */} - { - 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])} - > - { - e.stopPropagation(); - setCheckedIds((prev) => { - if (allDetailChecked) return prev.filter((id) => !detailIds.includes(id)); - return [...new Set([...prev, ...detailIds])]; - }); - }} - > - {}} - /> - - - {isExpanded - ? - : - } - - {/* 출고번호 */} - - {outboundNo} - ({group.details.length}) - - {/* 마스터 필드 (ts.visibleColumns 순서) */} - {visibleMasterLayout.map((col) => { - switch (col.key) { - case "outbound_type": return ( - - - {resolveCat("outbound_type", master.outbound_type) || "-"} - - - ); - case "outbound_date": return ( - - {master.outbound_date ? new Date(master.outbound_date).toLocaleDateString("ko-KR") : "-"} - - ); - case "reference_number": return ( - - {master.reference_number || ""} - - ); - case "customer_name": return ( - - {master.customer_name || ""} - - ); - case "warehouse_name": return ( - - {master.warehouse_name || master.warehouse_code || ""} - - ); - case "outbound_status": return ( - - - {master.outbound_status || "-"} - - - ); - case "memo": return ( - - {master.memo || ""} - - ); - default: return {(master as any)[col.key] ?? ""}; - } - })} - - - {/* 디테일 서브 헤더 (펼쳤을 때만) */} - {isExpanded && ( - - - - - {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(); - return ( - -
-
handleSort(col.key)} - > - {col.label} - {isSorted && ( - sortState!.direction === "asc" - ? - : - )} -
- {uniqueVals.length > 0 && ( - - )} -
-
- ); - })} -
+ { - const isClosing = closingOrders.has(outboundNo); - const isChecked = checkedIds.includes(row.id); - return ( - { - setCheckedIds((prev) => - prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] - ); - }} - onDoubleClick={() => openEditModal(row)} - > - { - e.stopPropagation(); - setCheckedIds((prev) => - prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] - ); - }} - > - {}} /> - - -
- - - {visibleDetailCols.map((col) => { - switch (col.key) { - case "source_type": return {row.source_type ? (SOURCE_TYPE_LABEL[row.source_type] || row.source_type) : "-"}; - case "item_code": return {row.item_code || ""}; - case "item_name": return {row.item_name || ""}; - case "specification": return {row.specification || ""}; - case "outbound_qty": return {row.outbound_qty ? Number(row.outbound_qty).toLocaleString() : ""}; - case "unit_price": return {row.unit_price ? Number(row.unit_price).toLocaleString() : ""}; - case "total_amount": return {row.total_amount ? Number(row.total_amount).toLocaleString() : ""}; - default: return {(row as any)[col.key] ?? ""}; - } - })} - + onClick={() => { + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] ); - })} - + }} + onDoubleClick={() => openEditModal(row as any)} + > + { + e.stopPropagation(); + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] + ); + }} + > + {}} /> + + {row.outbound_number || ""} + + + {row.outbound_type || "-"} + + + + {row.outbound_date ? new Date(row.outbound_date).toLocaleDateString("ko-KR") : ""} + + {row.reference_number || ""} + {row.source_type || ""} + {row.customer_name || ""} + {row.item_number || ""} + {row.item_name || ""} + {row.spec || ""} + {row.outbound_qty ? Number(row.outbound_qty).toLocaleString() : ""} + {row.unit_price ? Number(row.unit_price).toLocaleString() : ""} + {row.total_amount ? Number(row.total_amount).toLocaleString() : ""} + {row.warehouse_name || row.warehouse_code || ""} + + + {row.outbound_status || "-"} + + + {row.remark || row.memo || ""} + ); }) )} diff --git a/frontend/app/(main)/COMPANY_9/logistics/receiving/page.tsx b/frontend/app/(main)/COMPANY_9/logistics/receiving/page.tsx index 4c357120..77d21e7b 100644 --- a/frontend/app/(main)/COMPANY_9/logistics/receiving/page.tsx +++ b/frontend/app/(main)/COMPANY_9/logistics/receiving/page.tsx @@ -43,7 +43,6 @@ import { X, Save, ChevronRight, - ChevronDown, ChevronLeft, ChevronsLeft, ChevronsRight, @@ -64,6 +63,7 @@ import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSea import { getReceivingList, createReceiving, + updateReceiving, deleteReceiving, generateReceivingNumber, getReceivingWarehouses, @@ -95,41 +95,8 @@ const GRID_COLUMNS = [ { key: "remark", label: "비고" }, ]; -// 마스터 헤더 레이아웃 (입고번호 뒤, 디테일 7컬럼 위에 colSpan으로 맵핑) -const MASTER_BODY_LAYOUT = [ - { key: "inbound_type", label: "입고유형", colSpan: 1 }, - { key: "inbound_date", label: "입고일", colSpan: 1 }, - { key: "reference_number", label: "참조번호", colSpan: 1 }, - { key: "supplier_name", label: "공급처", colSpan: 1 }, - { key: "warehouse_name", label: "창고", colSpan: 1 }, - { key: "inbound_status", label: "입고상태", colSpan: 1 }, - { key: "memo", label: "비고", colSpan: 1 }, -]; - -// 디테일 헤더 컬럼 -const DETAIL_HEADER_COLS = [ - { key: "source_table", label: "출처" }, - { key: "item_number", label: "품목코드" }, - { key: "item_name", label: "품목명" }, - { key: "spec", label: "규격" }, - { key: "inbound_qty", label: "입고수량" }, - { key: "unit_price", label: "단가" }, - { key: "total_amount", label: "금액" }, -]; - -// 마스터 필드 키 목록 (필터 분류용) -const MASTER_KEYS = new Set(["inbound_number", ...MASTER_BODY_LAYOUT.map((c) => c.key)]); - -// 디테일 필드 키 매핑 (GRID_COLUMNS key → DETAIL_HEADER_COLS key) -const DETAIL_KEY_MAP: Record = { - source_type: "source_table", - item_number: "item_number", - item_name: "item_name", - spec: "spec", - inbound_qty: "inbound_qty", - unit_price: "unit_price", - total_amount: "total_amount", -}; +// 총 컬럼 수: 체크박스(1) + GRID_COLUMNS(15) = 16 +const TOTAL_COLS = 16; // 헤더 필터 Popover function HeaderFilterPopover({ @@ -287,30 +254,6 @@ interface SelectedSourceItem { export default function ReceivingPage() { const ts = useTableSettings("c16-receiving", "inbound_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([]); const [loading, setLoading] = useState(false); @@ -319,9 +262,7 @@ export default function ReceivingPage() { // 검색 필터 const [searchFilters, setSearchFilters] = useState([]); - // 마스터-디테일 그룹 테이블 - const [expandedOrders, setExpandedOrders] = useState>(new Set()); - const [closingOrders, setClosingOrders] = useState>(new Set()); + // 헤더 필터 & 정렬 const [headerFilters, setHeaderFilters] = useState>>({}); const [sortState, setSortState] = useState<{ key: string; direction: "asc" | "desc" } | null>(null); @@ -338,6 +279,10 @@ export default function ReceivingPage() { const [selectedItems, setSelectedItems] = useState([]); const [saving, setSaving] = useState(false); + // 수정 모드 + const [editMode, setEditMode] = useState(false); + const [editItemIds, setEditItemIds] = useState([]); + // 소스 데이터 const [sourceKeyword, setSourceKeyword] = useState(""); const [sourceLoading, setSourceLoading] = useState(false); @@ -377,7 +322,7 @@ export default function ReceivingPage() { Promise.all( ["material", "unit"].map(async (col) => { try { - const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_9`); + const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_16`); const items = flatten(res.data?.data || []); map[col] = {}; for (const item of items) map[col][item.code] = item.label; @@ -434,124 +379,56 @@ export default function ReceivingPage() { })(); }, []); - // 필터 + 정렬 적용된 데이터 -> 그룹핑 - const filteredGroups = useMemo(() => { - // 1차: inbound_number 기준 그룹핑 - const allGroups: Record = {}; - for (const row of data) { - const key = row.inbound_number || "_no_inbound"; - if (!allGroups[key]) { - allGroups[key] = { master: row, details: [] }; - } - allGroups[key].details.push(row); - } - - // 마스터 필터 / 디테일 필터 분리 - const masterFilters: Record> = {}; - const detailFilters: Record> = {}; - 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]) => { - let raw = (group.master as any)?.[colKey] ?? ""; - // 입고유형은 코드→라벨 변환된 값으로 비교 - if (colKey === "inbound_type") raw = resolveInboundType(String(raw)); - return values.has(String(raw)); - }) - ); - } - - // 3차: 디테일 필터 적용 (행 단위) - if (Object.keys(detailFilters).length > 0) { - entries = entries - .map(([inboundNo, 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_table") cellVal = SOURCE_TABLE_LABEL[cellVal] || cellVal; - return values.has(cellVal); - }) - ); - return [inboundNo, { ...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]) => { - let av: any = (a.master as any)?.[key] ?? ""; - let bv: any = (b.master as any)?.[key] ?? ""; - if (key === "inbound_type") { av = resolveInboundType(String(av)); bv = resolveInboundType(String(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)); - }); - } else { - entries.forEach(([, group]) => { - group.details.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 Object.fromEntries(entries); - }, [data, headerFilters, sortState]); - - // 마스터 컬럼별 고유값 (마스터 헤더 필터용) - const masterUniqueValues = useMemo(() => { - const result: Record = {}; - const seenMasters = new Map(); - data.forEach((row) => { - if (row.inbound_number && !seenMasters.has(row.inbound_number)) { - seenMasters.set(row.inbound_number, row); - } - }); - const masters = Array.from(seenMasters.values()); - for (const col of [{ key: "inbound_number", label: "입고번호" }, ...MASTER_BODY_LAYOUT.map(({ key, label }) => ({ key, label }))]) { - const values = new Set(); - masters.forEach((m) => { - let val = (m as any)?.[col.key]; - if (val !== null && val !== undefined && val !== "") { - if (col.key === "inbound_type") val = resolveInboundType(String(val)); - values.add(String(val)); - } - }); - result[col.key] = Array.from(values).sort(); - } - return result; + // 플랫 행 생성 (입고유형 코드→라벨 변환, source_table→한글 등) + const flatRows = useMemo(() => { + return data.map((row) => ({ + ...row, + inbound_type: resolveInboundType(row.inbound_type), + source_type: row.source_table ? (SOURCE_TABLE_LABEL[row.source_table] || row.source_table) : (row as any).source_type || "", + })); }, [data]); - // 디테일 컬럼별 고유값 (디테일 서브헤더 필터용) + // 컬럼별 고유값 (헤더 필터용) const columnUniqueValues = useMemo(() => { const result: Record = {}; - for (const col of DETAIL_HEADER_COLS) { + for (const col of GRID_COLUMNS) { const values = new Set(); - data.forEach((row) => { - let val = (row as any)[col.key]; - if (val !== null && val !== undefined && val !== "") { - if (col.key === "source_table") val = SOURCE_TABLE_LABEL[val] || val; - values.add(String(val)); - } + 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; - }, [data]); + }, [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) => { @@ -633,6 +510,8 @@ export default function ReceivingPage() { const openRegisterModal = async () => { const defaultType = "구매입고"; + setEditMode(false); + setEditItemIds([]); setModalInboundType(defaultType); setModalInboundDate(new Date().toISOString().split("T")[0]); setModalWarehouse(""); @@ -661,6 +540,51 @@ export default function ReceivingPage() { } }; + // 수정 모달 열기 + const openEditModal = (row: InboundItem) => { + const inNo = row.inbound_number; + const grouped = data.filter((d) => d.inbound_number === inNo); + const first = grouped[0] || row; + + setEditMode(true); + setEditItemIds(grouped.map((g) => g.id)); + setModalInboundNo(inNo); + setModalInboundType(first.inbound_type || "구매입고"); + setModalInboundDate(first.inbound_date ? String(first.inbound_date).slice(0, 10) : ""); + setModalWarehouse(first.warehouse_code || ""); + setModalLocation(first.location_code || ""); + setModalInspector((first as any).inspector || ""); + setModalManager((first as any).manager || ""); + setModalMemo(first.memo || ""); + setSelectedItems( + grouped.map((g) => ({ + key: g.id, + inbound_type: g.inbound_type || "", + reference_number: g.reference_number || "", + supplier_code: (g as any).supplier_code || "", + supplier_name: g.supplier_name || "", + item_number: g.item_number || "", + item_name: g.item_name || "", + spec: g.spec || "", + material: (g as any).material || "", + unit: (g as any).unit || "", + inbound_qty: Number(g.inbound_qty) || 0, + unit_price: Number(g.unit_price) || 0, + total_amount: Number(g.total_amount) || 0, + source_table: (g as any).source_table || "", + source_id: (g as any).source_id || "", + })) + ); + setSourceKeyword(""); + setPurchaseOrders([]); + setShipments([]); + setItems([]); + setSourcePage(1); + setSourceTotalCount(0); + setIsModalOpen(true); + loadSourceData(first.inbound_type || "구매입고", undefined, 1); + }; + // 검색 버튼 클릭 시 const searchSourceData = useCallback(async () => { setSourcePage(1); @@ -811,41 +735,97 @@ export default function ReceivingPage() { } setSaving(true); try { - const res = await createReceiving({ - inbound_number: modalInboundNo, - inbound_date: modalInboundDate, - warehouse_code: modalWarehouse, - location_code: modalLocation || undefined, - inspector: modalInspector || undefined, - manager: modalManager || undefined, - memo: modalMemo || undefined, - items: selectedItems.map((item) => ({ - inbound_type: item.inbound_type, - reference_number: item.reference_number, - supplier_code: item.supplier_code, - supplier_name: item.supplier_name, - item_number: item.item_number, - item_name: item.item_name, - spec: item.spec, - material: item.material, - unit: item.unit, - inbound_qty: item.inbound_qty, - unit_price: item.unit_price, - total_amount: item.total_amount, - source_table: item.source_table, - source_id: item.source_id, - inbound_status: "입고완료", - inspection_status: "대기", - })), - }); + if (editMode) { + // 기존 item 수정 + 삭제된 item 삭제 + 새 item 추가 + const currentKeys = new Set(selectedItems.map((i) => i.key)); + const toDelete = editItemIds.filter((id) => !currentKeys.has(id)); + const toUpdate = selectedItems.filter((i) => editItemIds.includes(i.key)); + const toCreate = selectedItems.filter((i) => !editItemIds.includes(i.key)); - if (res.success) { - alert(res.message || "입고 등록 완료"); + await Promise.all([ + ...toDelete.map((id) => deleteReceiving(id)), + ...toUpdate.map((item) => + updateReceiving(item.key, { + inbound_date: modalInboundDate, + inbound_qty: item.inbound_qty, + unit_price: item.unit_price, + total_amount: item.total_amount, + warehouse_code: modalWarehouse || undefined, + location_code: modalLocation || undefined, + memo: modalMemo || undefined, + } as any) + ), + ...(toCreate.length > 0 + ? [createReceiving({ + inbound_number: modalInboundNo, + inbound_date: modalInboundDate, + warehouse_code: modalWarehouse, + location_code: modalLocation || undefined, + inspector: modalInspector || undefined, + manager: modalManager || undefined, + memo: modalMemo || undefined, + items: toCreate.map((item) => ({ + inbound_type: item.inbound_type, + reference_number: item.reference_number, + supplier_code: item.supplier_code, + supplier_name: item.supplier_name, + item_number: item.item_number, + item_name: item.item_name, + spec: item.spec, + material: item.material, + unit: item.unit, + inbound_qty: item.inbound_qty, + unit_price: item.unit_price, + total_amount: item.total_amount, + source_table: item.source_table, + source_id: item.source_id, + inbound_status: "입고완료", + inspection_status: "대기", + })), + })] + : []), + ]); + toast.success("입고 정보를 수정했어요"); setIsModalOpen(false); fetchList(); + } else { + const res = await createReceiving({ + inbound_number: modalInboundNo, + inbound_date: modalInboundDate, + warehouse_code: modalWarehouse, + location_code: modalLocation || undefined, + inspector: modalInspector || undefined, + manager: modalManager || undefined, + memo: modalMemo || undefined, + items: selectedItems.map((item) => ({ + inbound_type: item.inbound_type, + reference_number: item.reference_number, + supplier_code: item.supplier_code, + supplier_name: item.supplier_name, + item_number: item.item_number, + item_name: item.item_name, + spec: item.spec, + material: item.material, + unit: item.unit, + inbound_qty: item.inbound_qty, + unit_price: item.unit_price, + total_amount: item.total_amount, + source_table: item.source_table, + source_id: item.source_id, + inbound_status: "입고완료", + inspection_status: "대기", + })), + }); + + if (res.success) { + toast.success(res.message || "입고 등록 완료"); + setIsModalOpen(false); + fetchList(); + } } - } catch { - alert("입고 등록 중 오류가 발생했습니다."); + } catch (err: any) { + const msg = err?.response?.data?.message || (editMode ? "수정에 실패했어요" : "입고 등록 중 오류가 발생했습니다."); + toast.error(msg); } finally { setSaving(false); } @@ -897,13 +877,13 @@ export default function ReceivingPage() { } /> - {/* 입고 목록 테이블 (마스터-디테일 그룹) */} + {/* 입고 목록 테이블 (플랫 리스트) */}

입고 목록

- {Object.keys(filteredGroups).length}건 + {filteredRows.length}건
-
+
- - {visibleMasterLayout.map((col) => ( - - ))} + + + + + + + + + + + + + + { - const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id)); + const allFilteredIds = filteredRows.map((r) => r.id); const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id)); setCheckedIds(allChecked ? [] : allFilteredIds); }} > { - const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id)); + const allFilteredIds = filteredRows.map((r) => r.id); return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id)); })()} onCheckedChange={() => {}} /> - - {/* 입고번호 (별도 컬럼) */} - -
-
handleSort("inbound_number")}> - 입고번호 - {sortState?.key === "inbound_number" && ( - sortState.direction === "asc" - ? - : - )} -
- {(masterUniqueValues["inbound_number"] || []).length > 0 && ( - ()} - onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} - /> - )} -
-
- {/* 마스터 필드 헤더 (ts.visibleColumns 순서) */} - {visibleMasterLayout.map((col) => ( - -
-
handleSort(col.key)}> - {col.label} - {sortState?.key === col.key && ( - sortState.direction === "asc" - ? - : + {GRID_COLUMNS.map((col) => { + const isRight = ["inbound_qty", "unit_price", "total_amount"].includes(col.key); + return ( + +
+
handleSort(col.key)}> + {col.label} + {sortState?.key === col.key && ( + sortState.direction === "asc" + ? + : + )} +
+ {(columnUniqueValues[col.key] || []).length > 0 && ( + ()} + onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} + /> )}
- {(masterUniqueValues[col.key] || []).length > 0 && ( - ()} - onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} - /> - )} -
- - ))} + + ); + })} @@ -993,7 +963,7 @@ export default function ReceivingPage() { - ) : Object.keys(filteredGroups).length === 0 ? ( + ) : filteredRows.length === 0 ? (
@@ -1003,212 +973,59 @@ export default function ReceivingPage() { ) : ( - Object.entries(filteredGroups).map(([inboundNo, group]) => { - const isExpanded = expandedOrders.has(inboundNo); - 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; + filteredRows.map((row) => { + const isChecked = checkedIds.includes(row.id); return ( - - {/* 마스터 행 */} - { - if (expandedOrders.has(inboundNo)) { - setClosingOrders((prev) => new Set(prev).add(inboundNo)); - setTimeout(() => { - setExpandedOrders((prev) => { const next = new Set(prev); next.delete(inboundNo); return next; }); - setClosingOrders((prev) => { const next = new Set(prev); next.delete(inboundNo); return next; }); - }, 200); - } else { - setExpandedOrders((prev) => new Set(prev).add(inboundNo)); - } + { + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] + ); + }} + onDoubleClick={() => openEditModal(row as any)} + > + { + e.stopPropagation(); + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] + ); }} > - { - e.stopPropagation(); - setCheckedIds((prev) => { - if (allDetailChecked) return prev.filter((id) => !detailIds.includes(id)); - return [...new Set([...prev, ...detailIds])]; - }); - }} - > - {}} - /> - - - {isExpanded - ? - : - } - - {/* 입고번호 */} - - {inboundNo} - ({group.details.length}) - - {/* 마스터 필드 (ts.visibleColumns 순서) */} - {visibleMasterLayout.map((col) => { - switch (col.key) { - case "inbound_type": return ( - - - {resolveInboundType(master.inbound_type)} - - - ); - case "inbound_date": return ( - - {master.inbound_date ? new Date(master.inbound_date).toLocaleDateString("ko-KR") : "-"} - - ); - case "reference_number": return ( - - {master.reference_number || ""} - - ); - case "supplier_name": return ( - - {master.supplier_name || ""} - - ); - case "warehouse_name": return ( - - {master.warehouse_name || master.warehouse_code || ""} - - ); - case "inbound_status": return ( - - - {master.inbound_status || "-"} - - - ); - case "memo": return ( - - {master.memo || ""} - - ); - default: return {(master as any)[col.key] ?? ""}; - } - })} - - - {/* 디테일 서브 헤더 (펼쳤을 때만) */} - {isExpanded && ( - - - - - {visibleDetailCols.map((col) => { - const isRight = ["inbound_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_table") v = SOURCE_TABLE_LABEL[v] || v; - return v; - }).filter((v: any) => v != null && v !== "").map(String) - )).sort(); - const filterVals = headerFilters[col.key] || new Set(); - return ( - -
-
handleSort(col.key)} - > - {col.label} - {isSorted && ( - sortState!.direction === "asc" - ? - : - )} -
- {uniqueVals.length > 0 && ( - - )} -
-
- ); - })} -
- )} - - {/* 디테일 행 (펼쳤을 때만) */} - {isExpanded && group.details.map((row, detailIdx) => { - const isClosing = closingOrders.has(inboundNo); - const isChecked = checkedIds.includes(row.id); - return ( - { - setCheckedIds((prev) => - prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] - ); - }} - > - { - e.stopPropagation(); - setCheckedIds((prev) => - prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] - ); - }} - > - {}} /> - - -
- - - {visibleDetailCols.map((col) => { - switch (col.key) { - case "source_table": return {row.source_table ? SOURCE_TABLE_LABEL[row.source_table] || row.source_table : "-"}; - case "item_number": return {row.item_number || ""}; - case "item_name": return {row.item_name || ""}; - case "spec": return {row.spec || ""}; - case "inbound_qty": return {row.inbound_qty ? Number(row.inbound_qty).toLocaleString() : ""}; - case "unit_price": return {row.unit_price ? Number(row.unit_price).toLocaleString() : ""}; - case "total_amount": return {row.total_amount ? Number(row.total_amount).toLocaleString() : ""}; - default: return {(row as any)[col.key] ?? ""}; - } - })} - - ); - })} - + {}} /> + + {row.inbound_number || ""} + + + {row.inbound_type || "-"} + + + + {row.inbound_date ? new Date(row.inbound_date).toLocaleDateString("ko-KR") : ""} + + {row.reference_number || ""} + {row.source_type || ""} + {row.supplier_name || ""} + {row.item_number || ""} + {row.item_name || ""} + {row.spec || ""} + {row.inbound_qty ? Number(row.inbound_qty).toLocaleString() : ""} + {row.unit_price ? Number(row.unit_price).toLocaleString() : ""} + {row.total_amount ? Number(row.total_amount).toLocaleString() : ""} + {row.warehouse_name || (row as any).warehouse_code || ""} + + + {row.inbound_status || "-"} + + + {row.remark || row.memo || ""} + ); }) )} @@ -1221,9 +1038,9 @@ export default function ReceivingPage() { - 입고 등록 + {editMode ? "입고 수정" : "입고 등록"} - 입고유형을 선택하고, 좌측에서 근거 데이터를 검색하여 추가해 주세요. + {editMode ? "입고 정보를 수정해 주세요." : "입고유형을 선택하고, 좌측에서 근거 데이터를 검색하여 추가해 주세요."}
diff --git a/frontend/app/(main)/COMPANY_9/purchase/order/page.tsx b/frontend/app/(main)/COMPANY_9/purchase/order/page.tsx index 143a84a9..90116bac 100644 --- a/frontend/app/(main)/COMPANY_9/purchase/order/page.tsx +++ b/frontend/app/(main)/COMPANY_9/purchase/order/page.tsx @@ -13,7 +13,7 @@ import { Badge } from "@/components/ui/badge"; import { Label } from "@/components/ui/label"; import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, - ClipboardList, Pencil, Search, X, Package, ChevronDown, + ClipboardList, Pencil, Search, X, Package, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Settings2, GripVertical, } from "lucide-react"; @@ -162,7 +162,6 @@ export default function PurchaseOrderPage() { const [excelUploadOpen, setExcelUploadOpen] = useState(false); const [categoryOptions, setCategoryOptions] = useState>({}); const [checkedIds, setCheckedIds] = useState([]); - const [expandedOrders, setExpandedOrders] = useState>(new Set()); // 테이블 설정 const ts = useTableSettings("c16-purchase-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG); @@ -372,20 +371,6 @@ export default function PurchaseOrderPage() { useEffect(() => { fetchOrders(); }, [fetchOrders]); // purchase_no 기준 그룹핑 - const orderGroups = useMemo(() => { - const map: Record = {}; - for (const row of orders) { - const key = row.purchase_no || row.id; - if (!map[key]) { - map[key] = { - master: { purchase_no: row.purchase_no, order_date: row.order_date, supplier_name: row.supplier_name, status: row.status, memo: row.memo }, - details: [], - }; - } - map[key].details.push(row); - } - return map; - }, [orders]); const getCategoryLabel = (col: string, code: string) => { if (!code) return ""; @@ -811,125 +796,83 @@ export default function PurchaseOrderPage() {
- {/* 데이터 테이블 — 아코디언 그룹핑 */} + {/* 데이터 테이블 — 플랫 리스트 */}
{loading ? (
- ) : Object.keys(orderGroups).length === 0 ? ( + ) : orders.length === 0 ? (

등록된 발주가 없어요

) : ( (() => { - const MASTER_KEYS = new Set(["purchase_no", "order_date", "supplier_name", "status", "memo"]); const numCols = new Set(["order_qty", "received_qty", "remain_qty", "unit_price", "amount"]); - - // ts.visibleColumns 순서를 따르되, 마스터/디테일 컬럼을 분리 - // 고정 컬럼(품목수)은 마스터 선행 컬럼 뒤에 배치 - const leadingMaster: typeof ts.visibleColumns = []; - const detailCols: typeof ts.visibleColumns = []; - const trailingMaster: typeof ts.visibleColumns = []; - let passedFirstDetail = false; - for (const col of ts.visibleColumns) { - if (MASTER_KEYS.has(col.key)) { - if (passedFirstDetail) trailingMaster.push(col); - else leadingMaster.push(col); - } else { - passedFirstDetail = true; - detailCols.push(col); - } - } - - const renderDetailCell = (row: any, key: string) => { + const renderCell = (row: any, key: string) => { const val = row[key]; if (key === "status") return val ? {val} : "-"; - if (numCols.has(key)) return {val ? Number(val).toLocaleString() : "0"}; - return val || "-"; + if (key === "order_date" || key === "due_date") return val ? new Date(val).toLocaleDateString("ko-KR") : "-"; + if (numCols.has(key)) return {val ? Number(val).toLocaleString() : ""}; + return val || ""; }; - - const renderMasterHead = (col: { key: string; label: string }) => ( - - {col.label} - - ); - - const renderMasterCell = (col: { key: string }, m: any, purchaseNo: string) => { - if (col.key === "purchase_no") return {purchaseNo}; - if (col.key === "order_date") return {m.order_date ? new Date(m.order_date).toLocaleDateString("ko-KR") : "-"}; - if (col.key === "supplier_name") return {m.supplier_name || "-"}; - if (col.key === "status") return {m.status && {m.status}}; - if (col.key === "memo") return {m.memo || ""}; - return ; - }; - return (
- - - {leadingMaster.map(renderMasterHead)} - 품목수 - {detailCols.map(col => ( - - {col.label}{col.key === "order_qty" || col.key === "amount" ? " 합계" : ""} + { + const allIds = orders.map((r) => r.id); + const allChecked = allIds.length > 0 && allIds.every((id) => checkedIds.includes(id)); + setCheckedIds(allChecked ? [] : allIds); + }} + > + 0 && orders.every((r) => checkedIds.includes(r.id))} + onCheckedChange={() => {}} + /> + + {ts.visibleColumns.map((col) => ( + + {col.label} ))} - {trailingMaster.map(renderMasterHead)} - {Object.entries(orderGroups).map(([purchaseNo, group]) => { - const isExpanded = expandedOrders.has(purchaseNo); - const detailIds = group.details.map(d => d.id); - const allChecked = detailIds.length > 0 && detailIds.every(id => checkedIds.includes(id)); - const someChecked = detailIds.some(id => checkedIds.includes(id)); - const m = group.master; - const totalQty = group.details.reduce((s, d) => s + (Number(d.order_qty) || 0), 0); - const totalAmt = group.details.reduce((s, d) => s + (Number(d.amount) || 0), 0); + {orders.map((row) => { + const isChecked = checkedIds.includes(row.id); return ( - - setExpandedOrders(prev => { const next = new Set(prev); if (next.has(purchaseNo)) next.delete(purchaseNo); else next.add(purchaseNo); return next; })} - onDoubleClick={() => openEditModal(purchaseNo)} + { + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] + ); + }} + onDoubleClick={() => openEditModal(row.purchase_no)} + > + { + e.stopPropagation(); + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] + ); + }} > - - + {}} /> + + {ts.visibleColumns.map((col) => ( + + {renderCell(row, col.key)} - { e.stopPropagation(); setCheckedIds(prev => allChecked ? prev.filter(id => !detailIds.includes(id)) : [...new Set([...prev, ...detailIds])]); }}> - {}} /> - - {leadingMaster.map(col => renderMasterCell(col, m, purchaseNo))} - {group.details.length}건 - {detailCols.map(col => ( - - {col.key === "order_qty" ? totalQty.toLocaleString() - : col.key === "amount" ? totalAmt.toLocaleString() - : ""} - - ))} - {trailingMaster.map(col => renderMasterCell(col, m, purchaseNo))} - - {isExpanded && group.details.map((row) => ( - - - { e.stopPropagation(); setCheckedIds(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id]); }}> - {}} /> - - {leadingMaster.map(col => )} - - {detailCols.map(col => ( - - {renderDetailCell(row, col.key)} - - ))} - {trailingMaster.map(col => )} - ))} - + ); })} @@ -938,7 +881,7 @@ export default function PurchaseOrderPage() { })() )}
- 전체 {Object.keys(orderGroups).length}건 (발주 기준) / {orders.length}건 (품목 기준) + 전체 {orders.length}건
diff --git a/frontend/app/(main)/COMPANY_9/purchase/purchase-item/page.tsx b/frontend/app/(main)/COMPANY_9/purchase/purchase-item/page.tsx index ce6e8198..31ca38f4 100644 --- a/frontend/app/(main)/COMPANY_9/purchase/purchase-item/page.tsx +++ b/frontend/app/(main)/COMPANY_9/purchase/purchase-item/page.tsx @@ -142,6 +142,10 @@ const FORM_FIELDS = [ { key: "division", label: "관리품목", type: "multi-category" }, { key: "type", label: "품목구분", type: "category" }, { key: "size", label: "규격", type: "text" }, + { key: "width", label: "가로", type: "text", placeholder: "숫자 입력 (mm)" }, + { key: "height", label: "세로", type: "text", placeholder: "숫자 입력 (mm)" }, + { key: "thickness", label: "두께", type: "text", placeholder: "숫자 입력 (mm)" }, + { key: "area", label: "면적", type: "text", placeholder: "숫자 입력 (㎡)" }, { key: "unit", label: "단위", type: "category" }, { key: "material", label: "재질", type: "category" }, { key: "status", label: "상태", type: "category" }, @@ -175,6 +179,10 @@ const ITEM_GRID_COLUMNS = [ { key: "item_number", label: "품번" }, { key: "item_name", label: "품명" }, { key: "size", label: "규격" }, + { key: "width", label: "가로" }, + { key: "height", label: "세로" }, + { key: "thickness", label: "두께" }, + { key: "area", label: "면적" }, { key: "unit", label: "단위" }, { key: "standard_price", label: "기준단가/구매단가" }, { key: "currency_code", label: "통화" }, @@ -1605,9 +1613,25 @@ export default function PurchaseItemPage() { ) : ( setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))} - placeholder={"placeholder" in field ? field.placeholder : field.label} - className="h-9" + readOnly={field.key === "area"} + onChange={(e) => { + if (field.key === "area") return; + const v = e.target.value; + setFormData((prev) => { + const next = { ...prev, [field.key]: v }; + // 가로/세로 변경 시 면적(㎡) 자동 계산: (가로mm × 세로mm) / 1,000,000 + if (field.key === "width" || field.key === "height") { + const w = Number(field.key === "width" ? v : prev.width); + const h = Number(field.key === "height" ? v : prev.height); + if (!isNaN(w) && !isNaN(h) && w > 0 && h > 0) { + next.area = ((w * h) / 1_000_000).toFixed(4); + } + } + return next; + }); + }} + placeholder={field.key === "area" ? "자동 계산" : ("placeholder" in field ? field.placeholder : field.label)} + className={cn("h-9", field.key === "area" && "bg-muted cursor-not-allowed")} /> )}