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:
@@ -134,6 +134,8 @@ k8s/*-secret.yaml
|
||||
*.dump
|
||||
db/dump/
|
||||
db/backup/
|
||||
# 부팅 시 적재되는 시드 SQL 은 코드 일부로 취급 — 예외 처리
|
||||
!backend-node/src/seed/*.sql
|
||||
|
||||
# 백업
|
||||
*.bak
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"] },
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user