From cf9f53e4c5a0ae2e19570b87dc70496616b2a66d Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 6 Apr 2026 15:50:33 +0900 Subject: [PATCH] feat: Enhance process and work standard management functionalities - Updated the processInfoController to allow for flexible use_yn filtering, supporting both "Y" and "N" values along with their corresponding "USE_Y" and "USE_N" mappings. - Modified the processWorkStandardController to include selected_bom_items in work item details, enabling better management of BOM data. - Improved the productionPlanService to handle order summaries more effectively, incorporating legacy and detail data through a unified query structure. - Enhanced the receiving and outbound pages with new filtering and grouping functionalities, improving user experience and data handling. These changes aim to streamline process management and improve overall functionality across various modules. --- .../src/controllers/processInfoController.ts | 11 +- .../processWorkStandardController.ts | 33 +- .../src/services/productionPlanService.ts | 75 ++- .../COMPANY_16/logistics/outbound/page.tsx | 595 +++++++++++++++-- .../COMPANY_16/logistics/receiving/page.tsx | 616 ++++++++++++++++-- .../COMPANY_16/master-data/item-info/page.tsx | 2 + .../(main)/COMPANY_16/production/bom/page.tsx | 110 ++-- .../production/plan-management/page.tsx | 8 +- .../components/DetailFormModal.tsx | 19 +- .../v2-process-work-standard/types.ts | 1 + 10 files changed, 1293 insertions(+), 177 deletions(-) diff --git a/backend-node/src/controllers/processInfoController.ts b/backend-node/src/controllers/processInfoController.ts index 025d6d66..f3213773 100644 --- a/backend-node/src/controllers/processInfoController.ts +++ b/backend-node/src/controllers/processInfoController.ts @@ -40,8 +40,15 @@ export async function getProcessList(req: AuthenticatedRequest, res: Response) { params.push(processType); } if (useYn) { - conditions.push(`use_yn = $${idx++}`); - params.push(useYn); + // "Y" → "USE_Y"도 매칭, "N" → "USE_N"도 매칭 + const useYnValue = String(useYn); + if (useYnValue === "Y" || useYnValue === "N") { + conditions.push(`use_yn IN ($${idx++}, $${idx++})`); + params.push(useYnValue, `USE_${useYnValue}`); + } else { + conditions.push(`use_yn = $${idx++}`); + params.push(useYnValue); + } } const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; diff --git a/backend-node/src/controllers/processWorkStandardController.ts b/backend-node/src/controllers/processWorkStandardController.ts index c3eeb736..d745bab4 100644 --- a/backend-node/src/controllers/processWorkStandardController.ts +++ b/backend-node/src/controllers/processWorkStandardController.ts @@ -463,7 +463,7 @@ export async function getWorkItemDetails(req: AuthenticatedRequest, res: Respons SELECT id, work_item_id, detail_type, content, is_required, sort_order, remark, inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, - created_date + selected_bom_items, created_date FROM process_work_item_detail WHERE work_item_id = $1 AND company_code = $2 ORDER BY sort_order, created_date @@ -492,6 +492,7 @@ export async function createWorkItemDetail(req: AuthenticatedRequest, res: Respo work_item_id, detail_type, content, is_required, sort_order, remark, inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, + selected_bom_items, } = req.body; if (!work_item_id || !content) { @@ -514,11 +515,14 @@ export async function createWorkItemDetail(req: AuthenticatedRequest, res: Respo INSERT INTO process_work_item_detail (company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer, inspection_code, inspection_method, unit, lower_limit, upper_limit, - duration_minutes, input_type, lookup_target, display_fields) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + duration_minutes, input_type, lookup_target, display_fields, selected_bom_items) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) RETURNING * `; + // selected_bom_items: 배열이면 JSON 문자열로 변환 + const bomItemsJson = Array.isArray(selected_bom_items) ? JSON.stringify(selected_bom_items) : selected_bom_items || null; + const result = await getPool().query(query, [ companyCode, work_item_id, @@ -537,6 +541,7 @@ export async function createWorkItemDetail(req: AuthenticatedRequest, res: Respo input_type || null, lookup_target || null, display_fields || null, + bomItemsJson, ]); logger.info("작업 항목 상세 생성", { companyCode, id: result.rows[0].id }); @@ -562,8 +567,11 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo detail_type, content, is_required, sort_order, remark, inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, + selected_bom_items, } = req.body; + const bomItemsJson = Array.isArray(selected_bom_items) ? JSON.stringify(selected_bom_items) : selected_bom_items ?? null; + const query = ` UPDATE process_work_item_detail SET detail_type = COALESCE($1, detail_type), @@ -580,6 +588,7 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo input_type = $14, lookup_target = $15, display_fields = $16, + selected_bom_items = $17, updated_date = NOW() WHERE id = $6 AND company_code = $7 RETURNING * @@ -602,6 +611,7 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo input_type || null, lookup_target || null, display_fields || null, + bomItemsJson, ]); if (result.rowCount === 0) { @@ -889,7 +899,22 @@ export async function registerItemsBatch(req: AuthenticatedRequest, res: Respons RETURNING *`, [screenCode, item.itemId, item.itemCode || null, companyCode, req.user?.userId || null] ); - if (result.rows[0]) inserted.push(result.rows[0]); + if (result.rows[0]) { + inserted.push(result.rows[0]); + // 기본 라우팅 버전이 없으면 자동 생성 + const itemCode = item.itemCode || item.itemId; + const existingVersion = await client.query( + `SELECT id FROM item_routing_version WHERE item_code = $1 AND company_code = $2 LIMIT 1`, + [itemCode, companyCode] + ); + if (existingVersion.rowCount === 0) { + await client.query( + `INSERT INTO item_routing_version (id, company_code, item_code, version_name, description, is_default, writer) + VALUES (gen_random_uuid()::text, $1, $2, '기본', '자동 생성된 기본 라우팅', true, $3)`, + [companyCode, itemCode, req.user?.userId || null] + ); + } + } } await client.query("COMMIT"); diff --git a/backend-node/src/services/productionPlanService.ts b/backend-node/src/services/productionPlanService.ts index adeef0ea..705033cc 100644 --- a/backend-node/src/services/productionPlanService.ts +++ b/backend-node/src/services/productionPlanService.ts @@ -63,18 +63,52 @@ export async function getOrderSummary( ),`; const query = ` - WITH order_summary AS ( + WITH all_orders AS ( + -- 레거시: sales_order_mng에 part_code가 직접 있는 경우 SELECT - so.part_code AS item_code, - COALESCE(so.part_name, so.part_code) AS item_name, - SUM(COALESCE(so.order_qty::numeric, 0)) AS total_order_qty, - SUM(COALESCE(so.ship_qty::numeric, 0)) AS total_ship_qty, - SUM(COALESCE(so.balance_qty::numeric, 0)) AS total_balance_qty, - COUNT(*) AS order_count, - MIN(so.due_date) AS earliest_due_date + so.part_code, + so.part_name, + so.company_code, + COALESCE(so.order_qty::numeric, 0) AS order_qty, + COALESCE(so.ship_qty::numeric, 0) AS ship_qty, + COALESCE(so.balance_qty::numeric, 0) AS balance_qty, + so.due_date FROM sales_order_mng so WHERE ${whereClause} - GROUP BY so.part_code, so.part_name + AND so.part_code IS NOT NULL AND so.part_code != '' + AND NOT EXISTS ( + SELECT 1 FROM sales_order_detail sd + WHERE sd.order_no = so.order_no AND sd.company_code = so.company_code + ) + + UNION ALL + + -- 마스터-디테일: sales_order_detail에 품목이 있는 경우 + SELECT + sd.part_code, + sd.part_name, + sd.company_code, + COALESCE(sd.qty::numeric, 0) AS order_qty, + COALESCE(sd.ship_qty::numeric, 0) AS ship_qty, + COALESCE(sd.balance_qty::numeric, sd.qty::numeric - COALESCE(sd.ship_qty::numeric, 0), 0) AS balance_qty, + sd.due_date::date + FROM sales_order_detail sd + INNER JOIN sales_order_mng so ON sd.order_no = so.order_no AND sd.company_code = so.company_code + WHERE sd.company_code = $1 + AND sd.part_code IS NOT NULL AND sd.part_code != '' + ), + order_summary AS ( + SELECT + ao.part_code AS item_code, + COALESCE(NULLIF(ao.part_name, ''), ii.item_name, ao.part_code) AS item_name, + SUM(ao.order_qty) AS total_order_qty, + SUM(ao.ship_qty) AS total_ship_qty, + SUM(ao.balance_qty) AS total_balance_qty, + COUNT(*) AS order_count, + MIN(ao.due_date) AS earliest_due_date + FROM all_orders ao + LEFT JOIN item_info ii ON ao.part_code = ii.item_number AND ao.company_code = ii.company_code + GROUP BY ao.part_code, COALESCE(NULLIF(ao.part_name, ''), ii.item_name, ao.part_code) ), ${itemLeadTimeCte} stock_info AS ( @@ -125,17 +159,34 @@ export async function getOrderSummary( const result = await pool.query(query, params); - // 그룹별 상세 수주 데이터도 함께 조회 + // 그룹별 상세 수주 데이터도 함께 조회 (레거시 + 디테일 UNION) const detailWhere = conditions.map(c => c.replace(/so\./g, "")).join(" AND "); const detailQuery = ` - SELECT - id, order_no, part_code, part_name, + SELECT id::text, order_no, part_code, part_name, COALESCE(order_qty::numeric, 0) AS order_qty, COALESCE(ship_qty::numeric, 0) AS ship_qty, COALESCE(balance_qty::numeric, 0) AS balance_qty, due_date, status, partner_id, manager_name FROM sales_order_mng WHERE ${detailWhere} + AND part_code IS NOT NULL AND part_code != '' + AND NOT EXISTS ( + SELECT 1 FROM sales_order_detail sd + WHERE sd.order_no = sales_order_mng.order_no AND sd.company_code = sales_order_mng.company_code + ) + + UNION ALL + + SELECT sd.id::text, sd.order_no, sd.part_code, sd.part_name, + COALESCE(sd.qty::numeric, 0) AS order_qty, + COALESCE(sd.ship_qty::numeric, 0) AS ship_qty, + COALESCE(sd.balance_qty::numeric, COALESCE(sd.qty::numeric, 0) - COALESCE(sd.ship_qty::numeric, 0), 0) AS balance_qty, + sd.due_date::date, so.status, so.partner_id, so.manager_name + FROM sales_order_detail sd + INNER JOIN sales_order_mng so ON sd.order_no = so.order_no AND sd.company_code = so.company_code + WHERE sd.company_code = $1 + AND sd.part_code IS NOT NULL AND sd.part_code != '' + ORDER BY part_code, due_date; `; const detailResult = await pool.query(detailQuery, params); diff --git a/frontend/app/(main)/COMPANY_16/logistics/outbound/page.tsx b/frontend/app/(main)/COMPANY_16/logistics/outbound/page.tsx index 49e3f6cf..5ab46a8a 100644 --- a/frontend/app/(main)/COMPANY_16/logistics/outbound/page.tsx +++ b/frontend/app/(main)/COMPANY_16/logistics/outbound/page.tsx @@ -37,17 +37,22 @@ import { Save, ChevronRight, ChevronLeft, + ChevronDown, ChevronsLeft, ChevronsRight, Inbox, Settings2, + Filter, + Check, + ArrowUp, + ArrowDown, } from "lucide-react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; -import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; // API: /outbound/* import { getOutboundList, @@ -110,6 +115,118 @@ 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)]); + +// 총 컬럼 수: 체크박스(1) + 화살표(1) + 출고번호(1) + 마스터필드(7) = 10 +const TOTAL_COLS = 10; + +// 헤더 필터 Popover +function HeaderFilterPopover({ + colKey, colLabel, uniqueValues, filterValues, onToggle, onClear, +}: { + colKey: string; + colLabel: string; + uniqueValues: string[]; + filterValues: Set; + onToggle: (colKey: string, value: string) => void; + onClear: (colKey: string) => void; +}) { + const [filterSearch, setFilterSearch] = useState(""); + const hasFilter = filterValues.size > 0; + const filteredValues = uniqueValues.filter( + (v) => !filterSearch || v.toLowerCase().includes(filterSearch.toLowerCase()) + ); + + return ( + + + + + e.stopPropagation()}> +
+
+ 필터: {colLabel} + {hasFilter && ( + + )} +
+
+ + setFilterSearch(e.target.value)} + placeholder="검색..." + className="h-7 text-xs pl-7" + /> +
+
+ {filteredValues.slice(0, 100).map((val) => { + const isSelected = filterValues.has(val); + return ( +
onToggle(colKey, val)} + > +
+ {isSelected && } +
+ {val || "(빈 값)"} +
+ ); + })} + {filteredValues.length > 100 && ( +
+ ...외 {filteredValues.length - 100}개 +
+ )} +
+
+
+
+ ); +} + // 선택된 소스 아이템 (등록 모달에서 사용) interface SelectedSourceItem { key: string; @@ -139,6 +256,12 @@ 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); + // 등록 모달 const [isModalOpen, setIsModalOpen] = useState(false); const [modalOutboundType, setModalOutboundType] = useState("판매출고"); @@ -210,14 +333,146 @@ export default function OutboundPage() { })(); }, []); - // 체크박스 - const allChecked = data.length > 0 && checkedIds.length === data.length; - const toggleCheckAll = () => { - setCheckedIds(allChecked ? [] : data.map((d) => d.id)); + // --- 마스터-디테일 그룹핑, 필터, 정렬 --- + + // 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 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 }))]) { + const values = new Set(); + masters.forEach((m) => { + const val = (m as any)?.[col.key]; + if (val !== null && val !== undefined && val !== "") values.add(String(val)); + }); + result[col.key] = Array.from(values).sort(); + } + return result; + }, [data]); + + // 디테일 컬럼별 고유값 (디테일 서브헤더 필터용) + const columnUniqueValues = useMemo(() => { + const result: Record = {}; + 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)); + } + }); + result[col.key] = Array.from(values).sort(); + } + return result; + }, [data]); + + // 헤더 필터 토글/초기화 + const toggleHeaderFilter = (colKey: string, value: string) => { + setHeaderFilters((prev) => { + const next = { ...prev }; + const set = new Set(next[colKey] || []); + if (set.has(value)) set.delete(value); else set.add(value); + if (set.size === 0) delete next[colKey]; else next[colKey] = set; + return next; + }); }; - const toggleCheck = (id: string) => { - setCheckedIds((prev) => - prev.includes(id) ? prev.filter((v) => v !== id) : [...prev, id] + + const clearHeaderFilter = (colKey: string) => { + setHeaderFilters((prev) => { + const next = { ...prev }; + delete next[colKey]; + return next; + }); + }; + + const handleSort = (key: string) => { + setSortState((prev) => + prev?.key === key + ? prev.direction === "asc" ? { key, direction: "desc" } : null + : { key, direction: "asc" } ); }; @@ -316,7 +571,7 @@ export default function OutboundPage() { unit_price: Number(g.unit_price) || 0, total_amount: Number(g.total_amount) || 0, source_type: g.source_type || "", - source_id: g.source_id || "", + source_id: (g as any).source_id || "", })) ); setSourceKeyword(""); @@ -644,40 +899,294 @@ export default function OutboundPage() { - ( - {v || "-"} - )}, - { key: "outbound_date", label: "출고일", width: "w-[100px]", render: (v) => v ? new Date(v).toLocaleDateString("ko-KR") : "-" }, - { key: "reference_number", label: "참조번호", width: "w-[120px]" }, - { key: "source_type", label: "데이터출처", width: "w-[80px]", render: (v) => v ? SOURCE_TYPE_LABEL[v] || v : "-" }, - { key: "customer_name", label: "거래처", width: "w-[120px]" }, - { key: "item_code", label: "품목코드", width: "w-[100px]" }, - { key: "item_name", label: "품목명", minWidth: "min-w-[150px]" }, - { key: "specification", label: "규격", width: "w-[80px]" }, - { key: "outbound_qty", label: "출고수량", width: "w-[80px]", align: "right", formatNumber: true }, - { key: "unit_price", label: "단가", width: "w-[90px]", align: "right", formatNumber: true }, - { key: "total_amount", label: "금액", width: "w-[100px]", align: "right", formatNumber: true }, - { key: "warehouse_name", label: "창고", width: "w-[100px]", render: (_v, row) => row.warehouse_name || row.warehouse_code || "-" }, - { key: "outbound_status", label: "출고상태", width: "w-[90px]", align: "center", render: (v) => ( - {v || "-"} - )}, - { key: "memo", label: "비고", width: "w-[100px]" }, - ] as EDataTableColumn[]} - data={ts.groupData(data)} - rowKey={(row) => row.id} - loading={loading} - emptyMessage="등록된 출고 내역이 없어요" - showCheckbox - checkedIds={checkedIds} - onCheckedChange={setCheckedIds} - onRowDoubleClick={(row) => openEditModal(row)} - showPagination - draggableColumns - columnOrderKey="c16-outbound" - /> +
+ + + + + { + const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id)); + const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id)); + setCheckedIds(allChecked ? [] : allFilteredIds); + }} + > + { + const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.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} + /> + )} +
+
+ {/* 마스터 필드 헤더 */} + {MASTER_BODY_LAYOUT.map((col) => ( + +
+
handleSort(col.key)}> + {col.label} + {sortState?.key === col.key && ( + sortState.direction === "asc" + ? + : + )} +
+ {(masterUniqueValues[col.key] || []).length > 0 && ( + ()} + onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} + /> + )} +
+
+ ))} +
+
+ + {loading ? ( + + + + + + ) : Object.keys(filteredGroups).length === 0 ? ( + + +
+ + 등록된 출고 내역이 없어요 +
+
+
+ ) : ( + Object.entries(filteredGroups).map(([outboundNo, group]) => { + const isExpanded = expandedOrders.has(outboundNo); + const detailIds = group.details.map((d) => d.id); + const allDetailChecked = detailIds.length > 0 && detailIds.every((id) => checkedIds.includes(id)); + const someDetailChecked = detailIds.some((id) => checkedIds.includes(id)); + const master = group.master; + return ( + + {/* 마스터 행 */} + { + 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}) + + {/* 출고유형 */} + + + {master.outbound_type || "-"} + + + {/* 출고일 */} + + {master.outbound_date ? new Date(master.outbound_date).toLocaleDateString("ko-KR") : "-"} + + {/* 참조번호 */} + + {master.reference_number || ""} + + {/* 거래처 */} + + {master.customer_name || ""} + + {/* 창고 */} + + {master.warehouse_name || master.warehouse_code || ""} + + {/* 출고상태 */} + + + {master.outbound_status || "-"} + + + {/* 비고 */} + + {master.memo || ""} + + + + {/* 디테일 서브 헤더 (펼쳤을 때만) */} + {isExpanded && ( + + + + + {DETAIL_HEADER_COLS.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 && ( + + )} +
+
+ ); + })} +
+ )} + + {/* 디테일 행 (펼쳤을 때만) */} + {isExpanded && group.details.map((row, detailIdx) => { + 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] + ); + }} + > + {}} /> + + +
+ + + {/* 출처 */} + {row.source_type ? (SOURCE_TYPE_LABEL[row.source_type] || row.source_type) : "-"} + {/* 품목코드 */} + {row.item_code || ""} + {/* 품목명 */} + {row.item_name || ""} + {/* 규격 */} + {row.specification || ""} + {/* 출고수량 */} + {row.outbound_qty ? Number(row.outbound_qty).toLocaleString() : ""} + {/* 단가 */} + {row.unit_price ? Number(row.unit_price).toLocaleString() : ""} + {/* 금액 */} + {row.total_amount ? Number(row.total_amount).toLocaleString() : ""} + + ); + })} + + ); + }) + )} + +
+
{/* 출고 등록 모달 */} diff --git a/frontend/app/(main)/COMPANY_16/logistics/receiving/page.tsx b/frontend/app/(main)/COMPANY_16/logistics/receiving/page.tsx index 42590754..a8d5fc2c 100644 --- a/frontend/app/(main)/COMPANY_16/logistics/receiving/page.tsx +++ b/frontend/app/(main)/COMPANY_16/logistics/receiving/page.tsx @@ -43,17 +43,23 @@ import { X, Save, ChevronRight, + ChevronDown, ChevronLeft, ChevronsLeft, ChevronsRight, Settings2, + Filter, + Check, + ArrowUp, + ArrowDown, } from "lucide-react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; -import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; +// EDataTable 제거 — 마스터-디테일 그룹 테이블로 교체 // API: /receiving/* import { getReceivingList, @@ -89,13 +95,137 @@ 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: "금액" }, +]; + +// 총 컬럼 수: 체크박스(1) + 화살표(1) + 입고번호(1) + 디테일(7) = 10 +const TOTAL_COLS = 10; + +// 마스터 필드 키 목록 (필터 분류용) +const MASTER_KEYS = new Set(["inbound_number", ...MASTER_BODY_LAYOUT.map((c) => c.key)]); + +// 헤더 필터 Popover +function HeaderFilterPopover({ + colKey, colLabel, uniqueValues, filterValues, onToggle, onClear, +}: { + colKey: string; + colLabel: string; + uniqueValues: string[]; + filterValues: Set; + onToggle: (colKey: string, value: string) => void; + onClear: (colKey: string) => void; +}) { + const [filterSearch, setFilterSearch] = useState(""); + const hasFilter = filterValues.size > 0; + const filteredValues = uniqueValues.filter( + (v) => !filterSearch || v.toLowerCase().includes(filterSearch.toLowerCase()) + ); + + return ( + + + + + e.stopPropagation()}> +
+
+ 필터: {colLabel} + {hasFilter && ( + + )} +
+
+ + setFilterSearch(e.target.value)} + placeholder="검색..." + className="h-7 text-xs pl-7" + /> +
+
+ {filteredValues.slice(0, 100).map((val) => { + const isSelected = filterValues.has(val); + return ( +
onToggle(colKey, val)} + > +
+ {isSelected && } +
+ {val || "(빈 값)"} +
+ ); + })} + {filteredValues.length > 100 && ( +
+ ...외 {filteredValues.length - 100}개 +
+ )} +
+
+
+
+ ); +} + // 입고유형 옵션 const INBOUND_TYPES = [ { value: "구매입고", label: "구매입고" }, + { value: "외주입고", label: "외주입고" }, + { value: "사급자재입고", label: "사급자재입고" }, { value: "반품입고", label: "반품입고" }, { value: "기타입고", label: "기타입고" }, ]; +// 입고유형 카테고리 코드→라벨 매핑 +const INBOUND_TYPE_CODE_MAP: Record = { + CAT_MLYTB8ON_A3AU: "구매입고", + CAT_MLYTBMH6_9AB7: "외주입고", + CAT_MLYTBSLW_5N81: "사급자재입고", + CAT_MLYTBGEV_N23U: "반품입고", + CAT_MLYTBYLU_0Z5T: "기타입고", +}; +const resolveInboundType = (v: string) => INBOUND_TYPE_CODE_MAP[v] || v || "-"; + const INBOUND_STATUS_OPTIONS = [ { value: "대기", label: "대기" }, { value: "입고완료", label: "입고완료" }, @@ -156,6 +286,12 @@ 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); + // 등록 모달 const [isModalOpen, setIsModalOpen] = useState(false); const [modalInboundType, setModalInboundType] = useState("구매입고"); @@ -237,14 +373,149 @@ export default function ReceivingPage() { })(); }, []); - // 체크박스 - const allChecked = data.length > 0 && checkedIds.length === data.length; - const toggleCheckAll = () => { - setCheckedIds(allChecked ? [] : data.map((d) => d.id)); + // 필터 + 정렬 적용된 데이터 -> 그룹핑 + 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; + }, [data]); + + // 디테일 컬럼별 고유값 (디테일 서브헤더 필터용) + 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_table") val = SOURCE_TABLE_LABEL[val] || val; + values.add(String(val)); + } + }); + result[col.key] = Array.from(values).sort(); + } + return result; + }, [data]); + + // 헤더 필터 토글/초기화 + const toggleHeaderFilter = (colKey: string, value: string) => { + setHeaderFilters((prev) => { + const next = { ...prev }; + const set = new Set(next[colKey] || []); + if (set.has(value)) set.delete(value); else set.add(value); + if (set.size === 0) delete next[colKey]; else next[colKey] = set; + return next; + }); }; - const toggleCheck = (id: string) => { - setCheckedIds((prev) => - prev.includes(id) ? prev.filter((v) => v !== id) : [...prev, id] + + const clearHeaderFilter = (colKey: string) => { + setHeaderFilters((prev) => { + const next = { ...prev }; + delete next[colKey]; + return next; + }); + }; + + const handleSort = (key: string) => { + setSortState((prev) => + prev?.key === key + ? prev.direction === "asc" ? { key, direction: "desc" } : null + : { key, direction: "asc" } ); }; @@ -561,13 +832,13 @@ export default function ReceivingPage() { } /> - {/* 입고 목록 테이블 */} -
+ {/* 입고 목록 테이블 (마스터-디테일 그룹) */} +

