diff --git a/backend-node/src/controllers/analyticsReportController.ts b/backend-node/src/controllers/analyticsReportController.ts index 126a8ffe..7c66485d 100644 --- a/backend-node/src/controllers/analyticsReportController.ts +++ b/backend-node/src/controllers/analyticsReportController.ts @@ -367,57 +367,33 @@ export async function getQualityReportData(req: any, res: Response): Promise 0`} ORDER BY os.item_code `; @@ -141,10 +147,11 @@ export async function getOrderSummary( let pagedParams: any[] = params; if (usePaging) { - // total: 그룹 수 = order_summary CTE의 행 수 + // total: excludePlanned 필터까지 적용한 그룹 수 + // order_summary + plan_info CTE만 다시 만들어서 동일 필터로 COUNT const countQuery = ` WITH all_orders AS ( - SELECT so.part_code, so.company_code + SELECT so.part_code, so.company_code, COALESCE(so.balance_qty::numeric, 0) AS balance_qty FROM sales_order_mng so WHERE ${whereClause} AND so.part_code IS NOT NULL AND so.part_code != '' @@ -153,15 +160,43 @@ export async function getOrderSummary( WHERE sd.order_no = so.order_no AND sd.company_code = so.company_code ) UNION ALL - SELECT sd.part_code, sd.company_code + SELECT sd.part_code, sd.company_code, COALESCE(sd.balance_qty::numeric, sd.qty::numeric - COALESCE(sd.ship_qty::numeric, 0), 0) 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 != '' + ), + os AS ( + SELECT part_code AS item_code, SUM(balance_qty) AS total_balance_qty + FROM all_orders GROUP BY part_code + ), + si AS ( + SELECT item_code, + SUM(COALESCE(current_qty::numeric, 0)) AS current_stock, + MAX(COALESCE(safety_qty::numeric, 0)) AS safety_stock + FROM inventory_stock WHERE company_code = $1 GROUP BY item_code + ), + pi AS ( + SELECT item_code, + SUM(CASE WHEN status = 'planned' THEN COALESCE(plan_qty, 0) ELSE 0 END) AS existing_plan_qty, + SUM(CASE WHEN status = 'in_progress' THEN COALESCE(plan_qty, 0) ELSE 0 END) AS in_progress_qty + FROM production_plan_mng + WHERE company_code = $1 + AND COALESCE(product_type, '완제품') = '완제품' + AND status NOT IN ('completed', 'cancelled') + GROUP BY item_code ) - SELECT COUNT(*)::int AS total FROM ( - SELECT DISTINCT part_code FROM all_orders - ) g; + SELECT COUNT(*)::int AS total + FROM os + LEFT JOIN si ON os.item_code = si.item_code + LEFT JOIN pi ON os.item_code = pi.item_code + ${options?.excludePlanned + ? "" + : `WHERE GREATEST( + os.total_balance_qty + COALESCE(si.safety_stock, 0) - COALESCE(si.current_stock, 0) + - COALESCE(pi.existing_plan_qty, 0) - COALESCE(pi.in_progress_qty, 0), + 0 + ) > 0`}; `; const countRes = await pool.query(countQuery, params); total = countRes.rows[0]?.total ?? 0; diff --git a/frontend/app/(main)/COMPANY_16/kpi/production/daily/page.tsx b/frontend/app/(main)/COMPANY_16/kpi/production/daily/page.tsx index 1d6fed5b..02da7f21 100644 --- a/frontend/app/(main)/COMPANY_16/kpi/production/daily/page.tsx +++ b/frontend/app/(main)/COMPANY_16/kpi/production/daily/page.tsx @@ -73,7 +73,7 @@ export default function KpiDailyProductionPage() { label, production_qty: Number(r.production_qty) || 0, work_hours: Number(r.work_hours) || 0, - hourly: Math.round(hourly * 100) / 100, + hourly: Math.floor(hourly * 100) / 100, }; }); }, [rows]); @@ -85,9 +85,9 @@ export default function KpiDailyProductionPage() { const hourly = totalHours > 0 ? totalQty / totalHours : 0; return { totalQty, - totalHours: Math.round(totalHours * 100) / 100, + totalHours: Math.floor(totalHours * 100) / 100, totalDefect, - hourly: Math.round(hourly * 100) / 100, + hourly: Math.floor(hourly * 100) / 100, }; }, [rows]); @@ -103,7 +103,7 @@ export default function KpiDailyProductionPage() { const wh = Number(row.work_hours) || 0; if (wh === 0) return "-"; const v = Number(row.production_qty) / wh; - return v.toFixed(2); + return (Math.floor(v * 100) / 100).toFixed(2); } }, ], []); diff --git a/frontend/app/(main)/COMPANY_30/kpi/production/daily/page.tsx b/frontend/app/(main)/COMPANY_30/kpi/production/daily/page.tsx index a86dac62..3a475a36 100644 --- a/frontend/app/(main)/COMPANY_30/kpi/production/daily/page.tsx +++ b/frontend/app/(main)/COMPANY_30/kpi/production/daily/page.tsx @@ -75,7 +75,7 @@ export default function KpiDailyProductionPage() { label, production_qty: Number(r.production_qty) || 0, work_hours: Number(r.work_hours) || 0, - hourly: Math.round(hourly * 100) / 100, + hourly: Math.floor(hourly * 100) / 100, }; }); }, [rows]); @@ -88,9 +88,9 @@ export default function KpiDailyProductionPage() { const hourly = totalHours > 0 ? totalQty / totalHours : 0; return { totalQty, - totalHours: Math.round(totalHours * 100) / 100, + totalHours: Math.floor(totalHours * 100) / 100, totalDefect, - hourly: Math.round(hourly * 100) / 100, + hourly: Math.floor(hourly * 100) / 100, }; }, [rows]); @@ -107,7 +107,7 @@ export default function KpiDailyProductionPage() { const wh = Number(row.work_hours) || 0; if (wh === 0) return "-"; const v = Number(row.production_qty) / wh; - return v.toFixed(2); + return (Math.floor(v * 100) / 100).toFixed(2); } }, ], []); diff --git a/frontend/app/(main)/COMPANY_30/sales/order/page.tsx b/frontend/app/(main)/COMPANY_30/sales/order/page.tsx index e43bdadd..92a58349 100644 --- a/frontend/app/(main)/COMPANY_30/sales/order/page.tsx +++ b/frontend/app/(main)/COMPANY_30/sales/order/page.tsx @@ -588,15 +588,25 @@ export default function ChunganSalesOrderPage() { // 통계 (전체 기준) const stats = totalStats; - // 우측: 선택된 수주 디테일 조회 (division 코드→라벨 변환) + // 우측: 선택된 수주 디테일 조회 (division 코드→라벨 변환, 콤마 복합 지원) useEffect(() => { if (!selectedOrderNo) { setDetailItems([]); setDetailCheckedIds([]); return; } + const resolveMulti = (col: string, raw: any) => { + const s = String(raw ?? ""); + if (!s) return ""; + const opts = categoryOptions[col] || []; + return s.split(",") + .map((t) => t.trim()) + .filter(Boolean) + .map((t) => opts.find((o) => o.code === t)?.label || t) + .join(", "); + }; const items = allDetails .filter((d) => d.order_no === selectedOrderNo) .map((d) => ({ ...d, - division: categoryOptions["item_division"]?.find((o) => o.code === d.division)?.label || d.division || "", - type: categoryOptions["item_type"]?.find((o) => o.code === d.type)?.label || d.type || "", + division: resolveMulti("item_division", d.division), + type: resolveMulti("item_type", d.type), })); setDetailItems(items); setDetailCheckedIds([]); diff --git a/frontend/components/common/TimelineScheduler.tsx b/frontend/components/common/TimelineScheduler.tsx index 5426687e..634f77b3 100644 --- a/frontend/components/common/TimelineScheduler.tsx +++ b/frontend/components/common/TimelineScheduler.tsx @@ -17,6 +17,7 @@ */ import React, { useState, useRef, useCallback, useMemo, useEffect } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { @@ -333,6 +334,26 @@ export default function TimelineScheduler({ return map; }, [eventsByResource, eventLanes]); + // ── 가상 스크롤 (리소스 행) ── + // 대량 리소스(수천~만 단위) 환경에서 보이는 행만 DOM 마운트하여 성능 확보 + const barHeight = 24; + const barGap = 2; + const getRowHeight = useCallback((idx: number) => { + const res = resources[idx]; + if (!res) return rowHeight; + const laneCount = resourceLaneCounts.get(res.id) || 1; + return Math.max(rowHeight, laneCount * (barHeight + barGap) + 12); + }, [resources, resourceLaneCounts, rowHeight]); + + const rowVirtualizer = useVirtualizer({ + count: resources.length, + getScrollElement: () => scrollRef.current, + estimateSize: getRowHeight, + overscan: 8, + }); + const virtualItems = rowVirtualizer.getVirtualItems(); + const totalRowsHeight = rowVirtualizer.getTotalSize(); + // ── 줌/네비게이션 핸들러 ── const handleZoom = useCallback( @@ -623,9 +644,6 @@ export default function TimelineScheduler({ // 데이터 0건이어도 네비게이션 컨트롤(이전/오늘/다음/줌)은 노출하여 이전 기간 탐색 가능하도록. const hasData = resources.length > 0 && events.length > 0; - const barHeight = 24; - const barGap = 2; - return (
{/* 컨트롤 바 */} @@ -712,27 +730,36 @@ export default function TimelineScheduler({ > 리소스
- {/* 리소스 행 */} - {resources.map((res) => { - const laneCount = resourceLaneCounts.get(res.id) || 1; - const h = Math.max(rowHeight, laneCount * (barHeight + barGap) + 12); - return ( -
- - {res.label} - - {res.subLabel && ( - - {res.subLabel} + {/* 리소스 행 — 가상화 (보이는 행만 마운트) */} +
+ {virtualItems.map((vRow) => { + const res = resources[vRow.index]; + if (!res) return null; + return ( +
+ + {res.label} - )} -
- ); - })} + {res.subLabel && ( + + {res.subLabel} + + )} +
+ ); + })} +
{/* 우측: 타임라인 그리드 */} @@ -788,14 +815,26 @@ export default function TimelineScheduler({ - {/* 리소스별 이벤트 행 */} - {resources.map((res) => { + {/* 리소스별 이벤트 행 — 가상화 (좌측 컬럼과 동일 인덱스/높이) */} +
+ {virtualItems.map((vRow) => { + const res = resources[vRow.index]; + if (!res) return null; const resEvents = eventsByResource.get(res.id) || []; - const laneCount = resourceLaneCounts.get(res.id) || 1; - const h = Math.max(rowHeight, laneCount * (barHeight + barGap) + 12); return ( -
+
{/* 배경 그리드 */}
{dates.map((date, idx) => ( @@ -922,6 +961,7 @@ export default function TimelineScheduler({
); })} +