From 6ddc84f285e1640306355dc44b41c7f4a58c7d2a Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 29 Apr 2026 18:20:01 +0900 Subject: [PATCH] Update date handling in inventory and sales order pages - Refactored date handling in the InventoryStatusPage to use `toLocaleString` for transaction dates and last in dates, ensuring correct timezone formatting. - Introduced FormDatePicker in SalesOrderPage for date inputs, enhancing user experience with automatic formatting and improved date handling. - Added a checkbox for filtering items by customer in SalesOrderPage, allowing users to view only items registered for the selected customer. This update improves date accuracy and user interaction in the inventory and sales order modules. --- backend-node/src/app.ts | 2 + .../qualityMonitoringController.ts | 116 ++++++++++++++++++ .../src/controllers/receivingController.ts | 40 +++--- .../src/controllers/shippingPlanController.ts | 23 ++-- .../src/routes/qualityMonitoringRoutes.ts | 16 +++ .../src/services/cuttingPlanService.ts | 10 +- .../src/services/productionPlanService.ts | 2 +- .../src/services/tableManagementService.ts | 13 +- .../COMPANY_10/logistics/inventory/page.tsx | 4 +- .../COMPANY_10/master-data/item-info/page.tsx | 50 ++++---- .../(main)/COMPANY_10/sales/order/page.tsx | 47 ++++--- .../COMPANY_16/logistics/inventory/page.tsx | 4 +- .../COMPANY_16/master-data/item-info/page.tsx | 50 ++++---- .../(main)/COMPANY_16/sales/order/page.tsx | 47 ++++--- .../COMPANY_29/logistics/inventory/page.tsx | 4 +- .../COMPANY_29/master-data/item-info/page.tsx | 50 ++++---- .../(main)/COMPANY_29/sales/order/page.tsx | 47 ++++--- .../COMPANY_30/logistics/inventory/page.tsx | 8 +- .../COMPANY_30/master-data/item-info/page.tsx | 51 ++++---- .../COMPANY_30/monitoring/quality/page.tsx | 82 ++++++++----- .../COMPANY_30/purchase/supplier/page.tsx | 8 +- .../(main)/COMPANY_30/sales/order/page.tsx | 21 +++- .../COMPANY_7/logistics/inventory/page.tsx | 4 +- .../COMPANY_7/master-data/item-info/page.tsx | 51 ++++---- .../app/(main)/COMPANY_7/sales/order/page.tsx | 47 ++++--- .../COMPANY_8/logistics/inventory/page.tsx | 4 +- .../COMPANY_8/master-data/item-info/page.tsx | 50 ++++---- .../app/(main)/COMPANY_8/sales/order/page.tsx | 47 ++++--- .../COMPANY_9/logistics/inventory/page.tsx | 4 +- .../COMPANY_9/master-data/item-info/page.tsx | 50 ++++---- frontend/components/common/DataGrid.tsx | 55 +++++++-- frontend/components/common/EDataTable.tsx | 54 ++++++-- .../components/common/TimelineScheduler.tsx | 37 +++--- .../screen/filters/FormDatePicker.tsx | 65 ++++++++-- frontend/components/ui/input.tsx | 20 +++ 35 files changed, 813 insertions(+), 370 deletions(-) create mode 100644 backend-node/src/controllers/qualityMonitoringController.ts create mode 100644 backend-node/src/routes/qualityMonitoringRoutes.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index a8d27e8f..3b270288 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -121,6 +121,7 @@ import bomRoutes from "./routes/bomRoutes"; // BOM 이력/버전 관리 import productionRoutes from "./routes/productionRoutes"; // 생산계획 관리 import itemInspectionRoutes from "./routes/itemInspectionRoutes"; // 품목검사정보 import salesOrderAuditRoutes from "./routes/salesOrderAuditRoutes"; // 수주 변경 이력 +import qualityMonitoringRoutes from "./routes/qualityMonitoringRoutes"; // 품질 모니터링 import crawlRoutes from "./routes/crawlRoutes"; // 웹 크롤링 import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리 import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리 @@ -362,6 +363,7 @@ app.use("/api/bom", bomRoutes); // BOM 이력/버전 관리 app.use("/api/production", productionRoutes); // 생산계획 관리 app.use("/api/item-inspection", itemInspectionRoutes); // 품목검사정보 (그룹 페이징) app.use("/api/sales-order", salesOrderAuditRoutes); // 수주 변경 이력 +app.use("/api/quality-monitoring", qualityMonitoringRoutes); // 품질 모니터링 app.use("/api/crawl", crawlRoutes); // 웹 크롤링 app.use("/api/material-status", materialStatusRoutes); // 자재현황 app.use("/api/process-info", processInfoRoutes); // 공정정보관리 diff --git a/backend-node/src/controllers/qualityMonitoringController.ts b/backend-node/src/controllers/qualityMonitoringController.ts new file mode 100644 index 00000000..c4736e52 --- /dev/null +++ b/backend-node/src/controllers/qualityMonitoringController.ts @@ -0,0 +1,116 @@ +/** + * 품질 모니터링 데이터 조회 (서버 페이징 + 통계 합산) + * work_order_process(공정 메타) + work_order_process_result(실적) JOIN + * - 페이지: 화면 표 표시용 + * - summary: KPI 카드용 (전체 합산) + */ + +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +export async function getQualityMonitoringData(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const from = (req.query.from as string | undefined)?.trim() || ""; + const to = (req.query.to as string | undefined)?.trim() || ""; + const page = Math.max(1, parseInt(String(req.query.page ?? "1"), 10) || 1); + const size = Math.max(1, Math.min(500, parseInt(String(req.query.size ?? "50"), 10) || 50)); + const offset = (page - 1) * size; + + const params: any[] = [companyCode]; + const conds: string[] = ["wopr.company_code = $1"]; + if (from) { + params.push(`${from} 00:00:00`); + conds.push(`wopr.created_date >= $${params.length}::timestamp`); + } + if (to) { + params.push(`${to} 23:59:59`); + conds.push(`wopr.created_date <= $${params.length}::timestamp`); + } + const whereClause = `WHERE ${conds.join(" AND ")}`; + + const pool = getPool(); + + // 1) total + summary (KPI 카드) + const summaryQuery = ` + SELECT + COUNT(*)::int AS total, + SUM(CASE WHEN wopr.status = 'completed' AND COALESCE(CAST(NULLIF(wopr.defect_qty, '') AS numeric), 0) = 0 THEN 1 ELSE 0 END)::int AS passed, + SUM(CASE WHEN wopr.status = 'completed' AND COALESCE(CAST(NULLIF(wopr.defect_qty, '') AS numeric), 0) > 0 THEN 1 ELSE 0 END)::int AS failed, + SUM(CASE WHEN wopr.status <> 'completed' OR wopr.status IS NULL THEN 1 ELSE 0 END)::int AS pending + FROM work_order_process_result wopr + ${whereClause} + `; + const summaryRes = await pool.query(summaryQuery, params); + const summaryRow = summaryRes.rows[0] || { total: 0, passed: 0, failed: 0, pending: 0 }; + const total = summaryRow.total || 0; + const passRate = total > 0 ? Math.round((summaryRow.passed / total) * 1000) / 10 : 0; + + // 2) 페이지 데이터 + const pageParams = [...params, size, offset]; + const dataQuery = ` + SELECT + wopr.id, + wopr.wop_id, + wopr.status, + wopr.input_qty, + wopr.good_qty, + wopr.defect_qty, + wopr.started_at, + wopr.completed_at, + wopr.completed_by, + wopr.accepted_by, + wop.wo_id, + wop.process_code, + wop.process_name, + wop.plan_qty + FROM work_order_process_result wopr + LEFT JOIN work_order_process wop + ON wop.id = wopr.wop_id AND wop.company_code = wopr.company_code + ${whereClause} + ORDER BY wopr.created_date DESC + LIMIT $${params.length + 1} OFFSET $${params.length + 2} + `; + const dataRes = await pool.query(dataQuery, pageParams); + + const rows = dataRes.rows.map((r: any) => ({ + id: r.id, + wo_id: r.wo_id, + process_code: r.process_code || "", + process_name: r.process_name || "", + status: r.status || "", + plan_qty: Number(r.plan_qty) || 0, + input_qty: Number(r.input_qty) || 0, + good_qty: Number(r.good_qty) || 0, + defect_qty: Number(r.defect_qty) || 0, + started_at: r.started_at || null, + completed_at: r.completed_at || null, + worker_name: r.completed_by || r.accepted_by || "", + })); + + logger.info("품질 모니터링 조회", { + companyCode, from, to, page, size, total, + }); + + return res.json({ + success: true, + rows, + total, + page, + size, + totalPages: Math.max(1, Math.ceil(total / size)), + summary: { + total, + passed: summaryRow.passed || 0, + failed: summaryRow.failed || 0, + pending: summaryRow.pending || 0, + passRate, + }, + }); + } 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/receivingController.ts b/backend-node/src/controllers/receivingController.ts index f8c635a5..ac9295f4 100644 --- a/backend-node/src/controllers/receivingController.ts +++ b/backend-node/src/controllers/receivingController.ts @@ -220,16 +220,9 @@ export async function create(req: AuthenticatedRequest, res: Response) { } const insertedDetails: any[] = []; - // 기존 디테일이 있으면 스킵 (멱등성 — 같은 inbound_number로 2번 호출 방지) - const existingDetails = await client.query( - `SELECT COUNT(*) AS cnt FROM inbound_detail WHERE company_code = $1 AND inbound_id = $2`, - [companyCode, inboundNumber] - ); - if (parseInt(existingDetails.rows[0].cnt, 10) > 0) { - await client.query("COMMIT"); - client.release(); - return res.json({ success: true, data: [], message: "이미 등록된 입고입니다." }); - } + // 멱등성 체크는 제거 — 수정 모달에서 "기존 입고에 새 품목 추가" 케이스가 차단되던 버그. + // 더블클릭 방지는 프론트 setSaving 가드로 처리. 같은 inbound_number에 detail이 이미 있어도 + // 새 품목 추가는 정상 흐름이므로 그대로 INSERT 진행. // 2. 디테일 INSERT (inbound_detail) + 재고/발주 업데이트 for (let i = 0; i < items.length; i++) { @@ -1312,6 +1305,8 @@ export async function getProductionResults( const pool = getPool(); + // 실적 컬럼(good_qty/concession_qty/result_status/is_rework)은 work_order_process가 아닌 + // work_order_process_result에 존재 → wopr_agg로 LEFT JOIN하여 합계/대표값 사용 const dataResult = await pool.query( `SELECT wop.id, @@ -1325,14 +1320,12 @@ export async function getProductionResults( COALESCE(ii.item_name, ii.item_number, wi.item_id) AS item_name, COALESCE(ii.size, '') AS spec, COALESCE(ii.material, '') AS material, - COALESCE(CAST(NULLIF(wop.good_qty, '') AS numeric), 0) - + COALESCE(CAST(NULLIF(wop.concession_qty, '') AS numeric), 0) AS order_qty, + COALESCE(wopr_agg.sum_good, 0) + COALESCE(wopr_agg.sum_concession, 0) AS order_qty, COALESCE(rcv.received_qty, 0) AS received_qty, - COALESCE(CAST(NULLIF(wop.good_qty, '') AS numeric), 0) - + COALESCE(CAST(NULLIF(wop.concession_qty, '') AS numeric), 0) + COALESCE(wopr_agg.sum_good, 0) + COALESCE(wopr_agg.sum_concession, 0) - COALESCE(rcv.received_qty, 0) AS remain_qty, 'work_order_process' AS source_table, - wop.result_status, + wopr_agg.last_status AS result_status, COALESCE(ii.image, NULL) AS image, CASE WHEN EXISTS ( SELECT 1 FROM item_inspection_info iii @@ -1348,6 +1341,16 @@ export async function getProductionResults( FROM item_info ORDER BY id, company_code, created_date DESC ) ii ON wi.item_id = ii.id AND wi.company_code = ii.company_code + LEFT JOIN ( + SELECT wop_id, company_code, + SUM(COALESCE(CAST(NULLIF(good_qty, '') AS numeric), 0)) AS sum_good, + SUM(COALESCE(CAST(NULLIF(concession_qty, '') AS numeric), 0)) AS sum_concession, + SUM(CASE WHEN is_rework = 'Y' THEN 1 ELSE 0 END) AS rework_count, + MAX(result_status) AS last_status + FROM work_order_process_result + WHERE company_code = $1 + GROUP BY wop_id, company_code + ) wopr_agg ON wopr_agg.wop_id = wop.id AND wopr_agg.company_code = wop.company_code LEFT JOIN ( SELECT im.source_id, SUM(COALESCE(CAST(NULLIF(id.inbound_qty::text, '') AS numeric), 0)) AS received_qty @@ -1362,11 +1365,10 @@ export async function getProductionResults( WHERE wop.company_code = $1 AND wop.process_code = $2 AND wop.parent_process_id IS NULL - AND (wop.is_rework IS NULL OR wop.is_rework != 'Y') - AND COALESCE(CAST(NULLIF(wop.good_qty, '') AS numeric), 0) > 0 + AND COALESCE(wopr_agg.rework_count, 0) = 0 + AND COALESCE(wopr_agg.sum_good, 0) > 0 AND ( - COALESCE(CAST(NULLIF(wop.good_qty, '') AS numeric), 0) - + COALESCE(CAST(NULLIF(wop.concession_qty, '') AS numeric), 0) + COALESCE(wopr_agg.sum_good, 0) + COALESCE(wopr_agg.sum_concession, 0) - COALESCE(rcv.received_qty, 0) ) > 0 ${keywordCondition} diff --git a/backend-node/src/controllers/shippingPlanController.ts b/backend-node/src/controllers/shippingPlanController.ts index 0ac09b35..06deaa0a 100644 --- a/backend-node/src/controllers/shippingPlanController.ts +++ b/backend-node/src/controllers/shippingPlanController.ts @@ -220,18 +220,23 @@ export async function getList(req: AuthenticatedRequest, res: Response) { COALESCE(i.item_name, d.part_name, m.part_name, COALESCE(d.part_code, m.part_code, '')) AS part_name, COALESCE(d.spec, m.spec, '') AS spec, COALESCE(m.material, '') AS material, - COALESCE(c.customer_name, '') AS customer_name, + COALESCE(NULLIF(c.customer_name, ''), m.partner_id, d.delivery_partner_code, '') AS customer_name, COALESCE(m.partner_id, d.delivery_partner_code, '') AS partner_code, - COALESCE(d.due_date, m.due_date::text, '') AS due_date, + COALESCE(NULLIF(d.due_date, ''), NULLIF(m.due_date::text, ''), NULLIF(m.item_due_date::text, ''), '') AS due_date, COALESCE(NULLIF(d.qty,'')::numeric, m.order_qty, 0) AS order_qty, - COALESCE(NULLIF(d.ship_qty,'')::numeric, m.ship_qty, 0) AS shipped_qty + COALESCE(NULLIF(d.ship_qty,'')::numeric, m.ship_qty, 0) AS shipped_qty, + COALESCE(NULLIF(d.width, ''), i.width::text, '') AS width, + COALESCE(NULLIF(d.height, ''), i.height::text, '') AS height, + COALESCE(NULLIF(d.thickness, ''), i.thickness::text, '') AS thickness 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 sales_order_detail d + ON sp.company_code = d.company_code + AND (d.id = sp.detail_id::text + OR (d.order_no = m.order_no AND d.part_code = m.part_code)) LEFT JOIN LATERAL ( - SELECT item_name FROM item_info + SELECT item_name, width, height, thickness FROM item_info WHERE item_number = COALESCE(d.part_code, m.part_code) AND company_code = sp.company_code LIMIT 1 @@ -250,10 +255,12 @@ export async function getList(req: AuthenticatedRequest, res: Response) { 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 sales_order_detail d + ON sp.company_code = d.company_code + AND (d.id = sp.detail_id::text + OR (d.order_no = m.order_no AND d.part_code = m.part_code)) LEFT JOIN LATERAL ( SELECT item_name FROM item_info WHERE item_number = COALESCE(d.part_code, m.part_code) diff --git a/backend-node/src/routes/qualityMonitoringRoutes.ts b/backend-node/src/routes/qualityMonitoringRoutes.ts new file mode 100644 index 00000000..38da2463 --- /dev/null +++ b/backend-node/src/routes/qualityMonitoringRoutes.ts @@ -0,0 +1,16 @@ +/** + * 품질 모니터링 라우트 + */ + +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as controller from "../controllers/qualityMonitoringController"; + +const router = Router(); + +router.use(authenticateToken); + +// 품질점검현황 모니터링 (서버 페이징 + summary) +router.get("/data", controller.getQualityMonitoringData); + +export default router; diff --git a/backend-node/src/services/cuttingPlanService.ts b/backend-node/src/services/cuttingPlanService.ts index 5c997ca0..97b85be7 100644 --- a/backend-node/src/services/cuttingPlanService.ts +++ b/backend-node/src/services/cuttingPlanService.ts @@ -30,7 +30,15 @@ export async function getMaterials(companyCode: string, cutType: string) { GROUP BY item_code ) inv ON inv.item_code = ii.item_number WHERE ii.company_code = $1 - AND ii.division = 'CAT_DIV_RAW_MAT' + AND EXISTS ( + SELECT 1 FROM category_values cv + WHERE cv.table_name = 'item_info' + AND cv.column_name = 'division' + AND cv.value_label = '원자재' + AND cv.is_active = true + AND (cv.company_code = $1 OR cv.company_code IS NULL OR cv.company_code = '') + AND cv.value_code = ANY(string_to_array(REPLACE(ii.division, ' ', ''), ',')) + ) AND COALESCE(ii.status,'active') <> 'deleted' ORDER BY ii.item_name `; diff --git a/backend-node/src/services/productionPlanService.ts b/backend-node/src/services/productionPlanService.ts index 850b6f20..95f787a9 100644 --- a/backend-node/src/services/productionPlanService.ts +++ b/backend-node/src/services/productionPlanService.ts @@ -131,7 +131,7 @@ export async function getOrderSummary( LEFT JOIN stock_info si ON os.item_code = si.item_code 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" : ""} + ${options?.excludePlanned ? "WHERE COALESCE(pi.existing_plan_qty, 0) + COALESCE(pi.in_progress_qty, 0) = 0" : ""} ORDER BY os.item_code `; diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 7b7a1270..12aadf03 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -2352,12 +2352,14 @@ export class TableManagementService { orderClause = `ORDER BY ${safeSortBy} ${safeSortOrder}`; } else { // sortBy가 없으면 created_date 컬럼이 있는 경우에만 기본 정렬 적용 + // 최근 등록 레코드가 최상단에 오도록 created_date DESC + NULLS LAST 적용 + // (TASK:ERP-018 — 사용자 미지정 시 default 정렬을 created_date DESC NULLS LAST로 통일) const hasCreatedDate = await query( `SELECT 1 FROM information_schema.columns WHERE table_name = $1 AND column_name = 'created_date' LIMIT 1`, [safeTableName] ); if (hasCreatedDate.length > 0) { - orderClause = `ORDER BY main.created_date DESC`; + orderClause = `ORDER BY main.created_date DESC NULLS LAST`; } } @@ -3653,13 +3655,14 @@ export class TableManagementService { // ORDER BY 절 구성 // sortBy가 메인 테이블 컬럼이면 main. 접두사, 조인 별칭이면 접두사 없이 사용 + // (TASK:ERP-018 — 사용자 미지정 시 default 정렬을 created_date DESC NULLS LAST로 통일) const hasCreatedDateColumn = selectColumns.includes("created_date"); const orderBy = options.sortBy ? selectColumns.includes(options.sortBy) ? `main."${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}` : `"${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}` : hasCreatedDateColumn - ? `main."created_date" DESC` + ? `main."created_date" DESC NULLS LAST` : ""; // 페이징 계산 @@ -3897,6 +3900,7 @@ export class TableManagementService { const entitySearchColumns: string[] = []; // Entity 조인 쿼리 생성하여 별칭 매핑 얻기 + // (TASK:ERP-018 — default 정렬: created_date DESC NULLS LAST) const hasCreatedDateForSearch = selectColumns.includes("created_date"); const joinQueryResult = entityJoinService.buildJoinQuery( tableName, @@ -3908,7 +3912,7 @@ export class TableManagementService { ? `main."${options.sortBy}" ${options.sortOrder || "ASC"}` : `"${options.sortBy}" ${options.sortOrder || "ASC"}` : hasCreatedDateForSearch - ? `main."created_date" DESC` + ? `main."created_date" DESC NULLS LAST` : undefined, options.size, (options.page - 1) * options.size @@ -4095,13 +4099,14 @@ export class TableManagementService { } const whereClause = whereConditions.join(" AND "); + // (TASK:ERP-018 — default 정렬: created_date DESC NULLS LAST) const hasCreatedDateForOrder = selectColumns.includes("created_date"); const orderBy = options.sortBy ? selectColumns.includes(options.sortBy) ? `main."${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}` : `"${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}` : hasCreatedDateForOrder - ? `main."created_date" DESC` + ? `main."created_date" DESC NULLS LAST` : ""; // 페이징 계산 diff --git a/frontend/app/(main)/COMPANY_10/logistics/inventory/page.tsx b/frontend/app/(main)/COMPANY_10/logistics/inventory/page.tsx index d5d36390..63b28ead 100644 --- a/frontend/app/(main)/COMPANY_10/logistics/inventory/page.tsx +++ b/frontend/app/(main)/COMPANY_10/logistics/inventory/page.tsx @@ -402,7 +402,7 @@ export default function InventoryStatusPage() { warehouse_code: targetWhCode, location_code: targetLocCode, transaction_type: "조정", - transaction_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"), + transaction_date: new Date().toLocaleString("sv-SE", { timeZone: "Asia/Seoul" }).replace(" ", "T"), quantity: String(changeQty), balance_qty: String(afterQty), remark: adjustForm.reason.trim(), @@ -419,7 +419,7 @@ export default function InventoryStatusPage() { location_code: targetLocCode, current_qty: String(afterQty), safety_qty: "0", - last_in_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"), + last_in_date: new Date().toLocaleString("sv-SE", { timeZone: "Asia/Seoul" }).replace(" ", "T"), } ); } else { diff --git a/frontend/app/(main)/COMPANY_10/master-data/item-info/page.tsx b/frontend/app/(main)/COMPANY_10/master-data/item-info/page.tsx index ad55d2b2..68552dc6 100644 --- a/frontend/app/(main)/COMPANY_10/master-data/item-info/page.tsx +++ b/frontend/app/(main)/COMPANY_10/master-data/item-info/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useCallback, useRef } from "react"; +import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -186,7 +186,7 @@ const CATEGORY_COLUMNS = [ export default function ItemInfoPage() { const { user } = useAuth(); const ts = useTableSettings("c16-item-info", TABLE_NAME, GRID_COLUMNS); - const [items, setItems] = useState([]); + // items는 rawItems + categoryOptions로 useMemo 파생 const [rawItems, setRawItems] = useState([]); const [loading, setLoading] = useState(false); @@ -359,6 +359,7 @@ export default function ItemInfoPage() { }, []); // 데이터 조회 + // 데이터 조회 — rawItems만 fetch. 라벨 변환은 useMemo로 파생. const fetchItems = useCallback(async () => { setLoading(true); try { @@ -369,36 +370,37 @@ export default function ItemInfoPage() { dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, autoFilter: true, }); - const raw = res.data?.data?.data || res.data?.data?.rows || []; - const resolve = (col: string, code: string) => { - if (!code) return ""; - // 쉼표 구분 다중값 지원 - if (code.includes(",")) { - return code.split(",").map((c) => { - const trimmed = c.trim(); - if (!trimmed || trimmed === "s") return ""; - return categoryOptions[col]?.find((o) => o.code === trimmed)?.label || trimmed; - }).filter(Boolean).join(", "); - } - return categoryOptions[col]?.find((o) => o.code === code)?.label || code; - }; setRawItems(raw); - const data = raw.map((r: any) => { - const converted = { ...r }; - for (const col of CATEGORY_COLUMNS) { - if (converted[col]) converted[col] = resolve(col, converted[col]); - } - return converted; - }); - setItems(data); } catch (err) { console.error("품목 조회 실패:", err); toast.error("품목 목록을 불러오는데 실패했어요."); } finally { setLoading(false); } - }, [categoryOptions, searchFilters]); + }, [searchFilters]); + + // 표시용 items — rawItems + categoryOptions 결합으로 파생 + const items = useMemo(() => { + const resolve = (col: string, code: string) => { + if (!code) return ""; + if (code.includes(",")) { + return code.split(",").map((c) => { + const trimmed = c.trim(); + if (!trimmed || trimmed === "s") return ""; + return categoryOptions[col]?.find((o) => o.code === trimmed)?.label || trimmed; + }).filter(Boolean).join(", "); + } + return categoryOptions[col]?.find((o) => o.code === code)?.label || code; + }; + return rawItems.map((r: any) => { + const converted = { ...r }; + for (const col of CATEGORY_COLUMNS) { + if (converted[col]) converted[col] = resolve(col, converted[col]); + } + return converted; + }); + }, [rawItems, categoryOptions]); useEffect(() => { fetchItems(); diff --git a/frontend/app/(main)/COMPANY_10/sales/order/page.tsx b/frontend/app/(main)/COMPANY_10/sales/order/page.tsx index c1b9a21d..94f02289 100644 --- a/frontend/app/(main)/COMPANY_10/sales/order/page.tsx +++ b/frontend/app/(main)/COMPANY_10/sales/order/page.tsx @@ -3,6 +3,7 @@ import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { FormDatePicker } from "@/components/screen/filters/FormDatePicker"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { @@ -207,6 +208,8 @@ export default function SalesOrderPage() { const [itemSearchLoading, setItemSearchLoading] = useState(false); const [itemSelectedMap, setItemSelectedMap] = useState>(new Map()); const [itemSearchDivision, setItemSearchDivision] = useState("all"); + // 거래처 품목만 보기 (기본: 끔 → 영업관리 전체 검색) + const [itemSearchOnlyCustomer, setItemSearchOnlyCustomer] = useState(false); const [itemPage, setItemPage] = useState(1); const [itemPageSize, setItemPageSize] = useState(20); const [itemTotalPages, setItemTotalPages] = useState(0); @@ -773,9 +776,11 @@ export default function SalesOrderPage() { }; // 품목 검색 - const searchItems = async (page?: number, size?: number) => { + // onlyCustomerOverride: 체크박스 onChange 직후 새 값을 즉시 반영하기 위한 override (stale state 회피) + const searchItems = async (page?: number, size?: number, onlyCustomerOverride?: boolean) => { const p = page ?? itemPage; const s = size ?? itemPageSize; + const useOnlyCustomer = onlyCustomerOverride !== undefined ? onlyCustomerOverride : itemSearchOnlyCustomer; setItemSearchLoading(true); try { const filters: any[] = []; @@ -786,13 +791,10 @@ export default function SalesOrderPage() { filters.push({ columnName: "division", operator: "contains", value: itemSearchDivision }); } - // 거래처우선 단가방식일 때 거래처 매핑 id 정규화 → 서버 필터 적용 - // price_mode의 라벨로 판단 (카테고리 코드는 회사마다 다를 수 있음) - const priceModeLabel = (categoryOptions["price_mode"] || []).find((o) => o.code === masterForm.price_mode)?.label || ""; - const isCustomerPrice = priceModeLabel.includes("거래처"); + // 거래처 품목 필터 — 옵션 체크박스가 켜진 경우만 적용 (price_mode와 무관하게 항상 전체 검색이 기본) const partnerId = masterForm.partner_id; - if (isCustomerPrice && partnerId) { + if (useOnlyCustomer && partnerId) { try { const mappingRes = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, { page: 1, size: 0, @@ -1490,11 +1492,10 @@ export default function SalesOrderPage() { - setMasterForm((p) => ({ ...p, order_date: e.target.value }))} - className="h-9" + onChange={(v) => setMasterForm((p) => ({ ...p, order_date: v }))} + placeholder="수주일" />
@@ -1796,11 +1797,10 @@ export default function SalesOrderPage() { {row.amount ? Number(row.amount).toLocaleString() : "0"} - updateDetailRow(idx, "due_date", e.target.value)} - className="h-8 text-xs w-full" + onChange={(v) => updateDetailRow(idx, "due_date", v)} + placeholder="납기일" /> @@ -1867,7 +1867,7 @@ export default function SalesOrderPage() { 품목 선택 수주에 추가할 품목을 선택 후 하단 버튼을 눌러주세요. -
+
: <>조회}
+
diff --git a/frontend/app/(main)/COMPANY_16/logistics/inventory/page.tsx b/frontend/app/(main)/COMPANY_16/logistics/inventory/page.tsx index a2b8d5ca..65a15e19 100644 --- a/frontend/app/(main)/COMPANY_16/logistics/inventory/page.tsx +++ b/frontend/app/(main)/COMPANY_16/logistics/inventory/page.tsx @@ -404,7 +404,7 @@ export default function InventoryStatusPage() { warehouse_code: targetWhCode, location_code: targetLocCode, transaction_type: "조정", - transaction_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"), + transaction_date: new Date().toLocaleString("sv-SE", { timeZone: "Asia/Seoul" }).replace(" ", "T"), quantity: String(changeQty), balance_qty: String(afterQty), remark: adjustForm.reason.trim(), @@ -422,7 +422,7 @@ export default function InventoryStatusPage() { location_code: targetLocCode, current_qty: String(afterQty), safety_qty: "0", - last_in_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"), + last_in_date: new Date().toLocaleString("sv-SE", { timeZone: "Asia/Seoul" }).replace(" ", "T"), } ); } else { diff --git a/frontend/app/(main)/COMPANY_16/master-data/item-info/page.tsx b/frontend/app/(main)/COMPANY_16/master-data/item-info/page.tsx index ad55d2b2..68552dc6 100644 --- a/frontend/app/(main)/COMPANY_16/master-data/item-info/page.tsx +++ b/frontend/app/(main)/COMPANY_16/master-data/item-info/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useCallback, useRef } from "react"; +import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -186,7 +186,7 @@ const CATEGORY_COLUMNS = [ export default function ItemInfoPage() { const { user } = useAuth(); const ts = useTableSettings("c16-item-info", TABLE_NAME, GRID_COLUMNS); - const [items, setItems] = useState([]); + // items는 rawItems + categoryOptions로 useMemo 파생 const [rawItems, setRawItems] = useState([]); const [loading, setLoading] = useState(false); @@ -359,6 +359,7 @@ export default function ItemInfoPage() { }, []); // 데이터 조회 + // 데이터 조회 — rawItems만 fetch. 라벨 변환은 useMemo로 파생. const fetchItems = useCallback(async () => { setLoading(true); try { @@ -369,36 +370,37 @@ export default function ItemInfoPage() { dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, autoFilter: true, }); - const raw = res.data?.data?.data || res.data?.data?.rows || []; - const resolve = (col: string, code: string) => { - if (!code) return ""; - // 쉼표 구분 다중값 지원 - if (code.includes(",")) { - return code.split(",").map((c) => { - const trimmed = c.trim(); - if (!trimmed || trimmed === "s") return ""; - return categoryOptions[col]?.find((o) => o.code === trimmed)?.label || trimmed; - }).filter(Boolean).join(", "); - } - return categoryOptions[col]?.find((o) => o.code === code)?.label || code; - }; setRawItems(raw); - const data = raw.map((r: any) => { - const converted = { ...r }; - for (const col of CATEGORY_COLUMNS) { - if (converted[col]) converted[col] = resolve(col, converted[col]); - } - return converted; - }); - setItems(data); } catch (err) { console.error("품목 조회 실패:", err); toast.error("품목 목록을 불러오는데 실패했어요."); } finally { setLoading(false); } - }, [categoryOptions, searchFilters]); + }, [searchFilters]); + + // 표시용 items — rawItems + categoryOptions 결합으로 파생 + const items = useMemo(() => { + const resolve = (col: string, code: string) => { + if (!code) return ""; + if (code.includes(",")) { + return code.split(",").map((c) => { + const trimmed = c.trim(); + if (!trimmed || trimmed === "s") return ""; + return categoryOptions[col]?.find((o) => o.code === trimmed)?.label || trimmed; + }).filter(Boolean).join(", "); + } + return categoryOptions[col]?.find((o) => o.code === code)?.label || code; + }; + return rawItems.map((r: any) => { + const converted = { ...r }; + for (const col of CATEGORY_COLUMNS) { + if (converted[col]) converted[col] = resolve(col, converted[col]); + } + return converted; + }); + }, [rawItems, categoryOptions]); useEffect(() => { fetchItems(); diff --git a/frontend/app/(main)/COMPANY_16/sales/order/page.tsx b/frontend/app/(main)/COMPANY_16/sales/order/page.tsx index 771abf0d..941a9397 100644 --- a/frontend/app/(main)/COMPANY_16/sales/order/page.tsx +++ b/frontend/app/(main)/COMPANY_16/sales/order/page.tsx @@ -3,6 +3,7 @@ import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { FormDatePicker } from "@/components/screen/filters/FormDatePicker"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { @@ -207,6 +208,8 @@ export default function SalesOrderPage() { const [itemSearchLoading, setItemSearchLoading] = useState(false); const [itemSelectedMap, setItemSelectedMap] = useState>(new Map()); const [itemSearchDivision, setItemSearchDivision] = useState("all"); + // 거래처 품목만 보기 (기본: 끔 → 영업관리 전체 검색) + const [itemSearchOnlyCustomer, setItemSearchOnlyCustomer] = useState(false); const [itemPage, setItemPage] = useState(1); const [itemPageSize, setItemPageSize] = useState(20); const [itemTotalPages, setItemTotalPages] = useState(0); @@ -773,9 +776,11 @@ export default function SalesOrderPage() { }; // 품목 검색 - const searchItems = async (page?: number, size?: number) => { + // onlyCustomerOverride: 체크박스 onChange 직후 새 값을 즉시 반영하기 위한 override (stale state 회피) + const searchItems = async (page?: number, size?: number, onlyCustomerOverride?: boolean) => { const p = page ?? itemPage; const s = size ?? itemPageSize; + const useOnlyCustomer = onlyCustomerOverride !== undefined ? onlyCustomerOverride : itemSearchOnlyCustomer; setItemSearchLoading(true); try { const filters: any[] = []; @@ -786,13 +791,10 @@ export default function SalesOrderPage() { filters.push({ columnName: "division", operator: "contains", value: itemSearchDivision }); } - // 거래처우선 단가방식일 때 거래처 매핑 id 정규화 → 서버 필터 적용 - // price_mode의 라벨로 판단 (카테고리 코드는 회사마다 다를 수 있음) - const priceModeLabel = (categoryOptions["price_mode"] || []).find((o) => o.code === masterForm.price_mode)?.label || ""; - const isCustomerPrice = priceModeLabel.includes("거래처"); + // 거래처 품목 필터 — 옵션 체크박스가 켜진 경우만 적용 (price_mode와 무관하게 항상 전체 검색이 기본) const partnerId = masterForm.partner_id; - if (isCustomerPrice && partnerId) { + if (useOnlyCustomer && partnerId) { try { const mappingRes = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, { page: 1, size: 0, @@ -1490,11 +1492,10 @@ export default function SalesOrderPage() { - setMasterForm((p) => ({ ...p, order_date: e.target.value }))} - className="h-9" + onChange={(v) => setMasterForm((p) => ({ ...p, order_date: v }))} + placeholder="수주일" />
@@ -1768,11 +1769,10 @@ export default function SalesOrderPage() { {row.amount ? Number(row.amount).toLocaleString() : "0"} - updateDetailRow(idx, "due_date", e.target.value)} - className="h-8 text-xs w-full" + onChange={(v) => updateDetailRow(idx, "due_date", v)} + placeholder="납기일" /> @@ -1839,7 +1839,7 @@ export default function SalesOrderPage() { 품목 선택 수주에 추가할 품목을 선택 후 하단 버튼을 눌러주세요. -
+
: <>조회}
+
diff --git a/frontend/app/(main)/COMPANY_29/logistics/inventory/page.tsx b/frontend/app/(main)/COMPANY_29/logistics/inventory/page.tsx index d5d36390..63b28ead 100644 --- a/frontend/app/(main)/COMPANY_29/logistics/inventory/page.tsx +++ b/frontend/app/(main)/COMPANY_29/logistics/inventory/page.tsx @@ -402,7 +402,7 @@ export default function InventoryStatusPage() { warehouse_code: targetWhCode, location_code: targetLocCode, transaction_type: "조정", - transaction_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"), + transaction_date: new Date().toLocaleString("sv-SE", { timeZone: "Asia/Seoul" }).replace(" ", "T"), quantity: String(changeQty), balance_qty: String(afterQty), remark: adjustForm.reason.trim(), @@ -419,7 +419,7 @@ export default function InventoryStatusPage() { location_code: targetLocCode, current_qty: String(afterQty), safety_qty: "0", - last_in_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"), + last_in_date: new Date().toLocaleString("sv-SE", { timeZone: "Asia/Seoul" }).replace(" ", "T"), } ); } else { diff --git a/frontend/app/(main)/COMPANY_29/master-data/item-info/page.tsx b/frontend/app/(main)/COMPANY_29/master-data/item-info/page.tsx index ad55d2b2..68552dc6 100644 --- a/frontend/app/(main)/COMPANY_29/master-data/item-info/page.tsx +++ b/frontend/app/(main)/COMPANY_29/master-data/item-info/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useCallback, useRef } from "react"; +import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -186,7 +186,7 @@ const CATEGORY_COLUMNS = [ export default function ItemInfoPage() { const { user } = useAuth(); const ts = useTableSettings("c16-item-info", TABLE_NAME, GRID_COLUMNS); - const [items, setItems] = useState([]); + // items는 rawItems + categoryOptions로 useMemo 파생 const [rawItems, setRawItems] = useState([]); const [loading, setLoading] = useState(false); @@ -359,6 +359,7 @@ export default function ItemInfoPage() { }, []); // 데이터 조회 + // 데이터 조회 — rawItems만 fetch. 라벨 변환은 useMemo로 파생. const fetchItems = useCallback(async () => { setLoading(true); try { @@ -369,36 +370,37 @@ export default function ItemInfoPage() { dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, autoFilter: true, }); - const raw = res.data?.data?.data || res.data?.data?.rows || []; - const resolve = (col: string, code: string) => { - if (!code) return ""; - // 쉼표 구분 다중값 지원 - if (code.includes(",")) { - return code.split(",").map((c) => { - const trimmed = c.trim(); - if (!trimmed || trimmed === "s") return ""; - return categoryOptions[col]?.find((o) => o.code === trimmed)?.label || trimmed; - }).filter(Boolean).join(", "); - } - return categoryOptions[col]?.find((o) => o.code === code)?.label || code; - }; setRawItems(raw); - const data = raw.map((r: any) => { - const converted = { ...r }; - for (const col of CATEGORY_COLUMNS) { - if (converted[col]) converted[col] = resolve(col, converted[col]); - } - return converted; - }); - setItems(data); } catch (err) { console.error("품목 조회 실패:", err); toast.error("품목 목록을 불러오는데 실패했어요."); } finally { setLoading(false); } - }, [categoryOptions, searchFilters]); + }, [searchFilters]); + + // 표시용 items — rawItems + categoryOptions 결합으로 파생 + const items = useMemo(() => { + const resolve = (col: string, code: string) => { + if (!code) return ""; + if (code.includes(",")) { + return code.split(",").map((c) => { + const trimmed = c.trim(); + if (!trimmed || trimmed === "s") return ""; + return categoryOptions[col]?.find((o) => o.code === trimmed)?.label || trimmed; + }).filter(Boolean).join(", "); + } + return categoryOptions[col]?.find((o) => o.code === code)?.label || code; + }; + return rawItems.map((r: any) => { + const converted = { ...r }; + for (const col of CATEGORY_COLUMNS) { + if (converted[col]) converted[col] = resolve(col, converted[col]); + } + return converted; + }); + }, [rawItems, categoryOptions]); useEffect(() => { fetchItems(); diff --git a/frontend/app/(main)/COMPANY_29/sales/order/page.tsx b/frontend/app/(main)/COMPANY_29/sales/order/page.tsx index c1b9a21d..94f02289 100644 --- a/frontend/app/(main)/COMPANY_29/sales/order/page.tsx +++ b/frontend/app/(main)/COMPANY_29/sales/order/page.tsx @@ -3,6 +3,7 @@ import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { FormDatePicker } from "@/components/screen/filters/FormDatePicker"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { @@ -207,6 +208,8 @@ export default function SalesOrderPage() { const [itemSearchLoading, setItemSearchLoading] = useState(false); const [itemSelectedMap, setItemSelectedMap] = useState>(new Map()); const [itemSearchDivision, setItemSearchDivision] = useState("all"); + // 거래처 품목만 보기 (기본: 끔 → 영업관리 전체 검색) + const [itemSearchOnlyCustomer, setItemSearchOnlyCustomer] = useState(false); const [itemPage, setItemPage] = useState(1); const [itemPageSize, setItemPageSize] = useState(20); const [itemTotalPages, setItemTotalPages] = useState(0); @@ -773,9 +776,11 @@ export default function SalesOrderPage() { }; // 품목 검색 - const searchItems = async (page?: number, size?: number) => { + // onlyCustomerOverride: 체크박스 onChange 직후 새 값을 즉시 반영하기 위한 override (stale state 회피) + const searchItems = async (page?: number, size?: number, onlyCustomerOverride?: boolean) => { const p = page ?? itemPage; const s = size ?? itemPageSize; + const useOnlyCustomer = onlyCustomerOverride !== undefined ? onlyCustomerOverride : itemSearchOnlyCustomer; setItemSearchLoading(true); try { const filters: any[] = []; @@ -786,13 +791,10 @@ export default function SalesOrderPage() { filters.push({ columnName: "division", operator: "contains", value: itemSearchDivision }); } - // 거래처우선 단가방식일 때 거래처 매핑 id 정규화 → 서버 필터 적용 - // price_mode의 라벨로 판단 (카테고리 코드는 회사마다 다를 수 있음) - const priceModeLabel = (categoryOptions["price_mode"] || []).find((o) => o.code === masterForm.price_mode)?.label || ""; - const isCustomerPrice = priceModeLabel.includes("거래처"); + // 거래처 품목 필터 — 옵션 체크박스가 켜진 경우만 적용 (price_mode와 무관하게 항상 전체 검색이 기본) const partnerId = masterForm.partner_id; - if (isCustomerPrice && partnerId) { + if (useOnlyCustomer && partnerId) { try { const mappingRes = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, { page: 1, size: 0, @@ -1490,11 +1492,10 @@ export default function SalesOrderPage() { - setMasterForm((p) => ({ ...p, order_date: e.target.value }))} - className="h-9" + onChange={(v) => setMasterForm((p) => ({ ...p, order_date: v }))} + placeholder="수주일" />
@@ -1796,11 +1797,10 @@ export default function SalesOrderPage() { {row.amount ? Number(row.amount).toLocaleString() : "0"} - updateDetailRow(idx, "due_date", e.target.value)} - className="h-8 text-xs w-full" + onChange={(v) => updateDetailRow(idx, "due_date", v)} + placeholder="납기일" /> @@ -1867,7 +1867,7 @@ export default function SalesOrderPage() { 품목 선택 수주에 추가할 품목을 선택 후 하단 버튼을 눌러주세요. -
+
: <>조회}
+
diff --git a/frontend/app/(main)/COMPANY_30/logistics/inventory/page.tsx b/frontend/app/(main)/COMPANY_30/logistics/inventory/page.tsx index 78c41b96..e3813c62 100644 --- a/frontend/app/(main)/COMPANY_30/logistics/inventory/page.tsx +++ b/frontend/app/(main)/COMPANY_30/logistics/inventory/page.tsx @@ -221,8 +221,8 @@ export default function InventoryStatusPage() { autoFilter: true, sort: { columnName: "item_code", order: "asc" }, }), - apiClient.post(`/table-management/tables/item_info/data`, { page: 1, size: 500, autoFilter: true }), - apiClient.post(`/table-management/tables/warehouse_info/data`, { page: 1, size: 500, autoFilter: true }), + apiClient.post(`/table-management/tables/item_info/data`, { page: 1, size: 0, autoFilter: true }), + apiClient.post(`/table-management/tables/warehouse_info/data`, { page: 1, size: 0, autoFilter: true }), ]); const raw = stockRes.data?.data?.data || stockRes.data?.data?.rows || []; const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || []; @@ -411,7 +411,7 @@ export default function InventoryStatusPage() { warehouse_code: targetWhCode, location_code: targetLocCode, transaction_type: "조정", - transaction_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"), + transaction_date: new Date().toLocaleString("sv-SE", { timeZone: "Asia/Seoul" }).replace(" ", "T"), quantity: String(changeQty), balance_qty: String(afterQty), remark: adjustForm.reason.trim(), @@ -428,7 +428,7 @@ export default function InventoryStatusPage() { location_code: targetLocCode, current_qty: String(afterQty), safety_qty: "0", - last_in_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"), + last_in_date: new Date().toLocaleString("sv-SE", { timeZone: "Asia/Seoul" }).replace(" ", "T"), } ); } else { diff --git a/frontend/app/(main)/COMPANY_30/master-data/item-info/page.tsx b/frontend/app/(main)/COMPANY_30/master-data/item-info/page.tsx index 2bf8de1b..987a063d 100644 --- a/frontend/app/(main)/COMPANY_30/master-data/item-info/page.tsx +++ b/frontend/app/(main)/COMPANY_30/master-data/item-info/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useCallback, useRef } from "react"; +import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -192,7 +192,7 @@ const CATEGORY_COLUMNS = [ export default function ItemInfoPage() { const { user } = useAuth(); const ts = useTableSettings("c16-item-info", TABLE_NAME, GRID_COLUMNS); - const [items, setItems] = useState([]); + // items는 rawItems + categoryOptions로 useMemo 파생 (아래 참조). setItems는 더 이상 사용 안 함. const [rawItems, setRawItems] = useState([]); const [loading, setLoading] = useState(false); @@ -365,7 +365,7 @@ export default function ItemInfoPage() { loadCategories(); }, []); - // 데이터 조회 + // 데이터 조회 — rawItems만 fetch (검색 필터 변경 시에만 재조회). 라벨 변환은 useMemo로 파생. const fetchItems = useCallback(async () => { setLoading(true); try { @@ -376,45 +376,46 @@ export default function ItemInfoPage() { dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, autoFilter: true, }); - const raw = res.data?.data?.data || res.data?.data?.rows || []; - const resolve = (col: string, code: string) => { - if (!code) return ""; - // 쉼표 구분 다중값 지원 - if (code.includes(",")) { - return code.split(",").map((c) => { - const trimmed = c.trim(); - if (!trimmed || trimmed === "s") return ""; - return categoryOptions[col]?.find((o) => o.code === trimmed)?.label || trimmed; - }).filter(Boolean).join(", "); - } - return categoryOptions[col]?.find((o) => o.code === code)?.label || code; - }; // item_number 내림차순 정렬 (최근 품목이 위로, 자연 정렬) const sortedRaw = [...raw].sort((a: any, b: any) => String(b.item_number || "").localeCompare(String(a.item_number || ""), undefined, { numeric: true, sensitivity: "base" }) ); setRawItems(sortedRaw); - const data = sortedRaw.map((r: any) => { - const converted = { ...r }; - for (const col of CATEGORY_COLUMNS) { - if (converted[col]) converted[col] = resolve(col, converted[col]); - } - return converted; - }); - setItems(data); } catch (err) { console.error("품목 조회 실패:", err); toast.error("품목 목록을 불러오는데 실패했어요."); } finally { setLoading(false); } - }, [categoryOptions, searchFilters]); + }, [searchFilters]); useEffect(() => { fetchItems(); }, [fetchItems]); + // 표시용 items — rawItems + categoryOptions 결합으로 파생 (카테고리 로드 시 자동 갱신, 추가 fetch 없음) + const items = useMemo(() => { + const resolve = (col: string, code: string) => { + if (!code) return ""; + if (code.includes(",")) { + return code.split(",").map((c) => { + const trimmed = c.trim(); + if (!trimmed || trimmed === "s") return ""; + return categoryOptions[col]?.find((o) => o.code === trimmed)?.label || trimmed; + }).filter(Boolean).join(", "); + } + return categoryOptions[col]?.find((o) => o.code === code)?.label || code; + }; + return rawItems.map((r: any) => { + const converted = { ...r }; + for (const col of CATEGORY_COLUMNS) { + if (converted[col]) converted[col] = resolve(col, converted[col]); + } + return converted; + }); + }, [rawItems, categoryOptions]); + // 채번 미리보기 로드 const loadNumberingPreview = async (currentFormData?: Record, currentManualValue?: string) => { try { diff --git a/frontend/app/(main)/COMPANY_30/monitoring/quality/page.tsx b/frontend/app/(main)/COMPANY_30/monitoring/quality/page.tsx index d789e5e2..f0e84054 100644 --- a/frontend/app/(main)/COMPANY_30/monitoring/quality/page.tsx +++ b/frontend/app/(main)/COMPANY_30/monitoring/quality/page.tsx @@ -88,6 +88,13 @@ export default function QualityMonitoringPage() { const [activeTab, setActiveTab] = useState("all"); const intervalRef = useRef | null>(null); + // 서버 페이징 + KPI summary + const [page, setPage] = useState(1); + const [pageSize] = useState(50); + const [total, setTotal] = useState(0); + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const [serverSummary, setServerSummary] = useState<{ total: number; passed: number; failed: number; pending: number; passRate: number }>({ total: 0, passed: 0, failed: 0, pending: 0, passRate: 0 }); + // 기간 필터 (기본: 오늘) const todayStr = new Date().toISOString().slice(0, 10); const [dateFrom, setDateFrom] = useState(todayStr); @@ -115,28 +122,31 @@ export default function QualityMonitoringPage() { return () => clearInterval(timer); }, []); - /* ───── 데이터 조회 ───── */ + /* ───── 데이터 조회 (서버 페이징 + summary) ───── */ const fetchData = useCallback(async () => { setLoading(true); try { - const res = await apiClient.post("/table-management/tables/work_order_process/data", { - 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 qp = new URLSearchParams({ + page: String(page), + size: String(pageSize), + from: dateFrom, + to: dateTo, }); - const rows: ProcessRow[] = res.data?.data?.rows ?? res.data?.rows ?? []; - setProcessData(rows); + const res = await apiClient.get(`/quality-monitoring/data?${qp.toString()}`); + if (res.data?.success) { + setProcessData((res.data.rows || []) as ProcessRow[]); + setTotal(res.data.total || 0); + setServerSummary(res.data.summary || { total: 0, passed: 0, failed: 0, pending: 0, passRate: 0 }); + } } catch (err) { console.error("품질점검현황 데이터 조회 실패:", err); } finally { setLoading(false); } - }, [dateFrom, dateTo]); + }, [dateFrom, dateTo, page, pageSize]); + + // 기간 변경 시 1페이지로 리셋 + useEffect(() => { setPage(1); }, [dateFrom, dateTo]); useEffect(() => { fetchData(); @@ -153,15 +163,9 @@ export default function QualityMonitoringPage() { }, [autoRefresh, fetchData, settings.refreshInterval]); /* ───── 검사 행 변환 ───── */ + // 기간 필터는 fetchData에서 이미 적용 — 여기서는 추가 필터 없이 모두 변환 const inspectionRows: InspectionRow[] = useMemo(() => { - const today = new Date().toISOString().slice(0, 10); - return processData - .filter((r) => { - // 금일 데이터만 - const dt = r.completed_at || r.started_at || ""; - return dt.slice(0, 10) === today; - }) .map((r, idx) => { const inspQty = r.input_qty || r.plan_qty || 0; const goodQty = r.good_qty ?? 0; @@ -194,20 +198,19 @@ export default function QualityMonitoringPage() { return []; }, [activeTab, inspectionRows]); - /* ───── 요약 통계 ───── */ - const summary = useMemo(() => { - const total = inspectionRows.length; - const passed = inspectionRows.filter((r) => r.result === "합격").length; - const failed = inspectionRows.filter((r) => r.result === "불합격").length; - const pending = inspectionRows.filter((r) => r.result === "대기").length; - const passRate = total > 0 ? (passed / total) * 100 : 0; - return { total, passed, failed, pending, passRate }; - }, [inspectionRows]); + /* ───── 요약 통계 (서버 합산 사용) ───── */ + const summary = useMemo(() => ({ + total: serverSummary.total, + passed: serverSummary.passed, + failed: serverSummary.failed, + pending: serverSummary.pending, + passRate: serverSummary.passRate, + }), [serverSummary]); /* ───── 요약 카드 정의 ───── */ const summaryCards = [ { - label: "금일 검사건수", + label: "검사건수", value: fmt(summary.total), sub: "건", color: "from-slate-500 to-slate-600", @@ -359,7 +362,7 @@ export default function QualityMonitoringPage() { ) : filteredRows.length === 0 ? (
-

금일 검사 데이터가 없습니다

+

선택 기간에 검사 데이터가 없습니다

) : (
@@ -462,6 +465,23 @@ export default function QualityMonitoringPage() {
)} + + {/* ── 페이지네이션 ── */} + {total > 0 && ( +
+
+ 전체 {total.toLocaleString()}건 · + {" "}{((page - 1) * pageSize + 1).toLocaleString()}~{Math.min(page * pageSize, total).toLocaleString()} +
+
+ + + {page} / {totalPages} + + +
+
+ )}
diff --git a/frontend/app/(main)/COMPANY_30/purchase/supplier/page.tsx b/frontend/app/(main)/COMPANY_30/purchase/supplier/page.tsx index 9b516330..0bbdd761 100644 --- a/frontend/app/(main)/COMPANY_30/purchase/supplier/page.tsx +++ b/frontend/app/(main)/COMPANY_30/purchase/supplier/page.tsx @@ -826,12 +826,14 @@ export default function SupplierManagementPage() { const allItems = res.data?.data?.data || res.data?.data?.rows || []; setItemTotalCount(allItems.length); const existingItemIds = new Set(priceItems.map((p: any) => p.item_id || p.item_number)); - const purchaseCode = categoryOptions["item_division"]?.find((o) => o.label === "구매관리")?.code; + const allowedCodes = ["구매관리", "원자재"] + .map((label) => categoryOptions["item_division"]?.find((o) => o.label === label)?.code) + .filter(Boolean) as string[]; setItemSearchResults(allItems.filter((item: any) => { if (existingItemIds.has(item.item_number) || existingItemIds.has(item.id)) return false; - if (!purchaseCode) return true; + if (allowedCodes.length === 0) return true; const div = item.division || ""; - return div.includes(purchaseCode); + return allowedCodes.some((code) => div.includes(code)); })); } catch { /* skip */ } finally { setItemSearchLoading(false); } }, [itemSearchKeyword, priceItems]); diff --git a/frontend/app/(main)/COMPANY_30/sales/order/page.tsx b/frontend/app/(main)/COMPANY_30/sales/order/page.tsx index 11ae354c..e43bdadd 100644 --- a/frontend/app/(main)/COMPANY_30/sales/order/page.tsx +++ b/frontend/app/(main)/COMPANY_30/sales/order/page.tsx @@ -28,6 +28,7 @@ import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil, ClipboardList, Package, Search, X, Settings2, GripVertical, ChevronsLeft, ChevronLeft, ChevronRight, ChevronsRight, History, ChevronDown, + Truck, } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; @@ -39,6 +40,7 @@ import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; import { FullscreenDialog } from "@/components/common/FullscreenDialog"; import { ExcelUploadModal } from "@/components/common/ExcelUploadModal"; +import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchModal"; import { exportToExcel } from "@/lib/utils/excelExport"; import { FormDatePicker } from "@/components/screen/filters/FormDatePicker"; import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal"; @@ -207,6 +209,8 @@ export default function ChunganSalesOrderPage() { // 우측: 디테일 const [detailItems, setDetailItems] = useState([]); const [detailLoading, setDetailLoading] = useState(false); + const [detailCheckedIds, setDetailCheckedIds] = useState([]); + const [shippingPlanOpen, setShippingPlanOpen] = useState(false); // 모달 const [isModalOpen, setIsModalOpen] = useState(false); @@ -586,7 +590,7 @@ export default function ChunganSalesOrderPage() { // 우측: 선택된 수주 디테일 조회 (division 코드→라벨 변환) useEffect(() => { - if (!selectedOrderNo) { setDetailItems([]); return; } + if (!selectedOrderNo) { setDetailItems([]); setDetailCheckedIds([]); return; } const items = allDetails .filter((d) => d.order_no === selectedOrderNo) .map((d) => ({ @@ -595,6 +599,7 @@ export default function ChunganSalesOrderPage() { type: categoryOptions["item_type"]?.find((o) => o.code === d.type)?.label || d.type || "", })); setDetailItems(items); + setDetailCheckedIds([]); }, [selectedOrderNo, allDetails, categoryOptions]); // 좌측 행 클릭 @@ -1306,6 +1311,9 @@ export default function ChunganSalesOrderPage() { )}
+ @@ -1328,6 +1336,9 @@ export default function ChunganSalesOrderPage() { data={detailItems} loading={detailLoading} showRowNumber + showCheckbox + checkedIds={detailCheckedIds} + onCheckedChange={setDetailCheckedIds} tableName={DETAIL_TABLE} emptyMessage="품목이 없습니다" /> @@ -1607,6 +1618,14 @@ export default function ChunganSalesOrderPage() { onSuccess={handleExcelUploadSuccess} /> + {/* 출하계획 동시 등록 모달 */} + { setDetailCheckedIds([]); fetchMasterOrders(); }} + /> + {/* 테이블 설정 */} ([]); + // items는 rawItems + categoryOptions로 useMemo 파생 const [rawItems, setRawItems] = useState([]); const [loading, setLoading] = useState(false); @@ -362,8 +362,8 @@ export default function ItemInfoPage() { }, []); // 데이터 조회 + // 데이터 조회 — rawItems만 fetch. 라벨 변환은 useMemo로 파생. const fetchItems = useCallback(async () => { - if (!isCategoriesLoaded) return; setLoading(true); try { const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value })); @@ -373,36 +373,37 @@ export default function ItemInfoPage() { dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, autoFilter: true, }); - const raw = res.data?.data?.data || res.data?.data?.rows || []; - const resolve = (col: string, code: string) => { - if (!code) return ""; - // 쉼표 구분 다중값 지원 - if (code.includes(",")) { - return code.split(",").map((c) => { - const trimmed = c.trim(); - if (!trimmed || trimmed === "s") return ""; - return categoryOptions[col]?.find((o) => o.code === trimmed)?.label || trimmed; - }).filter(Boolean).join(", "); - } - return categoryOptions[col]?.find((o) => o.code === code)?.label || code; - }; setRawItems(raw); - const data = raw.map((r: any) => { - const converted = { ...r }; - for (const col of CATEGORY_COLUMNS) { - if (converted[col]) converted[col] = resolve(col, converted[col]); - } - return converted; - }); - setItems(data); } catch (err) { console.error("품목 조회 실패:", err); toast.error("품목 목록을 불러오는데 실패했어요."); } finally { setLoading(false); } - }, [categoryOptions, searchFilters, isCategoriesLoaded]); + }, [searchFilters]); + + // 표시용 items — rawItems + categoryOptions 결합으로 파생 + const items = useMemo(() => { + const resolve = (col: string, code: string) => { + if (!code) return ""; + if (code.includes(",")) { + return code.split(",").map((c) => { + const trimmed = c.trim(); + if (!trimmed || trimmed === "s") return ""; + return categoryOptions[col]?.find((o) => o.code === trimmed)?.label || trimmed; + }).filter(Boolean).join(", "); + } + return categoryOptions[col]?.find((o) => o.code === code)?.label || code; + }; + return rawItems.map((r: any) => { + const converted = { ...r }; + for (const col of CATEGORY_COLUMNS) { + if (converted[col]) converted[col] = resolve(col, converted[col]); + } + return converted; + }); + }, [rawItems, categoryOptions]); useEffect(() => { fetchItems(); diff --git a/frontend/app/(main)/COMPANY_7/sales/order/page.tsx b/frontend/app/(main)/COMPANY_7/sales/order/page.tsx index b7980d33..68601014 100644 --- a/frontend/app/(main)/COMPANY_7/sales/order/page.tsx +++ b/frontend/app/(main)/COMPANY_7/sales/order/page.tsx @@ -3,6 +3,7 @@ import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { FormDatePicker } from "@/components/screen/filters/FormDatePicker"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { @@ -207,6 +208,8 @@ export default function SalesOrderPage() { const [itemSearchLoading, setItemSearchLoading] = useState(false); const [itemSelectedMap, setItemSelectedMap] = useState>(new Map()); const [itemSearchDivision, setItemSearchDivision] = useState("all"); + // 거래처 품목만 보기 (기본: 끔 → 영업관리 전체 검색) + const [itemSearchOnlyCustomer, setItemSearchOnlyCustomer] = useState(false); const [itemPage, setItemPage] = useState(1); const [itemPageSize, setItemPageSize] = useState(20); const [itemTotalPages, setItemTotalPages] = useState(0); @@ -774,9 +777,11 @@ export default function SalesOrderPage() { }; // 품목 검색 - const searchItems = async (page?: number, size?: number) => { + // onlyCustomerOverride: 체크박스 onChange 직후 새 값을 즉시 반영하기 위한 override (stale state 회피) + const searchItems = async (page?: number, size?: number, onlyCustomerOverride?: boolean) => { const p = page ?? itemPage; const s = size ?? itemPageSize; + const useOnlyCustomer = onlyCustomerOverride !== undefined ? onlyCustomerOverride : itemSearchOnlyCustomer; setItemSearchLoading(true); try { const filters: any[] = []; @@ -787,13 +792,10 @@ export default function SalesOrderPage() { filters.push({ columnName: "division", operator: "contains", value: itemSearchDivision }); } - // 거래처우선 단가방식일 때 거래처 매핑 id 정규화 → 서버 필터 적용 - // price_mode의 라벨로 판단 (카테고리 코드는 회사마다 다를 수 있음) - const priceModeLabel = (categoryOptions["price_mode"] || []).find((o) => o.code === masterForm.price_mode)?.label || ""; - const isCustomerPrice = priceModeLabel.includes("거래처"); + // 거래처 품목 필터 — 옵션 체크박스가 켜진 경우만 적용 (price_mode와 무관하게 항상 전체 검색이 기본) const partnerId = masterForm.partner_id; - if (isCustomerPrice && partnerId) { + if (useOnlyCustomer && partnerId) { try { const mappingRes = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, { page: 1, size: 0, @@ -1498,11 +1500,10 @@ export default function SalesOrderPage() { - setMasterForm((p) => ({ ...p, order_date: e.target.value }))} - className="h-9" + onChange={(v) => setMasterForm((p) => ({ ...p, order_date: v }))} + placeholder="수주일" />
@@ -1804,11 +1805,10 @@ export default function SalesOrderPage() { {row.amount ? Number(row.amount).toLocaleString() : "0"} - updateDetailRow(idx, "due_date", e.target.value)} - className="h-8 text-xs w-full" + onChange={(v) => updateDetailRow(idx, "due_date", v)} + placeholder="납기일" /> @@ -1875,7 +1875,7 @@ export default function SalesOrderPage() { 품목 선택 수주에 추가할 품목을 선택 후 하단 버튼을 눌러주세요. -
+
: <>조회}
+
diff --git a/frontend/app/(main)/COMPANY_8/logistics/inventory/page.tsx b/frontend/app/(main)/COMPANY_8/logistics/inventory/page.tsx index b89834f2..089c0f23 100644 --- a/frontend/app/(main)/COMPANY_8/logistics/inventory/page.tsx +++ b/frontend/app/(main)/COMPANY_8/logistics/inventory/page.tsx @@ -404,7 +404,7 @@ export default function InventoryStatusPage() { warehouse_code: targetWhCode, location_code: targetLocCode, transaction_type: "조정", - transaction_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"), + transaction_date: new Date().toLocaleString("sv-SE", { timeZone: "Asia/Seoul" }).replace(" ", "T"), quantity: String(changeQty), balance_qty: String(afterQty), remark: adjustForm.reason.trim(), @@ -421,7 +421,7 @@ export default function InventoryStatusPage() { location_code: targetLocCode, current_qty: String(afterQty), safety_qty: "0", - last_in_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"), + last_in_date: new Date().toLocaleString("sv-SE", { timeZone: "Asia/Seoul" }).replace(" ", "T"), } ); } else { diff --git a/frontend/app/(main)/COMPANY_8/master-data/item-info/page.tsx b/frontend/app/(main)/COMPANY_8/master-data/item-info/page.tsx index 80b39873..a171b9e0 100644 --- a/frontend/app/(main)/COMPANY_8/master-data/item-info/page.tsx +++ b/frontend/app/(main)/COMPANY_8/master-data/item-info/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useCallback, useRef } from "react"; +import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -197,7 +197,7 @@ const CATEGORY_COLUMNS = [ export default function ItemInfoPage() { const { user } = useAuth(); const ts = useTableSettings("c16-item-info", TABLE_NAME, GRID_COLUMNS); - const [items, setItems] = useState([]); + // items는 rawItems + categoryOptions로 useMemo 파생 const [rawItems, setRawItems] = useState([]); const [loading, setLoading] = useState(false); @@ -372,6 +372,7 @@ export default function ItemInfoPage() { }, []); // 데이터 조회 + // 데이터 조회 — rawItems만 fetch. 라벨 변환은 useMemo로 파생. const fetchItems = useCallback(async () => { setLoading(true); try { @@ -382,36 +383,37 @@ export default function ItemInfoPage() { dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, autoFilter: true, }); - const raw = res.data?.data?.data || res.data?.data?.rows || []; - const resolve = (col: string, code: string) => { - if (!code) return ""; - // 쉼표 구분 다중값 지원 - if (code.includes(",")) { - return code.split(",").map((c) => { - const trimmed = c.trim(); - if (!trimmed || trimmed === "s") return ""; - return categoryOptions[col]?.find((o) => o.code === trimmed)?.label || trimmed; - }).filter(Boolean).join(", "); - } - return categoryOptions[col]?.find((o) => o.code === code)?.label || code; - }; setRawItems(raw); - const data = raw.map((r: any) => { - const converted = { ...r }; - for (const col of CATEGORY_COLUMNS) { - if (converted[col]) converted[col] = resolve(col, converted[col]); - } - return converted; - }); - setItems(data); } catch (err) { console.error("품목 조회 실패:", err); toast.error("품목 목록을 불러오는데 실패했어요."); } finally { setLoading(false); } - }, [categoryOptions, searchFilters]); + }, [searchFilters]); + + // 표시용 items — rawItems + categoryOptions 결합으로 파생 + const items = useMemo(() => { + const resolve = (col: string, code: string) => { + if (!code) return ""; + if (code.includes(",")) { + return code.split(",").map((c) => { + const trimmed = c.trim(); + if (!trimmed || trimmed === "s") return ""; + return categoryOptions[col]?.find((o) => o.code === trimmed)?.label || trimmed; + }).filter(Boolean).join(", "); + } + return categoryOptions[col]?.find((o) => o.code === code)?.label || code; + }; + return rawItems.map((r: any) => { + const converted = { ...r }; + for (const col of CATEGORY_COLUMNS) { + if (converted[col]) converted[col] = resolve(col, converted[col]); + } + return converted; + }); + }, [rawItems, categoryOptions]); useEffect(() => { fetchItems(); diff --git a/frontend/app/(main)/COMPANY_8/sales/order/page.tsx b/frontend/app/(main)/COMPANY_8/sales/order/page.tsx index c1b9a21d..94f02289 100644 --- a/frontend/app/(main)/COMPANY_8/sales/order/page.tsx +++ b/frontend/app/(main)/COMPANY_8/sales/order/page.tsx @@ -3,6 +3,7 @@ import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { FormDatePicker } from "@/components/screen/filters/FormDatePicker"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { @@ -207,6 +208,8 @@ export default function SalesOrderPage() { const [itemSearchLoading, setItemSearchLoading] = useState(false); const [itemSelectedMap, setItemSelectedMap] = useState>(new Map()); const [itemSearchDivision, setItemSearchDivision] = useState("all"); + // 거래처 품목만 보기 (기본: 끔 → 영업관리 전체 검색) + const [itemSearchOnlyCustomer, setItemSearchOnlyCustomer] = useState(false); const [itemPage, setItemPage] = useState(1); const [itemPageSize, setItemPageSize] = useState(20); const [itemTotalPages, setItemTotalPages] = useState(0); @@ -773,9 +776,11 @@ export default function SalesOrderPage() { }; // 품목 검색 - const searchItems = async (page?: number, size?: number) => { + // onlyCustomerOverride: 체크박스 onChange 직후 새 값을 즉시 반영하기 위한 override (stale state 회피) + const searchItems = async (page?: number, size?: number, onlyCustomerOverride?: boolean) => { const p = page ?? itemPage; const s = size ?? itemPageSize; + const useOnlyCustomer = onlyCustomerOverride !== undefined ? onlyCustomerOverride : itemSearchOnlyCustomer; setItemSearchLoading(true); try { const filters: any[] = []; @@ -786,13 +791,10 @@ export default function SalesOrderPage() { filters.push({ columnName: "division", operator: "contains", value: itemSearchDivision }); } - // 거래처우선 단가방식일 때 거래처 매핑 id 정규화 → 서버 필터 적용 - // price_mode의 라벨로 판단 (카테고리 코드는 회사마다 다를 수 있음) - const priceModeLabel = (categoryOptions["price_mode"] || []).find((o) => o.code === masterForm.price_mode)?.label || ""; - const isCustomerPrice = priceModeLabel.includes("거래처"); + // 거래처 품목 필터 — 옵션 체크박스가 켜진 경우만 적용 (price_mode와 무관하게 항상 전체 검색이 기본) const partnerId = masterForm.partner_id; - if (isCustomerPrice && partnerId) { + if (useOnlyCustomer && partnerId) { try { const mappingRes = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, { page: 1, size: 0, @@ -1490,11 +1492,10 @@ export default function SalesOrderPage() { - setMasterForm((p) => ({ ...p, order_date: e.target.value }))} - className="h-9" + onChange={(v) => setMasterForm((p) => ({ ...p, order_date: v }))} + placeholder="수주일" />
@@ -1796,11 +1797,10 @@ export default function SalesOrderPage() { {row.amount ? Number(row.amount).toLocaleString() : "0"} - updateDetailRow(idx, "due_date", e.target.value)} - className="h-8 text-xs w-full" + onChange={(v) => updateDetailRow(idx, "due_date", v)} + placeholder="납기일" /> @@ -1867,7 +1867,7 @@ export default function SalesOrderPage() { 품목 선택 수주에 추가할 품목을 선택 후 하단 버튼을 눌러주세요. -
+
: <>조회}
+
diff --git a/frontend/app/(main)/COMPANY_9/logistics/inventory/page.tsx b/frontend/app/(main)/COMPANY_9/logistics/inventory/page.tsx index 69bbad1a..39e89606 100644 --- a/frontend/app/(main)/COMPANY_9/logistics/inventory/page.tsx +++ b/frontend/app/(main)/COMPANY_9/logistics/inventory/page.tsx @@ -402,7 +402,7 @@ export default function InventoryStatusPage() { warehouse_code: targetWhCode, location_code: targetLocCode, transaction_type: "조정", - transaction_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"), + transaction_date: new Date().toLocaleString("sv-SE", { timeZone: "Asia/Seoul" }).replace(" ", "T"), quantity: String(changeQty), balance_qty: String(afterQty), remark: adjustForm.reason.trim(), @@ -419,7 +419,7 @@ export default function InventoryStatusPage() { location_code: targetLocCode, current_qty: String(afterQty), safety_qty: "0", - last_in_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"), + last_in_date: new Date().toLocaleString("sv-SE", { timeZone: "Asia/Seoul" }).replace(" ", "T"), } ); } else { diff --git a/frontend/app/(main)/COMPANY_9/master-data/item-info/page.tsx b/frontend/app/(main)/COMPANY_9/master-data/item-info/page.tsx index 55d4f24b..6dcd8387 100644 --- a/frontend/app/(main)/COMPANY_9/master-data/item-info/page.tsx +++ b/frontend/app/(main)/COMPANY_9/master-data/item-info/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useCallback, useRef } from "react"; +import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -192,7 +192,7 @@ const CATEGORY_COLUMNS = [ export default function ItemInfoPage() { const { user } = useAuth(); const ts = useTableSettings("c16-item-info", TABLE_NAME, GRID_COLUMNS); - const [items, setItems] = useState([]); + // items는 rawItems + categoryOptions로 useMemo 파생 const [rawItems, setRawItems] = useState([]); const [loading, setLoading] = useState(false); @@ -365,6 +365,7 @@ export default function ItemInfoPage() { }, []); // 데이터 조회 + // 데이터 조회 — rawItems만 fetch. 라벨 변환은 useMemo로 파생. const fetchItems = useCallback(async () => { setLoading(true); try { @@ -375,40 +376,41 @@ export default function ItemInfoPage() { dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, autoFilter: true, }); - const raw = res.data?.data?.data || res.data?.data?.rows || []; - const resolve = (col: string, code: string) => { - if (!code) return ""; - // 쉼표 구분 다중값 지원 - if (code.includes(",")) { - return code.split(",").map((c) => { - const trimmed = c.trim(); - if (!trimmed || trimmed === "s") return ""; - return categoryOptions[col]?.find((o) => o.code === trimmed)?.label || trimmed; - }).filter(Boolean).join(", "); - } - return categoryOptions[col]?.find((o) => o.code === code)?.label || code; - }; // item_number 내림차순 정렬 (최근 품목이 위로, 자연 정렬) const sortedRaw = [...raw].sort((a: any, b: any) => String(b.item_number || "").localeCompare(String(a.item_number || ""), undefined, { numeric: true, sensitivity: "base" }) ); setRawItems(sortedRaw); - const data = sortedRaw.map((r: any) => { - const converted = { ...r }; - for (const col of CATEGORY_COLUMNS) { - if (converted[col]) converted[col] = resolve(col, converted[col]); - } - return converted; - }); - setItems(data); } catch (err) { console.error("품목 조회 실패:", err); toast.error("품목 목록을 불러오는데 실패했어요."); } finally { setLoading(false); } - }, [categoryOptions, searchFilters]); + }, [searchFilters]); + + // 표시용 items — rawItems + categoryOptions 결합으로 파생 + const items = useMemo(() => { + const resolve = (col: string, code: string) => { + if (!code) return ""; + if (code.includes(",")) { + return code.split(",").map((c) => { + const trimmed = c.trim(); + if (!trimmed || trimmed === "s") return ""; + return categoryOptions[col]?.find((o) => o.code === trimmed)?.label || trimmed; + }).filter(Boolean).join(", "); + } + return categoryOptions[col]?.find((o) => o.code === code)?.label || code; + }; + return rawItems.map((r: any) => { + const converted = { ...r }; + for (const col of CATEGORY_COLUMNS) { + if (converted[col]) converted[col] = resolve(col, converted[col]); + } + return converted; + }); + }, [rawItems, categoryOptions]); useEffect(() => { fetchItems(); diff --git a/frontend/components/common/DataGrid.tsx b/frontend/components/common/DataGrid.tsx index b6998da0..8ca4e979 100644 --- a/frontend/components/common/DataGrid.tsx +++ b/frontend/components/common/DataGrid.tsx @@ -70,6 +70,13 @@ export interface DataGridProps { showPagination?: boolean; /** 초기 페이지 크기 (기본: 50) */ defaultPageSize?: number; + /** + * 초기 정렬 컬럼 키. + * 미지정 시 첫 데이터 도착 시점에 created_date 컬럼이 존재하면 자동으로 created_date desc 적용. + */ + defaultSortKey?: string; + /** 초기 정렬 방향 (기본: 자동 default일 때 desc) */ + defaultSortDir?: "asc" | "desc"; } const fmtNum = (val: any) => { @@ -223,13 +230,32 @@ export function DataGrid({ gridId, showPagination = true, defaultPageSize = 50, + defaultSortKey, + defaultSortDir, }: DataGridProps) { const [columns, setColumns] = useState(initialColumns); useEffect(() => { setColumns(initialColumns); }, [initialColumns]); - // 정렬 - const [sortKey, setSortKey] = useState(null); - const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); + // 정렬 — 초기값: defaultSortKey props 우선, 없으면 첫 data 도착 시 created_date desc 자동 적용 + const [sortKey, setSortKey] = useState(defaultSortKey ?? null); + const [sortDir, setSortDir] = useState<"asc" | "desc">( + defaultSortDir ?? (defaultSortKey ? "asc" : "desc"), + ); + // 자동 default 1회만 적용하기 위한 가드 + const [autoDefaultApplied, setAutoDefaultApplied] = useState(!!defaultSortKey); + + // 첫 데이터 도착 시점에 created_date desc 자동 적용 (props.defaultSortKey 미지정 + 데이터에 created_date 컬럼 존재 시) + // 사용자가 헤더 클릭으로 정렬을 바꾸면 autoDefaultApplied가 true로 잠겨 재적용되지 않음 + useEffect(() => { + if (autoDefaultApplied) return; + if (data.length === 0) return; + // created_date 필드가 데이터에 존재하는지 확인 (첫 행 기준) + if (data[0]?.created_date !== undefined) { + setSortKey("created_date"); + setSortDir("desc"); + } + setAutoDefaultApplied(true); + }, [data, autoDefaultApplied]); // 헤더 필터 (컬럼별 선택된 값 Set) const [headerFilters, setHeaderFilters] = useState>>({}); @@ -296,8 +322,9 @@ export function DataGrid({ }); }; - // 정렬 + // 정렬 — 사용자가 헤더 클릭한 시점부터는 자동 default 재적용 차단 const handleSort = (key: string) => { + if (!autoDefaultApplied) setAutoDefaultApplied(true); if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc")); else { setSortKey(key); setSortDir("asc"); } }; @@ -339,12 +366,24 @@ export function DataGrid({ // 정렬 if (sortKey) { result.sort((a, b) => { - const av = a[sortKey] ?? ""; - const bv = b[sortKey] ?? ""; + const rawA = a[sortKey]; + const rawB = b[sortKey]; + // NULLS LAST — null/undefined/빈문자열은 정렬 방향과 무관하게 항상 뒤로 + const aEmpty = rawA == null || rawA === ""; + const bEmpty = rawB == null || rawB === ""; + if (aEmpty && bEmpty) return 0; + if (aEmpty) return 1; + if (bEmpty) return -1; + const av = rawA; + const bv = rawB; const na = Number(av); const nb = Number(bv); - if (!isNaN(na) && !isNaN(nb)) return sortDir === "asc" ? na - nb : nb - na; - return sortDir === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av)); + if (!isNaN(na) && !isNaN(nb) && av !== "" && bv !== "") { + return sortDir === "asc" ? na - nb : nb - na; + } + return sortDir === "asc" + ? String(av).localeCompare(String(bv)) + : String(bv).localeCompare(String(av)); }); } diff --git a/frontend/components/common/EDataTable.tsx b/frontend/components/common/EDataTable.tsx index 9e9fac41..a9e2501d 100644 --- a/frontend/components/common/EDataTable.tsx +++ b/frontend/components/common/EDataTable.tsx @@ -75,6 +75,12 @@ export interface EDataTableProps = any> { sort?: SortState | null; onSortChange?: (sort: SortState | null) => void; + // ─── 초기 자동 정렬 ─── + // defaultSortKey 미지정 + 데이터에 created_date 컬럼 존재 시 자동으로 created_date desc 적용 + // 사용자가 헤더 클릭하면 자동 default 재적용 차단 + defaultSortKey?: string; + defaultSortDir?: "asc" | "desc"; + draggableColumns?: boolean; onColumnOrderChange?: (columns: EDataTableColumn[]) => void; columnOrderKey?: string; @@ -290,15 +296,22 @@ export function EDataTable = any>({ serverTotalCount, onServerPageChange, onServerPageSizeChange, + defaultSortKey, + defaultSortDir, className, }: EDataTableProps) { const [columns, setColumns] = useState(initialColumns); useEffect(() => { setColumns(initialColumns); }, [initialColumns]); - // 정렬 - const [internalSort, setInternalSort] = useState(null); + // 정렬 — 초기값으로 defaultSortKey 적용 (있는 경우) + const [internalSort, setInternalSort] = useState( + defaultSortKey ? { key: defaultSortKey, direction: defaultSortDir ?? "asc" } : null + ); const sortState = externalSort !== undefined ? externalSort : internalSort; + // 자동 default 정렬 1회 가드 — 사용자 클릭 또는 명시 default 후 재적용 차단 + const [autoDefaultApplied, setAutoDefaultApplied] = useState(!!defaultSortKey); + // 헤더 필터 const [headerFilters, setHeaderFilters] = useState>>({}); @@ -337,6 +350,17 @@ export function EDataTable = any>({ useSensor(PointerSensor, { activationConstraint: { distance: 8 } }) ); + // 자동 default 정렬 — defaultSortKey 미지정 + 외부 sort 모드 아닐 때 + 데이터에 created_date 있으면 1회 적용 + useEffect(() => { + if (autoDefaultApplied) return; + if (defaultSortKey) { setAutoDefaultApplied(true); return; } + if (externalSort !== undefined) { setAutoDefaultApplied(true); return; } // 외부 정렬 제어 시 자동 적용 안 함 + if (data.length > 0 && data[0]?.created_date !== undefined) { + setInternalSort({ key: "created_date", direction: "desc" }); + setAutoDefaultApplied(true); + } + }, [data, defaultSortKey, externalSort, autoDefaultApplied]); + // localStorage에서 컬럼 순서 복원 useEffect(() => { if (!columnOrderKey) return; @@ -388,6 +412,9 @@ export function EDataTable = any>({ // 정렬 const handleSort = (key: string) => { + // 사용자가 헤더 클릭한 시점부터는 자동 default 재적용 차단 + setAutoDefaultApplied(true); + const newSort: SortState | null = sortState?.key === key ? sortState.direction === "asc" ? { key, direction: "desc" } @@ -440,14 +467,23 @@ export function EDataTable = any>({ if (sortState && !onSortChange) { const { key, direction } = sortState; result.sort((a, b) => { - const av = a[key] ?? ""; - const bv = b[key] ?? ""; - const na = Number(av); - const nb = Number(bv); - if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na; + const rawA = a[key]; + const rawB = b[key]; + // NULLS LAST: null/undefined/"" 는 항상 뒤로 (방향 무관) + const aEmpty = rawA === null || rawA === undefined || rawA === ""; + const bEmpty = rawB === null || rawB === undefined || rawB === ""; + if (aEmpty && bEmpty) return 0; + if (aEmpty) return 1; + if (bEmpty) return -1; + + const na = Number(rawA); + const nb = Number(rawB); + if (!isNaN(na) && !isNaN(nb) && rawA !== "" && rawB !== "") { + return direction === "asc" ? na - nb : nb - na; + } return direction === "asc" - ? String(av).localeCompare(String(bv)) - : String(bv).localeCompare(String(av)); + ? String(rawA).localeCompare(String(rawB)) + : String(rawB).localeCompare(String(rawA)); }); } diff --git a/frontend/components/common/TimelineScheduler.tsx b/frontend/components/common/TimelineScheduler.tsx index c768492f..5426687e 100644 --- a/frontend/components/common/TimelineScheduler.tsx +++ b/frontend/components/common/TimelineScheduler.tsx @@ -253,12 +253,19 @@ export default function TimelineScheduler({ }, [baseDate, config.spanDays]); // 표시 범위 변경 시 부모에 알림 (데이터 재조회 트리거용) + // 부모가 인라인 함수로 onRangeChange를 넘기는 경우(매 렌더마다 새 참조) useEffect가 + // 무한 트리거되어 < 오늘 > 클릭 시 로딩이 끝나지 않는 문제가 있었음. + // onRangeChange는 의존성에서 제외하고, 실제 시작/종료 날짜가 바뀐 경우에만 호출. + const lastRangeRef = useRef<{ s: string; e: string } | null>(null); useEffect(() => { if (!onRangeChange) return; const start = toDateStr(baseDate); const end = toDateStr(addDays(baseDate, config.spanDays - 1)); + if (lastRangeRef.current?.s === start && lastRangeRef.current?.e === end) return; + lastRangeRef.current = { s: start, e: end }; onRangeChange(start, end); - }, [baseDate, config.spanDays, onRangeChange]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [baseDate, config.spanDays]); const totalWidth = config.cellWidth * config.spanDays; @@ -613,14 +620,8 @@ export default function TimelineScheduler({ ); } - if (resources.length === 0 || events.length === 0) { - return ( -
- {emptyIcon} -

{emptyMessage}

-
- ); - } + // 데이터 0건이어도 네비게이션 컨트롤(이전/오늘/다음/줌)은 노출하여 이전 기간 탐색 가능하도록. + const hasData = resources.length > 0 && events.length > 0; const barHeight = 24; const barGap = 2; @@ -684,7 +685,14 @@ export default function TimelineScheduler({ )} - {/* 타임라인 본체 */} + {/* 타임라인 본체 — 데이터 0건이면 빈 안내, 컨트롤 바는 위에서 항상 노출 */} + {!hasData ? ( +
+ {emptyIcon} +

