From 04cfac6effd80bc59a574ccc92753a1b3eb0345c Mon Sep 17 00:00:00 2001 From: hjjeong Date: Wed, 13 May 2026 15:05:42 +0900 Subject: [PATCH 01/12] =?UTF-8?q?=EA=B5=AC=EB=A7=A4=EA=B4=80=EB=A6=AC>data?= =?UTF-8?q?-sync=20=E2=80=94=20M-BOM=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?= =?UTF-8?q?=EC=8B=A0=EC=84=A4(mbom=5Fheader/mbom=5Fdetail)=20+=20=EC=9A=B4?= =?UTF-8?q?=EC=98=81=20sample=20=EC=9D=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-A 베이스라인 사전 작업. 후속 백엔드/프론트 PR 의 의존성. DDL (400_mbom.sql): - mbom_header (21 cols) — PK objid varchar(64), UNIQUE mbom_no FK source_ebom_objid → part_bom_report.objid (SET NULL) FK source_mbom_objid → mbom_header.objid (SET NULL, self) - mbom_detail (47 cols) — PK objid varchar(64) FK mbom_header_objid → mbom_header.objid (CASCADE) FK part_objid → part_mng.objid (SET NULL) - 인덱스 11개 (project/source/status/header/parent/part 등) 운영DB vs RPS 타입 함정 정정: - 운영 part_mng.objid varchar(64) → RPS bigint (feedback_createobjid_pattern.md) - mbom_detail.part_objid 도 bigint 로 통일해 FK 호환 - numeric/varchar 모두 bigint 와 FK 불가 — 정확한 타입 매칭 필요 운영 sample 이관 (01_mbom_sync.sql): - mbom_header 3 / mbom_detail 95 → RPS 전이 - staging 테이블 패턴 (LIKE INCLUDING DEFAULTS, FK·PK 제거) - FK 매칭 안 되는 source_ebom_objid / part_objid 는 NULL 처리 - mbom_header 에 없는 mbom_header_objid 행은 DELETE (orphan 방지) - part_objid varchar(64) → bigint 형변환 (운영 String.hashCode 결과) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../purchase/data-sync/01_mbom_sync.sql | 63 +++++++++ .../purchase/ddl-extracted/400_mbom.sql | 125 ++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 docs/migration/purchase/data-sync/01_mbom_sync.sql create mode 100644 docs/migration/purchase/ddl-extracted/400_mbom.sql diff --git a/docs/migration/purchase/data-sync/01_mbom_sync.sql b/docs/migration/purchase/data-sync/01_mbom_sync.sql new file mode 100644 index 00000000..77f8420e --- /dev/null +++ b/docs/migration/purchase/data-sync/01_mbom_sync.sql @@ -0,0 +1,63 @@ +-- ============================================================ +-- M-BOM 운영 sample 데이터 → RPS 이관 +-- 운영: 211.115.91.141:11133/waceplm (mbom_header 3건, mbom_detail 95건) +-- 대상: 211.115.91.141:11134/vexplor_rps +-- +-- 함정: +-- 1) mbom_header.source_ebom_objid → RPS part_bom_report 에 없는 OBJID 는 NULL 처리 +-- 2) mbom_detail.part_objid varchar → RPS bigint (FK part_mng.objid bigint 호환) +-- 3) RPS part_mng 에 없는 part_objid 도 NULL 처리 +-- +-- 실행 전: /tmp/mbom_header.csv, /tmp/mbom_detail.csv 준비 (운영DB \copy TO) +-- ============================================================ + +-- ── mbom_header ─────────────────────────────────────────────── +DROP TABLE IF EXISTS mbom_header_stage; +CREATE TABLE mbom_header_stage (LIKE mbom_header INCLUDING DEFAULTS); +ALTER TABLE mbom_header_stage DROP CONSTRAINT IF EXISTS mbom_header_pkey; +ALTER TABLE mbom_header_stage DROP CONSTRAINT IF EXISTS mbom_header_mbom_no_key; +ALTER TABLE mbom_header_stage DROP CONSTRAINT IF EXISTS fk_mbom_source_ebom; +ALTER TABLE mbom_header_stage DROP CONSTRAINT IF EXISTS fk_mbom_source_mbom; + +\copy mbom_header_stage FROM '/tmp/mbom_header.csv' WITH CSV HEADER + +-- RPS part_bom_report 에 없는 source_ebom_objid 는 NULL +UPDATE mbom_header_stage SET source_ebom_objid = NULL + WHERE source_ebom_objid IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM part_bom_report WHERE objid = mbom_header_stage.source_ebom_objid); + +-- self FK source_mbom_objid: 자기 자신 OBJID 셋에 있으면 OK, 아니면 NULL +UPDATE mbom_header_stage SET source_mbom_objid = NULL + WHERE source_mbom_objid IS NOT NULL + AND source_mbom_objid NOT IN (SELECT objid FROM mbom_header_stage); + +INSERT INTO mbom_header SELECT * FROM mbom_header_stage; +DROP TABLE mbom_header_stage; + +-- ── mbom_detail ──────────────────────────────────────────────── +DROP TABLE IF EXISTS mbom_detail_stage; +CREATE TABLE mbom_detail_stage (LIKE mbom_detail INCLUDING DEFAULTS); +ALTER TABLE mbom_detail_stage DROP CONSTRAINT IF EXISTS mbom_detail_pkey; +ALTER TABLE mbom_detail_stage DROP CONSTRAINT IF EXISTS fk_mbom_detail_header; +ALTER TABLE mbom_detail_stage DROP CONSTRAINT IF EXISTS fk_mbom_detail_part; +ALTER TABLE mbom_detail_stage ALTER COLUMN part_objid TYPE varchar(64) USING part_objid::text; + +\copy mbom_detail_stage FROM '/tmp/mbom_detail.csv' WITH CSV HEADER + +-- RPS part_mng 에 없는 part_objid 는 NULL (bigint 형변환 안전) +UPDATE mbom_detail_stage SET part_objid = NULL + WHERE part_objid IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM part_mng WHERE objid::text = mbom_detail_stage.part_objid); + +-- RPS mbom_header 에 없는 mbom_header_objid 행은 import 제외 +DELETE FROM mbom_detail_stage + WHERE NOT EXISTS (SELECT 1 FROM mbom_header WHERE objid = mbom_detail_stage.mbom_header_objid); + +-- bigint 형변환 후 본 테이블 INSERT +ALTER TABLE mbom_detail_stage ALTER COLUMN part_objid TYPE bigint USING part_objid::bigint; +INSERT INTO mbom_detail SELECT * FROM mbom_detail_stage; +DROP TABLE mbom_detail_stage; + +-- ── 결과 ────────────────────────────────────────────────────── +SELECT 'mbom_header' AS t, COUNT(*) FROM mbom_header +UNION ALL SELECT 'mbom_detail', COUNT(*) FROM mbom_detail; diff --git a/docs/migration/purchase/ddl-extracted/400_mbom.sql b/docs/migration/purchase/ddl-extracted/400_mbom.sql new file mode 100644 index 00000000..b7739db0 --- /dev/null +++ b/docs/migration/purchase/ddl-extracted/400_mbom.sql @@ -0,0 +1,125 @@ +-- ============================================================ +-- M-BOM (Manufacturing BOM) — 구매관리/생산관리 공유 마스터 +-- 원본: 운영DB 211.115.91.141:11133/waceplm +-- 추출일: 2026-05-13 +-- 적용대상: vexplor_rps (11134) +-- +-- 의존성: +-- mbom_header.source_ebom_objid → part_bom_report.objid (개발관리 E-BOM, varchar) +-- mbom_detail.mbom_header_objid → mbom_header.objid (varchar, CASCADE) +-- mbom_detail.part_objid → part_mng.objid (개발관리 PART, **RPS 에서 bigint**, SET NULL) +-- +-- 운영DB ↔ RPS 타입 차이 (feedback_createobjid_pattern.md): +-- 운영DB: part_mng.objid varchar(64) → mbom_detail.part_objid varchar(64) +-- RPS: part_mng.objid bigint → mbom_detail.part_objid bigint (FK 호환) +-- 운영 데이터 임포트 시 part_objid::bigint cast 필요. +-- +-- 비즈니스 흐름: +-- M-BOM 생성 → 구매리스트(sales_request_master + mbom_detail) → +-- 견적요청서 / 품의서(sales_request_detail) → +-- 발주서(purchase_order_master + purchase_order_part) → +-- 입고(arrival_plan + inventory_mgmt + inventory_mgmt_in) +-- ============================================================ + +-- ── 1. mbom_header (M-BOM 마스터 헤더) ──────────────────────── +CREATE TABLE IF NOT EXISTS mbom_header ( + objid varchar(64) NOT NULL, + mbom_no varchar(100) NOT NULL, + source_bom_type varchar(20), + source_ebom_objid varchar(64), + source_mbom_objid varchar(64), + project_objid varchar(64), + contract_objid varchar(64), + part_no varchar(100), + part_name varchar(200), + revision varchar(50), + status varchar(20) DEFAULT 'Y', + mbom_status varchar(20) DEFAULT 'DRAFT', + production_type varchar(50), + total_cost numeric(15,2), + writer varchar(50), + regdate timestamp DEFAULT CURRENT_TIMESTAMP, + editer varchar(50), + edit_date timestamp, + approver varchar(50), + approve_date timestamp, + remark text, + CONSTRAINT mbom_header_pkey PRIMARY KEY (objid), + CONSTRAINT mbom_header_mbom_no_key UNIQUE (mbom_no), + CONSTRAINT fk_mbom_source_ebom FOREIGN KEY (source_ebom_objid) + REFERENCES part_bom_report(objid) ON DELETE SET NULL, + CONSTRAINT fk_mbom_source_mbom FOREIGN KEY (source_mbom_objid) + REFERENCES mbom_header(objid) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_mbom_header_mbom_no ON mbom_header (mbom_no); +CREATE INDEX IF NOT EXISTS idx_mbom_header_project_objid ON mbom_header (project_objid); +CREATE INDEX IF NOT EXISTS idx_mbom_header_source_ebom ON mbom_header (source_ebom_objid); +CREATE INDEX IF NOT EXISTS idx_mbom_header_source_mbom ON mbom_header (source_mbom_objid); +CREATE INDEX IF NOT EXISTS idx_mbom_header_source_type ON mbom_header (source_bom_type); +CREATE INDEX IF NOT EXISTS idx_mbom_header_status ON mbom_header (status); + +-- ── 2. mbom_detail (M-BOM 상세 트리: 부모-자식 + qty/단가/공급처) ── +CREATE TABLE IF NOT EXISTS mbom_detail ( + objid varchar(64) NOT NULL, + mbom_header_objid varchar(64) NOT NULL, + parent_objid varchar(64), + child_objid varchar(64), + seq integer, + level integer, + part_objid bigint, + part_no varchar(100), + part_name varchar(200), + qty numeric(15,4), + unit varchar(20), + supply_type varchar(50), + make_or_buy varchar(20), + raw_material_part_no varchar(100), + raw_material_spec varchar(200), + raw_material varchar(100), + raw_material_size varchar(100), + processing_vendor varchar(100), + processing_deadline varchar(10), + grinding_deadline varchar(10), + required_qty numeric(15,4), + order_qty numeric(15,4), + production_qty numeric(15,4), + stock_qty numeric(15,4), + shortage_qty numeric(15,4), + vendor varchar(100), + unit_price numeric(15,2), + total_price numeric(15,2), + currency varchar(10) DEFAULT 'KRW', + lead_time integer, + min_order_qty numeric(15,4), + status varchar(20) DEFAULT 'ACTIVE', + regdate timestamp DEFAULT CURRENT_TIMESTAMP, + writer varchar(50), + edit_date timestamp, + editer varchar(50), + remark text, + use_yn varchar(1) DEFAULT 'Y', + net_qty numeric DEFAULT 0, + po_qty numeric DEFAULT 0, + proposal_date date, + processing_unit_price numeric(15,2), + processing_total_price numeric(15,2), + grand_total_price numeric(15,2), + processing_proposal_date date, + delivery_request_date varchar(10), + item_qty numeric(15,4), + CONSTRAINT mbom_detail_pkey PRIMARY KEY (objid), + CONSTRAINT fk_mbom_detail_header FOREIGN KEY (mbom_header_objid) + REFERENCES mbom_header(objid) ON DELETE CASCADE, + CONSTRAINT fk_mbom_detail_part FOREIGN KEY (part_objid) + REFERENCES part_mng(objid) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_mbom_detail_header_objid ON mbom_detail (mbom_header_objid); +CREATE INDEX IF NOT EXISTS idx_mbom_detail_parent_objid ON mbom_detail (parent_objid); +CREATE INDEX IF NOT EXISTS idx_mbom_detail_part_no ON mbom_detail (part_no); +CREATE INDEX IF NOT EXISTS idx_mbom_detail_part_objid ON mbom_detail (part_objid); +CREATE INDEX IF NOT EXISTS idx_mbom_detail_proposal_date ON mbom_detail (proposal_date) WHERE proposal_date IS NULL; + +COMMENT ON TABLE mbom_header IS 'M-BOM 마스터 헤더 (제조용 BOM, E-BOM 파생/템플릿/복사)'; +COMMENT ON TABLE mbom_detail IS 'M-BOM 상세 트리 (부모-자식 구조 + 수량/단가/공급처/원자재)'; From 7af366c595196fe61ed6ebf5912f54964a987299 Mon Sep 17 00:00:00 2001 From: hjjeong Date: Wed, 13 May 2026 15:43:21 +0900 Subject: [PATCH 02/12] =?UTF-8?q?=EC=83=9D=EC=82=B0=EA=B4=80=EB=A6=AC>M-BO?= =?UTF-8?q?M=20=EA=B4=80=EB=A6=AC=20=E2=80=94=20PR-A0=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=8B=A0=EC=84=A4=20(mbom=5Fh?= =?UTF-8?q?istory/sales=5Frequest=5Fmaster/client=5Fmng=20+=20user=5Fname?= =?UTF-8?q?=20fn)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이전 04cfac6e (구매관리>data-sync) 분류 정정: M-BOM 은 운영 wace "생산관리_M-BOM관리" 메뉴에 1:1 대응. docs/migration/purchase/ → docs/migration/production/ 폴더 rename. 신규 DDL (401_mbom_dependencies.sql): - mbom_history (8 cols) — M-BOM 변경 이력. FK→mbom_header CASCADE, PK objid, idx (header_objid, change_date) - sales_request_master (27 cols) — 구매요청서 마스터. PK objid, mbom_header_objid 컬럼(FK 없음), doc_type/recipient_ref/executor/title 등 M-BOM→PURCHASE 연계 컬럼, idx (doc_type, mbom_header_objid) - client_mng (117 cols) — 거래처 마스터. uk(client_cd), idx 3종 - user_name(varchar) PL/pgSQL — 그리드 WRITER_NAME/MBOM_EDITOR 표시용 (운영 mBomMgmtGridList:user_name(MH.WRITER) 1:1) 운영 sample 이관 (02_mbom_dependencies_sync.sql): - mbom_history 14 / sales_request_master 3 / client_mng 8,946 → RPS 전이 - staging 패턴(LIKE INCLUDING DEFAULTS + PK·FK·UK 제거) 으로 무결성 우회 - mbom_history: mbom_header 매칭 없는 행은 import 제외 (FK CASCADE 호환) - sales_request_master: mbom_header_objid 매칭 안 되면 NULL fallback 검증: - contract_mgmt.customer_objid LIKE 'C_%' → client_mng 매칭 성공 (운영 매퍼 CUSTOMER_NAME 분기 RPS 에서 그대로 동작) - user_name('admin') → '관리자' 반환 확인 - M-BOM 관리 화면 PR-A1 (그리드/검색/상세) 의 모든 의존 테이블 준비 완료 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../data-sync/01_mbom_sync.sql | 0 .../data-sync/02_mbom_dependencies_sync.sql | 66 +++++ .../ddl-extracted/400_mbom.sql | 0 .../ddl-extracted/401_mbom_dependencies.sql | 239 ++++++++++++++++++ 4 files changed, 305 insertions(+) rename docs/migration/{purchase => production}/data-sync/01_mbom_sync.sql (100%) create mode 100644 docs/migration/production/data-sync/02_mbom_dependencies_sync.sql rename docs/migration/{purchase => production}/ddl-extracted/400_mbom.sql (100%) create mode 100644 docs/migration/production/ddl-extracted/401_mbom_dependencies.sql diff --git a/docs/migration/purchase/data-sync/01_mbom_sync.sql b/docs/migration/production/data-sync/01_mbom_sync.sql similarity index 100% rename from docs/migration/purchase/data-sync/01_mbom_sync.sql rename to docs/migration/production/data-sync/01_mbom_sync.sql diff --git a/docs/migration/production/data-sync/02_mbom_dependencies_sync.sql b/docs/migration/production/data-sync/02_mbom_dependencies_sync.sql new file mode 100644 index 00000000..9139c807 --- /dev/null +++ b/docs/migration/production/data-sync/02_mbom_dependencies_sync.sql @@ -0,0 +1,66 @@ +-- ============================================================ +-- M-BOM 의존 테이블 운영 → RPS 데이터 이관 (PR-A0) +-- 운영: 211.115.91.141:11133/waceplm +-- 대상: 211.115.91.141:11134/vexplor_rps +-- +-- 함정: +-- 1) mbom_history.mbom_header_objid FK → 매칭 없는 행은 import 제외 (CASCADE 와 일치) +-- 2) sales_request_master.mbom_header_objid 는 FK 없음 (NULL 허용) +-- 3) client_mng 는 운영 8,946건 전량 이관 (그리드 CUSTOMER_NAME 매칭용) +-- +-- 실행 전 export (운영에서): +-- PGPASSWORD='waceplm0909!!' psql -h 211.115.91.141 -p 11133 -U postgres -d waceplm \ +-- -c "\copy (SELECT * FROM mbom_history) TO '/tmp/mbom_history.csv' WITH CSV HEADER" +-- PGPASSWORD='waceplm0909!!' psql -h 211.115.91.141 -p 11133 -U postgres -d waceplm \ +-- -c "\copy (SELECT * FROM sales_request_master) TO '/tmp/sales_request_master.csv' WITH CSV HEADER" +-- PGPASSWORD='waceplm0909!!' psql -h 211.115.91.141 -p 11133 -U postgres -d waceplm \ +-- -c "\copy (SELECT * FROM client_mng) TO '/tmp/client_mng.csv' WITH CSV HEADER" +-- ============================================================ + +-- ── 1. mbom_history ──────────────────────────────────────────── +DROP TABLE IF EXISTS mbom_history_stage; +CREATE TABLE mbom_history_stage (LIKE mbom_history INCLUDING DEFAULTS); +ALTER TABLE mbom_history_stage DROP CONSTRAINT IF EXISTS mbom_history_pkey; +ALTER TABLE mbom_history_stage DROP CONSTRAINT IF EXISTS fk_mbom_history_header; + +\copy mbom_history_stage FROM '/tmp/mbom_history.csv' WITH CSV HEADER + +-- RPS mbom_header 에 없는 mbom_header_objid 행은 import 제외 (FK CASCADE 호환) +DELETE FROM mbom_history_stage + WHERE NOT EXISTS (SELECT 1 FROM mbom_header WHERE objid = mbom_history_stage.mbom_header_objid); + +INSERT INTO mbom_history SELECT * FROM mbom_history_stage; +DROP TABLE mbom_history_stage; + + +-- ── 2. sales_request_master ──────────────────────────────────── +DROP TABLE IF EXISTS sales_request_master_stage; +CREATE TABLE sales_request_master_stage (LIKE sales_request_master INCLUDING DEFAULTS); +ALTER TABLE sales_request_master_stage DROP CONSTRAINT IF EXISTS sales_request_master_pkey; + +\copy sales_request_master_stage FROM '/tmp/sales_request_master.csv' WITH CSV HEADER + +-- mbom_header_objid 매칭 안 되는 경우 NULL (FK 는 없지만 그리드 매칭 시 NULL fallback) +UPDATE sales_request_master_stage SET mbom_header_objid = NULL + WHERE mbom_header_objid IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM mbom_header WHERE objid = sales_request_master_stage.mbom_header_objid); + +INSERT INTO sales_request_master SELECT * FROM sales_request_master_stage; +DROP TABLE sales_request_master_stage; + + +-- ── 3. client_mng ────────────────────────────────────────────── +DROP TABLE IF EXISTS client_mng_stage; +CREATE TABLE client_mng_stage (LIKE client_mng INCLUDING DEFAULTS); +ALTER TABLE client_mng_stage DROP CONSTRAINT IF EXISTS uk_client_mng_client_cd; + +\copy client_mng_stage FROM '/tmp/client_mng.csv' WITH CSV HEADER + +INSERT INTO client_mng SELECT * FROM client_mng_stage; +DROP TABLE client_mng_stage; + + +-- ── 결과 ────────────────────────────────────────────────────── +SELECT 'mbom_history' AS t, COUNT(*) FROM mbom_history +UNION ALL SELECT 'sales_request_master', COUNT(*) FROM sales_request_master +UNION ALL SELECT 'client_mng', COUNT(*) FROM client_mng; diff --git a/docs/migration/purchase/ddl-extracted/400_mbom.sql b/docs/migration/production/ddl-extracted/400_mbom.sql similarity index 100% rename from docs/migration/purchase/ddl-extracted/400_mbom.sql rename to docs/migration/production/ddl-extracted/400_mbom.sql diff --git a/docs/migration/production/ddl-extracted/401_mbom_dependencies.sql b/docs/migration/production/ddl-extracted/401_mbom_dependencies.sql new file mode 100644 index 00000000..a7199887 --- /dev/null +++ b/docs/migration/production/ddl-extracted/401_mbom_dependencies.sql @@ -0,0 +1,239 @@ +-- ============================================================ +-- M-BOM 관리 화면 의존 테이블 신설 (PR-A0) +-- 운영: 211.115.91.141:11133/waceplm +-- 대상: 211.115.91.141:11134/vexplor_rps +-- +-- 1) mbom_history — M-BOM 변경 이력 (8 cols, FK→mbom_header) +-- 2) sales_request_master — 구매요청서 마스터 (27 cols, FK→mbom_header via mbom_header_objid) +-- 3) client_mng — 거래처 마스터 (117 cols) — 그리드 CUSTOMER_NAME 매칭용 +-- +-- 생성 명령: +-- PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d vexplor_rps -f 401_mbom_dependencies.sql +-- ============================================================ + +SET statement_timeout = 0; +SET client_encoding = 'UTF8'; + +-- ── 1. mbom_history ──────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS public.mbom_history ( + objid character varying(64) NOT NULL, + mbom_header_objid character varying(64) NOT NULL, + change_type character varying(50), + change_description text, + before_data jsonb, + after_data jsonb, + change_user character varying(50), + change_date timestamp without time zone DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT mbom_history_pkey PRIMARY KEY (objid), + CONSTRAINT fk_mbom_history_header FOREIGN KEY (mbom_header_objid) + REFERENCES public.mbom_header(objid) ON DELETE CASCADE +); + +COMMENT ON TABLE public.mbom_history IS 'M-BOM 변경 이력 테이블'; + +CREATE INDEX IF NOT EXISTS idx_mbom_history_header_objid ON public.mbom_history (mbom_header_objid); +CREATE INDEX IF NOT EXISTS idx_mbom_history_change_date ON public.mbom_history (change_date); + + +-- ── 2. sales_request_master ──────────────────────────────────── +CREATE TABLE IF NOT EXISTS public.sales_request_master ( + objid character varying NOT NULL, + request_mng_no character varying, + request_cd character varying, + project_no character varying, + release_date character varying, + request_reasons character varying, + request_user_id character varying, + delivery_request_date character varying, + unit_name character varying, + status character varying, + receipt_user_id character varying, + receipt_date character varying, + writer character varying, + regdate timestamp without time zone, + remark character varying, + purchase_type character varying(50), + order_type character varying(50), + product_name character varying(50), + area_cd character varying(50), + customer_objid character varying(50), + paid_type character varying(20), + mbom_header_objid character varying(50), + doc_type character varying(50), + recipient_ref character varying(500), + executor character varying(100), + execution_date date, + title character varying(500), + CONSTRAINT sales_request_master_pkey PRIMARY KEY (objid) +); + +COMMENT ON TABLE public.sales_request_master IS '구매요청서 마스터'; +COMMENT ON COLUMN public.sales_request_master.request_mng_no IS '요청번호'; +COMMENT ON COLUMN public.sales_request_master.request_cd IS '구분'; +COMMENT ON COLUMN public.sales_request_master.project_no IS '프로젝트번호'; +COMMENT ON COLUMN public.sales_request_master.release_date IS '출고일'; +COMMENT ON COLUMN public.sales_request_master.request_reasons IS '요청사유'; +COMMENT ON COLUMN public.sales_request_master.request_user_id IS '요청인'; +COMMENT ON COLUMN public.sales_request_master.delivery_request_date IS '입고요청일'; +COMMENT ON COLUMN public.sales_request_master.unit_name IS '유닛명'; +COMMENT ON COLUMN public.sales_request_master.status IS '상태'; +COMMENT ON COLUMN public.sales_request_master.receipt_user_id IS '접수자'; +COMMENT ON COLUMN public.sales_request_master.receipt_date IS '접수일'; +COMMENT ON COLUMN public.sales_request_master.writer IS '작성자'; +COMMENT ON COLUMN public.sales_request_master.regdate IS '작성일'; +COMMENT ON COLUMN public.sales_request_master.remark IS '비고'; +COMMENT ON COLUMN public.sales_request_master.mbom_header_objid IS 'M-BOM 헤더 OBJID (NULL: 수동작성, 값 있음: M-BOM 자동생성)'; +COMMENT ON COLUMN public.sales_request_master.doc_type IS '문서유형 (PURCHASE_REQUEST: 구매요청서, PROPOSAL: 품의서)'; +COMMENT ON COLUMN public.sales_request_master.recipient_ref IS '수신및참조'; +COMMENT ON COLUMN public.sales_request_master.executor IS '시행자'; +COMMENT ON COLUMN public.sales_request_master.execution_date IS '시행일자'; +COMMENT ON COLUMN public.sales_request_master.title IS '제목'; + +CREATE INDEX IF NOT EXISTS idx_sales_request_master_doc_type ON public.sales_request_master (doc_type); +CREATE INDEX IF NOT EXISTS idx_sales_request_master_mbom_header ON public.sales_request_master (mbom_header_objid); + + +-- ── 3. client_mng ────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS public.client_mng ( + comp_code character varying(50), + client_cd character varying(50), + client_nm character varying(200), + tr_nmk character varying(200), + client_nmk character varying(200), + attr_nmk character varying(100), + client_type character varying(50), + bus_reg_no character varying(50), + resident_no character varying(50), + ceo_nm character varying(100), + ceo_nmk character varying(100), + bus_type character varying(100), + bus_item character varying(100), + post_no character varying(20), + addr1 character varying(300), + addr2 character varying(300), + addr_fg character varying(50), + tel_no character varying(50), + fax_no character varying(50), + homepage character varying(200), + email character varying(100), + liq_rs character varying(100), + tr_fg character varying(100), + country_nm character varying(100), + class_cd character varying(50), + class_nm character varying(100), + grade_cd character varying(50), + grade_nm character varying(100), + collect_client_cd character varying(50), + collect_client_nm character varying(200), + region_cd character varying(50), + region_nm character varying(100), + trade_start_dt character varying(20), + trade_end_dt character varying(20), + use_yn character varying(10), + contract_start_dt character varying(20), + contract_end_dt character varying(20), + trade_type character varying(50), + discount_rate character varying(20), + contract_amt character varying(20), + monthly_fee character varying(20), + payment_term character varying(200), + rcp_tp character varying(50), + credit_limit character varying(20), + limit_return_day character varying(20), + pur_bank_cd character varying(50), + pur_bank_nm character varying(200), + pur_branch_nm character varying(200), + pur_account_no character varying(100), + pur_account_holder character varying(100), + pur_pay_plan character varying(200), + pur_slip_type character varying(50), + pur_tax_type character varying(50), + sale_bank_cd character varying(50), + sale_bank_nm character varying(200), + sale_branch_nm character varying(200), + sale_account_no character varying(100), + sale_collect_plan character varying(200), + sale_slip_type character varying(50), + sale_tax_type character varying(50), + vendor_dept_nm character varying(100), + vendor_position character varying(50), + vendor_duty character varying(100), + vendor_manager_nm character varying(100), + vendor_tel character varying(50), + vendor_ext character varying(20), + vendor_mobile character varying(50), + vendor_email character varying(100), + mgr_dept_cd character varying(50), + mgr_dept_nm character varying(100), + mgr_position character varying(50), + mgr_duty character varying(100), + mgr_emp_cd character varying(50), + mgr_emp_nm character varying(100), + mgr_tel character varying(50), + mgr_ext character varying(20), + mgr_mobile character varying(50), + mgr_email character varying(100), + mgr_remark text, + rec_remark text, + rec_post_no character varying(20), + rec_addr1 character varying(300), + rec_addr2 character varying(300), + rec_addr_fg character varying(50), + rec_tel character varying(50), + rec_fax character varying(50), + project_cd character varying(50), + project_nm character varying(200), + pjt_nmk character varying(200), + ext_data_cd character varying(100), + e_tax_yn character varying(10), + unit_report_client character varying(200), + sub_bus_no character varying(50), + procurement_yn character varying(10), + user_def_dc1 character varying(200), + user_def_dc2 character varying(200), + use_fg character varying(50), + use_nm character varying(100), + bizcon_fg character varying(50), + bizcon_nm character varying(100), + ship_tp character varying(50), + ship_nm character varying(100), + plan_day_type character varying(50), + plan_day character varying(20), + purpose_type character varying(50), + for_yn character varying(10), + check_data character varying(200), + check_state character varying(100), + check_order character varying(100), + fixed_order character varying(100), + insert_id character varying(50), + insert_ip character varying(50), + insert_dt timestamp without time zone DEFAULT now(), + modify_id character varying(50), + modify_ip character varying(50), + modify_dt timestamp without time zone, + objid character varying, + CONSTRAINT uk_client_mng_client_cd UNIQUE (client_cd) +); + +CREATE INDEX IF NOT EXISTS idx_client_mng_client_cd ON public.client_mng (client_cd); +CREATE INDEX IF NOT EXISTS idx_client_mng_client_nm ON public.client_mng (client_nm); +CREATE INDEX IF NOT EXISTS idx_client_mng_use_yn ON public.client_mng (use_yn); + + +-- ── 4. user_name() 함수 ───────────────────────────────────────── +-- 그리드 WRITER_NAME / MBOM_EDITOR 표시용 (운영 1:1). +-- 매퍼 productionplanning.mBomMgmtGridList: user_name(MH.WRITER) / user_name(COALESCE(MH.EDITER, MH.WRITER)) + +CREATE OR REPLACE FUNCTION public.user_name(v_user_id character varying) +RETURNS character varying +LANGUAGE plpgsql +AS $function$ +DECLARE + v_user_name varchar; +BEGIN + SELECT user_name INTO v_user_name + FROM user_info + WHERE user_id = v_user_id; + RETURN v_user_name; +END; +$function$; From 66cee22be39d37156a0f74a9dbb188e3ba7d5795 Mon Sep 17 00:00:00 2001 From: hjjeong Date: Wed, 13 May 2026 15:57:23 +0900 Subject: [PATCH 03/12] =?UTF-8?q?=EC=83=9D=EC=82=B0=EA=B4=80=EB=A6=AC>M-BO?= =?UTF-8?q?M=20=EA=B4=80=EB=A6=AC=20=E2=80=94=20PR-A1=20=EA=B7=B8=EB=A6=AC?= =?UTF-8?q?=EB=93=9C/=EA=B2=80=EC=83=89=20(mBomMgmtGridList=201:1=20?= =?UTF-8?q?=EC=9D=B4=EC=8B=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 운영 wace productionplanning/mBomMgmtList.jsp + productionplanning.xml:2874-3119 mBomMgmtGridList 매퍼 1:1 이식. PROJECT_MGMT × CONTRACT_ITEM 펼침 그리드 + M-BOM 헤더/히스토리/구매리스트 상태 표시 + 9 검색 필터. 백엔드 (3 파일 + app.ts 마운트): - services/mbomService.ts — list() : 9 검색 필터 + 30+ 컬럼 SELECT · 주문유형/제품구분/국내해외(CODE_NAME 비교)/고객사(C_ 3-way)/유무상/SN(EXISTS) · 품번/품명(PM·CI 양쪽 LIKE)/접수일·요청납기 범위 · WRITER_NAME/MBOM_EDITOR : user_name() PL/pgSQL (PR-A0 신설) · MBOM_STATUS/MBOM_PART_NO/MBOM_REGDATE/MBOM_VERSION : mbom_header+history 서브쿼리 · PURCHASE_LIST_OBJID/_DATE : sales_request_master.mbom_header_objid 매칭 · CUSTOMER_NAME : CASE C_% → client_mng / ELSE → supply_mng - controllers/mbomController.ts — getList - routes/productionMbomRoutes.ts — GET /list - app.ts — /api/production/mbom 마운트 (productionRoutes 다음) 프론트 (3 파일): - lib/api/mbom.ts — MbomListFilter / MbomRow / mbomApi.list - app/(main)/COMPANY_16/production/mbom/page.tsx — 검색 폼 2행(12 필드) + 16 컬럼 DataGrid · comm_code 옵션 로드: /api/sales/codes/0000167 (주문유형) /0000001 (제품구분) /0001782 (유무상) · 고객사: /api/sales/customers 재사용 (customer_mng) · 국내/해외 + 유상/무상 raw 옵션 - app/(main)/COMPANY_16/purchase/mbom/page.tsx — production/mbom 페이지 re-export (사용자 요청: 구매관리 메뉴 트리에도 동일 화면 노출) 메뉴 (data-sync): - 03_mbom_menu_dedup.sql — menu_info 100016(purchase/mbom) + 100032(production/mbom) 양쪽 active 보장 (이미 DB에 등록되어 있던 entry) PR-A2 이후 분리: - 단건 상세 다이얼로그, read-only mbom_detail 트리 표시 - BOM 복사 (E-BOM→M-BOM 트리 복사) - 구매리스트 생성 액션 (M-BOM→PURCHASE) - M-BOM 본 편집 (4프레임 팝업) 검증: - backend nodemon hot-load OK (401 TOKEN_MISSING 응답으로 라우터 등록 확인) - 매퍼 SQL 직접 실행: PROJECT_MGMT × CONTRACT_ITEM 5건 + CUSTOMER/M-BOM 매칭 정상 - typecheck: 신규 코드 0 에러 (pre-existing 에러만 잔존) Co-Authored-By: Claude Opus 4.7 (1M context) --- backend-node/src/app.ts | 2 + .../src/controllers/mbomController.ts | 27 ++ .../src/routes/productionMbomRoutes.ts | 15 + backend-node/src/services/mbomService.ts | 277 +++++++++++++++++ .../data-sync/03_mbom_menu_dedup.sql | 26 ++ .../COMPANY_16/production/mbom/page.tsx | 293 ++++++++++++++++++ .../(main)/COMPANY_16/purchase/mbom/page.tsx | 7 + frontend/lib/api/mbom.ts | 74 +++++ 8 files changed, 721 insertions(+) create mode 100644 backend-node/src/controllers/mbomController.ts create mode 100644 backend-node/src/routes/productionMbomRoutes.ts create mode 100644 backend-node/src/services/mbomService.ts create mode 100644 docs/migration/production/data-sync/03_mbom_menu_dedup.sql create mode 100644 frontend/app/(main)/COMPANY_16/production/mbom/page.tsx create mode 100644 frontend/app/(main)/COMPANY_16/purchase/mbom/page.tsx create mode 100644 frontend/lib/api/mbom.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 3b35c385..9353ccd2 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -119,6 +119,7 @@ import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관 import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회 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 crawlRoutes from "./routes/crawlRoutes"; // 웹 크롤링 import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리 @@ -380,6 +381,7 @@ app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리 app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회 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/crawl", crawlRoutes); // 웹 크롤링 app.use("/api/material-status", materialStatusRoutes); // 자재현황 diff --git a/backend-node/src/controllers/mbomController.ts b/backend-node/src/controllers/mbomController.ts new file mode 100644 index 00000000..3d52ff45 --- /dev/null +++ b/backend-node/src/controllers/mbomController.ts @@ -0,0 +1,27 @@ +// ============================================================ +// 생산관리 > M-BOM 관리 (PR-A1) — wace productionplanning.xml 1:1 이식. +// 라우트: +// GET /api/production/mbom/list M-BOM 관리 그리드 (PROJECT_MGMT × CONTRACT_ITEM 펼침) +// ============================================================ + +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import * as svc from "../services/mbomService"; +import { logger } from "../utils/logger"; + +function parseFilter(q: Record): svc.MbomListFilter { + const filter: svc.MbomListFilter = { ...q }; + if (q.page) filter.page = Number(q.page); + if (q.page_size) filter.page_size = Number(q.page_size); + return filter; +} + +export async function getList(req: AuthenticatedRequest, res: Response) { + try { + const data = await svc.list(parseFilter(req.query as Record)); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("M-BOM 관리 목록 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} diff --git a/backend-node/src/routes/productionMbomRoutes.ts b/backend-node/src/routes/productionMbomRoutes.ts new file mode 100644 index 00000000..4ec199b2 --- /dev/null +++ b/backend-node/src/routes/productionMbomRoutes.ts @@ -0,0 +1,15 @@ +// ============================================================ +// 생산관리 > M-BOM 관리 (PR-A1) 라우트. +// app.ts: app.use("/api/production/mbom", productionMbomRoutes) +// ============================================================ + +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as ctrl from "../controllers/mbomController"; + +const router = Router(); +router.use(authenticateToken); + +router.get("/list", ctrl.getList); + +export default router; diff --git a/backend-node/src/services/mbomService.ts b/backend-node/src/services/mbomService.ts new file mode 100644 index 00000000..f6f52b20 --- /dev/null +++ b/backend-node/src/services/mbomService.ts @@ -0,0 +1,277 @@ +// ============================================================ +// 생산관리 > M-BOM 관리 (PR-A1) — wace productionplanning.xml 1:1 이식. +// +// 매퍼 매핑 (원본: wace_plm/src/com/pms/mapper/productionplanning.xml): +// mBomMgmtGridList → list() (라인 2874~3119, PROJECT_MGMT × CONTRACT_ITEM 펼침) +// +// 그리드 베이스: PROJECT_MGMT × CONTRACT_ITEM 펼침 (1 프로젝트 = 1+ 행). +// 9 검색 필터 + 30+ 출력 컬럼 (M-BOM 상태/저장일/작성자 + 구매리스트 매칭). +// vexplor_rps 의존: project_mgmt / contract_mgmt / contract_item / contract_item_serial +// / mbom_header (PR-A0) / mbom_history / sales_request_master / client_mng +// / supply_mng / part_bom_report / comm_code / user_info / user_name() fn +// ============================================================ + +import { getPool } from "../database/db"; + +// ─── 필터/페이지 타입 ────────────────────────────────────────── + +export interface MbomListFilter { + search_category_cd?: string; + search_product_cd?: string; + search_area_cd?: string; + search_customer_objid?: string; + search_paid_type?: string; + search_serial_no?: string; + search_part_no?: string; + search_part_name?: string; + search_receipt_date_from?: string; + search_receipt_date_to?: string; + search_req_del_date_from?: string; + search_req_del_date_to?: string; + page?: number; + page_size?: number; +} + +function paginate(filter: { page?: number; page_size?: number }) { + const page = Math.max(1, Number(filter.page) || 1); + const pageSize = Math.min(500, Math.max(1, Number(filter.page_size) || 50)); + return { limit: pageSize, offset: (page - 1) * pageSize, page, pageSize }; +} + +// ─── WHERE 절 빌더 (매퍼 mBomMgmtGridList 조건 1:1) ────── + +function buildWhere(filter: MbomListFilter, startIdx: number) { + const params: any[] = []; + const conds: string[] = []; + let idx = startIdx; + + if (filter.search_category_cd) { + conds.push(`CM.CATEGORY_CD = $${idx++}`); + params.push(filter.search_category_cd); + } + if (filter.search_product_cd) { + conds.push(`CM.PRODUCT = $${idx++}`); + params.push(filter.search_product_cd); + } + if (filter.search_area_cd) { + // 운영판: CODE_NAME(CM.AREA_CD) = '국내'/'해외' (사람이 보는 값으로 검색) + conds.push(`(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CM.AREA_CD LIMIT 1) = $${idx++}`); + params.push(filter.search_area_cd); + } + if (filter.search_receipt_date_from) { + conds.push(`PM.REGDATE >= TO_DATE($${idx++}, 'YYYY-MM-DD')`); + params.push(filter.search_receipt_date_from); + } + if (filter.search_receipt_date_to) { + conds.push(`PM.REGDATE < TO_DATE($${idx++}, 'YYYY-MM-DD') + INTERVAL '1 day'`); + params.push(filter.search_receipt_date_to); + } + if (filter.search_customer_objid) { + // 운영판 3-way 매칭 (C_xxx 양방향) 1:1 + conds.push( + `(CM.CUSTOMER_OBJID = $${idx} OR CM.CUSTOMER_OBJID = REPLACE($${idx}, 'C_', '') OR 'C_' || CM.CUSTOMER_OBJID = $${idx})` + ); + params.push(filter.search_customer_objid); + idx++; + } + if (filter.search_paid_type) { + conds.push(`CM.PAID_TYPE = $${idx++}`); + params.push(filter.search_paid_type); + } + if (filter.search_serial_no) { + conds.push( + `EXISTS (SELECT 1 FROM CONTRACT_ITEM_SERIAL CIS + WHERE CIS.ITEM_OBJID::VARCHAR = PM.CONTRACT_ITEM_OBJID + AND UPPER(CIS.STATUS) = 'ACTIVE' + AND UPPER(CIS.SERIAL_NO) LIKE '%' || UPPER($${idx++}) || '%')` + ); + params.push(filter.search_serial_no); + } + if (filter.search_req_del_date_from) { + conds.push(`COALESCE(CI.DUE_DATE, PM.DUE_DATE, CM.REQ_DEL_DATE) >= $${idx++}`); + params.push(filter.search_req_del_date_from); + } + if (filter.search_req_del_date_to) { + conds.push(`COALESCE(CI.DUE_DATE, PM.DUE_DATE, CM.REQ_DEL_DATE) <= $${idx++}`); + params.push(filter.search_req_del_date_to); + } + if (filter.search_part_no) { + conds.push( + `(UPPER(PM.PART_NO) LIKE '%' || UPPER($${idx}) || '%' OR UPPER(CI.PART_NO) LIKE '%' || UPPER($${idx}) || '%')` + ); + params.push(filter.search_part_no); + idx++; + } + if (filter.search_part_name) { + conds.push( + `(UPPER(PM.PART_NAME) LIKE '%' || UPPER($${idx}) || '%' OR UPPER(CI.PART_NAME) LIKE '%' || UPPER($${idx}) || '%')` + ); + params.push(filter.search_part_name); + idx++; + } + + return { sql: conds.length ? "AND " + conds.join(" AND ") : "", params }; +} + +// ─── M-BOM 관리 그리드 ────────────────────────────────────────── +// +// 매퍼 productionplanning.xml mBomMgmtGridList (라인 2874~3119) 1:1 이식. + +export async function list(filter: MbomListFilter) { + const { limit, offset, page, pageSize } = paginate(filter); + const where = buildWhere(filter, 1); + const pool = getPool(); + + const baseSql = ` + FROM PROJECT_MGMT PM + LEFT JOIN CONTRACT_MGMT CM ON PM.CONTRACT_OBJID = CM.OBJID + LEFT OUTER JOIN CONTRACT_ITEM CI ON ( + CASE + WHEN PM.CONTRACT_ITEM_OBJID IS NOT NULL THEN CI.OBJID::VARCHAR = PM.CONTRACT_ITEM_OBJID + ELSE CI.CONTRACT_OBJID = PM.CONTRACT_OBJID AND CI.PART_OBJID = PM.PART_OBJID + END + ) + AND CI.STATUS = 'ACTIVE' + WHERE 1=1 + AND PM.PROJECT_NO IS NOT NULL + AND PM.PROJECT_NO != '' + ${where.sql} + `; + + const dataSql = ` + SELECT + PM.OBJID, + PM.CONTRACT_OBJID, + PM.PROJECT_NO, + CM.CATEGORY_CD, + COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CM.CATEGORY_CD LIMIT 1), '') AS CATEGORY_NAME, + CM.PRODUCT, + COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CM.PRODUCT LIMIT 1), '') AS PRODUCT_NAME, + CM.AREA_CD, + COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CM.AREA_CD LIMIT 1), '') AS AREA_NAME, + TO_CHAR(PM.REGDATE, 'YYYY-MM-DD') AS RECEIPT_DATE, + (SELECT user_name(MH.WRITER) + FROM MBOM_HEADER MH + WHERE MH.PROJECT_OBJID = PM.OBJID::VARCHAR + AND MH.STATUS = 'Y' + ORDER BY MH.REGDATE DESC + LIMIT 1) AS WRITER_NAME, + CM.CUSTOMER_OBJID, + COALESCE( + CASE WHEN CM.CUSTOMER_OBJID LIKE 'C_%' + THEN (SELECT CLIENT_NM FROM CLIENT_MNG AS C + WHERE 'C_' || C.OBJID::VARCHAR = CM.CUSTOMER_OBJID LIMIT 1) + ELSE (SELECT SUPPLY_NAME FROM SUPPLY_MNG + WHERE OBJID::VARCHAR = CM.CUSTOMER_OBJID::VARCHAR LIMIT 1) END, + '' + ) AS CUSTOMER_NAME, + CM.PAID_TYPE, + COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CM.PAID_TYPE LIMIT 1), + CASE + WHEN CM.PAID_TYPE = 'paid' THEN '유상' + WHEN CM.PAID_TYPE = 'free' THEN '무상' + ELSE '' END + ) AS PAID_TYPE_NAME, + -- 품목 정보: CONTRACT_ITEM 우선, 없으면 PM + COALESCE(CI.PART_NO, PM.PART_NO, '') AS PART_NO, + COALESCE(CI.PART_NAME, PM.PART_NAME, '') AS PART_NAME, + CI.PART_OBJID, + -- S/N 표시: "첫S/N 외 N건" + (SELECT + CASE + WHEN COUNT(*) = 0 THEN '' + WHEN COUNT(*) = 1 THEN MIN(CIS.SERIAL_NO) + ELSE MIN(CIS.SERIAL_NO) || ' 외 ' || (COUNT(*) - 1)::TEXT || '건' END + FROM CONTRACT_ITEM_SERIAL CIS + WHERE CIS.ITEM_OBJID::VARCHAR = PM.CONTRACT_ITEM_OBJID + AND UPPER(CIS.STATUS) = 'ACTIVE' + AND CIS.SERIAL_NO IS NOT NULL + AND CIS.SERIAL_NO != '' + ) AS SERIAL_NO, + -- S/N 전체 (콤마 리스트) — 팝업용 + (SELECT STRING_AGG(CIS.SERIAL_NO, ', ' ORDER BY CIS.SERIAL_NO) + FROM CONTRACT_ITEM_SERIAL CIS + WHERE CIS.ITEM_OBJID::VARCHAR = PM.CONTRACT_ITEM_OBJID + AND UPPER(CIS.STATUS) = 'ACTIVE' + AND CIS.SERIAL_NO IS NOT NULL + AND CIS.SERIAL_NO != '' + ) AS SERIAL_NO_LIST, + COALESCE(NULLIF(PM.QUANTITY, '')::numeric, NULLIF(CI.ORDER_QUANTITY, '')::numeric, 0) AS QUANTITY, + COALESCE(CI.DUE_DATE, PM.DUE_DATE, CM.REQ_DEL_DATE) AS REQ_DEL_DATE, + COALESCE(CI.CUSTOMER_REQUEST, '') AS CUSTOMER_REQUEST, + -- E-BOM 정보 + COALESCE(CI.PART_OBJID, PM.PART_OBJID) AS BOM_REPORT_OBJID, + COALESCE((SELECT PBR.STATUS FROM PART_BOM_REPORT PBR + WHERE PBR.OBJID::VARCHAR = COALESCE(CI.PART_OBJID, PM.PART_OBJID) LIMIT 1), '') AS EBOM_STATUS, + COALESCE((SELECT TO_CHAR(PBR.REGDATE, 'YYYY-MM-DD') FROM PART_BOM_REPORT PBR + WHERE PBR.OBJID::VARCHAR = COALESCE(CI.PART_OBJID, PM.PART_OBJID) LIMIT 1), '') AS EBOM_REGDATE, + -- M-BOM HEADER OBJID + (SELECT MH.OBJID::VARCHAR FROM MBOM_HEADER MH + WHERE MH.PROJECT_OBJID = PM.OBJID::VARCHAR AND MH.STATUS = 'Y' + ORDER BY MH.REGDATE DESC LIMIT 1) AS MBOM_HEADER_OBJID, + -- 구매리스트 OBJID + 생성일 + (SELECT SRM.OBJID::VARCHAR FROM SALES_REQUEST_MASTER SRM + WHERE SRM.MBOM_HEADER_OBJID = ( + SELECT MH.OBJID::VARCHAR FROM MBOM_HEADER MH + WHERE MH.PROJECT_OBJID = PM.OBJID::VARCHAR AND MH.STATUS = 'Y' + ORDER BY MH.REGDATE DESC LIMIT 1 + ) LIMIT 1) AS PURCHASE_LIST_OBJID, + (SELECT TO_CHAR(SRM.REGDATE, 'YYYY-MM-DD') FROM SALES_REQUEST_MASTER SRM + WHERE SRM.MBOM_HEADER_OBJID = ( + SELECT MH.OBJID::VARCHAR FROM MBOM_HEADER MH + WHERE MH.PROJECT_OBJID = PM.OBJID::VARCHAR AND MH.STATUS = 'Y' + ORDER BY MH.REGDATE DESC LIMIT 1 + ) LIMIT 1) AS PURCHASE_LIST_DATE, + -- M-BOM 상태 ('Y' 있으면 Y, 아니면 PM.mbom_status raw) + COALESCE( + (SELECT CASE WHEN COUNT(*) > 0 THEN 'Y' ELSE COALESCE(PM.MBOM_STATUS, '') END + FROM MBOM_HEADER MH + WHERE MH.PROJECT_OBJID = PM.OBJID::VARCHAR AND MH.STATUS = 'Y' LIMIT 1), + COALESCE(PM.MBOM_STATUS, '') + ) AS MBOM_STATUS, + -- M-BOM 품번 + COALESCE( + (SELECT MH.MBOM_NO FROM MBOM_HEADER MH + WHERE MH.PROJECT_OBJID = PM.OBJID::VARCHAR AND MH.STATUS = 'Y' + ORDER BY MH.REGDATE DESC LIMIT 1), '' + ) AS MBOM_PART_NO, + -- M-BOM 저장일 (수정일 우선, 없으면 등록일) + (SELECT TO_CHAR(COALESCE(MH.EDIT_DATE, MH.REGDATE), 'YYYY-MM-DD') + FROM MBOM_HEADER MH + WHERE MH.PROJECT_OBJID = PM.OBJID::VARCHAR AND MH.STATUS = 'Y' + ORDER BY COALESCE(MH.EDIT_DATE, MH.REGDATE) DESC LIMIT 1) AS MBOM_REGDATE, + -- M-BOM 작성자/수정자 + (SELECT user_name(COALESCE(MH.EDITER, MH.WRITER)) + FROM MBOM_HEADER MH + WHERE MH.PROJECT_OBJID = PM.OBJID::VARCHAR AND MH.STATUS = 'Y' + ORDER BY COALESCE(MH.EDITER, MH.WRITER) DESC LIMIT 1) AS MBOM_EDITOR, + -- M-BOM 변경이력 카운트 (0이면 NULL) + NULLIF( + (SELECT COUNT(1)::INTEGER FROM MBOM_HISTORY MHI + WHERE MHI.MBOM_HEADER_OBJID = ( + SELECT MH.OBJID::VARCHAR FROM MBOM_HEADER MH + WHERE MH.PROJECT_OBJID = PM.OBJID::VARCHAR AND MH.STATUS = 'Y' + ORDER BY MH.REGDATE DESC LIMIT 1 + )), 0 + ) AS MBOM_VERSION + ${baseSql} + ORDER BY PM.REGDATE DESC, CI.PART_NO + LIMIT $${where.params.length + 1} OFFSET $${where.params.length + 2} + `; + + const countSql = `SELECT COUNT(*)::int AS cnt ${baseSql}`; + + const dataParams = [...where.params, limit, offset]; + + const [dataRes, countRes] = await Promise.all([ + pool.query(dataSql, dataParams), + pool.query(countSql, where.params), + ]); + + return { + rows: dataRes.rows, + totalCount: countRes.rows[0]?.cnt ?? 0, + page, + pageSize, + }; +} diff --git a/docs/migration/production/data-sync/03_mbom_menu_dedup.sql b/docs/migration/production/data-sync/03_mbom_menu_dedup.sql new file mode 100644 index 00000000..697d8eb3 --- /dev/null +++ b/docs/migration/production/data-sync/03_mbom_menu_dedup.sql @@ -0,0 +1,26 @@ +-- ============================================================ +-- M-BOM 메뉴 양쪽 노출 (PR-A1) +-- 운영판 wace 분류는 "생산관리_M-BOM관리" 단독이지만, +-- 사용자 요청 (2026-05-13): 구매관리 메뉴 트리에도 동일 M-BOM 메뉴 노출. +-- +-- menu_info 100016 (/COMPANY_16/purchase/mbom) — 구매관리 하위 +-- menu_info 100032 (/COMPANY_16/production/mbom) — 생산관리 하위 +-- 두 URL 은 동일한 page.tsx 화면 (production/mbom 의 re-export). +-- +-- 본 스크립트는 menu_info 두 항목 모두 active 보장만 한다. +-- +-- 실행: +-- PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d vexplor_rps \ +-- -f 03_mbom_menu_dedup.sql +-- ============================================================ + +UPDATE menu_info + SET status = 'active' + WHERE objid IN (100016, 100032) + AND status <> 'active'; + +-- 확인 +SELECT objid, parent_obj_id, menu_name_kor, menu_url, status + FROM menu_info + WHERE objid IN (100016, 100032) + ORDER BY objid; diff --git a/frontend/app/(main)/COMPANY_16/production/mbom/page.tsx b/frontend/app/(main)/COMPANY_16/production/mbom/page.tsx new file mode 100644 index 00000000..b17f84bc --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/production/mbom/page.tsx @@ -0,0 +1,293 @@ +"use client"; + +// 생산관리 > M-BOM 관리 (PR-A1) — wace productionplanning/mBomMgmtList.jsp 1:1 +// 그리드: PROJECT_MGMT × CONTRACT_ITEM 펼침 (1 프로젝트 = 1+ 행) + M-BOM 상태/저장일 +// 검색: 주문유형 / 제품구분 / 국내해외 / 고객사 / 유무상 / S/N / 품번 / 품명 / 접수일 / 요청납기 +// 액션 (PR-A1): 조회 / 초기화 / 페이지 +// ※ BOM 복사 / 구매리스트 생성 / M-BOM 편집 트리 다이얼로그는 PR-A2 이후 분리. + +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Search, Loader2, RotateCcw } from "lucide-react"; +import { toast } from "sonner"; +import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; +import { apiClient } from "@/lib/api/client"; +import { mbomApi, MbomListFilter, MbomRow } from "@/lib/api/mbom"; + +const PARENT_CATEGORY = "0000167"; // 주문유형 comm_code parent_code_id +const PARENT_PRODUCT = "0000001"; // 제품구분 comm_code parent_code_id +const PARENT_PAID = "0001782"; // 유/무상 (참고: 빈 결과여도 raw paid/free 매칭으로 fallback) + +interface CodeOpt { code: string; label: string; sort?: number | null } +interface CustomerOpt { id: number | string; customer_name: string | null; customer_code: string | null } + +const EMPTY_FILTER: MbomListFilter = { + search_category_cd: "", + search_product_cd: "", + search_area_cd: "", + search_customer_objid: "", + search_paid_type: "", + search_serial_no: "", + search_part_no: "", + search_part_name: "", + search_receipt_date_from: "", + search_receipt_date_to: "", + search_req_del_date_from: "", + search_req_del_date_to: "", + page: 1, + page_size: 50, +}; + +const GRID_COLUMNS: DataGridColumn[] = [ + { key: "project_no", label: "프로젝트번호", width: "w-[140px]" }, + { key: "category_name", label: "주문유형", width: "w-[100px]", align: "center" }, + { key: "product_name", label: "제품구분", width: "w-[90px]", align: "center" }, + { key: "area_name", label: "국내/해외", width: "w-[90px]", align: "center" }, + { key: "receipt_date", label: "접수일", width: "w-[100px]", align: "center" }, + { key: "writer_name", label: "작성자", width: "w-[90px]", align: "center" }, + { key: "customer_name", label: "고객사", minWidth: "min-w-[160px]" }, + { key: "paid_type_name", label: "유/무상", width: "w-[80px]", align: "center" }, + { key: "part_no", label: "품번", width: "w-[150px]" }, + { key: "part_name", label: "품명", minWidth: "min-w-[180px]" }, + { key: "serial_no", label: "S/N", width: "w-[110px]", align: "center" }, + { key: "quantity", label: "수주수량", width: "w-[90px]", align: "right", formatNumber: true }, + { key: "req_del_date", label: "요청납기", width: "w-[100px]", align: "center" }, + { key: "customer_request", label: "고객사요청사항", minWidth: "min-w-[200px]" }, + { key: "mbom_status", label: "M-BOM", width: "w-[80px]", align: "center" }, + { key: "mbom_regdate", label: "최종저장일", width: "w-[100px]", align: "center" }, +]; + +export default function MbomMgmtPage() { + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [filter, setFilter] = useState(EMPTY_FILTER); + + const [categoryOpts, setCategoryOpts] = useState([]); + const [productOpts, setProductOpts] = useState([]); + const [paidOpts, setPaidOpts] = useState([]); + const [customerOpts, setCustomerOpts] = useState([]); + + const fetchList = useCallback(async (override?: Partial) => { + setLoading(true); + try { + const f = { ...filter, ...override }; + const res = await mbomApi.list(f); + setRows(res.rows ?? []); + setTotal(res.totalCount ?? 0); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패"); + } finally { + setLoading(false); + } + }, [filter]); + + // 초기 옵션 + 첫 조회 + useEffect(() => { + let dead = false; + (async () => { + try { + const [c1, c2, c3, cust] = await Promise.all([ + apiClient.get(`/sales/codes/${PARENT_CATEGORY}`), + apiClient.get(`/sales/codes/${PARENT_PRODUCT}`), + apiClient.get(`/sales/codes/${PARENT_PAID}`), + apiClient.get(`/sales/customers`), + ]); + if (dead) return; + setCategoryOpts(c1.data?.data ?? []); + setProductOpts(c2.data?.data ?? []); + setPaidOpts(c3.data?.data ?? []); + setCustomerOpts(cust.data?.data ?? []); + } catch { + /* 옵션 로드 실패는 무시 — 그리드는 조회 가능 */ + } + })(); + fetchList(EMPTY_FILTER); + return () => { dead = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // DataGrid 키 부여 (objid + part_no 조합 — 같은 프로젝트 다중 행 unique) + const gridRows = useMemo( + () => rows.map((r, i) => ({ ...r, id: `${r.objid}__${r.part_no ?? ""}__${i}` })), + [rows] + ); + + const handleSearch = () => { + setFilter((f) => ({ ...f, page: 1 })); + fetchList({ page: 1 }); + }; + + const handleReset = () => { + setFilter(EMPTY_FILTER); + fetchList(EMPTY_FILTER); + }; + + return ( +
+
+ {/* 운영판 wace mBomMgmtList.jsp 1:1 — 2행 검색 폼 */} +
+
+ + setFilter({ ...filter, search_category_cd: v })} + /> + + + setFilter({ ...filter, search_product_cd: v })} + /> + + + + + + + + + + + + setFilter({ ...filter, search_serial_no: e.target.value })} + /> + +
+
+ + setFilter({ ...filter, search_part_no: e.target.value })} + /> + + + setFilter({ ...filter, search_part_name: e.target.value })} + /> + + + setFilter({ ...filter, search_receipt_date_from: e.target.value })} + /> + + + setFilter({ ...filter, search_receipt_date_to: e.target.value })} + /> + + + setFilter({ ...filter, search_req_del_date_from: e.target.value })} + /> + + + setFilter({ ...filter, search_req_del_date_to: e.target.value })} + /> + +
+
+ +
+
+ 총 {total.toLocaleString()}건 · PROJECT_MGMT × CONTRACT_ITEM 펼침 +
+
+ + +
+
+
+ +
+ +
+
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} + +function SelectBox({ + value, options, onChange, +}: { value: string; options: CodeOpt[]; onChange: (v: string) => void }) { + return ( + + ); +} diff --git a/frontend/app/(main)/COMPANY_16/purchase/mbom/page.tsx b/frontend/app/(main)/COMPANY_16/purchase/mbom/page.tsx new file mode 100644 index 00000000..0daa918f --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/purchase/mbom/page.tsx @@ -0,0 +1,7 @@ +// 구매관리 > M-BOM 관리 — production/mbom 페이지 re-export. +// 사용자 요청 (2026-05-13): wace 운영판은 "생산관리_M-BOM관리" 1:1 이지만, +// 구매관리 메뉴 트리에서도 동일 화면 진입을 허용한다. +// +// menu_info: 100016 (purchase/mbom) + 100032 (production/mbom) — 둘 다 active. + +export { default } from "../../production/mbom/page"; diff --git a/frontend/lib/api/mbom.ts b/frontend/lib/api/mbom.ts new file mode 100644 index 00000000..6f75fd46 --- /dev/null +++ b/frontend/lib/api/mbom.ts @@ -0,0 +1,74 @@ +import { apiClient } from "./client"; + +// ============================================================ +// 생산관리 > M-BOM 관리 (PR-A1) — wace productionplanning.xml 1:1 +// 라우트: /api/production/mbom/* +// ============================================================ + +export interface MbomListFilter { + search_category_cd?: string; + search_product_cd?: string; + search_area_cd?: string; + search_customer_objid?: string; + search_paid_type?: string; + search_serial_no?: string; + search_part_no?: string; + search_part_name?: string; + search_receipt_date_from?: string; + search_receipt_date_to?: string; + search_req_del_date_from?: string; + search_req_del_date_to?: string; + page?: number; + page_size?: number; +} + +export interface MbomRow { + objid: string; + contract_objid: string | null; + project_no: string | null; + category_cd: string | null; + category_name: string | null; + product: string | null; + product_name: string | null; + area_cd: string | null; + area_name: string | null; + receipt_date: string | null; + writer_name: string | null; + customer_objid: string | null; + customer_name: string | null; + paid_type: string | null; + paid_type_name: string | null; + part_no: string | null; + part_name: string | null; + part_objid: string | null; + serial_no: string | null; + serial_no_list: string | null; + quantity: string | number | null; + req_del_date: string | null; + customer_request: string | null; + bom_report_objid: string | null; + ebom_status: string | null; + ebom_regdate: string | null; + mbom_header_objid: string | null; + purchase_list_objid: string | null; + purchase_list_date: string | null; + mbom_status: string | null; + mbom_part_no: string | null; + mbom_regdate: string | null; + mbom_editor: string | null; + mbom_version: number | null; +} + +export interface MbomListResponse { + rows: MbomRow[]; + totalCount: number; + page: number; + pageSize: number; +} + +export const mbomApi = { + async list(filter: MbomListFilter = {}): Promise { + const res = await apiClient.get("/production/mbom/list", { params: filter }); + return res.data?.data as MbomListResponse; + }, +}; From 55239547d6158e60f3632b42b0c28fd7189c0684 Mon Sep 17 00:00:00 2001 From: hjjeong Date: Wed, 13 May 2026 16:29:38 +0900 Subject: [PATCH 04/12] =?UTF-8?q?=EC=83=9D=EC=82=B0=EA=B4=80=EB=A6=AC>data?= =?UTF-8?q?-sync=20=E2=80=94=20production=5Fplan=20=EC=8B=A0=EC=84=A4=20+?= =?UTF-8?q?=20=EC=9A=B4=EC=98=81=20sample=202=ED=96=89=20=EC=9D=B4?= =?UTF-8?q?=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-A2 단건 상세의 TOTAL_PROD_QTY 서브쿼리(`SELECT NULLIF(PP.TOTAL_PROD_QTY,'')::numeric FROM PRODUCTION_PLAN PP WHERE PP.PROJECT_OBJID=PM.OBJID AND UPPER(PP.STATUS)='ACTIVE'`) 의존 테이블을 운영판 11133.waceplm에서 1:1 추출해 RPS 11134.vexplor_rps에 신설. - 402_production_plan.sql: 19 컬럼 / 4 인덱스 (objid, project_objid, total_prod_qty 포함) - 04_production_plan_sync.sql: 운영 sample 2행 - 1058002488 / W/M ASSY (-1752090174, total=4) - 1439133152 / O-RING ( 1157387151, total=5) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../data-sync/04_production_plan_sync.sql | 25 ++++++++++++ .../ddl-extracted/402_production_plan.sql | 40 +++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 docs/migration/production/data-sync/04_production_plan_sync.sql create mode 100644 docs/migration/production/ddl-extracted/402_production_plan.sql diff --git a/docs/migration/production/data-sync/04_production_plan_sync.sql b/docs/migration/production/data-sync/04_production_plan_sync.sql new file mode 100644 index 00000000..d05cbc3b --- /dev/null +++ b/docs/migration/production/data-sync/04_production_plan_sync.sql @@ -0,0 +1,25 @@ +-- ============================================================ +-- 운영판 wace 211.115.91.141:11133.production_plan → RPS 11134.production_plan +-- 2건 1:1 이관 (PR-A2 단건 상세 TOTAL_PROD_QTY 검증용) +-- ============================================================ + +-- 운영판 sample 1: W/M ASSY (project_objid=-1752090174, total_prod_qty=4) +-- 운영판 sample 2: O-RING (project_objid=1157387151, total_prod_qty=5) + +INSERT INTO production_plan + (objid, project_objid, product_code, category_code, production_type, customer_objid, + req_del_date, part_no, part_name, serial_no, + order_qty, extra_prod_qty, total_prod_qty, + customer_request, status, regdate, writer, moddate, modifier) +VALUES + ('1058002488', '-1752090174', '0001807', '0001792', '0001833', '0000007555', + '2026-04-06', '11030-0059', 'W/M ASSY (RWMR1070-NO07 LH)', NULL, + '2', '2', '4', + '납기 준수 必', 'active', '2026-03-19 06:36:13.087597', 'jsm1014', NULL, NULL), + ('1439133152', '1157387151', '0001539', '0001791', '0001833', '0000010054', + '2026-03-24', '000AN033000', 'O-RING', + 'item-001001, item-001002, item-001003, item-001004, item-001005, item-001006, item-001007, item-001008, item-001009, item-001010', + '1', '4', '5', + NULL, 'active', '2026-03-24 01:52:10.332136', 'plm_admin', + '2026-03-24 01:53:47.912482', 'plm_admin') +ON CONFLICT (objid) DO NOTHING; diff --git a/docs/migration/production/ddl-extracted/402_production_plan.sql b/docs/migration/production/ddl-extracted/402_production_plan.sql new file mode 100644 index 00000000..1d62d6e7 --- /dev/null +++ b/docs/migration/production/ddl-extracted/402_production_plan.sql @@ -0,0 +1,40 @@ +-- ============================================================ +-- 운영판 wace 211.115.91.141:11133 production_plan 1:1 추출 +-- 추출일: 2026-05-13 +-- 추출 명령: psql ... -c "\d production_plan" +-- +-- 용도: 생산관리>M-BOM 관리 PR-A2 의 getProjectMgmtDetail.TOTAL_PROD_QTY 서브쿼리 의존 +-- COALESCE((SELECT NULLIF(PP.TOTAL_PROD_QTY,'')::numeric +-- FROM PRODUCTION_PLAN PP +-- WHERE PP.PROJECT_OBJID = PM.OBJID +-- AND UPPER(PP.STATUS) = 'ACTIVE' LIMIT 1), +-- COALESCE(NULLIF(PM.QUANTITY,'')::numeric, 0)) AS TOTAL_PROD_QTY +-- ============================================================ + +CREATE TABLE IF NOT EXISTS public.production_plan ( + objid character varying(50) NOT NULL, + project_objid character varying(50), + product_code character varying(50), + category_code character varying(50), + production_type character varying(50), + customer_objid character varying(50), + req_del_date character varying(20), + part_no character varying(100), + part_name character varying(200), + serial_no character varying(500), + order_qty character varying(20) DEFAULT '0', + extra_prod_qty character varying(20) DEFAULT '0', + total_prod_qty character varying(20) DEFAULT '0', + customer_request character varying(500), + status character varying(20) DEFAULT 'active', + regdate timestamp without time zone DEFAULT now(), + writer character varying(50), + moddate timestamp without time zone, + modifier character varying(50), + CONSTRAINT production_plan_pkey PRIMARY KEY (objid) +); + +CREATE INDEX IF NOT EXISTS idx_production_plan_customer ON public.production_plan USING btree (customer_objid); +CREATE INDEX IF NOT EXISTS idx_production_plan_project ON public.production_plan USING btree (project_objid); +CREATE INDEX IF NOT EXISTS idx_production_plan_regdate ON public.production_plan USING btree (regdate); +CREATE INDEX IF NOT EXISTS idx_production_plan_status ON public.production_plan USING btree (status); From dd88dc6e8c0d0d0b2fcb4c209d53681e6ba51dcd Mon Sep 17 00:00:00 2001 From: hjjeong Date: Wed, 13 May 2026 16:29:52 +0900 Subject: [PATCH 05/12] =?UTF-8?q?=EC=83=9D=EC=82=B0=EA=B4=80=EB=A6=AC>M-BO?= =?UTF-8?q?M=20=EA=B4=80=EB=A6=AC=20=E2=80=94=20PR-A2=20=EB=8B=A8=EA=B1=B4?= =?UTF-8?q?=20=EC=83=81=EC=84=B8=20+=20read-only=20=ED=8A=B8=EB=A6=AC=204?= =?UTF-8?q?=EB=B6=84=EA=B8=B0=20(wace=20mBomPopupLeft.do=201:1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 행 더블클릭 → MbomDetailDialog (헤더 메타 + 동적 LEVEL × 19컬럼 트리 그리드). 운영판 ProductionPlanningController:1113~1276 의 4분기 자동 판별을 백엔드에서 처리: 1) SAVED mbom_header.status='Y' 우선 → getSavedMbomTreeList CTE 2) ASSIGNED_EBOM source_bom_type='EBOM' → partMng.getBOMTreeList(working) CTE 3) ASSIGNED_MBOM source_bom_type='MBOM' → getMbomStructureOnly CTE 4) TEMPLATE Machine 이외 + 동일 part_no → mbom_header 템플릿 CTE 5) NONE 빈 트리 backend: - mbomService.getDetail (getProjectMgmtDetail 1:1, TOTAL_PROD_QTY = production_plan 우선) - mbomService.getTree (4분기 orchestrator + 매퍼 4종 CTE 1:1) - GET /api/production/mbom/detail/:objid - GET /api/production/mbom/tree/:objid frontend: - lib/api/mbom.ts : MbomDetail / MbomTreeRow / MbomBomDataType / getDetail / getTree - components/production/MbomDetailDialog.tsx (max-w-1600px, 헤더 14필드 + 트리 그리드) - page.tsx 행 더블클릭 핸들러 검증: O-RING (593315995) SAVED 분기 5행 정상. TOTAL_PROD_QTY production_plan=5 / QUANTITY=2 fallback 확인. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/controllers/mbomController.ts | 31 +- .../src/routes/productionMbomRoutes.ts | 6 +- backend-node/src/services/mbomService.ts | 605 +++++++++++++++++- .../COMPANY_16/production/mbom/page.tsx | 23 +- .../production/MbomDetailDialog.tsx | 232 +++++++ frontend/lib/api/mbom.ts | 112 +++- 6 files changed, 992 insertions(+), 17 deletions(-) create mode 100644 frontend/components/production/MbomDetailDialog.tsx diff --git a/backend-node/src/controllers/mbomController.ts b/backend-node/src/controllers/mbomController.ts index 3d52ff45..0ad892ff 100644 --- a/backend-node/src/controllers/mbomController.ts +++ b/backend-node/src/controllers/mbomController.ts @@ -1,7 +1,9 @@ // ============================================================ -// 생산관리 > M-BOM 관리 (PR-A1) — wace productionplanning.xml 1:1 이식. +// 생산관리 > M-BOM 관리 — wace productionplanning.xml 1:1 이식. // 라우트: -// GET /api/production/mbom/list M-BOM 관리 그리드 (PROJECT_MGMT × CONTRACT_ITEM 펼침) +// GET /api/production/mbom/list 그리드 (PROJECT_MGMT × CONTRACT_ITEM 펼침) +// GET /api/production/mbom/detail/:objid 단건 상세 (mBomHeaderPopup.do 1:1) +// GET /api/production/mbom/tree/:objid read-only 트리 4분기 자동 판별 (mBomPopupLeft.do 1:1) // ============================================================ import { Response } from "express"; @@ -25,3 +27,28 @@ export async function getList(req: AuthenticatedRequest, res: Response) { return res.status(500).json({ success: false, message: e.message }); } } + +export async function getDetail(req: AuthenticatedRequest, res: Response) { + try { + const objid = String(req.params.objid ?? "").trim(); + if (!objid) return res.status(400).json({ success: false, message: "objid 누락" }); + const data = await svc.getDetail(objid); + if (!data) return res.status(404).json({ success: false, message: "프로젝트를 찾을 수 없습니다" }); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("M-BOM 단건 상세 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +export async function getTree(req: AuthenticatedRequest, res: Response) { + try { + const objid = String(req.params.objid ?? "").trim(); + if (!objid) return res.status(400).json({ success: false, message: "objid 누락" }); + const data = await svc.getTree(objid); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("M-BOM 트리 조회 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} diff --git a/backend-node/src/routes/productionMbomRoutes.ts b/backend-node/src/routes/productionMbomRoutes.ts index 4ec199b2..392cd071 100644 --- a/backend-node/src/routes/productionMbomRoutes.ts +++ b/backend-node/src/routes/productionMbomRoutes.ts @@ -1,5 +1,5 @@ // ============================================================ -// 생산관리 > M-BOM 관리 (PR-A1) 라우트. +// 생산관리 > M-BOM 관리 라우트. // app.ts: app.use("/api/production/mbom", productionMbomRoutes) // ============================================================ @@ -10,6 +10,8 @@ import * as ctrl from "../controllers/mbomController"; const router = Router(); router.use(authenticateToken); -router.get("/list", ctrl.getList); +router.get("/list", ctrl.getList); +router.get("/detail/:objid", ctrl.getDetail); +router.get("/tree/:objid", ctrl.getTree); export default router; diff --git a/backend-node/src/services/mbomService.ts b/backend-node/src/services/mbomService.ts index f6f52b20..6a1f57f0 100644 --- a/backend-node/src/services/mbomService.ts +++ b/backend-node/src/services/mbomService.ts @@ -1,14 +1,23 @@ // ============================================================ -// 생산관리 > M-BOM 관리 (PR-A1) — wace productionplanning.xml 1:1 이식. +// 생산관리 > M-BOM 관리 — wace productionplanning.xml 1:1 이식. // -// 매퍼 매핑 (원본: wace_plm/src/com/pms/mapper/productionplanning.xml): -// mBomMgmtGridList → list() (라인 2874~3119, PROJECT_MGMT × CONTRACT_ITEM 펼침) +// 매퍼 매핑 (원본: wace_plm/src/com/pms/mapper/productionplanning.xml, +// wace_plm/src/com/pms/mapper/partMng.xml): +// mBomMgmtGridList → list() (PR-A1, ~3119) +// getProjectMgmtDetail → getDetail() (PR-A2, ~3218) +// getLatestMbomByProjectId → getLatestSavedMbom() (PR-A2, ~3570) +// getLatestMbomTemplateByPartNo → getLatestTemplate() (PR-A2, ~3591) +// getSavedMbomTreeList → getSavedTree() (PR-A2, ~4359) +// getMbomStructureOnly → getStructureOnly() (PR-A2, ~4538) +// getMbomTemplateDetails → getTemplateDetails() (PR-A2, ~3794) +// partMng.getBOMTreeList → getEbomWorkingTree() (PR-A2, partMng.xml ~3549) // -// 그리드 베이스: PROJECT_MGMT × CONTRACT_ITEM 펼침 (1 프로젝트 = 1+ 행). -// 9 검색 필터 + 30+ 출력 컬럼 (M-BOM 상태/저장일/작성자 + 구매리스트 매칭). -// vexplor_rps 의존: project_mgmt / contract_mgmt / contract_item / contract_item_serial -// / mbom_header (PR-A0) / mbom_history / sales_request_master / client_mng -// / supply_mng / part_bom_report / comm_code / user_info / user_name() fn +// 트리 분기 (mBomPopupLeft.do 1:1): +// 1) SAVED — mbom_header 에 status='Y' 가 있으면 그 트리 +// 2) ASSIGNED_EBOM — project_mgmt.source_bom_type='EBOM' + source_ebom_objid +// 3) ASSIGNED_MBOM — project_mgmt.source_bom_type='MBOM' + source_mbom_objid +// 4) TEMPLATE — Machine 이외(product != '0000928') + part_no 있으면 동일 품번 mbom_header +// 5) NONE — 빈 트리 // ============================================================ import { getPool } from "../database/db"; @@ -275,3 +284,583 @@ export async function list(filter: MbomListFilter) { pageSize, }; } + +// ─── 단건 상세 (getProjectMgmtDetail) ────────────────────────── +// +// 매퍼 productionplanning.xml getProjectMgmtDetail (라인 3150~3218) 1:1. +// TOTAL_PROD_QTY = production_plan 우선 → PM.QUANTITY fallback. + +export async function getDetail(objid: string) { + const pool = getPool(); + const sql = ` + SELECT + PM.OBJID::VARCHAR AS objid, + PM.CONTRACT_OBJID AS contract_objid, + PM.PROJECT_NO AS project_no, + PM.BOM_REPORT_OBJID AS bom_report_objid, + PM.PART_OBJID AS part_objid, + PM.PART_NO AS part_no, + PM.PART_NAME AS part_name, + PM.SOURCE_BOM_TYPE AS source_bom_type, + PM.SOURCE_EBOM_OBJID AS source_ebom_objid, + PM.SOURCE_MBOM_OBJID AS source_mbom_objid, + PM.QUANTITY AS quantity, + COALESCE( + (SELECT NULLIF(PP.TOTAL_PROD_QTY, '')::numeric + FROM PRODUCTION_PLAN PP + WHERE PP.PROJECT_OBJID = PM.OBJID::VARCHAR + AND UPPER(PP.STATUS) = 'ACTIVE' + LIMIT 1), + COALESCE(NULLIF(PM.QUANTITY, '')::numeric, 0) + ) AS total_prod_qty, + COALESCE( + (SELECT PBR.PART_NO FROM PART_BOM_REPORT PBR + WHERE PBR.OBJID::VARCHAR = PM.BOM_REPORT_OBJID + AND PM.MBOM_STATUS = 'Y' + LIMIT 1), '' + ) AS mbom_part_no, + CM.CATEGORY_CD AS category_cd, + COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CM.CATEGORY_CD LIMIT 1), '') AS category_name, + CM.PRODUCT AS product, + CM.PRODUCT AS product_code, + COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CM.PRODUCT LIMIT 1), '') AS product_name, + CM.AREA_CD AS area_cd, + COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CM.AREA_CD LIMIT 1), '') AS area_name, + CM.CUSTOMER_OBJID AS customer_objid, + COALESCE( + CASE WHEN CM.CUSTOMER_OBJID LIKE 'C_%' + THEN (SELECT CLIENT_NM FROM CLIENT_MNG AS C + WHERE 'C_' || C.OBJID::VARCHAR = CM.CUSTOMER_OBJID LIMIT 1) + ELSE (SELECT SUPPLY_NAME FROM SUPPLY_MNG + WHERE OBJID::VARCHAR = CM.CUSTOMER_OBJID::VARCHAR LIMIT 1) END, + '' + ) AS customer_name, + CM.PAID_TYPE AS paid_type, + CM.REQ_DEL_DATE AS req_del_date, + TO_CHAR(PM.REGDATE, 'YYYY-MM-DD') AS receipt_date, + COALESCE( + (SELECT TO_CHAR(PBR.REGDATE, 'YYYY-MM-DD') + FROM PART_BOM_REPORT PBR + WHERE PBR.OBJID::VARCHAR = PM.BOM_REPORT_OBJID + AND PM.MBOM_STATUS = 'Y' + LIMIT 1), + TO_CHAR(PM.REGDATE, 'YYYY-MM-DD') + ) AS mbom_regdate + FROM PROJECT_MGMT PM + INNER JOIN CONTRACT_MGMT CM ON PM.CONTRACT_OBJID = CM.OBJID + WHERE PM.OBJID::VARCHAR = $1 + LIMIT 1 + `; + const r = await pool.query(sql, [objid]); + return r.rows[0] ?? null; +} + +// ─── 분기 진입점 (mBomPopupLeft.do 1:1) ──────────────────────── +// +// 운영판 ProductionPlanningController:1113~1276 의 분기 로직: +// 1) 저장된 M-BOM (mbom_header.status='Y') 우선 +// 2) 없으면 source_bom_type 으로 EBOM/MBOM 분기 +// 3) 그래도 없으면 Machine 이외 + part_no 매칭으로 템플릿 +// 4) 모두 없으면 빈 트리 + +export type BomDataType = "SAVED" | "ASSIGNED_EBOM" | "ASSIGNED_MBOM" | "TEMPLATE" | "NONE"; + +export interface MbomTreeResult { + bom_data_type: BomDataType; + bom_report_objid: string | null; + max_level: number; + rows: any[]; +} + +export async function getTree(objid: string): Promise { + const detail = await getDetail(objid); + if (!detail) { + return { bom_data_type: "NONE", bom_report_objid: null, max_level: 1, rows: [] }; + } + + // 1) SAVED — getLatestMbomByProjectId + const saved = await getLatestSavedMbom(objid); + if (saved && saved.objid) { + const rows = await getSavedTree(saved.objid); + return finalize("SAVED", saved.objid, rows); + } + + // 2) ASSIGNED_EBOM + if (detail.source_bom_type === "EBOM" && detail.source_ebom_objid) { + const rows = await getEbomWorkingTree(detail.source_ebom_objid); + return finalize("ASSIGNED_EBOM", detail.source_ebom_objid, rows); + } + + // 3) ASSIGNED_MBOM + if (detail.source_bom_type === "MBOM" && detail.source_mbom_objid) { + const rows = await getStructureOnly(detail.source_mbom_objid); + return finalize("ASSIGNED_MBOM", detail.source_mbom_objid, rows); + } + + // 4) TEMPLATE — Machine 이외 + part_no + if (detail.product_code !== "0000928" && detail.part_no) { + const tpl = await getLatestTemplate(detail.part_no); + if (tpl && tpl.template_header_objid) { + const rows = await getTemplateDetails(tpl.template_header_objid); + return finalize("TEMPLATE", tpl.template_header_objid, rows); + } + } + + // 5) NONE + return { bom_data_type: "NONE", bom_report_objid: detail.bom_report_objid ?? null, max_level: 1, rows: [] }; +} + +function finalize(type: BomDataType, bomReportObjid: string, rows: any[]): MbomTreeResult { + let maxLevel = 1; + for (const r of rows) { + const lv = Number(r.level ?? r.LEVEL ?? 1); + if (lv > maxLevel) maxLevel = lv; + } + return { bom_data_type: type, bom_report_objid: bomReportObjid, max_level: maxLevel, rows }; +} + +// ─── 분기 1) SAVED 진입 ────────────────────────────────────── +// +// 매퍼 getLatestMbomByProjectId (productionplanning.xml:3555~3570) 1:1. + +async function getLatestSavedMbom(projectObjId: string) { + const pool = getPool(); + const r = await pool.query( + `SELECT OBJID::VARCHAR AS objid, MBOM_NO AS mbom_no, + SOURCE_BOM_TYPE AS source_bom_type, + SOURCE_EBOM_OBJID AS source_ebom_objid, + SOURCE_MBOM_OBJID AS source_mbom_objid, + PROJECT_OBJID AS project_objid, STATUS AS status, REGDATE AS regdate + FROM MBOM_HEADER + WHERE PROJECT_OBJID = $1 AND STATUS = 'Y' + ORDER BY REGDATE DESC LIMIT 1`, + [projectObjId], + ); + return r.rows[0] ?? null; +} + +// ─── 분기 4) TEMPLATE 진입 ─────────────────────────────────── +// +// 매퍼 getLatestMbomTemplateByPartNo (productionplanning.xml:3573~3591) 1:1. + +async function getLatestTemplate(partNo: string) { + const pool = getPool(); + const r = await pool.query( + `SELECT MH.OBJID::VARCHAR AS template_header_objid, + MH.MBOM_NO AS template_mbom_no, + MH.PART_NO AS part_no, + MH.PART_NAME AS part_name, + MH.SOURCE_BOM_TYPE AS source_bom_type, + MH.SOURCE_EBOM_OBJID AS source_ebom_objid, + MH.SOURCE_MBOM_OBJID AS source_mbom_objid, + TO_CHAR(MH.REGDATE, 'YYYY-MM-DD HH24:MI:SS') AS regdate + FROM MBOM_HEADER MH + INNER JOIN PROJECT_MGMT PM ON MH.PROJECT_OBJID = PM.OBJID::VARCHAR + INNER JOIN CONTRACT_MGMT CM ON PM.CONTRACT_OBJID = CM.OBJID + WHERE MH.PART_NO = $1 + AND MH.STATUS = 'Y' + AND CM.PRODUCT != '0000928' + ORDER BY MH.REGDATE DESC LIMIT 1`, + [partNo], + ); + return r.rows[0] ?? null; +} + +// ─── 분기 1-SAVED 트리 ─────────────────────────────────────── +// +// 매퍼 getSavedMbomTreeList (productionplanning.xml:4114~4359) 1:1. +// RECURSIVE CTE + PART_MNG 조인 + ATTACH_FILE_INFO 카운트 (CU01/02/03_CNT) + 소재소요량. + +async function getSavedTree(mbomHeaderObjid: string) { + const pool = getPool(); + const r = await pool.query(SAVED_TREE_SQL, [mbomHeaderObjid]); + return r.rows; +} + +// ─── 분기 3-ASSIGNED_MBOM 구조만 ───────────────────────────── +// +// 매퍼 getMbomStructureOnly (productionplanning.xml:4362~4538) 1:1. +// 생산 정보는 NULL — 구조만 표시. + +async function getStructureOnly(mbomHeaderObjid: string) { + const pool = getPool(); + const r = await pool.query(STRUCTURE_ONLY_SQL, [mbomHeaderObjid]); + return r.rows; +} + +// ─── 분기 4-TEMPLATE 트리 ──────────────────────────────────── +// +// 매퍼 getMbomTemplateDetails (productionplanning.xml:3594~3794) 1:1. +// ORDER_QTY/PRODUCTION_QTY 가 빠진 점만 SAVED 와 다름. + +async function getTemplateDetails(mbomHeaderObjid: string) { + const pool = getPool(); + const r = await pool.query(TEMPLATE_TREE_SQL, [mbomHeaderObjid]); + return r.rows; +} + +// ─── 분기 2-ASSIGNED_EBOM 트리 ─────────────────────────────── +// +// 매퍼 partMng.getBOMTreeList (partMng.xml:3289~3549) - search_type='working' 1:1. +// bom_part_qty RECURSIVE CTE + PART_MNG 조인. + +async function getEbomWorkingTree(bomReportObjid: string) { + const pool = getPool(); + const r = await pool.query(EBOM_WORKING_TREE_SQL, [bomReportObjid]); + return r.rows; +} + +// ─── 트리 SELECT 본문 (매퍼 4종 1:1, lowercase alias) ────── + +const SAVED_TREE_SQL = ` +WITH RECURSIVE VIEW_BOM( + MBOM_HEADER_OBJID, OBJID, PARENT_OBJID, CHILD_OBJID, + PART_OBJID, PART_NO, PART_NAME, QTY, ITEM_QTY, QTY_TEMP, + REGDATE, SEQ, STATUS, LEV, PATH, PATH2, CYCLE, + UNIT, SUPPLY_TYPE, MAKE_OR_BUY, + RAW_MATERIAL_PART_NO, RAW_MATERIAL_SPEC, RAW_MATERIAL, RAW_MATERIAL_SIZE, + PROCESSING_VENDOR, PROCESSING_DEADLINE, GRINDING_DEADLINE, + REQUIRED_QTY, ORDER_QTY, PRODUCTION_QTY, STOCK_QTY, SHORTAGE_QTY, + VENDOR, UNIT_PRICE, PROCESSING_UNIT_PRICE, TOTAL_PRICE, CURRENCY, + LEAD_TIME, MIN_ORDER_QTY, WRITER, EDITER, EDIT_DATE, REMARK +) AS ( + SELECT A.MBOM_HEADER_OBJID, A.OBJID, A.PARENT_OBJID, A.CHILD_OBJID, + A.PART_OBJID, A.PART_NO, A.PART_NAME, A.QTY, A.ITEM_QTY, A.QTY, + A.REGDATE, A.SEQ, A.STATUS, 1, + ARRAY [A.CHILD_OBJID::TEXT], + ARRAY [LPAD(A.SEQ::TEXT, 10, '0')], + FALSE, + A.UNIT, A.SUPPLY_TYPE, A.MAKE_OR_BUY, + A.RAW_MATERIAL_PART_NO, A.RAW_MATERIAL_SPEC, A.RAW_MATERIAL, A.RAW_MATERIAL_SIZE, + A.PROCESSING_VENDOR, A.PROCESSING_DEADLINE, A.GRINDING_DEADLINE, + A.REQUIRED_QTY, A.ORDER_QTY, A.PRODUCTION_QTY, A.STOCK_QTY, A.SHORTAGE_QTY, + A.VENDOR, A.UNIT_PRICE, A.PROCESSING_UNIT_PRICE, A.TOTAL_PRICE, A.CURRENCY, + A.LEAD_TIME, A.MIN_ORDER_QTY, A.WRITER, A.EDITER, A.EDIT_DATE, A.REMARK + FROM MBOM_DETAIL A + WHERE 1=1 + AND (A.PARENT_OBJID IS NULL OR A.PARENT_OBJID = '') + AND A.MBOM_HEADER_OBJID = $1 + AND A.STATUS = 'ACTIVE' + UNION ALL + SELECT B.MBOM_HEADER_OBJID, B.OBJID, B.PARENT_OBJID, B.CHILD_OBJID, + B.PART_OBJID, B.PART_NO, B.PART_NAME, B.QTY, B.ITEM_QTY, B.QTY, + B.REGDATE, B.SEQ, B.STATUS, LEV + 1, + PATH || B.CHILD_OBJID::TEXT, + PATH2 || LPAD(B.SEQ::TEXT, 10, '0'), + B.PARENT_OBJID = ANY(PATH), + B.UNIT, B.SUPPLY_TYPE, B.MAKE_OR_BUY, + B.RAW_MATERIAL_PART_NO, B.RAW_MATERIAL_SPEC, B.RAW_MATERIAL, B.RAW_MATERIAL_SIZE, + B.PROCESSING_VENDOR, B.PROCESSING_DEADLINE, B.GRINDING_DEADLINE, + B.REQUIRED_QTY, B.ORDER_QTY, B.PRODUCTION_QTY, B.STOCK_QTY, B.SHORTAGE_QTY, + B.VENDOR, B.UNIT_PRICE, B.PROCESSING_UNIT_PRICE, B.TOTAL_PRICE, B.CURRENCY, + B.LEAD_TIME, B.MIN_ORDER_QTY, B.WRITER, B.EDITER, B.EDIT_DATE, B.REMARK + FROM MBOM_DETAIL B + JOIN VIEW_BOM ON B.PARENT_OBJID = VIEW_BOM.CHILD_OBJID + AND VIEW_BOM.MBOM_HEADER_OBJID = B.MBOM_HEADER_OBJID + AND B.STATUS = 'ACTIVE' +) +SELECT + V.MBOM_HEADER_OBJID AS bom_report_objid, + V.OBJID AS objid, + V.PARENT_OBJID AS parent_objid, + V.CHILD_OBJID AS child_objid, + V.PART_OBJID AS part_objid, + V.PART_NO AS part_no, + V.PART_NAME AS part_name, + V.QTY AS qty, + V.ITEM_QTY AS item_qty, + V.QTY_TEMP AS qty_temp, + V.LEV AS level, + (SELECT COUNT(*) FROM MBOM_DETAIL WHERE PARENT_OBJID = V.CHILD_OBJID) AS sub_part_cnt, + V.SEQ AS seq, + V.STATUS AS status, + V.UNIT AS unit, + V.SUPPLY_TYPE AS supply_type, + V.MAKE_OR_BUY AS make_or_buy, + V.RAW_MATERIAL_PART_NO AS raw_material_no, + V.RAW_MATERIAL_SPEC AS raw_material_spec, + V.RAW_MATERIAL AS raw_material, + V.RAW_MATERIAL_SIZE AS size, + V.PROCESSING_VENDOR AS processing_vendor, + (SELECT CLIENT_NM FROM CLIENT_MNG WHERE OBJID::VARCHAR = V.PROCESSING_VENDOR) AS processing_vendor_name, + V.PROCESSING_DEADLINE AS processing_deadline, + V.GRINDING_DEADLINE AS grinding_deadline, + V.REQUIRED_QTY AS required_qty, + V.ORDER_QTY AS order_qty, + V.PRODUCTION_QTY AS production_qty, + V.STOCK_QTY AS stock_qty, + V.SHORTAGE_QTY AS shortage_qty, + V.VENDOR AS vendor, + (SELECT CLIENT_NM FROM CLIENT_MNG WHERE OBJID::VARCHAR = V.VENDOR) AS vendor_name, + V.UNIT_PRICE AS unit_price, + V.PROCESSING_UNIT_PRICE AS processing_unit_price, + V.TOTAL_PRICE AS total_price, + V.CURRENCY AS currency, + V.LEAD_TIME AS lead_time, + V.MIN_ORDER_QTY AS min_order_qty, + V.WRITER AS writer, + TO_CHAR(V.REGDATE, 'YYYY-MM-DD HH24:MI:SS') AS regdate, + V.EDITER AS editer, + CASE WHEN V.EDIT_DATE IS NOT NULL THEN TO_CHAR(V.EDIT_DATE, 'YYYY-MM-DD HH24:MI:SS') END AS edit_date, + V.REMARK AS remark, + CASE WHEN V.LEV = 1 THEN V.OBJID END AS root_objid, + CASE WHEN V.LEV = 1 THEN V.OBJID END AS sub_root_objid, + 1 AS leaf, + P.SPEC, P.MATERIAL, P.WEIGHT, P.PART_TYPE, P.REVISION, P.MAKER, + P.THICKNESS, P.WIDTH, P.HEIGHT, P.OUT_DIAMETER, P.IN_DIAMETER, P.LENGTH, + P.SOURCING_CODE, P.HEAT_TREATMENT_HARDNESS, P.HEAT_TREATMENT_METHOD, P.SURFACE_TREATMENT, + (SELECT CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = P.UNIT) AS unit_title, + (SELECT CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = P.PART_TYPE) AS part_type_title, + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('3D_CAD')) AS cu01_cnt, + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_DRAWING_CAD')) AS cu02_cnt, + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_PDF_CAD')) AS cu03_cnt, + COALESCE((SELECT NULLIF(MP.UNIT_QTY, '')::numeric FROM PART_MNG MP WHERE MP.PART_NO = V.RAW_MATERIAL_PART_NO LIMIT 1), 0) AS part_unit_qty, + COALESCE((SELECT NULLIF(MP.UNIT_LENGTH, '')::numeric FROM PART_MNG MP WHERE MP.PART_NO = V.RAW_MATERIAL_PART_NO LIMIT 1), 0) AS part_unit_length +FROM VIEW_BOM V +INNER JOIN PART_MNG P ON P.OBJID = V.PART_OBJID +ORDER BY V.PATH2 +`; + +const STRUCTURE_ONLY_SQL = ` +WITH RECURSIVE VIEW_BOM( + MBOM_HEADER_OBJID, OBJID, PARENT_OBJID, CHILD_OBJID, + PART_OBJID, PART_NO, PART_NAME, QTY, ITEM_QTY, QTY_TEMP, + REGDATE, SEQ, STATUS, LEV, PATH, PATH2, CYCLE, + UNIT, WRITER, RAW_MATERIAL_PART_NO +) AS ( + SELECT A.MBOM_HEADER_OBJID, A.OBJID, A.PARENT_OBJID, A.CHILD_OBJID, + A.PART_OBJID, A.PART_NO, A.PART_NAME, A.QTY, A.QTY, A.QTY, + A.REGDATE, A.SEQ, A.STATUS, 1, + ARRAY [A.CHILD_OBJID::TEXT], + ARRAY [LPAD(A.SEQ::TEXT, 10, '0')], + FALSE, + A.UNIT, A.WRITER, A.RAW_MATERIAL_PART_NO + FROM MBOM_DETAIL A + WHERE 1=1 + AND (A.PARENT_OBJID IS NULL OR A.PARENT_OBJID = '') + AND A.MBOM_HEADER_OBJID = $1 + AND A.STATUS = 'ACTIVE' + UNION ALL + SELECT B.MBOM_HEADER_OBJID, B.OBJID, B.PARENT_OBJID, B.CHILD_OBJID, + B.PART_OBJID, B.PART_NO, B.PART_NAME, B.QTY, B.QTY, B.QTY, + B.REGDATE, B.SEQ, B.STATUS, LEV + 1, + PATH || B.CHILD_OBJID::TEXT, + PATH2 || LPAD(B.SEQ::TEXT, 10, '0'), + B.PARENT_OBJID = ANY(PATH), + B.UNIT, B.WRITER, B.RAW_MATERIAL_PART_NO + FROM MBOM_DETAIL B + JOIN VIEW_BOM ON B.PARENT_OBJID = VIEW_BOM.CHILD_OBJID + AND VIEW_BOM.MBOM_HEADER_OBJID = B.MBOM_HEADER_OBJID + AND B.STATUS = 'ACTIVE' +) +SELECT + V.MBOM_HEADER_OBJID AS bom_report_objid, + V.OBJID AS objid, + V.PARENT_OBJID AS parent_objid, + V.CHILD_OBJID AS child_objid, + V.PART_OBJID AS part_objid, + V.PART_NO AS part_no, + V.PART_NAME AS part_name, + V.QTY AS qty, + V.ITEM_QTY AS item_qty, + V.QTY_TEMP AS qty_temp, + V.LEV AS level, + (SELECT COUNT(*) FROM MBOM_DETAIL WHERE PARENT_OBJID = V.CHILD_OBJID) AS sub_part_cnt, + V.SEQ, V.STATUS, V.UNIT, V.WRITER, + TO_CHAR(V.REGDATE, 'YYYY-MM-DD HH24:MI:SS') AS regdate, + NULL::text AS supply_type, NULL::text AS make_or_buy, + NULL::text AS raw_material_no, NULL::text AS raw_material_spec, + NULL::text AS raw_material, NULL::text AS size, + NULL::text AS processing_vendor, NULL::text AS processing_deadline, NULL::text AS grinding_deadline, + NULL::numeric AS required_qty, NULL::numeric AS order_qty, NULL::numeric AS production_qty, + NULL::numeric AS stock_qty, NULL::numeric AS shortage_qty, + NULL::text AS vendor, NULL::numeric AS unit_price, NULL::numeric AS processing_unit_price, + NULL::numeric AS total_price, NULL::text AS currency, + NULL::int AS lead_time, NULL::numeric AS min_order_qty, + NULL::text AS editer, NULL::text AS edit_date, NULL::text AS remark, + CASE WHEN V.LEV = 1 THEN V.OBJID END AS root_objid, + CASE WHEN V.LEV = 1 THEN V.OBJID END AS sub_root_objid, + 1 AS leaf, + P.SPEC, P.MATERIAL, P.WEIGHT, P.PART_TYPE, P.REVISION, P.MAKER, + P.THICKNESS, P.WIDTH, P.HEIGHT, P.OUT_DIAMETER, P.IN_DIAMETER, P.LENGTH, + P.SOURCING_CODE, P.HEAT_TREATMENT_HARDNESS, P.HEAT_TREATMENT_METHOD, P.SURFACE_TREATMENT, + (SELECT CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = P.UNIT) AS unit_title, + (SELECT CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = P.PART_TYPE) AS part_type_title, + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('3D_CAD')) AS cu01_cnt, + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_DRAWING_CAD')) AS cu02_cnt, + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_PDF_CAD')) AS cu03_cnt, + COALESCE((SELECT NULLIF(MP.UNIT_QTY, '')::numeric FROM PART_MNG MP WHERE MP.PART_NO = V.RAW_MATERIAL_PART_NO LIMIT 1), 0) AS part_unit_qty, + COALESCE((SELECT NULLIF(MP.UNIT_LENGTH, '')::numeric FROM PART_MNG MP WHERE MP.PART_NO = V.RAW_MATERIAL_PART_NO LIMIT 1), 0) AS part_unit_length +FROM VIEW_BOM V +INNER JOIN PART_MNG P ON P.OBJID = V.PART_OBJID +ORDER BY V.PATH2 +`; + +// TEMPLATE 트리는 SAVED 와 동일한 CTE — 운영판도 mbom_detail 한 테이블에서 가져옴. +// 차이: TEMPLATE 은 ORDER_QTY/PRODUCTION_QTY 를 결과에서 표시하지 않음 (재계산 대상). +const TEMPLATE_TREE_SQL = ` +WITH RECURSIVE VIEW_BOM( + MBOM_HEADER_OBJID, OBJID, PARENT_OBJID, CHILD_OBJID, + PART_OBJID, PART_NO, PART_NAME, QTY, ITEM_QTY, QTY_TEMP, + REGDATE, SEQ, STATUS, LEV, PATH, PATH2, CYCLE, + UNIT, SUPPLY_TYPE, MAKE_OR_BUY, + RAW_MATERIAL_PART_NO, RAW_MATERIAL_SPEC, RAW_MATERIAL, RAW_MATERIAL_SIZE, + PROCESSING_VENDOR, PROCESSING_DEADLINE, GRINDING_DEADLINE, + REQUIRED_QTY, WRITER, EDITER, EDIT_DATE, REMARK +) AS ( + SELECT A.MBOM_HEADER_OBJID, A.OBJID, A.PARENT_OBJID, A.CHILD_OBJID, + A.PART_OBJID, A.PART_NO, A.PART_NAME, A.QTY, A.QTY, A.QTY, + A.REGDATE, A.SEQ, A.STATUS, 1, + ARRAY [A.CHILD_OBJID::TEXT], + ARRAY [LPAD(A.SEQ::TEXT, 10, '0')], + FALSE, + A.UNIT, A.SUPPLY_TYPE, A.MAKE_OR_BUY, + A.RAW_MATERIAL_PART_NO, A.RAW_MATERIAL_SPEC, A.RAW_MATERIAL, A.RAW_MATERIAL_SIZE, + A.PROCESSING_VENDOR, A.PROCESSING_DEADLINE, A.GRINDING_DEADLINE, + A.REQUIRED_QTY, A.WRITER, A.EDITER, A.EDIT_DATE, A.REMARK + FROM MBOM_DETAIL A + WHERE 1=1 + AND (A.PARENT_OBJID IS NULL OR A.PARENT_OBJID = '') + AND A.MBOM_HEADER_OBJID = $1 + AND A.STATUS = 'ACTIVE' + UNION ALL + SELECT B.MBOM_HEADER_OBJID, B.OBJID, B.PARENT_OBJID, B.CHILD_OBJID, + B.PART_OBJID, B.PART_NO, B.PART_NAME, B.QTY, B.QTY, B.QTY, + B.REGDATE, B.SEQ, B.STATUS, LEV + 1, + PATH || B.CHILD_OBJID::TEXT, + PATH2 || LPAD(B.SEQ::TEXT, 10, '0'), + B.PARENT_OBJID = ANY(PATH), + B.UNIT, B.SUPPLY_TYPE, B.MAKE_OR_BUY, + B.RAW_MATERIAL_PART_NO, B.RAW_MATERIAL_SPEC, B.RAW_MATERIAL, B.RAW_MATERIAL_SIZE, + B.PROCESSING_VENDOR, B.PROCESSING_DEADLINE, B.GRINDING_DEADLINE, + B.REQUIRED_QTY, B.WRITER, B.EDITER, B.EDIT_DATE, B.REMARK + FROM MBOM_DETAIL B + JOIN VIEW_BOM ON B.PARENT_OBJID = VIEW_BOM.CHILD_OBJID + AND VIEW_BOM.MBOM_HEADER_OBJID = B.MBOM_HEADER_OBJID + AND B.STATUS = 'ACTIVE' +) +SELECT + V.MBOM_HEADER_OBJID AS bom_report_objid, + V.OBJID, V.PARENT_OBJID AS parent_objid, V.CHILD_OBJID AS child_objid, + V.PART_OBJID AS part_objid, V.PART_NO AS part_no, V.PART_NAME AS part_name, + V.QTY AS qty, V.ITEM_QTY AS item_qty, V.QTY_TEMP AS qty_temp, + V.LEV AS level, + (SELECT COUNT(*) FROM MBOM_DETAIL WHERE PARENT_OBJID = V.CHILD_OBJID) AS sub_part_cnt, + V.SEQ AS seq, V.STATUS AS status, + V.UNIT, V.SUPPLY_TYPE AS supply_type, V.MAKE_OR_BUY AS make_or_buy, + V.RAW_MATERIAL_PART_NO AS raw_material_no, + V.RAW_MATERIAL_SPEC AS raw_material_spec, + V.RAW_MATERIAL AS raw_material, + V.RAW_MATERIAL_SIZE AS size, + V.PROCESSING_VENDOR AS processing_vendor, + V.PROCESSING_DEADLINE AS processing_deadline, + V.GRINDING_DEADLINE AS grinding_deadline, + V.REQUIRED_QTY AS required_qty, + NULL::numeric AS order_qty, NULL::numeric AS production_qty, + V.WRITER AS writer, + TO_CHAR(V.REGDATE, 'YYYY-MM-DD HH24:MI:SS') AS regdate, + V.EDITER AS editer, + CASE WHEN V.EDIT_DATE IS NOT NULL THEN TO_CHAR(V.EDIT_DATE, 'YYYY-MM-DD HH24:MI:SS') END AS edit_date, + V.REMARK AS remark, + CASE WHEN V.LEV = 1 THEN V.OBJID END AS root_objid, + CASE WHEN V.LEV = 1 THEN V.OBJID END AS sub_root_objid, + 1 AS leaf, + P.SPEC, P.MATERIAL, P.WEIGHT, P.PART_TYPE, P.REVISION, P.MAKER, + P.THICKNESS, P.WIDTH, P.HEIGHT, P.OUT_DIAMETER, P.IN_DIAMETER, P.LENGTH, + P.SOURCING_CODE, P.HEAT_TREATMENT_HARDNESS, P.HEAT_TREATMENT_METHOD, P.SURFACE_TREATMENT, + (SELECT CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = P.UNIT) AS unit_title, + (SELECT CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = P.PART_TYPE) AS part_type_title, + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('3D_CAD')) AS cu01_cnt, + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_DRAWING_CAD')) AS cu02_cnt, + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_PDF_CAD')) AS cu03_cnt, + COALESCE((SELECT NULLIF(MP.UNIT_QTY, '')::numeric FROM PART_MNG MP WHERE MP.PART_NO = V.RAW_MATERIAL_PART_NO LIMIT 1), 0) AS part_unit_qty, + COALESCE((SELECT NULLIF(MP.UNIT_LENGTH, '')::numeric FROM PART_MNG MP WHERE MP.PART_NO = V.RAW_MATERIAL_PART_NO LIMIT 1), 0) AS part_unit_length +FROM VIEW_BOM V +LEFT JOIN PART_MNG P ON V.PART_OBJID = P.OBJID +ORDER BY V.PATH2 +`; + +// 매퍼 partMng.getBOMTreeList search_type='working' 1:1. +// E-BOM 호환 — part_no 컬럼명 충돌 회피 위해 운영판처럼 V.PART_NO 는 PART_OBJID 로 alias. +const EBOM_WORKING_TREE_SQL = ` +WITH RECURSIVE VIEW_BOM( + BOM_REPORT_OBJID, OBJID, PARENT_OBJID, CHILD_OBJID, + PARENT_PART_NO, PART_NO, LAST_PART_OBJID, + QTY, ITEM_QTY, QTY_TEMP, REGDATE, SEQ, STATUS, + PART_MNG_NO, PARENT_PART_MNG_NO, LEV, PATH, PATH2, CYCLE +) AS ( + SELECT A.BOM_REPORT_OBJID, A.OBJID, A.PARENT_OBJID, A.CHILD_OBJID, + A.PARENT_PART_NO, A.PART_NO, A.LAST_PART_OBJID, + A.QTY, A.ITEM_QTY, A.QTY_TEMP, A.REGDATE, A.SEQ, A.STATUS, + (SELECT PART_NO FROM PART_MNG P WHERE P.OBJID::varchar = A.PART_NO) AS PART_MNG_NO, + (SELECT PART_NO FROM PART_MNG P WHERE P.OBJID::varchar = A.PARENT_PART_NO) AS PARENT_PART_MNG_NO, + 1, + ARRAY [A.CHILD_OBJID::TEXT], + ARRAY [LPAD(A.SEQ::TEXT, 10, '0')], + FALSE + FROM BOM_PART_QTY A + WHERE 1=1 + AND (A.PARENT_OBJID IS NULL OR A.PARENT_OBJID = '') + AND A.BOM_REPORT_OBJID = $1 + AND (A.STATUS NOT IN ('deleting', 'deleted') OR A.STATUS IS NULL) + UNION ALL + SELECT B.BOM_REPORT_OBJID, B.OBJID, B.PARENT_OBJID, B.CHILD_OBJID, + B.PARENT_PART_NO, B.PART_NO, B.LAST_PART_OBJID, + B.QTY, B.ITEM_QTY, B.QTY_TEMP, B.REGDATE, B.SEQ, B.STATUS, + (SELECT PART_NO FROM PART_MNG P WHERE P.OBJID::varchar = B.PART_NO) AS PART_MNG_NO, + (SELECT PART_NO FROM PART_MNG P WHERE P.OBJID::varchar = B.PARENT_PART_NO) AS PARENT_PART_MNG_NO, + LEV + 1, + PATH || B.CHILD_OBJID::TEXT, + PATH2 || LPAD(B.SEQ::TEXT, 10, '0'), + B.PARENT_OBJID = ANY(PATH) + FROM BOM_PART_QTY B + JOIN VIEW_BOM ON B.PARENT_OBJID = VIEW_BOM.CHILD_OBJID + AND VIEW_BOM.BOM_REPORT_OBJID = B.BOM_REPORT_OBJID + AND (B.STATUS NOT IN ('deleting', 'deleted') OR B.STATUS IS NULL) +) +SELECT + V.BOM_REPORT_OBJID AS bom_report_objid, + V.OBJID AS objid, + V.PARENT_OBJID AS parent_objid, + V.CHILD_OBJID AS child_objid, + V.PARENT_PART_NO AS parent_part_no, + V.PART_NO AS part_objid, + V.LAST_PART_OBJID AS bom_last_part_objid, + V.QTY AS qty, + V.ITEM_QTY AS item_qty, + (CASE WHEN V.STATUS = 'deploy' THEN V.QTY + WHEN V.STATUS = 'beforeEdit' THEN V.QTY + WHEN V.STATUS != 'editing' AND (V.QTY_TEMP IS NULL OR V.QTY_TEMP = '') THEN V.QTY + ELSE COALESCE(V.QTY_TEMP, V.QTY) END) AS qty_temp, + V.LEV AS level, + (SELECT COUNT(*) FROM BOM_PART_QTY WHERE PARENT_OBJID = V.CHILD_OBJID) AS sub_part_cnt, + V.SEQ AS seq, V.STATUS AS status, + P.OBJID AS last_part_objid, + P.PART_NAME AS part_name, + P.PART_NO AS part_no, + (SELECT CODE_NAME FROM COMM_CODE CC WHERE CODE_ID = P.UNIT) AS unit_title, + P.SPEC, P.MATERIAL, P.WEIGHT, P.REVISION, P.MAKER, + (SELECT CODE_NAME FROM COMM_CODE CC WHERE CODE_ID = P.PART_TYPE) AS part_type_title, + P.REMARK AS part_remark, + P.THICKNESS, P.WIDTH, P.HEIGHT, P.OUT_DIAMETER, P.IN_DIAMETER, P.LENGTH, + P.SOURCING_CODE, P.HEAT_TREATMENT_HARDNESS, P.HEAT_TREATMENT_METHOD, P.SURFACE_TREATMENT, + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('3D_CAD')) AS cu01_cnt, + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_DRAWING_CAD')) AS cu02_cnt, + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_PDF_CAD')) AS cu03_cnt, + -- E-BOM 분기는 생산정보가 없으므로 NULL 로 채워 SAVED 와 동일한 키셋 유지 + NULL::text AS supply_type, NULL::text AS make_or_buy, + NULL::text AS raw_material_no, NULL::text AS raw_material_spec, + NULL::text AS raw_material, NULL::text AS size, + NULL::text AS processing_vendor, NULL::text AS processing_vendor_name, + NULL::text AS processing_deadline, NULL::text AS grinding_deadline, + NULL::numeric AS required_qty, NULL::numeric AS order_qty, NULL::numeric AS production_qty, + NULL::numeric AS stock_qty, NULL::numeric AS shortage_qty, + NULL::text AS vendor, NULL::text AS vendor_name, + NULL::numeric AS unit_price, NULL::numeric AS processing_unit_price, + NULL::numeric AS total_price, NULL::text AS currency, + NULL::int AS lead_time, NULL::numeric AS min_order_qty, + NULL::text AS writer, NULL::text AS regdate, NULL::text AS editer, NULL::text AS edit_date, NULL::text AS remark +FROM VIEW_BOM V +INNER JOIN PART_MNG P ON P.OBJID = COALESCE(V.LAST_PART_OBJID, V.PART_NO) +ORDER BY V.PATH2 +`; diff --git a/frontend/app/(main)/COMPANY_16/production/mbom/page.tsx b/frontend/app/(main)/COMPANY_16/production/mbom/page.tsx index b17f84bc..115d8a66 100644 --- a/frontend/app/(main)/COMPANY_16/production/mbom/page.tsx +++ b/frontend/app/(main)/COMPANY_16/production/mbom/page.tsx @@ -1,10 +1,12 @@ "use client"; -// 생산관리 > M-BOM 관리 (PR-A1) — wace productionplanning/mBomMgmtList.jsp 1:1 +// 생산관리 > M-BOM 관리 — wace productionplanning/mBomMgmtList.jsp 1:1 // 그리드: PROJECT_MGMT × CONTRACT_ITEM 펼침 (1 프로젝트 = 1+ 행) + M-BOM 상태/저장일 // 검색: 주문유형 / 제품구분 / 국내해외 / 고객사 / 유무상 / S/N / 품번 / 품명 / 접수일 / 요청납기 -// 액션 (PR-A1): 조회 / 초기화 / 페이지 -// ※ BOM 복사 / 구매리스트 생성 / M-BOM 편집 트리 다이얼로그는 PR-A2 이후 분리. +// 액션: +// PR-A1: 조회 / 초기화 / 페이지 +// PR-A2: 행 더블클릭 → MbomDetailDialog (헤더 + read-only 트리 4분기) +// ※ BOM 복사 / 구매리스트 생성 / M-BOM 본 편집 — PR-B 분리. import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; @@ -15,6 +17,7 @@ import { toast } from "sonner"; import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; import { apiClient } from "@/lib/api/client"; import { mbomApi, MbomListFilter, MbomRow } from "@/lib/api/mbom"; +import { MbomDetailDialog } from "@/components/production/MbomDetailDialog"; const PARENT_CATEGORY = "0000167"; // 주문유형 comm_code parent_code_id const PARENT_PRODUCT = "0000001"; // 제품구분 comm_code parent_code_id @@ -70,6 +73,9 @@ export default function MbomMgmtPage() { const [paidOpts, setPaidOpts] = useState([]); const [customerOpts, setCustomerOpts] = useState([]); + const [dialogOpen, setDialogOpen] = useState(false); + const [dialogObjid, setDialogObjid] = useState(null); + const fetchList = useCallback(async (override?: Partial) => { setLoading(true); try { @@ -260,8 +266,19 @@ export default function MbomMgmtPage() { showRowNumber emptyMessage="조건에 맞는 프로젝트가 없습니다." gridId="production-mbom-mgmt" + onRowDoubleClick={(row: any) => { + if (!row?.objid) return; + setDialogObjid(String(row.objid)); + setDialogOpen(true); + }} /> + + ); } diff --git a/frontend/components/production/MbomDetailDialog.tsx b/frontend/components/production/MbomDetailDialog.tsx new file mode 100644 index 00000000..f7ee38a7 --- /dev/null +++ b/frontend/components/production/MbomDetailDialog.tsx @@ -0,0 +1,232 @@ +"use client"; + +// 생산관리 > M-BOM 관리 — 단건 상세 + read-only 트리 다이얼로그. +// +// 운영판 통합: +// wace mBomHeaderPopup.jsp (헤더 메타) +// + wace mBomPopupLeft.jsp (read-only 트리 — 4분기 자동) +// +// 4분기 (운영판 mBomPopupLeft.do): +// SAVED 저장된 mbom_header.status='Y' 의 트리 (생산정보 포함) +// ASSIGNED_EBOM source_bom_type='EBOM' + source_ebom_objid → bom_part_qty 트리 +// ASSIGNED_MBOM source_bom_type='MBOM' + source_mbom_objid → mbom_detail 구조만 +// TEMPLATE Machine 이외 + 동일 part_no 의 mbom_header 템플릿 +// NONE 빈 트리 +// +// 본 편집 / BOM 복사 / 구매리스트 생성 / 변경이력 — PR-B 분리. + +import React, { useEffect, useMemo, useState } from "react"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { mbomApi, MbomDetail, MbomTreeResponse, MbomBomDataType, MbomTreeRow } from "@/lib/api/mbom"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + projectObjid: string | null; +} + +const BOM_DATA_TYPE_LABEL: Record = { + SAVED: { text: "저장된 M-BOM", color: "bg-emerald-600" }, + ASSIGNED_EBOM: { text: "할당된 E-BOM", color: "bg-sky-600" }, + ASSIGNED_MBOM: { text: "할당된 M-BOM", color: "bg-indigo-600" }, + TEMPLATE: { text: "M-BOM 템플릿", color: "bg-amber-600" }, + NONE: { text: "BOM 없음", color: "bg-slate-500" }, +}; + +export function MbomDetailDialog({ open, onOpenChange, projectObjid }: Props) { + const [detail, setDetail] = useState(null); + const [tree, setTree] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!open || !projectObjid) { + setDetail(null); setTree(null); + return; + } + let alive = true; + setLoading(true); + Promise.all([ + mbomApi.getDetail(projectObjid), + mbomApi.getTree(projectObjid), + ]) + .then(([d, t]) => { + if (!alive) return; + setDetail(d); + setTree(t); + }) + .catch((e: any) => { + toast.error(e?.response?.data?.message ?? e?.message ?? "M-BOM 조회 실패"); + }) + .finally(() => { if (alive) setLoading(false); }); + return () => { alive = false; }; + }, [open, projectObjid]); + + const maxLevel = Math.max(1, tree?.max_level ?? 1); + const rows: MbomTreeRow[] = tree?.rows ?? []; + const bomDataType: MbomBomDataType = tree?.bom_data_type ?? "NONE"; + const meta = BOM_DATA_TYPE_LABEL[bomDataType]; + + const levelHeaders = useMemo(() => { + const h: number[] = []; + for (let i = 1; i <= maxLevel; i++) h.push(i); + return h; + }, [maxLevel]); + + return ( + + + + + M-BOM 관리 — 단건 상세 + {meta.text} + + + + {/* 헤더 메타 (운영판 mBomHeaderPopup.jsp 1:1) */} + {detail && ( +
+ + + + + + + + + + + + + + +
+ )} + +
+
+ 총 {rows.length.toLocaleString()}행 · MAX_LEVEL = {maxLevel} + {tree?.bom_report_objid && ( + BOM_OBJID = {tree.bom_report_objid} + )} +
+
+ +
+ {loading ? ( +
+ +
+ ) : ( + + + + {levelHeaders.map((i) => ( + + ))} + + + + + + + + + + + + + + + + + + + + + + + + {rows.length === 0 && ( + + + + )} + {rows.map((r, idx) => { + const lv = Number(r.level ?? 1); + return ( + + {levelHeaders.map((i) => ( + + ))} + + + + + + + + + + + + + + + + + + + + + + ); + })} + +
{i}품번품명수량항목수량단위자/사급Make/Buy소재품번소재규격필요수량주문수량생산수량가공업체가공납기연삭납기3D2DPDF비고
+ {bomDataType === "NONE" ? "표시할 BOM이 없습니다." : "트리가 비어있습니다."} +
+ {i === lv ? "*" : ""} + {r.part_no}{r.part_name}{fmtNum(r.qty)}{fmtNum(r.item_qty)}{r.unit_title ?? r.unit ?? ""}{r.supply_type ?? ""}{r.make_or_buy ?? ""}{r.raw_material_no ?? ""}{r.raw_material ?? ""}{r.size ?? ""}{fmtNum(r.required_qty)}{fmtNum(r.order_qty)}{fmtNum(r.production_qty)}{r.processing_vendor_name ?? r.processing_vendor ?? ""}{r.processing_deadline ?? ""}{r.grinding_deadline ?? ""}{Number(r.cu01_cnt ?? 0) > 0 ? "Y" : ""}{Number(r.cu02_cnt ?? 0) > 0 ? "Y" : ""}{Number(r.cu03_cnt ?? 0) > 0 ? "Y" : ""}{r.remark ?? ""}
+ )} +
+ + + + +
+
+ ); +} + +function MetaRow({ label, value, numeric }: { label: string; value: any; numeric?: boolean }) { + return ( +
+ {label} + + {value != null && value !== "" ? value : "—"} + +
+ ); +} + +function fmtNum(v: any): string { + if (v == null || v === "") return ""; + const n = Number(v); + if (!isFinite(n)) return String(v); + // 정수면 천 단위, 소수가 있으면 그대로 (4자리 까지 표시) + return Number.isInteger(n) + ? n.toLocaleString() + : n.toLocaleString(undefined, { maximumFractionDigits: 4 }); +} + +function paidLabel(v: string | null | undefined): string { + if (v === "paid") return "유상"; + if (v === "free") return "무상"; + return v ?? ""; +} diff --git a/frontend/lib/api/mbom.ts b/frontend/lib/api/mbom.ts index 6f75fd46..5d49098a 100644 --- a/frontend/lib/api/mbom.ts +++ b/frontend/lib/api/mbom.ts @@ -1,8 +1,11 @@ import { apiClient } from "./client"; // ============================================================ -// 생산관리 > M-BOM 관리 (PR-A1) — wace productionplanning.xml 1:1 -// 라우트: /api/production/mbom/* +// 생산관리 > M-BOM 관리 — wace productionplanning.xml 1:1 +// 라우트: +// GET /api/production/mbom/list (PR-A1, 그리드) +// GET /api/production/mbom/detail/:objid (PR-A2, 단건 상세) +// GET /api/production/mbom/tree/:objid (PR-A2, read-only 트리 4분기) // ============================================================ export interface MbomListFilter { @@ -66,9 +69,114 @@ export interface MbomListResponse { pageSize: number; } +// ─── 단건 상세 (PR-A2) ────────────────────────────────────── + +export interface MbomDetail { + objid: string; + contract_objid: string | null; + project_no: string | null; + bom_report_objid: string | null; + part_objid: string | null; + part_no: string | null; + part_name: string | null; + source_bom_type: string | null; + source_ebom_objid: string | null; + source_mbom_objid: string | null; + quantity: string | number | null; + total_prod_qty: string | number | null; + mbom_part_no: string | null; + category_cd: string | null; + category_name: string | null; + product: string | null; + product_code: string | null; + product_name: string | null; + area_cd: string | null; + area_name: string | null; + customer_objid: string | null; + customer_name: string | null; + paid_type: string | null; + req_del_date: string | null; + receipt_date: string | null; + mbom_regdate: string | null; +} + +// ─── read-only 트리 (PR-A2) ───────────────────────────────── +// 운영판 mBomPopupLeft.do 4분기 자동 판별: +// SAVED — mbom_header.status='Y' 최신 +// ASSIGNED_EBOM — source_bom_type='EBOM' + source_ebom_objid +// ASSIGNED_MBOM — source_bom_type='MBOM' + source_mbom_objid +// TEMPLATE — Machine 이외 + 동일 part_no 의 mbom_header +// NONE — 빈 트리 + +export type MbomBomDataType = "SAVED" | "ASSIGNED_EBOM" | "ASSIGNED_MBOM" | "TEMPLATE" | "NONE"; + +export interface MbomTreeRow { + objid: string; + parent_objid: string | null; + child_objid: string | null; + part_objid: string | null; + part_no: string | null; + part_name: string | null; + qty: string | number | null; + item_qty: string | number | null; + qty_temp: string | number | null; + level: number; + sub_part_cnt: number; + seq: number; + status: string | null; + unit: string | null; + unit_title: string | null; + supply_type: string | null; + make_or_buy: string | null; + raw_material_no: string | null; + raw_material_spec: string | null; + raw_material: string | null; + size: string | null; + processing_vendor: string | null; + processing_vendor_name: string | null; + processing_deadline: string | null; + grinding_deadline: string | null; + required_qty: string | number | null; + order_qty: string | number | null; + production_qty: string | number | null; + vendor: string | null; + vendor_name: string | null; + unit_price: string | number | null; + total_price: string | number | null; + currency: string | null; + writer: string | null; + regdate: string | null; + editer: string | null; + edit_date: string | null; + remark: string | null; + spec: string | null; + material: string | null; + weight: string | number | null; + revision: string | null; + cu01_cnt: number; + cu02_cnt: number; + cu03_cnt: number; + [key: string]: any; +} + +export interface MbomTreeResponse { + bom_data_type: MbomBomDataType; + bom_report_objid: string | null; + max_level: number; + rows: MbomTreeRow[]; +} + export const mbomApi = { async list(filter: MbomListFilter = {}): Promise { const res = await apiClient.get("/production/mbom/list", { params: filter }); return res.data?.data as MbomListResponse; }, + async getDetail(objid: string): Promise { + const res = await apiClient.get(`/production/mbom/detail/${encodeURIComponent(objid)}`); + return res.data?.data as MbomDetail; + }, + async getTree(objid: string): Promise { + const res = await apiClient.get(`/production/mbom/tree/${encodeURIComponent(objid)}`); + return res.data?.data as MbomTreeResponse; + }, }; From 364d4707fe4793e9dba0beb171658a9a4bd199e3 Mon Sep 17 00:00:00 2001 From: hjjeong Date: Wed, 13 May 2026 16:44:26 +0900 Subject: [PATCH 06/12] =?UTF-8?q?=EA=B3=B5=EC=9A=A9=20=E2=80=94=20native?= =?UTF-8?q?=20 7개 자리에 일괄 적용. customer-cs/cs 메뉴의 컴팩트 검색바 패턴을 공용 컴포넌트로 추출하고 M-BOM 페이지에 시범 마이그레이션. 신설: - components/common/CompactFilterBar.tsx — CompactFilterBar + CompactFilterField + CompactDateRange · rounded-md border bg-muted/20 p-2 + flex-wrap (자동 줄바꿈) · 자식 input/combobox 자동 h-7 + text-xs 컴팩트화 · onSearch / onReset / totalText 슬롯 native + 자체 grid 검색폼 작성 금지 - 메모리: feedback_compact_search_pattern.md 타입체크 0건 에러 (변경 파일 기준). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../development/change-list/page.tsx | 19 +- .../development/ebom-regist/page.tsx | 16 +- .../development/ebom-search/page.tsx | 25 +- .../COMPANY_16/production/mbom/page.tsx | 253 +++++++----------- .../COMPANY_16/project/progress/page.tsx | 54 ++-- .../components/common/CompactFilterBar.tsx | 150 +++++++++++ .../BomReportExcelImportDialog.tsx | 24 +- .../components/development/PartFormDialog.tsx | 14 +- 8 files changed, 321 insertions(+), 234 deletions(-) create mode 100644 frontend/components/common/CompactFilterBar.tsx diff --git a/frontend/app/(main)/COMPANY_16/development/change-list/page.tsx b/frontend/app/(main)/COMPANY_16/development/change-list/page.tsx index 405f5d0a..c175b976 100644 --- a/frontend/app/(main)/COMPANY_16/development/change-list/page.tsx +++ b/frontend/app/(main)/COMPANY_16/development/change-list/page.tsx @@ -13,6 +13,7 @@ import { Search, Loader2, RotateCcw } from "lucide-react"; import { toast } from "sonner"; import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; import { CommCodeSelect } from "@/components/common/CommCodeSelect"; +import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect"; import { devEoHistoryApi, EoHistoryListFilter, EoHistoryRow } from "@/lib/api/devEoHistory"; import { PartHisDetailDialog } from "@/components/development/PartHisDetailDialog"; @@ -21,10 +22,10 @@ const GROUP_PART_TYPE = "0000062"; // change_type/change_option은 wace 운영판 그룹 ID가 명확하지 않으므로 text input으로 우선 운영. // (시드 후 그룹 ID 확인되면 SmartSelect 전환) -const YEAR_OPTIONS = (() => { +const YEAR_OPTIONS: SmartSelectOption[] = (() => { const cur = new Date().getFullYear(); - const arr: string[] = []; - for (let y = cur + 4; y >= cur - 8; y--) arr.push(String(y)); + const arr: SmartSelectOption[] = []; + for (let y = cur + 4; y >= cur - 8; y--) arr.push({ code: String(y), label: String(y) }); return arr; })(); @@ -95,12 +96,12 @@ export default function EoHistoryPage() {
- + setFilter({ ...filter, Year: v })} + placeholder="전체" + /> - + onValueChange={(v) => setFilter({ ...filter, status: v })} + placeholder="전체" + /> {/* wace structureList.jsp 1:1 — select2-part 자동완성 (양방향 동기) */} diff --git a/frontend/app/(main)/COMPANY_16/development/ebom-search/page.tsx b/frontend/app/(main)/COMPANY_16/development/ebom-search/page.tsx index 567f27a6..1239e682 100644 --- a/frontend/app/(main)/COMPANY_16/development/ebom-search/page.tsx +++ b/frontend/app/(main)/COMPANY_16/development/ebom-search/page.tsx @@ -15,6 +15,15 @@ import { toast } from "sonner"; import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; import { devBomApi, BomTreeFilter, BomTreeRow } from "@/lib/api/devBom"; import { DevPartSelect } from "@/components/development/DevPartSelect"; +import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect"; + +const LEVEL_OPTIONS: SmartSelectOption[] = [ + { code: "1", label: "1레벨" }, + { code: "2", label: "2레벨" }, + { code: "3", label: "3레벨" }, + { code: "4", label: "4레벨" }, + { code: "5", label: "5레벨" }, +]; import { PartDetailDialog } from "@/components/development/PartDetailDialog"; type Direction = "ascending" | "descending"; @@ -203,18 +212,12 @@ export default function EbomSearchPage() { }))} /> - + onValueChange={(v) => setFilter({ ...filter, search_level: v })} + placeholder="전체" + />
diff --git a/frontend/app/(main)/COMPANY_16/production/mbom/page.tsx b/frontend/app/(main)/COMPANY_16/production/mbom/page.tsx index 115d8a66..42bdd7c0 100644 --- a/frontend/app/(main)/COMPANY_16/production/mbom/page.tsx +++ b/frontend/app/(main)/COMPANY_16/production/mbom/page.tsx @@ -9,12 +9,12 @@ // ※ BOM 복사 / 구매리스트 생성 / M-BOM 본 편집 — PR-B 분리. import React, { useCallback, useEffect, useMemo, useState } from "react"; -import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Search, Loader2, RotateCcw } from "lucide-react"; import { toast } from "sonner"; import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; +import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect"; +import { CustomerSelect } from "@/components/common/CustomerSelect"; +import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar"; import { apiClient } from "@/lib/api/client"; import { mbomApi, MbomListFilter, MbomRow } from "@/lib/api/mbom"; import { MbomDetailDialog } from "@/components/production/MbomDetailDialog"; @@ -23,8 +23,18 @@ const PARENT_CATEGORY = "0000167"; // 주문유형 comm_code parent_code_id const PARENT_PRODUCT = "0000001"; // 제품구분 comm_code parent_code_id const PARENT_PAID = "0001782"; // 유/무상 (참고: 빈 결과여도 raw paid/free 매칭으로 fallback) -interface CodeOpt { code: string; label: string; sort?: number | null } -interface CustomerOpt { id: number | string; customer_name: string | null; customer_code: string | null } +interface CodeOpt extends SmartSelectOption { sort?: number | null } + +const AREA_OPTS: SmartSelectOption[] = [ + { code: "국내", label: "국내" }, + { code: "해외", label: "해외" }, +]; + +// 운영판 1:1 — paid/free raw 매칭이 기본. comm_code 응답이 비어있을 때 사용. +const PAID_FALLBACK_OPTS: SmartSelectOption[] = [ + { code: "paid", label: "유상" }, + { code: "free", label: "무상" }, +]; const EMPTY_FILTER: MbomListFilter = { search_category_cd: "", @@ -71,7 +81,6 @@ export default function MbomMgmtPage() { const [categoryOpts, setCategoryOpts] = useState([]); const [productOpts, setProductOpts] = useState([]); const [paidOpts, setPaidOpts] = useState([]); - const [customerOpts, setCustomerOpts] = useState([]); const [dialogOpen, setDialogOpen] = useState(false); const [dialogObjid, setDialogObjid] = useState(null); @@ -95,17 +104,15 @@ export default function MbomMgmtPage() { let dead = false; (async () => { try { - const [c1, c2, c3, cust] = await Promise.all([ + const [c1, c2, c3] = await Promise.all([ apiClient.get(`/sales/codes/${PARENT_CATEGORY}`), apiClient.get(`/sales/codes/${PARENT_PRODUCT}`), apiClient.get(`/sales/codes/${PARENT_PAID}`), - apiClient.get(`/sales/customers`), ]); if (dead) return; setCategoryOpts(c1.data?.data ?? []); setProductOpts(c2.data?.data ?? []); setPaidOpts(c3.data?.data ?? []); - setCustomerOpts(cust.data?.data ?? []); } catch { /* 옵션 로드 실패는 무시 — 그리드는 조회 가능 */ } @@ -132,133 +139,84 @@ export default function MbomMgmtPage() { }; return ( -
-
- {/* 운영판 wace mBomMgmtList.jsp 1:1 — 2행 검색 폼 */} -
-
- - setFilter({ ...filter, search_category_cd: v })} - /> - - - setFilter({ ...filter, search_product_cd: v })} - /> - - - - - - - - - - - - setFilter({ ...filter, search_serial_no: e.target.value })} - /> - -
-
- - setFilter({ ...filter, search_part_no: e.target.value })} - /> - - - setFilter({ ...filter, search_part_name: e.target.value })} - /> - - - setFilter({ ...filter, search_receipt_date_from: e.target.value })} - /> - - - setFilter({ ...filter, search_receipt_date_to: e.target.value })} - /> - - - setFilter({ ...filter, search_req_del_date_from: e.target.value })} - /> - - - setFilter({ ...filter, search_req_del_date_to: e.target.value })} - /> - -
-
+
+ 총 {total.toLocaleString()}건 · PROJECT_MGMT × CONTRACT_ITEM 펼침} + > + + setFilter({ ...filter, search_category_cd: v })} + /> + + + setFilter({ ...filter, search_product_cd: v })} + /> + + + setFilter({ ...filter, search_area_cd: v })} + /> + + + setFilter({ ...filter, search_customer_objid: v })} + /> + + + 0 ? paidOpts : PAID_FALLBACK_OPTS} + value={filter.search_paid_type ?? ""} + onValueChange={(v) => setFilter({ ...filter, search_paid_type: v })} + /> + + + setFilter({ ...filter, search_serial_no: e.target.value })} + /> + + + setFilter({ ...filter, search_part_no: e.target.value })} + /> + + + setFilter({ ...filter, search_part_name: e.target.value })} + /> + + + setFilter({ ...filter, search_receipt_date_from: v })} + to={filter.search_receipt_date_to ?? ""} + setTo={(v) => setFilter({ ...filter, search_receipt_date_to: v })} + /> + + + setFilter({ ...filter, search_req_del_date_from: v })} + to={filter.search_req_del_date_to ?? ""} + setTo={(v) => setFilter({ ...filter, search_req_del_date_to: v })} + /> + + -
-
- 총 {total.toLocaleString()}건 · PROJECT_MGMT × CONTRACT_ITEM 펼침 -
-
- - -
-
-
- -
+
- - {children} -
- ); -} - -function SelectBox({ - value, options, onChange, -}: { value: string; options: CodeOpt[]; onChange: (v: string) => void }) { - return ( - - ); -} diff --git a/frontend/app/(main)/COMPANY_16/project/progress/page.tsx b/frontend/app/(main)/COMPANY_16/project/progress/page.tsx index e7984bab..669a59f3 100644 --- a/frontend/app/(main)/COMPANY_16/project/progress/page.tsx +++ b/frontend/app/(main)/COMPANY_16/project/progress/page.tsx @@ -78,13 +78,23 @@ const CATEGORY_GROUP = "0000167"; // 주문유형 const PRODUCT_GROUP = "0000001"; // 제품구분 // wace L229: 시스템년도 ±4 (운영판은 sysYear-4 ~ sysYear). RPS는 sysYear±4로 여유. -const YEAR_OPTIONS = (() => { +const YEAR_OPTIONS: SmartSelectOption[] = (() => { const cur = new Date().getFullYear(); - const arr: string[] = []; - for (let y = cur + 4; y >= cur - 4; y--) arr.push(String(y)); + const arr: SmartSelectOption[] = []; + for (let y = cur + 4; y >= cur - 4; y--) arr.push({ code: String(y), label: String(y) }); return arr; })(); +const AREA_OPTIONS: SmartSelectOption[] = [ + { code: "국내", label: "국내" }, + { code: "해외", label: "해외" }, +]; + +const PAID_OPTIONS: SmartSelectOption[] = [ + { code: "유상", label: "유상" }, + { code: "무상", label: "무상" }, +]; + const EMPTY_FILTER: ProgressListFilter = { Year: "", project_nos: "", category_cd: "", customer_objid: "", product: "", contract_start_date: "", contract_end_date: "", @@ -143,14 +153,12 @@ export default function ProjectProgressPage() {
{/* 1행 */} - + onValueChange={(v) => setFilter({ ...filter, Year: v })} + placeholder="전체" + /> - + onValueChange={(v) => setFilter({ ...filter, area_cd: v })} + placeholder="전체" + /> - + onValueChange={(v) => setFilter({ ...filter, free_of_charge: v })} + placeholder="전체" + /> fetchList()} + * onReset={() => handleReset()} + * totalText={`총 ${total}건`} + * > + * + * + * + * + * + * + * + * + * 원칙: + * - 모든 RPS 메뉴의 검색 폼은 이 컴포넌트를 사용. 자체 검색 폼 구성 금지. + * - SmartSelect / CustomerSelect / CommCodeSelect / Input 모두 h-7 + text-xs 자동 적용. + */ + +import React from "react"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Search, Loader2, RotateCcw } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface CompactFilterBarProps { + children: React.ReactNode; + onSearch?: () => void; + onReset?: () => void; + /** 우측에 표시할 합계/통계 텍스트 (예: "총 12,345건 · 합계 12,000,000원") */ + totalText?: React.ReactNode; + loading?: boolean; + searchLabel?: string; + resetLabel?: string; + className?: string; +} + +export function CompactFilterBar({ + children, + onSearch, + onReset, + totalText, + loading, + searchLabel = "검색", + resetLabel = "초기화", + className, +}: CompactFilterBarProps) { + return ( +
+ {children} + {(onReset || onSearch) && ( +
+ {onReset && ( + + )} + {onSearch && ( + + )} +
+ )} + {totalText != null && ( + {totalText} + )} +
+ ); +} + +interface CompactFilterFieldProps { + label: string; + /** 컨트롤 박스 폭(px). 기본 120. */ + width?: number; + /** 폭 자동 (자식이 100% 폭을 차지하지 않게 할 때 유용) */ + flex?: boolean; + children: React.ReactNode; + className?: string; +} + +export function CompactFilterField({ + label, width = 120, flex, children, className, +}: CompactFilterFieldProps) { + return ( +
+ +
+ {children} +
+
+ ); +} + +/** + * 날짜 범위 입력 (CompactFilterField 자식으로 사용). + * + * + * + * + */ +export function CompactDateRange({ + from, setFrom, to, setTo, disabled, +}: { + from: string; + setFrom: (v: string) => void; + to: string; + setTo: (v: string) => void; + disabled?: boolean; +}) { + return ( +
+ setFrom(e.target.value)} + disabled={disabled} + /> + ~ + setTo(e.target.value)} + disabled={disabled} + /> +
+ ); +} diff --git a/frontend/components/development/BomReportExcelImportDialog.tsx b/frontend/components/development/BomReportExcelImportDialog.tsx index fae60795..7b6607e9 100644 --- a/frontend/components/development/BomReportExcelImportDialog.tsx +++ b/frontend/components/development/BomReportExcelImportDialog.tsx @@ -20,6 +20,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { CommCodeSelect } from "@/components/common/CommCodeSelect"; +import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect"; import { Download, Upload, Save, Loader2, FileX, Copy } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; @@ -261,18 +262,17 @@ export function BomReportExcelImportDialog({ open, onOpenChange, editObjid, init
- +
+ ((o) => ({ + code: o.objid, + label: `${o.part_no} / ${o.part_name}${o.revision ? ` (v${o.revision})` : ""}${o.regdate ? ` - ${o.regdate}` : ""}`, + }))} + value={copySelect} + onValueChange={setCopySelect} + placeholder="선택" + /> +
} /> + * + * 원칙: + * - 모든 page.tsx 의 최상위 자식으로 를 배치한다. + * - 메뉴명이 menu_info 에 있다면 props 없이도 자동 매칭되므로 그냥 면 충분. + * - 액션 버튼이 있으면 actions 슬롯에만 넣는다 (검색 영역에 두지 말 것). + */ + +import React, { useMemo } from "react"; +import { usePathname } from "next/navigation"; +import { useMenu } from "@/contexts/MenuContext"; +import type { MenuItem } from "@/lib/api/menu"; +import { cn } from "@/lib/utils"; + +interface PageHeaderProps { + /** 명시 메뉴명. 없으면 usePathname() + MenuContext 자동 매칭. */ + title?: string; + /** 명시 설명. 없으면 자동 매칭 결과의 menu_desc. */ + description?: string; + /** 우측 액션 슬롯 (새로고침/등록/엑셀 다운로드 등) */ + actions?: React.ReactNode; + className?: string; +} + +function findByUrl(menus: MenuItem[], pathname: string): MenuItem | null { + // MenuContext 는 flat 리스트. 정확 매칭 우선, 없으면 prefix(파라미터 라우트 대응). + for (const m of menus) { + if (m.menu_url && m.menu_url === pathname) return m; + } + // 동적 라우트 fallback: /COMPANY_16/foo/123 → /COMPANY_16/foo 매칭 + let best: MenuItem | null = null; + let bestLen = 0; + for (const m of menus) { + if (m.menu_url && pathname.startsWith(m.menu_url) && m.menu_url.length > bestLen) { + best = m; + bestLen = m.menu_url.length; + } + } + return best; +} + +export function PageHeader({ title, description, actions, className }: PageHeaderProps) { + const pathname = usePathname() ?? ""; + // useMenu() 가 Provider 밖에서 호출되면 throw — 안전하게 try + let menu: MenuItem | null = null; + try { + const { userMenus, adminMenus } = useMenu(); + menu = findByUrl(userMenus, pathname) ?? findByUrl(adminMenus, pathname); + } catch { + /* MenuProvider 밖 (스토리북/테스트 등) — 자동 매칭 생략 */ + } + + const resolvedTitle = title ?? menu?.menu_name_kor ?? ""; + const resolvedDesc = description ?? menu?.menu_desc ?? ""; + + if (!resolvedTitle && !resolvedDesc && !actions) return null; + + return ( +
+
+ {resolvedTitle && ( +

{resolvedTitle}

+ )} + {resolvedDesc && ( +

{resolvedDesc}

+ )} +
+ {actions &&
{actions}
} +
+ ); +} From e208d26e51b680a269ab5f9ee21034f9f7d50a47 Mon Sep 17 00:00:00 2001 From: hjjeong Date: Wed, 13 May 2026 16:51:16 +0900 Subject: [PATCH 08/12] =?UTF-8?q?=EA=B3=B5=EC=9A=A9=20=E2=80=94=20PageHead?= =?UTF-8?q?er=20=ED=83=AD=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EB=8C=80?= =?UTF-8?q?=EC=9D=91=20(=ED=99=9C=EC=84=B1=20=ED=83=AD=20adminUrl=20?= =?UTF-8?q?=EB=A7=A4=EC=B9=AD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RPS 는 탭 기반 라우터라 usePathname() 이 /main 으로 고정됨. 사용자 보고: M-BOM 페이지에서 PageHeader 가 메뉴명을 못 잡아 빈 상태. 수정: useCurrent2ndLevelMenuObjid 와 동일 패턴 적용 - useTabStore.selectTabs / selectActiveTabId 로 활성 탭 조회 - pathname='/main' 이면 활성 탭의 adminUrl 로 매칭 - stripCompanyPrefix 로 /COMPANY_NN 무시 → menu_info.menu_url 양방향 비교 Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/components/common/PageHeader.tsx | 57 ++++++++++++++--------- 1 file changed, 35 insertions(+), 22 deletions(-) diff --git a/frontend/components/common/PageHeader.tsx b/frontend/components/common/PageHeader.tsx index c44deb80..0d78e813 100644 --- a/frontend/components/common/PageHeader.tsx +++ b/frontend/components/common/PageHeader.tsx @@ -5,50 +5,52 @@ * * customer-cs/cs 페이지 패턴 1:1 추출. 모든 RPS 메뉴 페이지의 상단에 의무 배치. * - * 자동 매칭: - * - usePathname() 으로 현재 경로를 잡고 MenuContext 의 userMenus/adminMenus 에서 - * menu_url 이 일치하는 항목을 찾아 menu_name_kor / menu_desc 를 표시. + * 자동 매칭 (탭 시스템 대응): + * - RPS 는 탭 기반이라 usePathname() 이 /main 으로 고정됨. + * - useTabStore 의 활성 탭 adminUrl → /COMPANY_NN prefix 제거 → menu_info.menu_url 매칭. + * - useCurrent2ndLevelMenuObjid 와 동일 패턴. * * 명시 지정: - * - * - * 액션 슬롯: - * } /> + * * * 원칙: * - 모든 page.tsx 의 최상위 자식으로 를 배치한다. - * - 메뉴명이 menu_info 에 있다면 props 없이도 자동 매칭되므로 그냥 면 충분. - * - 액션 버튼이 있으면 actions 슬롯에만 넣는다 (검색 영역에 두지 말 것). + * - menu_info 에 등록만 되어 있으면 props 없이도 자동 매칭. */ -import React, { useMemo } from "react"; +import React from "react"; import { usePathname } from "next/navigation"; import { useMenu } from "@/contexts/MenuContext"; +import { useTabStore, selectTabs, selectActiveTabId } from "@/stores/tabStore"; import type { MenuItem } from "@/lib/api/menu"; import { cn } from "@/lib/utils"; interface PageHeaderProps { - /** 명시 메뉴명. 없으면 usePathname() + MenuContext 자동 매칭. */ title?: string; - /** 명시 설명. 없으면 자동 매칭 결과의 menu_desc. */ description?: string; - /** 우측 액션 슬롯 (새로고침/등록/엑셀 다운로드 등) */ actions?: React.ReactNode; className?: string; } -function findByUrl(menus: MenuItem[], pathname: string): MenuItem | null { - // MenuContext 는 flat 리스트. 정확 매칭 우선, 없으면 prefix(파라미터 라우트 대응). +function stripCompanyPrefix(p: string): string { + return p.replace(/^\/COMPANY_\d+/, "") || "/"; +} + +function findByUrl(menus: MenuItem[], strippedUrl: string): MenuItem | null { + // menu_info.menu_url 이 /COMPANY_16/... 으로 저장되어 있으므로 양쪽 비교 for (const m of menus) { - if (m.menu_url && m.menu_url === pathname) return m; + if (!m.menu_url) continue; + if (m.menu_url === strippedUrl) return m; + if (stripCompanyPrefix(m.menu_url) === strippedUrl) return m; } - // 동적 라우트 fallback: /COMPANY_16/foo/123 → /COMPANY_16/foo 매칭 let best: MenuItem | null = null; let bestLen = 0; for (const m of menus) { - if (m.menu_url && pathname.startsWith(m.menu_url) && m.menu_url.length > bestLen) { + if (!m.menu_url) continue; + const stripped = stripCompanyPrefix(m.menu_url); + if (strippedUrl.startsWith(stripped) && stripped.length > bestLen) { best = m; - bestLen = m.menu_url.length; + bestLen = stripped.length; } } return best; @@ -56,13 +58,24 @@ function findByUrl(menus: MenuItem[], pathname: string): MenuItem | null { export function PageHeader({ title, description, actions, className }: PageHeaderProps) { const pathname = usePathname() ?? ""; - // useMenu() 가 Provider 밖에서 호출되면 throw — 안전하게 try + const tabs = useTabStore(selectTabs); + const activeTabId = useTabStore(selectActiveTabId); + let menu: MenuItem | null = null; try { const { userMenus, adminMenus } = useMenu(); - menu = findByUrl(userMenus, pathname) ?? findByUrl(adminMenus, pathname); + // RPS 탭 시스템: pathname=/main 이면 활성 탭의 adminUrl 사용 + let targetUrl = stripCompanyPrefix(pathname); + const isRootLike = pathname === "/main" || pathname === "/" || pathname === ""; + if (isRootLike) { + const activeTab = tabs.find((t: any) => t.id === activeTabId); + if (activeTab?.adminUrl) { + targetUrl = stripCompanyPrefix(activeTab.adminUrl); + } + } + menu = findByUrl(userMenus as MenuItem[], targetUrl) ?? findByUrl(adminMenus as MenuItem[], targetUrl); } catch { - /* MenuProvider 밖 (스토리북/테스트 등) — 자동 매칭 생략 */ + /* Provider 밖 — 자동 매칭 생략 */ } const resolvedTitle = title ?? menu?.menu_name_kor ?? ""; From 4f5dd8b47f1e6ae7f2df29999057cee20c303ab0 Mon Sep 17 00:00:00 2001 From: hjjeong Date: Wed, 13 May 2026 17:10:07 +0900 Subject: [PATCH 09/12] =?UTF-8?q?=EA=B3=B5=EC=9A=A9=20=E2=80=94=20?= =?UTF-8?q?=EC=98=81=EC=97=85=204=20+=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=202=20+=20=EA=B0=9C=EB=B0=9C=205=EB=A9=94=EB=89=B4=20?= =?UTF-8?q?PageHeader=20+=20CompactFilterBar=20=EC=9D=BC=EA=B4=84=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 총 11개 페이지를 동일한 페이지 구조 표준으로 마이그레이션. 페이지 메뉴명은 PageHeader 가 useMenu() 자동 매칭, 검색 영역은 CompactFilterBar/CompactFilterField, 날짜 범위는 CompactDateRange 로 통일. 모든 자체 grid 검색폼 + 자체 h1 + 자체 액션 버튼 그룹 제거. 영업관리 4: - sales/estimate (견적관리) — 7필드 + 결재상태 SmartSelect - sales/order (주문서관리) — 9필드 (날짜 2종) - sales/sale (판매관리) — 10필드 (출하지시상태 SmartSelect) - sales/revenue (매출관리) — 11필드 (날짜 3종) 프로젝트관리 2: - project/progress (진행관리) — 11필드 (그리드 6→자동 wrap) - project/wbs-template (제품구분_WBS관리) — 1필드 개발관리 5: - development/part-regist (PART 등록) — 2필드 (자동완성) + 7 액션 - development/part-search (PART 조회) — 2필드 + 5 액션 - development/ebom-regist (E-BOM 등록) — 4필드 + 3 액션 (잔재 Field helper 제거) - development/ebom-search (E-BOM 조회) — 3필드 + 4 액션 (정/역전개) - development/change-list (설계변경 리스트) — 8필드 (read-only) DB: - menu_info.menu_desc 11개 메뉴 보강 (PageHeader 자동 표시) - docs/migration/common/menu_desc_sync.sql (멱등 UPDATE) Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/migration/common/menu_desc_sync.sql | 20 ++ .../development/change-list/page.tsx | 132 +++++------ .../development/ebom-regist/page.tsx | 140 ++++++------ .../development/ebom-search/page.tsx | 163 +++++++------- .../development/part-regist/page.tsx | 125 +++++------ .../development/part-search/page.tsx | 110 +++++----- .../COMPANY_16/project/progress/page.tsx | 206 ++++++++---------- .../COMPANY_16/project/wbs-template/page.tsx | 68 +++--- .../(main)/COMPANY_16/sales/estimate/page.tsx | 155 ++++++------- .../(main)/COMPANY_16/sales/order/page.tsx | 160 ++++++-------- .../(main)/COMPANY_16/sales/revenue/page.tsx | 175 +++++++-------- .../app/(main)/COMPANY_16/sales/sale/page.tsx | 171 +++++++-------- 12 files changed, 736 insertions(+), 889 deletions(-) create mode 100644 docs/migration/common/menu_desc_sync.sql diff --git a/docs/migration/common/menu_desc_sync.sql b/docs/migration/common/menu_desc_sync.sql new file mode 100644 index 00000000..a92e6bd0 --- /dev/null +++ b/docs/migration/common/menu_desc_sync.sql @@ -0,0 +1,20 @@ +-- ============================================================ +-- 영업관리 4 + 프로젝트관리 2 + M-BOM 2 메뉴 menu_desc 보강 +-- (PageHeader 자동 매칭용) +-- 2026-05-13 +-- ============================================================ + +UPDATE menu_info SET menu_desc='고객사 견적 작성 · 발송 · 승인 (wace estimateList 1:1)' WHERE objid=100002; +UPDATE menu_info SET menu_desc='고객사 주문서 등록 · 항목 관리 (wace orderList 1:1)' WHERE objid=100003; +UPDATE menu_info SET menu_desc='출하 · 판매 처리 및 시리얼 · 송장 관리 (wace saleList 1:1)' WHERE objid=100004; +UPDATE menu_info SET menu_desc='매출 등록 · 세금계산서 · 수금 관리 (wace revenueList 1:1)' WHERE objid=100005; +UPDATE menu_info SET menu_desc='제품구분 별 WBS 템플릿 + 표준 작업 (wace 1:1)' WHERE objid=100007; +UPDATE menu_info SET menu_desc='프로젝트 진행 현황 + 8그룹 컬럼 (wace projectMgmtWbsList3 1:1)' WHERE objid=100008; +UPDATE menu_info SET menu_desc='생산용 BOM 트리 + read-only 조회 (운영판 mBomMgmtList 1:1)' WHERE objid IN (100016, 100032); + +-- 개발관리 5메뉴 +UPDATE menu_info SET menu_desc='PART 마스터 등록 · 수정 (wace partMngList 1:1)' WHERE objid=100010; +UPDATE menu_info SET menu_desc='PART 마스터 조회 (wace partMngListSearch 1:1)' WHERE objid=100011; +UPDATE menu_info SET menu_desc='E-BOM 등록 · CSV Import (wace structureList 1:1)' WHERE objid=100012; +UPDATE menu_info SET menu_desc='E-BOM 트리 정/역전개 조회 + Excel 다운로드 (wace 1:1)' WHERE objid=100013; +UPDATE menu_info SET menu_desc='설계변경 리스트 (read-only, wace partMngHisList 1:1)' WHERE objid=100014; diff --git a/frontend/app/(main)/COMPANY_16/development/change-list/page.tsx b/frontend/app/(main)/COMPANY_16/development/change-list/page.tsx index c175b976..29699a58 100644 --- a/frontend/app/(main)/COMPANY_16/development/change-list/page.tsx +++ b/frontend/app/(main)/COMPANY_16/development/change-list/page.tsx @@ -6,14 +6,13 @@ // 참조: docs/migration/development/03-eo-history.md import React, { useCallback, useEffect, useMemo, useState } from "react"; -import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Search, Loader2, RotateCcw } from "lucide-react"; import { toast } from "sonner"; import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; import { CommCodeSelect } from "@/components/common/CommCodeSelect"; import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect"; +import { PageHeader } from "@/components/common/PageHeader"; +import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar"; import { devEoHistoryApi, EoHistoryListFilter, EoHistoryRow } from "@/lib/api/devEoHistory"; import { PartHisDetailDialog } from "@/components/development/PartHisDetailDialog"; @@ -92,73 +91,62 @@ export default function EoHistoryPage() { ); return ( -
-
-
- - setFilter({ ...filter, Year: v })} - placeholder="전체" - /> - - - setFilter({ ...filter, contract_objid: e.target.value })} - placeholder="project_mgmt.objid" /> - - - setFilter({ ...filter, part_no: e.target.value })} - placeholder="part_no LIKE" /> - - - setFilter({ ...filter, part_name: e.target.value })} - placeholder="part_name LIKE" /> - +
+ - - setFilter({ ...filter, eo_start_date: e.target.value })} /> - - - setFilter({ ...filter, eo_end_date: e.target.value })} /> - - - setFilter({ ...filter, part_type: v })} /> - - -
- setFilter({ ...filter, change_type: e.target.value })} - placeholder="EO구분 code_id" /> - setFilter({ ...filter, change_option: e.target.value })} - placeholder="EO사유 code_id" /> -
-
-
-
-
총 {total.toLocaleString()}건 (read-only)
-
- - -
-
-
+ fetchList()} + onReset={() => { setFilter(EMPTY_FILTER); fetchList(EMPTY_FILTER); }} + totalText={<>총 {total.toLocaleString()}건 (read-only)} + > + + setFilter({ ...filter, Year: v })} + placeholder="전체" + /> + + + setFilter({ ...filter, contract_objid: e.target.value })} + placeholder="project_mgmt.objid" /> + + + setFilter({ ...filter, part_no: e.target.value })} + placeholder="part_no LIKE" /> + + + setFilter({ ...filter, part_name: e.target.value })} + placeholder="part_name LIKE" /> + + + setFilter({ ...filter, eo_start_date: v })} + to={filter.eo_end_date ?? ""} + setTo={(v) => setFilter({ ...filter, eo_end_date: v })} + /> + + + setFilter({ ...filter, part_type: v })} /> + + + setFilter({ ...filter, change_type: e.target.value })} /> + + + setFilter({ ...filter, change_option: e.target.value })} /> + + -
+
- - {children} -
- ); -} diff --git a/frontend/app/(main)/COMPANY_16/development/ebom-regist/page.tsx b/frontend/app/(main)/COMPANY_16/development/ebom-regist/page.tsx index 5bc09508..4a3e8343 100644 --- a/frontend/app/(main)/COMPANY_16/development/ebom-regist/page.tsx +++ b/frontend/app/(main)/COMPANY_16/development/ebom-regist/page.tsx @@ -7,15 +7,13 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - Search, Loader2, RotateCcw, Trash2, Settings, FileSpreadsheet, -} from "lucide-react"; +import { Trash2, Settings, FileSpreadsheet } from "lucide-react"; import { toast } from "sonner"; import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; import { CommCodeSelect } from "@/components/common/CommCodeSelect"; import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect"; +import { PageHeader } from "@/components/common/PageHeader"; +import { CompactFilterBar, CompactFilterField } from "@/components/common/CompactFilterBar"; import { devBomApi, BomReportListFilter, BomReportRow } from "@/lib/api/devBom"; import { BomReportStatusDialog } from "@/components/development/BomReportStatusDialog"; import { DevPartSelect } from "@/components/development/DevPartSelect"; @@ -116,72 +114,66 @@ export default function EbomRegistPage() { const gridRows = useMemo(() => rows.map((r) => ({ ...r, id: r.objid })), [rows]); return ( -
-
-
- - setFilter({ ...filter, product_cd: v })} - /> - - - setFilter({ ...filter, status: v })} - placeholder="전체" - /> - - {/* wace structureList.jsp 1:1 — select2-part 자동완성 (양방향 동기) */} - - setFilter((prev) => ({ - ...prev, - search_part_no: v, - search_part_name: row?.part_name ?? prev.search_part_name, - }))} /> - - - setFilter((prev) => ({ - ...prev, - search_part_name: v, - search_part_no: row?.part_no ?? prev.search_part_no, - }))} /> - -
-
-
총 {total.toLocaleString()}건
-
- - - - - -
-
-
+
+ + + + + + } /> -
+ fetchList()} + onReset={() => { setFilter(EMPTY_FILTER); fetchList(EMPTY_FILTER); }} + totalText={<>총 {total.toLocaleString()}건} + > + + setFilter({ ...filter, product_cd: v })} + /> + + + setFilter({ ...filter, status: v })} + placeholder="전체" + /> + + + setFilter((prev) => ({ + ...prev, + search_part_no: v, + search_part_name: row?.part_name ?? prev.search_part_name, + }))} /> + + + setFilter((prev) => ({ + ...prev, + search_part_name: v, + search_part_no: row?.part_no ?? prev.search_part_no, + }))} /> + + + +
- - {children} -
- ); -} diff --git a/frontend/app/(main)/COMPANY_16/development/ebom-search/page.tsx b/frontend/app/(main)/COMPANY_16/development/ebom-search/page.tsx index 1239e682..3d7fa329 100644 --- a/frontend/app/(main)/COMPANY_16/development/ebom-search/page.tsx +++ b/frontend/app/(main)/COMPANY_16/development/ebom-search/page.tsx @@ -6,11 +6,9 @@ import React, { useCallback, useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - Search, Loader2, RotateCcw, ChevronsRight, ChevronsLeft, FileSpreadsheet, -} from "lucide-react"; +import { PageHeader } from "@/components/common/PageHeader"; +import { CompactFilterBar, CompactFilterField } from "@/components/common/CompactFilterBar"; +import { Loader2, ChevronsRight, ChevronsLeft, FileSpreadsheet } from "lucide-react"; import { toast } from "sonner"; import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; import { devBomApi, BomTreeFilter, BomTreeRow } from "@/lib/api/devBom"; @@ -186,81 +184,80 @@ export default function EbomSearchPage() { }, [rows, maxLevel, hasChildSet, ancestorsByChildId, collapsedChildIds]); return ( -
-
- {/* 운영판 wace structureAscendingList.jsp 1:1 — 노출 검색 필드 3개 - (고객사/프로젝트번호/유닛명 은 운영판에서도 주석 처리되어 노출 안 됨) */} -
- - setFilter((prev) => ({ - ...prev, - search_part_no: v, - // 품번 선택 시 품명 자동 채움 (wace select2-part 1:1) - search_part_name: row?.part_name ?? prev.search_part_name, - }))} /> - - - setFilter((prev) => ({ - ...prev, - search_part_name: v, - // 품명 선택 시 품번 자동 채움 - search_part_no: row?.part_no ?? prev.search_part_no, - }))} /> - - - setFilter({ ...filter, search_level: v })} - placeholder="전체" - /> - -
-
-
- 모드: {direction === "ascending" ? "정전개 (루트 → 리프)" : "역전개 (리프 → 부모)"} · {rows.length.toLocaleString()}행 · MAX_LEVEL = {maxLevel} -
-
- - - - - -
-
- {direction === "descending" && ( -
- 역전개는 품번 또는 품명 검색 조건이 필요합니다. -
- )} -
+
+ + + + + + + } /> -
+ {/* 운영판 wace structureAscendingList.jsp 1:1 — 노출 검색 필드 3개 + (고객사/프로젝트번호/유닛명 은 운영판에서도 주석 처리되어 노출 안 됨) */} + { setFilter(EMPTY_FILTER); setRows([]); setMaxLevel(0); }} + totalText={<>모드: {direction === "ascending" ? "정전개 (루트 → 리프)" : "역전개 (리프 → 부모)"} · {rows.length.toLocaleString()}행 · MAX_LEVEL = {maxLevel}} + > + + setFilter((prev) => ({ + ...prev, + search_part_no: v, + // 품번 선택 시 품명 자동 채움 (wace select2-part 1:1) + search_part_name: row?.part_name ?? prev.search_part_name, + }))} /> + + + setFilter((prev) => ({ + ...prev, + search_part_name: v, + // 품명 선택 시 품번 자동 채움 + search_part_no: row?.part_no ?? prev.search_part_no, + }))} /> + + + setFilter({ ...filter, search_level: v })} + placeholder="전체" + /> + + + + {direction === "descending" && ( +
+ 역전개는 품번 또는 품명 검색 조건이 필요합니다. +
+ )} + +
- - {children} -
- ); -} diff --git a/frontend/app/(main)/COMPANY_16/development/part-regist/page.tsx b/frontend/app/(main)/COMPANY_16/development/part-regist/page.tsx index ca80b6d6..a84f37da 100644 --- a/frontend/app/(main)/COMPANY_16/development/part-regist/page.tsx +++ b/frontend/app/(main)/COMPANY_16/development/part-regist/page.tsx @@ -7,10 +7,10 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; +import { PageHeader } from "@/components/common/PageHeader"; +import { CompactFilterBar, CompactFilterField } from "@/components/common/CompactFilterBar"; import { - Search, Loader2, RotateCcw, Plus, Pencil, Trash2, CheckSquare, FileSpreadsheet, + Plus, Pencil, Trash2, CheckSquare, FileSpreadsheet, } from "lucide-react"; import { toast } from "sonner"; import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; @@ -155,71 +155,62 @@ export default function PartRegistPage() { }; return ( -
- {/* 검색폼 — wace partMngTempList.jsp 활성 2필드 */} -
-
- {/* wace partMngTempList.jsp 1:1 — select2-part 자동완성 (양방향 동기) */} -
- - setFilter((prev) => ({ - ...prev, - search_part_no: v, - search_part_name: row?.part_name ?? prev.search_part_name, - }))} /> -
-
- - setFilter((prev) => ({ - ...prev, - search_part_name: v, - search_part_no: row?.part_no ?? prev.search_part_no, - }))} /> -
-
- - - - - - - r.part_no).filter(Boolean) as string[]} - onUploaded={() => fetchList()} - /> - -
-
-
- 총 {total.toLocaleString()}건 (M1: status ≠ 'release') -
-
+
+ + + + + + r.part_no).filter(Boolean) as string[]} + onUploaded={() => fetchList()} + /> + + + } /> -
+ fetchList()} + onReset={() => { setFilter(EMPTY_FILTER); fetchList(EMPTY_FILTER); }} + totalText={<>총 {total.toLocaleString()}건 (M1: status ≠ 'release')} + > + + setFilter((prev) => ({ + ...prev, + search_part_no: v, + search_part_name: row?.part_name ?? prev.search_part_name, + }))} /> + + + setFilter((prev) => ({ + ...prev, + search_part_name: v, + search_part_no: row?.part_no ?? prev.search_part_no, + }))} /> + + + +
-
-
- {/* wace partMngList.jsp 1:1 — select2-part 자동완성 (양방향 동기) */} -
- - setFilter((prev) => ({ - ...prev, - search_part_no: v, - search_part_name: row?.part_name ?? prev.search_part_name, - }))} /> -
-
- - setFilter((prev) => ({ - ...prev, - search_part_name: v, - search_part_no: row?.part_no ?? prev.search_part_no, - }))} /> -
-
- - - - - - - {/* M2 조회 — partNoList 미전달: IS_LAST='1' 전체 part_mng 매칭 (페이지 밖도 허용) */} - fetchList()} /> -
-
-
- 총 {total.toLocaleString()}건 (M2: status = 'release') -
-
+
+ + + + + + {/* M2 조회 — partNoList 미전달: IS_LAST='1' 전체 part_mng 매칭 (페이지 밖도 허용) */} + fetchList()} /> + + } /> -
+ fetchList()} + onReset={() => { setFilter(EMPTY_FILTER); fetchList(EMPTY_FILTER); }} + totalText={<>총 {total.toLocaleString()}건 (M2: status = 'release')} + > + + setFilter((prev) => ({ + ...prev, + search_part_no: v, + search_part_name: row?.part_name ?? prev.search_part_name, + }))} /> + + + setFilter((prev) => ({ + ...prev, + search_part_name: v, + search_part_no: row?.part_no ?? prev.search_part_no, + }))} /> + + + +
- {/* 검색폼 — wace projectMgmtWbsList3.jsp:222-313 활성 11필드 */} -
-
- {/* 1행 */} - - setFilter({ ...filter, Year: v })} - placeholder="전체" - /> - - - setFilter({ ...filter, project_nos: v })} - placeholder="전체" - /> - - - setFilter({ ...filter, category_cd: v })} - /> - - - setFilter({ ...filter, customer_objid: v })} - /> - - - setFilter({ ...filter, product: v })} - /> - - -
- setFilter({ ...filter, contract_start_date: e.target.value })} /> - ~ - setFilter({ ...filter, contract_end_date: e.target.value })} /> -
-
+
+ - {/* 2행 */} - - setFilter({ ...filter, area_cd: v })} - placeholder="전체" - /> - - - setFilter({ ...filter, free_of_charge: v })} - placeholder="전체" - /> - - - setFilter({ ...filter, search_partObjId: v })} - /> - - - setFilter({ ...filter, search_partObjId: v })} - /> - - - setFilter({ ...filter, serial_no: e.target.value })} - placeholder="S/N LIKE" - /> - + 총 {rows.length.toLocaleString()}건} + > + + setFilter({ ...filter, Year: v })} + placeholder="전체" + /> + + + setFilter({ ...filter, project_nos: v })} + placeholder="전체" + /> + + + setFilter({ ...filter, category_cd: v })} + /> + + + setFilter({ ...filter, customer_objid: v })} + /> + + + setFilter({ ...filter, product: v })} + /> + + + setFilter({ ...filter, area_cd: v })} + placeholder="전체" + /> + + + setFilter({ ...filter, free_of_charge: v })} + placeholder="전체" + /> + + + setFilter({ ...filter, search_partObjId: v })} + /> + + + setFilter({ ...filter, search_partObjId: v })} + /> + + + setFilter({ ...filter, serial_no: e.target.value })} + placeholder="S/N LIKE" + /> + + + setFilter({ ...filter, contract_start_date: v })} + to={filter.contract_end_date ?? ""} + setTo={(v) => setFilter({ ...filter, contract_end_date: v })} + /> + + - {/* 액션 */} -
- - -
-
-
- - {/* 그리드 (8그룹 18셀 평탄화) */} -
+
); } - -function Field({ label, children }: { label: string; children: React.ReactNode }) { - return ( -
- - {children} -
- ); -} diff --git a/frontend/app/(main)/COMPANY_16/project/wbs-template/page.tsx b/frontend/app/(main)/COMPANY_16/project/wbs-template/page.tsx index c3d747cb..b3bd66ca 100644 --- a/frontend/app/(main)/COMPANY_16/project/wbs-template/page.tsx +++ b/frontend/app/(main)/COMPANY_16/project/wbs-template/page.tsx @@ -12,11 +12,12 @@ import React, { useCallback, useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; -import { Search, Loader2, RotateCcw, Plus, Trash2 } from "lucide-react"; +import { Plus, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; import { CommCodeSelect } from "@/components/common/CommCodeSelect"; -import { Label } from "@/components/ui/label"; +import { PageHeader } from "@/components/common/PageHeader"; +import { CompactFilterBar, CompactFilterField } from "@/components/common/CompactFilterBar"; import { wbsTemplateApi, TemplateRow } from "@/lib/api/wbsTemplate"; import { WbsTemplateDialog } from "@/components/project/WbsTemplateDialog"; @@ -110,43 +111,34 @@ export default function WbsTemplatePage() { ); return ( -
- {/* 검색폼 — wace wbsTemplateMngList.jsp:361-371 (제품구분 1필드) */} -
-
-
- - -
-
- - - - -
-
-
+
+ + + + + } /> - {/* 그리드 (5컬럼) */} -
+ 총 {rows.length.toLocaleString()}건} + > + + + + + +
setSearchForm({ + category_cd: "", customer_objid: "", + search_partObjId: "", search_partName: "", search_serialNo: "", + appr_status: "", + receipt_start_date: "", receipt_end_date: "", + }); + return ( -
+
{ConfirmDialogComponent} - {/* 헤더 */} -
-
-

영업관리 _ 견적관리

-

총 {rows.length}건

-
-
- - - - - - - -
-
+ + } /> - {/* 검색 폼 — wace 원본 estimateList_new.jsp 활성 7개 */} -
-
- + 총 {rows.length.toLocaleString()}건} + > + setSearchForm({ ...searchForm, category_cd: v })} - className="h-8 text-xs" /> -
-
- + onValueChange={(v) => setSearchForm({ ...searchForm, category_cd: v })} /> + + setSearchForm({ ...searchForm, customer_objid: v })} - className="h-8 text-xs" /> -
-
- + onValueChange={(v) => setSearchForm({ ...searchForm, customer_objid: v })} /> + + setSearchForm({ ...searchForm, search_partObjId: v })} - className="h-8 text-xs" /> -
-
- + onValueChange={(v) => setSearchForm({ ...searchForm, search_partObjId: v })} /> + + setSearchForm({ ...searchForm, search_partObjId: v })} - className="h-8 text-xs" /> -
-
- - setSearchForm({ ...searchForm, search_partObjId: v })} /> + + + setSearchForm({ ...searchForm, search_serialNo: e.target.value })} /> -
-
- - -
-
- -
- setSearchForm({ ...searchForm, receipt_start_date: e.target.value })} /> - ~ - setSearchForm({ ...searchForm, receipt_end_date: e.target.value })} /> -
-
-
+ + + setSearchForm({ ...searchForm, appr_status: v })} + placeholder="전체" + /> + + + setSearchForm({ ...searchForm, receipt_start_date: v })} + to={searchForm.receipt_end_date} + setTo={(v) => setSearchForm({ ...searchForm, receipt_end_date: v })} + /> + + {/* 그리드 — 첫 컬럼 체크박스 (행 아무 셀 클릭으로 단일 선택, 클립/폴더 등 팝업 컬럼은 stopPropagation으로 제외) */} setSearchForm({ + category_cd: "", search_poNo: "", customer_objid: "", + search_partObjId: "", search_partName: "", search_serialNo: "", contract_result: "", + order_start_date: "", order_end_date: "", + due_start_date: "", due_end_date: "", + }); + return ( -
+
{ConfirmDialogComponent} -
-
-

주문서관리

-

총 {rows.length}건

-
-
- - - - - - - - -
-
+ + } /> - {/* 검색 폼 — wace 원본 orderMgmtList.jsp 활성 9개 */} -
-
- + 총 {rows.length.toLocaleString()}건} + > + setSearchForm({ ...searchForm, category_cd: v })} - className="h-8 text-xs" /> -
-
- - setSearchForm({ ...searchForm, category_cd: v })} /> + + + setSearchForm({ ...searchForm, search_poNo: e.target.value })} /> -
-
- + + setSearchForm({ ...searchForm, customer_objid: v })} - className="h-8 text-xs" /> -
-
- + onValueChange={(v) => setSearchForm({ ...searchForm, customer_objid: v })} /> + + setSearchForm({ ...searchForm, search_partObjId: v })} - className="h-8 text-xs" /> -
-
- + onValueChange={(v) => setSearchForm({ ...searchForm, search_partObjId: v })} /> + + setSearchForm({ ...searchForm, search_partObjId: v })} - className="h-8 text-xs" /> -
-
- - setSearchForm({ ...searchForm, search_partObjId: v })} /> + + + setSearchForm({ ...searchForm, search_serialNo: e.target.value })} /> -
-
- + + setSearchForm({ ...searchForm, contract_result: v })} - className="h-8 text-xs" /> -
- {/* 2줄 */} -
- -
- setSearchForm({ ...searchForm, order_start_date: e.target.value })} /> - ~ - setSearchForm({ ...searchForm, order_end_date: e.target.value })} /> -
-
-
- -
- setSearchForm({ ...searchForm, due_start_date: e.target.value })} /> - ~ - setSearchForm({ ...searchForm, due_end_date: e.target.value })} /> -
-
-
+ onValueChange={(v) => setSearchForm({ ...searchForm, contract_result: v })} /> + + + setSearchForm({ ...searchForm, order_start_date: v })} + to={searchForm.order_end_date} + setTo={(v) => setSearchForm({ ...searchForm, order_end_date: v })} + /> + + + setSearchForm({ ...searchForm, due_start_date: v })} + to={searchForm.due_end_date} + setTo={(v) => setSearchForm({ ...searchForm, due_end_date: v })} + /> + + setSearchForm({ + orderType: "", poNo: "", customer_objid: "", + productType: "", search_partObjId: "", nation: "", + serialNo: "", + salesDeadlineFrom: "", salesDeadlineTo: "", + orderDateFrom: "", orderDateTo: "", + shippingDateFrom: "", shippingDateTo: "", + }); + return ( -
+
{ConfirmDialogComponent} -
-
-

매출관리

-

총 {rows.length}건 (출하/매출 이력)

-
-
- - - - -
-
+ + } /> - {/* 검색 폼 — wace 원본 revenueMgmtList.jsp 활성 11개 */} -
-
- + 총 {rows.length.toLocaleString()}건 (출하/매출 이력)} + > + setSearchForm({ ...searchForm, orderType: v })} - className="h-8 text-xs" /> -
-
- - setSearchForm({ ...searchForm, orderType: v })} /> + + + setSearchForm({ ...searchForm, poNo: e.target.value })} /> -
-
- + + setSearchForm({ ...searchForm, customer_objid: v })} - className="h-8 text-xs" /> -
-
- + onValueChange={(v) => setSearchForm({ ...searchForm, customer_objid: v })} /> + + setSearchForm({ ...searchForm, productType: v })} - className="h-8 text-xs" /> -
-
- + onValueChange={(v) => setSearchForm({ ...searchForm, productType: v })} /> + + setSearchForm({ ...searchForm, search_partObjId: v })} - className="h-8 text-xs" /> -
-
- + onValueChange={(v) => setSearchForm({ ...searchForm, search_partObjId: v })} /> + + setSearchForm({ ...searchForm, search_partObjId: v })} - className="h-8 text-xs" /> -
-
- + onValueChange={(v) => setSearchForm({ ...searchForm, search_partObjId: v })} /> + + setSearchForm({ ...searchForm, nation: v })} - className="h-8 text-xs" /> -
- {/* 2줄 */} -
- - setSearchForm({ ...searchForm, nation: v })} /> + + + setSearchForm({ ...searchForm, serialNo: e.target.value })} /> -
-
- -
- setSearchForm({ ...searchForm, salesDeadlineFrom: e.target.value })} /> - ~ - setSearchForm({ ...searchForm, salesDeadlineTo: e.target.value })} /> -
-
-
- -
- setSearchForm({ ...searchForm, orderDateFrom: e.target.value })} /> - ~ - setSearchForm({ ...searchForm, orderDateTo: e.target.value })} /> -
-
-
- -
- setSearchForm({ ...searchForm, shippingDateFrom: e.target.value })} /> - ~ - setSearchForm({ ...searchForm, shippingDateTo: e.target.value })} /> -
-
-
+ + + setSearchForm({ ...searchForm, salesDeadlineFrom: v })} + to={searchForm.salesDeadlineTo} + setTo={(v) => setSearchForm({ ...searchForm, salesDeadlineTo: v })} + /> + + + setSearchForm({ ...searchForm, orderDateFrom: v })} + to={searchForm.orderDateTo} + setTo={(v) => setSearchForm({ ...searchForm, orderDateTo: v })} + /> + + + setSearchForm({ ...searchForm, shippingDateFrom: v })} + to={searchForm.shippingDateTo} + setTo={(v) => setSearchForm({ ...searchForm, shippingDateTo: v })} + /> + + -
-
-

