품질관리/고객CS/ECR — wace_plm 1:1 이식 + 견적관리 그리드 패턴 통일
Build and Push Images / build-and-push (push) Has been cancelled
Build and Push Images / build-and-push (push) Has been cancelled
신규 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건이므로 스키마만 정합.
This commit is contained in:
@@ -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); // 공정정보관리
|
||||
|
||||
@@ -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<string, string>;
|
||||
|
||||
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<string, string>;
|
||||
|
||||
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<string, string>;
|
||||
|
||||
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<string, string>;
|
||||
|
||||
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;
|
||||
@@ -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
|
||||
`;
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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<CsCategories | null>(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<Set<string>>(new Set());
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [selected, setSelected] = useState<CsItem | null>(null);
|
||||
|
||||
// 대시보드
|
||||
const [dash, setDash] = useState<Awaited<ReturnType<typeof customerCsApi.dashboard>> | null>(null);
|
||||
@@ -60,7 +78,6 @@ export default function CustomerCsPage() {
|
||||
// 모달
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [selected, setSelected] = useState<CsItem | null>(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 (
|
||||
<div className="flex h-full min-h-0 flex-col gap-3 p-4">
|
||||
{/* 헤더 + 액션 */}
|
||||
<div className="flex flex-shrink-0 items-end justify-between gap-3 border-b pb-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold tracking-tight">고객 CS 관리</h1>
|
||||
<p className="text-xs text-muted-foreground">고객 클레임/AS/문의 접수 · 분석 · 조치 관리</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button variant="outline" size="sm" className="h-8 gap-1 text-xs" onClick={() => { loadList(); loadDash(); }} disabled={loading}>
|
||||
<RefreshCw className={cn("h-3.5 w-3.5", loading && "animate-spin")} /> 새로고침
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" className="h-8 gap-1 text-xs" onClick={handleDelete} disabled={selectedIds.size === 0}>
|
||||
<Trash2 className="h-3.5 w-3.5" /> 삭제 ({selectedIds.size})
|
||||
</Button>
|
||||
<Button size="sm" className="h-8 gap-1 text-xs" onClick={() => { setSelected(null); setFormOpen(true); }}>
|
||||
<Plus className="h-3.5 w-3.5" /> 등록
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
|
||||
<PageHeader
|
||||
loading={loading}
|
||||
onSearch={() => { setPage(1); loadList(); loadDash(); }}
|
||||
onReset={handleReset}
|
||||
actions={
|
||||
<>
|
||||
<Button size="sm" variant="destructive" className="h-8 gap-1 text-xs"
|
||||
onClick={handleDelete} disabled={selectedIds.length === 0}>
|
||||
<Trash2 className="h-3.5 w-3.5" />삭제 ({selectedIds.length})
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-8 gap-1 text-xs"
|
||||
onClick={() => { if (selected) { setDetailOpen(true); } else { toast.warning("상세를 볼 항목을 선택하세요."); } }}
|
||||
disabled={!selected}>
|
||||
<Eye className="h-3.5 w-3.5" />상세
|
||||
</Button>
|
||||
<Button size="sm" className="h-8 gap-1 text-xs"
|
||||
onClick={() => { setFormOpen(true); }}>
|
||||
{selected ? <Pencil className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
||||
{selected ? "수정" : "등록"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 요약 카드 */}
|
||||
{dash && (
|
||||
<div className="grid flex-shrink-0 grid-cols-2 gap-2 md:grid-cols-4">
|
||||
<SummaryCard label="총 건수" value={dash.summary.total_count} suffix="건" />
|
||||
<SummaryCard label="총 조치 금액" value={dash.summary.sum_amount} suffix="원" />
|
||||
<SummaryCard label="유상 합계" value={dash.summary.sum_paid_amount} suffix="원" tone="emerald" />
|
||||
<SummaryCard label="무상 합계" value={dash.summary.sum_free_amount} suffix="원" tone="amber" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 필터바 */}
|
||||
<div className="flex flex-shrink-0 flex-wrap items-end gap-2 rounded-md border bg-muted/20 p-2">
|
||||
<FilterField label="연도">
|
||||
<CompactFilterBar totalText={<>총 {total.toLocaleString()}건 · 합계 {sumAmount.toLocaleString()}원</>}>
|
||||
<CompactFilterField label="연도" width={100}>
|
||||
<Select value={searchYear || "all"} onValueChange={(v) => setSearchYear(v === "all" ? "" : v)}>
|
||||
<SelectTrigger className="h-7 w-[100px] text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectTrigger className="h-7 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
{yearOptions.map((y) => <SelectItem key={y} value={y}>{y}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FilterField>
|
||||
<FilterField label="관리유형">
|
||||
<CodeSelect value={mngType} onChange={setMngType} options={mngTypeOpts} width={140} />
|
||||
</FilterField>
|
||||
<FilterField label="제품구분">
|
||||
<CodeSelect value={productDivision} onChange={setProductDivision} options={productDivOpts} width={120} />
|
||||
</FilterField>
|
||||
<FilterField label="상태">
|
||||
<CodeSelect value={statusF} onChange={setStatusF} options={statusOpts} width={120} />
|
||||
</FilterField>
|
||||
<FilterField label="담당자">
|
||||
<CodeSelect value={performer} onChange={setPerformer} options={performerOpts} width={120} />
|
||||
</FilterField>
|
||||
<FilterField label="조치유형">
|
||||
<CodeSelect value={measureType} onChange={setMeasureType} options={measureTypeOpts} width={100} />
|
||||
</FilterField>
|
||||
<FilterField label="고객명">
|
||||
<Input value={customerName} onChange={(e) => setCustomerName(e.target.value)} className="h-7 w-[140px] text-xs" />
|
||||
</FilterField>
|
||||
<FilterField label="장소">
|
||||
<Input value={eventLocation} onChange={(e) => setEventLocation(e.target.value)} className="h-7 w-[140px] text-xs" />
|
||||
</FilterField>
|
||||
<FilterField label="접수일">
|
||||
<div className="flex items-center gap-1">
|
||||
<Input type="date" value={startReceptionDate} onChange={(e) => setStartReceptionDate(e.target.value)} className="h-7 w-[130px] text-xs" />
|
||||
<span className="text-xs">~</span>
|
||||
<Input type="date" value={endReceptionDate} onChange={(e) => setEndReceptionDate(e.target.value)} className="h-7 w-[130px] text-xs" />
|
||||
</div>
|
||||
</FilterField>
|
||||
<Button size="sm" variant="outline" className="h-7 gap-1 px-2 text-xs" onClick={() => { setPage(1); loadList(); loadDash(); }}>
|
||||
<Search className="h-3 w-3" /> 검색
|
||||
</Button>
|
||||
<span className="ml-auto text-[11px] text-muted-foreground">총 {total}건 · 합계 {sumAmount.toLocaleString()}원</span>
|
||||
</div>
|
||||
</CompactFilterField>
|
||||
<CompactFilterField label="관리유형" width={140}>
|
||||
<CodeSelect value={mngType} onChange={setMngType} options={mngTypeOpts} />
|
||||
</CompactFilterField>
|
||||
<CompactFilterField label="제품구분" width={120}>
|
||||
<CodeSelect value={productDivision} onChange={setProductDivision} options={productDivOpts} />
|
||||
</CompactFilterField>
|
||||
<CompactFilterField label="상태" width={120}>
|
||||
<CodeSelect value={statusF} onChange={setStatusF} options={statusOpts} />
|
||||
</CompactFilterField>
|
||||
<CompactFilterField label="담당자" width={120}>
|
||||
<CodeSelect value={performer} onChange={setPerformer} options={performerOpts} />
|
||||
</CompactFilterField>
|
||||
<CompactFilterField label="조치유형" width={120}>
|
||||
<CodeSelect value={measureType} onChange={setMeasureType} options={measureTypeOpts} />
|
||||
</CompactFilterField>
|
||||
<CompactFilterField label="고객명" width={150}>
|
||||
<Input value={customerName} onChange={(e) => setCustomerName(e.target.value)} />
|
||||
</CompactFilterField>
|
||||
<CompactFilterField label="장소" width={150}>
|
||||
<Input value={eventLocation} onChange={(e) => setEventLocation(e.target.value)} />
|
||||
</CompactFilterField>
|
||||
<CompactFilterField label="접수일" width={280}>
|
||||
<CompactDateRange
|
||||
from={startReceptionDate} setFrom={setStartReceptionDate}
|
||||
to={endReceptionDate} setTo={setEndReceptionDate}
|
||||
/>
|
||||
</CompactFilterField>
|
||||
</CompactFilterBar>
|
||||
|
||||
{/* 그리드 */}
|
||||
<div className="flex flex-1 min-h-0 flex-col rounded-md border bg-card">
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 z-10 bg-muted/60 backdrop-blur">
|
||||
<tr className="text-left">
|
||||
<th className="w-8 px-2 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={list.length > 0 && selectedIds.size === list.length}
|
||||
onChange={(e) => setSelectedIds(e.target.checked ? new Set(list.map((r) => r.objid)) : new Set())}
|
||||
/>
|
||||
</th>
|
||||
<th className="px-2 py-2">CS 번호</th>
|
||||
<th className="px-2 py-2">관리유형</th>
|
||||
<th className="px-2 py-2">제품구분</th>
|
||||
<th className="px-2 py-2">접수일</th>
|
||||
<th className="px-2 py-2">고객</th>
|
||||
<th className="px-2 py-2">제목</th>
|
||||
<th className="px-2 py-2">장소</th>
|
||||
<th className="px-2 py-2">담당자</th>
|
||||
<th className="px-2 py-2">조치일</th>
|
||||
<th className="px-2 py-2">조치유형</th>
|
||||
<th className="px-2 py-2 text-right">금액</th>
|
||||
<th className="px-2 py-2">상태</th>
|
||||
<th className="w-16 px-2 py-2 text-center">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={14} className="py-12 text-center text-muted-foreground">불러오는 중...</td></tr>
|
||||
) : list.length === 0 ? (
|
||||
<tr><td colSpan={14} className="py-12 text-center text-muted-foreground">데이터가 없습니다.</td></tr>
|
||||
) : (
|
||||
list.map((row) => (
|
||||
<tr key={row.objid} className="cursor-pointer border-t hover:bg-muted/40"
|
||||
onClick={() => { setSelected(row); setDetailOpen(true); }}>
|
||||
<td className="px-2 py-1.5" onClick={(e) => e.stopPropagation()}>
|
||||
<input type="checkbox" checked={selectedIds.has(row.objid)}
|
||||
onChange={(e) => {
|
||||
const next = new Set(selectedIds);
|
||||
if (e.target.checked) next.add(row.objid); else next.delete(row.objid);
|
||||
setSelectedIds(next);
|
||||
}} />
|
||||
</td>
|
||||
<td className="px-2 py-1.5 font-mono">{row.mng_number}</td>
|
||||
<td className="px-2 py-1.5">{row.mng_type_title || "-"}</td>
|
||||
<td className="px-2 py-1.5">{row.product_division_title || "-"}</td>
|
||||
<td className="px-2 py-1.5">{row.reception_date_title || "-"}</td>
|
||||
<td className="px-2 py-1.5">{row.customer_name || "-"}</td>
|
||||
<td className="px-2 py-1.5 max-w-[240px] truncate">{row.title}</td>
|
||||
<td className="px-2 py-1.5 max-w-[140px] truncate">{row.event_location || "-"}</td>
|
||||
<td className="px-2 py-1.5">{row.performer_title || "-"}</td>
|
||||
<td className="px-2 py-1.5">{row.action_date_title || "-"}</td>
|
||||
<td className="px-2 py-1.5">{row.measure_type_title || "-"}</td>
|
||||
<td className="px-2 py-1.5 text-right font-mono">
|
||||
{row.measure_amount && /^\d+$/.test(row.measure_amount)
|
||||
? Number(row.measure_amount).toLocaleString()
|
||||
: row.measure_amount || "-"}
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
{row.status_title ? <Badge variant="secondary">{row.status_title}</Badge> : "-"}
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<div className="flex items-center justify-center gap-0.5" onClick={(e) => e.stopPropagation()}>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" title="상세"
|
||||
onClick={() => { setSelected(row); setDetailOpen(true); }}>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" title="수정"
|
||||
onClick={() => { setSelected(row); setFormOpen(true); }}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 items-center justify-between gap-2 border-t px-3 py-1.5 text-xs">
|
||||
<span className="text-muted-foreground">{page} / {totalPages} 페이지</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="outline" size="sm" className="h-6 px-2 text-[11px]" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>이전</Button>
|
||||
<Button variant="outline" size="sm" className="h-6 px-2 text-[11px]" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>다음</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DataGrid
|
||||
gridId="customer-cs"
|
||||
columns={GRID_COLUMNS}
|
||||
data={rows}
|
||||
loading={loading}
|
||||
showCheckbox
|
||||
checkedIds={selectedIds}
|
||||
onCheckedChange={(ids) => {
|
||||
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<string, any> = {};
|
||||
GRID_COLUMNS.forEach((c) => { out[c.label] = (r as any)[c.key] ?? ""; });
|
||||
return out;
|
||||
});
|
||||
exportToExcel(exportRows, "고객CS관리.xlsx", "고객CS관리");
|
||||
}}
|
||||
/>
|
||||
|
||||
<CsFormModal
|
||||
open={formOpen}
|
||||
@@ -328,31 +297,10 @@ export default function CustomerCsPage() {
|
||||
}
|
||||
|
||||
// ── 보조 컴포넌트 ────────────────────────────────────────────
|
||||
function SummaryCard({ label, value, suffix, tone }: { label: string; value: number; suffix?: string; tone?: "emerald" | "amber" }) {
|
||||
const toneClass = tone === "emerald" ? "text-emerald-600" : tone === "amber" ? "text-amber-600" : "text-foreground";
|
||||
return (
|
||||
<div className="rounded-md border bg-card p-2.5">
|
||||
<div className="text-[11px] text-muted-foreground">{label}</div>
|
||||
<div className={cn("mt-0.5 text-lg font-bold", toneClass)}>
|
||||
{value.toLocaleString()}{suffix && <span className="ml-1 text-xs font-normal text-muted-foreground">{suffix}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterField({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px]">{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Select value={value || "all"} onValueChange={(v) => onChange(v === "all" ? "" : v)}>
|
||||
<SelectTrigger className="h-7 text-xs" style={{ width }}><SelectValue placeholder="전체" /></SelectTrigger>
|
||||
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="전체" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
{options.map((o) => (<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>))}
|
||||
@@ -372,14 +320,12 @@ function CsFormModal({
|
||||
mngTypeOpts: Option[]; productDivOpts: Option[]; performerOpts: Option[]; measureTypeOpts: Option[]; statusOpts: Option[];
|
||||
}) {
|
||||
const isEdit = !!editing?.objid;
|
||||
|
||||
// 폼 상태 — wace_plm customerMngFormPopUp.jsp 와 동일 필드 구성
|
||||
const [form, setForm] = useState({
|
||||
title: "", customer_name: "", event_location: "",
|
||||
mng_type: "", product_division: "", performer: "", measure_type: "", status: "",
|
||||
reception_date: "", action_date: "",
|
||||
analysis: "", measure: "", measure_amount: "",
|
||||
project_objid: "", // 프로젝트(PROJECT_OBJID) — wace_plm 의 ORDER_MGMT 참조
|
||||
project_objid: "",
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
@@ -399,7 +345,7 @@ function CsFormModal({
|
||||
analysis: editing?.analysis || "",
|
||||
measure: editing?.measure || "",
|
||||
measure_amount: editing?.measure_amount || "",
|
||||
project_objid: editing?.project_objid || "",
|
||||
project_objid: (editing as any)?.project_objid || "",
|
||||
});
|
||||
}
|
||||
}, [open, editing, statusOpts]);
|
||||
@@ -410,10 +356,7 @@ function CsFormModal({
|
||||
if (!form.title.trim()) return toast.warning("제목을 입력하세요.");
|
||||
setSaving(true);
|
||||
try {
|
||||
await customerCsApi.merge({
|
||||
objId: editing?.objid,
|
||||
...form,
|
||||
});
|
||||
await customerCsApi.merge({ objId: editing?.objid, ...form });
|
||||
toast.success("저장되었습니다.");
|
||||
onSaved();
|
||||
} catch (e: any) {
|
||||
@@ -430,55 +373,23 @@ function CsFormModal({
|
||||
<DialogTitle>{isEdit ? `CS 수정 — ${editing?.mng_number}` : "CS 등록"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||
{/* CS 번호 (자동채번) — wace_plm MNG_NUMBER */}
|
||||
<Field label="CS 번호">
|
||||
<Input
|
||||
value={editing?.mng_number || ""}
|
||||
placeholder={isEdit ? "" : "저장 시 자동 채번됩니다 (YYYY-NNN)"}
|
||||
disabled
|
||||
className="h-8 bg-muted text-xs"
|
||||
/>
|
||||
<Input value={editing?.mng_number || ""} placeholder={isEdit ? "" : "저장 시 자동 채번됩니다 (YYYY-NNN)"} disabled className="h-8 bg-muted text-xs" />
|
||||
</Field>
|
||||
{/* 프로젝트(PROJECT_OBJID) — wace_plm ORDER_MGMT 참조 */}
|
||||
<Field label="프로젝트">
|
||||
<Input
|
||||
value={form.project_objid}
|
||||
onChange={(e) => setField("project_objid", e.target.value.replace(/[^\d]/g, ""))}
|
||||
placeholder="프로젝트 OBJID (숫자)"
|
||||
inputMode="numeric"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="관리유형 *">
|
||||
<CodeFormSelect value={form.mng_type} onChange={(v) => setField("mng_type", v)} options={mngTypeOpts} />
|
||||
</Field>
|
||||
<Field label="제품구분">
|
||||
<CodeFormSelect value={form.product_division} onChange={(v) => setField("product_division", v)} options={productDivOpts} />
|
||||
</Field>
|
||||
<Field label="고객명">
|
||||
<Input value={form.customer_name} onChange={(e) => setField("customer_name", e.target.value)} className="h-8 text-xs" />
|
||||
</Field>
|
||||
<Field label="발생 장소">
|
||||
<Input value={form.event_location} onChange={(e) => setField("event_location", e.target.value)} className="h-8 text-xs" />
|
||||
</Field>
|
||||
<Field label="접수일">
|
||||
<Input type="date" value={form.reception_date} onChange={(e) => setField("reception_date", e.target.value)} className="h-8 text-xs" />
|
||||
</Field>
|
||||
<Field label="조치일">
|
||||
<Input type="date" value={form.action_date} onChange={(e) => setField("action_date", e.target.value)} className="h-8 text-xs" />
|
||||
</Field>
|
||||
<Field label="담당자">
|
||||
<CodeFormSelect value={form.performer} onChange={(v) => setField("performer", v)} options={performerOpts} />
|
||||
</Field>
|
||||
<Field label="상태 *">
|
||||
<CodeFormSelect value={form.status} onChange={(v) => setField("status", v)} options={statusOpts} />
|
||||
</Field>
|
||||
<Field label="조치유형">
|
||||
<CodeFormSelect value={form.measure_type} onChange={(v) => setField("measure_type", v)} options={measureTypeOpts} />
|
||||
</Field>
|
||||
<Field label="조치 금액 (원)">
|
||||
<Input value={form.measure_amount} onChange={(e) => setField("measure_amount", e.target.value.replace(/[^\d]/g, ""))} className="h-8 text-xs" inputMode="numeric" />
|
||||
<Input value={form.project_objid} onChange={(e) => setField("project_objid", e.target.value.replace(/[^\d]/g, ""))}
|
||||
placeholder="프로젝트 OBJID (숫자)" inputMode="numeric" className="h-8 text-xs" />
|
||||
</Field>
|
||||
<Field label="관리유형 *"><CodeFormSelect value={form.mng_type} onChange={(v) => setField("mng_type", v)} options={mngTypeOpts} /></Field>
|
||||
<Field label="제품구분"><CodeFormSelect value={form.product_division} onChange={(v) => setField("product_division", v)} options={productDivOpts} /></Field>
|
||||
<Field label="고객명"><Input value={form.customer_name} onChange={(e) => setField("customer_name", e.target.value)} className="h-8 text-xs" /></Field>
|
||||
<Field label="발생 장소"><Input value={form.event_location} onChange={(e) => setField("event_location", e.target.value)} className="h-8 text-xs" /></Field>
|
||||
<Field label="접수일"><Input type="date" value={form.reception_date} onChange={(e) => setField("reception_date", e.target.value)} className="h-8 text-xs" /></Field>
|
||||
<Field label="조치일"><Input type="date" value={form.action_date} onChange={(e) => setField("action_date", e.target.value)} className="h-8 text-xs" /></Field>
|
||||
<Field label="담당자"><CodeFormSelect value={form.performer} onChange={(v) => setField("performer", v)} options={performerOpts} /></Field>
|
||||
<Field label="상태 *"><CodeFormSelect value={form.status} onChange={(v) => setField("status", v)} options={statusOpts} /></Field>
|
||||
<Field label="조치유형"><CodeFormSelect value={form.measure_type} onChange={(v) => setField("measure_type", v)} options={measureTypeOpts} /></Field>
|
||||
<Field label="조치 금액 (원)"><Input value={form.measure_amount} onChange={(e) => setField("measure_amount", e.target.value.replace(/[^\d]/g, ""))} className="h-8 text-xs" inputMode="numeric" /></Field>
|
||||
<div className="col-span-2"><Field label="제목 *">
|
||||
<Input value={form.title} onChange={(e) => setField("title", e.target.value)} className="h-8 text-xs" />
|
||||
</Field></div>
|
||||
|
||||
@@ -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<string, string> = {
|
||||
"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<EcrItem[]>([]);
|
||||
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<Set<string>>(new Set());
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [selected, setSelected] = useState<EcrItem | null>(null);
|
||||
|
||||
// 필터 — wace_plm 기본값 = 전부 미선택(전체)
|
||||
const [year, setYear] = useState<string>("");
|
||||
@@ -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<EcrItem | null>(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 (
|
||||
<div className="flex h-full min-h-0 flex-col gap-3 p-4">
|
||||
{/* 헤더 */}
|
||||
<div className="flex flex-shrink-0 items-end justify-between gap-3 border-b pb-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold tracking-tight">ECR 관리</h1>
|
||||
<p className="text-xs text-muted-foreground">설계변경요청(Engineering Change Request) 등록 · 검토 · 조치완료</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button variant="outline" size="sm" className="h-8 gap-1 text-xs" onClick={loadList} disabled={loading}>
|
||||
<RefreshCw className={cn("h-3.5 w-3.5", loading && "animate-spin")} /> 새로고침
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-8 gap-1 text-xs"
|
||||
onClick={handleDelete}
|
||||
disabled={selectedIds.size === 0}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" /> 삭제 ({selectedIds.size})
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 gap-1 text-xs"
|
||||
onClick={() => {
|
||||
setSelected(null);
|
||||
setFormOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> 등록
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
const handleReset = () => {
|
||||
setYear(""); setProductCode(""); setRequestCode("");
|
||||
setWriter(""); setStatusCode("");
|
||||
};
|
||||
|
||||
{/* 필터바 — wace_plm 과 동일 5 필터 */}
|
||||
<div className="flex flex-shrink-0 flex-wrap items-end gap-2 rounded-md border bg-muted/20 p-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px]">연도</Label>
|
||||
// status 컬러 뱃지를 적용한 row (status_name 셀을 컬러칩으로 렌더)
|
||||
const rows = useMemo(() => list, [list]);
|
||||
const gridColumns = useMemo<DataGridColumn[]>(
|
||||
() => GRID_COLUMNS.map((c) => c.key === "status_name" ? { ...c } : c),
|
||||
[],
|
||||
);
|
||||
|
||||
const isDraftSelected = selected?.status_cd === STATUS_DRAFT;
|
||||
const isCompletedSelected = selected?.status_cd === "0000102";
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
|
||||
<PageHeader
|
||||
loading={loading}
|
||||
onSearch={() => { setPage(1); loadList(); }}
|
||||
onReset={handleReset}
|
||||
actions={
|
||||
<>
|
||||
<Button size="sm" variant="destructive" className="h-8 gap-1 text-xs"
|
||||
onClick={handleDelete} disabled={selectedIds.length === 0}>
|
||||
<Trash2 className="h-3.5 w-3.5" />삭제 ({selectedIds.length})
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-8 gap-1 text-xs"
|
||||
onClick={() => { if (selected) setDetailOpen(true); else toast.warning("상세를 볼 항목을 선택하세요."); }}
|
||||
disabled={!selected}>
|
||||
<Eye className="h-3.5 w-3.5" />상세
|
||||
</Button>
|
||||
<Button size="sm" className="h-8 gap-1 text-xs"
|
||||
onClick={() => { setFormOpen(true); }}
|
||||
disabled={!!selected && !isDraftSelected}>
|
||||
{selected ? <Pencil className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
||||
{selected ? "수정" : "등록"}
|
||||
</Button>
|
||||
<Button size="sm" className="h-8 gap-1 bg-emerald-600 hover:bg-emerald-700 text-white text-xs"
|
||||
onClick={() => { if (!selected) toast.warning("조치완료할 항목을 선택하세요."); else if (isCompletedSelected) toast.warning("이미 적용완료된 건입니다."); else setCompleteOpen(true); }}
|
||||
disabled={!selected || isCompletedSelected}>
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />조치완료
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<CompactFilterBar totalText={<>총 {total.toLocaleString()}건</>}>
|
||||
<CompactFilterField label="연도" width={100}>
|
||||
<Select value={year || "all"} onValueChange={(v) => setYear(v === "all" ? "" : v)}>
|
||||
<SelectTrigger className="h-7 w-[100px] text-xs"><SelectValue placeholder="전체" /></SelectTrigger>
|
||||
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="전체" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
{yearOptions.map((y) => (<SelectItem key={y} value={y}>{y}</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px]">기종(모델)명</Label>
|
||||
</CompactFilterField>
|
||||
<CompactFilterField label="기종(모델)명" width={160}>
|
||||
<Select value={productCode || "all"} onValueChange={(v) => setProductCode(v === "all" ? "" : v)}>
|
||||
<SelectTrigger className="h-7 w-[160px] text-xs"><SelectValue placeholder="전체" /></SelectTrigger>
|
||||
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="전체" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
{productOptions.map((o) => (<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px]">요청구분</Label>
|
||||
</CompactFilterField>
|
||||
<CompactFilterField label="요청구분" width={160}>
|
||||
<Select value={requestCode || "all"} onValueChange={(v) => setRequestCode(v === "all" ? "" : v)}>
|
||||
<SelectTrigger className="h-7 w-[160px] text-xs"><SelectValue placeholder="전체" /></SelectTrigger>
|
||||
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="전체" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
{requestOptions.map((o) => (<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px]">작성자</Label>
|
||||
</CompactFilterField>
|
||||
<CompactFilterField label="작성자" width={160}>
|
||||
<Select value={writer || "all"} onValueChange={(v) => setWriter(v === "all" ? "" : v)}>
|
||||
<SelectTrigger className="h-7 w-[160px] text-xs"><SelectValue placeholder="전체" /></SelectTrigger>
|
||||
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="전체" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
{writerOptions.map((o) => (<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px]">상태</Label>
|
||||
</CompactFilterField>
|
||||
<CompactFilterField label="상태" width={140}>
|
||||
<Select value={statusCode || "all"} onValueChange={(v) => setStatusCode(v === "all" ? "" : v)}>
|
||||
<SelectTrigger className="h-7 w-[140px] text-xs"><SelectValue placeholder="전체" /></SelectTrigger>
|
||||
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="전체" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
{statusOptions.map((o) => (<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" className="h-7 gap-1 px-2 text-xs" onClick={() => { setPage(1); loadList(); }}>
|
||||
<Search className="h-3 w-3" /> 검색
|
||||
</Button>
|
||||
<span className="ml-auto text-[11px] text-muted-foreground">총 {total}건</span>
|
||||
</div>
|
||||
</CompactFilterField>
|
||||
</CompactFilterBar>
|
||||
|
||||
{/* 그리드 — wace_plm 컬럼 순서 동일 */}
|
||||
<div className="flex flex-1 min-h-0 flex-col rounded-md border bg-card">
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 z-10 bg-muted/60 backdrop-blur">
|
||||
<tr className="text-left">
|
||||
<th className="w-8 px-2 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={deletableIds.size > 0 && selectedIds.size === deletableIds.size}
|
||||
onChange={(e) =>
|
||||
setSelectedIds(e.target.checked ? new Set(deletableIds) : new Set())
|
||||
}
|
||||
/>
|
||||
</th>
|
||||
<th className="px-2 py-2">ECR_NO</th>
|
||||
<th className="px-2 py-2">기종(모델)명</th>
|
||||
<th className="px-2 py-2">품번</th>
|
||||
<th className="px-2 py-2">품명</th>
|
||||
<th className="px-2 py-2">설변요청</th>
|
||||
<th className="px-2 py-2">제목</th>
|
||||
<th className="px-2 py-2">작성자</th>
|
||||
<th className="px-2 py-2">작성일</th>
|
||||
<th className="px-2 py-2">조치자</th>
|
||||
<th className="px-2 py-2">조치일</th>
|
||||
<th className="px-2 py-2">상태</th>
|
||||
<th className="w-20 px-2 py-2 text-center">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={13} className="py-12 text-center text-muted-foreground">불러오는 중...</td></tr>
|
||||
) : list.length === 0 ? (
|
||||
<tr><td colSpan={13} className="py-12 text-center text-muted-foreground">조회된 데이터가 없습니다.</td></tr>
|
||||
) : (
|
||||
list.map((row) => {
|
||||
const isDraft = row.status_cd === STATUS_DRAFT;
|
||||
return (
|
||||
<tr
|
||||
key={row.objid}
|
||||
className="cursor-pointer border-t hover:bg-muted/40"
|
||||
onClick={() => {
|
||||
setSelected(row);
|
||||
// wace_plm: 작성중이면 등록/수정 폼, 그 외에는 상세
|
||||
if (isDraft) setFormOpen(true);
|
||||
else setDetailOpen(true);
|
||||
}}
|
||||
>
|
||||
<td className="px-2 py-1.5" onClick={(e) => e.stopPropagation()}>
|
||||
{isDraft && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(row.objid)}
|
||||
onChange={(e) => {
|
||||
const next = new Set(selectedIds);
|
||||
if (e.target.checked) next.add(row.objid); else next.delete(row.objid);
|
||||
setSelectedIds(next);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 font-mono">{row.ecr_no}</td>
|
||||
<td className="px-2 py-1.5">{row.product_name || "-"}</td>
|
||||
<td className="px-2 py-1.5">{row.part_no || "-"}</td>
|
||||
<td className="px-2 py-1.5">{row.part_name || "-"}</td>
|
||||
<td className="px-2 py-1.5 text-[11px]">{row.request_name || "-"}</td>
|
||||
<td className="px-2 py-1.5 max-w-[280px] truncate" title={row.title}>{row.title}</td>
|
||||
<td className="px-2 py-1.5">{row.writer_name || row.writer || "-"}</td>
|
||||
<td className="px-2 py-1.5">{row.reg_date}</td>
|
||||
<td className="px-2 py-1.5">{row.check_name || "-"}</td>
|
||||
<td className="px-2 py-1.5">{row.check_date || "-"}</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<Badge className={cn("font-medium", STATUS_COLOR[row.status_cd || ""] || "bg-muted text-muted-foreground")}>
|
||||
{row.status_name || row.status_cd}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<div className="flex items-center justify-center gap-0.5" onClick={(e) => e.stopPropagation()}>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" title="상세"
|
||||
onClick={() => { setSelected(row); setDetailOpen(true); }}>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{isDraft && (
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" title="수정"
|
||||
onClick={() => { setSelected(row); setFormOpen(true); }}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{row.status_cd !== "0000102" && (
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-emerald-600" title="조치완료"
|
||||
onClick={() => { setSelected(row); setCompleteOpen(true); }}>
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
<div className="flex flex-shrink-0 items-center justify-between gap-2 border-t px-3 py-1.5 text-xs">
|
||||
<span className="text-muted-foreground">{page} / {totalPages} 페이지</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="outline" size="sm" className="h-6 px-2 text-[11px]" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>이전</Button>
|
||||
<Button variant="outline" size="sm" className="h-6 px-2 text-[11px]" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>다음</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DataGrid
|
||||
gridId="ecr-list"
|
||||
columns={gridColumns}
|
||||
data={rows}
|
||||
loading={loading}
|
||||
showCheckbox
|
||||
checkedIds={selectedIds}
|
||||
onCheckedChange={(ids) => {
|
||||
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<string, any> = {};
|
||||
GRID_COLUMNS.forEach((c) => { out[c.label] = (r as any)[c.key] ?? ""; });
|
||||
return out;
|
||||
});
|
||||
exportToExcel(exportRows, "ECR관리.xlsx", "ECR관리");
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 등록/수정 모달 */}
|
||||
<EcrFormModal
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 수입검사 관리 — wace_plm incomingInspectionProgressList.jsp 이식.
|
||||
*
|
||||
* 수입검사 요청 기반에 검사자/검사일/검사결과/불량수량 컬럼이 추가됨.
|
||||
*/
|
||||
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, IncomingMgmtRow } 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: "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: "inspection_date", label: "검사일", width: "w-[115px]", align: "center" },
|
||||
{ key: "inspector_name", label: "검사자", width: "w-[110px]", align: "center" },
|
||||
{ key: "total_qty", label: "검사수량", width: "w-[110px]", align: "right", formatNumber: true },
|
||||
{ key: "good_qty", label: "양품수량", width: "w-[110px]", align: "right", formatNumber: true },
|
||||
{ key: "bad_qty", label: "불량수량", width: "w-[110px]", align: "right", formatNumber: true },
|
||||
{ key: "inspection_result", label: "검사결과", width: "w-[100px]", align: "center" },
|
||||
{ key: "request_status", label: "요청현황", width: "w-[110px]", align: "center" },
|
||||
];
|
||||
|
||||
export default function IncomingMgmtPage() {
|
||||
const { user } = useAuth();
|
||||
const [rows, setRows] = useState<IncomingMgmtRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const [search, setSearch] = useState({
|
||||
project_no: "",
|
||||
partner_objid: "",
|
||||
inspector_id: "",
|
||||
});
|
||||
|
||||
const fetchList = useCallback(async () => {
|
||||
if (!user) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: Record<string, string> = {};
|
||||
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 (
|
||||
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
|
||||
<PageHeader
|
||||
loading={loading}
|
||||
onSearch={fetchList}
|
||||
onReset={handleReset}
|
||||
actions={
|
||||
<Button size="sm" className="h-8 gap-1 text-xs" disabled>
|
||||
<Plus className="h-3.5 w-3.5" />수입검사 진행등록
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CompactFilterBar totalText={<>총 {rows.length.toLocaleString()}건</>}>
|
||||
<CompactFilterField label="프로젝트번호" width={160}>
|
||||
<Input value={search.project_no}
|
||||
onChange={(e) => setSearch({ ...search, project_no: e.target.value })} />
|
||||
</CompactFilterField>
|
||||
<CompactFilterField label="공급업체 ID" width={160}>
|
||||
<Input value={search.partner_objid}
|
||||
onChange={(e) => setSearch({ ...search, partner_objid: e.target.value })} />
|
||||
</CompactFilterField>
|
||||
<CompactFilterField label="검사자 ID" width={140}>
|
||||
<Input value={search.inspector_id}
|
||||
onChange={(e) => setSearch({ ...search, inspector_id: e.target.value })} />
|
||||
</CompactFilterField>
|
||||
</CompactFilterBar>
|
||||
<DataGrid
|
||||
gridId="quality-incoming-mgmt"
|
||||
columns={GRID_COLUMNS}
|
||||
data={rows}
|
||||
loading={loading}
|
||||
showCheckbox
|
||||
checkedIds={selectedId ? [selectedId] : []}
|
||||
onCheckedChange={(ids) => 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<string, any> = {};
|
||||
GRID_COLUMNS.forEach((c) => { out[c.label] = (r as any)[c.key] ?? ""; });
|
||||
return out;
|
||||
});
|
||||
exportToExcel(exportRows, "수입검사관리.xlsx", "수입검사관리");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<IncomingRequestRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(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<string, string> = {};
|
||||
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 (
|
||||
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
|
||||
<PageHeader
|
||||
loading={loading}
|
||||
onSearch={fetchList}
|
||||
onReset={handleReset}
|
||||
actions={
|
||||
<Button size="sm" className="h-8 gap-1 text-xs" disabled>
|
||||
<Plus className="h-3.5 w-3.5" />수입검사 요청
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CompactFilterBar totalText={<>총 {rows.length.toLocaleString()}건</>}>
|
||||
<CompactFilterField label="프로젝트번호" width={160}>
|
||||
<Input value={search.project_no}
|
||||
onChange={(e) => setSearch({ ...search, project_no: e.target.value })} />
|
||||
</CompactFilterField>
|
||||
<CompactFilterField label="공급업체 ID" width={160}>
|
||||
<Input value={search.partner_objid}
|
||||
onChange={(e) => setSearch({ ...search, partner_objid: e.target.value })} />
|
||||
</CompactFilterField>
|
||||
<CompactFilterField label="요청자 ID" width={140}>
|
||||
<Input value={search.request_user_id}
|
||||
onChange={(e) => setSearch({ ...search, request_user_id: e.target.value })} />
|
||||
</CompactFilterField>
|
||||
</CompactFilterBar>
|
||||
<DataGrid
|
||||
gridId="quality-incoming-request"
|
||||
columns={GRID_COLUMNS}
|
||||
data={rows}
|
||||
loading={loading}
|
||||
showCheckbox
|
||||
checkedIds={selectedId ? [selectedId] : []}
|
||||
onCheckedChange={(ids) => 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<string, any> = {};
|
||||
GRID_COLUMNS.forEach((c) => { out[c.label] = (r as any)[c.key] ?? ""; });
|
||||
return out;
|
||||
});
|
||||
exportToExcel(exportRows, "수입검사요청.xlsx", "수입검사요청");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<ProcessInspectionRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(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<string, string> = {};
|
||||
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 (
|
||||
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
|
||||
<PageHeader
|
||||
loading={loading}
|
||||
onSearch={fetchList}
|
||||
onReset={handleReset}
|
||||
actions={
|
||||
<Button size="sm" className="h-8 gap-1 text-xs" disabled>
|
||||
<Plus className="h-3.5 w-3.5" />공정검사 등록
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CompactFilterBar totalText={<>총 {rows.length.toLocaleString()}건</>}>
|
||||
<CompactFilterField label="프로젝트번호" width={160}>
|
||||
<Input value={search.project_no}
|
||||
onChange={(e) => setSearch({ ...search, project_no: e.target.value })} />
|
||||
</CompactFilterField>
|
||||
<CompactFilterField label="제품구분" width={140}>
|
||||
<Input value={search.productType}
|
||||
onChange={(e) => setSearch({ ...search, productType: e.target.value })} />
|
||||
</CompactFilterField>
|
||||
<CompactFilterField label="품명" width={160}>
|
||||
<Input value={search.part_name}
|
||||
onChange={(e) => setSearch({ ...search, part_name: e.target.value })} />
|
||||
</CompactFilterField>
|
||||
<CompactFilterField label="검사자 ID" width={130}>
|
||||
<Input value={search.inspector_id}
|
||||
onChange={(e) => setSearch({ ...search, inspector_id: e.target.value })} />
|
||||
</CompactFilterField>
|
||||
<CompactFilterField label="검사일" width={280}>
|
||||
<CompactDateRange
|
||||
from={search.from} setFrom={(v) => setSearch({ ...search, from: v })}
|
||||
to={search.to} setTo={(v) => setSearch({ ...search, to: v })}
|
||||
/>
|
||||
</CompactFilterField>
|
||||
</CompactFilterBar>
|
||||
<DataGrid
|
||||
gridId="quality-process-inspection"
|
||||
columns={GRID_COLUMNS}
|
||||
data={rows}
|
||||
loading={loading}
|
||||
showCheckbox
|
||||
checkedIds={selectedId ? [selectedId] : []}
|
||||
onCheckedChange={(ids) => 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<string, any> = {};
|
||||
GRID_COLUMNS.forEach((c) => { out[c.label] = (r as any)[c.key] ?? ""; });
|
||||
return out;
|
||||
});
|
||||
exportToExcel(exportRows, "공정검사관리.xlsx", "공정검사관리");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<SemiProductInspectionRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(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<string, string> = {};
|
||||
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 (
|
||||
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
|
||||
<PageHeader
|
||||
loading={loading}
|
||||
onSearch={fetchList}
|
||||
onReset={handleReset}
|
||||
actions={
|
||||
<Button size="sm" className="h-8 gap-1 text-xs" disabled>
|
||||
<Plus className="h-3.5 w-3.5" />반제품검사 등록
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CompactFilterBar totalText={<>총 {rows.length.toLocaleString()}건</>}>
|
||||
<CompactFilterField label="모델명" width={160}>
|
||||
<Input value={search.model_name}
|
||||
onChange={(e) => setSearch({ ...search, model_name: e.target.value })} />
|
||||
</CompactFilterField>
|
||||
<CompactFilterField label="부품품번" width={140}>
|
||||
<Input value={search.part_no}
|
||||
onChange={(e) => setSearch({ ...search, part_no: e.target.value })} />
|
||||
</CompactFilterField>
|
||||
<CompactFilterField label="부품명" width={160}>
|
||||
<Input value={search.part_name}
|
||||
onChange={(e) => setSearch({ ...search, part_name: e.target.value })} />
|
||||
</CompactFilterField>
|
||||
<CompactFilterField label="검사자 ID" width={130}>
|
||||
<Input value={search.writer}
|
||||
onChange={(e) => setSearch({ ...search, writer: e.target.value })} />
|
||||
</CompactFilterField>
|
||||
<CompactFilterField label="검사일" width={280}>
|
||||
<CompactDateRange
|
||||
from={search.from} setFrom={(v) => setSearch({ ...search, from: v })}
|
||||
to={search.to} setTo={(v) => setSearch({ ...search, to: v })}
|
||||
/>
|
||||
</CompactFilterField>
|
||||
</CompactFilterBar>
|
||||
<DataGrid
|
||||
gridId="quality-semi-product-inspection"
|
||||
columns={GRID_COLUMNS}
|
||||
data={rows}
|
||||
loading={loading}
|
||||
showCheckbox
|
||||
checkedIds={selectedId ? [selectedId] : []}
|
||||
onCheckedChange={(ids) => 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<string, any> = {};
|
||||
GRID_COLUMNS.forEach((c) => { out[c.label] = (r as any)[c.key] ?? ""; });
|
||||
return out;
|
||||
});
|
||||
exportToExcel(exportRows, "반제품검사관리.xlsx", "반제품검사관리");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -141,6 +141,10 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||
"/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 }),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<T> {
|
||||
success: boolean;
|
||||
list: T[];
|
||||
pagination: { page: number; pageSize: number; total: number; totalPages: number };
|
||||
}
|
||||
|
||||
export const qualityApi = {
|
||||
incomingRequest: async (params: Record<string, string> = {}): Promise<ListResp<IncomingRequestRow>> =>
|
||||
(await apiClient.get("/quality/incoming-request", { params })).data,
|
||||
incomingMgmt: async (params: Record<string, string> = {}): Promise<ListResp<IncomingMgmtRow>> =>
|
||||
(await apiClient.get("/quality/incoming-mgmt", { params })).data,
|
||||
processInspection: async (params: Record<string, string> = {}): Promise<ListResp<ProcessInspectionRow>> =>
|
||||
(await apiClient.get("/quality/process-inspection", { params })).data,
|
||||
semiProductInspection: async (params: Record<string, string> = {}): Promise<ListResp<SemiProductInspectionRow>> =>
|
||||
(await apiClient.get("/quality/semi-product-inspection", { params })).data,
|
||||
};
|
||||
Reference in New Issue
Block a user