{emptyMessage}

+

현재 표시 범위: {toDateStr(dates[0])} ~ {toDateStr(dates[dates.length - 1])}

+
+ ) : (
- {/* 헤더 공간 */} + {/* 좌상단 코너 — 가로/세로 스크롤 모두에서 고정 (이벤트 바보다 위) */}
리소스 @@ -729,8 +737,8 @@ export default function TimelineScheduler({ {/* 우측: 타임라인 그리드 */}
- {/* 날짜 헤더 */} -
+ {/* 날짜 헤더 — 이벤트 바(z-10)보다 위로 올려야 스크롤 시 겹침 방지 */} +
{/* 상위 그룹 (월) */} {dateGroups && (
@@ -918,6 +926,7 @@ export default function TimelineScheduler({
+ )}
); } diff --git a/frontend/components/screen/filters/FormDatePicker.tsx b/frontend/components/screen/filters/FormDatePicker.tsx index 5ab5a1ff..403cff03 100644 --- a/frontend/components/screen/filters/FormDatePicker.tsx +++ b/frontend/components/screen/filters/FormDatePicker.tsx @@ -121,21 +121,66 @@ export const FormDatePicker: React.FC = ({ setIsOpen(false); }; + // 숫자만 입력 받고 자릿수에 맞춰 자동 포맷팅 + // includeTime=false : yyyy-MM-dd (최대 8자리) + // includeTime=true : yyyy-MM-dd HH:mm (최대 12자리) const handleTriggerInput = (raw: string) => { setIsTyping(true); - setTypingValue(raw); if (!isOpen) setIsOpen(true); - const digitsOnly = raw.replace(/\D/g, ""); - if (digitsOnly.length === 8) { - const y = parseInt(digitsOnly.slice(0, 4), 10); - const m = parseInt(digitsOnly.slice(4, 6), 10) - 1; - const d = parseInt(digitsOnly.slice(6, 8), 10); + const maxDigits = includeTime ? 12 : 8; + const digits = raw.replace(/\D/g, "").slice(0, maxDigits); + + // 자릿수별 자동 포맷팅 — 4/6/8/10 자리에서 자동으로 다음 칸 구분자 삽입 + let formatted = digits; + if (digits.length > 10) { + formatted = `${digits.slice(0, 4)}-${digits.slice(4, 6)}-${digits.slice(6, 8)} ${digits.slice(8, 10)}:${digits.slice(10, 12)}`; + } else if (digits.length > 8) { + formatted = `${digits.slice(0, 4)}-${digits.slice(4, 6)}-${digits.slice(6, 8)} ${digits.slice(8)}`; + } else if (digits.length === 8) { + formatted = `${digits.slice(0, 4)}-${digits.slice(4, 6)}-${digits.slice(6, 8)}${includeTime ? " " : ""}`; + } else if (digits.length > 6) { + formatted = `${digits.slice(0, 4)}-${digits.slice(4, 6)}-${digits.slice(6)}`; + } else if (digits.length > 4) { + formatted = `${digits.slice(0, 4)}-${digits.slice(4)}`; + } + setTypingValue(formatted); + + // 날짜 8자리 완성 시 — 시간 미포함 모드는 즉시 적용·닫기, 시간 포함 모드는 시간 입력 대기 + if (digits.length >= 8) { + const y = parseInt(digits.slice(0, 4), 10); + const m = parseInt(digits.slice(4, 6), 10) - 1; + const d = parseInt(digits.slice(6, 8), 10); + const dateValid = y >= 1900 && y <= 2100; + if (!dateValid) return; const date = new Date(y, m, d); - if (date.getFullYear() === y && date.getMonth() === m && date.getDate() === d && y >= 1900 && y <= 2100) { + if (date.getFullYear() !== y || date.getMonth() !== m || date.getDate() !== d) return; + setCurrentMonth(new Date(y, m, 1)); + + if (!includeTime) { onChange(buildDateStr(date)); - setCurrentMonth(new Date(y, m, 1)); - if (!includeTime) setTimeout(() => { setIsTyping(false); setIsOpen(false); }, 400); - else setIsTyping(false); + setTimeout(() => { setIsTyping(false); setIsOpen(false); }, 400); + return; + } + + // includeTime: 8자리(날짜만)면 기존 시간(또는 00:00) 유지하고 onChange + if (digits.length === 8) { + onChange(buildDateStr(date, timeValue)); + return; + } + + // 시간 자리 일부 입력 시 + if (digits.length >= 10) { + const hh = Math.min(parseInt(digits.slice(8, 10), 10), 23); + const hhStr = String(hh).padStart(2, "0"); + let mmStr = digits.length >= 12 ? digits.slice(10, 12) : "00"; + const mmNum = Math.min(parseInt(mmStr, 10) || 0, 59); + mmStr = String(mmNum).padStart(2, "0"); + const newTime = `${hhStr}:${mmStr}`; + setTimeValue(newTime); + onChange(buildDateStr(date, newTime)); + if (digits.length === 12) { + setTimeout(() => { setIsTyping(false); setIsOpen(false); }, 400); + } } } }; diff --git a/frontend/components/ui/input.tsx b/frontend/components/ui/input.tsx index f6c5e4ea..626bc6c7 100644 --- a/frontend/components/ui/input.tsx +++ b/frontend/components/ui/input.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { cn } from "@/lib/utils"; +import { FormDatePicker } from "@/components/screen/filters/FormDatePicker"; export interface InputProps extends React.ComponentProps<"input"> { label?: string; // 툴팁에 표시할 라벨 @@ -9,6 +10,25 @@ export interface InputProps extends React.ComponentProps<"input"> { const Input = React.forwardRef( ({ className, type, label, enableEnterNavigation = false, onKeyDown, ...props }, ref) => { + // type="date" / "datetime-local" → 공통 FormDatePicker로 자동 위임 (타이핑 자릿수 자동 진행) + if (type === "date" || type === "datetime-local") { + const fakeEvent = (v: string) => ({ + target: { value: v }, + currentTarget: { value: v }, + } as unknown as React.ChangeEvent); + return ( + props.onChange?.(fakeEvent(v))} + placeholder={(props as any).placeholder} + disabled={props.disabled} + readOnly={props.readOnly} + includeTime={type === "datetime-local"} + /> + ); + } + const [showTooltip, setShowTooltip] = React.useState(false); const [tooltipPosition, setTooltipPosition] = React.useState({ x: 0, y: 0 });