ECR 기능/스키마 wace_plm 일치 + 공통코드·테이블타입 화면 정리

- ECR 관리: wace 의 ecrList/Form/Detail JSP 와 동일하게 5개 필터(연도/기종/요청/작성자/상태),
  변경전/후 2분할 모달, 작성중(0000100)만 삭제·수정 허용, 컬럼 순서/라벨 wace 일치
- ECR 스키마 wace 풀세트 동기화: ecr_mng 컬럼폭 확장, product_mgmt 16컬럼, part_mng 52컬럼,
  user_info 22컬럼, comm_code 보강(id/code_cd/ext_val), code_name(varchar) 함수,
  seq_ecr_no setval(33) 정렬
- wace_plm public.comm_code 733행 시드: src/seed/wace_comm_code.sql 추출 + 부팅 시 자동 적재
  (writer='system-seed' placeholder 자동 정리, 무중단 재적재 엔드포인트 /comm-code-seed)
- wace_plm 데이터 import 풀스키마: PRODUCT(7→16), PART(6→52), USER_INFO·COMM_CODE 신규
- 공통코드 관리 화면: 제목/설명 축소, 카테고리·코드 카드 → 컴팩트 리스트, 활성 토글 점,
  계층 배지 톤다운, hover 시 액션 노출
- 테이블 타입 관리 — 좌측: 한 줄 리스트 + 알파벳 인덱스 sticky 헤더
- 테이블 타입 관리 — 우측: 타입 카드 그리드 → 그룹 셀렉트(기본/참조/자동/첨부/표시변형),
  표시이름 제거 + 코멘트(description) Textarea 신설(화면관리에서 기본 라벨로 활용),
  시스템 자동 생성 컬럼(id/company_code/writer/created_date/updated_date) 잠금,
  표시옵션/고급설정(필수·읽기·기본값·최대길이) 제거 — 화면관리로 이관

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chpark
2026-05-11 15:58:54 +09:00
parent 575098698c
commit 690b85805c
19 changed files with 3336 additions and 648 deletions
+2
View File
@@ -134,6 +134,8 @@ k8s/*-secret.yaml
*.dump
db/dump/
db/backup/
# 부팅 시 적재되는 시드 SQL 은 코드 일부로 취급 — 예외 처리
!backend-node/src/seed/*.sql
# 백업
*.bak
+8
View File
@@ -529,6 +529,14 @@ async function initializeServices() {
logger.error(`❌ ECR 테이블 점검 실패:`, error);
}
// wace_plm public.comm_code 전체 시드 — 공통코드관리 화면이 카테고리/코드를 그대로 노출
try {
const { ensureWaceCommCodeSeed } = await import("./services/waceCommCodeSeedService");
await ensureWaceCommCodeSeed();
} catch (error) {
logger.error(`❌ wace_plm 공통코드 시드 실패:`, error);
}
// 고객 CS 관리 테이블 점검 (customer_cs_mng + 공통코드 카테고리)
try {
const { ensureCustomerCsTables } = await import("./services/customerCsTableMigration");
@@ -36,9 +36,11 @@ export class WacePlmDataImportController {
static async importMasters(req: AuthenticatedRequest, res: Response) {
if (!requireSuperAdmin(req, res)) return;
try {
const product = await WacePlmDataImportService.importProduct();
const part = await WacePlmDataImportService.importPart();
return res.json({ success: true, data: { product, part } });
const commCode = await WacePlmDataImportService.importCommCode();
const userInfo = await WacePlmDataImportService.importUserInfo();
const product = await WacePlmDataImportService.importProduct();
const part = await WacePlmDataImportService.importPart();
return res.json({ success: true, data: { commCode, userInfo, product, part } });
} catch (e: any) { return res.status(500).json({ success: false, message: e?.message }); }
}
static async importApproval(req: AuthenticatedRequest, res: Response) {
@@ -46,4 +48,26 @@ export class WacePlmDataImportController {
try { return res.json({ success: true, data: await WacePlmDataImportService.importApprovalAll() }); }
catch (e: any) { return res.status(500).json({ success: false, message: e?.message }); }
}
static async importCommCode(req: AuthenticatedRequest, res: Response) {
if (!requireSuperAdmin(req, res)) return;
try { return res.json({ success: true, data: await WacePlmDataImportService.importCommCode() }); }
catch (e: any) { return res.status(500).json({ success: false, message: e?.message }); }
}
static async importUserInfo(req: AuthenticatedRequest, res: Response) {
if (!requireSuperAdmin(req, res)) return;
try { return res.json({ success: true, data: await WacePlmDataImportService.importUserInfo() }); }
catch (e: any) { return res.status(500).json({ success: false, message: e?.message }); }
}
/** wace_plm 의 comm_code 733 행 SQL 시드를 재실행 (부팅 시 자동 호출되지만 재시작 없이 즉시 반영용) */
static async seedCommCodeFromFile(req: AuthenticatedRequest, res: Response) {
if (!requireSuperAdmin(req, res)) return;
try {
const { ensureWaceCommCodeSeed } = await import("../services/waceCommCodeSeedService");
await ensureWaceCommCodeSeed();
return res.json({ success: true, message: "wace_plm comm_code 시드 적재 완료" });
} catch (e: any) {
return res.status(500).json({ success: false, message: e?.message });
}
}
}
@@ -7,10 +7,13 @@ import { WacePlmDataImportController } from "../controllers/wacePlmDataImportCon
const router = Router();
router.post("/all", authenticateToken, WacePlmDataImportController.importAll);
router.post("/ecr", authenticateToken, WacePlmDataImportController.importEcr);
router.post("/cs", authenticateToken, WacePlmDataImportController.importCs);
router.post("/masters", authenticateToken, WacePlmDataImportController.importMasters);
router.post("/approval", authenticateToken, WacePlmDataImportController.importApproval);
router.post("/all", authenticateToken, WacePlmDataImportController.importAll);
router.post("/ecr", authenticateToken, WacePlmDataImportController.importEcr);
router.post("/cs", authenticateToken, WacePlmDataImportController.importCs);
router.post("/masters", authenticateToken, WacePlmDataImportController.importMasters);
router.post("/approval", authenticateToken, WacePlmDataImportController.importApproval);
router.post("/comm-code", authenticateToken, WacePlmDataImportController.importCommCode);
router.post("/user-info", authenticateToken, WacePlmDataImportController.importUserInfo);
router.post("/comm-code-seed", authenticateToken, WacePlmDataImportController.seedCommCodeFromFile);
export default router;
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -43,7 +43,8 @@ const SELECT_BASE = `
T.writer,
(SELECT user_name FROM user_info WHERE user_id = T.writer) AS writer_name,
T.status_cd,
(SELECT code_name FROM comm_code WHERE code_id = T.status_cd) AS status_name,
-- wace_plm 의 code_name(varchar) 함수를 그대로 사용 (ecrTableMigration 에서 함수도 동일 정의)
code_name(T.status_cd) AS status_name,
(SELECT user_name FROM user_info WHERE user_id = T.check_user_id) AS check_name,
T.check_user_id,
T.before_contents,
+249 -45
View File
@@ -1,69 +1,268 @@
/**
* ECR(Engineering Change Request) 관 테이블 idempotent 마이그레이션
* - wace_plm 의 ECR_MNG / PRODUCT_MGMT / PART_MNG 핵심 컬럼 + 시퀀스 + 공통코드 시드
* - 부팅 시 1회 실행
* ECR(Engineering Change Request) 관 테이블 idempotent 마이그레이션
*
* - wace_plm 의 ECR_MNG / PRODUCT_MGMT / PART_MNG / COMM_CODE / USER_INFO 스키마와
* 1:1 매칭되도록 컬럼 명세를 동기화 (CREATE TABLE IF NOT EXISTS + ADD COLUMN IF NOT EXISTS)
* - wace_plm 의 code_name(varchar) 함수도 그대로 재현 (mapper SQL 에서 사용)
* - 시퀀스 seq_ecr_no 도 동일 보장
* - 부팅 시 1회 실행 (멱등)
*
* 주의: 데이터 자체의 import 는 WacePlmDataImportService (POST /api/wace-import/*) 가 담당.
* 본 마이그레이션은 "동일 스키마" 보장만 한다.
*/
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
const STATEMENTS: string[] = [
// ── ECR_MNG ────────────────────────────────────────────────
// ────────────────────────────────────────────────────────────────────────────
// 1) ECR_MNG (wace_plm dbexport 기준 컬럼 명세와 정확히 일치)
//
// wace_plm DDL:
// objid integer NOT NULL, ecr_no varchar(100), product_objid integer,
// upg_no varchar(100), part_objid integer, request_cd varchar(100),
// title varchar(1000), writer varchar(100), status_cd varchar(100),
// before_contents varchar(4000), after_contents varchar(4000),
// reg_date timestamp, check_user_id varchar(100), check_date timestamp
// ────────────────────────────────────────────────────────────────────────────
`CREATE TABLE IF NOT EXISTS ecr_mng (
objid BIGINT PRIMARY KEY,
ecr_no VARCHAR(40),
ecr_no VARCHAR(100),
product_objid BIGINT,
upg_no VARCHAR(100),
part_objid BIGINT,
upg_no VARCHAR(40),
request_cd VARCHAR(200),
title VARCHAR(500),
writer VARCHAR(80),
status_cd VARCHAR(20) DEFAULT '0000100',
before_contents TEXT,
after_contents TEXT,
check_user_id VARCHAR(80),
check_date TIMESTAMP,
reg_date TIMESTAMP DEFAULT NOW(),
company_code VARCHAR(40)
request_cd VARCHAR(100),
title VARCHAR(1000),
writer VARCHAR(100),
status_cd VARCHAR(100) DEFAULT '0000100',
before_contents VARCHAR(4000),
after_contents VARCHAR(4000),
reg_date TIMESTAMP DEFAULT NOW(),
check_user_id VARCHAR(100),
check_date TIMESTAMP
)`,
// 기존 환경(이전 마이그레이션으로 짧은 길이로 만들어진 경우)을 위해 컬럼 폭 확장 (멱등)
`ALTER TABLE ecr_mng ALTER COLUMN ecr_no TYPE VARCHAR(100) USING ecr_no::VARCHAR(100)`,
`ALTER TABLE ecr_mng ALTER COLUMN upg_no TYPE VARCHAR(100) USING upg_no::VARCHAR(100)`,
`ALTER TABLE ecr_mng ALTER COLUMN request_cd TYPE VARCHAR(100) USING request_cd::VARCHAR(100)`,
`ALTER TABLE ecr_mng ALTER COLUMN title TYPE VARCHAR(1000) USING title::VARCHAR(1000)`,
`ALTER TABLE ecr_mng ALTER COLUMN writer TYPE VARCHAR(100) USING writer::VARCHAR(100)`,
`ALTER TABLE ecr_mng ALTER COLUMN status_cd TYPE VARCHAR(100) USING status_cd::VARCHAR(100)`,
`ALTER TABLE ecr_mng ALTER COLUMN before_contents TYPE VARCHAR(4000) USING before_contents::VARCHAR(4000)`,
`ALTER TABLE ecr_mng ALTER COLUMN after_contents TYPE VARCHAR(4000) USING after_contents::VARCHAR(4000)`,
`ALTER TABLE ecr_mng ALTER COLUMN check_user_id TYPE VARCHAR(100) USING check_user_id::VARCHAR(100)`,
// 신규로 만든 환경에 company_code 가 떨어졌을 수 있어 제거 시도 없이 그냥 두되, 누락된 컬럼만 보충
`ALTER TABLE ecr_mng ADD COLUMN IF NOT EXISTS upg_no VARCHAR(100)`,
`ALTER TABLE ecr_mng ADD COLUMN IF NOT EXISTS check_user_id VARCHAR(100)`,
`ALTER TABLE ecr_mng ADD COLUMN IF NOT EXISTS check_date TIMESTAMP`,
`CREATE INDEX IF NOT EXISTS idx_ecr_mng_status ON ecr_mng (status_cd)`,
`CREATE INDEX IF NOT EXISTS idx_ecr_mng_writer ON ecr_mng (writer)`,
`CREATE INDEX IF NOT EXISTS idx_ecr_mng_reg_date ON ecr_mng (reg_date DESC)`,
// 자바 원본은 ECR_NO 시퀀스를 SELECT nextval('seq_ecr_no')::VARCHAR 사용 → 별도 시퀀스 보장
`CREATE SEQUENCE IF NOT EXISTS seq_ecr_no START WITH 1 INCREMENT BY 1`,
// ── PRODUCT_MGMT (참조용 최소 컬럼만) ──────────────────────
// 원본 자바는 더 풍부한 컬럼이 있지만 ECR 화면이 사용하는 건
// OBJID / PRODUCT_CODE / PRODUCT_NAME 정도. 운영 중 점진 보강.
// ECR_NO 시퀀스 — wace_plm 도 동일하게 별도 SEQUENCE 사용
`CREATE SEQUENCE IF NOT EXISTS seq_ecr_no START WITH 1 INCREMENT BY 1`,
// wace_plm 운영본은 33 까지 발번되어 있음. 우리 seq 가 더 작으면 그 값까지 끌어올려서
// 채번 충돌(ON CONFLICT 로 UPDATE 분기 타는 사고)을 방지.
`SELECT setval('seq_ecr_no', GREATEST((SELECT last_value FROM seq_ecr_no), 33))`,
// ────────────────────────────────────────────────────────────────────────────
// 2) PRODUCT_MGMT (wace_plm dbexport 컬럼 풀세트)
// ────────────────────────────────────────────────────────────────────────────
`CREATE TABLE IF NOT EXISTS product_mgmt (
objid BIGINT PRIMARY KEY,
product_code VARCHAR(80),
product_name VARCHAR(200),
product_type VARCHAR(80),
production_flag VARCHAR(2) DEFAULT 'Y',
status VARCHAR(20) DEFAULT 'active',
writer VARCHAR(80),
regdate TIMESTAMP DEFAULT NOW(),
company_code VARCHAR(40)
objid BIGINT PRIMARY KEY,
product_category VARCHAR(100),
product_type VARCHAR(100),
product_grade VARCHAR(100),
product_ton VARCHAR(100),
product_boom VARCHAR(100),
product_vehicle VARCHAR(100),
product_code VARCHAR(100),
production_flag VARCHAR(100),
regdate TIMESTAMP,
writer VARCHAR(100),
contents TEXT,
price VARCHAR,
product_name VARCHAR,
product_name_code VARCHAR,
note VARCHAR
)`,
// 기존 좁은 컬럼이면 폭 확장
`ALTER TABLE product_mgmt ALTER COLUMN product_code TYPE VARCHAR(100) USING product_code::VARCHAR(100)`,
`ALTER TABLE product_mgmt ALTER COLUMN product_type TYPE VARCHAR(100) USING product_type::VARCHAR(100)`,
`ALTER TABLE product_mgmt ALTER COLUMN writer TYPE VARCHAR(100) USING writer::VARCHAR(100)`,
// 누락 컬럼 보충 (이전 마이그레이션은 일부만 만들었음)
`ALTER TABLE product_mgmt ADD COLUMN IF NOT EXISTS product_category VARCHAR(100)`,
`ALTER TABLE product_mgmt ADD COLUMN IF NOT EXISTS product_grade VARCHAR(100)`,
`ALTER TABLE product_mgmt ADD COLUMN IF NOT EXISTS product_ton VARCHAR(100)`,
`ALTER TABLE product_mgmt ADD COLUMN IF NOT EXISTS product_boom VARCHAR(100)`,
`ALTER TABLE product_mgmt ADD COLUMN IF NOT EXISTS product_vehicle VARCHAR(100)`,
`ALTER TABLE product_mgmt ADD COLUMN IF NOT EXISTS contents TEXT`,
`ALTER TABLE product_mgmt ADD COLUMN IF NOT EXISTS price VARCHAR`,
`ALTER TABLE product_mgmt ADD COLUMN IF NOT EXISTS product_name_code VARCHAR`,
`ALTER TABLE product_mgmt ADD COLUMN IF NOT EXISTS note VARCHAR`,
`CREATE INDEX IF NOT EXISTS idx_product_mgmt_code ON product_mgmt (product_code)`,
// ── PART_MNG (참조용 최소 컬럼만) ──────────────────────────
// ────────────────────────────────────────────────────────────────────────────
// 3) PART_MNG (wace_plm dbexport 컬럼 풀세트 — 50+ 컬럼)
// ────────────────────────────────────────────────────────────────────────────
`CREATE TABLE IF NOT EXISTS part_mng (
objid BIGINT PRIMARY KEY,
part_no VARCHAR(80),
part_name VARCHAR(200),
part_type VARCHAR(80),
status VARCHAR(20) DEFAULT 'active',
writer VARCHAR(80),
regdate TIMESTAMP DEFAULT NOW(),
company_code VARCHAR(40)
objid BIGINT PRIMARY KEY,
product_mgmt_objid VARCHAR(100),
upg_no VARCHAR(100),
part_no VARCHAR(100),
part_name VARCHAR(100),
unit VARCHAR(50),
qty VARCHAR(50),
spec VARCHAR(100),
material VARCHAR(100),
weight VARCHAR(50),
part_type VARCHAR(100),
remark VARCHAR(1000),
es_spec VARCHAR(100),
ms_spec VARCHAR(100),
change_option VARCHAR(50),
design_apply_point VARCHAR(50),
management_flag VARCHAR(50),
revision VARCHAR(50),
status VARCHAR(30),
reg_date TIMESTAMP,
edit_date TIMESTAMP,
writer VARCHAR(30),
is_last VARCHAR(5),
eo_no VARCHAR,
eo_temp VARCHAR,
excel_upload_seq INTEGER,
sourcing_code VARCHAR,
sub_material VARCHAR(100),
parent_part_no VARCHAR,
design_date VARCHAR,
eo_date VARCHAR,
deploy_date TIMESTAMP,
thickness VARCHAR,
width VARCHAR,
height VARCHAR,
out_diameter VARCHAR,
in_diameter VARCHAR,
length VARCHAR,
supply_code VARCHAR,
change_type VARCHAR,
contract_objid VARCHAR,
maker VARCHAR,
post_processing VARCHAR,
material_code VARCHAR,
code1 VARCHAR,
code2 VARCHAR,
code3 VARCHAR,
code4 VARCHAR,
code5 VARCHAR,
major_category VARCHAR,
sub_category VARCHAR,
is_new VARCHAR(5),
is_longd VARCHAR(5)
)`,
// 기존 part_mng 가 작은 스키마였다면 누락 컬럼 보충 (멱등 ADD COLUMN IF NOT EXISTS)
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS product_mgmt_objid VARCHAR(100)`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS upg_no VARCHAR(100)`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS unit VARCHAR(50)`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS qty VARCHAR(50)`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS spec VARCHAR(100)`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS material VARCHAR(100)`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS weight VARCHAR(50)`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS remark VARCHAR(1000)`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS es_spec VARCHAR(100)`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS ms_spec VARCHAR(100)`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS change_option VARCHAR(50)`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS design_apply_point VARCHAR(50)`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS management_flag VARCHAR(50)`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS revision VARCHAR(50)`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS reg_date TIMESTAMP`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS edit_date TIMESTAMP`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS is_last VARCHAR(5)`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS eo_no VARCHAR`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS eo_temp VARCHAR`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS excel_upload_seq INTEGER`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS sourcing_code VARCHAR`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS sub_material VARCHAR(100)`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS parent_part_no VARCHAR`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS design_date VARCHAR`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS eo_date VARCHAR`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS deploy_date TIMESTAMP`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS thickness VARCHAR`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS width VARCHAR`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS height VARCHAR`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS out_diameter VARCHAR`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS in_diameter VARCHAR`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS length VARCHAR`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS supply_code VARCHAR`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS change_type VARCHAR`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS contract_objid VARCHAR`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS maker VARCHAR`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS post_processing VARCHAR`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS material_code VARCHAR`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS code1 VARCHAR`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS code2 VARCHAR`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS code3 VARCHAR`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS code4 VARCHAR`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS code5 VARCHAR`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS major_category VARCHAR`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS sub_category VARCHAR`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS is_new VARCHAR(5)`,
`ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS is_longd VARCHAR(5)`,
`CREATE INDEX IF NOT EXISTS idx_part_mng_no ON part_mng (part_no)`,
// ── 설변요청 코드 카테고리(0000090) 자식 시드 ──
// wace_plm 의 ECR 화면은 0000090 하위 코드를 다중 체크박스로 사용.
// 기존 DB 에 카테고리/자식이 없으면 기본값을 NOT EXISTS 로 시드.
// comm_code 는 unique 제약이 없어 ON CONFLICT 대신 NOT EXISTS 로 idempotent 처리.
// ────────────────────────────────────────────────────────────────────────────
// 4) COMM_CODE (wace_plm 명세 보충 — 우리 시스템은 이미 일부 컬럼만 가졌을 수 있음)
// ────────────────────────────────────────────────────────────────────────────
`ALTER TABLE comm_code ADD COLUMN IF NOT EXISTS id VARCHAR(100)`,
`ALTER TABLE comm_code ADD COLUMN IF NOT EXISTS code_cd VARCHAR(100)`,
`ALTER TABLE comm_code ADD COLUMN IF NOT EXISTS ext_val VARCHAR(10)`,
// ────────────────────────────────────────────────────────────────────────────
// 5) USER_INFO (wace_plm 의 USER_INFO 와 동일하게 만들지만, 우리 user 테이블과는 별도)
// - mapper SQL 이 USER_INFO 를 직접 참조 (writer_name, check_name 서브쿼리)
// - import 시 wace_plm.USER_INFO → 우리 user_info 에 그대로 적재
// ────────────────────────────────────────────────────────────────────────────
`CREATE TABLE IF NOT EXISTS user_info (
sabun VARCHAR(1024),
user_id VARCHAR(1024) PRIMARY KEY,
user_password VARCHAR(1024),
user_name VARCHAR(1024),
user_name_eng VARCHAR(1024),
user_name_cn VARCHAR(1024),
dept_code VARCHAR(1024),
dept_name VARCHAR(1024),
position_code VARCHAR(1024),
position_name VARCHAR(1024),
email VARCHAR(1024),
tel VARCHAR(1024),
cell_phone VARCHAR(1024),
user_type VARCHAR(1024),
user_type_name VARCHAR(1024),
regdate TIMESTAMP,
data_type VARCHAR(64),
status VARCHAR(32),
end_date TIMESTAMP,
fax_no VARCHAR,
partner_objid VARCHAR,
rank VARCHAR
)`,
// ────────────────────────────────────────────────────────────────────────────
// 6) public.code_name(varchar) 함수
// wace_plm mapper 들이 ECR 등에서 `code_name(STATUS_CD)` 호출 — 동일 함수 정의
// ────────────────────────────────────────────────────────────────────────────
`CREATE OR REPLACE FUNCTION public.code_name(v_code_id varchar) RETURNS varchar
LANGUAGE plpgsql AS $$
DECLARE v_code_name varchar;
BEGIN
SELECT code_name INTO v_code_name FROM comm_code WHERE code_id = v_code_id LIMIT 1;
RETURN v_code_name;
END;
$$`,
// ────────────────────────────────────────────────────────────────────────────
// 7) 공통코드 시드 — 0000090(설변요청) / 0000099(ECR상태)
// ────────────────────────────────────────────────────────────────────────────
`DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM comm_code WHERE code_id='0000090') THEN
@@ -81,8 +280,13 @@ const STATEMENTS: string[] = [
END IF;
END $$`,
// ── ECR 메뉴 활성화 (이미 menu_info 에 'ECR관리' objid=100045 있음) ──
// 기존 상태 그대로 두되 url 정규화만 (만에 하나 다른 url 로 박혀있을 때 대비)
// 0000099 / 자식(0000100=작성중, 0000101=결재중, 0000107=반려, 0000102=적용완료) 는 wace_plm
// 원본에 그대로 존재하므로 별도 시드하지 않는다 (wace_comm_code.sql 시드 로더가 처리).
// wace 가 미보유한 0000090(설변요청) 만 위에서 시드.
// ────────────────────────────────────────────────────────────────────────────
// 8) ECR 메뉴 활성화 (기존 menu_info 100045 정규화)
// ────────────────────────────────────────────────────────────────────────────
`UPDATE menu_info SET menu_url = '/COMPANY_16/ecr/ecr', status = 'active'
WHERE objid = '100045' AND (menu_url IS NULL OR menu_url = '')`,
];
@@ -0,0 +1,69 @@
/**
* wace_plm public.comm_code 전체 시드 로더
*
* - src/seed/wace_comm_code.sql 안에 wace_plm 의 comm_code 733 행이 NOT EXISTS 패턴으로 들어있음.
* - 부팅 시 한 번 실행 → 비어있을 때만 적재되며, 이미 있으면 자연스럽게 skip.
* - 시드 후 「system-seed」 로 임시 박았던 placeholder 카테고리(0000099 등) 중 wace 가 동일 code_id 로
* 제공한 항목은 의미적으로 중복이므로 제거(우리 placeholder).
*/
import { readFile } from "fs/promises";
import path from "path";
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
// ts-node(개발): __dirname = src/services → ../seed
// tsc 빌드(운영): __dirname = dist/services → ../../src/seed (tsc 가 .sql 을 복사하지 않으므로 src 를 직접 가리킴)
const CANDIDATE_PATHS = [
path.resolve(__dirname, "../seed/wace_comm_code.sql"),
path.resolve(__dirname, "../../src/seed/wace_comm_code.sql"),
];
async function readSeed(): Promise<string | null> {
for (const p of CANDIDATE_PATHS) {
try { return await readFile(p, "utf8"); } catch { /* try next */ }
}
return null;
}
export async function ensureWaceCommCodeSeed(): Promise<void> {
const pool = getPool();
try {
// 시드 파일이 없으면 무시 (테스트/슬림 환경 대비)
const sql = await readSeed();
if (!sql) {
logger.warn(`[waceCommCodeSeed] seed 파일 없음: ${CANDIDATE_PATHS.join(" | ")} — 건너뜀`);
return;
}
// (1) 본 시드 적재 — INSERT … WHERE NOT EXISTS 로 idempotent
await pool.query(sql);
// (2) wace 가 동일 code_id 로 권위 데이터 제공 → 우리 system-seed placeholder 제거
// (writer='system-seed' 면서 wace 가 같은 code_id 를 가지고 있는 행만)
const { rowCount } = await pool.query(`
DELETE FROM comm_code AS me
WHERE me.writer = 'system-seed'
AND EXISTS (
SELECT 1 FROM comm_code w
WHERE w.code_id = me.code_id
AND w.writer <> 'system-seed'
AND w.objid <> me.objid
)
`);
// (3) 적재된 카테고리/자식 수 로깅
const cnt = await pool.query<{ total: string; cats: string; children: string }>(`
SELECT COUNT(*)::text AS total,
COUNT(*) FILTER (WHERE parent_code_id IS NULL OR parent_code_id='')::text AS cats,
COUNT(*) FILTER (WHERE parent_code_id IS NOT NULL AND parent_code_id<>'')::text AS children
FROM comm_code
`);
const r = cnt.rows[0];
logger.info(
`🌱 wace comm_code 시드 완료: 총 ${r?.total}건 (카테고리 ${r?.cats} / 자식 ${r?.children}), ` +
`placeholder 정리 ${rowCount || 0}`,
);
} catch (e: any) {
logger.warn(`[waceCommCodeSeed] 적재 실패(계속 진행): ${e?.message?.slice(0, 300)}`);
}
}
@@ -125,21 +125,31 @@ export class WacePlmDataImportService {
}
}
/** PRODUCT_MGMT → product_mgmt (ECR 의 product_objid 참조 보강용) */
/** PRODUCT_MGMT → product_mgmt (wace_plm 풀스키마 16 컬럼) */
static async importProduct(cfg?: Partial<SourceConfig>) {
const src = await openSource(cfg);
const tgt = getPool();
let inserted = 0, skipped = 0;
try {
const rows = await src.query(`
SELECT objid::bigint, product_code, product_name, product_type, production_flag, writer, regdate
SELECT objid::bigint, product_category, product_type, product_grade, product_ton, product_boom,
product_vehicle, product_code, production_flag, regdate, writer, contents, price,
product_name, product_name_code, note
FROM product_mgmt
`);
for (const r of rows.rows) {
const ins = await tgt.query(
`INSERT INTO product_mgmt (objid, product_code, product_name, product_type, production_flag, writer, regdate)
VALUES ($1,$2,$3,$4,$5,$6,$7) ON CONFLICT (objid) DO NOTHING`,
[r.objid, r.product_code, r.product_name, r.product_type, r.production_flag, r.writer, r.regdate],
`INSERT INTO product_mgmt
(objid, product_category, product_type, product_grade, product_ton, product_boom,
product_vehicle, product_code, production_flag, regdate, writer, contents, price,
product_name, product_name_code, note)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
ON CONFLICT (objid) DO NOTHING`,
[
r.objid, r.product_category, r.product_type, r.product_grade, r.product_ton, r.product_boom,
r.product_vehicle, r.product_code, r.production_flag, r.regdate, r.writer, r.contents, r.price,
r.product_name, r.product_name_code, r.note,
],
);
if (ins.rowCount === 1) inserted++; else skipped++;
}
@@ -150,20 +160,46 @@ export class WacePlmDataImportService {
}
}
/** PART_MNG → part_mng */
/** PART_MNG → part_mng (wace_plm 풀스키마 52 컬럼) */
static async importPart(cfg?: Partial<SourceConfig>) {
const src = await openSource(cfg);
const tgt = getPool();
let inserted = 0, skipped = 0;
try {
const rows = await src.query(`
SELECT objid::bigint, part_no, part_name, part_type, writer, regdate FROM part_mng
SELECT objid::bigint AS objid, product_mgmt_objid, upg_no, part_no, part_name, unit, qty, spec,
material, weight, part_type, remark, es_spec, ms_spec, change_option, design_apply_point,
management_flag, revision, status, reg_date, edit_date, writer, is_last, eo_no, eo_temp,
excel_upload_seq, sourcing_code, sub_material, parent_part_no, design_date, eo_date,
deploy_date, thickness, width, height, out_diameter, in_diameter, length, supply_code,
change_type, contract_objid, maker, post_processing, material_code, code1, code2, code3,
code4, code5, major_category, sub_category, is_new, is_longd
FROM part_mng
`);
for (const r of rows.rows) {
const ins = await tgt.query(
`INSERT INTO part_mng (objid, part_no, part_name, part_type, writer, regdate)
VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT (objid) DO NOTHING`,
[r.objid, r.part_no, r.part_name, r.part_type, r.writer, r.regdate],
`INSERT INTO part_mng
(objid, product_mgmt_objid, upg_no, part_no, part_name, unit, qty, spec, material, weight,
part_type, remark, es_spec, ms_spec, change_option, design_apply_point, management_flag,
revision, status, reg_date, edit_date, writer, is_last, eo_no, eo_temp, excel_upload_seq,
sourcing_code, sub_material, parent_part_no, design_date, eo_date, deploy_date, thickness,
width, height, out_diameter, in_diameter, length, supply_code, change_type, contract_objid,
maker, post_processing, material_code, code1, code2, code3, code4, code5, major_category,
sub_category, is_new, is_longd)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,
$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37,$38,$39,$40,
$41,$42,$43,$44,$45,$46,$47,$48,$49,$50,$51,$52,$53)
ON CONFLICT (objid) DO NOTHING`,
[
r.objid, r.product_mgmt_objid, r.upg_no, r.part_no, r.part_name, r.unit, r.qty, r.spec,
r.material, r.weight, r.part_type, r.remark, r.es_spec, r.ms_spec, r.change_option,
r.design_apply_point, r.management_flag, r.revision, r.status, r.reg_date, r.edit_date,
r.writer, r.is_last, r.eo_no, r.eo_temp, r.excel_upload_seq, r.sourcing_code, r.sub_material,
r.parent_part_no, r.design_date, r.eo_date, r.deploy_date, r.thickness, r.width, r.height,
r.out_diameter, r.in_diameter, r.length, r.supply_code, r.change_type, r.contract_objid,
r.maker, r.post_processing, r.material_code, r.code1, r.code2, r.code3, r.code4, r.code5,
r.major_category, r.sub_category, r.is_new, r.is_longd,
],
);
if (ins.rowCount === 1) inserted++; else skipped++;
}
@@ -174,6 +210,68 @@ export class WacePlmDataImportService {
}
}
/** USER_INFO → user_info (wace_plm 의 ECR mapper 가 직접 참조) */
static async importUserInfo(cfg?: Partial<SourceConfig>) {
const src = await openSource(cfg);
const tgt = getPool();
let inserted = 0, skipped = 0;
try {
const rows = await src.query(`
SELECT sabun, user_id, user_password, user_name, user_name_eng, user_name_cn, dept_code,
dept_name, position_code, position_name, email, tel, cell_phone, user_type, user_type_name,
regdate, data_type, status, end_date, fax_no, partner_objid, rank
FROM user_info
`);
for (const r of rows.rows) {
const ins = await tgt.query(
`INSERT INTO user_info
(sabun, user_id, user_password, user_name, user_name_eng, user_name_cn, dept_code,
dept_name, position_code, position_name, email, tel, cell_phone, user_type, user_type_name,
regdate, data_type, status, end_date, fax_no, partner_objid, rank)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22)
ON CONFLICT (user_id) DO NOTHING`,
[
r.sabun, r.user_id, r.user_password, r.user_name, r.user_name_eng, r.user_name_cn, r.dept_code,
r.dept_name, r.position_code, r.position_name, r.email, r.tel, r.cell_phone, r.user_type,
r.user_type_name, r.regdate, r.data_type, r.status, r.end_date, r.fax_no, r.partner_objid, r.rank,
],
);
if (ins.rowCount === 1) inserted++; else skipped++;
}
logger.info(`📦 USER_INFO import 완료: 신규 ${inserted}건 / 기존 ${skipped}`);
return { inserted, skipped, total: rows.rows.length };
} finally {
await src.end();
}
}
/** COMM_CODE → comm_code (wace_plm 의 0000090/0000099 등 ECR 가 의존하는 공통코드) */
static async importCommCode(cfg?: Partial<SourceConfig>) {
const src = await openSource(cfg);
const tgt = getPool();
let inserted = 0, skipped = 0;
try {
const rows = await src.query(`
SELECT objid::bigint, code_id, parent_code_id, code_name, id, code_cd, ext_val, writer, regdate, status
FROM comm_code
`);
for (const r of rows.rows) {
const ins = await tgt.query(
`INSERT INTO comm_code
(objid, code_id, parent_code_id, code_name, id, code_cd, ext_val, writer, regdate, status)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
ON CONFLICT (objid) DO NOTHING`,
[r.objid, r.code_id, r.parent_code_id, r.code_name, r.id, r.code_cd, r.ext_val, r.writer, r.regdate, r.status],
);
if (ins.rowCount === 1) inserted++; else skipped++;
}
logger.info(`📦 COMM_CODE import 완료: 신규 ${inserted}건 / 기존 ${skipped}`);
return { inserted, skipped, total: rows.rows.length };
} finally {
await src.end();
}
}
/** APPROVAL/ROUTE/INBOXTASK/APPROVAL_TARGET 일괄 import */
static async importApprovalAll(cfg?: Partial<SourceConfig>) {
const src = await openSource(cfg);
@@ -243,14 +341,15 @@ export class WacePlmDataImportService {
}
}
/** 모든 import 한 번에 */
/** 모든 import 한 번에 — 마스터(공통코드/사용자/제품/부품) → 트랜잭션(ECR/CS/결재) 순 */
static async importAll(cfg?: Partial<SourceConfig>) {
const result: Record<string, any> = {};
// 마스터 먼저
try { result.product = await this.importProduct(cfg); } catch (e: any) { result.product = { error: e?.message }; }
try { result.part = await this.importPart(cfg); } catch (e: any) { result.part = { error: e?.message }; }
try { result.ecr = await this.importEcr(cfg); } catch (e: any) { result.ecr = { error: e?.message }; }
try { result.cs = await this.importCustomerMng(cfg); } catch (e: any) { result.cs = { error: e?.message }; }
try { result.commCode = await this.importCommCode(cfg); } catch (e: any) { result.commCode = { error: e?.message }; }
try { result.userInfo = await this.importUserInfo(cfg); } catch (e: any) { result.userInfo = { error: e?.message }; }
try { result.product = await this.importProduct(cfg); } catch (e: any) { result.product = { error: e?.message }; }
try { result.part = await this.importPart(cfg); } catch (e: any) { result.part = { error: e?.message }; }
try { result.ecr = await this.importEcr(cfg); } catch (e: any) { result.ecr = { error: e?.message }; }
try { result.cs = await this.importCustomerMng(cfg); } catch (e: any) { result.cs = { error: e?.message }; }
try { result.approval = await this.importApprovalAll(cfg); } catch (e: any) { result.approval = { error: e?.message }; }
return result;
}
+204 -118
View File
@@ -1,10 +1,11 @@
"use client";
/**
* ECR(Engineering Change Request) wace_plm ecrList.jsp
* - (///) + /
* - (ECR번호////////)
* - · / +
* ECR(Engineering Change Request) wace_plm ecrList.jsp / ecrMngFormPopUp.jsp / ecrMngDetailPopUp.jsp
* - (/()///) wace_plm 5
* - (ECR_NO, (), , , , , , , , , )
* - · (/ textarea 2) / /
* - status_cd='0000100'()
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import { Plus, RefreshCw, Search, Trash2, Pencil, Eye, CheckCircle2 } from "lucide-react";
@@ -27,29 +28,31 @@ import { cn } from "@/lib/utils";
type Option = { value: string; label: string };
// 상태 컬러 매핑 (status code 별)
// 상태 컬러 매핑 — wace_plm 의 comm_code(0000099 자식) 와 동일
const STATUS_COLOR: Record<string, string> = {
"0000100": "bg-blue-100 text-blue-700", // 작성중/진행중
"0000101": "bg-amber-100 text-amber-700", // 결재중/검토중
"0000102": "bg-emerald-100 text-emerald-700", // 적용완료/조치완료
"0000100": "bg-blue-100 text-blue-700", // 작성중
"0000101": "bg-amber-100 text-amber-700", // 결재중
"0000102": "bg-emerald-100 text-emerald-700", // 적용완료
"0000107": "bg-rose-100 text-rose-700", // 반려
"0000103": "bg-rose-100 text-rose-700",
};
const STATUS_DRAFT = "0000100"; // 작성중
export default function EcrListPage() {
// 목록 상태
const [list, setList] = useState<EcrItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [pageSize] = useState(20);
const [loading, setLoading] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// 필터
const [year, setYear] = useState<string>(String(new Date().getFullYear()));
const [statusCode, setStatusCode] = useState<string>("");
// 필터 — wace_plm 기본값 = 전부 미선택(전체)
const [year, setYear] = useState<string>("");
const [productCode, setProductCode] = useState<string>("");
const [requestCode, setRequestCode] = useState<string>("");
const [writer, setWriter] = useState<string>("");
const [statusCode, setStatusCode] = useState<string>("");
// 옵션
const [statusOptions, setStatusOptions] = useState<Option[]>([]);
@@ -91,6 +94,7 @@ export default function EcrListPage() {
try {
const res = await ecrMngApi.list({
year: year || undefined,
productCode: productCode || undefined,
statusCode: statusCode || undefined,
requestCode: requestCode || undefined,
writer: writer || undefined,
@@ -105,7 +109,7 @@ export default function EcrListPage() {
} finally {
setLoading(false);
}
}, [year, statusCode, requestCode, writer, page, pageSize]);
}, [year, productCode, statusCode, requestCode, writer, page, pageSize]);
useEffect(() => {
loadList();
@@ -113,11 +117,18 @@ export default function EcrListPage() {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
// wace_plm: sysYear-4 ~ sysYear (5 개년)
const yearOptions = useMemo(() => {
const cur = new Date().getFullYear();
return Array.from({ length: 6 }, (_, i) => String(cur - i));
return Array.from({ length: 5 }, (_, i) => String(cur - i));
}, []);
// 삭제 가능한(작성중) 항목 ID 셋
const deletableIds = useMemo(
() => new Set(list.filter((r) => r.status_cd === STATUS_DRAFT).map((r) => r.objid)),
[list],
);
const handleDelete = async () => {
if (selectedIds.size === 0) return toast.warning("삭제할 항목을 선택하세요.");
if (!confirm(`선택된 ${selectedIds.size}건을 삭제하시겠습니까?`)) return;
@@ -164,12 +175,12 @@ export default function EcrListPage() {
</div>
</div>
{/* 필터바 */}
{/* 필터바 — wace_plm 과 동일 5 필터 */}
<div className="flex flex-shrink-0 flex-wrap items-end gap-2 rounded-md border bg-muted/20 p-2">
<div className="space-y-1">
<Label className="text-[11px]"></Label>
<Select value={year || "all"} onValueChange={(v) => setYear(v === "all" ? "" : v)}>
<SelectTrigger className="h-7 w-[100px] text-xs"><SelectValue /></SelectTrigger>
<SelectTrigger className="h-7 w-[100px] text-xs"><SelectValue placeholder="전체" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{yearOptions.map((y) => (<SelectItem key={y} value={y}>{y}</SelectItem>))}
@@ -177,17 +188,17 @@ export default function EcrListPage() {
</Select>
</div>
<div className="space-y-1">
<Label className="text-[11px]"></Label>
<Select value={statusCode || "all"} onValueChange={(v) => setStatusCode(v === "all" ? "" : v)}>
<SelectTrigger className="h-7 w-[140px] text-xs"><SelectValue placeholder="전체" /></SelectTrigger>
<Label className="text-[11px]">()</Label>
<Select value={productCode || "all"} onValueChange={(v) => setProductCode(v === "all" ? "" : v)}>
<SelectTrigger className="h-7 w-[160px] text-xs"><SelectValue placeholder="전체" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{statusOptions.map((o) => (<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>))}
{productOptions.map((o) => (<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[11px]"></Label>
<Label className="text-[11px]"></Label>
<Select value={requestCode || "all"} onValueChange={(v) => setRequestCode(v === "all" ? "" : v)}>
<SelectTrigger className="h-7 w-[160px] text-xs"><SelectValue placeholder="전체" /></SelectTrigger>
<SelectContent>
@@ -206,13 +217,23 @@ export default function EcrListPage() {
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[11px]"></Label>
<Select value={statusCode || "all"} onValueChange={(v) => setStatusCode(v === "all" ? "" : v)}>
<SelectTrigger className="h-7 w-[140px] text-xs"><SelectValue placeholder="전체" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{statusOptions.map((o) => (<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>))}
</SelectContent>
</Select>
</div>
<Button size="sm" variant="outline" className="h-7 gap-1 px-2 text-xs" onClick={() => { setPage(1); loadList(); }}>
<Search className="h-3 w-3" />
</Button>
<span className="ml-auto text-[11px] text-muted-foreground"> {total}</span>
</div>
{/* 그리드 */}
{/* 그리드 — wace_plm 컬럼 순서 동일 */}
<div className="flex flex-1 min-h-0 flex-col rounded-md border bg-card">
<div className="flex-1 min-h-0 overflow-auto">
<table className="w-full text-xs">
@@ -221,82 +242,96 @@ export default function EcrListPage() {
<th className="w-8 px-2 py-2">
<input
type="checkbox"
checked={list.length > 0 && selectedIds.size === list.length}
checked={deletableIds.size > 0 && selectedIds.size === deletableIds.size}
onChange={(e) =>
setSelectedIds(e.target.checked ? new Set(list.map((r) => r.objid)) : new Set())
setSelectedIds(e.target.checked ? new Set(deletableIds) : new Set())
}
/>
</th>
<th className="px-2 py-2">ECR </th>
<th className="px-2 py-2"></th>
<th className="px-2 py-2"></th>
<th className="px-2 py-2"></th>
<th className="px-2 py-2">ECR_NO</th>
<th className="px-2 py-2">()</th>
<th className="px-2 py-2"></th>
<th className="px-2 py-2"></th>
<th className="px-2 py-2"></th>
<th className="px-2 py-2"></th>
<th className="px-2 py-2"></th>
<th className="px-2 py-2"></th>
<th className="px-2 py-2"></th>
<th className="px-2 py-2"></th>
<th className="px-2 py-2"></th>
<th className="px-2 py-2"></th>
<th className="px-2 py-2"></th>
<th className="px-2 py-2"></th>
<th className="w-20 px-2 py-2 text-center"></th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={12} className="py-12 text-center text-muted-foreground"> ...</td></tr>
<tr><td colSpan={13} className="py-12 text-center text-muted-foreground"> ...</td></tr>
) : list.length === 0 ? (
<tr><td colSpan={12} className="py-12 text-center text-muted-foreground"> .</td></tr>
<tr><td colSpan={13} className="py-12 text-center text-muted-foreground"> .</td></tr>
) : (
list.map((row) => (
<tr
key={row.objid}
className="cursor-pointer border-t hover:bg-muted/40"
onClick={() => { setSelected(row); setDetailOpen(true); }}
>
<td className="px-2 py-1.5" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedIds.has(row.objid)}
onChange={(e) => {
const next = new Set(selectedIds);
if (e.target.checked) next.add(row.objid); else next.delete(row.objid);
setSelectedIds(next);
}}
/>
</td>
<td className="px-2 py-1.5 font-mono">{row.ecr_no}</td>
<td className="px-2 py-1.5">{row.product_name || "-"}</td>
<td className="px-2 py-1.5">{row.part_no ? `${row.part_no} (${row.part_name})` : "-"}</td>
<td className="px-2 py-1.5 text-[11px]">{row.request_name || "-"}</td>
<td className="px-2 py-1.5 max-w-[280px] truncate">{row.title}</td>
<td className="px-2 py-1.5">{row.writer_name || row.writer || "-"}</td>
<td className="px-2 py-1.5">
<Badge className={cn("font-medium", STATUS_COLOR[row.status_cd || ""] || "bg-muted text-muted-foreground")}>
{row.status_name || row.status_cd}
</Badge>
</td>
<td className="px-2 py-1.5">{row.reg_date}</td>
<td className="px-2 py-1.5">{row.check_name || "-"}</td>
<td className="px-2 py-1.5">{row.check_date || "-"}</td>
<td className="px-2 py-1.5">
<div className="flex items-center justify-center gap-0.5" onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="icon" className="h-6 w-6" title="상세"
onClick={() => { setSelected(row); setDetailOpen(true); }}>
<Eye className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-6 w-6" title="수정"
onClick={() => { setSelected(row); setFormOpen(true); }}>
<Pencil className="h-3.5 w-3.5" />
</Button>
{row.status_cd !== "0000102" && (
<Button variant="ghost" size="icon" className="h-6 w-6 text-emerald-600" title="조치완료"
onClick={() => { setSelected(row); setCompleteOpen(true); }}>
<CheckCircle2 className="h-3.5 w-3.5" />
</Button>
list.map((row) => {
const isDraft = row.status_cd === STATUS_DRAFT;
return (
<tr
key={row.objid}
className="cursor-pointer border-t hover:bg-muted/40"
onClick={() => {
setSelected(row);
// wace_plm: 작성중이면 등록/수정 폼, 그 외에는 상세
if (isDraft) setFormOpen(true);
else setDetailOpen(true);
}}
>
<td className="px-2 py-1.5" onClick={(e) => e.stopPropagation()}>
{isDraft && (
<input
type="checkbox"
checked={selectedIds.has(row.objid)}
onChange={(e) => {
const next = new Set(selectedIds);
if (e.target.checked) next.add(row.objid); else next.delete(row.objid);
setSelectedIds(next);
}}
/>
)}
</div>
</td>
</tr>
))
</td>
<td className="px-2 py-1.5 font-mono">{row.ecr_no}</td>
<td className="px-2 py-1.5">{row.product_name || "-"}</td>
<td className="px-2 py-1.5">{row.part_no || "-"}</td>
<td className="px-2 py-1.5">{row.part_name || "-"}</td>
<td className="px-2 py-1.5 text-[11px]">{row.request_name || "-"}</td>
<td className="px-2 py-1.5 max-w-[280px] truncate" title={row.title}>{row.title}</td>
<td className="px-2 py-1.5">{row.writer_name || row.writer || "-"}</td>
<td className="px-2 py-1.5">{row.reg_date}</td>
<td className="px-2 py-1.5">{row.check_name || "-"}</td>
<td className="px-2 py-1.5">{row.check_date || "-"}</td>
<td className="px-2 py-1.5">
<Badge className={cn("font-medium", STATUS_COLOR[row.status_cd || ""] || "bg-muted text-muted-foreground")}>
{row.status_name || row.status_cd}
</Badge>
</td>
<td className="px-2 py-1.5">
<div className="flex items-center justify-center gap-0.5" onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="icon" className="h-6 w-6" title="상세"
onClick={() => { setSelected(row); setDetailOpen(true); }}>
<Eye className="h-3.5 w-3.5" />
</Button>
{isDraft && (
<Button variant="ghost" size="icon" className="h-6 w-6" title="수정"
onClick={() => { setSelected(row); setFormOpen(true); }}>
<Pencil className="h-3.5 w-3.5" />
</Button>
)}
{row.status_cd !== "0000102" && (
<Button variant="ghost" size="icon" className="h-6 w-6 text-emerald-600" title="조치완료"
onClick={() => { setSelected(row); setCompleteOpen(true); }}>
<CheckCircle2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
@@ -360,6 +395,7 @@ function EcrFormModal({
const [partObjid, setPartObjid] = useState("");
const [requestCodes, setRequestCodes] = useState<Set<string>>(new Set());
const [beforeContents, setBeforeContents] = useState("");
const [afterContents, setAfterContents] = useState("");
const [saving, setSaving] = useState(false);
useEffect(() => {
@@ -369,10 +405,13 @@ function EcrFormModal({
setPartObjid(editing?.part_objid || "");
setRequestCodes(new Set((editing?.request_cd || "").split(",").map((s) => s.trim()).filter(Boolean)));
setBeforeContents(editing?.before_contents || "");
setAfterContents(editing?.after_contents || "");
}
}, [open, editing]);
const handleSave = async () => {
if (!productObjid) return toast.warning("기종(모델)을 선택하세요.");
if (!partObjid) return toast.warning("품번을 선택하세요.");
if (!title.trim()) return toast.warning("제목을 입력하세요.");
setSaving(true);
try {
@@ -383,6 +422,7 @@ function EcrFormModal({
part_objid: partObjid || null,
request_codeArr: Array.from(requestCodes).join(","),
before_contents: beforeContents,
after_contents: afterContents,
});
toast.success("저장되었습니다.");
onSaved();
@@ -393,16 +433,20 @@ function EcrFormModal({
}
};
// 선택된 부품의 품명 표시 (wace_plm 의 part_name 자동 표시 모방)
const selectedPart = partOptions.find((p) => p.value === partObjid);
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-2xl">
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>{isEdit ? `ECR 수정 ${editing?.ecr_no}` : "ECR 등록"}</DialogTitle>
<DialogTitle> (Engineering Change Request) {isEdit ? `${editing?.ecr_no}` : ""}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
{/* 1행 : 기종(모델) / 품번 / 품명 / ECR_NO */}
<div className="grid grid-cols-4 gap-2 rounded border bg-muted/10 p-2">
<div className="space-y-1">
<Label className="text-xs">()</Label>
<Label className="text-[11px]">() <span className="text-destructive">*</span></Label>
<Select value={productObjid || "none"} onValueChange={(v) => setProductObjid(v === "none" ? "" : v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
@@ -412,7 +456,7 @@ function EcrFormModal({
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs"></Label>
<Label className="text-[11px]"> <span className="text-destructive">*</span></Label>
<Select value={partObjid || "none"} onValueChange={(v) => setPartObjid(v === "none" ? "" : v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
@@ -421,10 +465,23 @@ function EcrFormModal({
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[11px]"></Label>
<div className="flex h-8 items-center rounded-md border bg-muted/30 px-2 text-xs">
{selectedPart?.label?.replace(/^\S+\s*/, "") || editing?.part_name || "-"}
</div>
</div>
<div className="space-y-1">
<Label className="text-[11px]">ECR NO</Label>
<div className="flex h-8 items-center rounded-md border bg-muted/30 px-2 text-xs font-mono">
{editing?.ecr_no || "(자동채번)"}
</div>
</div>
</div>
{/* 설변요청 (다중) */}
<div className="space-y-1">
<Label className="text-xs"> ( )</Label>
<Label className="text-xs"></Label>
<div className="flex flex-wrap gap-1.5 rounded-md border bg-muted/20 p-2">
{requestOptions.length === 0 ? (
<span className="text-xs text-muted-foreground">(0000090) .</span>
@@ -455,21 +512,41 @@ function EcrFormModal({
</div>
</div>
{/* 제목 */}
<div className="space-y-1">
<Label className="text-xs"> <span className="text-destructive">*</span></Label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="ECR 제목" className="h-8 text-xs" />
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Textarea value={beforeContents} onChange={(e) => setBeforeContents(e.target.value)} className="min-h-[100px] text-xs" rows={5} />
{/* 변경전 / 변경후 (wace_plm 2분할 레이아웃) */}
<div className="grid grid-cols-2 gap-2 rounded border border-black/60 p-2">
<div className="space-y-1">
<Label className="text-xs font-bold">[]</Label>
<Textarea
value={beforeContents}
onChange={(e) => setBeforeContents(e.target.value)}
placeholder="변경전"
className="min-h-[200px] text-xs"
rows={9}
/>
</div>
<div className="space-y-1">
<Label className="text-xs font-bold">[]</Label>
<Textarea
value={afterContents}
onChange={(e) => setAfterContents(e.target.value)}
placeholder="변경후"
className="min-h-[200px] text-xs"
rows={9}
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" size="sm" className="h-8 text-xs" onClick={onClose} disabled={saving}></Button>
<Button variant="outline" size="sm" className="h-8 text-xs" onClick={onClose} disabled={saving}></Button>
<Button size="sm" className="h-8 text-xs" onClick={handleSave} disabled={saving}>
{saving ? "저장 중..." : "저장"}
{saving ? "저장 중..." : (isEdit ? "수정" : "저장")}
</Button>
</DialogFooter>
</DialogContent>
@@ -488,26 +565,34 @@ function EcrDetailModal({
}) {
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-2xl">
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>ECR {item?.ecr_no}</DialogTitle>
<DialogTitle> () {item?.ecr_no}</DialogTitle>
</DialogHeader>
{item && (
<div className="space-y-2 text-xs">
<div className="grid grid-cols-2 gap-2">
<Field label="ECR 번호" value={item.ecr_no} />
<Field label="상태" value={item.status_name || statusOptions.find((s) => s.value === item.status_cd)?.label || item.status_cd} />
<Field label="모델" value={item.product_name || "-"} />
<Field label="부품" value={item.part_no ? `${item.part_no} (${item.part_name})` : "-"} />
<Field label="작성자" value={item.writer_name || item.writer || "-"} />
<Field label="등록일" value={item.reg_date} />
<Field label="검토자" value={item.check_name || "-"} />
<Field label="완료일" value={item.check_date || "-"} />
<div className="grid grid-cols-4 gap-2 rounded border bg-muted/10 p-2">
<Field label="기종(모델)" value={item.product_name || "-"} />
<Field label="품번" value={item.part_no || "-"} />
<Field label="품명" value={item.part_name || "-"} />
<Field label="ECR NO" value={item.ecr_no} mono />
</div>
<Field label="요청 코드" value={item.request_name || "-"} />
<Field label="제목" value={item.title} />
<Field label="변경 전 내용" value={item.before_contents || "-"} block />
<Field label="변경 후 내용 (조치 결과)" value={item.after_contents || "-"} block />
<Field label="설변요청" value={item.request_name || "-"} block />
<Field label="제목" value={item.title} block />
<div className="grid grid-cols-2 gap-2 rounded border border-black/60 p-2">
<Field label="[변경전]" value={item.before_contents || "-"} block dense />
<Field label="[변경후]" value={item.after_contents || "-"} block dense />
</div>
<div className="grid grid-cols-4 gap-2">
<Field label="작성자" value={item.writer_name || item.writer || "-"} />
<Field label="작성일" value={item.reg_date || "-"} />
<Field label="조치자" value={item.check_name || "-"} />
<Field label="조치일" value={item.check_date || "-"} />
</div>
<Field
label="상태"
value={item.status_name || statusOptions.find((s) => s.value === item.status_cd)?.label || item.status_cd}
/>
</div>
)}
<DialogFooter>
@@ -518,11 +603,11 @@ function EcrDetailModal({
);
}
function Field({ label, value, block }: { label: string; value?: React.ReactNode; block?: boolean }) {
function Field({ label, value, block, mono, dense }: { label: string; value?: React.ReactNode; block?: boolean; mono?: boolean; dense?: boolean }) {
return (
<div className={cn("rounded border bg-muted/20 p-1.5", block && "col-span-2")}>
<div className={cn("rounded border bg-muted/20 p-1.5", block && "col-span-full")}>
<div className="text-[10px] font-medium uppercase text-muted-foreground">{label}</div>
<div className={cn("mt-0.5 text-xs", block && "whitespace-pre-wrap")}>{value || "-"}</div>
<div className={cn("mt-0.5 text-xs", block && "whitespace-pre-wrap", mono && "font-mono", dense && "min-h-[120px]")}>{value || "-"}</div>
</div>
);
}
@@ -543,11 +628,12 @@ function EcrCompleteModal({
const handleComplete = async () => {
if (!item) return;
if (!afterContents.trim()) return toast.warning("조치 결과(변경 후 내용)를 입력세요.");
if (!afterContents.trim()) return toast.warning("조치내역을 입력해주세요.");
if (!confirm("조치완료 하시겠습니까?")) return;
setSaving(true);
try {
await ecrMngApi.complete(item.objid, afterContents);
toast.success("조치완료 처리되었습니다.");
toast.success("저장되었습니다.");
onCompleted();
} catch (e: any) {
toast.error(e?.message || "조치완료 실패");
@@ -558,7 +644,7 @@ function EcrCompleteModal({
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-xl">
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>ECR {item?.ecr_no}</DialogTitle>
</DialogHeader>
@@ -567,8 +653,8 @@ function EcrCompleteModal({
<span className="font-medium">:</span> {item?.title}
</div>
<div className="space-y-1">
<Label className="text-xs"> ( ) <span className="text-destructive">*</span></Label>
<Textarea value={afterContents} onChange={(e) => setAfterContents(e.target.value)} className="min-h-[140px] text-xs" rows={6} />
<Label className="text-xs"> <span className="text-destructive">*</span></Label>
<Textarea value={afterContents} onChange={(e) => setAfterContents(e.target.value)} className="min-h-[180px] text-xs" rows={8} />
</div>
</div>
<DialogFooter>
@@ -7,14 +7,12 @@ import { usePageMultiLang } from "@/hooks/usePageMultiLang";
const LANG_KEYS = [
"commonCode.page.title",
"commonCode.page.description",
"commonCode.category.title",
"commonCode.detail.title",
] as const;
const DEFAULT_TEXTS: Record<string, string> = {
"commonCode.page.title": "공통코드 관리",
"commonCode.page.description": "시스템에서 사용하는 공통코드를 관리합니다",
"commonCode.category.title": "코드 카테고리",
"commonCode.detail.title": "코드 상세 정보",
};
@@ -28,32 +26,37 @@ export default function CommonCodeManagementPage() {
const { selectedCategoryCode, selectCategory } = useSelectedCategory();
return (
<div className="flex h-full flex-col overflow-hidden bg-background p-6">
{/* 페이지 헤더 (고정) */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight">{t("commonCode.page.title")}</h1>
<p className="text-muted-foreground text-sm">{t("commonCode.page.description")}</p>
<div className="flex h-full flex-col overflow-hidden bg-background p-4">
{/* 페이지 헤더 (얇게, 설명 없이) */}
<div className="flex items-center justify-between border-b pb-2">
<h1 className="text-lg font-semibold tracking-tight">{t("commonCode.page.title")}</h1>
</div>
{/* 메인 콘텐츠 -우 레이아웃 (남은 공간 가득) */}
<div className="mt-6 flex min-h-0 flex-1 flex-col gap-6 lg:flex-row">
{/* 메인 콘텐츠 (카테고리) / 우(코드 상세) 2열, 한 프레임으로 보이게 컴팩트 */}
<div className="mt-3 flex min-h-0 flex-1 flex-col gap-3 lg:flex-row">
{/* 좌측: 카테고리 패널 */}
<div className="flex min-h-0 w-full flex-col lg:w-80 lg:border-r lg:pr-6">
<h2 className="mb-4 text-lg font-semibold">{t("commonCode.category.title")}</h2>
<div className="min-h-0 flex-1">
<div className="flex min-h-0 w-full flex-col rounded-md border bg-card lg:w-72">
<div className="flex h-9 items-center border-b px-3">
<h2 className="text-xs font-semibold tracking-tight">{t("commonCode.category.title")}</h2>
</div>
<div className="flex min-h-0 flex-1 flex-col p-2">
<CodeCategoryPanel selectedCategoryCode={selectedCategoryCode} onSelectCategory={selectCategory} />
</div>
</div>
{/* 우측: 코드 상세 패널 */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<h2 className="mb-4 text-lg font-semibold">
{t("commonCode.detail.title")}
{selectedCategoryCode && (
<span className="text-muted-foreground ml-2 text-sm font-normal">({selectedCategoryCode})</span>
)}
</h2>
<div className="min-h-0 flex-1">
<div className="flex min-h-0 min-w-0 flex-1 flex-col rounded-md border bg-card">
<div className="flex h-9 items-center justify-between border-b px-3">
<h2 className="text-xs font-semibold tracking-tight">
{t("commonCode.detail.title")}
{selectedCategoryCode && (
<span className="ml-1.5 font-mono text-[11px] font-normal text-muted-foreground">
· {selectedCategoryCode}
</span>
)}
</h2>
</div>
<div className="flex min-h-0 flex-1 flex-col p-2">
<CodeDetailPanel categoryCode={selectedCategoryCode} />
</div>
</div>
@@ -1358,58 +1358,61 @@ export default function TableManagementPage() {
{/* 3패널 메인 */}
<div className="flex flex-1 overflow-hidden">
{/* 좌측: 테이블 목록 (컴팩트 240px) */}
<div className="bg-card flex w-[240px] min-w-[240px] flex-shrink-0 flex-col border-r">
{/* 좌측: 테이블 목록 — 한 줄 리스트 / 알파벳 인덱스 sticky */}
<div className="flex w-[260px] min-w-[260px] flex-shrink-0 flex-col border-r bg-card">
{/* 검색 */}
<div className="flex-shrink-0 p-2 pb-0">
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-2 h-3 w-3 -translate-y-1/2" />
<div className="flex flex-shrink-0 items-center gap-1.5 border-b bg-muted/30 px-2 py-1.5">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder={getTextFromUI(TABLE_MANAGEMENT_KEYS.SEARCH_PLACEHOLDER, "테이블 검색...")}
placeholder={getTextFromUI(TABLE_MANAGEMENT_KEYS.SEARCH_PLACEHOLDER, "테이블 검색")}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="bg-background h-7 pl-7 text-[11px]"
className="h-7 border-transparent bg-background pl-7 text-[11px] shadow-none focus-visible:border-input"
/>
</div>
{isSuperAdmin && (
<div className="mt-1.5 flex items-center justify-between border-b pb-1.5">
<div className="flex items-center gap-1.5">
<Checkbox
checked={
filteredTables.length > 0 &&
filteredTables.every((table) => selectedTableIds.has(table.tableName))
}
onCheckedChange={handleSelectAll}
aria-label="전체 선택"
className="h-3 w-3"
/>
<span className="text-muted-foreground text-[10px]">
{selectedTableIds.size > 0 ? `${selectedTableIds.size}` : "전체"}
</span>
</div>
{selectedTableIds.size > 0 && (
<Button
variant="destructive"
size="sm"
onClick={handleBulkDeleteClick}
className="h-5 gap-1 px-1.5 text-[10px]"
>
<Trash2 className="h-2.5 w-2.5" />
</Button>
)}
</div>
)}
<span className="flex-shrink-0 rounded-md bg-muted px-1.5 py-0.5 font-mono text-[10px] font-medium tabular-nums text-muted-foreground">
{filteredTables.length}
</span>
</div>
{/* 전체선택 + 삭제 (SUPER_ADMIN) */}
{isSuperAdmin && (
<div className="flex flex-shrink-0 items-center justify-between border-b px-2 py-1">
<label className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<Checkbox
checked={
filteredTables.length > 0 &&
filteredTables.every((table) => selectedTableIds.has(table.tableName))
}
onCheckedChange={handleSelectAll}
aria-label="전체 선택"
className="h-3 w-3"
/>
{selectedTableIds.size > 0 ? `${selectedTableIds.size}개 선택됨` : "전체 선택"}
</label>
{selectedTableIds.size > 0 && (
<Button
variant="destructive"
size="sm"
onClick={handleBulkDeleteClick}
className="h-5 gap-1 px-1.5 text-[10px]"
>
<Trash2 className="h-2.5 w-2.5" />
</Button>
)}
</div>
)}
{/* 테이블 리스트 */}
<div className="flex-1 overflow-y-auto px-1">
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-8">
<LoadingSpinner />
</div>
) : filteredTables.length === 0 ? (
<div className="text-muted-foreground py-8 text-center text-xs">
<div className="py-8 text-center text-xs text-muted-foreground">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_TABLES, "테이블이 없습니다")}
</div>
) : (
@@ -1423,16 +1426,18 @@ export default function TableManagementPage() {
return (
<div key={table.tableName}>
{showDivider && (
<div className="text-muted-foreground/60 mt-1.5 mb-0.5 px-1.5 text-[9px] font-bold uppercase tracking-widest">
{isKo ? "한글" : "ENGLISH"}
<div className="sticky top-0 z-10 flex items-center gap-1 bg-muted/80 px-2 py-0.5 text-[9px] font-bold uppercase tracking-widest text-muted-foreground backdrop-blur">
<span className="h-px flex-1 bg-border" />
<span>{isKo ? "한글" : "ENGLISH"}</span>
<span className="h-px flex-1 bg-border" />
</div>
)}
<div
className={cn(
"group relative flex items-center gap-1.5 rounded-md px-2 py-1 transition-colors",
"group relative flex cursor-pointer items-center gap-1.5 border-l-2 px-2 py-1.5 text-[12px] leading-tight transition-colors",
isActive
? "bg-accent text-foreground"
: "text-foreground/80 hover:bg-accent/50",
? "border-l-primary bg-primary/5 text-foreground"
: "border-l-transparent text-foreground/80 hover:bg-muted/50",
)}
onClick={() => handleTableSelect(table.tableName)}
role="button"
@@ -1444,9 +1449,6 @@ export default function TableManagementPage() {
}
}}
>
{isActive && (
<div className="bg-primary absolute top-1 bottom-1 left-0 w-[2px] rounded-r" />
)}
{isSuperAdmin && (
<Checkbox
checked={selectedTableIds.has(table.tableName)}
@@ -1456,25 +1458,23 @@ export default function TableManagementPage() {
onClick={(e) => e.stopPropagation()}
/>
)}
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-1">
<span className={cn(
"truncate text-[12px] leading-tight",
isActive ? "font-bold" : "font-medium",
)}>
{table.displayName || table.tableName}
<div className="min-w-0 flex-1 truncate">
<span className={cn("truncate", isActive ? "font-semibold" : "font-medium")}>
{table.displayName || table.tableName}
</span>
{table.displayName && table.displayName !== table.tableName && (
<span className="ml-1 truncate font-mono text-[10px] font-normal text-muted-foreground/80">
· {table.tableName}
</span>
</div>
<div className="text-muted-foreground truncate font-mono text-[10px] leading-tight tracking-tight">
{table.tableName}
</div>
)}
</div>
<span className={cn(
"flex-shrink-0 rounded-full px-1 py-0.5 font-mono text-[9px] font-bold leading-none",
isActive
? "bg-primary/15 text-primary"
: "text-muted-foreground",
)}>
<span
className={cn(
"flex-shrink-0 font-mono text-[10px] tabular-nums",
isActive ? "text-primary" : "text-muted-foreground/70",
)}
title={`${table.columnCount} 컬럼`}
>
{table.columnCount}
</span>
</div>
@@ -1485,8 +1485,9 @@ export default function TableManagementPage() {
</div>
{/* 하단 정보 */}
<div className="text-muted-foreground flex-shrink-0 border-t px-2 py-1 text-[9px] font-medium">
{filteredTables.length} / {tables.length}
<div className="flex flex-shrink-0 items-center justify-between border-t bg-muted/30 px-2 py-1 text-[10px] font-medium text-muted-foreground">
<span>{filteredTables.length} </span>
<span className="font-mono tabular-nums">/ {tables.length} </span>
</div>
</div>
+45 -45
View File
@@ -2,7 +2,6 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Edit, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { useUpdateCategory } from "@/hooks/queries/useCategories";
@@ -18,9 +17,12 @@ interface CategoryItemProps {
export function CategoryItem({ category, isSelected, onSelect, onEdit, onDelete }: CategoryItemProps) {
const updateCategoryMutation = useUpdateCategory();
const isActive = category.is_active === "Y";
// 활성/비활성 토글 핸들러
const handleToggleActive = async (checked: boolean) => {
const handleToggleActive = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (updateCategoryMutation.isPending) return;
try {
await updateCategoryMutation.mutateAsync({
categoryCode: category.category_code,
@@ -29,7 +31,7 @@ export function CategoryItem({ category, isSelected, onSelect, onEdit, onDelete
categoryNameEng: category.category_name_eng || "",
description: category.description || "",
sortOrder: category.sort_order,
isActive: checked ? "Y" : "N",
isActive: isActive ? "N" : "Y",
},
});
} catch (error) {
@@ -39,52 +41,50 @@ export function CategoryItem({ category, isSelected, onSelect, onEdit, onDelete
return (
<div
className={cn(
"cursor-pointer rounded-lg border bg-card p-4 shadow-sm transition-all",
isSelected
? "shadow-md"
: "hover:shadow-md",
)}
onClick={onSelect}
className={cn(
"group flex cursor-pointer items-center justify-between rounded-md border px-2.5 py-2 transition-colors",
isSelected
? "border-primary/60 bg-primary/5"
: "border-border bg-card hover:border-primary/30 hover:bg-muted/40",
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h4 className="text-sm font-semibold">{category.category_name}</h4>
<Badge
variant={category.is_active === "Y" ? "default" : "secondary"}
className={cn(
"cursor-pointer text-xs transition-colors",
updateCategoryMutation.isPending && "cursor-not-allowed opacity-50",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (!updateCategoryMutation.isPending) {
handleToggleActive(category.is_active !== "Y");
}
}}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
{category.is_active === "Y" ? "활성" : "비활성"}
</Badge>
</div>
<p className="mt-1 text-xs text-muted-foreground">{category.category_code}</p>
{category.description && <p className="mt-1 text-xs text-muted-foreground">{category.description}</p>}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
{/* 활성 상태 점 (클릭으로 토글) */}
<button
type="button"
onClick={handleToggleActive}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
title={isActive ? "활성 (클릭하여 비활성)" : "비활성 (클릭하여 활성)"}
className={cn(
"h-1.5 w-1.5 flex-shrink-0 rounded-full transition-colors",
isActive ? "bg-emerald-500" : "bg-muted-foreground/40",
updateCategoryMutation.isPending && "opacity-50",
)}
/>
<span className="truncate text-[13px] font-medium" title={category.category_name}>
{category.category_name}
</span>
</div>
<p className="mt-0.5 font-mono text-[10px] text-muted-foreground">{category.category_code}</p>
</div>
{/* 액션 버튼 */}
{isSelected && (
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="sm" onClick={onEdit}>
<Edit className="h-3 w-3" />
</Button>
<Button variant="ghost" size="sm" onClick={onDelete}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* 액션 — 선택되었거나 hover 시 노출 */}
<div
className={cn(
"flex items-center gap-0.5 transition-opacity",
isSelected ? "opacity-100" : "opacity-0 group-hover:opacity-100",
)}
onClick={(e) => e.stopPropagation()}
>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={onEdit} title="수정">
<Edit className="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" className="h-6 w-6 text-destructive hover:bg-destructive/10" onClick={onDelete} title="삭제">
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
);
+28 -37
View File
@@ -93,49 +93,40 @@ export function CodeCategoryPanel({ selectedCategoryCode, onSelectCategory }: Co
return (
<div className="flex h-full min-h-0 flex-col">
{/* 검색 및 액션 (고정) */}
<div className="space-y-3 pb-4">
{/* 검색 + 버튼 */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="카테고리 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-10 pl-10 text-sm"
/>
</div>
<Button onClick={handleNewCategory} className="h-10 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" />
</Button>
</div>
{/* 활성 필터 */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="activeOnly"
checked={showActiveOnly}
onChange={(e) => setShowActiveOnly(e.target.checked)}
className="border-input h-4 w-4 rounded"
{/* 검색 + 등록 + 활성 토글 — 한 줄로 컴팩트 */}
<div className="flex flex-shrink-0 items-center gap-1.5 pb-2">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="카테고리 검색"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-7 pl-7 text-xs"
/>
<label htmlFor="activeOnly" className="text-muted-foreground text-sm">
</label>
</div>
<Button onClick={handleNewCategory} size="sm" className="h-7 gap-1 px-2 text-xs">
<Plus className="h-3 w-3" />
</Button>
</div>
<label className="flex flex-shrink-0 items-center gap-1.5 pb-2 text-[11px] text-muted-foreground">
<input
type="checkbox"
checked={showActiveOnly}
onChange={(e) => setShowActiveOnly(e.target.checked)}
className="h-3 w-3 rounded border-input"
/>
</label>
{/* 카테고리 목록 (자체 스크롤 + 무한 스크롤) */}
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto pr-1" onScroll={handleScroll}>
<div className="min-h-0 flex-1 space-y-1 overflow-y-auto pr-1" onScroll={handleScroll}>
{isLoading ? (
<div className="flex h-32 items-center justify-center">
<div className="flex h-24 items-center justify-center">
<LoadingSpinner />
</div>
) : categories.length === 0 ? (
<div className="flex h-32 items-center justify-center">
<p className="text-sm text-muted-foreground">
<div className="flex h-24 items-center justify-center">
<p className="text-xs text-muted-foreground">
{searchTerm ? "검색 결과가 없습니다." : "카테고리가 없습니다."}
</p>
</div>
@@ -154,15 +145,15 @@ export function CodeCategoryPanel({ selectedCategoryCode, onSelectCategory }: Co
{/* 추가 로딩 표시 */}
{isFetchingNextPage && (
<div className="flex items-center justify-center py-4">
<div className="flex items-center justify-center py-2">
<LoadingSpinner size="sm" />
<span className="ml-2 text-sm text-muted-foreground"> ...</span>
<span className="ml-1.5 text-[11px] text-muted-foreground"> </span>
</div>
)}
{/* 더 이상 데이터가 없을 때 */}
{!hasNextPage && categories.length > 0 && (
<div className="py-4 text-center text-sm text-muted-foreground"> .</div>
<div className="py-2 text-center text-[11px] text-muted-foreground"> </div>
)}
</>
)}
+29 -38
View File
@@ -227,18 +227,18 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
if (!categoryCode) {
return (
<div className="flex h-96 items-center justify-center">
<p className="text-muted-foreground text-sm"> </p>
<div className="flex h-full items-center justify-center">
<p className="text-xs text-muted-foreground"> </p>
</div>
);
}
if (error) {
return (
<div className="flex h-96 items-center justify-center">
<div className="flex h-full items-center justify-center">
<div className="text-center">
<p className="text-destructive text-sm font-semibold"> .</p>
<Button variant="outline" onClick={() => window.location.reload()} className="mt-4 h-10 text-sm font-medium">
<p className="text-xs font-semibold text-destructive"> .</p>
<Button variant="outline" onClick={() => window.location.reload()} size="sm" className="mt-2 h-7 text-xs">
</Button>
</div>
@@ -248,49 +248,40 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
return (
<div className="flex h-full min-h-0 flex-col">
{/* 검색 및 액션 (고정) */}
<div className="space-y-3 pb-4">
{/* 검색 + 버튼 */}
<div className="flex items-center gap-2">
<div className="relative w-full sm:w-[300px]">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="코드 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-10 pl-10 text-sm"
/>
</div>
<Button onClick={handleNewCode} className="h-10 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" />
</Button>
{/* 검색 + 등록 + 활성 토글 — 한 줄로 컴팩트 */}
<div className="flex flex-shrink-0 items-center gap-1.5 pb-2">
<div className="relative w-full max-w-[280px]">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="코드 검색"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-7 pl-7 text-xs"
/>
</div>
{/* 활성 필터 */}
<div className="flex items-center gap-2">
<Button onClick={handleNewCode} size="sm" className="h-7 gap-1 px-2 text-xs">
<Plus className="h-3 w-3" />
</Button>
<label className="ml-auto flex items-center gap-1.5 text-[11px] text-muted-foreground">
<input
type="checkbox"
id="activeOnlyCodes"
checked={showActiveOnly}
onChange={(e) => setShowActiveOnly(e.target.checked)}
className="border-input h-4 w-4 rounded"
className="h-3 w-3 rounded border-input"
/>
<label htmlFor="activeOnlyCodes" className="text-muted-foreground text-sm">
</label>
</div>
</label>
</div>
{/* 코드 목록 (자체 스크롤 + 무한 스크롤) */}
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto pr-1" onScroll={handleScroll}>
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1" onScroll={handleScroll}>
{isLoading ? (
<div className="flex h-32 items-center justify-center">
<div className="flex h-24 items-center justify-center">
<LoadingSpinner />
</div>
) : visibleCodes.length === 0 ? (
<div className="flex h-32 items-center justify-center">
<p className="text-muted-foreground text-sm">
<div className="flex h-24 items-center justify-center">
<p className="text-xs text-muted-foreground">
{codes.length === 0 ? "코드가 없습니다." : "검색 결과가 없습니다."}
</p>
</div>
@@ -360,15 +351,15 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
{/* 무한 스크롤 로딩 인디케이터 */}
{isFetchingNextPage && (
<div className="flex items-center justify-center py-4">
<div className="flex items-center justify-center py-2">
<LoadingSpinner size="sm" />
<span className="text-muted-foreground ml-2 text-sm"> ...</span>
<span className="ml-1.5 text-[11px] text-muted-foreground"> </span>
</div>
)}
{/* 모든 코드 로드 완료 메시지 */}
{!hasNextPage && codes.length > 0 && (
<div className="text-muted-foreground py-4 text-center text-sm"> .</div>
<div className="py-2 text-center text-[11px] text-muted-foreground"> </div>
)}
</>
)}
+108 -82
View File
@@ -74,18 +74,19 @@ export function SortableCodeItem({
// 계층구조 깊이에 따른 들여쓰기
const depth = code.depth || 1;
const indentLevel = (depth - 1) * 28; // 28px per level
const indentLevel = (depth - 1) * 16; // 16px per level (was 28)
const hasParent = !!(code.parentCodeValue || code.parent_code_value);
const isActive = code.isActive === "Y" || code.is_active === "Y";
return (
<div className="flex items-stretch">
{/* 계층구조 들여쓰기 영역 */}
{depth > 1 && (
<div
className="flex items-center justify-end pr-2"
className="flex items-center justify-end pr-1"
style={{ width: `${indentLevel}px`, minWidth: `${indentLevel}px` }}
>
<CornerDownRight className="text-muted-foreground/50 h-4 w-4" />
<CornerDownRight className="h-3 w-3 text-muted-foreground/50" />
</div>
)}
@@ -95,136 +96,161 @@ export function SortableCodeItem({
{...attributes}
{...listeners}
className={cn(
"group bg-card flex-1 cursor-grab rounded-lg border p-4 shadow-sm transition-all hover:shadow-md",
isDragging && "cursor-grabbing opacity-50",
depth === 1 && "border-l-primary border-l-4",
depth === 2 && "border-l-4 border-l-blue-400",
depth === 3 && "border-l-4 border-l-green-400",
"group flex-1 cursor-grab rounded-md border bg-card px-2.5 py-1.5 transition-colors hover:bg-muted/40",
isDragging && "cursor-grabbing opacity-50 shadow-md",
depth === 1 && "border-l-2 border-l-primary",
depth === 2 && "border-l-2 border-l-blue-400",
depth === 3 && "border-l-2 border-l-emerald-400",
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
{/* 접기/펼치기 버튼 (자식이 있을 때만 표시) */}
{hasChildren && onToggleExpand && (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onToggleExpand();
}}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
className="text-muted-foreground hover:text-foreground -ml-1 flex h-5 w-5 items-center justify-center rounded transition-colors hover:bg-muted"
title={isExpanded ? "접기" : "펼치기"}
>
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
)}
<h4 className="text-sm font-semibold">{code.codeName || code.code_name}</h4>
{/* 접힌 상태에서 자식 개수 표시 */}
{hasChildren && !isExpanded && <span className="text-muted-foreground text-[10px]">({childCount})</span>}
{/* 깊이 표시 배지 */}
{depth === 1 && (
<Badge
variant="outline"
className="border-primary/30 bg-primary/10 text-primary px-1.5 py-0 text-[10px]"
>
</Badge>
)}
{depth === 2 && (
<Badge variant="outline" className="bg-primary/10 px-1.5 py-0 text-[10px] text-primary">
</Badge>
)}
{depth === 3 && (
<Badge variant="outline" className="bg-emerald-50 px-1.5 py-0 text-[10px] text-emerald-600">
</Badge>
)}
{depth > 3 && (
<Badge variant="outline" className="bg-muted px-1.5 py-0 text-[10px]">
{depth}
</Badge>
)}
<Badge
variant={code.isActive === "Y" || code.is_active === "Y" ? "default" : "secondary"}
className={cn(
"cursor-pointer text-xs transition-colors",
updateCodeMutation.isPending && "cursor-not-allowed opacity-50",
)}
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 flex-1 items-center gap-1.5">
{/* 접기/펼치기 버튼 */}
{hasChildren && onToggleExpand ? (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (!updateCodeMutation.isPending) {
const isActive = code.isActive === "Y" || code.is_active === "Y";
handleToggleActive(!isActive);
}
onToggleExpand();
}}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title={isExpanded ? "접기" : "펼치기"}
>
{code.isActive === "Y" || code.is_active === "Y" ? "활성" : "비활성"}
</Badge>
</div>
<p className="text-muted-foreground mt-1 text-xs">{code.codeValue || code.code_value}</p>
{/* 부모 코드 표시 */}
{hasParent && (
<p className="text-muted-foreground mt-0.5 text-[10px]">
: {code.parentCodeValue || code.parent_code_value}
</p>
{isExpanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
</button>
) : (
<div className="h-4 w-4 flex-shrink-0" />
)}
{/* 활성 토글 (점) */}
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (!updateCodeMutation.isPending) handleToggleActive(!isActive);
}}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
className={cn(
"h-1.5 w-1.5 flex-shrink-0 rounded-full transition-colors",
isActive ? "bg-emerald-500" : "bg-muted-foreground/40",
updateCodeMutation.isPending && "opacity-50",
)}
title={isActive ? "활성 (클릭하여 비활성)" : "비활성 (클릭하여 활성)"}
/>
{/* 코드명 */}
<span className="truncate text-[13px] font-medium" title={code.codeName || code.code_name}>
{code.codeName || code.code_name}
</span>
{/* 코드값 (mono) */}
<span className="hidden flex-shrink-0 font-mono text-[10px] text-muted-foreground sm:inline">
{code.codeValue || code.code_value}
</span>
{/* 자식 개수(접힌 경우) */}
{hasChildren && !isExpanded && (
<span className="flex-shrink-0 text-[10px] text-muted-foreground">({childCount})</span>
)}
{/* 깊이 배지 — 1단계만 표시 (덜 어수선) */}
{depth === 1 && (
<Badge
variant="outline"
className="ml-1 flex-shrink-0 border-primary/30 bg-primary/5 px-1 py-0 text-[9px] font-medium text-primary"
>
</Badge>
)}
{depth === 2 && (
<Badge
variant="outline"
className="ml-1 flex-shrink-0 border-blue-300 bg-blue-50 px-1 py-0 text-[9px] font-medium text-blue-700 dark:bg-blue-950/20"
>
</Badge>
)}
{depth === 3 && (
<Badge
variant="outline"
className="ml-1 flex-shrink-0 border-emerald-300 bg-emerald-50 px-1 py-0 text-[9px] font-medium text-emerald-700 dark:bg-emerald-950/20"
>
</Badge>
)}
{depth > 3 && (
<Badge variant="outline" className="ml-1 flex-shrink-0 px-1 py-0 text-[9px]">
L{depth}
</Badge>
)}
{code.description && <p className="text-muted-foreground mt-1 text-xs">{code.description}</p>}
</div>
{/* 액션 버튼 */}
{/* 액션 — hover 시 노출 */}
<div
className="flex items-center gap-1"
className="flex flex-shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100"
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
{/* 하위 코드 추가 버튼 (최대 깊이 미만일 때만 표시) */}
{depth < maxDepth && (
<Button
variant="ghost"
size="sm"
size="icon"
className="h-6 w-6 text-primary hover:bg-primary/10"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onAddChild();
}}
title="하위 코드 추가"
className="text-primary hover:bg-primary/10 hover:text-primary"
>
<Plus className="h-3 w-3" />
</Button>
)}
<Button
variant="ghost"
size="sm"
size="icon"
className="h-6 w-6"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onEdit();
}}
title="수정"
>
<Edit className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
size="icon"
className="h-6 w-6 text-destructive hover:bg-destructive/10"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDelete();
}}
title="삭제"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
{/* 부모 표시(있을 때만, 한 줄) */}
{hasParent && (
<p className="ml-7 mt-0.5 truncate font-mono text-[10px] text-muted-foreground">
{code.parentCodeValue || code.parent_code_value}
</p>
)}
{code.description && (
<p className="ml-7 mt-0.5 truncate text-[11px] text-muted-foreground" title={code.description}>
{code.description}
</p>
)}
</div>
</div>
);
@@ -1,26 +1,26 @@
"use client";
import React, { useMemo } from "react";
import { X, Type, Settings2, Tag, ToggleLeft, ChevronDown, ChevronRight } from "lucide-react";
import { X, Settings2, MessageSquareText, Lock } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Badge } from "@/components/ui/badge";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import type { ColumnTypeInfo, TableInfo, SecondLevelMenu } from "./types";
import { INPUT_TYPE_COLORS } from "./types";
import { INPUT_TYPE_COLORS, INPUT_TYPE_GROUPS, isSystemColumn } from "./types";
import type { ReferenceTableColumn } from "@/lib/api/entityJoin";
export interface ColumnDetailPanelProps {
@@ -49,7 +49,6 @@ export function ColumnDetailPanel({
codeCategoryOptions = [],
referenceTableOptions = [],
}: ColumnDetailPanelProps) {
const [advancedOpen, setAdvancedOpen] = React.useState(false);
const [entityTableOpen, setEntityTableOpen] = React.useState(false);
const [entityColumnOpen, setEntityColumnOpen] = React.useState(false);
@@ -64,16 +63,11 @@ export function ColumnDetailPanel({
}
}, [column?.referenceTable, onLoadReferenceColumns]);
const advancedCount = useMemo(() => {
if (!column) return 0;
let n = 0;
if (column.defaultValue != null && column.defaultValue !== "") n++;
if (column.maxLength != null && column.maxLength > 0) n++;
return n;
}, [column]);
if (!column) return null;
// 시스템 자동 생성 컬럼은 타입/표시이름 등 일체 편집 불가
const isSystem = isSystemColumn(column.columnName);
const refTableOpts = useMemo(() => {
const hasKorean = (s: string) => /[가-힣]/.test(s);
const raw = referenceTableOptions.length
@@ -113,11 +107,7 @@ export function ColumnDetailPanel({
{typeConf.label}
</span>
)}
<span className="truncate text-sm font-medium">
{column.displayName && column.displayName !== column.columnName
? `${column.displayName} (${column.columnName})`
: column.columnName}
</span>
<span className="truncate font-mono text-sm font-medium">{column.columnName}</span>
</div>
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={onClose} aria-label="닫기">
<X className="h-4 w-4" />
@@ -125,50 +115,70 @@ export function ColumnDetailPanel({
</div>
<div className="flex-1 space-y-4 overflow-y-auto p-4">
{/* [섹션 1] 데이터 타입 선택 */}
{/* 시스템 자동 생성 컬럼 안내 */}
{isSystem && (
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 dark:border-amber-900/40 dark:bg-amber-950/20">
<Lock className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-amber-600 dark:text-amber-400" />
<div className="text-[11px] leading-relaxed text-amber-700 dark:text-amber-300">
<p className="font-semibold"> </p>
<p className="text-[10px] text-amber-600/80 dark:text-amber-400/70">
/ .
</p>
</div>
</div>
)}
{/* [섹션 1] 데이터 타입 선택 — 셀렉트 박스 */}
<section className="space-y-2">
<div>
<p className="text-sm font-semibold"> ?</p>
<p className="text-xs text-muted-foreground"> </p>
</div>
<div className="grid grid-cols-3 gap-1.5">
{Object.entries(INPUT_TYPE_COLORS).map(([type, conf]) => {
const isSelected = (column.inputType || "text") === type;
return (
<button
key={type}
type="button"
onClick={() => onColumnChange("inputType", type)}
className={cn(
"flex flex-col items-center gap-1 rounded-lg border px-1.5 py-2.5 text-center transition-all",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/30"
: "border-border hover:border-primary/30 hover:bg-accent/50",
)}
>
<span className={cn(
"text-base font-bold leading-none",
isSelected ? "text-primary" : conf.color,
)}>
{conf.iconChar}
</span>
<span className={cn(
"text-[16px] font-semibold leading-tight",
isSelected ? "text-primary" : "text-foreground",
)}>
{conf.label}
</span>
<span className="text-[12px] leading-tight text-muted-foreground">
{conf.desc}
</span>
</button>
);
})}
<p className="text-xs text-muted-foreground">
. UI (text/textarea ) .
</p>
</div>
<Select
value={column.inputType || "text"}
onValueChange={(v) => onColumnChange("inputType", v)}
disabled={isSystem}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="타입 선택" />
</SelectTrigger>
<SelectContent>
{INPUT_TYPE_GROUPS.map((group, gi) => (
<SelectGroup key={group.groupLabel}>
<SelectLabel className="text-[10px] uppercase tracking-wider text-muted-foreground">
{group.groupLabel}
</SelectLabel>
{group.types.map((type) => {
const conf = INPUT_TYPE_COLORS[type];
if (!conf) return null;
return (
<SelectItem key={type} value={type} className="text-sm">
<span className="flex items-center gap-2">
<span
className={cn(
"inline-flex h-4 w-4 items-center justify-center rounded text-[10px] font-bold",
conf.bgColor,
conf.color,
)}
>
{conf.iconChar}
</span>
<span>{conf.label}</span>
<span className="text-[10px] text-muted-foreground">· {conf.desc}</span>
</span>
</SelectItem>
);
})}
</SelectGroup>
))}
</SelectContent>
</Select>
</section>
{/* [섹션 2] 타입별 상세 설정 */}
{column.inputType === "entity" && (
{/* [섹션 2] 타입별 상세 설정 — 시스템 컬럼은 표시하지 않음 */}
{!isSystem && column.inputType === "entity" && (
<section className="space-y-3">
<div className="flex items-center gap-2">
<Settings2 className="h-4 w-4 text-muted-foreground" />
@@ -328,7 +338,7 @@ export function ColumnDetailPanel({
</section>
)}
{column.inputType === "code" && (
{!isSystem && column.inputType === "code" && (
<section className="space-y-2">
<div className="flex items-center gap-2">
<Settings2 className="h-4 w-4 text-muted-foreground" />
@@ -345,11 +355,18 @@ export function ColumnDetailPanel({
<SelectValue placeholder="코드 선택" />
</SelectTrigger>
<SelectContent>
{[{ value: "none", label: "선택 안함" }, ...codeCategoryOptions].map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
{/* "none" , codeCategoryOptions "none"
(key ). */}
{(() => {
const existingNone = codeCategoryOptions.find((o) => o.value === "none");
const rest = codeCategoryOptions.filter((o) => o.value !== "none");
const noneOpt = existingNone ?? { value: "none", label: "선택 안함" };
return [noneOpt, ...rest].map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
));
})()}
</SelectContent>
</Select>
</div>
@@ -378,7 +395,7 @@ export function ColumnDetailPanel({
</section>
)}
{column.inputType === "category" && (
{!isSystem && column.inputType === "category" && (
<section className="space-y-2">
<div className="flex items-center gap-2">
<Settings2 className="h-4 w-4 text-muted-foreground" />
@@ -396,7 +413,7 @@ export function ColumnDetailPanel({
</section>
)}
{column.inputType === "numbering" && (
{!isSystem && column.inputType === "numbering" && (
<section className="space-y-2">
<div className="flex items-center gap-2">
<Settings2 className="h-4 w-4 text-muted-foreground" />
@@ -409,103 +426,25 @@ export function ColumnDetailPanel({
</section>
)}
{/* [섹션 3] 표시 이름 */}
{/* [ 3] PostgreSQL COMMENT .
, . */}
<section className="space-y-2">
<div className="flex items-center gap-2">
<Tag className="h-4 w-4 text-muted-foreground" />
<Label className="text-sm font-medium"> </Label>
<MessageSquareText className="h-4 w-4 text-muted-foreground" />
<Label className="text-sm font-medium"></Label>
</div>
<Input
value={column.displayName ?? ""}
onChange={(e) => onColumnChange("displayName", e.target.value)}
placeholder={column.columnName}
className="h-9 text-sm"
<Textarea
value={column.description ?? ""}
onChange={(e) => onColumnChange("description", e.target.value)}
placeholder="이 컬럼이 어떤 값인지 한 줄로 설명 (예: 거래처 세금유형 코드)"
className="min-h-[72px] text-xs"
rows={3}
disabled={isSystem}
/>
<p className="text-[10px] leading-snug text-muted-foreground">
. .
</p>
</section>
{/* [섹션 4] 표시 옵션 */}
<section className="space-y-2">
<div className="flex items-center gap-2">
<ToggleLeft className="h-4 w-4 text-muted-foreground" />
<Label className="text-sm font-medium"> </Label>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium"> </p>
<p className="text-xs text-muted-foreground"> .</p>
</div>
<Switch
checked={column.isNullable === "NO"}
onCheckedChange={(checked) => onColumnChange("isNullable", checked ? "NO" : "YES")}
aria-label="필수 입력"
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium"> </p>
<p className="text-xs text-muted-foreground"> .</p>
</div>
<Switch
checked={false}
onCheckedChange={() => {}}
disabled
aria-label="읽기 전용 (향후 확장)"
/>
</div>
</div>
</section>
{/* [섹션 5] 고급 설정 */}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between py-1 text-left"
aria-expanded={advancedOpen}
>
<div className="flex items-center gap-2">
{advancedOpen ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<span className="text-sm font-medium"> </span>
{advancedCount > 0 && (
<Badge variant="secondary" className="text-xs">
{advancedCount}
</Badge>
)}
</div>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="space-y-3 pt-2">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Input
value={column.defaultValue ?? ""}
onChange={(e) => onColumnChange("defaultValue", e.target.value)}
placeholder="기본값"
className="h-9 text-xs"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"> </Label>
<Input
type="number"
value={column.maxLength ?? ""}
onChange={(e) => {
const v = e.target.value;
onColumnChange("maxLength", v === "" ? undefined : Number(v));
}}
placeholder="숫자"
className="h-9 text-xs"
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
</div>
);
@@ -1,12 +1,12 @@
"use client";
import React, { useMemo } from "react";
import { MoreHorizontal, Database, Layers, FileStack } from "lucide-react";
import { MoreHorizontal, Database, Layers, FileStack, Lock } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import type { ColumnTypeInfo, TableInfo } from "./types";
import { INPUT_TYPE_COLORS, getColumnGroup } from "./types";
import { INPUT_TYPE_COLORS, getColumnGroup, isSystemColumn } from "./types";
import type { ReferenceTableColumn } from "@/lib/api/entityJoin";
export interface ColumnGridConstraints {
@@ -124,6 +124,7 @@ export function ColumnGrid({
const typeConf = INPUT_TYPE_COLORS[column.inputType || "text"] || INPUT_TYPE_COLORS.text;
const idxState = getIdxState(column.columnName);
const isSelected = selectedColumn === column.columnName;
const isLocked = isSystemColumn(column.columnName);
return (
<div
@@ -141,17 +142,24 @@ export function ColumnGrid({
"group grid h-9 cursor-pointer items-center gap-2 px-3 transition-colors",
"hover:bg-muted/40",
isSelected && "bg-primary/5 ring-1 ring-inset ring-primary/30",
isLocked && "bg-muted/20",
)}
style={{ gridTemplateColumns: GRID_TEMPLATE }}
>
{/* 3px 색상바 (타입별 진한 색) */}
<div className={cn("h-5 w-[3px] rounded-full", typeConf.barColor)} />
{/* 라벨 + 컬럼명 — 한글 라벨이 우선, 영문명은 옆에 모노폰트 */}
{/* 라벨 + 컬럼명 — 시스템 컬럼이면 자물쇠 아이콘 표시 */}
<div className="flex min-w-0 items-baseline gap-1.5">
{isLocked && (
<Lock
className="h-2.5 w-2.5 flex-shrink-0 self-center text-muted-foreground/70"
aria-label="시스템 자동 생성 컬럼"
/>
)}
{column.displayName && column.displayName !== column.columnName ? (
<>
<span className="truncate text-[12px] font-medium leading-tight">
<span className={cn("truncate text-[12px] font-medium leading-tight", isLocked && "text-muted-foreground")}>
{column.displayName}
</span>
<span className="truncate font-mono text-[10px] text-muted-foreground leading-tight">
@@ -159,7 +167,7 @@ export function ColumnGrid({
</span>
</>
) : (
<span className="truncate font-mono text-[12px] font-medium leading-tight">
<span className={cn("truncate font-mono text-[12px] font-medium leading-tight", isLocked && "text-muted-foreground")}>
{column.columnName}
</span>
)}
@@ -225,69 +233,77 @@ export function ColumnGrid({
{typeConf.label}
</div>
{/* PK / NN / IDX / UQ (클릭 토글) — 한 줄 nowrap */}
{/* PK / NN / IDX / UQ (클릭 토글) — 시스템 컬럼은 잠금(읽기 전용) */}
<div className="flex flex-nowrap items-center justify-center gap-0.5">
<button
type="button"
disabled={isLocked}
className={cn(
"h-5 w-7 rounded border text-[9px] font-bold transition-colors",
"h-5 w-7 rounded border text-[9px] font-bold transition-colors disabled:cursor-not-allowed disabled:opacity-60",
idxState.isPk
? "border-blue-200 bg-blue-50 text-blue-600"
: "border-border/50 text-muted-foreground/40 hover:border-blue-200 hover:text-blue-400",
)}
onClick={(e) => {
e.stopPropagation();
if (isLocked) return;
onPkToggle?.(column.columnName, !idxState.isPk);
}}
title="Primary Key 토글"
title={isLocked ? "시스템 컬럼 — 수정 불가" : "Primary Key 토글"}
>
PK
</button>
<button
type="button"
disabled={isLocked}
className={cn(
"h-5 w-7 rounded border text-[9px] font-bold transition-colors",
"h-5 w-7 rounded border text-[9px] font-bold transition-colors disabled:cursor-not-allowed disabled:opacity-60",
column.isNullable === "NO"
? "border-amber-200 bg-amber-50 text-amber-600"
: "border-border/50 text-muted-foreground/40 hover:border-amber-200 hover:text-amber-400",
)}
onClick={(e) => {
e.stopPropagation();
if (isLocked) return;
onColumnChange(column.columnName, "isNullable", column.isNullable === "NO" ? "YES" : "NO");
}}
title="Not Null 토글"
title={isLocked ? "시스템 컬럼 — 수정 불가" : "Not Null 토글"}
>
NN
</button>
<button
type="button"
disabled={isLocked}
className={cn(
"h-5 w-7 rounded border text-[9px] font-bold transition-colors",
"h-5 w-7 rounded border text-[9px] font-bold transition-colors disabled:cursor-not-allowed disabled:opacity-60",
idxState.hasIndex
? "border-emerald-200 bg-emerald-50 text-emerald-600"
: "border-border/50 text-muted-foreground/40 hover:border-emerald-200 hover:text-emerald-400",
)}
onClick={(e) => {
e.stopPropagation();
if (isLocked) return;
onIndexToggle?.(column.columnName, !idxState.hasIndex);
}}
title="Index 토글"
title={isLocked ? "시스템 컬럼 — 수정 불가" : "Index 토글"}
>
IDX
</button>
<button
type="button"
disabled={isLocked}
className={cn(
"h-5 w-7 rounded border text-[9px] font-bold transition-colors",
"h-5 w-7 rounded border text-[9px] font-bold transition-colors disabled:cursor-not-allowed disabled:opacity-60",
column.isUnique === "YES"
? "border-violet-200 bg-violet-50 text-violet-600"
: "border-border/50 text-muted-foreground/40 hover:border-violet-200 hover:text-violet-400",
)}
onClick={(e) => {
e.stopPropagation();
if (isLocked) return;
onColumnChange(column.columnName, "isUnique", column.isUnique === "YES" ? "NO" : "YES");
}}
title="Unique 토글"
title={isLocked ? "시스템 컬럼 — 수정 불가" : "Unique 토글"}
>
UQ
</button>
+23 -2
View File
@@ -70,10 +70,31 @@ export const INPUT_TYPE_COLORS: Record<string, TypeColorConfig> = {
image: { color: "text-sky-600", bgColor: "bg-sky-50", barColor: "bg-sky-500", label: "이미지", desc: "이미지 표시", iconChar: "🖼" },
};
/** 시스템 자동 생성 컬럼 — 테이블 생성 시 일괄 부여, 사용자 편집 금지 */
export const SYSTEM_COLUMNS = new Set<string>([
"id",
"created_date",
"updated_date",
"writer",
"company_code",
]);
export function isSystemColumn(columnName: string): boolean {
return SYSTEM_COLUMNS.has(columnName);
}
/** 컬럼 그룹 판별 */
export function getColumnGroup(col: ColumnTypeInfo): ColumnGroup {
const metaCols = ["id", "created_date", "updated_date", "writer", "company_code"];
if (metaCols.includes(col.columnName)) return "meta";
if (isSystemColumn(col.columnName)) return "meta";
if (["entity", "code", "category"].includes(col.inputType)) return "reference";
return "basic";
}
/** 타입 선택 셀렉트박스용 그룹 정의 (저장계층 타입 위주, UI 표시 변형은 화면관리에서) */
export const INPUT_TYPE_GROUPS: Array<{ groupLabel: string; types: string[] }> = [
{ groupLabel: "기본", types: ["text", "number", "date", "checkbox"] },
{ groupLabel: "참조", types: ["code", "entity", "category"] },
{ groupLabel: "자동", types: ["numbering"] },
{ groupLabel: "첨부", types: ["file", "image"] },
{ groupLabel: "표시 변형", types: ["textarea", "select", "radio"] },
];