판매관리

-

총 {rows.length}건 (라인 단위)

-
-
- - - -
-
+ const shippingStatusOpts: SmartSelectOption[] = [ + { code: "PENDING", label: "대기" }, + { code: "COMPLETED", label: "완료" }, + { code: "CANCELLED", label: "취소" }, + ]; - {/* 검색 폼 — wace 원본 salesMgmtList.jsp 재현 (1줄 7개 / 2줄 3개) */} -
-
- + const handleReset = () => setSearchForm({ + orderType: "", poNo: "", customer_objid: "", search_partObjId: "", + serialNo: "", shippingStatus: "", salesStatus: "", + orderDateFrom: "", orderDateTo: "", + shippingDateFrom: "", shippingDateTo: "", + }); + + return ( +
+ + 출하지시/판매등록 + + } /> + + 총 {rows.length.toLocaleString()}건 (라인 단위)} + > + setSearchForm({ ...searchForm, orderType: v })} - className="h-8 text-xs" /> -
-
- - setSearchForm({ ...searchForm, orderType: v })} /> + + + setSearchForm({ ...searchForm, poNo: e.target.value })} /> -
-
- + + setSearchForm({ ...searchForm, customer_objid: v })} - className="h-8 text-xs" /> -
-
- + onValueChange={(v) => setSearchForm({ ...searchForm, customer_objid: v })} /> + + setSearchForm({ ...searchForm, search_partObjId: v })} - className="h-8 text-xs" /> -
-
- + onValueChange={(v) => setSearchForm({ ...searchForm, search_partObjId: v })} /> + + setSearchForm({ ...searchForm, search_partObjId: v })} - className="h-8 text-xs" /> -
-
- - setSearchForm({ ...searchForm, search_partObjId: v })} /> + + + setSearchForm({ ...searchForm, serialNo: e.target.value })} /> -
-
- - -
- {/* 2줄 */} -
- -
- setSearchForm({ ...searchForm, orderDateFrom: e.target.value })} /> - ~ - setSearchForm({ ...searchForm, orderDateTo: e.target.value })} /> -
-
-
- -
- setSearchForm({ ...searchForm, shippingDateFrom: e.target.value })} /> - ~ - setSearchForm({ ...searchForm, shippingDateTo: e.target.value })} /> -
-
-
- + + + setSearchForm({ ...searchForm, shippingStatus: v })} + placeholder="전체" + /> + + setSearchForm({ ...searchForm, salesStatus: v })} - className="h-8 text-xs" /> -
-
+ onValueChange={(v) => setSearchForm({ ...searchForm, salesStatus: v })} /> + + + setSearchForm({ ...searchForm, orderDateFrom: v })} + to={searchForm.orderDateTo} + setTo={(v) => setSearchForm({ ...searchForm, orderDateTo: v })} + /> + + + setSearchForm({ ...searchForm, shippingDateFrom: v })} + to={searchForm.shippingDateTo} + setTo={(v) => setSearchForm({ ...searchForm, shippingDateTo: v })} + /> + + Date: Wed, 13 May 2026 17:11:43 +0900 Subject: [PATCH 10/12] =?UTF-8?q?=EA=B3=B5=EC=9A=A9=20=E2=80=94=20menu=5Fd?= =?UTF-8?q?esc=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EB=85=B8=EC=B6=9C=20?= =?UTF-8?q?=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EC=A0=95=EB=A6=AC=20(=EA=B0=9C?= =?UTF-8?q?=EB=B0=9C=20=EB=A9=94=EB=AA=A8=20=EC=A0=9C=EA=B1=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PageHeader 가 표시하는 menu_info.menu_desc 에서 (wace estimateList 1:1) 같은 원본 매퍼 출처/JSP 파일명 등 개발 메모를 제거. 사용자가 보는 화면이므로 업무 의미만 남긴다. DB 업데이트 + data-sync 스크립트 동일 내용으로 정정 (멱등 UPDATE 13건). 원칙은 feedback_compact_search_pattern.md 에 박제. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/migration/common/menu_desc_sync.sql | 35 ++++++++++++++---------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/docs/migration/common/menu_desc_sync.sql b/docs/migration/common/menu_desc_sync.sql index a92e6bd0..a1fb890a 100644 --- a/docs/migration/common/menu_desc_sync.sql +++ b/docs/migration/common/menu_desc_sync.sql @@ -1,20 +1,25 @@ -- ============================================================ --- 영업관리 4 + 프로젝트관리 2 + M-BOM 2 메뉴 menu_desc 보강 --- (PageHeader 자동 매칭용) +-- 메뉴 menu_desc 보강 (PageHeader 자동 매칭용) -- 2026-05-13 +-- 사용자 화면에 표시되는 텍스트 — 개발 메모(wace ... 1:1)는 포함하지 않는다. -- ============================================================ -UPDATE menu_info SET menu_desc='고객사 견적 작성 · 발송 · 승인 (wace estimateList 1:1)' WHERE objid=100002; -UPDATE menu_info SET menu_desc='고객사 주문서 등록 · 항목 관리 (wace orderList 1:1)' WHERE objid=100003; -UPDATE menu_info SET menu_desc='출하 · 판매 처리 및 시리얼 · 송장 관리 (wace saleList 1:1)' WHERE objid=100004; -UPDATE menu_info SET menu_desc='매출 등록 · 세금계산서 · 수금 관리 (wace revenueList 1:1)' WHERE objid=100005; -UPDATE menu_info SET menu_desc='제품구분 별 WBS 템플릿 + 표준 작업 (wace 1:1)' WHERE objid=100007; -UPDATE menu_info SET menu_desc='프로젝트 진행 현황 + 8그룹 컬럼 (wace projectMgmtWbsList3 1:1)' WHERE objid=100008; -UPDATE menu_info SET menu_desc='생산용 BOM 트리 + read-only 조회 (운영판 mBomMgmtList 1:1)' WHERE objid IN (100016, 100032); +-- 영업관리 +UPDATE menu_info SET menu_desc='고객사 견적 작성 · 발송 · 승인' WHERE objid=100002; +UPDATE menu_info SET menu_desc='고객사 주문서 등록 · 항목 관리' WHERE objid=100003; +UPDATE menu_info SET menu_desc='출하 · 판매 처리 및 시리얼 · 송장 관리' WHERE objid=100004; +UPDATE menu_info SET menu_desc='매출 등록 · 세금계산서 · 수금 관리' WHERE objid=100005; --- 개발관리 5메뉴 -UPDATE menu_info SET menu_desc='PART 마스터 등록 · 수정 (wace partMngList 1:1)' WHERE objid=100010; -UPDATE menu_info SET menu_desc='PART 마스터 조회 (wace partMngListSearch 1:1)' WHERE objid=100011; -UPDATE menu_info SET menu_desc='E-BOM 등록 · CSV Import (wace structureList 1:1)' WHERE objid=100012; -UPDATE menu_info SET menu_desc='E-BOM 트리 정/역전개 조회 + Excel 다운로드 (wace 1:1)' WHERE objid=100013; -UPDATE menu_info SET menu_desc='설계변경 리스트 (read-only, wace partMngHisList 1:1)' WHERE objid=100014; +-- 프로젝트관리 +UPDATE menu_info SET menu_desc='제품구분 별 WBS 템플릿 + 표준 작업' WHERE objid=100007; +UPDATE menu_info SET menu_desc='프로젝트 진행 현황' WHERE objid=100008; + +-- 개발관리 +UPDATE menu_info SET menu_desc='PART 마스터 등록 · 수정' WHERE objid=100010; +UPDATE menu_info SET menu_desc='PART 마스터 조회' WHERE objid=100011; +UPDATE menu_info SET menu_desc='E-BOM 등록 · CSV Import' WHERE objid=100012; +UPDATE menu_info SET menu_desc='E-BOM 트리 정/역전개 조회 + Excel 다운로드' WHERE objid=100013; +UPDATE menu_info SET menu_desc='설계변경 이력 조회' WHERE objid=100014; + +-- 생산관리/구매관리 공용 +UPDATE menu_info SET menu_desc='생산용 BOM 트리 + read-only 조회' WHERE objid IN (100016, 100032); From a136867f524a6040bb72a76a8fad4595a7994acf Mon Sep 17 00:00:00 2001 From: hjjeong Date: Wed, 13 May 2026 17:17:24 +0900 Subject: [PATCH 11/12] =?UTF-8?q?=EA=B3=B5=EC=9A=A9=20=E2=80=94=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=C2=B7=EC=B4=88=EA=B8=B0=ED=99=94=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=EC=9D=84=20PageHeader=20=EC=9A=B0=EC=B8=A1=20?= =?UTF-8?q?=EC=95=A1=EC=85=98=20=EC=98=81=EC=97=AD=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EC=A0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 보고: "초기화, 검색 버튼은 상단의 메뉴이름 쪽에 다른 버튼들이랑 같이 있으면 될거같아" CompactFilterBar 안에 있던 [초기화][검색] 버튼이 자리 차지 + 시선 분산. PageHeader 의 actions 슬롯 옆으로 통합하면서 11개 페이지 일괄 적용. PageHeader 확장: - onSearch / onReset / loading / searchLabel / resetLabel prop 추가 - actions 뒤에 [초기화][검색] 버튼 자동 렌더 (h-8 / text-xs) CompactFilterBar 단순화: - onSearch / onReset / loading / searchLabel / resetLabel prop 제거 - children + totalText 만 유지 (필드 컨테이너 + 합계 텍스트) 11개 페이지: 3 prop 을 로 이동 메모리: feedback_compact_search_pattern.md 에 "검색·초기화 위치 = PageHeader" 박제 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../development/change-list/page.tsx | 9 ++-- .../development/ebom-regist/page.tsx | 13 +++--- .../development/ebom-search/page.tsx | 6 +-- .../development/part-regist/page.tsx | 13 +++--- .../development/part-search/page.tsx | 13 +++--- .../COMPANY_16/production/mbom/page.tsx | 5 ++- .../COMPANY_16/project/progress/page.tsx | 9 ++-- .../COMPANY_16/project/wbs-template/page.tsx | 13 +++--- .../(main)/COMPANY_16/sales/estimate/page.tsx | 9 ++-- .../(main)/COMPANY_16/sales/order/page.tsx | 13 +++--- .../(main)/COMPANY_16/sales/revenue/page.tsx | 13 +++--- .../app/(main)/COMPANY_16/sales/sale/page.tsx | 13 +++--- .../components/common/CompactFilterBar.tsx | 36 ++-------------- frontend/components/common/PageHeader.tsx | 41 +++++++++++++++++-- 14 files changed, 102 insertions(+), 104 deletions(-) diff --git a/frontend/app/(main)/COMPANY_16/development/change-list/page.tsx b/frontend/app/(main)/COMPANY_16/development/change-list/page.tsx index 29699a58..18db5ddb 100644 --- a/frontend/app/(main)/COMPANY_16/development/change-list/page.tsx +++ b/frontend/app/(main)/COMPANY_16/development/change-list/page.tsx @@ -92,14 +92,13 @@ export default function EoHistoryPage() { return (
- - - fetchList()} onReset={() => { setFilter(EMPTY_FILTER); fetchList(EMPTY_FILTER); }} - totalText={<>총 {total.toLocaleString()}건 (read-only)} - > + /> + + 총 {total.toLocaleString()}건 (read-only)}> - fetchList()} + onReset={() => { setFilter(EMPTY_FILTER); fetchList(EMPTY_FILTER); }} + actions={ <> } /> - 총 {rows.length.toLocaleString()}건 (라인 단위)} - > + 총 {rows.length.toLocaleString()}건 (라인 단위)}> void; - onReset?: () => void; /** 우측에 표시할 합계/통계 텍스트 (예: "총 12,345건 · 합계 12,000,000원") */ totalText?: React.ReactNode; - loading?: boolean; - searchLabel?: string; - resetLabel?: string; className?: string; } -export function CompactFilterBar({ - children, - onSearch, - onReset, - totalText, - loading, - searchLabel = "검색", - resetLabel = "초기화", - className, -}: CompactFilterBarProps) { +export function CompactFilterBar({ children, totalText, className }: CompactFilterBarProps) { + // 검색/초기화 버튼은 PageHeader 의 우측 액션 영역으로 통합. + // CompactFilterBar 는 필드 컨테이너 + 합계 텍스트만 담당. return (
{children} - {(onReset || onSearch) && ( -
- {onReset && ( - - )} - {onSearch && ( - - )} -
- )} {totalText != null && ( {totalText} )} diff --git a/frontend/components/common/PageHeader.tsx b/frontend/components/common/PageHeader.tsx index 0d78e813..dea24e55 100644 --- a/frontend/components/common/PageHeader.tsx +++ b/frontend/components/common/PageHeader.tsx @@ -23,12 +23,23 @@ import { usePathname } from "next/navigation"; import { useMenu } from "@/contexts/MenuContext"; import { useTabStore, selectTabs, selectActiveTabId } from "@/stores/tabStore"; import type { MenuItem } from "@/lib/api/menu"; +import { Button } from "@/components/ui/button"; +import { Search, Loader2, RotateCcw } from "lucide-react"; import { cn } from "@/lib/utils"; interface PageHeaderProps { title?: string; description?: string; + /** 업무 액션 슬롯 (등록/삭제/상신 등). 검색·초기화는 onSearch/onReset 로 전달. */ actions?: React.ReactNode; + /** 검색 핸들러. 지정 시 우측에 검색 버튼 자동 렌더. */ + onSearch?: () => void; + /** 초기화 핸들러. 지정 시 우측에 초기화 버튼 자동 렌더. */ + onReset?: () => void; + /** 검색 중 로딩 표시 */ + loading?: boolean; + searchLabel?: string; + resetLabel?: string; className?: string; } @@ -56,7 +67,10 @@ function findByUrl(menus: MenuItem[], strippedUrl: string): MenuItem | null { return best; } -export function PageHeader({ title, description, actions, className }: PageHeaderProps) { +export function PageHeader({ + title, description, actions, onSearch, onReset, loading, + searchLabel = "검색", resetLabel = "초기화", className, +}: PageHeaderProps) { const pathname = usePathname() ?? ""; const tabs = useTabStore(selectTabs); const activeTabId = useTabStore(selectActiveTabId); @@ -81,7 +95,8 @@ export function PageHeader({ title, description, actions, className }: PageHeade const resolvedTitle = title ?? menu?.menu_name_kor ?? ""; const resolvedDesc = description ?? menu?.menu_desc ?? ""; - if (!resolvedTitle && !resolvedDesc && !actions) return null; + const hasSearchButtons = !!(onSearch || onReset); + if (!resolvedTitle && !resolvedDesc && !actions && !hasSearchButtons) return null; return (
@@ -93,7 +108,27 @@ export function PageHeader({ title, description, actions, className }: PageHeade

{resolvedDesc}

)}
- {actions &&
{actions}
} + {(actions || hasSearchButtons) && ( +
+ {actions} + {hasSearchButtons && ( + <> + {onReset && ( + + )} + {onSearch && ( + + )} + + )} +
+ )}
); } From 49956f7afa51d58e7dad0abab8f2dba83634f09a Mon Sep 17 00:00:00 2001 From: hjjeong Date: Wed, 13 May 2026 17:40:25 +0900 Subject: [PATCH 12/12] =?UTF-8?q?=EA=B0=9C=EB=B0=9C=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=C2=B7=EC=83=9D=EC=82=B0=EA=B4=80=EB=A6=AC=20=E2=80=94=203D/2D/?= =?UTF-8?q?PDF=20=EC=B9=B4=EC=9A=B4=ED=8A=B8=20=EC=BB=AC=EB=9F=BC=EC=9D=84?= =?UTF-8?q?=20=ED=8F=B4=EB=8D=94=20=EC=95=84=EC=9D=B4=EC=BD=98=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 보고: "품명/3D 컬럼이 좁아서 겹쳐 보임 + 폴더 모양(파랑/투명)으로 표시해줘" 운영판 wace 견적현황·partMng 폴더 아이콘 패턴 1:1 적용: - 값 > 0 → 파란 폴더 (fill-[#1a73e8]) - 값 = 0 → 흰색 폴더 (투명 효과, fill-white + muted text) 수정: - app/(main)/COMPANY_16/development/part-regist/page.tsx — 3D/2D/PDF (60→70px, center, folder) - app/(main)/COMPANY_16/development/part-search/page.tsx — 3D/2D/PDF (60→70px, center, folder) - app/(main)/COMPANY_16/development/ebom-search/page.tsx — 3D/2D/PDF (60→70px width 통일) - components/development/BomReportTreeDialog.tsx — "Y/공백" → FolderCell - components/production/MbomDetailDialog.tsx — "Y/공백" → FolderCell sales/{order,sale,revenue}.tsx 의 cu01_cnt 는 "주문서첨부" clip 아이콘이라 별 의미라 미수정. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../development/ebom-search/page.tsx | 6 +++--- .../development/part-regist/page.tsx | 6 +++--- .../development/part-search/page.tsx | 6 +++--- .../development/BomReportTreeDialog.tsx | 18 ++++++++++++++---- .../components/production/MbomDetailDialog.tsx | 18 ++++++++++++++---- 5 files changed, 37 insertions(+), 17 deletions(-) diff --git a/frontend/app/(main)/COMPANY_16/development/ebom-search/page.tsx b/frontend/app/(main)/COMPANY_16/development/ebom-search/page.tsx index 33192575..c12a9d54 100644 --- a/frontend/app/(main)/COMPANY_16/development/ebom-search/page.tsx +++ b/frontend/app/(main)/COMPANY_16/development/ebom-search/page.tsx @@ -148,9 +148,9 @@ export default function EbomSearchPage() { { key: "pm_part_name", label: "품명", minWidth: "min-w-[200px]" }, { key: "qty", label: "수량", width: "w-[70px]", align: "right", formatNumber: true }, { key: "p_qty", label: "항목수량", width: "w-[80px]", align: "right", formatNumber: true }, - { key: "cu01_cnt", label: "3D", width: "w-[60px]", align: "center", renderType: "folder" }, - { key: "cu02_cnt", label: "2D", width: "w-[60px]", align: "center", renderType: "folder" }, - { key: "cu03_cnt", label: "PDF", width: "w-[60px]", align: "center", renderType: "folder" }, + { key: "cu01_cnt", label: "3D", width: "w-[70px]", align: "center", renderType: "folder" }, + { key: "cu02_cnt", label: "2D", width: "w-[70px]", align: "center", renderType: "folder" }, + { key: "cu03_cnt", label: "PDF", width: "w-[70px]", align: "center", renderType: "folder" }, { key: "material", label: "재료", width: "w-[100px]" }, { key: "heat_treatment_hardness", label: "열처리경도", width: "w-[110px]" }, { key: "heat_treatment_method", label: "열처리방법", width: "w-[110px]" }, diff --git a/frontend/app/(main)/COMPANY_16/development/part-regist/page.tsx b/frontend/app/(main)/COMPANY_16/development/part-regist/page.tsx index 8e3672a2..f146dcc8 100644 --- a/frontend/app/(main)/COMPANY_16/development/part-regist/page.tsx +++ b/frontend/app/(main)/COMPANY_16/development/part-regist/page.tsx @@ -25,9 +25,9 @@ import { DevPartSelect } from "@/components/development/DevPartSelect"; const GRID_COLUMNS: DataGridColumn[] = [ { key: "part_no", label: "품번", width: "w-[140px]", frozen: true }, { key: "part_name", label: "품명", minWidth: "min-w-[220px]" }, - { key: "cu01_cnt", label: "3D", width: "w-[60px]", align: "right", formatNumber: true }, - { key: "cu02_cnt", label: "2D", width: "w-[60px]", align: "right", formatNumber: true }, - { key: "cu03_cnt", label: "PDF", width: "w-[60px]", align: "right", formatNumber: true }, + { key: "cu01_cnt", label: "3D", width: "w-[70px]", align: "center", renderType: "folder" }, + { key: "cu02_cnt", label: "2D", width: "w-[70px]", align: "center", renderType: "folder" }, + { key: "cu03_cnt", label: "PDF", width: "w-[70px]", align: "center", renderType: "folder" }, { key: "material", label: "재료", width: "w-[100px]" }, { key: "heat_treatment_hardness", label: "열처리경도", width: "w-[110px]" }, { key: "heat_treatment_method", label: "열처리방법", width: "w-[110px]" }, diff --git a/frontend/app/(main)/COMPANY_16/development/part-search/page.tsx b/frontend/app/(main)/COMPANY_16/development/part-search/page.tsx index 2616565b..f01732dd 100644 --- a/frontend/app/(main)/COMPANY_16/development/part-search/page.tsx +++ b/frontend/app/(main)/COMPANY_16/development/part-search/page.tsx @@ -24,9 +24,9 @@ import { DevPartSelect } from "@/components/development/DevPartSelect"; const GRID_COLUMNS: DataGridColumn[] = [ { key: "part_no", label: "품번", width: "w-[140px]", frozen: true }, { key: "part_name", label: "품명", minWidth: "min-w-[220px]" }, - { key: "cu01_cnt", label: "3D", width: "w-[60px]", align: "right", formatNumber: true }, - { key: "cu02_cnt", label: "2D", width: "w-[60px]", align: "right", formatNumber: true }, - { key: "cu03_cnt", label: "PDF", width: "w-[60px]", align: "right", formatNumber: true }, + { key: "cu01_cnt", label: "3D", width: "w-[70px]", align: "center", renderType: "folder" }, + { key: "cu02_cnt", label: "2D", width: "w-[70px]", align: "center", renderType: "folder" }, + { key: "cu03_cnt", label: "PDF", width: "w-[70px]", align: "center", renderType: "folder" }, { key: "material", label: "재료", width: "w-[100px]" }, { key: "heat_treatment_hardness", label: "열처리경도", width: "w-[110px]" }, { key: "heat_treatment_method", label: "열처리방법", width: "w-[110px]" }, diff --git a/frontend/components/development/BomReportTreeDialog.tsx b/frontend/components/development/BomReportTreeDialog.tsx index 12af65e7..eaebcdea 100644 --- a/frontend/components/development/BomReportTreeDialog.tsx +++ b/frontend/components/development/BomReportTreeDialog.tsx @@ -14,7 +14,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; -import { Loader2, FileSpreadsheet } from "lucide-react"; +import { Loader2, FileSpreadsheet, Folder } from "lucide-react"; import { toast } from "sonner"; import { devBomApi, BomReportRow, BomTreeFullRow } from "@/lib/api/devBom"; import { cn } from "@/lib/utils"; @@ -158,9 +158,9 @@ export function BomReportTreeDialog({ open, onOpenChange, bomReport }: Props) { {r.pm_part_name} {r.qty} {r.p_qty} - {Number(r.cu01_cnt ?? 0) > 0 ? "Y" : ""} - {Number(r.cu02_cnt ?? 0) > 0 ? "Y" : ""} - {Number(r.cu03_cnt ?? 0) > 0 ? "Y" : ""} + + + {r.material} {r.heat_treatment_hardness} {r.heat_treatment_method} @@ -184,6 +184,16 @@ export function BomReportTreeDialog({ open, onOpenChange, bomReport }: Props) { ); } +function FolderCell({ n }: { n: any }) { + const has = Number(n ?? 0) > 0; + return ( + + + + ); +} + function MetaRow({ label, value }: { label: string; value: any }) { return (
diff --git a/frontend/components/production/MbomDetailDialog.tsx b/frontend/components/production/MbomDetailDialog.tsx index f7ee38a7..2da312a9 100644 --- a/frontend/components/production/MbomDetailDialog.tsx +++ b/frontend/components/production/MbomDetailDialog.tsx @@ -20,7 +20,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; -import { Loader2 } from "lucide-react"; +import { Loader2, Folder } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { mbomApi, MbomDetail, MbomTreeResponse, MbomBomDataType, MbomTreeRow } from "@/lib/api/mbom"; @@ -184,9 +184,9 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid }: Props) { {r.processing_vendor_name ?? r.processing_vendor ?? ""} {r.processing_deadline ?? ""} {r.grinding_deadline ?? ""} - {Number(r.cu01_cnt ?? 0) > 0 ? "Y" : ""} - {Number(r.cu02_cnt ?? 0) > 0 ? "Y" : ""} - {Number(r.cu03_cnt ?? 0) > 0 ? "Y" : ""} + + + {r.remark ?? ""} ); @@ -204,6 +204,16 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid }: Props) { ); } +function FolderCell({ n }: { n: any }) { + const has = Number(n ?? 0) > 0; + return ( + + + + ); +} + function MetaRow({ label, value, numeric }: { label: string; value: any; numeric?: boolean }) { return (