입고 목록

- {data.length}건 + {Object.keys(filteredGroups).length}건
- ( - {v || "-"} - )}, - { key: "inbound_date", label: "입고일", width: "w-[100px]", render: (v) => v ? new Date(v).toLocaleDateString("ko-KR") : "-" }, - { key: "reference_number", label: "참조번호", width: "w-[120px]" }, - { key: "source_table", label: "데이터출처", width: "w-[80px]", render: (v) => v ? SOURCE_TABLE_LABEL[v] || v : "-" }, - { key: "supplier_name", label: "공급처", width: "w-[120px]" }, - { key: "item_number", label: "품목코드", width: "w-[100px]" }, - { key: "item_name", label: "품목명", minWidth: "min-w-[150px]" }, - { key: "spec", label: "규격", width: "w-[80px]" }, - { key: "inbound_qty", label: "입고수량", width: "w-[80px]", align: "right", formatNumber: true }, - { key: "unit_price", label: "단가", width: "w-[90px]", align: "right", formatNumber: true }, - { key: "total_amount", label: "금액", width: "w-[100px]", align: "right", formatNumber: true }, - { key: "warehouse_name", label: "창고", width: "w-[100px]", render: (_v, row) => row.warehouse_name || row.warehouse_code || "-" }, - { key: "inbound_status", label: "입고상태", width: "w-[90px]", align: "center", render: (v) => ( - {v || "-"} - )}, - { key: "memo", label: "비고", width: "w-[100px]" }, - ] as EDataTableColumn[]} - data={ts.groupData(data)} - rowKey={(row) => row.id} - loading={loading} - emptyMessage="등록된 입고 내역이 없어요" - showCheckbox - checkedIds={checkedIds} - onCheckedChange={setCheckedIds} - showPagination - draggableColumns - columnOrderKey="c16-receiving" - /> +
+ + + + + { + const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id)); + const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id)); + setCheckedIds(allChecked ? [] : allFilteredIds); + }} + > + { + const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.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} + /> + )} +
+
+ {/* 마스터 필드 헤더 (colSpan으로 디테일 컬럼 위에 맵핑) */} + {MASTER_BODY_LAYOUT.map((col) => ( + +
+
handleSort(col.key)}> + {col.label} + {sortState?.key === col.key && ( + sortState.direction === "asc" + ? + : + )} +
+ {(masterUniqueValues[col.key] || []).length > 0 && ( + ()} + onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} + /> + )} +
+
+ ))} +
+
+ + {loading ? ( + + + + + + ) : Object.keys(filteredGroups).length === 0 ? ( + + +
+ + 등록된 입고 내역이 없어요 +
+
+
+ ) : ( + 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; + 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)); + } + }} + > + { + e.stopPropagation(); + setCheckedIds((prev) => { + if (allDetailChecked) return prev.filter((id) => !detailIds.includes(id)); + return [...new Set([...prev, ...detailIds])]; + }); + }} + > + {}} + /> + + + {isExpanded + ? + : + } + + {/* 입고번호 */} + + {inboundNo} + ({group.details.length}) + + {/* 입고유형 */} + + + {resolveInboundType(master.inbound_type)} + + + {/* 입고일 */} + + {master.inbound_date ? new Date(master.inbound_date).toLocaleDateString("ko-KR") : "-"} + + {/* 참조번호 */} + + {master.reference_number || ""} + + {/* 공급처 */} + + {master.supplier_name || ""} + + {/* 창고 */} + + {master.warehouse_name || master.warehouse_code || ""} + + {/* 입고상태 */} + + + {master.inbound_status || "-"} + + + {/* 비고 */} + + {master.memo || ""} + + + + {/* 디테일 서브 헤더 (펼쳤을 때만) */} + {isExpanded && ( + + + + + {DETAIL_HEADER_COLS.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] + ); + }} + > + {}} /> + + +
+ + + {/* 출처 */} + {row.source_table ? SOURCE_TABLE_LABEL[row.source_table] || row.source_table : "-"} + {/* 품목코드 */} + {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() : ""} + + ); + })} + + ); + }) + )} + +
+
{/* 입고 등록 모달 */} @@ -1033,11 +1557,11 @@ function SourcePurchaseOrderTable({ - {data.map((po) => { + {data.map((po, idx) => { const isSelected = selectedKeys.includes(`po-${po.id}`); return ( { const item = itemMap.get(d.child_item_id) as any; + const divisionRaw = item?.division || ""; + // 카테고리 코드 → 라벨 변환 (쉼표 구분 다중값 지원) + const divisionLabel = divisionRaw.split(",").map((code: string) => { + const c = code.trim(); + return categoryOptions["division"]?.find((o) => o.code === c)?.label || c; + }).filter((v: string) => v && v !== "s").join(", "); return { ...d, item_number: item?.item_number || "", item_name: item?.item_name || "", - item_type: item?.division || "", + item_type: divisionLabel, unit: d.unit || item?.unit || "", }; }); @@ -409,16 +415,16 @@ export default function BomManagementPage() { } finally { setDetailLoading(false); } - }, []); + }, [categoryOptions]); // 버전 목록 로드 const fetchVersions = useCallback(async (bomId: string) => { setVersionLoading(true); try { const res = await apiClient.get(`/bom/${bomId}/versions`); - const data = res.data?.data || res.data; - setVersions(data?.versions || []); - setCurrentVersionId(data?.currentVersionId || null); + const resData = res.data; + setVersions(resData?.data || []); + setCurrentVersionId(resData?.currentVersionId || null); } catch (err: any) { toast.error("버전 목록 조회에 실패했어요"); } finally { @@ -636,6 +642,8 @@ export default function BomManagementPage() { fetchBomList(); if (bomId) { setSelectedBomId(bomId); + fetchBomDetail(bomId); + fetchVersions(bomId); } } catch (err: any) { toast.error(err?.response?.data?.message || "BOM 저장에 실패했어요"); @@ -1087,72 +1095,44 @@ export default function BomManagementPage() {
{/* 트리뷰 탭 */} - + {flatTree.length === 0 ? (

BOM 구성 데이터가 없어요

) : ( -
- {flatTree.map((node) => { - const typeBadge = getItemTypeBadge(node.item_type); - const hasChildren = node.children.length > 0; - return ( -
- {/* 토글 버튼 */} - {hasChildren ? ( - - ) : ( - - )} - - {/* 타입 뱃지 */} - - {typeBadge.label} - - - {/* 품목코드 */} - - {node.item_number || "-"} - - - | - - {/* 품명 */} - - {node.item_name || "-"} - - - {/* 수량 */} - - {node.quantity || "1"} - {node.unit || ""} - - - {/* 비고 */} - {node.remark && ( - - {node.remark} - - )} -
- ); - })} -
+ + + + 레벨 + 품번 + 품명 + 품목구분 + 기준수량 + + + + {flatTree.map((node) => { + const typeBadge = getItemTypeBadge(node.item_type); + return ( + + {node._level} + + {node.item_number || "-"} + + {node.item_name || "-"} + + {typeBadge.label} + + + {node.quantity ? Number(node.quantity).toLocaleString() : "-"} + + + ); + })} + +
)}
diff --git a/frontend/app/(main)/COMPANY_16/production/plan-management/page.tsx b/frontend/app/(main)/COMPANY_16/production/plan-management/page.tsx index 32f7b2cd..4a0d341a 100644 --- a/frontend/app/(main)/COMPANY_16/production/plan-management/page.tsx +++ b/frontend/app/(main)/COMPANY_16/production/plan-management/page.tsx @@ -1026,8 +1026,8 @@ export default function ProductionPlanManagementPage() { 0} onCheckedChange={(c) => toggleAllItemGroups(!!c)} className="h-4 w-4" /> - 품목코드 - 품목명 + 품목코드 + 품목명 {isColVisible("total_order_qty") && 총수주량} {isColVisible("total_ship_qty") && 출고량} {isColVisible("total_balance_qty") && 잔량} @@ -1066,8 +1066,8 @@ export default function ProductionPlanManagementPage() { toggleItemExpand(item.item_code)}> - toggleItemExpand(item.item_code)}>{item.item_code} - toggleItemExpand(item.item_code)}>{item.item_name} + toggleItemExpand(item.item_code)}>{item.item_code} + toggleItemExpand(item.item_code)}>{item.item_name} {isColVisible("total_order_qty") && toggleItemExpand(item.item_code)}>{formatNumber(item.total_order_qty)}} {isColVisible("total_ship_qty") && toggleItemExpand(item.item_code)}>{formatNumber(item.total_ship_qty)}} {isColVisible("total_balance_qty") && toggleItemExpand(item.item_code)}>{formatNumber(item.total_balance_qty)}} diff --git a/frontend/lib/registry/components/v2-process-work-standard/components/DetailFormModal.tsx b/frontend/lib/registry/components/v2-process-work-standard/components/DetailFormModal.tsx index eaeb7262..f598db74 100644 --- a/frontend/lib/registry/components/v2-process-work-standard/components/DetailFormModal.tsx +++ b/frontend/lib/registry/components/v2-process-work-standard/components/DetailFormModal.tsx @@ -105,7 +105,7 @@ export function DetailFormModal({ const res = await getBomMaterials(selectedItemCode); if (res.success && res.data) { setBomMaterials(res.data); - setBomChecked(new Set(res.data.map((m) => m.child_item_id))); + // bomChecked 초기화는 별도로 처리 (아래 effect) } else { setBomMaterials([]); } @@ -116,6 +116,21 @@ export function DetailFormModal({ } }, [selectedItemCode]); + // BOM 자재 로드 완료 후 체크 상태 초기화 + useEffect(() => { + if (!open || bomMaterials.length === 0) return; + if (mode === "edit" && editData?.selected_bom_items) { + const savedBom = editData.selected_bom_items; + const parsedBom = typeof savedBom === "string" ? JSON.parse(savedBom) : savedBom; + if (Array.isArray(parsedBom)) { + setBomChecked(new Set(parsedBom)); + return; + } + } + // 신규 추가 또는 저장값 없으면 전체 체크 + setBomChecked(new Set(bomMaterials.map((m) => m.child_item_id))); + }, [open, bomMaterials, mode, editData]); + useEffect(() => { if (open) { if (mode === "edit" && editData) { @@ -197,6 +212,8 @@ export function DetailFormModal({ } if (type === "material_input") { submitData.content = submitData.content || "BOM 구성 자재 (자동 연동)"; + // 체크된 자재 ID 목록 저장 + submitData.selected_bom_items = Array.from(bomChecked); } onSubmit(submitData); diff --git a/frontend/lib/registry/components/v2-process-work-standard/types.ts b/frontend/lib/registry/components/v2-process-work-standard/types.ts index 457fb999..da1aca10 100644 --- a/frontend/lib/registry/components/v2-process-work-standard/types.ts +++ b/frontend/lib/registry/components/v2-process-work-standard/types.ts @@ -138,6 +138,7 @@ export interface WorkItemDetail { material_name?: string; quantity?: string; material_unit?: string; + selected_bom_items?: string[]; } export interface InspectionStandard {