diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 389f8b11..d8298d9e 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -149,6 +149,7 @@ import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다 import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계 import categoryTreeRoutes from "./routes/categoryTreeRoutes"; // 카테고리 트리 (테스트) import processWorkStandardRoutes from "./routes/processWorkStandardRoutes"; // 공정 작업기준 +import kpiRoutes from "./routes/kpiRoutes"; // KPI (TASK:ERP-022) import aiAssistantProxy from "./routes/aiAssistantProxy"; // AI 어시스턴트 API 프록시 (같은 포트로 서비스) import auditLogRoutes from "./routes/auditLogRoutes"; // 통합 변경 이력 import moldRoutes from "./routes/moldRoutes"; // 금형 관리 @@ -378,6 +379,7 @@ app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계 app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계 app.use("/api/category-tree", categoryTreeRoutes); // 카테고리 트리 (테스트) app.use("/api/process-work-standard", processWorkStandardRoutes); // 공정 작업기준 +app.use("/api/kpi", kpiRoutes); // KPI (TASK:ERP-022) app.use("/api/audit-log", auditLogRoutes); // 통합 변경 이력 app.use("/api/mold", moldRoutes); // 금형 관리 app.use("/api/shipping-plan", shippingPlanRoutes); // 출하계획 관리 diff --git a/backend-node/src/controllers/kpiController.ts b/backend-node/src/controllers/kpiController.ts new file mode 100644 index 00000000..35519f57 --- /dev/null +++ b/backend-node/src/controllers/kpiController.ts @@ -0,0 +1,48 @@ +/** + * KPI 컨트롤러 — TASK:ERP-022 + * 일별 생산량 등 KPI 지표 조회 전담 + */ +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +/** + * GET /api/kpi/daily-production?from=YYYY-MM-DD&to=YYYY-MM-DD + * 회사별 일별 생산량 조회 + */ +export async function getDailyProduction(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 필요" }); + } + + const from = (req.query.from as string) || ""; + const to = (req.query.to as string) || ""; + + const params: any[] = [companyCode]; + let where = "company_code = $1"; + if (from) { + params.push(from); + where += ` AND prod_date >= $${params.length}`; + } + if (to) { + params.push(to); + where += ` AND prod_date <= $${params.length}`; + } + + const result = await getPool().query( + `SELECT prod_date, production_qty, defect_qty, work_hours, remark + FROM kpi_daily_production + WHERE ${where} + ORDER BY prod_date`, + params + ); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("KPI 일별 생산량 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/controllers/processInfoController.ts b/backend-node/src/controllers/processInfoController.ts index 22dfe51d..96af4e7b 100644 --- a/backend-node/src/controllers/processInfoController.ts +++ b/backend-node/src/controllers/processInfoController.ts @@ -468,10 +468,10 @@ export async function saveRoutingDetails(req: AuthenticatedRequest, res: Respons } const insertRes = await client.query( - `INSERT INTO item_routing_detail (id, company_code, routing_version_id, seq_no, process_code, is_required, is_fixed_order, work_type, standard_time, outsource_supplier, writer) - VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + `INSERT INTO item_routing_detail (id, company_code, routing_version_id, seq_no, process_code, is_required, is_fixed_order, work_type, standard_time, outsource_supplier, execution_type, writer) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id`, - [companyCode, versionId, d.seq_no, d.process_code, d.is_required || "Y", d.is_fixed_order || "Y", d.work_type || "내부", d.standard_time || "0", legacyCode, writer] + [companyCode, versionId, d.seq_no, d.process_code, d.is_required || "Y", d.is_fixed_order || "Y", d.work_type || "내부", d.standard_time || "0", legacyCode, d.execution_type || null, writer] ); const newDetailId = insertRes.rows[0].id; diff --git a/backend-node/src/controllers/productionController.ts b/backend-node/src/controllers/productionController.ts index 73aeb53f..f832a3ad 100644 --- a/backend-node/src/controllers/productionController.ts +++ b/backend-node/src/controllers/productionController.ts @@ -14,13 +14,23 @@ export async function getOrderSummary(req: AuthenticatedRequest, res: Response) const companyCode = req.user!.companyCode; const { excludePlanned, itemCode, itemName } = req.query; - const data = await productionService.getOrderSummary(companyCode, { + // 서버 페이징 (size 미지정 시 기존 동작 유지: 전체 반환) + const page = parseInt(String(req.query.page ?? "1"), 10) || 1; + const size = parseInt(String(req.query.size ?? "0"), 10) || 0; + + const result = await productionService.getOrderSummary(companyCode, { excludePlanned: excludePlanned === "true", itemCode: itemCode as string, itemName: itemName as string, + page, + size, }); - return res.json({ success: true, data }); + // 페이징 사용 시 result는 { data, total, page, size, totalPages } 객체 + if (size > 0 && !Array.isArray(result)) { + return res.json({ success: true, ...result }); + } + return res.json({ success: true, data: result }); } catch (error: any) { logger.error("수주 데이터 조회 실패", { error: error.message }); return res.status(500).json({ success: false, message: error.message }); @@ -47,15 +57,23 @@ export async function getPlans(req: AuthenticatedRequest, res: Response) { const companyCode = req.user!.companyCode; const { productType, status, startDate, endDate, itemCode } = req.query; - const data = await productionService.getPlans(companyCode, { + const page = parseInt(String(req.query.page ?? "1"), 10) || 1; + const size = parseInt(String(req.query.size ?? "0"), 10) || 0; + + const result = await productionService.getPlans(companyCode, { productType: productType as string, status: status as string, startDate: startDate as string, endDate: endDate as string, itemCode: itemCode as string, + page, + size, }); - return res.json({ success: true, data }); + if (size > 0 && !Array.isArray(result)) { + return res.json({ success: true, ...result }); + } + return res.json({ success: true, data: result }); } catch (error: any) { logger.error("생산계획 목록 조회 실패", { error: error.message }); return res.status(500).json({ success: false, message: error.message }); diff --git a/backend-node/src/controllers/shippingOrderController.ts b/backend-node/src/controllers/shippingOrderController.ts index 896f68f0..0f875ec5 100644 --- a/backend-node/src/controllers/shippingOrderController.ts +++ b/backend-node/src/controllers/shippingOrderController.ts @@ -13,6 +13,11 @@ export async function getList(req: AuthenticatedRequest, res: Response) { const companyCode = req.user!.companyCode; const { dateFrom, dateTo, status, customer, keyword } = req.query; + // 서버 페이징 파라미터 (없으면 기존 동작 유지: 전체 조회) + const page = parseInt(String(req.query.page ?? "1"), 10) || 1; + const size = parseInt(String(req.query.size ?? "0"), 10) || 0; + const usePaging = size > 0; + const conditions: string[] = []; const params: any[] = []; let idx = 1; @@ -89,10 +94,41 @@ export async function getList(req: AuthenticatedRequest, res: Response) { `; const pool = getPool(); + + if (usePaging) { + // total 카운트 — JOIN/GROUP 없이 si 기준 distinct count + const countQuery = ` + SELECT COUNT(DISTINCT si.id)::int AS total + FROM shipment_instruction si + LEFT JOIN customer_mng c + ON si.partner_id = c.customer_code AND si.company_code = c.company_code + ${where} + `; + const countResult = await pool.query(countQuery, params); + const total = countResult.rows[0]?.total ?? 0; + + const offset = (page - 1) * size; + const pagedQuery = `${query} LIMIT $${idx} OFFSET $${idx + 1}`; + const pagedResult = await pool.query(pagedQuery, [...params, size, offset]); + + logger.info("출하지시 목록 조회 (페이징)", { + companyCode, page, size, total, count: pagedResult.rowCount, + }); + + return res.json({ + success: true, + data: pagedResult.rows, + total, + page, + size, + totalPages: Math.max(1, Math.ceil(total / size)), + }); + } + const result = await pool.query(query, params); logger.info("출하지시 목록 조회", { companyCode, count: result.rowCount }); - return res.json({ success: true, data: result.rows }); + return res.json({ success: true, data: result.rows, total: result.rowCount }); } catch (error: any) { logger.error("출하지시 목록 조회 실패", { error: error.message }); return res.status(500).json({ success: false, message: error.message }); diff --git a/backend-node/src/controllers/shippingPlanController.ts b/backend-node/src/controllers/shippingPlanController.ts index 64f63f76..0ac09b35 100644 --- a/backend-node/src/controllers/shippingPlanController.ts +++ b/backend-node/src/controllers/shippingPlanController.ts @@ -151,6 +151,11 @@ export async function getList(req: AuthenticatedRequest, res: Response) { const companyCode = req.user!.companyCode; const { dateFrom, dateTo, status, customer, keyword } = req.query; + // 서버 페이징 파라미터 (없으면 기존 동작 유지: 전체 조회) + const page = parseInt(String(req.query.page ?? "1"), 10) || 1; + const size = parseInt(String(req.query.size ?? "0"), 10) || 0; + const usePaging = size > 0; + const conditions: string[] = []; const params: any[] = []; let paramIndex = 1; @@ -239,6 +244,53 @@ export async function getList(req: AuthenticatedRequest, res: Response) { `; const pool = getPool(); + + // 서버 페이징 적용 시: COUNT + LIMIT/OFFSET + if (usePaging) { + const countQuery = ` + SELECT COUNT(*)::int AS total + FROM shipment_plan sp + LEFT JOIN sales_order_detail d + ON sp.detail_id = d.id AND sp.company_code = d.company_code + LEFT JOIN sales_order_mng m + ON sp.sales_order_id = m.id AND sp.company_code = m.company_code + LEFT JOIN LATERAL ( + SELECT item_name FROM item_info + WHERE item_number = COALESCE(d.part_code, m.part_code) + AND company_code = sp.company_code + LIMIT 1 + ) i ON true + LEFT JOIN customer_mng c + ON COALESCE(NULLIF(m.partner_id, ''), NULLIF(d.delivery_partner_code, '')) = c.customer_code + AND sp.company_code = c.company_code + ${whereClause} + `; + const countResult = await pool.query(countQuery, params); + const total = countResult.rows[0]?.total ?? 0; + + const offset = (page - 1) * size; + const pagedQuery = `${query} LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`; + const pagedResult = await pool.query(pagedQuery, [...params, size, offset]); + + logger.info("출하계획 목록 조회 (페이징)", { + companyCode, + page, + size, + total, + rowCount: pagedResult.rowCount, + }); + + return res.json({ + success: true, + data: pagedResult.rows, + total, + page, + size, + totalPages: Math.max(1, Math.ceil(total / size)), + }); + } + + // 페이징 미사용: 기존 동작 (전체 조회) const result = await pool.query(query, params); logger.info("출하계획 목록 조회", { @@ -246,7 +298,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) { rowCount: result.rowCount, }); - return res.json({ success: true, data: result.rows }); + return res.json({ success: true, data: result.rows, total: result.rowCount }); } catch (error: any) { logger.error("출하계획 목록 조회 실패", { error: error.message, diff --git a/backend-node/src/routes/kpiRoutes.ts b/backend-node/src/routes/kpiRoutes.ts new file mode 100644 index 00000000..e043eeee --- /dev/null +++ b/backend-node/src/routes/kpiRoutes.ts @@ -0,0 +1,13 @@ +/** + * KPI 라우트 — TASK:ERP-022 + */ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as ctrl from "../controllers/kpiController"; + +const router = Router(); +router.use(authenticateToken); + +router.get("/daily-production", ctrl.getDailyProduction); + +export default router; diff --git a/backend-node/src/services/productionPlanService.ts b/backend-node/src/services/productionPlanService.ts index 8959d0ad..570816f6 100644 --- a/backend-node/src/services/productionPlanService.ts +++ b/backend-node/src/services/productionPlanService.ts @@ -15,7 +15,13 @@ import { logger } from "../utils/logger"; export async function getOrderSummary( companyCode: string, - options?: { excludePlanned?: boolean; itemCode?: string; itemName?: string } + options?: { + excludePlanned?: boolean; + itemCode?: string; + itemName?: string; + page?: number; + size?: number; + } ) { const pool = getPool(); const conditions: string[] = ["so.company_code = $1"]; @@ -35,6 +41,10 @@ export async function getOrderSummary( const whereClause = conditions.join(" AND "); + const page = options?.page && options.page > 0 ? options.page : 1; + const size = options?.size && options.size > 0 ? options.size : 0; // 0 = 전체 (하위호환) + const usePaging = size > 0; + // 단일 쿼리로 요약 + 상세 + 재고 + 계획 통합 조회 const query = ` WITH all_orders AS ( @@ -122,38 +132,113 @@ export async function getOrderSummary( LEFT JOIN plan_info pi ON os.item_code = pi.item_code LEFT JOIN item_info_dedup ilt ON os.item_code = ilt.item_number ${options?.excludePlanned ? "WHERE COALESCE(pi.existing_plan_qty, 0) = 0" : ""} - ORDER BY os.item_code; + ORDER BY os.item_code `; - const result = await pool.query(query, params); + // 페이징 시: total 별도 산출 + LIMIT/OFFSET 적용 + let total = 0; + let pagedQuery = `${query};`; + let pagedParams: any[] = params; - // 상세 데이터: all_orders CTE와 동일 로직 (쿼리 재사용 위해 별도 실행) - const detailQuery = ` - 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 ${conditions.map(c => c.replace(/so\./g, "")).join(" AND ")} - 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 + if (usePaging) { + // total: 그룹 수 = order_summary CTE의 행 수 + const countQuery = ` + WITH all_orders AS ( + SELECT so.part_code, so.company_code + FROM sales_order_mng so + WHERE ${whereClause} + 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 + SELECT sd.part_code, sd.company_code + 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 != '' ) - 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); + SELECT COUNT(*)::int AS total FROM ( + SELECT DISTINCT part_code FROM all_orders + ) g; + `; + const countRes = await pool.query(countQuery, params); + total = countRes.rows[0]?.total ?? 0; + + const offset = (page - 1) * size; + pagedQuery = `${query} LIMIT $${paramIdx} OFFSET $${paramIdx + 1};`; + pagedParams = [...params, size, offset]; + } + + const result = await pool.query(pagedQuery, pagedParams); + + // 상세 데이터: 페이징 시 현재 페이지의 part_codes만, 미페이징 시 전체 + let detailQuery: string; + let detailParams: any[]; + + if (usePaging) { + const partCodes = result.rows.map((r: any) => r.item_code).filter(Boolean); + if (partCodes.length === 0) { + const data = result.rows.map((g: any) => ({ ...g, orders: [] })); + return { data, total, page, size, totalPages: Math.max(1, Math.ceil(total / size)) }; + } + detailQuery = ` + 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 company_code = $1 + AND part_code = ANY($2::text[]) + 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 = ANY($2::text[]) + ORDER BY part_code, due_date; + `; + detailParams = [companyCode, partCodes]; + } else { + detailQuery = ` + 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 ${conditions.map(c => c.replace(/so\./g, "")).join(" AND ")} + 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; + `; + detailParams = params; + } + + const detailResult = await pool.query(detailQuery, detailParams); // 그룹별로 상세 데이터 매핑 const ordersByItem: Record = {}; @@ -168,7 +253,18 @@ export async function getOrderSummary( orders: ordersByItem[group.item_code || "__null__"] || [], })); - logger.info("수주 데이터 조회", { companyCode, groupCount: data.length }); + logger.info("수주 데이터 조회", { companyCode, groupCount: data.length, page, size, total }); + + if (usePaging) { + return { + data, + total, + page, + size, + totalPages: Math.max(1, Math.ceil(total / size)), + }; + } + // 하위호환: 페이징 미사용 시 기존 형태 (배열 그대로) 유지 return data; } @@ -210,6 +306,8 @@ export async function getPlans( startDate?: string; endDate?: string; itemCode?: string; + page?: number; + size?: number; } ) { const pool = getPool(); @@ -217,6 +315,10 @@ export async function getPlans( const params: any[] = [companyCode]; let paramIdx = 2; + const page = options?.page && options.page > 0 ? options.page : 1; + const size = options?.size && options.size > 0 ? options.size : 0; + const usePaging = size > 0; + if (companyCode !== "*") { // 일반 회사: 자사 데이터만 } else { @@ -269,6 +371,26 @@ export async function getPlans( ORDER BY p.start_date ASC, p.item_code ASC `; + if (usePaging) { + const countQuery = `SELECT COUNT(*)::int AS total FROM production_plan_mng p ${whereClause}`; + const countRes = await pool.query(countQuery, params); + const total = countRes.rows[0]?.total ?? 0; + + const offset = (page - 1) * size; + const pagedQuery = `${query} LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`; + const pagedRes = await pool.query(pagedQuery, [...params, size, offset]); + + logger.info("생산계획 목록 조회 (페이징)", { companyCode, page, size, total }); + + return { + data: pagedRes.rows, + total, + page, + size, + totalPages: Math.max(1, Math.ceil(total / size)), + }; + } + const result = await pool.query(query, params); logger.info("생산계획 목록 조회", { companyCode, count: result.rowCount }); return result.rows; diff --git a/frontend/app/(main)/COMPANY_10/production/plan-management/page.tsx b/frontend/app/(main)/COMPANY_10/production/plan-management/page.tsx index 6eae857e..cd159a0b 100644 --- a/frontend/app/(main)/COMPANY_10/production/plan-management/page.tsx +++ b/frontend/app/(main)/COMPANY_10/production/plan-management/page.tsx @@ -139,10 +139,11 @@ export default function ProductionPlanManagementPage() { // 데이터 상태 const [orderItems, setOrderItems] = useState([]); - // 좌측 수주목록 페이지네이션 + // 좌측 수주목록 페이지네이션 (서버 페이징) const [orderPage, setOrderPage] = useState(1); const [orderPageSize, setOrderPageSize] = useState(20); const [orderPageSizeInput, setOrderPageSizeInput] = useState("20"); + const [orderTotalCount, setOrderTotalCount] = useState(0); const [stockItems, setStockItems] = useState([]); const [finishedPlans, setFinishedPlans] = useState([]); const [semiPlans, setSemiPlans] = useState([]); @@ -210,22 +211,25 @@ export default function ProductionPlanManagementPage() { const res = await getOrderSummary({ excludePlanned: filterUnplannedOrdersOnly, itemCode: searchItemCode || undefined, + page: orderPage, + size: orderPageSize, }); - if (res.success) setOrderItems(res.data || []); + if (res.success) { + setOrderItems(res.data || []); + setOrderTotalCount(res.total ?? res.data?.length ?? 0); + } } catch (err: any) { toast.error("수주 데이터 조회 실패: " + (err.message || "")); } finally { setLoadingOrders(false); } - }, [filterUnplannedOrdersOnly, searchItemCode]); + }, [filterUnplannedOrdersOnly, searchItemCode, orderPage, orderPageSize]); // 수주목록 페이지네이션 계산 - const orderTotalPages = Math.max(1, Math.ceil(orderItems.length / orderPageSize)); + const orderTotalPages = Math.max(1, Math.ceil(orderTotalCount / orderPageSize)); const orderSafePage = Math.min(Math.max(1, orderPage), orderTotalPages); - const paginatedOrderItems = useMemo(() => { - const start = (orderSafePage - 1) * orderPageSize; - return orderItems.slice(start, start + orderPageSize); - }, [orderItems, orderSafePage, orderPageSize]); + // 서버 페이징: 응답 자체가 페이지 데이터이므로 slice 불필요 + const paginatedOrderItems = orderItems; const applyOrderPageSize = () => { const n = parseInt(orderPageSizeInput, 10); @@ -247,8 +251,8 @@ export default function ProductionPlanManagementPage() { return pages; }; - // orderItems 변경 시 1페이지로 리셋 - useEffect(() => { setOrderPage(1); }, [orderItems.length]); + // 검색 필터 변경 시 1페이지로 리셋 + useEffect(() => { setOrderPage(1); }, [filterUnplannedOrdersOnly, searchItemCode]); const fetchStockShortage = useCallback(async () => { setLoadingStock(true); @@ -265,19 +269,28 @@ export default function ProductionPlanManagementPage() { const fetchPlans = useCallback(async () => { setLoadingPlans(true); try { + // 타임라인 성능: 기간 필터 미입력 시 기본 오늘 ~ +60일 자동 적용 + // 이전 기록은 검색에서 시작일/종료일을 직접 지정하면 조회됨 + const today = new Date(); + const fmt = (d: Date) => d.toISOString().slice(0, 10); + const defaultStart = fmt(today); + const defaultEnd = fmt(new Date(today.getTime() + 60 * 86400000)); + const effectiveStart = searchStartDate || defaultStart; + const effectiveEnd = searchEndDate || defaultEnd; + const [finRes, semiRes] = await Promise.all([ getPlans({ productType: "완제품", status: searchStatus !== "all" ? searchStatus : undefined, - startDate: searchStartDate || undefined, - endDate: searchEndDate || undefined, + startDate: effectiveStart, + endDate: effectiveEnd, itemCode: searchItemCode || undefined, }), getPlans({ productType: "반제품", status: searchStatus !== "all" ? searchStatus : undefined, - startDate: searchStartDate || undefined, - endDate: searchEndDate || undefined, + startDate: effectiveStart, + endDate: effectiveEnd, }), ]); if (finRes.success) setFinishedPlans(finRes.data || []); @@ -329,11 +342,10 @@ export default function ProductionPlanManagementPage() { }, []); useEffect(() => { - if (searchFilters.length > 0) { - fetchOrderSummary(); - fetchPlans(); - } - }, [searchItemCode, searchStatus, searchStartDate, searchEndDate]); + // 검색 state 변경 시 자동 재조회 (필터 비어있어도 default 기간으로 재조회) + fetchOrderSummary(); + fetchPlans(); + }, [searchItemCode, searchStatus, searchStartDate, searchEndDate]); // eslint-disable-line react-hooks/exhaustive-deps // ========== 토글/선택 핸들러 ========== @@ -1516,6 +1528,7 @@ export default function ProductionPlanManagementPage() { onEventClick={openScheduleDetail} onEventMove={handleEventMove} onEventResize={handleEventResize} + onRangeChange={(s, e) => { setSearchStartDate(s); setSearchEndDate(e); }} /> @@ -1585,6 +1598,7 @@ export default function ProductionPlanManagementPage() { onEventClick={openScheduleDetail} onEventMove={handleEventMove} onEventResize={handleEventResize} + onRangeChange={(s, e) => { setSearchStartDate(s); setSearchEndDate(e); }} /> diff --git a/frontend/app/(main)/COMPANY_10/production/process-info/ItemRoutingTab.tsx b/frontend/app/(main)/COMPANY_10/production/process-info/ItemRoutingTab.tsx index 18bb8106..ec0c8fb1 100644 --- a/frontend/app/(main)/COMPANY_10/production/process-info/ItemRoutingTab.tsx +++ b/frontend/app/(main)/COMPANY_10/production/process-info/ItemRoutingTab.tsx @@ -92,6 +92,7 @@ export function ItemRoutingTab() { const [formFixedOrder, setFormFixedOrder] = useState("Y"); const [formWorkType, setFormWorkType] = useState("내부"); const [formStandardTime, setFormStandardTime] = useState(""); + const [formExecutionType, setFormExecutionType] = useState("serial"); // serial=순차 / parallel=병렬 const [formOutsources, setFormOutsources] = useState([]); const [subcontractorOptions, setSubcontractorOptions] = useState<{ id: string; code: string; name: string }[]>([]); const [detailSubmitting, setDetailSubmitting] = useState(false); @@ -282,6 +283,7 @@ export function ItemRoutingTab() { setFormFixedOrder("Y"); setFormWorkType("내부"); setFormStandardTime(""); + setFormExecutionType("serial"); setFormOutsources([]); setDetailDialogOpen(true); }; @@ -309,6 +311,7 @@ export function ItemRoutingTab() { setFormFixedOrder(row.is_fixed_order === "N" ? "N" : "Y"); setFormWorkType(row.work_type || "내부"); setFormStandardTime(row.standard_time || ""); + setFormExecutionType(row.execution_type === "parallel" ? "parallel" : "serial"); // 우선순위: id 배열 → legacy code 배열(id로 역변환) → legacy 단일 code(id로 역변환) let loadedIds: string[] = []; if (Array.isArray(row.outsource_supplier_ids) && row.outsource_supplier_ids.length > 0) { @@ -362,6 +365,7 @@ export function ItemRoutingTab() { standard_time: st || "0", outsource_supplier: outsourcePrimaryCode, outsource_supplier_ids: outsourceIds, + execution_type: formExecutionType, }; setDetails((prev) => sortDetailsBySeq([...prev, newRow])); toast.success("공정이 추가되었어요. 저장을 눌러 반영해주세요"); @@ -381,6 +385,7 @@ export function ItemRoutingTab() { standard_time: st || "0", outsource_supplier: outsourcePrimaryCode, outsource_supplier_ids: outsourceIds, + execution_type: formExecutionType, } : d, ), @@ -418,6 +423,7 @@ export function ItemRoutingTab() { standard_time: String(d.standard_time ?? "0"), outsource_supplier: d.outsource_supplier || "", outsource_supplier_ids: d.outsource_supplier_ids || [], + execution_type: d.execution_type || "serial", })); setSaving(true); @@ -514,6 +520,7 @@ export function ItemRoutingTab() { ...d, process_display: d.process_name || d.process_code, outsource_display: names.length === 0 ? "—" : names.join(", "), + execution_display: d.execution_type === "parallel" ? "병렬" : "순차", }; }), [details, subcontractorOptions], @@ -777,6 +784,7 @@ export function ItemRoutingTab() { { key: "process_display", label: "공정명" }, { key: "is_required", label: "필수", width: "w-[80px]", align: "center" as const }, { key: "is_fixed_order", label: "순서고정", width: "w-[90px]", align: "center" as const }, + { key: "execution_display", label: "실행방식", width: "w-[90px]", align: "center" as const }, { key: "work_type", label: "작업구분", width: "w-[100px]" }, { key: "standard_time", label: "표준시간", width: "w-[90px]", align: "right" as const }, { key: "outsource_display", label: "외주업체" }, @@ -913,18 +921,32 @@ export function ItemRoutingTab() { -
- - +
+
+ + +
+
+ + +
diff --git a/frontend/app/(main)/COMPANY_10/quality/item-inspection/page.tsx b/frontend/app/(main)/COMPANY_10/quality/item-inspection/page.tsx index 74fb5981..45042ac7 100644 --- a/frontend/app/(main)/COMPANY_10/quality/item-inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_10/quality/item-inspection/page.tsx @@ -33,6 +33,7 @@ const INSPECTION_TABLE = "inspection_standard"; const GRID_COLUMNS = [ { key: "item_code", label: "품목코드" }, { key: "item_name", label: "품명" }, + { key: "size", label: "규격" }, { key: "inspection_type", label: "검사유형" }, { key: "is_active", label: "사용여부" }, ]; @@ -74,7 +75,7 @@ export default function ItemInspectionInfoPage() { const [saving, setSaving] = useState(false); // FK 옵션 - const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string }[]>([]); + const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; size: string; unit: string }[]>([]); const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; judgment_criteria: string; selection_options: string; unit: string; types: string[] }[]>([]); const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]); @@ -130,6 +131,7 @@ export default function ItemInspectionInfoPage() { code: r.item_number || r.item_code || "", name: r.item_name || "", item_type: r.type || r.item_type || "", + size: r.size || "", unit: r.inventory_unit || "", }))); @@ -239,7 +241,7 @@ export default function ItemInspectionInfoPage() { const resData = res.data?.data; const rows = resData?.data || resData?.rows || []; const cm = itemCatMapRef.current; - setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: cm["type"]?.[r.type] || r.type || "", unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "" }))); + setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: cm["type"]?.[r.type] || r.type || "", size: r.size || "", unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "" }))); setItemTotal(resData?.total || resData?.totalCount || rows.length); } catch { /* skip */ } finally { setItemSearchLoading(false); } }; @@ -459,11 +461,13 @@ export default function ItemInspectionInfoPage() { // item_code별 그룹핑 const groupedData = useMemo(() => { - const map: Record = {}; + const itemSizeMap: Record = {}; + for (const it of itemOptions) itemSizeMap[it.code] = it.size || ""; + const map: Record = {}; for (const row of data) { const key = row.item_code || row.id; if (!map[key]) { - map[key] = { item_code: row.item_code, item_name: row.item_name, is_active: row.is_active || "", types: [], rows: [] }; + map[key] = { item_code: row.item_code, item_name: row.item_name, size: itemSizeMap[row.item_code] || "", is_active: row.is_active || "", types: [], rows: [] }; } map[key].rows.push(row); if (row.inspection_type && !map[key].types.includes(row.inspection_type)) { @@ -471,7 +475,7 @@ export default function ItemInspectionInfoPage() { } } return Object.values(map); - }, [data]); + }, [data, itemOptions]); // 좌측 품목 목록 정렬 (컬럼 헤더 클릭 → asc → desc → 해제 순환) const [sortConfig, setSortConfig] = useState<{ key: string; direction: "asc" | "desc" } | null>(null); @@ -1052,12 +1056,13 @@ export default function ItemInspectionInfoPage() { switch (col.key) { case "item_code": return {group.item_code}; case "item_name": return {group.item_name}; + case "size": return {group.size}; case "inspection_type": return ( -
+
{group.types.map((t: string) => { const label = inspTypeCatOptions.find((o) => o.code === t)?.label || t; - return {label}; + return {label}; })}
@@ -1252,17 +1257,19 @@ export default function ItemInspectionInfoPage() { 품목코드 품목명 + 규격 품목유형 단위 {filteredItems.length === 0 ? ( - {itemSearchLoading ? "검색 중..." : "검색 결과가 없어요"} + {itemSearchLoading ? "검색 중..." : "검색 결과가 없어요"} ) : filteredItems.map((item) => ( selectItem(item)}> {item.code} {item.name} + {item.size} {item.item_type} {item.unit} diff --git a/frontend/app/(main)/COMPANY_10/sales/shipping-order/page.tsx b/frontend/app/(main)/COMPANY_10/sales/shipping-order/page.tsx index 2ed29b40..a8c95d2e 100644 --- a/frontend/app/(main)/COMPANY_10/sales/shipping-order/page.tsx +++ b/frontend/app/(main)/COMPANY_10/sales/shipping-order/page.tsx @@ -97,6 +97,11 @@ export default function ShippingOrderPage() { // 검색 필터 (DynamicSearchFilter에서 관리) const [searchFilters, setSearchFilters] = useState([]); + // 서버 페이징 + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(50); + const [totalCount, setTotalCount] = useState(0); + // 엑셀 업로드 const [excelUploadOpen, setExcelUploadOpen] = useState(false); @@ -136,7 +141,7 @@ export default function ShippingOrderPage() { const fetchOrders = useCallback(async () => { setLoading(true); try { - const params: any = {}; + const params: any = { page: currentPage, size: pageSize }; for (const f of searchFilters) { if (f.columnName === "ship_date" && f.operator === "between" && f.value) { const [from, to] = f.value.split(","); @@ -152,18 +157,25 @@ export default function ShippingOrderPage() { } const result = await getShippingOrderList(params); - if (result.success) setOrders(result.data || []); + if (result.success) { + setOrders(result.data || []); + setTotalCount(result.total ?? result.data?.length ?? 0); + } } catch (err) { console.error("출하지시 조회 실패:", err); } finally { setLoading(false); } - }, [searchFilters]); + }, [searchFilters, currentPage, pageSize]); useEffect(() => { fetchOrders(); }, [fetchOrders]); + useEffect(() => { + setCurrentPage(1); + }, [searchFilters]); + // 소스 데이터 조회 const fetchSourceData = useCallback(async (pageOverride?: number) => { setSourceLoading(true); @@ -473,7 +485,7 @@ export default function ShippingOrderPage() { tableName={ts.tableName} filterId="c16-shipping-order" onFilterChange={setSearchFilters} - dataCount={orders.length} + dataCount={totalCount} externalFilterConfig={ts.filterConfig} /> @@ -482,7 +494,7 @@ export default function ShippingOrderPage() {

출하지시 관리

- {orders.length}건 + {totalCount}건 {loading && }
@@ -548,6 +560,13 @@ export default function ShippingOrderPage() { onRowClick={(row) => setSelectedOrderId(row._orderId)} onRowDoubleClick={(row) => openModal(row._order)} showPagination + serverPagination + serverCurrentPage={currentPage} + serverPageSize={pageSize} + serverTotalCount={totalCount} + onServerPageChange={setCurrentPage} + onServerPageSizeChange={(s) => { setPageSize(s); setCurrentPage(1); }} + defaultPageSize={pageSize} draggableColumns={false} columnOrderKey="c16-shipping-order" /> diff --git a/frontend/app/(main)/COMPANY_10/sales/shipping-plan/page.tsx b/frontend/app/(main)/COMPANY_10/sales/shipping-plan/page.tsx index 747ac23d..9e0398f9 100644 --- a/frontend/app/(main)/COMPANY_10/sales/shipping-plan/page.tsx +++ b/frontend/app/(main)/COMPANY_10/sales/shipping-plan/page.tsx @@ -69,6 +69,11 @@ export default function ShippingPlanPage() { // 검색 필터 (DynamicSearchFilter에서 관리) const [searchFilters, setSearchFilters] = useState([]); + // 서버 페이징 + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(50); + const [totalCount, setTotalCount] = useState(0); + // 상세 패널 편집 const [editPlanQty, setEditPlanQty] = useState(""); const [editPlanDate, setEditPlanDate] = useState(""); @@ -81,7 +86,7 @@ export default function ShippingPlanPage() { const fetchData = useCallback(async () => { setLoading(true); try { - const params: any = {}; + const params: any = { page: currentPage, size: pageSize }; for (const f of searchFilters) { if (f.columnName === "plan_date" && f.operator === "between" && f.value) { const [from, to] = f.value.split(","); @@ -99,19 +104,24 @@ export default function ShippingPlanPage() { const result = await getShipmentPlanList(params); if (result.success) { setData(result.data || []); + setTotalCount(result.total ?? result.data?.length ?? 0); } } catch (err) { console.error("출하계획 조회 실패:", err); } finally { setLoading(false); } - }, [searchFilters]); + }, [searchFilters, currentPage, pageSize]); - // searchFilters 변경 시 자동 조회 + // searchFilters 변경 시 자동 조회 + 1페이지로 리셋 useEffect(() => { fetchData(); }, [fetchData]); + useEffect(() => { + setCurrentPage(1); + }, [searchFilters]); + const selectedPlan = useMemo(() => data.find(p => p.id === selectedId), [data, selectedId]); const groupedData = useMemo(() => { @@ -209,7 +219,7 @@ export default function ShippingPlanPage() { tableName="shipment_plan" filterId="c16-shipping-plan" onFilterChange={setSearchFilters} - dataCount={data.length} + dataCount={totalCount} externalFilterConfig={ts.filterConfig} /> @@ -224,7 +234,7 @@ export default function ShippingPlanPage() {
출하계획 목록 - {data.length}건 + {totalCount}건 {loading && }
@@ -262,7 +272,14 @@ export default function ShippingPlanPage() { showCheckbox checkedIds={checkedIds.map(String)} onCheckedChange={(ids) => setCheckedIds(ids.map(Number))} - showPagination={false} + showPagination + serverPagination + serverCurrentPage={currentPage} + serverPageSize={pageSize} + serverTotalCount={totalCount} + onServerPageChange={setCurrentPage} + onServerPageSizeChange={(s) => { setPageSize(s); setCurrentPage(1); }} + defaultPageSize={pageSize} draggableColumns={false} />
diff --git a/frontend/app/(main)/COMPANY_16/kpi/production/daily/page.tsx b/frontend/app/(main)/COMPANY_16/kpi/production/daily/page.tsx new file mode 100644 index 00000000..1d6fed5b --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/kpi/production/daily/page.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { useState, useEffect, useMemo, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card } from "@/components/ui/card"; +import { Loader2, Search } from "lucide-react"; +import { toast } from "sonner"; +import { apiClient } from "@/lib/api/client"; +import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; +import { + ComposedChart, Bar, Line, XAxis, YAxis, CartesianGrid, + Tooltip, Legend, ResponsiveContainer, +} from "recharts"; + +// TASK:ERP-022 — KPI 일별 생산량 (COMPANY_16) +type Row = { + prod_date: string; + production_qty: number; + defect_qty: number; + work_hours: number; + remark?: string | null; +}; + +function defaultRange() { + const now = new Date(); + const y = now.getFullYear(); + const m = now.getMonth(); + const start = new Date(y, m, 1); + const end = new Date(y, m + 1, 0); + const fmt = (d: Date) => d.toISOString().slice(0, 10); + return { from: fmt(start), to: fmt(end) }; +} + +export default function KpiDailyProductionPage() { + const init = defaultRange(); + const [from, setFrom] = useState(init.from); + const [to, setTo] = useState(init.to); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + + const fetchData = useCallback(async () => { + if (!from || !to) { + toast.error("기간을 입력해주세요"); + return; + } + setLoading(true); + try { + const res = await apiClient.get(`/kpi/daily-production`, { params: { from, to } }); + const data: Row[] = res.data?.data || []; + setRows(data); + } catch (err: any) { + toast.error(err?.response?.data?.message || "조회 실패"); + } finally { + setLoading(false); + } + }, [from, to]); + + useEffect(() => { + fetchData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const chartData = useMemo(() => { + return rows.map((r) => { + const d = new Date(r.prod_date); + const label = `${d.getMonth() + 1}/${d.getDate()}`; + const hourly = Number(r.work_hours) > 0 + ? Number(r.production_qty) / Number(r.work_hours) + : 0; + return { + label, + production_qty: Number(r.production_qty) || 0, + work_hours: Number(r.work_hours) || 0, + hourly: Math.round(hourly * 100) / 100, + }; + }); + }, [rows]); + + const summary = useMemo(() => { + const totalQty = rows.reduce((a, r) => a + (Number(r.production_qty) || 0), 0); + const totalHours = rows.reduce((a, r) => a + (Number(r.work_hours) || 0), 0); + const totalDefect = rows.reduce((a, r) => a + (Number(r.defect_qty) || 0), 0); + const hourly = totalHours > 0 ? totalQty / totalHours : 0; + return { + totalQty, + totalHours: Math.round(totalHours * 100) / 100, + totalDefect, + hourly: Math.round(hourly * 100) / 100, + }; + }, [rows]); + + const columns = useMemo(() => [ + { key: "prod_date", label: "일자", width: "120px", + render: (_v, row) => row.prod_date ? String(row.prod_date).slice(0, 10) : "-" }, + { key: "production_qty", label: "생산수량", width: "120px", align: "right", formatNumber: true }, + { key: "defect_qty", label: "불량수량", width: "100px", align: "right", formatNumber: true }, + { key: "work_hours", label: "작업시간(h)", width: "120px", align: "right", + render: (_v, row) => Number(row.work_hours || 0).toFixed(2) }, + { key: "hourly", label: "시간당 생산량", width: "140px", align: "right", + render: (_v, row) => { + const wh = Number(row.work_hours) || 0; + if (wh === 0) return "-"; + const v = Number(row.production_qty) / wh; + return v.toFixed(2); + } }, + ], []); + + return ( +
+
+
+ + setFrom(e.target.value)} className="h-9 w-40" /> +
+
+ + setTo(e.target.value)} className="h-9 w-40" /> +
+ +
+ +
+ +
총 생산수량
+
{summary.totalQty.toLocaleString()}
+
+ +
총 작업시간 (h)
+
{summary.totalHours.toLocaleString()}
+
+ +
총 불량수량
+
{summary.totalDefect.toLocaleString()}
+
+ +
시간당 평균 생산량
+
{summary.hourly.toLocaleString()}
+
+
+ + +
일별 생산량 / 작업시간
+
+ + + + + + + + + + + + +
+
+ +
+ r.prod_date} + columnOrderKey="c16-kpi-daily-production" + /> +
+
+ ); +} 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 6eae857e..cd159a0b 100644 --- a/frontend/app/(main)/COMPANY_16/production/plan-management/page.tsx +++ b/frontend/app/(main)/COMPANY_16/production/plan-management/page.tsx @@ -139,10 +139,11 @@ export default function ProductionPlanManagementPage() { // 데이터 상태 const [orderItems, setOrderItems] = useState([]); - // 좌측 수주목록 페이지네이션 + // 좌측 수주목록 페이지네이션 (서버 페이징) const [orderPage, setOrderPage] = useState(1); const [orderPageSize, setOrderPageSize] = useState(20); const [orderPageSizeInput, setOrderPageSizeInput] = useState("20"); + const [orderTotalCount, setOrderTotalCount] = useState(0); const [stockItems, setStockItems] = useState([]); const [finishedPlans, setFinishedPlans] = useState([]); const [semiPlans, setSemiPlans] = useState([]); @@ -210,22 +211,25 @@ export default function ProductionPlanManagementPage() { const res = await getOrderSummary({ excludePlanned: filterUnplannedOrdersOnly, itemCode: searchItemCode || undefined, + page: orderPage, + size: orderPageSize, }); - if (res.success) setOrderItems(res.data || []); + if (res.success) { + setOrderItems(res.data || []); + setOrderTotalCount(res.total ?? res.data?.length ?? 0); + } } catch (err: any) { toast.error("수주 데이터 조회 실패: " + (err.message || "")); } finally { setLoadingOrders(false); } - }, [filterUnplannedOrdersOnly, searchItemCode]); + }, [filterUnplannedOrdersOnly, searchItemCode, orderPage, orderPageSize]); // 수주목록 페이지네이션 계산 - const orderTotalPages = Math.max(1, Math.ceil(orderItems.length / orderPageSize)); + const orderTotalPages = Math.max(1, Math.ceil(orderTotalCount / orderPageSize)); const orderSafePage = Math.min(Math.max(1, orderPage), orderTotalPages); - const paginatedOrderItems = useMemo(() => { - const start = (orderSafePage - 1) * orderPageSize; - return orderItems.slice(start, start + orderPageSize); - }, [orderItems, orderSafePage, orderPageSize]); + // 서버 페이징: 응답 자체가 페이지 데이터이므로 slice 불필요 + const paginatedOrderItems = orderItems; const applyOrderPageSize = () => { const n = parseInt(orderPageSizeInput, 10); @@ -247,8 +251,8 @@ export default function ProductionPlanManagementPage() { return pages; }; - // orderItems 변경 시 1페이지로 리셋 - useEffect(() => { setOrderPage(1); }, [orderItems.length]); + // 검색 필터 변경 시 1페이지로 리셋 + useEffect(() => { setOrderPage(1); }, [filterUnplannedOrdersOnly, searchItemCode]); const fetchStockShortage = useCallback(async () => { setLoadingStock(true); @@ -265,19 +269,28 @@ export default function ProductionPlanManagementPage() { const fetchPlans = useCallback(async () => { setLoadingPlans(true); try { + // 타임라인 성능: 기간 필터 미입력 시 기본 오늘 ~ +60일 자동 적용 + // 이전 기록은 검색에서 시작일/종료일을 직접 지정하면 조회됨 + const today = new Date(); + const fmt = (d: Date) => d.toISOString().slice(0, 10); + const defaultStart = fmt(today); + const defaultEnd = fmt(new Date(today.getTime() + 60 * 86400000)); + const effectiveStart = searchStartDate || defaultStart; + const effectiveEnd = searchEndDate || defaultEnd; + const [finRes, semiRes] = await Promise.all([ getPlans({ productType: "완제품", status: searchStatus !== "all" ? searchStatus : undefined, - startDate: searchStartDate || undefined, - endDate: searchEndDate || undefined, + startDate: effectiveStart, + endDate: effectiveEnd, itemCode: searchItemCode || undefined, }), getPlans({ productType: "반제품", status: searchStatus !== "all" ? searchStatus : undefined, - startDate: searchStartDate || undefined, - endDate: searchEndDate || undefined, + startDate: effectiveStart, + endDate: effectiveEnd, }), ]); if (finRes.success) setFinishedPlans(finRes.data || []); @@ -329,11 +342,10 @@ export default function ProductionPlanManagementPage() { }, []); useEffect(() => { - if (searchFilters.length > 0) { - fetchOrderSummary(); - fetchPlans(); - } - }, [searchItemCode, searchStatus, searchStartDate, searchEndDate]); + // 검색 state 변경 시 자동 재조회 (필터 비어있어도 default 기간으로 재조회) + fetchOrderSummary(); + fetchPlans(); + }, [searchItemCode, searchStatus, searchStartDate, searchEndDate]); // eslint-disable-line react-hooks/exhaustive-deps // ========== 토글/선택 핸들러 ========== @@ -1516,6 +1528,7 @@ export default function ProductionPlanManagementPage() { onEventClick={openScheduleDetail} onEventMove={handleEventMove} onEventResize={handleEventResize} + onRangeChange={(s, e) => { setSearchStartDate(s); setSearchEndDate(e); }} />
@@ -1585,6 +1598,7 @@ export default function ProductionPlanManagementPage() { onEventClick={openScheduleDetail} onEventMove={handleEventMove} onEventResize={handleEventResize} + onRangeChange={(s, e) => { setSearchStartDate(s); setSearchEndDate(e); }} />
diff --git a/frontend/app/(main)/COMPANY_16/production/process-info/ItemRoutingTab.tsx b/frontend/app/(main)/COMPANY_16/production/process-info/ItemRoutingTab.tsx index 18bb8106..6cee0035 100644 --- a/frontend/app/(main)/COMPANY_16/production/process-info/ItemRoutingTab.tsx +++ b/frontend/app/(main)/COMPANY_16/production/process-info/ItemRoutingTab.tsx @@ -91,6 +91,7 @@ export function ItemRoutingTab() { const [formRequired, setFormRequired] = useState("Y"); const [formFixedOrder, setFormFixedOrder] = useState("Y"); const [formWorkType, setFormWorkType] = useState("내부"); + const [formExecutionType, setFormExecutionType] = useState("serial"); // serial=순차 / parallel=병렬 const [formStandardTime, setFormStandardTime] = useState(""); const [formOutsources, setFormOutsources] = useState([]); const [subcontractorOptions, setSubcontractorOptions] = useState<{ id: string; code: string; name: string }[]>([]); @@ -281,6 +282,7 @@ export function ItemRoutingTab() { setFormRequired("Y"); setFormFixedOrder("Y"); setFormWorkType("내부"); + setFormExecutionType("serial"); setFormStandardTime(""); setFormOutsources([]); setDetailDialogOpen(true); @@ -308,6 +310,7 @@ export function ItemRoutingTab() { setFormRequired(row.is_required === "N" ? "N" : "Y"); setFormFixedOrder(row.is_fixed_order === "N" ? "N" : "Y"); setFormWorkType(row.work_type || "내부"); + setFormExecutionType(row.execution_type === "parallel" ? "parallel" : "serial"); setFormStandardTime(row.standard_time || ""); // 우선순위: id 배열 → legacy code 배열(id로 역변환) → legacy 단일 code(id로 역변환) let loadedIds: string[] = []; @@ -362,6 +365,7 @@ export function ItemRoutingTab() { standard_time: st || "0", outsource_supplier: outsourcePrimaryCode, outsource_supplier_ids: outsourceIds, + execution_type: formExecutionType, }; setDetails((prev) => sortDetailsBySeq([...prev, newRow])); toast.success("공정이 추가되었어요. 저장을 눌러 반영해주세요"); @@ -381,6 +385,7 @@ export function ItemRoutingTab() { standard_time: st || "0", outsource_supplier: outsourcePrimaryCode, outsource_supplier_ids: outsourceIds, + execution_type: formExecutionType, } : d, ), @@ -418,6 +423,7 @@ export function ItemRoutingTab() { standard_time: String(d.standard_time ?? "0"), outsource_supplier: d.outsource_supplier || "", outsource_supplier_ids: d.outsource_supplier_ids || [], + execution_type: d.execution_type || "serial", })); setSaving(true); @@ -514,6 +520,7 @@ export function ItemRoutingTab() { ...d, process_display: d.process_name || d.process_code, outsource_display: names.length === 0 ? "—" : names.join(", "), + execution_display: d.execution_type === "parallel" ? "병렬" : "순차", }; }), [details, subcontractorOptions], @@ -777,6 +784,7 @@ export function ItemRoutingTab() { { key: "process_display", label: "공정명" }, { key: "is_required", label: "필수", width: "w-[80px]", align: "center" as const }, { key: "is_fixed_order", label: "순서고정", width: "w-[90px]", align: "center" as const }, + { key: "execution_display", label: "실행방식", width: "w-[90px]", align: "center" as const }, { key: "work_type", label: "작업구분", width: "w-[100px]" }, { key: "standard_time", label: "표준시간", width: "w-[90px]", align: "right" as const }, { key: "outsource_display", label: "외주업체" }, @@ -913,18 +921,32 @@ export function ItemRoutingTab() { -
- - +
+
+ + +
+
+ + +
diff --git a/frontend/app/(main)/COMPANY_16/production/result/page.tsx b/frontend/app/(main)/COMPANY_16/production/result/page.tsx index 151bc0bf..6d1c49be 100644 --- a/frontend/app/(main)/COMPANY_16/production/result/page.tsx +++ b/frontend/app/(main)/COMPANY_16/production/result/page.tsx @@ -182,13 +182,55 @@ export default function ProductionResultPage() { const load = async () => { setProcessLoading(true); try { + // 1) 공정 마스터 조회 const res = await apiClient.post(`/table-management/tables/${WOP_TABLE}/data`, { page: 1, size: 0, dataFilter: { enabled: true, filters: [{ columnName: "wo_id", operator: "equals", value: selectedWiId }] }, autoFilter: true, sort: { columnName: "seq_no", order: "asc" }, }); - setProcessData(res.data?.data?.data || res.data?.data?.rows || []); + const wops: any[] = res.data?.data?.data || res.data?.data?.rows || []; + + // 2) 공정별 실적(work_order_process_result) 합계 조인 — 화면이 양품/불량/입력 합계를 보여주려면 result를 별도 집계해 매핑해야 함 + if (wops.length > 0) { + const wopIds = wops.map((w) => w.id); + try { + const wr = await apiClient.post(`/table-management/tables/work_order_process_result/data`, { + page: 1, size: 0, + dataFilter: { enabled: true, filters: [{ columnName: "wop_id", operator: "in", value: wopIds }] }, + autoFilter: true, + }); + const results: any[] = wr.data?.data?.data || wr.data?.data?.rows || []; + const agg = new Map }>(); + for (const r of results) { + const cur = agg.get(r.wop_id) || { good: 0, defect: 0, input: 0, firstStart: null, lastEnd: null, statuses: new Set() }; + cur.good += Number(r.good_qty) || 0; + cur.defect += Number(r.defect_qty) || 0; + cur.input += Number(r.input_qty) || 0; + if (r.started_at && (!cur.firstStart || String(r.started_at) < cur.firstStart)) cur.firstStart = String(r.started_at); + if (r.completed_at && (!cur.lastEnd || String(r.completed_at) > cur.lastEnd)) cur.lastEnd = String(r.completed_at); + if (r.status) cur.statuses.add(String(r.status)); + agg.set(r.wop_id, cur); + } + const enriched = wops.map((w) => { + const a = agg.get(w.id); + return { + ...w, + good_qty: a?.good ?? 0, + defect_qty: a?.defect ?? 0, + input_qty: a?.input ?? 0, + started_at: a?.firstStart ?? null, + completed_at: a?.lastEnd ?? null, + result_status: a?.statuses.has("completed") ? "completed" : (a?.statuses.values().next().value ?? null), + }; + }); + setProcessData(enriched); + } catch { + setProcessData(wops); + } + } else { + setProcessData([]); + } } catch { setProcessData([]); } finally { setProcessLoading(false); } }; diff --git a/frontend/app/(main)/COMPANY_16/quality/item-inspection/page.tsx b/frontend/app/(main)/COMPANY_16/quality/item-inspection/page.tsx index 6bdf9f93..71f21a18 100644 --- a/frontend/app/(main)/COMPANY_16/quality/item-inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_16/quality/item-inspection/page.tsx @@ -33,6 +33,7 @@ const INSPECTION_TABLE = "inspection_standard"; const GRID_COLUMNS = [ { key: "item_code", label: "품목코드" }, { key: "item_name", label: "품명" }, + { key: "size", label: "규격" }, { key: "inspection_type", label: "검사유형" }, { key: "is_active", label: "사용여부" }, ]; @@ -453,11 +454,13 @@ export default function ItemInspectionInfoPage() { // item_code별 그룹핑 const groupedData = useMemo(() => { - const map: Record = {}; + const itemSizeMap: Record = {}; + for (const it of itemOptions) itemSizeMap[it.code] = it.size || ""; + const map: Record = {}; for (const row of data) { const key = row.item_code || row.id; if (!map[key]) { - map[key] = { item_code: row.item_code, item_name: row.item_name, is_active: row.is_active || "", types: [], rows: [] }; + map[key] = { item_code: row.item_code, item_name: row.item_name, size: itemSizeMap[row.item_code] || "", is_active: row.is_active || "", types: [], rows: [] }; } map[key].rows.push(row); if (row.inspection_type && !map[key].types.includes(row.inspection_type)) { @@ -465,7 +468,7 @@ export default function ItemInspectionInfoPage() { } } return Object.values(map); - }, [data]); + }, [data, itemOptions]); // 좌측 품목 목록 정렬 (컬럼 헤더 클릭 → asc → desc → 해제 순환) const [sortConfig, setSortConfig] = useState<{ key: string; direction: "asc" | "desc" } | null>(null); @@ -1046,12 +1049,13 @@ export default function ItemInspectionInfoPage() { switch (col.key) { case "item_code": return {group.item_code}; case "item_name": return {group.item_name}; + case "size": return {group.size}; case "inspection_type": return ( -
+
{group.types.map((t: string) => { const label = inspTypeCatOptions.find((o) => o.code === t)?.label || t; - return {label}; + return {label}; })}
diff --git a/frontend/app/(main)/COMPANY_16/sales/shipping-order/page.tsx b/frontend/app/(main)/COMPANY_16/sales/shipping-order/page.tsx index 2ed29b40..a8c95d2e 100644 --- a/frontend/app/(main)/COMPANY_16/sales/shipping-order/page.tsx +++ b/frontend/app/(main)/COMPANY_16/sales/shipping-order/page.tsx @@ -97,6 +97,11 @@ export default function ShippingOrderPage() { // 검색 필터 (DynamicSearchFilter에서 관리) const [searchFilters, setSearchFilters] = useState([]); + // 서버 페이징 + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(50); + const [totalCount, setTotalCount] = useState(0); + // 엑셀 업로드 const [excelUploadOpen, setExcelUploadOpen] = useState(false); @@ -136,7 +141,7 @@ export default function ShippingOrderPage() { const fetchOrders = useCallback(async () => { setLoading(true); try { - const params: any = {}; + const params: any = { page: currentPage, size: pageSize }; for (const f of searchFilters) { if (f.columnName === "ship_date" && f.operator === "between" && f.value) { const [from, to] = f.value.split(","); @@ -152,18 +157,25 @@ export default function ShippingOrderPage() { } const result = await getShippingOrderList(params); - if (result.success) setOrders(result.data || []); + if (result.success) { + setOrders(result.data || []); + setTotalCount(result.total ?? result.data?.length ?? 0); + } } catch (err) { console.error("출하지시 조회 실패:", err); } finally { setLoading(false); } - }, [searchFilters]); + }, [searchFilters, currentPage, pageSize]); useEffect(() => { fetchOrders(); }, [fetchOrders]); + useEffect(() => { + setCurrentPage(1); + }, [searchFilters]); + // 소스 데이터 조회 const fetchSourceData = useCallback(async (pageOverride?: number) => { setSourceLoading(true); @@ -473,7 +485,7 @@ export default function ShippingOrderPage() { tableName={ts.tableName} filterId="c16-shipping-order" onFilterChange={setSearchFilters} - dataCount={orders.length} + dataCount={totalCount} externalFilterConfig={ts.filterConfig} /> @@ -482,7 +494,7 @@ export default function ShippingOrderPage() {

출하지시 관리

- {orders.length}건 + {totalCount}건 {loading && }
@@ -548,6 +560,13 @@ export default function ShippingOrderPage() { onRowClick={(row) => setSelectedOrderId(row._orderId)} onRowDoubleClick={(row) => openModal(row._order)} showPagination + serverPagination + serverCurrentPage={currentPage} + serverPageSize={pageSize} + serverTotalCount={totalCount} + onServerPageChange={setCurrentPage} + onServerPageSizeChange={(s) => { setPageSize(s); setCurrentPage(1); }} + defaultPageSize={pageSize} draggableColumns={false} columnOrderKey="c16-shipping-order" /> diff --git a/frontend/app/(main)/COMPANY_16/sales/shipping-plan/page.tsx b/frontend/app/(main)/COMPANY_16/sales/shipping-plan/page.tsx index 747ac23d..9e0398f9 100644 --- a/frontend/app/(main)/COMPANY_16/sales/shipping-plan/page.tsx +++ b/frontend/app/(main)/COMPANY_16/sales/shipping-plan/page.tsx @@ -69,6 +69,11 @@ export default function ShippingPlanPage() { // 검색 필터 (DynamicSearchFilter에서 관리) const [searchFilters, setSearchFilters] = useState([]); + // 서버 페이징 + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(50); + const [totalCount, setTotalCount] = useState(0); + // 상세 패널 편집 const [editPlanQty, setEditPlanQty] = useState(""); const [editPlanDate, setEditPlanDate] = useState(""); @@ -81,7 +86,7 @@ export default function ShippingPlanPage() { const fetchData = useCallback(async () => { setLoading(true); try { - const params: any = {}; + const params: any = { page: currentPage, size: pageSize }; for (const f of searchFilters) { if (f.columnName === "plan_date" && f.operator === "between" && f.value) { const [from, to] = f.value.split(","); @@ -99,19 +104,24 @@ export default function ShippingPlanPage() { const result = await getShipmentPlanList(params); if (result.success) { setData(result.data || []); + setTotalCount(result.total ?? result.data?.length ?? 0); } } catch (err) { console.error("출하계획 조회 실패:", err); } finally { setLoading(false); } - }, [searchFilters]); + }, [searchFilters, currentPage, pageSize]); - // searchFilters 변경 시 자동 조회 + // searchFilters 변경 시 자동 조회 + 1페이지로 리셋 useEffect(() => { fetchData(); }, [fetchData]); + useEffect(() => { + setCurrentPage(1); + }, [searchFilters]); + const selectedPlan = useMemo(() => data.find(p => p.id === selectedId), [data, selectedId]); const groupedData = useMemo(() => { @@ -209,7 +219,7 @@ export default function ShippingPlanPage() { tableName="shipment_plan" filterId="c16-shipping-plan" onFilterChange={setSearchFilters} - dataCount={data.length} + dataCount={totalCount} externalFilterConfig={ts.filterConfig} /> @@ -224,7 +234,7 @@ export default function ShippingPlanPage() {
출하계획 목록 - {data.length}건 + {totalCount}건 {loading && }
@@ -262,7 +272,14 @@ export default function ShippingPlanPage() { showCheckbox checkedIds={checkedIds.map(String)} onCheckedChange={(ids) => setCheckedIds(ids.map(Number))} - showPagination={false} + showPagination + serverPagination + serverCurrentPage={currentPage} + serverPageSize={pageSize} + serverTotalCount={totalCount} + onServerPageChange={setCurrentPage} + onServerPageSizeChange={(s) => { setPageSize(s); setCurrentPage(1); }} + defaultPageSize={pageSize} draggableColumns={false} />
diff --git a/frontend/app/(main)/COMPANY_29/production/plan-management/page.tsx b/frontend/app/(main)/COMPANY_29/production/plan-management/page.tsx index 6eae857e..cd159a0b 100644 --- a/frontend/app/(main)/COMPANY_29/production/plan-management/page.tsx +++ b/frontend/app/(main)/COMPANY_29/production/plan-management/page.tsx @@ -139,10 +139,11 @@ export default function ProductionPlanManagementPage() { // 데이터 상태 const [orderItems, setOrderItems] = useState([]); - // 좌측 수주목록 페이지네이션 + // 좌측 수주목록 페이지네이션 (서버 페이징) const [orderPage, setOrderPage] = useState(1); const [orderPageSize, setOrderPageSize] = useState(20); const [orderPageSizeInput, setOrderPageSizeInput] = useState("20"); + const [orderTotalCount, setOrderTotalCount] = useState(0); const [stockItems, setStockItems] = useState([]); const [finishedPlans, setFinishedPlans] = useState([]); const [semiPlans, setSemiPlans] = useState([]); @@ -210,22 +211,25 @@ export default function ProductionPlanManagementPage() { const res = await getOrderSummary({ excludePlanned: filterUnplannedOrdersOnly, itemCode: searchItemCode || undefined, + page: orderPage, + size: orderPageSize, }); - if (res.success) setOrderItems(res.data || []); + if (res.success) { + setOrderItems(res.data || []); + setOrderTotalCount(res.total ?? res.data?.length ?? 0); + } } catch (err: any) { toast.error("수주 데이터 조회 실패: " + (err.message || "")); } finally { setLoadingOrders(false); } - }, [filterUnplannedOrdersOnly, searchItemCode]); + }, [filterUnplannedOrdersOnly, searchItemCode, orderPage, orderPageSize]); // 수주목록 페이지네이션 계산 - const orderTotalPages = Math.max(1, Math.ceil(orderItems.length / orderPageSize)); + const orderTotalPages = Math.max(1, Math.ceil(orderTotalCount / orderPageSize)); const orderSafePage = Math.min(Math.max(1, orderPage), orderTotalPages); - const paginatedOrderItems = useMemo(() => { - const start = (orderSafePage - 1) * orderPageSize; - return orderItems.slice(start, start + orderPageSize); - }, [orderItems, orderSafePage, orderPageSize]); + // 서버 페이징: 응답 자체가 페이지 데이터이므로 slice 불필요 + const paginatedOrderItems = orderItems; const applyOrderPageSize = () => { const n = parseInt(orderPageSizeInput, 10); @@ -247,8 +251,8 @@ export default function ProductionPlanManagementPage() { return pages; }; - // orderItems 변경 시 1페이지로 리셋 - useEffect(() => { setOrderPage(1); }, [orderItems.length]); + // 검색 필터 변경 시 1페이지로 리셋 + useEffect(() => { setOrderPage(1); }, [filterUnplannedOrdersOnly, searchItemCode]); const fetchStockShortage = useCallback(async () => { setLoadingStock(true); @@ -265,19 +269,28 @@ export default function ProductionPlanManagementPage() { const fetchPlans = useCallback(async () => { setLoadingPlans(true); try { + // 타임라인 성능: 기간 필터 미입력 시 기본 오늘 ~ +60일 자동 적용 + // 이전 기록은 검색에서 시작일/종료일을 직접 지정하면 조회됨 + const today = new Date(); + const fmt = (d: Date) => d.toISOString().slice(0, 10); + const defaultStart = fmt(today); + const defaultEnd = fmt(new Date(today.getTime() + 60 * 86400000)); + const effectiveStart = searchStartDate || defaultStart; + const effectiveEnd = searchEndDate || defaultEnd; + const [finRes, semiRes] = await Promise.all([ getPlans({ productType: "완제품", status: searchStatus !== "all" ? searchStatus : undefined, - startDate: searchStartDate || undefined, - endDate: searchEndDate || undefined, + startDate: effectiveStart, + endDate: effectiveEnd, itemCode: searchItemCode || undefined, }), getPlans({ productType: "반제품", status: searchStatus !== "all" ? searchStatus : undefined, - startDate: searchStartDate || undefined, - endDate: searchEndDate || undefined, + startDate: effectiveStart, + endDate: effectiveEnd, }), ]); if (finRes.success) setFinishedPlans(finRes.data || []); @@ -329,11 +342,10 @@ export default function ProductionPlanManagementPage() { }, []); useEffect(() => { - if (searchFilters.length > 0) { - fetchOrderSummary(); - fetchPlans(); - } - }, [searchItemCode, searchStatus, searchStartDate, searchEndDate]); + // 검색 state 변경 시 자동 재조회 (필터 비어있어도 default 기간으로 재조회) + fetchOrderSummary(); + fetchPlans(); + }, [searchItemCode, searchStatus, searchStartDate, searchEndDate]); // eslint-disable-line react-hooks/exhaustive-deps // ========== 토글/선택 핸들러 ========== @@ -1516,6 +1528,7 @@ export default function ProductionPlanManagementPage() { onEventClick={openScheduleDetail} onEventMove={handleEventMove} onEventResize={handleEventResize} + onRangeChange={(s, e) => { setSearchStartDate(s); setSearchEndDate(e); }} />
@@ -1585,6 +1598,7 @@ export default function ProductionPlanManagementPage() { onEventClick={openScheduleDetail} onEventMove={handleEventMove} onEventResize={handleEventResize} + onRangeChange={(s, e) => { setSearchStartDate(s); setSearchEndDate(e); }} />
diff --git a/frontend/app/(main)/COMPANY_29/production/process-info/ItemRoutingTab.tsx b/frontend/app/(main)/COMPANY_29/production/process-info/ItemRoutingTab.tsx index 18bb8106..6cee0035 100644 --- a/frontend/app/(main)/COMPANY_29/production/process-info/ItemRoutingTab.tsx +++ b/frontend/app/(main)/COMPANY_29/production/process-info/ItemRoutingTab.tsx @@ -91,6 +91,7 @@ export function ItemRoutingTab() { const [formRequired, setFormRequired] = useState("Y"); const [formFixedOrder, setFormFixedOrder] = useState("Y"); const [formWorkType, setFormWorkType] = useState("내부"); + const [formExecutionType, setFormExecutionType] = useState("serial"); // serial=순차 / parallel=병렬 const [formStandardTime, setFormStandardTime] = useState(""); const [formOutsources, setFormOutsources] = useState([]); const [subcontractorOptions, setSubcontractorOptions] = useState<{ id: string; code: string; name: string }[]>([]); @@ -281,6 +282,7 @@ export function ItemRoutingTab() { setFormRequired("Y"); setFormFixedOrder("Y"); setFormWorkType("내부"); + setFormExecutionType("serial"); setFormStandardTime(""); setFormOutsources([]); setDetailDialogOpen(true); @@ -308,6 +310,7 @@ export function ItemRoutingTab() { setFormRequired(row.is_required === "N" ? "N" : "Y"); setFormFixedOrder(row.is_fixed_order === "N" ? "N" : "Y"); setFormWorkType(row.work_type || "내부"); + setFormExecutionType(row.execution_type === "parallel" ? "parallel" : "serial"); setFormStandardTime(row.standard_time || ""); // 우선순위: id 배열 → legacy code 배열(id로 역변환) → legacy 단일 code(id로 역변환) let loadedIds: string[] = []; @@ -362,6 +365,7 @@ export function ItemRoutingTab() { standard_time: st || "0", outsource_supplier: outsourcePrimaryCode, outsource_supplier_ids: outsourceIds, + execution_type: formExecutionType, }; setDetails((prev) => sortDetailsBySeq([...prev, newRow])); toast.success("공정이 추가되었어요. 저장을 눌러 반영해주세요"); @@ -381,6 +385,7 @@ export function ItemRoutingTab() { standard_time: st || "0", outsource_supplier: outsourcePrimaryCode, outsource_supplier_ids: outsourceIds, + execution_type: formExecutionType, } : d, ), @@ -418,6 +423,7 @@ export function ItemRoutingTab() { standard_time: String(d.standard_time ?? "0"), outsource_supplier: d.outsource_supplier || "", outsource_supplier_ids: d.outsource_supplier_ids || [], + execution_type: d.execution_type || "serial", })); setSaving(true); @@ -514,6 +520,7 @@ export function ItemRoutingTab() { ...d, process_display: d.process_name || d.process_code, outsource_display: names.length === 0 ? "—" : names.join(", "), + execution_display: d.execution_type === "parallel" ? "병렬" : "순차", }; }), [details, subcontractorOptions], @@ -777,6 +784,7 @@ export function ItemRoutingTab() { { key: "process_display", label: "공정명" }, { key: "is_required", label: "필수", width: "w-[80px]", align: "center" as const }, { key: "is_fixed_order", label: "순서고정", width: "w-[90px]", align: "center" as const }, + { key: "execution_display", label: "실행방식", width: "w-[90px]", align: "center" as const }, { key: "work_type", label: "작업구분", width: "w-[100px]" }, { key: "standard_time", label: "표준시간", width: "w-[90px]", align: "right" as const }, { key: "outsource_display", label: "외주업체" }, @@ -913,18 +921,32 @@ export function ItemRoutingTab() { -
- - +
+
+ + +
+
+ + +
diff --git a/frontend/app/(main)/COMPANY_29/quality/item-inspection/page.tsx b/frontend/app/(main)/COMPANY_29/quality/item-inspection/page.tsx index 74fb5981..45042ac7 100644 --- a/frontend/app/(main)/COMPANY_29/quality/item-inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_29/quality/item-inspection/page.tsx @@ -33,6 +33,7 @@ const INSPECTION_TABLE = "inspection_standard"; const GRID_COLUMNS = [ { key: "item_code", label: "품목코드" }, { key: "item_name", label: "품명" }, + { key: "size", label: "규격" }, { key: "inspection_type", label: "검사유형" }, { key: "is_active", label: "사용여부" }, ]; @@ -74,7 +75,7 @@ export default function ItemInspectionInfoPage() { const [saving, setSaving] = useState(false); // FK 옵션 - const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string }[]>([]); + const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; size: string; unit: string }[]>([]); const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; judgment_criteria: string; selection_options: string; unit: string; types: string[] }[]>([]); const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]); @@ -130,6 +131,7 @@ export default function ItemInspectionInfoPage() { code: r.item_number || r.item_code || "", name: r.item_name || "", item_type: r.type || r.item_type || "", + size: r.size || "", unit: r.inventory_unit || "", }))); @@ -239,7 +241,7 @@ export default function ItemInspectionInfoPage() { const resData = res.data?.data; const rows = resData?.data || resData?.rows || []; const cm = itemCatMapRef.current; - setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: cm["type"]?.[r.type] || r.type || "", unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "" }))); + setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: cm["type"]?.[r.type] || r.type || "", size: r.size || "", unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "" }))); setItemTotal(resData?.total || resData?.totalCount || rows.length); } catch { /* skip */ } finally { setItemSearchLoading(false); } }; @@ -459,11 +461,13 @@ export default function ItemInspectionInfoPage() { // item_code별 그룹핑 const groupedData = useMemo(() => { - const map: Record = {}; + const itemSizeMap: Record = {}; + for (const it of itemOptions) itemSizeMap[it.code] = it.size || ""; + const map: Record = {}; for (const row of data) { const key = row.item_code || row.id; if (!map[key]) { - map[key] = { item_code: row.item_code, item_name: row.item_name, is_active: row.is_active || "", types: [], rows: [] }; + map[key] = { item_code: row.item_code, item_name: row.item_name, size: itemSizeMap[row.item_code] || "", is_active: row.is_active || "", types: [], rows: [] }; } map[key].rows.push(row); if (row.inspection_type && !map[key].types.includes(row.inspection_type)) { @@ -471,7 +475,7 @@ export default function ItemInspectionInfoPage() { } } return Object.values(map); - }, [data]); + }, [data, itemOptions]); // 좌측 품목 목록 정렬 (컬럼 헤더 클릭 → asc → desc → 해제 순환) const [sortConfig, setSortConfig] = useState<{ key: string; direction: "asc" | "desc" } | null>(null); @@ -1052,12 +1056,13 @@ export default function ItemInspectionInfoPage() { switch (col.key) { case "item_code": return {group.item_code}; case "item_name": return {group.item_name}; + case "size": return {group.size}; case "inspection_type": return ( -
+
{group.types.map((t: string) => { const label = inspTypeCatOptions.find((o) => o.code === t)?.label || t; - return {label}; + return {label}; })}
@@ -1252,17 +1257,19 @@ export default function ItemInspectionInfoPage() { 품목코드 품목명 + 규격 품목유형 단위 {filteredItems.length === 0 ? ( - {itemSearchLoading ? "검색 중..." : "검색 결과가 없어요"} + {itemSearchLoading ? "검색 중..." : "검색 결과가 없어요"} ) : filteredItems.map((item) => ( selectItem(item)}> {item.code} {item.name} + {item.size} {item.item_type} {item.unit} diff --git a/frontend/app/(main)/COMPANY_29/sales/shipping-order/page.tsx b/frontend/app/(main)/COMPANY_29/sales/shipping-order/page.tsx index 2ed29b40..a8c95d2e 100644 --- a/frontend/app/(main)/COMPANY_29/sales/shipping-order/page.tsx +++ b/frontend/app/(main)/COMPANY_29/sales/shipping-order/page.tsx @@ -97,6 +97,11 @@ export default function ShippingOrderPage() { // 검색 필터 (DynamicSearchFilter에서 관리) const [searchFilters, setSearchFilters] = useState([]); + // 서버 페이징 + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(50); + const [totalCount, setTotalCount] = useState(0); + // 엑셀 업로드 const [excelUploadOpen, setExcelUploadOpen] = useState(false); @@ -136,7 +141,7 @@ export default function ShippingOrderPage() { const fetchOrders = useCallback(async () => { setLoading(true); try { - const params: any = {}; + const params: any = { page: currentPage, size: pageSize }; for (const f of searchFilters) { if (f.columnName === "ship_date" && f.operator === "between" && f.value) { const [from, to] = f.value.split(","); @@ -152,18 +157,25 @@ export default function ShippingOrderPage() { } const result = await getShippingOrderList(params); - if (result.success) setOrders(result.data || []); + if (result.success) { + setOrders(result.data || []); + setTotalCount(result.total ?? result.data?.length ?? 0); + } } catch (err) { console.error("출하지시 조회 실패:", err); } finally { setLoading(false); } - }, [searchFilters]); + }, [searchFilters, currentPage, pageSize]); useEffect(() => { fetchOrders(); }, [fetchOrders]); + useEffect(() => { + setCurrentPage(1); + }, [searchFilters]); + // 소스 데이터 조회 const fetchSourceData = useCallback(async (pageOverride?: number) => { setSourceLoading(true); @@ -473,7 +485,7 @@ export default function ShippingOrderPage() { tableName={ts.tableName} filterId="c16-shipping-order" onFilterChange={setSearchFilters} - dataCount={orders.length} + dataCount={totalCount} externalFilterConfig={ts.filterConfig} /> @@ -482,7 +494,7 @@ export default function ShippingOrderPage() {

출하지시 관리

- {orders.length}건 + {totalCount}건 {loading && }
@@ -548,6 +560,13 @@ export default function ShippingOrderPage() { onRowClick={(row) => setSelectedOrderId(row._orderId)} onRowDoubleClick={(row) => openModal(row._order)} showPagination + serverPagination + serverCurrentPage={currentPage} + serverPageSize={pageSize} + serverTotalCount={totalCount} + onServerPageChange={setCurrentPage} + onServerPageSizeChange={(s) => { setPageSize(s); setCurrentPage(1); }} + defaultPageSize={pageSize} draggableColumns={false} columnOrderKey="c16-shipping-order" /> diff --git a/frontend/app/(main)/COMPANY_29/sales/shipping-plan/page.tsx b/frontend/app/(main)/COMPANY_29/sales/shipping-plan/page.tsx index 747ac23d..9e0398f9 100644 --- a/frontend/app/(main)/COMPANY_29/sales/shipping-plan/page.tsx +++ b/frontend/app/(main)/COMPANY_29/sales/shipping-plan/page.tsx @@ -69,6 +69,11 @@ export default function ShippingPlanPage() { // 검색 필터 (DynamicSearchFilter에서 관리) const [searchFilters, setSearchFilters] = useState([]); + // 서버 페이징 + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(50); + const [totalCount, setTotalCount] = useState(0); + // 상세 패널 편집 const [editPlanQty, setEditPlanQty] = useState(""); const [editPlanDate, setEditPlanDate] = useState(""); @@ -81,7 +86,7 @@ export default function ShippingPlanPage() { const fetchData = useCallback(async () => { setLoading(true); try { - const params: any = {}; + const params: any = { page: currentPage, size: pageSize }; for (const f of searchFilters) { if (f.columnName === "plan_date" && f.operator === "between" && f.value) { const [from, to] = f.value.split(","); @@ -99,19 +104,24 @@ export default function ShippingPlanPage() { const result = await getShipmentPlanList(params); if (result.success) { setData(result.data || []); + setTotalCount(result.total ?? result.data?.length ?? 0); } } catch (err) { console.error("출하계획 조회 실패:", err); } finally { setLoading(false); } - }, [searchFilters]); + }, [searchFilters, currentPage, pageSize]); - // searchFilters 변경 시 자동 조회 + // searchFilters 변경 시 자동 조회 + 1페이지로 리셋 useEffect(() => { fetchData(); }, [fetchData]); + useEffect(() => { + setCurrentPage(1); + }, [searchFilters]); + const selectedPlan = useMemo(() => data.find(p => p.id === selectedId), [data, selectedId]); const groupedData = useMemo(() => { @@ -209,7 +219,7 @@ export default function ShippingPlanPage() { tableName="shipment_plan" filterId="c16-shipping-plan" onFilterChange={setSearchFilters} - dataCount={data.length} + dataCount={totalCount} externalFilterConfig={ts.filterConfig} /> @@ -224,7 +234,7 @@ export default function ShippingPlanPage() {
출하계획 목록 - {data.length}건 + {totalCount}건 {loading && }
@@ -262,7 +272,14 @@ export default function ShippingPlanPage() { showCheckbox checkedIds={checkedIds.map(String)} onCheckedChange={(ids) => setCheckedIds(ids.map(Number))} - showPagination={false} + showPagination + serverPagination + serverCurrentPage={currentPage} + serverPageSize={pageSize} + serverTotalCount={totalCount} + onServerPageChange={setCurrentPage} + onServerPageSizeChange={(s) => { setPageSize(s); setCurrentPage(1); }} + defaultPageSize={pageSize} draggableColumns={false} />
diff --git a/frontend/app/(main)/COMPANY_30/kpi/production/daily/page.tsx b/frontend/app/(main)/COMPANY_30/kpi/production/daily/page.tsx new file mode 100644 index 00000000..a86dac62 --- /dev/null +++ b/frontend/app/(main)/COMPANY_30/kpi/production/daily/page.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { useState, useEffect, useMemo, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card } from "@/components/ui/card"; +import { Loader2, Search } from "lucide-react"; +import { toast } from "sonner"; +import { apiClient } from "@/lib/api/client"; +import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; +import { + ComposedChart, Bar, Line, XAxis, YAxis, CartesianGrid, + Tooltip, Legend, ResponsiveContainer, +} from "recharts"; + +// TASK:ERP-022 — KPI 일별 생산량 (COMPANY_30) +type Row = { + prod_date: string; + production_qty: number; + defect_qty: number; + work_hours: number; + remark?: string | null; +}; + +// 기본 기간: 이번 달 1일 ~ 말일 +function defaultRange() { + const now = new Date(); + const y = now.getFullYear(); + const m = now.getMonth(); + const start = new Date(y, m, 1); + const end = new Date(y, m + 1, 0); + const fmt = (d: Date) => d.toISOString().slice(0, 10); + return { from: fmt(start), to: fmt(end) }; +} + +export default function KpiDailyProductionPage() { + const init = defaultRange(); + const [from, setFrom] = useState(init.from); + const [to, setTo] = useState(init.to); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + + const fetchData = useCallback(async () => { + if (!from || !to) { + toast.error("기간을 입력해주세요"); + return; + } + setLoading(true); + try { + const res = await apiClient.get(`/kpi/daily-production`, { params: { from, to } }); + const data: Row[] = res.data?.data || []; + setRows(data); + } catch (err: any) { + toast.error(err?.response?.data?.message || "조회 실패"); + } finally { + setLoading(false); + } + }, [from, to]); + + useEffect(() => { + fetchData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // 차트용 데이터: 일자만 짧게 (M/D) + const chartData = useMemo(() => { + return rows.map((r) => { + const d = new Date(r.prod_date); + const label = `${d.getMonth() + 1}/${d.getDate()}`; + const hourly = Number(r.work_hours) > 0 + ? Number(r.production_qty) / Number(r.work_hours) + : 0; + return { + label, + production_qty: Number(r.production_qty) || 0, + work_hours: Number(r.work_hours) || 0, + hourly: Math.round(hourly * 100) / 100, + }; + }); + }, [rows]); + + // 합계 + const summary = useMemo(() => { + const totalQty = rows.reduce((a, r) => a + (Number(r.production_qty) || 0), 0); + const totalHours = rows.reduce((a, r) => a + (Number(r.work_hours) || 0), 0); + const totalDefect = rows.reduce((a, r) => a + (Number(r.defect_qty) || 0), 0); + const hourly = totalHours > 0 ? totalQty / totalHours : 0; + return { + totalQty, + totalHours: Math.round(totalHours * 100) / 100, + totalDefect, + hourly: Math.round(hourly * 100) / 100, + }; + }, [rows]); + + // 테이블 컬럼 + const columns = useMemo(() => [ + { key: "prod_date", label: "일자", width: "120px", + render: (_v, row) => row.prod_date ? String(row.prod_date).slice(0, 10) : "-" }, + { key: "production_qty", label: "생산수량", width: "120px", align: "right", formatNumber: true }, + { key: "defect_qty", label: "불량수량", width: "100px", align: "right", formatNumber: true }, + { key: "work_hours", label: "작업시간(h)", width: "120px", align: "right", + render: (_v, row) => Number(row.work_hours || 0).toFixed(2) }, + { key: "hourly", label: "시간당 생산량", width: "140px", align: "right", + render: (_v, row) => { + const wh = Number(row.work_hours) || 0; + if (wh === 0) return "-"; + const v = Number(row.production_qty) / wh; + return v.toFixed(2); + } }, + ], []); + + return ( +
+ {/* 필터 바 */} +
+
+ + setFrom(e.target.value)} className="h-9 w-40" /> +
+
+ + setTo(e.target.value)} className="h-9 w-40" /> +
+ +
+ + {/* 요약 카드 */} +
+ +
총 생산수량
+
{summary.totalQty.toLocaleString()}
+
+ +
총 작업시간 (h)
+
{summary.totalHours.toLocaleString()}
+
+ +
총 불량수량
+
{summary.totalDefect.toLocaleString()}
+
+ +
시간당 평균 생산량
+
{summary.hourly.toLocaleString()}
+
+
+ + {/* 차트 */} + +
일별 생산량 / 작업시간
+
+ + + + + + + + + + + + +
+
+ + {/* 테이블 */} +
+ r.prod_date} + columnOrderKey="c30-kpi-daily-production" + /> +
+
+ ); +} diff --git a/frontend/app/(main)/COMPANY_30/monitoring/equipment/page.tsx b/frontend/app/(main)/COMPANY_30/monitoring/equipment/page.tsx index b165ff65..692b2ca5 100644 --- a/frontend/app/(main)/COMPANY_30/monitoring/equipment/page.tsx +++ b/frontend/app/(main)/COMPANY_30/monitoring/equipment/page.tsx @@ -151,6 +151,27 @@ export default function EquipmentMonitoringPage() { const [filterStatus, setFilterStatus] = useState("all"); const autoRefreshRef = useRef(autoRefresh); + // 기간 필터 (기본: 오늘) + const todayStr = new Date().toISOString().slice(0, 10); + const [dateFrom, setDateFrom] = useState(todayStr); + const [dateTo, setDateTo] = useState(todayStr); + const setRangeToday = () => { const t = new Date().toISOString().slice(0, 10); setDateFrom(t); setDateTo(t); }; + const setRangeThisWeek = () => { + const now = new Date(); + const day = now.getDay() || 7; + const mon = new Date(now); mon.setDate(now.getDate() - (day - 1)); + const sun = new Date(mon); sun.setDate(mon.getDate() + 6); + setDateFrom(mon.toISOString().slice(0, 10)); + setDateTo(sun.toISOString().slice(0, 10)); + }; + const setRangeThisMonth = () => { + const now = new Date(); + const first = new Date(now.getFullYear(), now.getMonth(), 1); + const last = new Date(now.getFullYear(), now.getMonth() + 1, 0); + setDateFrom(first.toISOString().slice(0, 10)); + setDateTo(last.toISOString().slice(0, 10)); + }; + // autoRefreshRef 동기화 useEffect(() => { autoRefreshRef.current = autoRefresh; @@ -167,16 +188,27 @@ export default function EquipmentMonitoringPage() { try { setLoading(true); const [equipRes, wiRes, procRes] = await Promise.all([ + // 설비 마스터 (기간 무관, 전체) apiClient.post("/table-management/tables/equipment_mng/data", { autoFilter: true, page: 1, size: 500, }), - apiClient.get("/work-instruction/list").catch(() => ({ data: { data: [] } })), + // 작업지시 (시작일 기준 기간) + apiClient.get("/work-instruction/list", { params: { dateFrom, dateTo } }) + .catch(() => ({ data: { data: [] } })), + // 작업공정 (생성일 기준 기간) apiClient.post("/table-management/tables/work_order_process/data", { page: 1, size: 2000, autoFilter: true, + dataFilter: { + enabled: true, + filters: [ + { columnName: "created_date", operator: "greater_or_equal", value: `${dateFrom} 00:00:00` }, + { columnName: "created_date", operator: "less_or_equal", value: `${dateTo} 23:59:59` }, + ], + }, }).catch(() => ({ data: { data: { data: [] } } })), ]); @@ -193,7 +225,7 @@ export default function EquipmentMonitoringPage() { } finally { setLoading(false); } - }, []); + }, [dateFrom, dateTo]); useEffect(() => { fetchData(); @@ -384,9 +416,9 @@ export default function EquipmentMonitoringPage() { /* ────────────── 렌더 ────────────── */ return ( -
+
{/* ── 헤더 ── */} -
+

설비운영모니터링

@@ -443,8 +475,21 @@ export default function EquipmentMonitoringPage() {
+ {/* ── 기간 필터 ── */} +
+ 조회 기간 + setDateFrom(e.target.value)} + className={cn("h-8 rounded border px-2 text-sm", theme.cardBorder, theme.card, theme.text)} /> + ~ + setDateTo(e.target.value)} + className={cn("h-8 rounded border px-2 text-sm", theme.cardBorder, theme.card, theme.text)} /> + + + +
+ {/* ── 요약 카드 5개 ── */} -
+
{summaryCards.map((card) => (
- {/* ── 로딩 ── */} + {/* ── 스크롤 영역: 로딩 / 데이터 없음 / 설비 카드 그리드 ── */} +
{loading && equipments.length === 0 && (
@@ -494,7 +540,6 @@ export default function EquipmentMonitoringPage() {
)} - {/* ── 데이터 없음 ── */} {!loading && equipments.length === 0 && (
@@ -502,7 +547,6 @@ export default function EquipmentMonitoringPage() {
)} - {/* ── 설비 카드 그리드 ── */} {filteredEquipments.length > 0 && (
{filteredEquipments.map((eq) => { @@ -661,6 +705,7 @@ export default function EquipmentMonitoringPage() {

해당 상태의 설비가 없습니다.

)} +
); } diff --git a/frontend/app/(main)/COMPANY_30/monitoring/production/page.tsx b/frontend/app/(main)/COMPANY_30/monitoring/production/page.tsx index 820a30d5..d9fa0fa8 100644 --- a/frontend/app/(main)/COMPANY_30/monitoring/production/page.tsx +++ b/frontend/app/(main)/COMPANY_30/monitoring/production/page.tsx @@ -120,6 +120,27 @@ export default function ProductionMonitoringPage() { const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh); const [activeTab, setActiveTab] = useState("전체"); + // ─── 기간 필터 (기본: 오늘) ────────────────────────────────── + const todayStr = new Date().toISOString().slice(0, 10); + const [dateFrom, setDateFrom] = useState(todayStr); + const [dateTo, setDateTo] = useState(todayStr); + const setRangeToday = () => { const t = new Date().toISOString().slice(0, 10); setDateFrom(t); setDateTo(t); }; + const setRangeThisWeek = () => { + const now = new Date(); + const day = now.getDay() || 7; // Sun=0 → 7 + const mon = new Date(now); mon.setDate(now.getDate() - (day - 1)); + const sun = new Date(mon); sun.setDate(mon.getDate() + 6); + setDateFrom(mon.toISOString().slice(0, 10)); + setDateTo(sun.toISOString().slice(0, 10)); + }; + const setRangeThisMonth = () => { + const now = new Date(); + const first = new Date(now.getFullYear(), now.getMonth(), 1); + const last = new Date(now.getFullYear(), now.getMonth() + 1, 0); + setDateFrom(first.toISOString().slice(0, 10)); + setDateTo(last.toISOString().slice(0, 10)); + }; + // ─── 실시간 시계 ───────────────────────────────────────── useEffect(() => { const timer = setInterval(() => setCurrentTime(new Date()), 1000); @@ -131,8 +152,10 @@ export default function ProductionMonitoringPage() { try { setLoading(true); - // 작업지시 목록 조회 - const wiRes = await apiClient.get("/work-instruction/list"); + // 작업지시 목록 조회 — 기간(시작일) 필터 적용 + const wiRes = await apiClient.get("/work-instruction/list", { + params: { dateFrom, dateTo }, + }); const wiRaw: WorkInstruction[] = wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : []; // 작업지시번호 기준 중복 제거 (디테일 JOIN으로 중복 발생 가능) const seen = new Set(); @@ -144,12 +167,19 @@ export default function ProductionMonitoringPage() { }); setWorkInstructions(wiData); - // 공정현황 조회 (실패해도 작업지시는 표시) + // 공정현황 조회 (실패해도 작업지시는 표시) — 생성일(created_date) 기준 기간 필터 try { const procRes = await apiClient.post("/table-management/tables/work_order_process/data", { page: 1, size: 1000, autoFilter: true, + dataFilter: { + enabled: true, + filters: [ + { columnName: "created_date", operator: "greater_or_equal", value: `${dateFrom} 00:00:00` }, + { columnName: "created_date", operator: "less_or_equal", value: `${dateTo} 23:59:59` }, + ], + }, }); const rows: ProcessStep[] = procRes.data?.data?.data || procRes.data?.data?.rows || procRes.data?.data || []; @@ -195,7 +225,7 @@ export default function ProductionMonitoringPage() { } finally { setLoading(false); } - }, []); + }, [dateFrom, dateTo]); useEffect(() => { fetchData(); @@ -241,9 +271,9 @@ export default function ProductionMonitoringPage() { // ─── 렌더링 ────────────────────────────────────────────── return ( -
+
{/* 헤더 */} -
+

생산모니터링

@@ -275,8 +305,21 @@ export default function ProductionMonitoringPage() {
+ {/* 기간 필터 */} +
+ 조회 기간 + setDateFrom(e.target.value)} + className={cn("h-8 rounded border px-2 text-sm", theme.cardBorder, theme.card, theme.text)} /> + ~ + setDateTo(e.target.value)} + className={cn("h-8 rounded border px-2 text-sm", theme.cardBorder, theme.card, theme.text)} /> + + + +
+ {/* 요약 카드 */} -
+
} label="대기중" @@ -304,7 +347,7 @@ export default function ProductionMonitoringPage() {
{/* 탭 필터 */} -
+
{(["전체", "대기", "진행중", "완료"] as FilterTab[]).map((tab) => ( + + +
+ {/* 요약 카드 */} -
+
{summaryCards.map((card) => (

{card.label}

@@ -280,7 +323,7 @@ export default function QualityMonitoringPage() {
{/* 검사유형 탭 */} -
+
{TABS.map((tab) => (
-
- - +
+
+ + +
+
+ + +
diff --git a/frontend/app/(main)/COMPANY_30/quality/item-inspection/page.tsx b/frontend/app/(main)/COMPANY_30/quality/item-inspection/page.tsx index 6bb55a70..7c6df883 100644 --- a/frontend/app/(main)/COMPANY_30/quality/item-inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_30/quality/item-inspection/page.tsx @@ -33,6 +33,7 @@ const INSPECTION_TABLE = "inspection_standard"; const GRID_COLUMNS = [ { key: "item_code", label: "품목코드" }, { key: "item_name", label: "품명" }, + { key: "size", label: "규격" }, { key: "inspection_type", label: "검사유형" }, { key: "is_active", label: "사용여부" }, ]; @@ -74,7 +75,7 @@ export default function ItemInspectionInfoPage() { const [saving, setSaving] = useState(false); // FK 옵션 - const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string }[]>([]); + const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; size: string; unit: string }[]>([]); const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; judgment_criteria: string; selection_options: string; unit: string; types: string[] }[]>([]); const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]); @@ -130,6 +131,7 @@ export default function ItemInspectionInfoPage() { code: r.item_number || r.item_code || "", name: r.item_name || "", item_type: r.type || r.item_type || "", + size: r.size || "", unit: r.inventory_unit || "", }))); @@ -239,7 +241,7 @@ export default function ItemInspectionInfoPage() { const resData = res.data?.data; const rows = resData?.data || resData?.rows || []; const cm = itemCatMapRef.current; - setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: cm["type"]?.[r.type] || r.type || "", unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "" }))); + setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: cm["type"]?.[r.type] || r.type || "", size: r.size || "", unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "" }))); setItemTotal(resData?.total || resData?.totalCount || rows.length); } catch { /* skip */ } finally { setItemSearchLoading(false); } }; @@ -459,11 +461,13 @@ export default function ItemInspectionInfoPage() { // item_code별 그룹핑 const groupedData = useMemo(() => { - const map: Record = {}; + const itemSizeMap: Record = {}; + for (const it of itemOptions) itemSizeMap[it.code] = it.size || ""; + const map: Record = {}; for (const row of data) { const key = row.item_code || row.id; if (!map[key]) { - map[key] = { item_code: row.item_code, item_name: row.item_name, is_active: row.is_active || "", types: [], rows: [] }; + map[key] = { item_code: row.item_code, item_name: row.item_name, size: itemSizeMap[row.item_code] || "", is_active: row.is_active || "", types: [], rows: [] }; } map[key].rows.push(row); if (row.inspection_type && !map[key].types.includes(row.inspection_type)) { @@ -471,7 +475,7 @@ export default function ItemInspectionInfoPage() { } } return Object.values(map); - }, [data]); + }, [data, itemOptions]); // 좌측 품목 목록 정렬 (컬럼 헤더 클릭 → asc → desc → 해제 순환) const [sortConfig, setSortConfig] = useState<{ key: string; direction: "asc" | "desc" } | null>(null); @@ -1052,12 +1056,13 @@ export default function ItemInspectionInfoPage() { switch (col.key) { case "item_code": return {group.item_code}; case "item_name": return {group.item_name}; + case "size": return {group.size}; case "inspection_type": return ( -
+
{group.types.map((t: string) => { const label = inspTypeCatOptions.find((o) => o.code === t)?.label || t; - return {label}; + return {label}; })}
@@ -1252,17 +1257,19 @@ export default function ItemInspectionInfoPage() { 품목코드 품목명 + 규격 품목유형 단위 {filteredItems.length === 0 ? ( - {itemSearchLoading ? "검색 중..." : "검색 결과가 없어요"} + {itemSearchLoading ? "검색 중..." : "검색 결과가 없어요"} ) : filteredItems.map((item) => ( selectItem(item)}> {item.code} {item.name} + {item.size} {item.item_type} {item.unit} diff --git a/frontend/app/(main)/COMPANY_30/sales/shipping-order/page.tsx b/frontend/app/(main)/COMPANY_30/sales/shipping-order/page.tsx index d9e11ed6..1547482e 100644 --- a/frontend/app/(main)/COMPANY_30/sales/shipping-order/page.tsx +++ b/frontend/app/(main)/COMPANY_30/sales/shipping-order/page.tsx @@ -103,6 +103,11 @@ export default function ShippingOrderPage() { // 검색 필터 (DynamicSearchFilter에서 관리) const [searchFilters, setSearchFilters] = useState([]); + // 서버 페이징 + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(50); + const [totalCount, setTotalCount] = useState(0); + // 엑셀 업로드 const [excelUploadOpen, setExcelUploadOpen] = useState(false); @@ -142,7 +147,7 @@ export default function ShippingOrderPage() { const fetchOrders = useCallback(async () => { setLoading(true); try { - const params: any = {}; + const params: any = { page: currentPage, size: pageSize }; for (const f of searchFilters) { if (f.columnName === "ship_date" && f.operator === "between" && f.value) { const [from, to] = f.value.split(","); @@ -158,18 +163,25 @@ export default function ShippingOrderPage() { } const result = await getShippingOrderList(params); - if (result.success) setOrders(result.data || []); + if (result.success) { + setOrders(result.data || []); + setTotalCount(result.total ?? result.data?.length ?? 0); + } } catch (err) { console.error("출하지시 조회 실패:", err); } finally { setLoading(false); } - }, [searchFilters]); + }, [searchFilters, currentPage, pageSize]); useEffect(() => { fetchOrders(); }, [fetchOrders]); + useEffect(() => { + setCurrentPage(1); + }, [searchFilters]); + // 소스 데이터 조회 const fetchSourceData = useCallback(async (pageOverride?: number) => { setSourceLoading(true); @@ -485,7 +497,7 @@ export default function ShippingOrderPage() { tableName={ts.tableName} filterId="c16-shipping-order" onFilterChange={setSearchFilters} - dataCount={orders.length} + dataCount={totalCount} externalFilterConfig={ts.filterConfig} /> @@ -494,7 +506,7 @@ export default function ShippingOrderPage() {

출하지시 관리

- {orders.length}건 + {totalCount}건 {loading && }
@@ -560,6 +572,13 @@ export default function ShippingOrderPage() { onRowClick={(row) => setSelectedOrderId(row._orderId)} onRowDoubleClick={(row) => openModal(row._order)} showPagination + serverPagination + serverCurrentPage={currentPage} + serverPageSize={pageSize} + serverTotalCount={totalCount} + onServerPageChange={setCurrentPage} + onServerPageSizeChange={(s) => { setPageSize(s); setCurrentPage(1); }} + defaultPageSize={pageSize} draggableColumns={false} columnOrderKey="c16-shipping-order" /> diff --git a/frontend/app/(main)/COMPANY_30/sales/shipping-plan/page.tsx b/frontend/app/(main)/COMPANY_30/sales/shipping-plan/page.tsx index b890f439..fe1e9163 100644 --- a/frontend/app/(main)/COMPANY_30/sales/shipping-plan/page.tsx +++ b/frontend/app/(main)/COMPANY_30/sales/shipping-plan/page.tsx @@ -72,6 +72,11 @@ export default function ShippingPlanPage() { // 검색 필터 (DynamicSearchFilter에서 관리) const [searchFilters, setSearchFilters] = useState([]); + // 서버 페이징 + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(50); + const [totalCount, setTotalCount] = useState(0); + // 상세 패널 편집 const [editPlanQty, setEditPlanQty] = useState(""); const [editPlanDate, setEditPlanDate] = useState(""); @@ -84,7 +89,7 @@ export default function ShippingPlanPage() { const fetchData = useCallback(async () => { setLoading(true); try { - const params: any = {}; + const params: any = { page: currentPage, size: pageSize }; for (const f of searchFilters) { if (f.columnName === "plan_date" && f.operator === "between" && f.value) { const [from, to] = f.value.split(","); @@ -102,19 +107,24 @@ export default function ShippingPlanPage() { const result = await getShipmentPlanList(params); if (result.success) { setData(result.data || []); + setTotalCount(result.total ?? result.data?.length ?? 0); } } catch (err) { console.error("출하계획 조회 실패:", err); } finally { setLoading(false); } - }, [searchFilters]); + }, [searchFilters, currentPage, pageSize]); - // searchFilters 변경 시 자동 조회 + // searchFilters 변경 시 자동 조회 + 1페이지로 리셋 useEffect(() => { fetchData(); }, [fetchData]); + useEffect(() => { + setCurrentPage(1); + }, [searchFilters]); + const selectedPlan = useMemo(() => data.find(p => p.id === selectedId), [data, selectedId]); const groupedData = useMemo(() => { @@ -212,7 +222,7 @@ export default function ShippingPlanPage() { tableName="shipment_plan" filterId="c16-shipping-plan" onFilterChange={setSearchFilters} - dataCount={data.length} + dataCount={totalCount} externalFilterConfig={ts.filterConfig} /> @@ -227,7 +237,7 @@ export default function ShippingPlanPage() {
출하계획 목록 - {data.length}건 + {totalCount}건 {loading && }
@@ -268,7 +278,14 @@ export default function ShippingPlanPage() { showCheckbox checkedIds={checkedIds.map(String)} onCheckedChange={(ids) => setCheckedIds(ids.map(Number))} - showPagination={false} + showPagination + serverPagination + serverCurrentPage={currentPage} + serverPageSize={pageSize} + serverTotalCount={totalCount} + onServerPageChange={setCurrentPage} + onServerPageSizeChange={(s) => { setPageSize(s); setCurrentPage(1); }} + defaultPageSize={pageSize} draggableColumns={false} />
diff --git a/frontend/app/(main)/COMPANY_7/production/plan-management/page.tsx b/frontend/app/(main)/COMPANY_7/production/plan-management/page.tsx index 6eae857e..cd159a0b 100644 --- a/frontend/app/(main)/COMPANY_7/production/plan-management/page.tsx +++ b/frontend/app/(main)/COMPANY_7/production/plan-management/page.tsx @@ -139,10 +139,11 @@ export default function ProductionPlanManagementPage() { // 데이터 상태 const [orderItems, setOrderItems] = useState([]); - // 좌측 수주목록 페이지네이션 + // 좌측 수주목록 페이지네이션 (서버 페이징) const [orderPage, setOrderPage] = useState(1); const [orderPageSize, setOrderPageSize] = useState(20); const [orderPageSizeInput, setOrderPageSizeInput] = useState("20"); + const [orderTotalCount, setOrderTotalCount] = useState(0); const [stockItems, setStockItems] = useState([]); const [finishedPlans, setFinishedPlans] = useState([]); const [semiPlans, setSemiPlans] = useState([]); @@ -210,22 +211,25 @@ export default function ProductionPlanManagementPage() { const res = await getOrderSummary({ excludePlanned: filterUnplannedOrdersOnly, itemCode: searchItemCode || undefined, + page: orderPage, + size: orderPageSize, }); - if (res.success) setOrderItems(res.data || []); + if (res.success) { + setOrderItems(res.data || []); + setOrderTotalCount(res.total ?? res.data?.length ?? 0); + } } catch (err: any) { toast.error("수주 데이터 조회 실패: " + (err.message || "")); } finally { setLoadingOrders(false); } - }, [filterUnplannedOrdersOnly, searchItemCode]); + }, [filterUnplannedOrdersOnly, searchItemCode, orderPage, orderPageSize]); // 수주목록 페이지네이션 계산 - const orderTotalPages = Math.max(1, Math.ceil(orderItems.length / orderPageSize)); + const orderTotalPages = Math.max(1, Math.ceil(orderTotalCount / orderPageSize)); const orderSafePage = Math.min(Math.max(1, orderPage), orderTotalPages); - const paginatedOrderItems = useMemo(() => { - const start = (orderSafePage - 1) * orderPageSize; - return orderItems.slice(start, start + orderPageSize); - }, [orderItems, orderSafePage, orderPageSize]); + // 서버 페이징: 응답 자체가 페이지 데이터이므로 slice 불필요 + const paginatedOrderItems = orderItems; const applyOrderPageSize = () => { const n = parseInt(orderPageSizeInput, 10); @@ -247,8 +251,8 @@ export default function ProductionPlanManagementPage() { return pages; }; - // orderItems 변경 시 1페이지로 리셋 - useEffect(() => { setOrderPage(1); }, [orderItems.length]); + // 검색 필터 변경 시 1페이지로 리셋 + useEffect(() => { setOrderPage(1); }, [filterUnplannedOrdersOnly, searchItemCode]); const fetchStockShortage = useCallback(async () => { setLoadingStock(true); @@ -265,19 +269,28 @@ export default function ProductionPlanManagementPage() { const fetchPlans = useCallback(async () => { setLoadingPlans(true); try { + // 타임라인 성능: 기간 필터 미입력 시 기본 오늘 ~ +60일 자동 적용 + // 이전 기록은 검색에서 시작일/종료일을 직접 지정하면 조회됨 + const today = new Date(); + const fmt = (d: Date) => d.toISOString().slice(0, 10); + const defaultStart = fmt(today); + const defaultEnd = fmt(new Date(today.getTime() + 60 * 86400000)); + const effectiveStart = searchStartDate || defaultStart; + const effectiveEnd = searchEndDate || defaultEnd; + const [finRes, semiRes] = await Promise.all([ getPlans({ productType: "완제품", status: searchStatus !== "all" ? searchStatus : undefined, - startDate: searchStartDate || undefined, - endDate: searchEndDate || undefined, + startDate: effectiveStart, + endDate: effectiveEnd, itemCode: searchItemCode || undefined, }), getPlans({ productType: "반제품", status: searchStatus !== "all" ? searchStatus : undefined, - startDate: searchStartDate || undefined, - endDate: searchEndDate || undefined, + startDate: effectiveStart, + endDate: effectiveEnd, }), ]); if (finRes.success) setFinishedPlans(finRes.data || []); @@ -329,11 +342,10 @@ export default function ProductionPlanManagementPage() { }, []); useEffect(() => { - if (searchFilters.length > 0) { - fetchOrderSummary(); - fetchPlans(); - } - }, [searchItemCode, searchStatus, searchStartDate, searchEndDate]); + // 검색 state 변경 시 자동 재조회 (필터 비어있어도 default 기간으로 재조회) + fetchOrderSummary(); + fetchPlans(); + }, [searchItemCode, searchStatus, searchStartDate, searchEndDate]); // eslint-disable-line react-hooks/exhaustive-deps // ========== 토글/선택 핸들러 ========== @@ -1516,6 +1528,7 @@ export default function ProductionPlanManagementPage() { onEventClick={openScheduleDetail} onEventMove={handleEventMove} onEventResize={handleEventResize} + onRangeChange={(s, e) => { setSearchStartDate(s); setSearchEndDate(e); }} />
@@ -1585,6 +1598,7 @@ export default function ProductionPlanManagementPage() { onEventClick={openScheduleDetail} onEventMove={handleEventMove} onEventResize={handleEventResize} + onRangeChange={(s, e) => { setSearchStartDate(s); setSearchEndDate(e); }} />
diff --git a/frontend/app/(main)/COMPANY_7/production/process-info/ItemRoutingTab.tsx b/frontend/app/(main)/COMPANY_7/production/process-info/ItemRoutingTab.tsx index 18bb8106..ec0c8fb1 100644 --- a/frontend/app/(main)/COMPANY_7/production/process-info/ItemRoutingTab.tsx +++ b/frontend/app/(main)/COMPANY_7/production/process-info/ItemRoutingTab.tsx @@ -92,6 +92,7 @@ export function ItemRoutingTab() { const [formFixedOrder, setFormFixedOrder] = useState("Y"); const [formWorkType, setFormWorkType] = useState("내부"); const [formStandardTime, setFormStandardTime] = useState(""); + const [formExecutionType, setFormExecutionType] = useState("serial"); // serial=순차 / parallel=병렬 const [formOutsources, setFormOutsources] = useState([]); const [subcontractorOptions, setSubcontractorOptions] = useState<{ id: string; code: string; name: string }[]>([]); const [detailSubmitting, setDetailSubmitting] = useState(false); @@ -282,6 +283,7 @@ export function ItemRoutingTab() { setFormFixedOrder("Y"); setFormWorkType("내부"); setFormStandardTime(""); + setFormExecutionType("serial"); setFormOutsources([]); setDetailDialogOpen(true); }; @@ -309,6 +311,7 @@ export function ItemRoutingTab() { setFormFixedOrder(row.is_fixed_order === "N" ? "N" : "Y"); setFormWorkType(row.work_type || "내부"); setFormStandardTime(row.standard_time || ""); + setFormExecutionType(row.execution_type === "parallel" ? "parallel" : "serial"); // 우선순위: id 배열 → legacy code 배열(id로 역변환) → legacy 단일 code(id로 역변환) let loadedIds: string[] = []; if (Array.isArray(row.outsource_supplier_ids) && row.outsource_supplier_ids.length > 0) { @@ -362,6 +365,7 @@ export function ItemRoutingTab() { standard_time: st || "0", outsource_supplier: outsourcePrimaryCode, outsource_supplier_ids: outsourceIds, + execution_type: formExecutionType, }; setDetails((prev) => sortDetailsBySeq([...prev, newRow])); toast.success("공정이 추가되었어요. 저장을 눌러 반영해주세요"); @@ -381,6 +385,7 @@ export function ItemRoutingTab() { standard_time: st || "0", outsource_supplier: outsourcePrimaryCode, outsource_supplier_ids: outsourceIds, + execution_type: formExecutionType, } : d, ), @@ -418,6 +423,7 @@ export function ItemRoutingTab() { standard_time: String(d.standard_time ?? "0"), outsource_supplier: d.outsource_supplier || "", outsource_supplier_ids: d.outsource_supplier_ids || [], + execution_type: d.execution_type || "serial", })); setSaving(true); @@ -514,6 +520,7 @@ export function ItemRoutingTab() { ...d, process_display: d.process_name || d.process_code, outsource_display: names.length === 0 ? "—" : names.join(", "), + execution_display: d.execution_type === "parallel" ? "병렬" : "순차", }; }), [details, subcontractorOptions], @@ -777,6 +784,7 @@ export function ItemRoutingTab() { { key: "process_display", label: "공정명" }, { key: "is_required", label: "필수", width: "w-[80px]", align: "center" as const }, { key: "is_fixed_order", label: "순서고정", width: "w-[90px]", align: "center" as const }, + { key: "execution_display", label: "실행방식", width: "w-[90px]", align: "center" as const }, { key: "work_type", label: "작업구분", width: "w-[100px]" }, { key: "standard_time", label: "표준시간", width: "w-[90px]", align: "right" as const }, { key: "outsource_display", label: "외주업체" }, @@ -913,18 +921,32 @@ export function ItemRoutingTab() {
-
- - +
+
+ + +
+
+ + +
diff --git a/frontend/app/(main)/COMPANY_7/quality/item-inspection/page.tsx b/frontend/app/(main)/COMPANY_7/quality/item-inspection/page.tsx index 5e9874ac..76e88f1c 100644 --- a/frontend/app/(main)/COMPANY_7/quality/item-inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_7/quality/item-inspection/page.tsx @@ -36,6 +36,7 @@ const INSPECTION_TABLE = "inspection_standard"; const GRID_COLUMNS = [ { key: "item_code", label: "품목코드" }, { key: "item_name", label: "품명" }, + { key: "size", label: "규격" }, { key: "inspection_type", label: "검사유형" }, { key: "is_active", label: "사용여부" }, ]; @@ -103,7 +104,7 @@ export default function ItemInspectionInfoPage() { const [saving, setSaving] = useState(false); // FK 옵션 - const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string }[]>([]); + const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; size: string; unit: string }[]>([]); const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; judgment_criteria: string; selection_options: string; unit: string; types: string[] }[]>([]); const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]); @@ -159,6 +160,7 @@ export default function ItemInspectionInfoPage() { code: r.item_number || r.item_code || "", name: r.item_name || "", item_type: r.type || r.item_type || "", + size: r.size || "", unit: r.inventory_unit || "", }))); @@ -268,7 +270,7 @@ export default function ItemInspectionInfoPage() { const resData = res.data?.data; const rows = resData?.data || resData?.rows || []; const cm = itemCatMapRef.current; - setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: cm["type"]?.[r.type] || r.type || "", unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "" }))); + setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: cm["type"]?.[r.type] || r.type || "", size: r.size || "", unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "" }))); setItemTotal(resData?.total || resData?.totalCount || rows.length); } catch { /* skip */ } finally { setItemSearchLoading(false); } }; @@ -488,11 +490,13 @@ export default function ItemInspectionInfoPage() { // item_code별 그룹핑 const groupedData = useMemo(() => { - const map: Record = {}; + const itemSizeMap: Record = {}; + for (const it of itemOptions) itemSizeMap[it.code] = it.size || ""; + const map: Record = {}; for (const row of data) { const key = row.item_code || row.id; if (!map[key]) { - map[key] = { item_code: row.item_code, item_name: row.item_name, is_active: row.is_active || "", types: [], rows: [] }; + map[key] = { item_code: row.item_code, item_name: row.item_name, size: itemSizeMap[row.item_code] || "", is_active: row.is_active || "", types: [], rows: [] }; } map[key].rows.push(row); if (row.inspection_type && !map[key].types.includes(row.inspection_type)) { @@ -500,7 +504,7 @@ export default function ItemInspectionInfoPage() { } } return Object.values(map); - }, [data]); + }, [data, itemOptions]); // 좌측 품목 목록 정렬 (컬럼 헤더 클릭 → asc → desc → 해제 순환) const [sortConfig, setSortConfig] = useState<{ key: string; direction: "asc" | "desc" } | null>(null); @@ -1090,12 +1094,13 @@ export default function ItemInspectionInfoPage() { switch (col.key) { case "item_code": return {group.item_code}; case "item_name": return {group.item_name}; + case "size": return {group.size}; case "inspection_type": return ( -
+
{group.types.map((t: string) => { const label = inspTypeCatOptions.find((o) => o.code === t)?.label || t; - return {label}; + return {label}; })}
@@ -1290,17 +1295,19 @@ export default function ItemInspectionInfoPage() { 품목코드 품목명 + 규격 품목유형 단위 {filteredItems.length === 0 ? ( - {itemSearchLoading ? "검색 중..." : "검색 결과가 없어요"} + {itemSearchLoading ? "검색 중..." : "검색 결과가 없어요"} ) : filteredItems.map((item) => ( selectItem(item)}> {item.code} {item.name} + {item.size} {item.item_type} {item.unit} diff --git a/frontend/app/(main)/COMPANY_7/sales/shipping-order/page.tsx b/frontend/app/(main)/COMPANY_7/sales/shipping-order/page.tsx index 2ed29b40..a8c95d2e 100644 --- a/frontend/app/(main)/COMPANY_7/sales/shipping-order/page.tsx +++ b/frontend/app/(main)/COMPANY_7/sales/shipping-order/page.tsx @@ -97,6 +97,11 @@ export default function ShippingOrderPage() { // 검색 필터 (DynamicSearchFilter에서 관리) const [searchFilters, setSearchFilters] = useState([]); + // 서버 페이징 + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(50); + const [totalCount, setTotalCount] = useState(0); + // 엑셀 업로드 const [excelUploadOpen, setExcelUploadOpen] = useState(false); @@ -136,7 +141,7 @@ export default function ShippingOrderPage() { const fetchOrders = useCallback(async () => { setLoading(true); try { - const params: any = {}; + const params: any = { page: currentPage, size: pageSize }; for (const f of searchFilters) { if (f.columnName === "ship_date" && f.operator === "between" && f.value) { const [from, to] = f.value.split(","); @@ -152,18 +157,25 @@ export default function ShippingOrderPage() { } const result = await getShippingOrderList(params); - if (result.success) setOrders(result.data || []); + if (result.success) { + setOrders(result.data || []); + setTotalCount(result.total ?? result.data?.length ?? 0); + } } catch (err) { console.error("출하지시 조회 실패:", err); } finally { setLoading(false); } - }, [searchFilters]); + }, [searchFilters, currentPage, pageSize]); useEffect(() => { fetchOrders(); }, [fetchOrders]); + useEffect(() => { + setCurrentPage(1); + }, [searchFilters]); + // 소스 데이터 조회 const fetchSourceData = useCallback(async (pageOverride?: number) => { setSourceLoading(true); @@ -473,7 +485,7 @@ export default function ShippingOrderPage() { tableName={ts.tableName} filterId="c16-shipping-order" onFilterChange={setSearchFilters} - dataCount={orders.length} + dataCount={totalCount} externalFilterConfig={ts.filterConfig} /> @@ -482,7 +494,7 @@ export default function ShippingOrderPage() {

출하지시 관리

- {orders.length}건 + {totalCount}건 {loading && }
@@ -548,6 +560,13 @@ export default function ShippingOrderPage() { onRowClick={(row) => setSelectedOrderId(row._orderId)} onRowDoubleClick={(row) => openModal(row._order)} showPagination + serverPagination + serverCurrentPage={currentPage} + serverPageSize={pageSize} + serverTotalCount={totalCount} + onServerPageChange={setCurrentPage} + onServerPageSizeChange={(s) => { setPageSize(s); setCurrentPage(1); }} + defaultPageSize={pageSize} draggableColumns={false} columnOrderKey="c16-shipping-order" /> diff --git a/frontend/app/(main)/COMPANY_7/sales/shipping-plan/page.tsx b/frontend/app/(main)/COMPANY_7/sales/shipping-plan/page.tsx index 747ac23d..9e0398f9 100644 --- a/frontend/app/(main)/COMPANY_7/sales/shipping-plan/page.tsx +++ b/frontend/app/(main)/COMPANY_7/sales/shipping-plan/page.tsx @@ -69,6 +69,11 @@ export default function ShippingPlanPage() { // 검색 필터 (DynamicSearchFilter에서 관리) const [searchFilters, setSearchFilters] = useState([]); + // 서버 페이징 + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(50); + const [totalCount, setTotalCount] = useState(0); + // 상세 패널 편집 const [editPlanQty, setEditPlanQty] = useState(""); const [editPlanDate, setEditPlanDate] = useState(""); @@ -81,7 +86,7 @@ export default function ShippingPlanPage() { const fetchData = useCallback(async () => { setLoading(true); try { - const params: any = {}; + const params: any = { page: currentPage, size: pageSize }; for (const f of searchFilters) { if (f.columnName === "plan_date" && f.operator === "between" && f.value) { const [from, to] = f.value.split(","); @@ -99,19 +104,24 @@ export default function ShippingPlanPage() { const result = await getShipmentPlanList(params); if (result.success) { setData(result.data || []); + setTotalCount(result.total ?? result.data?.length ?? 0); } } catch (err) { console.error("출하계획 조회 실패:", err); } finally { setLoading(false); } - }, [searchFilters]); + }, [searchFilters, currentPage, pageSize]); - // searchFilters 변경 시 자동 조회 + // searchFilters 변경 시 자동 조회 + 1페이지로 리셋 useEffect(() => { fetchData(); }, [fetchData]); + useEffect(() => { + setCurrentPage(1); + }, [searchFilters]); + const selectedPlan = useMemo(() => data.find(p => p.id === selectedId), [data, selectedId]); const groupedData = useMemo(() => { @@ -209,7 +219,7 @@ export default function ShippingPlanPage() { tableName="shipment_plan" filterId="c16-shipping-plan" onFilterChange={setSearchFilters} - dataCount={data.length} + dataCount={totalCount} externalFilterConfig={ts.filterConfig} /> @@ -224,7 +234,7 @@ export default function ShippingPlanPage() {
출하계획 목록 - {data.length}건 + {totalCount}건 {loading && }
@@ -262,7 +272,14 @@ export default function ShippingPlanPage() { showCheckbox checkedIds={checkedIds.map(String)} onCheckedChange={(ids) => setCheckedIds(ids.map(Number))} - showPagination={false} + showPagination + serverPagination + serverCurrentPage={currentPage} + serverPageSize={pageSize} + serverTotalCount={totalCount} + onServerPageChange={setCurrentPage} + onServerPageSizeChange={(s) => { setPageSize(s); setCurrentPage(1); }} + defaultPageSize={pageSize} draggableColumns={false} />
diff --git a/frontend/app/(main)/COMPANY_8/production/plan-management/page.tsx b/frontend/app/(main)/COMPANY_8/production/plan-management/page.tsx index 6eae857e..cd159a0b 100644 --- a/frontend/app/(main)/COMPANY_8/production/plan-management/page.tsx +++ b/frontend/app/(main)/COMPANY_8/production/plan-management/page.tsx @@ -139,10 +139,11 @@ export default function ProductionPlanManagementPage() { // 데이터 상태 const [orderItems, setOrderItems] = useState([]); - // 좌측 수주목록 페이지네이션 + // 좌측 수주목록 페이지네이션 (서버 페이징) const [orderPage, setOrderPage] = useState(1); const [orderPageSize, setOrderPageSize] = useState(20); const [orderPageSizeInput, setOrderPageSizeInput] = useState("20"); + const [orderTotalCount, setOrderTotalCount] = useState(0); const [stockItems, setStockItems] = useState([]); const [finishedPlans, setFinishedPlans] = useState([]); const [semiPlans, setSemiPlans] = useState([]); @@ -210,22 +211,25 @@ export default function ProductionPlanManagementPage() { const res = await getOrderSummary({ excludePlanned: filterUnplannedOrdersOnly, itemCode: searchItemCode || undefined, + page: orderPage, + size: orderPageSize, }); - if (res.success) setOrderItems(res.data || []); + if (res.success) { + setOrderItems(res.data || []); + setOrderTotalCount(res.total ?? res.data?.length ?? 0); + } } catch (err: any) { toast.error("수주 데이터 조회 실패: " + (err.message || "")); } finally { setLoadingOrders(false); } - }, [filterUnplannedOrdersOnly, searchItemCode]); + }, [filterUnplannedOrdersOnly, searchItemCode, orderPage, orderPageSize]); // 수주목록 페이지네이션 계산 - const orderTotalPages = Math.max(1, Math.ceil(orderItems.length / orderPageSize)); + const orderTotalPages = Math.max(1, Math.ceil(orderTotalCount / orderPageSize)); const orderSafePage = Math.min(Math.max(1, orderPage), orderTotalPages); - const paginatedOrderItems = useMemo(() => { - const start = (orderSafePage - 1) * orderPageSize; - return orderItems.slice(start, start + orderPageSize); - }, [orderItems, orderSafePage, orderPageSize]); + // 서버 페이징: 응답 자체가 페이지 데이터이므로 slice 불필요 + const paginatedOrderItems = orderItems; const applyOrderPageSize = () => { const n = parseInt(orderPageSizeInput, 10); @@ -247,8 +251,8 @@ export default function ProductionPlanManagementPage() { return pages; }; - // orderItems 변경 시 1페이지로 리셋 - useEffect(() => { setOrderPage(1); }, [orderItems.length]); + // 검색 필터 변경 시 1페이지로 리셋 + useEffect(() => { setOrderPage(1); }, [filterUnplannedOrdersOnly, searchItemCode]); const fetchStockShortage = useCallback(async () => { setLoadingStock(true); @@ -265,19 +269,28 @@ export default function ProductionPlanManagementPage() { const fetchPlans = useCallback(async () => { setLoadingPlans(true); try { + // 타임라인 성능: 기간 필터 미입력 시 기본 오늘 ~ +60일 자동 적용 + // 이전 기록은 검색에서 시작일/종료일을 직접 지정하면 조회됨 + const today = new Date(); + const fmt = (d: Date) => d.toISOString().slice(0, 10); + const defaultStart = fmt(today); + const defaultEnd = fmt(new Date(today.getTime() + 60 * 86400000)); + const effectiveStart = searchStartDate || defaultStart; + const effectiveEnd = searchEndDate || defaultEnd; + const [finRes, semiRes] = await Promise.all([ getPlans({ productType: "완제품", status: searchStatus !== "all" ? searchStatus : undefined, - startDate: searchStartDate || undefined, - endDate: searchEndDate || undefined, + startDate: effectiveStart, + endDate: effectiveEnd, itemCode: searchItemCode || undefined, }), getPlans({ productType: "반제품", status: searchStatus !== "all" ? searchStatus : undefined, - startDate: searchStartDate || undefined, - endDate: searchEndDate || undefined, + startDate: effectiveStart, + endDate: effectiveEnd, }), ]); if (finRes.success) setFinishedPlans(finRes.data || []); @@ -329,11 +342,10 @@ export default function ProductionPlanManagementPage() { }, []); useEffect(() => { - if (searchFilters.length > 0) { - fetchOrderSummary(); - fetchPlans(); - } - }, [searchItemCode, searchStatus, searchStartDate, searchEndDate]); + // 검색 state 변경 시 자동 재조회 (필터 비어있어도 default 기간으로 재조회) + fetchOrderSummary(); + fetchPlans(); + }, [searchItemCode, searchStatus, searchStartDate, searchEndDate]); // eslint-disable-line react-hooks/exhaustive-deps // ========== 토글/선택 핸들러 ========== @@ -1516,6 +1528,7 @@ export default function ProductionPlanManagementPage() { onEventClick={openScheduleDetail} onEventMove={handleEventMove} onEventResize={handleEventResize} + onRangeChange={(s, e) => { setSearchStartDate(s); setSearchEndDate(e); }} />
@@ -1585,6 +1598,7 @@ export default function ProductionPlanManagementPage() { onEventClick={openScheduleDetail} onEventMove={handleEventMove} onEventResize={handleEventResize} + onRangeChange={(s, e) => { setSearchStartDate(s); setSearchEndDate(e); }} />
diff --git a/frontend/app/(main)/COMPANY_8/production/process-info/ItemRoutingTab.tsx b/frontend/app/(main)/COMPANY_8/production/process-info/ItemRoutingTab.tsx index 18bb8106..ec0c8fb1 100644 --- a/frontend/app/(main)/COMPANY_8/production/process-info/ItemRoutingTab.tsx +++ b/frontend/app/(main)/COMPANY_8/production/process-info/ItemRoutingTab.tsx @@ -92,6 +92,7 @@ export function ItemRoutingTab() { const [formFixedOrder, setFormFixedOrder] = useState("Y"); const [formWorkType, setFormWorkType] = useState("내부"); const [formStandardTime, setFormStandardTime] = useState(""); + const [formExecutionType, setFormExecutionType] = useState("serial"); // serial=순차 / parallel=병렬 const [formOutsources, setFormOutsources] = useState([]); const [subcontractorOptions, setSubcontractorOptions] = useState<{ id: string; code: string; name: string }[]>([]); const [detailSubmitting, setDetailSubmitting] = useState(false); @@ -282,6 +283,7 @@ export function ItemRoutingTab() { setFormFixedOrder("Y"); setFormWorkType("내부"); setFormStandardTime(""); + setFormExecutionType("serial"); setFormOutsources([]); setDetailDialogOpen(true); }; @@ -309,6 +311,7 @@ export function ItemRoutingTab() { setFormFixedOrder(row.is_fixed_order === "N" ? "N" : "Y"); setFormWorkType(row.work_type || "내부"); setFormStandardTime(row.standard_time || ""); + setFormExecutionType(row.execution_type === "parallel" ? "parallel" : "serial"); // 우선순위: id 배열 → legacy code 배열(id로 역변환) → legacy 단일 code(id로 역변환) let loadedIds: string[] = []; if (Array.isArray(row.outsource_supplier_ids) && row.outsource_supplier_ids.length > 0) { @@ -362,6 +365,7 @@ export function ItemRoutingTab() { standard_time: st || "0", outsource_supplier: outsourcePrimaryCode, outsource_supplier_ids: outsourceIds, + execution_type: formExecutionType, }; setDetails((prev) => sortDetailsBySeq([...prev, newRow])); toast.success("공정이 추가되었어요. 저장을 눌러 반영해주세요"); @@ -381,6 +385,7 @@ export function ItemRoutingTab() { standard_time: st || "0", outsource_supplier: outsourcePrimaryCode, outsource_supplier_ids: outsourceIds, + execution_type: formExecutionType, } : d, ), @@ -418,6 +423,7 @@ export function ItemRoutingTab() { standard_time: String(d.standard_time ?? "0"), outsource_supplier: d.outsource_supplier || "", outsource_supplier_ids: d.outsource_supplier_ids || [], + execution_type: d.execution_type || "serial", })); setSaving(true); @@ -514,6 +520,7 @@ export function ItemRoutingTab() { ...d, process_display: d.process_name || d.process_code, outsource_display: names.length === 0 ? "—" : names.join(", "), + execution_display: d.execution_type === "parallel" ? "병렬" : "순차", }; }), [details, subcontractorOptions], @@ -777,6 +784,7 @@ export function ItemRoutingTab() { { key: "process_display", label: "공정명" }, { key: "is_required", label: "필수", width: "w-[80px]", align: "center" as const }, { key: "is_fixed_order", label: "순서고정", width: "w-[90px]", align: "center" as const }, + { key: "execution_display", label: "실행방식", width: "w-[90px]", align: "center" as const }, { key: "work_type", label: "작업구분", width: "w-[100px]" }, { key: "standard_time", label: "표준시간", width: "w-[90px]", align: "right" as const }, { key: "outsource_display", label: "외주업체" }, @@ -913,18 +921,32 @@ export function ItemRoutingTab() {
-
- - +
+
+ + +
+
+ + +
diff --git a/frontend/app/(main)/COMPANY_8/quality/item-inspection/page.tsx b/frontend/app/(main)/COMPANY_8/quality/item-inspection/page.tsx index 74fb5981..45042ac7 100644 --- a/frontend/app/(main)/COMPANY_8/quality/item-inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_8/quality/item-inspection/page.tsx @@ -33,6 +33,7 @@ const INSPECTION_TABLE = "inspection_standard"; const GRID_COLUMNS = [ { key: "item_code", label: "품목코드" }, { key: "item_name", label: "품명" }, + { key: "size", label: "규격" }, { key: "inspection_type", label: "검사유형" }, { key: "is_active", label: "사용여부" }, ]; @@ -74,7 +75,7 @@ export default function ItemInspectionInfoPage() { const [saving, setSaving] = useState(false); // FK 옵션 - const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string }[]>([]); + const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; size: string; unit: string }[]>([]); const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; judgment_criteria: string; selection_options: string; unit: string; types: string[] }[]>([]); const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]); @@ -130,6 +131,7 @@ export default function ItemInspectionInfoPage() { code: r.item_number || r.item_code || "", name: r.item_name || "", item_type: r.type || r.item_type || "", + size: r.size || "", unit: r.inventory_unit || "", }))); @@ -239,7 +241,7 @@ export default function ItemInspectionInfoPage() { const resData = res.data?.data; const rows = resData?.data || resData?.rows || []; const cm = itemCatMapRef.current; - setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: cm["type"]?.[r.type] || r.type || "", unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "" }))); + setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: cm["type"]?.[r.type] || r.type || "", size: r.size || "", unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "" }))); setItemTotal(resData?.total || resData?.totalCount || rows.length); } catch { /* skip */ } finally { setItemSearchLoading(false); } }; @@ -459,11 +461,13 @@ export default function ItemInspectionInfoPage() { // item_code별 그룹핑 const groupedData = useMemo(() => { - const map: Record = {}; + const itemSizeMap: Record = {}; + for (const it of itemOptions) itemSizeMap[it.code] = it.size || ""; + const map: Record = {}; for (const row of data) { const key = row.item_code || row.id; if (!map[key]) { - map[key] = { item_code: row.item_code, item_name: row.item_name, is_active: row.is_active || "", types: [], rows: [] }; + map[key] = { item_code: row.item_code, item_name: row.item_name, size: itemSizeMap[row.item_code] || "", is_active: row.is_active || "", types: [], rows: [] }; } map[key].rows.push(row); if (row.inspection_type && !map[key].types.includes(row.inspection_type)) { @@ -471,7 +475,7 @@ export default function ItemInspectionInfoPage() { } } return Object.values(map); - }, [data]); + }, [data, itemOptions]); // 좌측 품목 목록 정렬 (컬럼 헤더 클릭 → asc → desc → 해제 순환) const [sortConfig, setSortConfig] = useState<{ key: string; direction: "asc" | "desc" } | null>(null); @@ -1052,12 +1056,13 @@ export default function ItemInspectionInfoPage() { switch (col.key) { case "item_code": return {group.item_code}; case "item_name": return {group.item_name}; + case "size": return {group.size}; case "inspection_type": return ( -
+
{group.types.map((t: string) => { const label = inspTypeCatOptions.find((o) => o.code === t)?.label || t; - return {label}; + return {label}; })}
@@ -1252,17 +1257,19 @@ export default function ItemInspectionInfoPage() { 품목코드 품목명 + 규격 품목유형 단위 {filteredItems.length === 0 ? ( - {itemSearchLoading ? "검색 중..." : "검색 결과가 없어요"} + {itemSearchLoading ? "검색 중..." : "검색 결과가 없어요"} ) : filteredItems.map((item) => ( selectItem(item)}> {item.code} {item.name} + {item.size} {item.item_type} {item.unit} diff --git a/frontend/app/(main)/COMPANY_8/sales/shipping-order/page.tsx b/frontend/app/(main)/COMPANY_8/sales/shipping-order/page.tsx index 2ed29b40..a8c95d2e 100644 --- a/frontend/app/(main)/COMPANY_8/sales/shipping-order/page.tsx +++ b/frontend/app/(main)/COMPANY_8/sales/shipping-order/page.tsx @@ -97,6 +97,11 @@ export default function ShippingOrderPage() { // 검색 필터 (DynamicSearchFilter에서 관리) const [searchFilters, setSearchFilters] = useState([]); + // 서버 페이징 + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(50); + const [totalCount, setTotalCount] = useState(0); + // 엑셀 업로드 const [excelUploadOpen, setExcelUploadOpen] = useState(false); @@ -136,7 +141,7 @@ export default function ShippingOrderPage() { const fetchOrders = useCallback(async () => { setLoading(true); try { - const params: any = {}; + const params: any = { page: currentPage, size: pageSize }; for (const f of searchFilters) { if (f.columnName === "ship_date" && f.operator === "between" && f.value) { const [from, to] = f.value.split(","); @@ -152,18 +157,25 @@ export default function ShippingOrderPage() { } const result = await getShippingOrderList(params); - if (result.success) setOrders(result.data || []); + if (result.success) { + setOrders(result.data || []); + setTotalCount(result.total ?? result.data?.length ?? 0); + } } catch (err) { console.error("출하지시 조회 실패:", err); } finally { setLoading(false); } - }, [searchFilters]); + }, [searchFilters, currentPage, pageSize]); useEffect(() => { fetchOrders(); }, [fetchOrders]); + useEffect(() => { + setCurrentPage(1); + }, [searchFilters]); + // 소스 데이터 조회 const fetchSourceData = useCallback(async (pageOverride?: number) => { setSourceLoading(true); @@ -473,7 +485,7 @@ export default function ShippingOrderPage() { tableName={ts.tableName} filterId="c16-shipping-order" onFilterChange={setSearchFilters} - dataCount={orders.length} + dataCount={totalCount} externalFilterConfig={ts.filterConfig} /> @@ -482,7 +494,7 @@ export default function ShippingOrderPage() {

출하지시 관리

- {orders.length}건 + {totalCount}건 {loading && }
@@ -548,6 +560,13 @@ export default function ShippingOrderPage() { onRowClick={(row) => setSelectedOrderId(row._orderId)} onRowDoubleClick={(row) => openModal(row._order)} showPagination + serverPagination + serverCurrentPage={currentPage} + serverPageSize={pageSize} + serverTotalCount={totalCount} + onServerPageChange={setCurrentPage} + onServerPageSizeChange={(s) => { setPageSize(s); setCurrentPage(1); }} + defaultPageSize={pageSize} draggableColumns={false} columnOrderKey="c16-shipping-order" /> diff --git a/frontend/app/(main)/COMPANY_8/sales/shipping-plan/page.tsx b/frontend/app/(main)/COMPANY_8/sales/shipping-plan/page.tsx index 747ac23d..9e0398f9 100644 --- a/frontend/app/(main)/COMPANY_8/sales/shipping-plan/page.tsx +++ b/frontend/app/(main)/COMPANY_8/sales/shipping-plan/page.tsx @@ -69,6 +69,11 @@ export default function ShippingPlanPage() { // 검색 필터 (DynamicSearchFilter에서 관리) const [searchFilters, setSearchFilters] = useState([]); + // 서버 페이징 + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(50); + const [totalCount, setTotalCount] = useState(0); + // 상세 패널 편집 const [editPlanQty, setEditPlanQty] = useState(""); const [editPlanDate, setEditPlanDate] = useState(""); @@ -81,7 +86,7 @@ export default function ShippingPlanPage() { const fetchData = useCallback(async () => { setLoading(true); try { - const params: any = {}; + const params: any = { page: currentPage, size: pageSize }; for (const f of searchFilters) { if (f.columnName === "plan_date" && f.operator === "between" && f.value) { const [from, to] = f.value.split(","); @@ -99,19 +104,24 @@ export default function ShippingPlanPage() { const result = await getShipmentPlanList(params); if (result.success) { setData(result.data || []); + setTotalCount(result.total ?? result.data?.length ?? 0); } } catch (err) { console.error("출하계획 조회 실패:", err); } finally { setLoading(false); } - }, [searchFilters]); + }, [searchFilters, currentPage, pageSize]); - // searchFilters 변경 시 자동 조회 + // searchFilters 변경 시 자동 조회 + 1페이지로 리셋 useEffect(() => { fetchData(); }, [fetchData]); + useEffect(() => { + setCurrentPage(1); + }, [searchFilters]); + const selectedPlan = useMemo(() => data.find(p => p.id === selectedId), [data, selectedId]); const groupedData = useMemo(() => { @@ -209,7 +219,7 @@ export default function ShippingPlanPage() { tableName="shipment_plan" filterId="c16-shipping-plan" onFilterChange={setSearchFilters} - dataCount={data.length} + dataCount={totalCount} externalFilterConfig={ts.filterConfig} /> @@ -224,7 +234,7 @@ export default function ShippingPlanPage() {
출하계획 목록 - {data.length}건 + {totalCount}건 {loading && }
@@ -262,7 +272,14 @@ export default function ShippingPlanPage() { showCheckbox checkedIds={checkedIds.map(String)} onCheckedChange={(ids) => setCheckedIds(ids.map(Number))} - showPagination={false} + showPagination + serverPagination + serverCurrentPage={currentPage} + serverPageSize={pageSize} + serverTotalCount={totalCount} + onServerPageChange={setCurrentPage} + onServerPageSizeChange={(s) => { setPageSize(s); setCurrentPage(1); }} + defaultPageSize={pageSize} draggableColumns={false} />
diff --git a/frontend/app/(main)/COMPANY_9/production/plan-management/page.tsx b/frontend/app/(main)/COMPANY_9/production/plan-management/page.tsx index 6eae857e..cd159a0b 100644 --- a/frontend/app/(main)/COMPANY_9/production/plan-management/page.tsx +++ b/frontend/app/(main)/COMPANY_9/production/plan-management/page.tsx @@ -139,10 +139,11 @@ export default function ProductionPlanManagementPage() { // 데이터 상태 const [orderItems, setOrderItems] = useState([]); - // 좌측 수주목록 페이지네이션 + // 좌측 수주목록 페이지네이션 (서버 페이징) const [orderPage, setOrderPage] = useState(1); const [orderPageSize, setOrderPageSize] = useState(20); const [orderPageSizeInput, setOrderPageSizeInput] = useState("20"); + const [orderTotalCount, setOrderTotalCount] = useState(0); const [stockItems, setStockItems] = useState([]); const [finishedPlans, setFinishedPlans] = useState([]); const [semiPlans, setSemiPlans] = useState([]); @@ -210,22 +211,25 @@ export default function ProductionPlanManagementPage() { const res = await getOrderSummary({ excludePlanned: filterUnplannedOrdersOnly, itemCode: searchItemCode || undefined, + page: orderPage, + size: orderPageSize, }); - if (res.success) setOrderItems(res.data || []); + if (res.success) { + setOrderItems(res.data || []); + setOrderTotalCount(res.total ?? res.data?.length ?? 0); + } } catch (err: any) { toast.error("수주 데이터 조회 실패: " + (err.message || "")); } finally { setLoadingOrders(false); } - }, [filterUnplannedOrdersOnly, searchItemCode]); + }, [filterUnplannedOrdersOnly, searchItemCode, orderPage, orderPageSize]); // 수주목록 페이지네이션 계산 - const orderTotalPages = Math.max(1, Math.ceil(orderItems.length / orderPageSize)); + const orderTotalPages = Math.max(1, Math.ceil(orderTotalCount / orderPageSize)); const orderSafePage = Math.min(Math.max(1, orderPage), orderTotalPages); - const paginatedOrderItems = useMemo(() => { - const start = (orderSafePage - 1) * orderPageSize; - return orderItems.slice(start, start + orderPageSize); - }, [orderItems, orderSafePage, orderPageSize]); + // 서버 페이징: 응답 자체가 페이지 데이터이므로 slice 불필요 + const paginatedOrderItems = orderItems; const applyOrderPageSize = () => { const n = parseInt(orderPageSizeInput, 10); @@ -247,8 +251,8 @@ export default function ProductionPlanManagementPage() { return pages; }; - // orderItems 변경 시 1페이지로 리셋 - useEffect(() => { setOrderPage(1); }, [orderItems.length]); + // 검색 필터 변경 시 1페이지로 리셋 + useEffect(() => { setOrderPage(1); }, [filterUnplannedOrdersOnly, searchItemCode]); const fetchStockShortage = useCallback(async () => { setLoadingStock(true); @@ -265,19 +269,28 @@ export default function ProductionPlanManagementPage() { const fetchPlans = useCallback(async () => { setLoadingPlans(true); try { + // 타임라인 성능: 기간 필터 미입력 시 기본 오늘 ~ +60일 자동 적용 + // 이전 기록은 검색에서 시작일/종료일을 직접 지정하면 조회됨 + const today = new Date(); + const fmt = (d: Date) => d.toISOString().slice(0, 10); + const defaultStart = fmt(today); + const defaultEnd = fmt(new Date(today.getTime() + 60 * 86400000)); + const effectiveStart = searchStartDate || defaultStart; + const effectiveEnd = searchEndDate || defaultEnd; + const [finRes, semiRes] = await Promise.all([ getPlans({ productType: "완제품", status: searchStatus !== "all" ? searchStatus : undefined, - startDate: searchStartDate || undefined, - endDate: searchEndDate || undefined, + startDate: effectiveStart, + endDate: effectiveEnd, itemCode: searchItemCode || undefined, }), getPlans({ productType: "반제품", status: searchStatus !== "all" ? searchStatus : undefined, - startDate: searchStartDate || undefined, - endDate: searchEndDate || undefined, + startDate: effectiveStart, + endDate: effectiveEnd, }), ]); if (finRes.success) setFinishedPlans(finRes.data || []); @@ -329,11 +342,10 @@ export default function ProductionPlanManagementPage() { }, []); useEffect(() => { - if (searchFilters.length > 0) { - fetchOrderSummary(); - fetchPlans(); - } - }, [searchItemCode, searchStatus, searchStartDate, searchEndDate]); + // 검색 state 변경 시 자동 재조회 (필터 비어있어도 default 기간으로 재조회) + fetchOrderSummary(); + fetchPlans(); + }, [searchItemCode, searchStatus, searchStartDate, searchEndDate]); // eslint-disable-line react-hooks/exhaustive-deps // ========== 토글/선택 핸들러 ========== @@ -1516,6 +1528,7 @@ export default function ProductionPlanManagementPage() { onEventClick={openScheduleDetail} onEventMove={handleEventMove} onEventResize={handleEventResize} + onRangeChange={(s, e) => { setSearchStartDate(s); setSearchEndDate(e); }} />
@@ -1585,6 +1598,7 @@ export default function ProductionPlanManagementPage() { onEventClick={openScheduleDetail} onEventMove={handleEventMove} onEventResize={handleEventResize} + onRangeChange={(s, e) => { setSearchStartDate(s); setSearchEndDate(e); }} />
diff --git a/frontend/app/(main)/COMPANY_9/production/process-info/ItemRoutingTab.tsx b/frontend/app/(main)/COMPANY_9/production/process-info/ItemRoutingTab.tsx index 18bb8106..ec0c8fb1 100644 --- a/frontend/app/(main)/COMPANY_9/production/process-info/ItemRoutingTab.tsx +++ b/frontend/app/(main)/COMPANY_9/production/process-info/ItemRoutingTab.tsx @@ -92,6 +92,7 @@ export function ItemRoutingTab() { const [formFixedOrder, setFormFixedOrder] = useState("Y"); const [formWorkType, setFormWorkType] = useState("내부"); const [formStandardTime, setFormStandardTime] = useState(""); + const [formExecutionType, setFormExecutionType] = useState("serial"); // serial=순차 / parallel=병렬 const [formOutsources, setFormOutsources] = useState([]); const [subcontractorOptions, setSubcontractorOptions] = useState<{ id: string; code: string; name: string }[]>([]); const [detailSubmitting, setDetailSubmitting] = useState(false); @@ -282,6 +283,7 @@ export function ItemRoutingTab() { setFormFixedOrder("Y"); setFormWorkType("내부"); setFormStandardTime(""); + setFormExecutionType("serial"); setFormOutsources([]); setDetailDialogOpen(true); }; @@ -309,6 +311,7 @@ export function ItemRoutingTab() { setFormFixedOrder(row.is_fixed_order === "N" ? "N" : "Y"); setFormWorkType(row.work_type || "내부"); setFormStandardTime(row.standard_time || ""); + setFormExecutionType(row.execution_type === "parallel" ? "parallel" : "serial"); // 우선순위: id 배열 → legacy code 배열(id로 역변환) → legacy 단일 code(id로 역변환) let loadedIds: string[] = []; if (Array.isArray(row.outsource_supplier_ids) && row.outsource_supplier_ids.length > 0) { @@ -362,6 +365,7 @@ export function ItemRoutingTab() { standard_time: st || "0", outsource_supplier: outsourcePrimaryCode, outsource_supplier_ids: outsourceIds, + execution_type: formExecutionType, }; setDetails((prev) => sortDetailsBySeq([...prev, newRow])); toast.success("공정이 추가되었어요. 저장을 눌러 반영해주세요"); @@ -381,6 +385,7 @@ export function ItemRoutingTab() { standard_time: st || "0", outsource_supplier: outsourcePrimaryCode, outsource_supplier_ids: outsourceIds, + execution_type: formExecutionType, } : d, ), @@ -418,6 +423,7 @@ export function ItemRoutingTab() { standard_time: String(d.standard_time ?? "0"), outsource_supplier: d.outsource_supplier || "", outsource_supplier_ids: d.outsource_supplier_ids || [], + execution_type: d.execution_type || "serial", })); setSaving(true); @@ -514,6 +520,7 @@ export function ItemRoutingTab() { ...d, process_display: d.process_name || d.process_code, outsource_display: names.length === 0 ? "—" : names.join(", "), + execution_display: d.execution_type === "parallel" ? "병렬" : "순차", }; }), [details, subcontractorOptions], @@ -777,6 +784,7 @@ export function ItemRoutingTab() { { key: "process_display", label: "공정명" }, { key: "is_required", label: "필수", width: "w-[80px]", align: "center" as const }, { key: "is_fixed_order", label: "순서고정", width: "w-[90px]", align: "center" as const }, + { key: "execution_display", label: "실행방식", width: "w-[90px]", align: "center" as const }, { key: "work_type", label: "작업구분", width: "w-[100px]" }, { key: "standard_time", label: "표준시간", width: "w-[90px]", align: "right" as const }, { key: "outsource_display", label: "외주업체" }, @@ -913,18 +921,32 @@ export function ItemRoutingTab() {
-
- - +
+
+ + +
+
+ + +
diff --git a/frontend/app/(main)/COMPANY_9/quality/item-inspection/page.tsx b/frontend/app/(main)/COMPANY_9/quality/item-inspection/page.tsx index 6bb55a70..7c6df883 100644 --- a/frontend/app/(main)/COMPANY_9/quality/item-inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_9/quality/item-inspection/page.tsx @@ -33,6 +33,7 @@ const INSPECTION_TABLE = "inspection_standard"; const GRID_COLUMNS = [ { key: "item_code", label: "품목코드" }, { key: "item_name", label: "품명" }, + { key: "size", label: "규격" }, { key: "inspection_type", label: "검사유형" }, { key: "is_active", label: "사용여부" }, ]; @@ -74,7 +75,7 @@ export default function ItemInspectionInfoPage() { const [saving, setSaving] = useState(false); // FK 옵션 - const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string }[]>([]); + const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; size: string; unit: string }[]>([]); const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; judgment_criteria: string; selection_options: string; unit: string; types: string[] }[]>([]); const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]); @@ -130,6 +131,7 @@ export default function ItemInspectionInfoPage() { code: r.item_number || r.item_code || "", name: r.item_name || "", item_type: r.type || r.item_type || "", + size: r.size || "", unit: r.inventory_unit || "", }))); @@ -239,7 +241,7 @@ export default function ItemInspectionInfoPage() { const resData = res.data?.data; const rows = resData?.data || resData?.rows || []; const cm = itemCatMapRef.current; - setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: cm["type"]?.[r.type] || r.type || "", unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "" }))); + setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: cm["type"]?.[r.type] || r.type || "", size: r.size || "", unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "" }))); setItemTotal(resData?.total || resData?.totalCount || rows.length); } catch { /* skip */ } finally { setItemSearchLoading(false); } }; @@ -459,11 +461,13 @@ export default function ItemInspectionInfoPage() { // item_code별 그룹핑 const groupedData = useMemo(() => { - const map: Record = {}; + const itemSizeMap: Record = {}; + for (const it of itemOptions) itemSizeMap[it.code] = it.size || ""; + const map: Record = {}; for (const row of data) { const key = row.item_code || row.id; if (!map[key]) { - map[key] = { item_code: row.item_code, item_name: row.item_name, is_active: row.is_active || "", types: [], rows: [] }; + map[key] = { item_code: row.item_code, item_name: row.item_name, size: itemSizeMap[row.item_code] || "", is_active: row.is_active || "", types: [], rows: [] }; } map[key].rows.push(row); if (row.inspection_type && !map[key].types.includes(row.inspection_type)) { @@ -471,7 +475,7 @@ export default function ItemInspectionInfoPage() { } } return Object.values(map); - }, [data]); + }, [data, itemOptions]); // 좌측 품목 목록 정렬 (컬럼 헤더 클릭 → asc → desc → 해제 순환) const [sortConfig, setSortConfig] = useState<{ key: string; direction: "asc" | "desc" } | null>(null); @@ -1052,12 +1056,13 @@ export default function ItemInspectionInfoPage() { switch (col.key) { case "item_code": return {group.item_code}; case "item_name": return {group.item_name}; + case "size": return {group.size}; case "inspection_type": return ( -
+
{group.types.map((t: string) => { const label = inspTypeCatOptions.find((o) => o.code === t)?.label || t; - return {label}; + return {label}; })}
@@ -1252,17 +1257,19 @@ export default function ItemInspectionInfoPage() { 품목코드 품목명 + 규격 품목유형 단위 {filteredItems.length === 0 ? ( - {itemSearchLoading ? "검색 중..." : "검색 결과가 없어요"} + {itemSearchLoading ? "검색 중..." : "검색 결과가 없어요"} ) : filteredItems.map((item) => ( selectItem(item)}> {item.code} {item.name} + {item.size} {item.item_type} {item.unit} diff --git a/frontend/app/(main)/COMPANY_9/sales/shipping-order/page.tsx b/frontend/app/(main)/COMPANY_9/sales/shipping-order/page.tsx index 2ed29b40..a8c95d2e 100644 --- a/frontend/app/(main)/COMPANY_9/sales/shipping-order/page.tsx +++ b/frontend/app/(main)/COMPANY_9/sales/shipping-order/page.tsx @@ -97,6 +97,11 @@ export default function ShippingOrderPage() { // 검색 필터 (DynamicSearchFilter에서 관리) const [searchFilters, setSearchFilters] = useState([]); + // 서버 페이징 + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(50); + const [totalCount, setTotalCount] = useState(0); + // 엑셀 업로드 const [excelUploadOpen, setExcelUploadOpen] = useState(false); @@ -136,7 +141,7 @@ export default function ShippingOrderPage() { const fetchOrders = useCallback(async () => { setLoading(true); try { - const params: any = {}; + const params: any = { page: currentPage, size: pageSize }; for (const f of searchFilters) { if (f.columnName === "ship_date" && f.operator === "between" && f.value) { const [from, to] = f.value.split(","); @@ -152,18 +157,25 @@ export default function ShippingOrderPage() { } const result = await getShippingOrderList(params); - if (result.success) setOrders(result.data || []); + if (result.success) { + setOrders(result.data || []); + setTotalCount(result.total ?? result.data?.length ?? 0); + } } catch (err) { console.error("출하지시 조회 실패:", err); } finally { setLoading(false); } - }, [searchFilters]); + }, [searchFilters, currentPage, pageSize]); useEffect(() => { fetchOrders(); }, [fetchOrders]); + useEffect(() => { + setCurrentPage(1); + }, [searchFilters]); + // 소스 데이터 조회 const fetchSourceData = useCallback(async (pageOverride?: number) => { setSourceLoading(true); @@ -473,7 +485,7 @@ export default function ShippingOrderPage() { tableName={ts.tableName} filterId="c16-shipping-order" onFilterChange={setSearchFilters} - dataCount={orders.length} + dataCount={totalCount} externalFilterConfig={ts.filterConfig} /> @@ -482,7 +494,7 @@ export default function ShippingOrderPage() {

출하지시 관리

- {orders.length}건 + {totalCount}건 {loading && }
@@ -548,6 +560,13 @@ export default function ShippingOrderPage() { onRowClick={(row) => setSelectedOrderId(row._orderId)} onRowDoubleClick={(row) => openModal(row._order)} showPagination + serverPagination + serverCurrentPage={currentPage} + serverPageSize={pageSize} + serverTotalCount={totalCount} + onServerPageChange={setCurrentPage} + onServerPageSizeChange={(s) => { setPageSize(s); setCurrentPage(1); }} + defaultPageSize={pageSize} draggableColumns={false} columnOrderKey="c16-shipping-order" /> diff --git a/frontend/app/(main)/COMPANY_9/sales/shipping-plan/page.tsx b/frontend/app/(main)/COMPANY_9/sales/shipping-plan/page.tsx index 747ac23d..9e0398f9 100644 --- a/frontend/app/(main)/COMPANY_9/sales/shipping-plan/page.tsx +++ b/frontend/app/(main)/COMPANY_9/sales/shipping-plan/page.tsx @@ -69,6 +69,11 @@ export default function ShippingPlanPage() { // 검색 필터 (DynamicSearchFilter에서 관리) const [searchFilters, setSearchFilters] = useState([]); + // 서버 페이징 + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(50); + const [totalCount, setTotalCount] = useState(0); + // 상세 패널 편집 const [editPlanQty, setEditPlanQty] = useState(""); const [editPlanDate, setEditPlanDate] = useState(""); @@ -81,7 +86,7 @@ export default function ShippingPlanPage() { const fetchData = useCallback(async () => { setLoading(true); try { - const params: any = {}; + const params: any = { page: currentPage, size: pageSize }; for (const f of searchFilters) { if (f.columnName === "plan_date" && f.operator === "between" && f.value) { const [from, to] = f.value.split(","); @@ -99,19 +104,24 @@ export default function ShippingPlanPage() { const result = await getShipmentPlanList(params); if (result.success) { setData(result.data || []); + setTotalCount(result.total ?? result.data?.length ?? 0); } } catch (err) { console.error("출하계획 조회 실패:", err); } finally { setLoading(false); } - }, [searchFilters]); + }, [searchFilters, currentPage, pageSize]); - // searchFilters 변경 시 자동 조회 + // searchFilters 변경 시 자동 조회 + 1페이지로 리셋 useEffect(() => { fetchData(); }, [fetchData]); + useEffect(() => { + setCurrentPage(1); + }, [searchFilters]); + const selectedPlan = useMemo(() => data.find(p => p.id === selectedId), [data, selectedId]); const groupedData = useMemo(() => { @@ -209,7 +219,7 @@ export default function ShippingPlanPage() { tableName="shipment_plan" filterId="c16-shipping-plan" onFilterChange={setSearchFilters} - dataCount={data.length} + dataCount={totalCount} externalFilterConfig={ts.filterConfig} /> @@ -224,7 +234,7 @@ export default function ShippingPlanPage() {
출하계획 목록 - {data.length}건 + {totalCount}건 {loading && }
@@ -262,7 +272,14 @@ export default function ShippingPlanPage() { showCheckbox checkedIds={checkedIds.map(String)} onCheckedChange={(ids) => setCheckedIds(ids.map(Number))} - showPagination={false} + showPagination + serverPagination + serverCurrentPage={currentPage} + serverPageSize={pageSize} + serverTotalCount={totalCount} + onServerPageChange={setCurrentPage} + onServerPageSizeChange={(s) => { setPageSize(s); setCurrentPage(1); }} + defaultPageSize={pageSize} draggableColumns={false} />
diff --git a/frontend/components/common/TimelineScheduler.tsx b/frontend/components/common/TimelineScheduler.tsx index edad9de2..c768492f 100644 --- a/frontend/components/common/TimelineScheduler.tsx +++ b/frontend/components/common/TimelineScheduler.tsx @@ -70,6 +70,8 @@ export interface TimelineSchedulerProps { onEventMove?: (eventId: string | number, newStartDate: string, newEndDate: string) => void; /** 리사이즈 완료 */ onEventResize?: (eventId: string | number, newStartDate: string, newEndDate: string) => void; + /** 표시 기간(이전/다음/오늘 또는 줌 변경) 변경 시 호출 — 부모가 데이터 재조회 등에 사용 */ + onRangeChange?: (startDate: string, endDate: string) => void; /** 상태별 색상 배열 */ statusColors?: StatusColor[]; /** 진행률 바 표시 여부 */ @@ -191,6 +193,7 @@ export default function TimelineScheduler({ onEventClick, onEventMove, onEventResize, + onRangeChange, statusColors = DEFAULT_STATUS_COLORS, showProgress = true, showMilestones = true, @@ -249,6 +252,14 @@ export default function TimelineScheduler({ return arr; }, [baseDate, config.spanDays]); + // 표시 범위 변경 시 부모에 알림 (데이터 재조회 트리거용) + useEffect(() => { + if (!onRangeChange) return; + const start = toDateStr(baseDate); + const end = toDateStr(addDays(baseDate, config.spanDays - 1)); + onRangeChange(start, end); + }, [baseDate, config.spanDays, onRangeChange]); + const totalWidth = config.cellWidth * config.spanDays; // 충돌 ID 집합 diff --git a/frontend/components/layout/AdminPageRenderer.tsx b/frontend/components/layout/AdminPageRenderer.tsx index 3662876a..397e5cc0 100644 --- a/frontend/components/layout/AdminPageRenderer.tsx +++ b/frontend/components/layout/AdminPageRenderer.tsx @@ -372,6 +372,8 @@ const ADMIN_PAGE_REGISTRY: Record> = { "/COMPANY_9/design/task-management": dynamic(() => import("@/app/(main)/COMPANY_9/design/task-management/page"), { ssr: false, loading: LoadingFallback }), // === COMPANY_30 === + "/COMPANY_30/kpi/production/daily": dynamic(() => import("@/app/(main)/COMPANY_30/kpi/production/daily/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_16/kpi/production/daily": dynamic(() => import("@/app/(main)/COMPANY_16/kpi/production/daily/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_30/master-data/item-info": dynamic(() => import("@/app/(main)/COMPANY_30/master-data/item-info/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_30/master-data/department": dynamic(() => import("@/app/(main)/COMPANY_30/master-data/department/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_30/master-data/company": dynamic(() => import("@/app/(main)/COMPANY_30/master-data/company/page"), { ssr: false, loading: LoadingFallback }), @@ -601,6 +603,8 @@ const DYNAMIC_ADMIN_IMPORTS: Record Promise> = { "/COMPANY_9/design/design-request": () => import("@/app/(main)/COMPANY_9/design/design-request/page"), "/COMPANY_9/design/task-management": () => import("@/app/(main)/COMPANY_9/design/task-management/page"), // COMPANY_30 + "/COMPANY_30/kpi/production/daily": () => import("@/app/(main)/COMPANY_30/kpi/production/daily/page"), + "/COMPANY_16/kpi/production/daily": () => import("@/app/(main)/COMPANY_16/kpi/production/daily/page"), "/COMPANY_30/master-data/item-info": () => import("@/app/(main)/COMPANY_30/master-data/item-info/page"), "/COMPANY_30/master-data/department": () => import("@/app/(main)/COMPANY_30/master-data/department/page"), "/COMPANY_30/master-data/company": () => import("@/app/(main)/COMPANY_30/master-data/company/page"), @@ -818,6 +822,7 @@ const COMPANY_PAGE_PREFIXES = [ "/quality/", "/mold/", "/monitoring/", + "/kpi/", ]; function isCompanyPage(url: string): boolean { diff --git a/frontend/lib/api/processInfo.ts b/frontend/lib/api/processInfo.ts index 594484cd..0f512fae 100644 --- a/frontend/lib/api/processInfo.ts +++ b/frontend/lib/api/processInfo.ts @@ -60,6 +60,8 @@ export interface RoutingDetail { outsource_supplier: string; outsource_supplier_ids?: string[]; outsource_supplier_list?: string[]; // legacy code 배열 (호환용) + /** 실행 방식 — 카테고리 코드 (item_routing_detail.execution_type 컬럼) */ + execution_type?: string; } interface ApiResponse { diff --git a/frontend/lib/api/production.ts b/frontend/lib/api/production.ts index 48d3e894..4b6caad1 100644 --- a/frontend/lib/api/production.ts +++ b/frontend/lib/api/production.ts @@ -108,6 +108,8 @@ export async function getPlans(params?: { startDate?: string; endDate?: string; itemCode?: string; + page?: number; + size?: number; }) { const queryParams = new URLSearchParams(); if (params?.productType) queryParams.set("productType", params.productType); @@ -115,11 +117,20 @@ export async function getPlans(params?: { if (params?.startDate) queryParams.set("startDate", params.startDate); if (params?.endDate) queryParams.set("endDate", params.endDate); if (params?.itemCode) queryParams.set("itemCode", params.itemCode); + if (params?.page != null) queryParams.set("page", String(params.page)); + if (params?.size != null) queryParams.set("size", String(params.size)); const qs = queryParams.toString(); const url = `/production/plans${qs ? `?${qs}` : ""}`; const response = await apiClient.get(url); - return response.data as { success: boolean; data: ProductionPlan[] }; + return response.data as { + success: boolean; + data: ProductionPlan[]; + total?: number; + page?: number; + size?: number; + totalPages?: number; + }; } /** 자동 스케줄 미리보기 (DB 변경 없이 예상 결과) */ @@ -145,16 +156,27 @@ export async function getOrderSummary(params?: { excludePlanned?: boolean; itemCode?: string; itemName?: string; + page?: number; + size?: number; }) { const queryParams = new URLSearchParams(); if (params?.excludePlanned) queryParams.set("excludePlanned", "true"); if (params?.itemCode) queryParams.set("itemCode", params.itemCode); if (params?.itemName) queryParams.set("itemName", params.itemName); + if (params?.page != null) queryParams.set("page", String(params.page)); + if (params?.size != null) queryParams.set("size", String(params.size)); const qs = queryParams.toString(); const url = `/production/order-summary${qs ? `?${qs}` : ""}`; const response = await apiClient.get(url); - return response.data as { success: boolean; data: OrderSummaryItem[] }; + return response.data as { + success: boolean; + data: OrderSummaryItem[]; + total?: number; + page?: number; + size?: number; + totalPages?: number; + }; } /** 안전재고 부족분 조회 */ diff --git a/frontend/lib/api/shipping.ts b/frontend/lib/api/shipping.ts index 28608d84..bfbfaba6 100644 --- a/frontend/lib/api/shipping.ts +++ b/frontend/lib/api/shipping.ts @@ -90,11 +90,20 @@ export interface ShipmentPlanListParams { status?: string; customer?: string; keyword?: string; + page?: number; + size?: number; } export async function getShipmentPlanList(params: ShipmentPlanListParams) { const res = await apiClient.get("/shipping-plan/list", { params }); - return res.data as { success: boolean; data: ShipmentPlanListItem[] }; + return res.data as { + success: boolean; + data: ShipmentPlanListItem[]; + total?: number; + page?: number; + size?: number; + totalPages?: number; + }; } // 출하계획 단건 수정 @@ -114,9 +123,18 @@ export async function getShippingOrderList(params?: { status?: string; customer?: string; keyword?: string; + page?: number; + size?: number; }) { const res = await apiClient.get("/shipping-order/list", { params }); - return res.data as { success: boolean; data: any[] }; + return res.data as { + success: boolean; + data: any[]; + total?: number; + page?: number; + size?: number; + totalPages?: number; + }; } export async function saveShippingOrder(data: any) { diff --git a/frontend/lib/registry/components/v2-process-work-standard/hooks/useProcessWorkStandard.ts b/frontend/lib/registry/components/v2-process-work-standard/hooks/useProcessWorkStandard.ts index 4bdbf5a4..3b955114 100644 --- a/frontend/lib/registry/components/v2-process-work-standard/hooks/useProcessWorkStandard.ts +++ b/frontend/lib/registry/components/v2-process-work-standard/hooks/useProcessWorkStandard.ts @@ -135,7 +135,7 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) { [config.dataSource] ); - // 작업 항목 조회 + // 작업 항목 조회 + 각 phase별 첫 항목 자동 선택 (상세 영역이 비어 보이는 오해 방지) const fetchWorkItems = useCallback(async (routingDetailId: string) => { try { setLoading(true); @@ -143,7 +143,26 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) { `${API_BASE}/routing-detail/${routingDetailId}/work-items` ); if (res.data?.success) { - setWorkItems(res.data.items || []); + const items: WorkItem[] = res.data.items || []; + setWorkItems(items); + + // 각 phase별 첫 번째 항목 자동 선택 + 상세 병렬 로드 + const firstByPhase: Record = {}; + for (const it of items) { + const phase = (it as any).work_phase; + if (phase && !firstByPhase[phase]) firstByPhase[phase] = it; + } + await Promise.all( + Object.entries(firstByPhase).map(async ([phaseKey, item]) => { + try { + const dr = await apiClient.get(`${API_BASE}/work-items/${item.id}/details`); + if (dr.data?.success) { + setSelectedDetailsByPhase((prev) => ({ ...prev, [phaseKey]: dr.data.data })); + setSelectedWorkItemIdByPhase((prev) => ({ ...prev, [phaseKey]: item.id })); + } + } catch { /* skip */ } + }) + ); } } catch (err) { console.error("작업 항목 조회 실패", err);