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 });