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 {