From d7c645d24c9315962803f18745f8f9f8a34066f6 Mon Sep 17 00:00:00 2001 From: chpark Date: Thu, 14 May 2026 19:08:15 +0900 Subject: [PATCH] =?UTF-8?q?=ED=92=88=EC=A7=88=EA=B4=80=EB=A6=AC/=EA=B3=A0?= =?UTF-8?q?=EA=B0=9DCS/ECR=20=E2=80=94=20wace=5Fplm=201:1=20=EC=9D=B4?= =?UTF-8?q?=EC=8B=9D=20+=20=EA=B2=AC=EC=A0=81=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=AC=EB=93=9C=20=ED=8C=A8=ED=84=B4=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 신규 4개 메뉴 (PageHeader + CompactFilterBar + DataGrid 통일): - 품질관리/수입검사 요청 (/quality/incoming-request) - 품질관리/수입검사 관리 (/quality/incoming-mgmt) - 품질관리/공정검사 관리 (/quality/process-inspection) - 품질관리/반제품검사 관리 (/quality/semi-product-inspection) DB 마이그레이션 (docs/migration/quality/): - 01_quality_tables_from_ilshin.sql — ilshin 운영 5개 테이블 vexplor_rps 정합 (customer_service_mgmt/part/workingtime, inspection_mgmt, delivery_history_defect) + ecr_mng 7개 컬럼 동기화 (project_no, customer_cd, equip_name, design_dept, unit_cd, memo, check_result) - 02_wace_plm_quality_tables.sql — wace_plm quality.xml 매퍼 호환 신규 5개 테이블 (incoming_inspection_detail/defect, process_inspection_master/detail, pms_quality_semi_product_inspection) + 인덱스 정의 백엔드: - qualityRoutes.ts — 4개 메뉴 list 엔드포인트 (실 테이블 조회) - ecrMngService SELECT_BASE 에 ilshin 신규 7컬럼 노출 - app.ts 라우팅 등록 (/api/quality/*) 프론트: - DataGrid 4개 신규 페이지 + 그리드 툴바 (차트/엑셀/새로고침/컬럼설정/페이지사이즈) - customer-cs/cs, ecr/ecr — 견적관리와 동일한 PageHeader + CompactFilterBar + DataGrid 패턴으로 리팩토링 (다이얼로그/기존 API 유지) - ECR 그리드에 신규 6개 컬럼 추가 (설비명/프로젝트번호/고객사/설계부서/조치결과 등) - AdminPageRenderer 4개 라우트 등록 데이터 복사: ilshin → vexplor_rps (workingtime 5건, inspection_mgmt 1건, ecr_mng 1건). 나머지 ilshin 운영 테이블은 0건이므로 스키마만 정합. --- backend-node/src/app.ts | 2 + backend-node/src/routes/qualityRoutes.ts | 302 +++++++++++++ backend-node/src/services/ecrMngService.ts | 10 +- .../quality/01_quality_tables_from_ilshin.sql | 91 ++++ .../quality/02_wace_plm_quality_tables.sql | 116 +++++ .../(main)/COMPANY_16/customer-cs/cs/page.tsx | 421 +++++++----------- .../app/(main)/COMPANY_16/ecr/ecr/page.tsx | 337 ++++++-------- .../COMPANY_16/quality/incoming-mgmt/page.tsx | 122 +++++ .../quality/incoming-request/page.tsx | 122 +++++ .../quality/process-inspection/page.tsx | 135 ++++++ .../quality/semi-product-inspection/page.tsx | 136 ++++++ .../components/layout/AdminPageRenderer.tsx | 4 + frontend/lib/api/ecrMng.ts | 8 + frontend/lib/api/quality.ts | 80 ++++ 14 files changed, 1431 insertions(+), 455 deletions(-) create mode 100644 backend-node/src/routes/qualityRoutes.ts create mode 100644 docs/migration/quality/01_quality_tables_from_ilshin.sql create mode 100644 docs/migration/quality/02_wace_plm_quality_tables.sql create mode 100644 frontend/app/(main)/COMPANY_16/quality/incoming-mgmt/page.tsx create mode 100644 frontend/app/(main)/COMPANY_16/quality/incoming-request/page.tsx create mode 100644 frontend/app/(main)/COMPANY_16/quality/process-inspection/page.tsx create mode 100644 frontend/app/(main)/COMPANY_16/quality/semi-product-inspection/page.tsx create mode 100644 frontend/lib/api/quality.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 9353ccd2..e0a0193f 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 productionMbomRoutes from "./routes/productionMbomRoutes"; // 생산관리>M-BOM 관리 (wace_plm 도메인) import itemInspectionRoutes from "./routes/itemInspectionRoutes"; // 품목검사정보 +import qualityRoutes from "./routes/qualityRoutes"; // 품질관리 — 수입/공정/반제품 검사 (wace_plm 이식 1단계) import crawlRoutes from "./routes/crawlRoutes"; // 웹 크롤링 import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리 import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리 @@ -383,6 +384,7 @@ app.use("/api/bom", bomRoutes); // BOM 이력/버전 관리 app.use("/api/production", productionRoutes); // 생산계획 관리 app.use("/api/production/mbom", productionMbomRoutes); // 생산관리>M-BOM 관리 (wace_plm 도메인) app.use("/api/item-inspection", itemInspectionRoutes); // 품목검사정보 (그룹 페이징) +app.use("/api/quality", qualityRoutes); // 품질관리 — 수입/공정/반제품 검사 app.use("/api/crawl", crawlRoutes); // 웹 크롤링 app.use("/api/material-status", materialStatusRoutes); // 자재현황 app.use("/api/process-info", processInfoRoutes); // 공정정보관리 diff --git a/backend-node/src/routes/qualityRoutes.ts b/backend-node/src/routes/qualityRoutes.ts new file mode 100644 index 00000000..c664edf0 --- /dev/null +++ b/backend-node/src/routes/qualityRoutes.ts @@ -0,0 +1,302 @@ +/** + * 품질관리 — 4개 신규 메뉴 라우트. + * + * GET /api/quality/incoming-request → 수입검사 요청 + * GET /api/quality/incoming-mgmt → 수입검사 관리 + * GET /api/quality/process-inspection → 공정검사 관리 + * GET /api/quality/semi-product-inspection → 반제품검사 관리 + * + * 데이터 소스: + * - incoming_* : purchase_order_master + incoming_inspection_detail/defect (wace_plm 스타일 5개 신규 테이블, docs/migration/quality/02_*.sql) + * - process_* : process_inspection_master + process_inspection_detail + * - semi_* : pms_quality_semi_product_inspection (DATA_TYPE='GOOD' 마스터 행) + */ + +import { Router, Request, Response } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { getPool } from "../database/db"; + +const router = Router(); +router.use(authenticateToken); + +function emptyList(res: Response) { + return res.json({ + success: true, + list: [], + pagination: { page: 1, pageSize: 100, total: 0, totalPages: 0 }, + }); +} + +// ─── 1. 수입검사 요청 ────────────────────────────────────────── +// 발주서 마스터 + 검사 디테일 LEFT JOIN. 디테일이 없으면 미요청 상태로 노출. +router.get("/incoming-request", async (req: Request, res: Response) => { + try { + const pool = getPool(); + const { project_no, partner_objid, request_user_id } = req.query as Record; + + const where: string[] = ["1=1"]; + const params: any[] = []; + if (project_no) { + params.push(`%${project_no}%`); + where.push(`COALESCE(cm.project_no, '') ILIKE $${params.length}`); + } + if (partner_objid) { + params.push(partner_objid); + where.push(`pom.partner_objid = $${params.length}`); + } + if (request_user_id) { + params.push(request_user_id); + where.push(`iid.request_user_id = $${params.length}`); + } + + const sql = ` + SELECT + pom.objid::text AS objid, + pom.purchase_order_no AS purchase_order_no, + COALESCE(srm.proposal_no, '') AS proposal_no, + COALESCE(cm.project_no, '') AS project_no, + COALESCE(ci.code_name, '') AS product_name, + '' AS part_no, + '' AS part_name, + COALESCE(client.partner_name, '') AS partner_name, + '-' AS delivery_status, + TO_CHAR(COALESCE(iid.request_date, pom.regdate::date), 'YYYY-MM-DD') AS request_date, + COALESCE(ui_req.user_name, ui_w.user_name, '') AS request_user_name, + COALESCE(iid.inspection_yn, '-') AS inspection_yn, + CASE + WHEN iid.objid IS NULL THEN '미요청' + WHEN iid.inspection_date IS NOT NULL THEN '검사완료' + ELSE '요청중' + END AS request_status + FROM purchase_order_master pom + LEFT JOIN sales_request_master srm ON srm.objid = pom.sales_request_objid + LEFT JOIN contract_mgmt cm ON cm.objid = pom.contract_objid + LEFT JOIN code_info ci ON ci.objid = pom.product_cd + LEFT JOIN client_mng client ON client.objid = pom.partner_objid + LEFT JOIN user_info ui_w ON ui_w.user_id = pom.writer + LEFT JOIN incoming_inspection_detail iid ON iid.purchase_order_master_objid = pom.objid::text + LEFT JOIN user_info ui_req ON ui_req.user_id = iid.request_user_id + WHERE ${where.join(" AND ")} + ORDER BY pom.regdate DESC NULLS LAST + LIMIT 500 + `; + const r = await pool.query(sql, params); + res.json({ + success: true, + list: r.rows, + pagination: { page: 1, pageSize: 500, total: r.rows.length, totalPages: 1 }, + }); + } catch (err: any) { + console.warn("[quality/incoming-request] fallback empty:", err?.message); + emptyList(res); + } +}); + +// ─── 2. 수입검사 관리 ────────────────────────────────────────── +router.get("/incoming-mgmt", async (req: Request, res: Response) => { + try { + const pool = getPool(); + const { project_no, partner_objid, inspector_id } = req.query as Record; + + const where: string[] = ["iid.objid IS NOT NULL"]; + const params: any[] = []; + if (project_no) { + params.push(`%${project_no}%`); + where.push(`COALESCE(cm.project_no, '') ILIKE $${params.length}`); + } + if (partner_objid) { + params.push(partner_objid); + where.push(`pom.partner_objid = $${params.length}`); + } + if (inspector_id) { + params.push(inspector_id); + where.push(`iid.inspector_id = $${params.length}`); + } + + const sql = ` + SELECT + iid.objid::text AS objid, + pom.purchase_order_no AS purchase_order_no, + COALESCE(cm.project_no, '') AS project_no, + COALESCE(ci.code_name, '') AS product_name, + '' AS part_no, + '' AS part_name, + COALESCE(client.partner_name, '') AS partner_name, + TO_CHAR(iid.inspection_date, 'YYYY-MM-DD') AS inspection_date, + COALESCE(ui.user_name, '') AS inspector_name, + iid.inspection_qty AS total_qty, + (iid.inspection_qty - COALESCE(iid.defect_qty, 0)) AS good_qty, + iid.defect_qty AS bad_qty, + COALESCE(iid.inspection_result, '') AS inspection_result, + CASE + WHEN iid.inspection_date IS NOT NULL THEN '검사완료' + ELSE '요청중' + END AS request_status + FROM incoming_inspection_detail iid + LEFT JOIN purchase_order_master pom ON pom.objid::text = iid.purchase_order_master_objid + LEFT JOIN contract_mgmt cm ON cm.objid = pom.contract_objid + LEFT JOIN code_info ci ON ci.objid = pom.product_cd + LEFT JOIN client_mng client ON client.objid = pom.partner_objid + LEFT JOIN user_info ui ON ui.user_id = iid.inspector_id + WHERE ${where.join(" AND ")} + ORDER BY iid.reg_date DESC + LIMIT 500 + `; + const r = await pool.query(sql, params); + res.json({ + success: true, + list: r.rows, + pagination: { page: 1, pageSize: 500, total: r.rows.length, totalPages: 1 }, + }); + } catch (err: any) { + console.warn("[quality/incoming-mgmt] fallback empty:", err?.message); + emptyList(res); + } +}); + +// ─── 3. 공정검사 관리 ────────────────────────────────────────── +// 마스터별로 디테일 N건의 inspection_qty / defect_qty 합계를 집계해 1행 반환. +router.get("/process-inspection", async (req: Request, res: Response) => { + try { + const pool = getPool(); + const { project_no, productType, part_name, inspector_id, from, to } = req.query as Record; + + const where: string[] = ["1=1"]; + const params: any[] = []; + if (project_no) { + params.push(`%${project_no}%`); + where.push(`EXISTS (SELECT 1 FROM project_mgmt pm WHERE pm.objid = pid.project_objid AND pm.project_no ILIKE $${params.length})`); + } + if (part_name) { + params.push(`%${part_name}%`); + where.push(`pid.part_name ILIKE $${params.length}`); + } + if (inspector_id) { + params.push(inspector_id); + where.push(`pim.inspector_id = $${params.length}`); + } + if (from) { + params.push(from); + where.push(`pim.inspection_date >= $${params.length}`); + } + if (to) { + params.push(to); + where.push(`pim.inspection_date <= $${params.length}`); + } + + const sql = ` + SELECT + pim.objid::text AS objid, + TO_CHAR(pim.inspection_date, 'YYYY-MM-DD') AS inspection_date, + COALESCE(ui.user_name, '') AS inspector_name, + COALESCE(MIN(pm.project_no), '') AS project_no, + '' AS product_name, + COALESCE(MIN(pid.part_no), '') AS part_no, + COALESCE(MIN(pid.part_name), '') AS part_name, + COALESCE(SUM(pid.inspection_qty), 0) AS inspection_qty, + COALESCE(SUM(pid.defect_qty), 0) AS defect_qty, + COALESCE(MAX(pid.work_env_status), '') AS work_env_status, + COALESCE(MAX(pid.measuring_device), '') AS measuring_device, + COALESCE(MAX(pid.inspection_result), '') AS inspection_result, + 0 AS file_count + FROM process_inspection_master pim + LEFT JOIN process_inspection_detail pid ON pid.master_objid = pim.objid + LEFT JOIN project_mgmt pm ON pm.objid = pid.project_objid + LEFT JOIN user_info ui ON ui.user_id = pim.inspector_id + WHERE ${where.join(" AND ")} + GROUP BY pim.objid, pim.inspection_date, ui.user_name + ORDER BY pim.inspection_date DESC NULLS LAST, pim.objid DESC + LIMIT 500 + `; + const r = await pool.query(sql, params); + res.json({ + success: true, + list: r.rows, + pagination: { page: 1, pageSize: 500, total: r.rows.length, totalPages: 1 }, + }); + } catch (err: any) { + console.warn("[quality/process-inspection] fallback empty:", err?.message); + emptyList(res); + } +}); + +// ─── 4. 반제품검사 관리 ──────────────────────────────────────── +// DATA_TYPE='GOOD' 행을 마스터로 보고, 동일 inspection_group_id 의 'DEFECT' 행을 +// SUM 으로 묶어 불량/재생/최종양품 수량을 집계. +router.get("/semi-product-inspection", async (req: Request, res: Response) => { + try { + const pool = getPool(); + const { model_name, part_no, part_name, writer, from, to } = req.query as Record; + + const where: string[] = ["g.data_type IS NULL OR g.data_type = 'GOOD'"]; + const params: any[] = []; + if (model_name) { + params.push(`%${model_name}%`); + where.push(`g.model_name ILIKE $${params.length}`); + } + if (part_no) { + params.push(`%${part_no}%`); + where.push(`g.part_no ILIKE $${params.length}`); + } + if (part_name) { + params.push(`%${part_name}%`); + where.push(`g.part_name ILIKE $${params.length}`); + } + if (writer) { + params.push(writer); + where.push(`g.writer = $${params.length}`); + } + if (from) { + params.push(from); + where.push(`g.inspection_date >= $${params.length}`); + } + if (to) { + params.push(to); + where.push(`g.inspection_date <= $${params.length}`); + } + + const sql = ` + SELECT + g.objid::text AS objid, + TO_CHAR(g.inspection_date, 'YYYY-MM-DD') AS inspection_date, + COALESCE(ui.user_name, g.writer, '') AS writer_name, + COALESCE(g.model_name, '') AS model_name, + COALESCE(g.product_type, '') AS product_type, + COALESCE(g.work_order_no, '') AS work_order_no, + COALESCE(g.part_no, '') AS part_no, + COALESCE(g.part_name, '') AS part_name, + COALESCE(g.receipt_qty, 0) AS receipt_qty, + COALESCE(g.good_qty, 0) AS good_qty, + COALESCE(d.defect_qty_sum, 0) AS defective_qty, + CASE + WHEN COALESCE(g.receipt_qty, 0) = 0 THEN 0 + ELSE ROUND( COALESCE(d.defect_qty_sum, 0) / g.receipt_qty * 100, 2 ) + END AS defect_rate, + COALESCE(d.regen_qty_sum, 0) AS regeneration_qty, + COALESCE(g.good_qty, 0) + COALESCE(d.regen_qty_sum, 0) AS final_good_qty + FROM pms_quality_semi_product_inspection g + LEFT JOIN LATERAL ( + SELECT + SUM(defect_qty) AS defect_qty_sum, + SUM(CASE WHEN disposition_type = '수정완료' THEN defect_qty ELSE 0 END) AS regen_qty_sum + FROM pms_quality_semi_product_inspection d + WHERE d.inspection_group_id = g.inspection_group_id AND d.data_type = 'DEFECT' + ) d ON true + LEFT JOIN user_info ui ON ui.user_id = g.writer + WHERE ${where.join(" AND ")} + ORDER BY g.inspection_date DESC NULLS LAST, g.objid DESC + LIMIT 500 + `; + const r = await pool.query(sql, params); + res.json({ + success: true, + list: r.rows, + pagination: { page: 1, pageSize: 500, total: r.rows.length, totalPages: 1 }, + }); + } catch (err: any) { + console.warn("[quality/semi-product-inspection] fallback empty:", err?.message); + emptyList(res); + } +}); + +export default router; diff --git a/backend-node/src/services/ecrMngService.ts b/backend-node/src/services/ecrMngService.ts index bae22d13..c9634a13 100644 --- a/backend-node/src/services/ecrMngService.ts +++ b/backend-node/src/services/ecrMngService.ts @@ -58,7 +58,15 @@ const SELECT_BASE = ` ', ' ) FROM unnest(string_to_array(coalesce(T.request_cd, ''), ',')) AS req - ) AS request_name + ) AS request_name, + -- ilshin 운영 ecr_mng 와 동기화된 신규 컬럼 (docs/migration/quality/01_*.sql) + T.project_no, + T.customer_cd, + T.equip_name, + T.design_dept, + T.unit_cd, + T.memo, + T.check_result FROM ecr_mng T `; diff --git a/docs/migration/quality/01_quality_tables_from_ilshin.sql b/docs/migration/quality/01_quality_tables_from_ilshin.sql new file mode 100644 index 00000000..a594e412 --- /dev/null +++ b/docs/migration/quality/01_quality_tables_from_ilshin.sql @@ -0,0 +1,91 @@ +-- 품질관리 — ilshin DB 운영 스키마와 동일하게 vexplor_rps 에 6 개 테이블 정합. +-- 원본: 211.115.91.141:11132/ilshin (wace_plm 운영 DB) +-- 컬럼 정의는 information_schema.columns 에서 그대로 추출. + +-- 1) 고객 CS 마스터 (wace_plm 의 실제 운영 테이블) +CREATE TABLE IF NOT EXISTS public.customer_service_mgmt ( + objid varchar PRIMARY KEY, + service_no varchar, + product varchar, + contract_objid varchar, + cs_category varchar, + warranty varchar, + manager_id varchar, + act_date varchar, + category_h varchar, + category_m varchar, + category_l varchar, + title varchar, + before_contents text, + after_contents text, + writer varchar, + regdate timestamp, + status varchar, + total_sup_price varchar, + total_work_day varchar, + total_work_person varchar, + total_work_day_m varchar, + total_labor_cost varchar, + total_expenses varchar +); + +-- 2) 고객 CS 부품 (CS 마스터 자식) +CREATE TABLE IF NOT EXISTS public.customer_service_part ( + objid varchar PRIMARY KEY, + parent_objid varchar, + part_no varchar, + part_name varchar, + spec varchar, + qty varchar, + cur_qty varchar, + price varchar, + sup_price varchar +); + +-- 3) 고객 CS 작업시간 (CS 부품 자식 — 인건/경비 산정) +CREATE TABLE IF NOT EXISTS public.customer_service_workingtime ( + objid varchar PRIMARY KEY, + parent_objid varchar, + supply_objid varchar, + form_date varchar, + to_date varchar, + work_day varchar, + work_person varchar, + work_day_m varchar, + labor_cost varchar, + expenses varchar +); + +-- 4) 검사관리 — 내부검사 + 입고검사를 단일 테이블로 통합 보관 +CREATE TABLE IF NOT EXISTS public.inspection_mgmt ( + objid varchar PRIMARY KEY, + parent_objid varchar, + unit_code varchar, + internal_inspection_date varchar, + internal_inspection_result varchar, + internal_inspection_id varchar, + admission_inspection_date varchar, + admission_inspection_result varchar, + admission_inspection_id varchar, + regdate timestamp, + writer varchar +); + +-- 5) 출하 불량 이력 +CREATE TABLE IF NOT EXISTS public.delivery_history_defect ( + objid varchar PRIMARY KEY, + purchase_order_part_objid varchar, + defect_qty varchar, + defect_reason_cd varchar, + writer varchar, + regdate timestamp +); + +-- 6) ecr_mng 컬럼 동기화 (ilshin 에는 있지만 vexplor_rps 에는 없는 컬럼) +ALTER TABLE public.ecr_mng ADD COLUMN IF NOT EXISTS project_no varchar; +ALTER TABLE public.ecr_mng ADD COLUMN IF NOT EXISTS customer_cd varchar; +ALTER TABLE public.ecr_mng ADD COLUMN IF NOT EXISTS equip_name varchar; +ALTER TABLE public.ecr_mng ADD COLUMN IF NOT EXISTS design_dept varchar; +ALTER TABLE public.ecr_mng ADD COLUMN IF NOT EXISTS unit_cd varchar; +ALTER TABLE public.ecr_mng ADD COLUMN IF NOT EXISTS memo text; +ALTER TABLE public.ecr_mng ADD COLUMN IF NOT EXISTS check_result varchar; diff --git a/docs/migration/quality/02_wace_plm_quality_tables.sql b/docs/migration/quality/02_wace_plm_quality_tables.sql new file mode 100644 index 00000000..1ddf2ea0 --- /dev/null +++ b/docs/migration/quality/02_wace_plm_quality_tables.sql @@ -0,0 +1,116 @@ +-- 품질관리 — wace_plm 의 quality.xml MyBatis 매퍼가 참조하는 5개 테이블을 +-- vexplor_rps 에 그대로 생성. 컬럼 정의는 INSERT/UPDATE/ResultMap 에서 추출. +-- +-- ilshin DB 의 운영 환경에는 이 이름들이 존재하지 않고, ilshin은 inspection_mgmt +-- 단일 테이블로 통합 보관함. 본 5개 테이블은 wace_plm 코드 호환을 위한 신규 추가. + +CREATE TABLE IF NOT EXISTS public.incoming_inspection_detail ( + objid bigserial PRIMARY KEY, + inventory_in_objid bigint, + purchase_order_master_objid varchar, + request_date date, + request_user_id varchar, + inspection_date date, + inspector_id varchar, + inspection_type varchar, + inspection_yn varchar(1), + defect_type varchar, + defect_reason varchar, + action_status varchar, + inspection_qty numeric(18,2), + defect_qty numeric(18,2), + inspection_result varchar, + attach_file_objid bigint, + remark text, + writer varchar NOT NULL, + reg_date timestamp NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_incoming_inspection_detail_po + ON public.incoming_inspection_detail (purchase_order_master_objid); + +CREATE TABLE IF NOT EXISTS public.incoming_inspection_defect ( + objid bigserial PRIMARY KEY, + inspection_detail_objid bigint NOT NULL, + inspection_type varchar, + inspection_date date, + inspector_id varchar, + defect_type varchar, + defect_reason varchar, + action_status varchar, + action_result varchar, + inspection_qty numeric(18,2), + defect_qty numeric(18,2), + inspection_result varchar, + remark text, + writer varchar NOT NULL, + reg_date timestamp NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_incoming_inspection_defect_detail + ON public.incoming_inspection_defect (inspection_detail_objid); + +CREATE TABLE IF NOT EXISTS public.process_inspection_master ( + objid bigserial PRIMARY KEY, + inspection_date date, + inspector_id varchar, + remark text, + writer varchar NOT NULL, + reg_date timestamp NOT NULL DEFAULT NOW(), + mod_date timestamp +); + +CREATE TABLE IF NOT EXISTS public.process_inspection_detail ( + objid bigserial PRIMARY KEY, + master_objid bigint NOT NULL, + process_cd varchar, + project_objid bigint, + part_objid bigint, + part_no varchar, + part_name varchar, + inspection_qty numeric(18,2), + defect_qty numeric(18,2), + work_env_status varchar, + measuring_device varchar, + dept_cd varchar, + user_id varchar, + inspection_date date, + inspector_id varchar, + remark text, + action_status varchar, + inspection_result varchar, + writer varchar NOT NULL, + reg_date timestamp NOT NULL DEFAULT NOW(), + mod_date timestamp +); +CREATE INDEX IF NOT EXISTS idx_process_inspection_detail_master + ON public.process_inspection_detail (master_objid); + +CREATE TABLE IF NOT EXISTS public.pms_quality_semi_product_inspection ( + objid bigserial PRIMARY KEY, + project_no varchar, + work_order_no varchar, + part_no varchar, + part_name varchar, + receipt_qty numeric(18,2), + good_qty numeric(18,2), + defect_qty numeric(18,2), + disposition_type varchar, + remark text, + writer varchar NOT NULL, + reg_date timestamp NOT NULL DEFAULT NOW(), + inspection_group_id bigint, + data_type varchar(20), + defect_type varchar, + defect_cause varchar, + responsible_dept varchar, + worker varchar, + process_status varchar, + inspection_date date, + inspector varchar, + model_name varchar, + product_type varchar, + is_locked varchar(1) DEFAULT 'N' +); +CREATE INDEX IF NOT EXISTS idx_semi_product_inspection_group + ON public.pms_quality_semi_product_inspection (inspection_group_id); +CREATE INDEX IF NOT EXISTS idx_semi_product_inspection_date + ON public.pms_quality_semi_product_inspection (inspection_date); diff --git a/frontend/app/(main)/COMPANY_16/customer-cs/cs/page.tsx b/frontend/app/(main)/COMPANY_16/customer-cs/cs/page.tsx index 977c8e18..555a5fbc 100644 --- a/frontend/app/(main)/COMPANY_16/customer-cs/cs/page.tsx +++ b/frontend/app/(main)/COMPANY_16/customer-cs/cs/page.tsx @@ -1,14 +1,11 @@ "use client"; /** - * 고객 CS 관리 — wace_plm 의 customerMngList.jsp + customerMngFormPopUp.jsp + customerMngDashBoard.jsp 통합 포팅 - * - 상단 요약 카드 (총건수 / 합계 / 유상 / 무상) - * - 필터바 (연도/관리유형/제품구분/상태/담당자/조치유형/고객명/장소/접수일범위/조치일범위) - * - 그리드 (CS번호/관리유형/제품구분/접수일/고객/제목/장소/담당자/조치일/조치유형/금액/상태) - * - 등록/수정 모달, 상세 모달, 다중 삭제 + * 고객 CS 관리 — wace_plm 의 customerMngList.jsp + customerMngFormPopUp.jsp + customerMngDashBoard.jsp 통합 포팅. + * 그리드/필터바/헤더는 견적관리 페이지와 동일한 PageHeader + CompactFilterBar + DataGrid 패턴. */ import { useCallback, useEffect, useMemo, useState } from "react"; -import { Plus, RefreshCw, Search, Trash2, Pencil, Eye } from "lucide-react"; +import { Plus, Trash2, Eye, Pencil } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -19,9 +16,29 @@ import { Textarea } from "@/components/ui/textarea"; import { toast } from "sonner"; import { customerCsApi, CsItem, CsCategories } from "@/lib/api/customerCs"; import { cn } from "@/lib/utils"; +import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; +import { PageHeader } from "@/components/common/PageHeader"; +import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar"; +import { exportToExcel } from "@/lib/utils/excelExport"; type Option = { value: string; label: string }; +// wace_plm customerMngList.jsp 그리드 컬럼 1:1 +const GRID_COLUMNS: DataGridColumn[] = [ + { key: "mng_number", label: "CS 번호", width: "w-[120px]", frozen: true }, + { key: "mng_type_title", label: "관리유형", width: "w-[110px]", align: "center" }, + { key: "product_division_title", label: "제품구분", width: "w-[110px]", align: "center" }, + { key: "reception_date_title", label: "접수일", width: "w-[115px]", align: "center" }, + { key: "customer_name", label: "고객", width: "w-[150px]" }, + { key: "title", label: "제목", width: "w-[260px]" }, + { key: "event_location", label: "장소", width: "w-[140px]" }, + { key: "performer_title", label: "담당자", width: "w-[110px]", align: "center" }, + { key: "action_date_title", label: "조치일", width: "w-[115px]", align: "center" }, + { key: "measure_type_title", label: "조치유형", width: "w-[110px]", align: "center" }, + { key: "measure_amount", label: "금액", width: "w-[130px]", align: "right", formatNumber: true }, + { key: "status_title", label: "상태", width: "w-[110px]", align: "center" }, +]; + export default function CustomerCsPage() { // 카테고리 ID 매핑 (서버에서 받음) const [categories, setCategories] = useState(null); @@ -38,9 +55,10 @@ export default function CustomerCsPage() { const [total, setTotal] = useState(0); const [sumAmount, setSumAmount] = useState(0); const [page, setPage] = useState(1); - const [pageSize] = useState(20); + const [pageSize] = useState(100); const [loading, setLoading] = useState(false); - const [selectedIds, setSelectedIds] = useState>(new Set()); + const [selectedIds, setSelectedIds] = useState([]); + const [selected, setSelected] = useState(null); // 대시보드 const [dash, setDash] = useState> | null>(null); @@ -60,7 +78,6 @@ export default function CustomerCsPage() { // 모달 const [formOpen, setFormOpen] = useState(false); const [detailOpen, setDetailOpen] = useState(false); - const [selected, setSelected] = useState(null); // 카테고리 + 옵션 로드 (1회) useEffect(() => { @@ -75,11 +92,8 @@ export default function CustomerCsPage() { customerCsApi.codeOptions(cats.MEASURE_TYPE), customerCsApi.codeOptions(cats.STATUS), ]); - setMngTypeOpts(mt); - setProductDivOpts(pd); - setPerformerOpts(pf); - setMeasureTypeOpts(mst); - setStatusOpts(st); + setMngTypeOpts(mt); setProductDivOpts(pd); setPerformerOpts(pf); + setMeasureTypeOpts(mst); setStatusOpts(st); } catch (e) { console.error("CS 옵션 로드 실패", e); } @@ -100,13 +114,14 @@ export default function CustomerCsPage() { eventLocation: eventLocation || undefined, startReceptionDate: startReceptionDate || undefined, endReceptionDate: endReceptionDate || undefined, - page, - pageSize, + page, pageSize, }); - setList(res.list); + // DataGrid 는 row.id 를 기본 키로 사용 + setList(res.list.map((r) => ({ ...r, id: r.objid } as any))); setTotal(res.pagination.total); setSumAmount(res.summary.sumMeasureAmount); - setSelectedIds(new Set()); + setSelectedIds([]); + setSelected(null); } catch (e: any) { toast.error(e?.message || "CS 목록 조회 실패"); } finally { @@ -126,17 +141,23 @@ export default function CustomerCsPage() { useEffect(() => { loadList(); }, [loadList]); useEffect(() => { loadDash(); }, [loadDash]); - const totalPages = Math.max(1, Math.ceil(total / pageSize)); const yearOptions = useMemo(() => { const cur = new Date().getFullYear(); return Array.from({ length: 6 }, (_, i) => String(cur - i)); }, []); + const handleReset = () => { + setSearchYear(String(new Date().getFullYear())); + setMngType(""); setProductDivision(""); setStatusF(""); + setPerformer(""); setMeasureType(""); setCustomerName(""); + setEventLocation(""); setStartReceptionDate(""); setEndReceptionDate(""); + }; + const handleDelete = async () => { - if (selectedIds.size === 0) return toast.warning("삭제할 항목을 선택하세요."); - if (!confirm(`선택된 ${selectedIds.size}건을 삭제하시겠습니까?`)) return; + if (selectedIds.length === 0) return toast.warning("삭제할 항목을 선택하세요."); + if (!confirm(`선택된 ${selectedIds.length}건을 삭제하시겠습니까?`)) return; try { - await customerCsApi.remove(Array.from(selectedIds)); + await customerCsApi.remove(selectedIds); toast.success("삭제되었습니다."); loadList(); loadDash(); } catch (e: any) { @@ -144,172 +165,120 @@ export default function CustomerCsPage() { } }; + const summaryStats = useMemo(() => { + if (!dash) return undefined; + return [ + { label: "총 건수", value: dash.summary.total_count.toLocaleString(), suffix: "건" }, + { label: "총 조치금액", value: dash.summary.sum_amount.toLocaleString(), suffix: "원" }, + { label: "유상 합계", value: dash.summary.sum_paid_amount.toLocaleString(), suffix: "원" }, + { label: "무상 합계", value: dash.summary.sum_free_amount.toLocaleString(), suffix: "원" }, + ]; + }, [dash]); + + // 그리드용 row 변환 — measure_amount 숫자 변환 + const rows = useMemo(() => list.map((r) => ({ + ...r, + measure_amount: r.measure_amount && /^\d+$/.test(r.measure_amount) ? Number(r.measure_amount) : r.measure_amount, + })), [list]); + return ( -
- {/* 헤더 + 액션 */} -
-
-

고객 CS 관리

-

고객 클레임/AS/문의 접수 · 분석 · 조치 관리

-
-
- - - -
-
+
+ { setPage(1); loadList(); loadDash(); }} + onReset={handleReset} + actions={ + <> + + + + + } + /> - {/* 요약 카드 */} - {dash && ( -
- - - - -
- )} - - {/* 필터바 */} -
- + 총 {total.toLocaleString()}건 · 합계 {sumAmount.toLocaleString()}원}> + - - - - - - - - - - - - - - - - - - setCustomerName(e.target.value)} className="h-7 w-[140px] text-xs" /> - - - setEventLocation(e.target.value)} className="h-7 w-[140px] text-xs" /> - - -
- setStartReceptionDate(e.target.value)} className="h-7 w-[130px] text-xs" /> - ~ - setEndReceptionDate(e.target.value)} className="h-7 w-[130px] text-xs" /> -
-
- - 총 {total}건 · 합계 {sumAmount.toLocaleString()}원 -
+ + + + + + + + + + + + + + + + + + setCustomerName(e.target.value)} /> + + + setEventLocation(e.target.value)} /> + + + + + - {/* 그리드 */} -
-
- - - - - - - - - - - - - - - - - - - - - {loading ? ( - - ) : list.length === 0 ? ( - - ) : ( - list.map((row) => ( - { setSelected(row); setDetailOpen(true); }}> - - - - - - - - - - - - - - - - )) - )} - -
- 0 && selectedIds.size === list.length} - onChange={(e) => setSelectedIds(e.target.checked ? new Set(list.map((r) => r.objid)) : new Set())} - /> - CS 번호관리유형제품구분접수일고객제목장소담당자조치일조치유형금액상태작업
불러오는 중...
데이터가 없습니다.
e.stopPropagation()}> - { - const next = new Set(selectedIds); - if (e.target.checked) next.add(row.objid); else next.delete(row.objid); - setSelectedIds(next); - }} /> - {row.mng_number}{row.mng_type_title || "-"}{row.product_division_title || "-"}{row.reception_date_title || "-"}{row.customer_name || "-"}{row.title}{row.event_location || "-"}{row.performer_title || "-"}{row.action_date_title || "-"}{row.measure_type_title || "-"} - {row.measure_amount && /^\d+$/.test(row.measure_amount) - ? Number(row.measure_amount).toLocaleString() - : row.measure_amount || "-"} - - {row.status_title ? {row.status_title} : "-"} - -
e.stopPropagation()}> - - -
-
-
- -
- {page} / {totalPages} 페이지 -
- - -
-
-
+ { + setSelectedIds(ids); + if (ids.length === 0) { setSelected(null); return; } + const last = ids[ids.length - 1]; + setSelected(rows.find((r) => r.objid === last) ?? null); + }} + selectedId={selected ? selected.objid : null} + onSelect={(id) => setSelected(rows.find((r) => r.objid === id) ?? null)} + onRowDoubleClick={(row) => { setSelected(row as CsItem); setDetailOpen(true); }} + summaryStats={summaryStats} + emptyMessage="조회된 고객 CS가 없습니다." + showColumnSettings + paginationStyle="range" + pageSizeOptions={[10, 15, 20, 50, 100]} + showChart + onRefresh={() => { loadList(); loadDash(); }} + onDownload={() => { + if (rows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; } + const exportRows = rows.map((r) => { + const out: Record = {}; + GRID_COLUMNS.forEach((c) => { out[c.label] = (r as any)[c.key] ?? ""; }); + return out; + }); + exportToExcel(exportRows, "고객CS관리.xlsx", "고객CS관리"); + }} + /> -
{label}
-
- {value.toLocaleString()}{suffix && {suffix}} -
-
- ); -} - -function FilterField({ label, children }: { label: string; children: React.ReactNode }) { - return ( -
- - {children} -
- ); -} - -function CodeSelect({ value, onChange, options, width = 120 }: { value: string; onChange: (v: string) => void; options: Option[]; width?: number }) { +function CodeSelect({ value, onChange, options }: { value: string; onChange: (v: string) => void; options: Option[] }) { return ( + - {/* 프로젝트(PROJECT_OBJID) — wace_plm ORDER_MGMT 참조 */} - setField("project_objid", e.target.value.replace(/[^\d]/g, ""))} - placeholder="프로젝트 OBJID (숫자)" - inputMode="numeric" - className="h-8 text-xs" - /> - - - setField("mng_type", v)} options={mngTypeOpts} /> - - - setField("product_division", v)} options={productDivOpts} /> - - - setField("customer_name", e.target.value)} className="h-8 text-xs" /> - - - setField("event_location", e.target.value)} className="h-8 text-xs" /> - - - setField("reception_date", e.target.value)} className="h-8 text-xs" /> - - - setField("action_date", e.target.value)} className="h-8 text-xs" /> - - - setField("performer", v)} options={performerOpts} /> - - - setField("status", v)} options={statusOpts} /> - - - setField("measure_type", v)} options={measureTypeOpts} /> - - - setField("measure_amount", e.target.value.replace(/[^\d]/g, ""))} className="h-8 text-xs" inputMode="numeric" /> + setField("project_objid", e.target.value.replace(/[^\d]/g, ""))} + placeholder="프로젝트 OBJID (숫자)" inputMode="numeric" className="h-8 text-xs" /> + setField("mng_type", v)} options={mngTypeOpts} /> + setField("product_division", v)} options={productDivOpts} /> + setField("customer_name", e.target.value)} className="h-8 text-xs" /> + setField("event_location", e.target.value)} className="h-8 text-xs" /> + setField("reception_date", e.target.value)} className="h-8 text-xs" /> + setField("action_date", e.target.value)} className="h-8 text-xs" /> + setField("performer", v)} options={performerOpts} /> + setField("status", v)} options={statusOpts} /> + setField("measure_type", v)} options={measureTypeOpts} /> + setField("measure_amount", e.target.value.replace(/[^\d]/g, ""))} className="h-8 text-xs" inputMode="numeric" />
setField("title", e.target.value)} className="h-8 text-xs" />
diff --git a/frontend/app/(main)/COMPANY_16/ecr/ecr/page.tsx b/frontend/app/(main)/COMPANY_16/ecr/ecr/page.tsx index ae39f333..22a6501d 100644 --- a/frontend/app/(main)/COMPANY_16/ecr/ecr/page.tsx +++ b/frontend/app/(main)/COMPANY_16/ecr/ecr/page.tsx @@ -8,11 +8,10 @@ * - 삭제는 status_cd='0000100'(작성중) 인 행에서만 허용 */ import { useCallback, useEffect, useMemo, useState } from "react"; -import { Plus, RefreshCw, Search, Trash2, Pencil, Eye, CheckCircle2 } from "lucide-react"; +import { Plus, Trash2, Pencil, Eye, CheckCircle2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Badge } from "@/components/ui/badge"; import { Dialog, DialogContent, @@ -25,27 +24,44 @@ import { Textarea } from "@/components/ui/textarea"; import { toast } from "sonner"; import { ecrMngApi, EcrItem } from "@/lib/api/ecrMng"; import { cn } from "@/lib/utils"; +import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; +import { PageHeader } from "@/components/common/PageHeader"; +import { CompactFilterBar, CompactFilterField } from "@/components/common/CompactFilterBar"; +import { exportToExcel } from "@/lib/utils/excelExport"; type Option = { value: string; label: string }; -// 상태 컬러 매핑 — wace_plm 의 comm_code(0000099 자식) 와 동일 -const STATUS_COLOR: Record = { - "0000100": "bg-blue-100 text-blue-700", // 작성중 - "0000101": "bg-amber-100 text-amber-700", // 결재중 - "0000102": "bg-emerald-100 text-emerald-700", // 적용완료 - "0000107": "bg-rose-100 text-rose-700", // 반려 -}; +// wace_plm ecrList.jsp + ilshin 운영 ecr_mng 컬럼 통합 그리드 +const GRID_COLUMNS: DataGridColumn[] = [ + { key: "ecr_no", label: "ECR NO", width: "w-[130px]", frozen: true }, + { key: "product_name", label: "기종(모델)명", width: "w-[160px]" }, + { key: "equip_name", label: "설비명", width: "w-[140px]" }, + { key: "part_no", label: "품번", width: "w-[140px]" }, + { key: "part_name", label: "품명", width: "w-[180px]" }, + { key: "project_no", label: "프로젝트번호", width: "w-[140px]" }, + { key: "customer_cd", label: "고객사", width: "w-[120px]" }, + { key: "design_dept", label: "설계부서", width: "w-[120px]", align: "center" }, + { key: "request_name", label: "설변요청", width: "w-[140px]" }, + { key: "title", label: "제목", width: "w-[280px]" }, + { key: "writer_name", label: "작성자", width: "w-[110px]", align: "center" }, + { key: "reg_date", label: "작성일", width: "w-[115px]", align: "center" }, + { key: "check_name", label: "조치자", width: "w-[110px]", align: "center" }, + { key: "check_date", label: "조치일", width: "w-[115px]", align: "center" }, + { key: "check_result", label: "조치결과", width: "w-[110px]", align: "center" }, + { key: "status_name", label: "상태", width: "w-[110px]", align: "center" }, +]; -const STATUS_DRAFT = "0000100"; // 작성중 +const STATUS_DRAFT = "0000100"; // 작성중 (삭제/수정 허용 상태) export default function EcrListPage() { // 목록 상태 const [list, setList] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); - const [pageSize] = useState(20); + const [pageSize] = useState(100); const [loading, setLoading] = useState(false); - const [selectedIds, setSelectedIds] = useState>(new Set()); + const [selectedIds, setSelectedIds] = useState([]); + const [selected, setSelected] = useState(null); // 필터 — wace_plm 기본값 = 전부 미선택(전체) const [year, setYear] = useState(""); @@ -65,7 +81,6 @@ export default function EcrListPage() { const [formOpen, setFormOpen] = useState(false); const [detailOpen, setDetailOpen] = useState(false); const [completeOpen, setCompleteOpen] = useState(false); - const [selected, setSelected] = useState(null); // 옵션 로드 (1회) useEffect(() => { @@ -101,9 +116,10 @@ export default function EcrListPage() { page, pageSize, }); - setList(res.list); + setList(res.list.map((r) => ({ ...r, id: r.objid } as any))); setTotal(res.pagination.total); - setSelectedIds(new Set()); + setSelectedIds([]); + setSelected(null); } catch (e: any) { toast.error(e?.message || "ECR 목록 조회 실패"); } finally { @@ -115,25 +131,22 @@ export default function EcrListPage() { loadList(); }, [loadList]); - const totalPages = Math.max(1, Math.ceil(total / pageSize)); - // wace_plm: sysYear-4 ~ sysYear (5 개년) const yearOptions = useMemo(() => { const cur = new Date().getFullYear(); return Array.from({ length: 5 }, (_, i) => String(cur - i)); }, []); - // 삭제 가능한(작성중) 항목 ID 셋 - const deletableIds = useMemo( - () => new Set(list.filter((r) => r.status_cd === STATUS_DRAFT).map((r) => r.objid)), - [list], - ); - const handleDelete = async () => { - if (selectedIds.size === 0) return toast.warning("삭제할 항목을 선택하세요."); - if (!confirm(`선택된 ${selectedIds.size}건을 삭제하시겠습니까?`)) return; + // 작성중 행만 삭제 가능 (wace_plm 규칙) + const targets = selectedIds.filter((id) => { + const row = list.find((r) => r.objid === id); + return row?.status_cd === STATUS_DRAFT; + }); + if (targets.length === 0) return toast.warning("작성중 상태의 행만 삭제할 수 있습니다."); + if (!confirm(`선택된 ${targets.length}건을 삭제하시겠습니까?`)) return; try { - await ecrMngApi.remove(Array.from(selectedIds)); + await ecrMngApi.remove(targets); toast.success("삭제되었습니다."); loadList(); } catch (e: any) { @@ -141,211 +154,137 @@ export default function EcrListPage() { } }; - return ( -
- {/* 헤더 */} -
-
-

ECR 관리

-

설계변경요청(Engineering Change Request) 등록 · 검토 · 조치완료

-
-
- - - -
-
+ const handleReset = () => { + setYear(""); setProductCode(""); setRequestCode(""); + setWriter(""); setStatusCode(""); + }; - {/* 필터바 — wace_plm 과 동일 5 필터 */} -
-
- + // status 컬러 뱃지를 적용한 row (status_name 셀을 컬러칩으로 렌더) + const rows = useMemo(() => list, [list]); + const gridColumns = useMemo( + () => GRID_COLUMNS.map((c) => c.key === "status_name" ? { ...c } : c), + [], + ); + + const isDraftSelected = selected?.status_cd === STATUS_DRAFT; + const isCompletedSelected = selected?.status_cd === "0000102"; + + return ( +
+ { setPage(1); loadList(); }} + onReset={handleReset} + actions={ + <> + + + + + + } + /> + + 총 {total.toLocaleString()}건}> + -
-
- + + -
-
- + + -
-
- + + -
-
- + + -
- - 총 {total}건 -
+ + - {/* 그리드 — wace_plm 컬럼 순서 동일 */} -
-
- - - - - - - - - - - - - - - - - - - - {loading ? ( - - ) : list.length === 0 ? ( - - ) : ( - list.map((row) => { - const isDraft = row.status_cd === STATUS_DRAFT; - return ( - { - setSelected(row); - // wace_plm: 작성중이면 등록/수정 폼, 그 외에는 상세 - if (isDraft) setFormOpen(true); - else setDetailOpen(true); - }} - > - - - - - - - - - - - - - - - ); - }) - )} - -
- 0 && selectedIds.size === deletableIds.size} - onChange={(e) => - setSelectedIds(e.target.checked ? new Set(deletableIds) : new Set()) - } - /> - ECR_NO기종(모델)명품번품명설변요청제목작성자작성일조치자조치일상태작업
불러오는 중...
조회된 데이터가 없습니다.
e.stopPropagation()}> - {isDraft && ( - { - const next = new Set(selectedIds); - if (e.target.checked) next.add(row.objid); else next.delete(row.objid); - setSelectedIds(next); - }} - /> - )} - {row.ecr_no}{row.product_name || "-"}{row.part_no || "-"}{row.part_name || "-"}{row.request_name || "-"}{row.title}{row.writer_name || row.writer || "-"}{row.reg_date}{row.check_name || "-"}{row.check_date || "-"} - - {row.status_name || row.status_cd} - - -
e.stopPropagation()}> - - {isDraft && ( - - )} - {row.status_cd !== "0000102" && ( - - )} -
-
-
- - {/* 페이지네이션 */} -
- {page} / {totalPages} 페이지 -
- - -
-
-
+ { + setSelectedIds(ids); + if (ids.length === 0) { setSelected(null); return; } + const last = ids[ids.length - 1]; + setSelected(rows.find((r) => r.objid === last) ?? null); + }} + selectedId={selected ? selected.objid : null} + onSelect={(id) => setSelected(rows.find((r) => r.objid === id) ?? null)} + onRowDoubleClick={(row) => { + setSelected(row as EcrItem); + if (row.status_cd === STATUS_DRAFT) setFormOpen(true); + else setDetailOpen(true); + }} + emptyMessage="조회된 ECR이 없습니다." + showColumnSettings + paginationStyle="range" + pageSizeOptions={[10, 15, 20, 50, 100]} + showChart + onRefresh={loadList} + onDownload={() => { + if (rows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; } + const exportRows = rows.map((r) => { + const out: Record = {}; + GRID_COLUMNS.forEach((c) => { out[c.label] = (r as any)[c.key] ?? ""; }); + return out; + }); + exportToExcel(exportRows, "ECR관리.xlsx", "ECR관리"); + }} + /> {/* 등록/수정 모달 */} ([]); + const [loading, setLoading] = useState(false); + const [selectedId, setSelectedId] = useState(null); + + const [search, setSearch] = useState({ + project_no: "", + partner_objid: "", + inspector_id: "", + }); + + const fetchList = useCallback(async () => { + if (!user) return; + setLoading(true); + try { + const params: Record = {}; + Object.entries(search).forEach(([k, v]) => { if (v) params[k] = v; }); + const res = await qualityApi.incomingMgmt(params); + setRows(res.list.map((r) => ({ ...r, id: r.objid } as any))); + setSelectedId(null); + } catch { + toast.error("수입검사 관리 목록 조회 실패"); + } finally { + setLoading(false); + } + }, [user, search]); + + useEffect(() => { fetchList(); }, [fetchList]); + + const handleReset = () => setSearch({ project_no: "", partner_objid: "", inspector_id: "" }); + + return ( +
+ + 수입검사 진행등록 + + } + /> + 총 {rows.length.toLocaleString()}건}> + + setSearch({ ...search, project_no: e.target.value })} /> + + + setSearch({ ...search, partner_objid: e.target.value })} /> + + + setSearch({ ...search, inspector_id: e.target.value })} /> + + + setSelectedId(ids.length ? ids[ids.length - 1] : null)} + selectedId={selectedId} + onSelect={setSelectedId} + emptyMessage="조회된 수입검사 진행 내역이 없습니다." + showColumnSettings + paginationStyle="range" + pageSizeOptions={[10, 15, 20, 50, 100]} + showChart + onRefresh={fetchList} + onDownload={() => { + if (rows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; } + const exportRows = rows.map((r) => { + const out: Record = {}; + GRID_COLUMNS.forEach((c) => { out[c.label] = (r as any)[c.key] ?? ""; }); + return out; + }); + exportToExcel(exportRows, "수입검사관리.xlsx", "수입검사관리"); + }} + /> +
+ ); +} diff --git a/frontend/app/(main)/COMPANY_16/quality/incoming-request/page.tsx b/frontend/app/(main)/COMPANY_16/quality/incoming-request/page.tsx new file mode 100644 index 00000000..34dab81b --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/quality/incoming-request/page.tsx @@ -0,0 +1,122 @@ +"use client"; + +/** + * 수입검사 요청 — wace_plm incomingInspectionList.jsp + QualityController.getIncomingInspectionList 이식. + * + * 그리드 컬럼은 wace_plm 원본의 12개를 1:1로 따른다. + * 일부 백엔드 테이블 부재로 데이터가 비어 있을 수 있다(qualityRoutes.ts 참조). + */ +import React, { useCallback, useEffect, useState } from "react"; +import { Plus } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { toast } from "sonner"; +import { useAuth } from "@/hooks/useAuth"; +import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; +import { PageHeader } from "@/components/common/PageHeader"; +import { CompactFilterBar, CompactFilterField } from "@/components/common/CompactFilterBar"; +import { qualityApi, IncomingRequestRow } from "@/lib/api/quality"; +import { exportToExcel } from "@/lib/utils/excelExport"; + +const GRID_COLUMNS: DataGridColumn[] = [ + { key: "purchase_order_no", label: "발주서 No", width: "w-[150px]", frozen: true }, + { key: "proposal_no", label: "품의서 No", width: "w-[140px]" }, + { key: "project_no", label: "프로젝트번호", width: "w-[150px]" }, + { key: "product_name", label: "제품구분", width: "w-[110px]", align: "center" }, + { key: "part_no", label: "품번", width: "w-[140px]" }, + { key: "part_name", label: "품명", width: "w-[200px]" }, + { key: "partner_name", label: "공급업체", width: "w-[160px]" }, + { key: "delivery_status", label: "입고결과", width: "w-[110px]", align: "center" }, + { key: "request_date", label: "요청일", width: "w-[115px]", align: "center" }, + { key: "request_user_name", label: "요청자", width: "w-[110px]", align: "center" }, + { key: "inspection_yn", label: "검사여부", width: "w-[100px]", align: "center" }, + { key: "request_status", label: "요청현황", width: "w-[110px]", align: "center" }, +]; + +export default function IncomingRequestPage() { + const { user } = useAuth(); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + const [selectedId, setSelectedId] = useState(null); + + const [search, setSearch] = useState({ + project_no: "", + partner_objid: "", + request_user_id: "", + }); + + const fetchList = useCallback(async () => { + if (!user) return; + setLoading(true); + try { + const params: Record = {}; + Object.entries(search).forEach(([k, v]) => { if (v) params[k] = v; }); + const res = await qualityApi.incomingRequest(params); + setRows(res.list.map((r) => ({ ...r, id: r.objid } as any))); + setSelectedId(null); + } catch (e: any) { + toast.error("수입검사 요청 목록 조회 실패"); + } finally { + setLoading(false); + } + }, [user, search]); + + useEffect(() => { fetchList(); }, [fetchList]); + + const handleReset = () => setSearch({ project_no: "", partner_objid: "", request_user_id: "" }); + + return ( +
+ + 수입검사 요청 + + } + /> + 총 {rows.length.toLocaleString()}건}> + + setSearch({ ...search, project_no: e.target.value })} /> + + + setSearch({ ...search, partner_objid: e.target.value })} /> + + + setSearch({ ...search, request_user_id: e.target.value })} /> + + + setSelectedId(ids.length ? ids[ids.length - 1] : null)} + selectedId={selectedId} + onSelect={setSelectedId} + emptyMessage="조회된 수입검사 요청이 없습니다." + showColumnSettings + paginationStyle="range" + pageSizeOptions={[10, 15, 20, 50, 100]} + showChart + onRefresh={fetchList} + onDownload={() => { + if (rows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; } + const exportRows = rows.map((r) => { + const out: Record = {}; + GRID_COLUMNS.forEach((c) => { out[c.label] = (r as any)[c.key] ?? ""; }); + return out; + }); + exportToExcel(exportRows, "수입검사요청.xlsx", "수입검사요청"); + }} + /> +
+ ); +} diff --git a/frontend/app/(main)/COMPANY_16/quality/process-inspection/page.tsx b/frontend/app/(main)/COMPANY_16/quality/process-inspection/page.tsx new file mode 100644 index 00000000..575e19b8 --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/quality/process-inspection/page.tsx @@ -0,0 +1,135 @@ +"use client"; + +/** + * 공정검사 관리 (IPQC) — wace_plm processInspectionList.jsp 이식. + * + * 마스터별 검사 N건을 SUM 으로 집계해 1행 표시 (검사수량/불량수량 합계). + */ +import React, { useCallback, useEffect, useState } from "react"; +import { Plus } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { toast } from "sonner"; +import { useAuth } from "@/hooks/useAuth"; +import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; +import { PageHeader } from "@/components/common/PageHeader"; +import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar"; +import { qualityApi, ProcessInspectionRow } from "@/lib/api/quality"; +import { exportToExcel } from "@/lib/utils/excelExport"; + +const GRID_COLUMNS: DataGridColumn[] = [ + { key: "inspection_date", label: "검사일", width: "w-[120px]", align: "center", frozen: true }, + { key: "inspector_name", label: "검사자", width: "w-[110px]", align: "center" }, + { key: "project_no", label: "프로젝트번호", width: "w-[150px]" }, + { key: "product_name", label: "제품구분", width: "w-[110px]", align: "center" }, + { key: "part_no", label: "품번", width: "w-[140px]" }, + { key: "part_name", label: "품명", width: "w-[200px]" }, + { key: "inspection_qty", label: "검사수량 합계", width: "w-[140px]", align: "right", formatNumber: true }, + { key: "defect_qty", label: "불량수량 합계", width: "w-[140px]", align: "right", formatNumber: true }, + { key: "work_env_status", label: "작업환경상태", width: "w-[120px]", align: "center" }, + { key: "measuring_device", label: "측정기", width: "w-[100px]", align: "center" }, + { key: "inspection_result", label: "검사결과", width: "w-[100px]", align: "center" }, + { key: "file_count", label: "첨부파일", width: "w-[100px]", align: "center", renderType: "clip" }, +]; + +export default function ProcessInspectionPage() { + const { user } = useAuth(); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + const [selectedId, setSelectedId] = useState(null); + + const [search, setSearch] = useState({ + project_no: "", + productType: "", + part_name: "", + inspector_id: "", + from: "", + to: "", + }); + + const fetchList = useCallback(async () => { + if (!user) return; + setLoading(true); + try { + const params: Record = {}; + Object.entries(search).forEach(([k, v]) => { if (v) params[k] = v; }); + const res = await qualityApi.processInspection(params); + setRows(res.list.map((r) => ({ ...r, id: r.objid } as any))); + setSelectedId(null); + } catch { + toast.error("공정검사 목록 조회 실패"); + } finally { + setLoading(false); + } + }, [user, search]); + + useEffect(() => { fetchList(); }, [fetchList]); + + const handleReset = () => + setSearch({ project_no: "", productType: "", part_name: "", inspector_id: "", from: "", to: "" }); + + return ( +
+ + 공정검사 등록 + + } + /> + 총 {rows.length.toLocaleString()}건}> + + setSearch({ ...search, project_no: e.target.value })} /> + + + setSearch({ ...search, productType: e.target.value })} /> + + + setSearch({ ...search, part_name: e.target.value })} /> + + + setSearch({ ...search, inspector_id: e.target.value })} /> + + + setSearch({ ...search, from: v })} + to={search.to} setTo={(v) => setSearch({ ...search, to: v })} + /> + + + setSelectedId(ids.length ? ids[ids.length - 1] : null)} + selectedId={selectedId} + onSelect={setSelectedId} + emptyMessage="조회된 공정검사 내역이 없습니다." + showColumnSettings + paginationStyle="range" + pageSizeOptions={[10, 15, 20, 50, 100]} + showChart + onRefresh={fetchList} + onDownload={() => { + if (rows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; } + const exportRows = rows.map((r) => { + const out: Record = {}; + GRID_COLUMNS.forEach((c) => { out[c.label] = (r as any)[c.key] ?? ""; }); + return out; + }); + exportToExcel(exportRows, "공정검사관리.xlsx", "공정검사관리"); + }} + /> +
+ ); +} diff --git a/frontend/app/(main)/COMPANY_16/quality/semi-product-inspection/page.tsx b/frontend/app/(main)/COMPANY_16/quality/semi-product-inspection/page.tsx new file mode 100644 index 00000000..5ed9ac32 --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/quality/semi-product-inspection/page.tsx @@ -0,0 +1,136 @@ +"use client"; + +/** + * 반제품검사 관리 — wace_plm semiProductInspectionList.jsp 이식. + * + * 입고/양품/불량/재생/최종양품 수량과 불량률을 1행에 집계한다. + */ +import React, { useCallback, useEffect, useState } from "react"; +import { Plus } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { toast } from "sonner"; +import { useAuth } from "@/hooks/useAuth"; +import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; +import { PageHeader } from "@/components/common/PageHeader"; +import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar"; +import { qualityApi, SemiProductInspectionRow } from "@/lib/api/quality"; +import { exportToExcel } from "@/lib/utils/excelExport"; + +const GRID_COLUMNS: DataGridColumn[] = [ + { key: "inspection_date", label: "검사일", width: "w-[120px]", align: "center", frozen: true }, + { key: "writer_name", label: "검사자", width: "w-[110px]", align: "center" }, + { key: "model_name", label: "품명(모델명)", width: "w-[180px]" }, + { key: "product_type", label: "제품구분", width: "w-[110px]", align: "center" }, + { key: "work_order_no", label: "작업지시번호", width: "w-[150px]" }, + { key: "part_no", label: "부품품번", width: "w-[140px]" }, + { key: "part_name", label: "부품명", width: "w-[180px]" }, + { key: "receipt_qty", label: "입고수량", width: "w-[110px]", align: "right", formatNumber: true }, + { key: "good_qty", label: "양품수량", width: "w-[110px]", align: "right", formatNumber: true }, + { key: "defective_qty", label: "불량수량", width: "w-[110px]", align: "right", formatNumber: true }, + { key: "defect_rate", label: "불량률(%)", width: "w-[100px]", align: "right" }, + { key: "regeneration_qty",label: "재생수량", width: "w-[110px]", align: "right", formatNumber: true }, + { key: "final_good_qty", label: "최종양품수량", width: "w-[130px]", align: "right", formatNumber: true }, +]; + +export default function SemiProductInspectionPage() { + const { user } = useAuth(); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + const [selectedId, setSelectedId] = useState(null); + + const [search, setSearch] = useState({ + model_name: "", + part_no: "", + part_name: "", + writer: "", + from: "", + to: "", + }); + + const fetchList = useCallback(async () => { + if (!user) return; + setLoading(true); + try { + const params: Record = {}; + Object.entries(search).forEach(([k, v]) => { if (v) params[k] = v; }); + const res = await qualityApi.semiProductInspection(params); + setRows(res.list.map((r) => ({ ...r, id: r.objid } as any))); + setSelectedId(null); + } catch { + toast.error("반제품검사 목록 조회 실패"); + } finally { + setLoading(false); + } + }, [user, search]); + + useEffect(() => { fetchList(); }, [fetchList]); + + const handleReset = () => + setSearch({ model_name: "", part_no: "", part_name: "", writer: "", from: "", to: "" }); + + return ( +
+ + 반제품검사 등록 + + } + /> + 총 {rows.length.toLocaleString()}건}> + + setSearch({ ...search, model_name: e.target.value })} /> + + + setSearch({ ...search, part_no: e.target.value })} /> + + + setSearch({ ...search, part_name: e.target.value })} /> + + + setSearch({ ...search, writer: e.target.value })} /> + + + setSearch({ ...search, from: v })} + to={search.to} setTo={(v) => setSearch({ ...search, to: v })} + /> + + + setSelectedId(ids.length ? ids[ids.length - 1] : null)} + selectedId={selectedId} + onSelect={setSelectedId} + emptyMessage="조회된 반제품검사 내역이 없습니다." + showColumnSettings + paginationStyle="range" + pageSizeOptions={[10, 15, 20, 50, 100]} + showChart + onRefresh={fetchList} + onDownload={() => { + if (rows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; } + const exportRows = rows.map((r) => { + const out: Record = {}; + GRID_COLUMNS.forEach((c) => { out[c.label] = (r as any)[c.key] ?? ""; }); + return out; + }); + exportToExcel(exportRows, "반제품검사관리.xlsx", "반제품검사관리"); + }} + /> +
+ ); +} diff --git a/frontend/components/layout/AdminPageRenderer.tsx b/frontend/components/layout/AdminPageRenderer.tsx index 3418f123..0fd0b1d4 100644 --- a/frontend/components/layout/AdminPageRenderer.tsx +++ b/frontend/components/layout/AdminPageRenderer.tsx @@ -141,6 +141,10 @@ const ADMIN_PAGE_REGISTRY: Record> = { "/COMPANY_16/quality/inspection": dynamic(() => import("@/app/(main)/COMPANY_16/quality/inspection/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/quality/inspection-result": dynamic(() => import("@/app/(main)/COMPANY_16/quality/inspection-result/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/quality/item-inspection": dynamic(() => import("@/app/(main)/COMPANY_16/quality/item-inspection/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_16/quality/incoming-request": dynamic(() => import("@/app/(main)/COMPANY_16/quality/incoming-request/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_16/quality/incoming-mgmt": dynamic(() => import("@/app/(main)/COMPANY_16/quality/incoming-mgmt/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_16/quality/process-inspection": dynamic(() => import("@/app/(main)/COMPANY_16/quality/process-inspection/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_16/quality/semi-product-inspection": dynamic(() => import("@/app/(main)/COMPANY_16/quality/semi-product-inspection/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/mold/info": dynamic(() => import("@/app/(main)/COMPANY_16/mold/info/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/design/project": dynamic(() => import("@/app/(main)/COMPANY_16/design/project/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/design/change-management": dynamic(() => import("@/app/(main)/COMPANY_16/design/change-management/page"), { ssr: false, loading: LoadingFallback }), diff --git a/frontend/lib/api/ecrMng.ts b/frontend/lib/api/ecrMng.ts index a2cea561..2de94e00 100644 --- a/frontend/lib/api/ecrMng.ts +++ b/frontend/lib/api/ecrMng.ts @@ -26,6 +26,14 @@ export interface EcrItem { after_contents?: string; reg_date?: string; check_date?: string; + /* ilshin 운영 ecr_mng 동기화로 추가된 컬럼 */ + project_no?: string; + customer_cd?: string; + equip_name?: string; + design_dept?: string; + unit_cd?: string; + memo?: string; + check_result?: string; } export interface EcrListResponse { diff --git a/frontend/lib/api/quality.ts b/frontend/lib/api/quality.ts new file mode 100644 index 00000000..8baeea04 --- /dev/null +++ b/frontend/lib/api/quality.ts @@ -0,0 +1,80 @@ +/** + * 품질관리 API 클라이언트 — 4개 메뉴 list 엔드포인트. + * 백엔드: backend-node/src/routes/qualityRoutes.ts + */ +import { apiClient } from "./client"; + +export interface IncomingRequestRow { + objid: string; + purchase_order_no: string; + proposal_no?: string; + project_no?: string; + product_name?: string; + part_no?: string; + part_name?: string; + partner_name?: string; + delivery_status?: string; + request_date?: string; + request_user_name?: string; + inspection_yn?: string; + request_status?: string; +} + +export interface IncomingMgmtRow extends IncomingRequestRow { + inspector_name?: string; + inspection_date?: string; + total_qty?: number; + good_qty?: number; + bad_qty?: number; + inspection_result?: string; +} + +export interface ProcessInspectionRow { + objid: string; + inspection_date?: string; + inspector_name?: string; + project_no?: string; + product_name?: string; + part_no?: string; + part_name?: string; + inspection_qty?: number; + defect_qty?: number; + work_env_status?: string; + measuring_device?: string; + inspection_result?: string; + file_count?: number; +} + +export interface SemiProductInspectionRow { + objid: string; + inspection_date?: string; + writer_name?: string; + model_name?: string; + product_type?: string; + work_order_no?: string; + part_no?: string; + part_name?: string; + receipt_qty?: number; + good_qty?: number; + defective_qty?: number; + defect_rate?: number; + regeneration_qty?: number; + final_good_qty?: number; +} + +interface ListResp { + success: boolean; + list: T[]; + pagination: { page: number; pageSize: number; total: number; totalPages: number }; +} + +export const qualityApi = { + incomingRequest: async (params: Record = {}): Promise> => + (await apiClient.get("/quality/incoming-request", { params })).data, + incomingMgmt: async (params: Record = {}): Promise> => + (await apiClient.get("/quality/incoming-mgmt", { params })).data, + processInspection: async (params: Record = {}): Promise> => + (await apiClient.get("/quality/process-inspection", { params })).data, + semiProductInspection: async (params: Record = {}): Promise> => + (await apiClient.get("/quality/semi-product-inspection", { params })).data, +};