시퀀스 관리 메뉴 + 테이블 타입관리 코멘트/검증 + 설계 문서
Build and Push Images / build-and-push (push) Has been cancelled
Build and Push Images / build-and-push (push) Has been cancelled
- 시스템 관리 > 시퀀스 관리 신규 메뉴 + 페이지(채번 룰 빌더) - ensureSequenceMngMenu 부팅 시드 - 테이블 타입관리 → 채번 룰 드롭다운 + 의존성 자동 검증 - 표시명/코멘트 UX 개선 - 설계 문서 추가 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -539,6 +539,14 @@ async function initializeServices() {
|
||||
logger.error(`❌ wace_plm 공통코드 시드 실패:`, error);
|
||||
}
|
||||
|
||||
// 시퀀스 관리 메뉴(시스템 관리 > 시퀀스 관리) 등록 — menu_info 멱등 시드
|
||||
try {
|
||||
const { ensureSequenceMngMenu } = await import("./services/sequenceMngMenuMigration");
|
||||
await ensureSequenceMngMenu();
|
||||
} catch (error) {
|
||||
logger.error(`❌ 시퀀스 관리 메뉴 시드 실패:`, error);
|
||||
}
|
||||
|
||||
// 고객 CS 관리 테이블 점검 (customer_cs_mng + 공통코드 카테고리)
|
||||
try {
|
||||
const { ensureCustomerCsTables } = await import("./services/customerCsTableMigration");
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* 시퀀스 관리 메뉴(시스템 관리 > 시퀀스 관리) idempotent 시드.
|
||||
*
|
||||
* - 부팅 시 1회 실행.
|
||||
* - menu_url='/admin/systemMng/sequenceMng' 이 이미 있으면 skip(정규화만).
|
||||
* - 부모(시스템 관리) 찾기 — 다음 순서로 fallback:
|
||||
* (1) menu_name_kor = '시스템 관리' / '시스템관리'
|
||||
* (2) menu_url LIKE '/admin/systemMng/%' 자식들의 가장 빈도 높은 parent_obj_id
|
||||
* (3) menu_url LIKE '/admin/system%' 자식들의 가장 빈도 높은 parent_obj_id
|
||||
* (4) 같은 회사 코드의 메뉴 중 menu_name_kor LIKE '%시스템%' (느슨한 매칭)
|
||||
* - 부모를 찾지 못하면 등록을 건너뛰고 진단 로그 남김(운영 환경에서 수동 등록 가능).
|
||||
*/
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
const TARGET_URL = "/admin/systemMng/sequenceMng";
|
||||
const MENU_NAME_KOR = "시퀀스 관리";
|
||||
const MENU_NAME_ENG = "Sequence Management";
|
||||
|
||||
async function findSystemMngParentObjId(pool: ReturnType<typeof getPool>): Promise<number | null> {
|
||||
// (1) 이름이 정확히 '시스템 관리' or '시스템관리'
|
||||
const byName = await pool.query<{ objid: number }>(
|
||||
`SELECT objid FROM menu_info
|
||||
WHERE COALESCE(status,'active') = 'active'
|
||||
AND (menu_name_kor = '시스템 관리' OR menu_name_kor = '시스템관리')
|
||||
ORDER BY objid ASC
|
||||
LIMIT 1`,
|
||||
);
|
||||
if (byName.rowCount && byName.rowCount > 0) {
|
||||
return Number(byName.rows[0].objid);
|
||||
}
|
||||
|
||||
// (2) /admin/systemMng/* 자식들의 부모 빈도 1위
|
||||
const byChildren1 = await pool.query<{ parent_obj_id: number; cnt: string }>(
|
||||
`SELECT parent_obj_id, COUNT(*)::text AS cnt
|
||||
FROM menu_info
|
||||
WHERE menu_url LIKE '/admin/systemMng/%'
|
||||
AND parent_obj_id IS NOT NULL
|
||||
GROUP BY parent_obj_id
|
||||
ORDER BY cnt DESC NULLS LAST
|
||||
LIMIT 1`,
|
||||
);
|
||||
if (byChildren1.rowCount && byChildren1.rowCount > 0) {
|
||||
return Number(byChildren1.rows[0].parent_obj_id);
|
||||
}
|
||||
|
||||
// (3) /admin/system* 자식들 (대소문자/언더스코어 변형 대비)
|
||||
const byChildren2 = await pool.query<{ parent_obj_id: number; cnt: string }>(
|
||||
`SELECT parent_obj_id, COUNT(*)::text AS cnt
|
||||
FROM menu_info
|
||||
WHERE (menu_url ILIKE '/admin/system%' OR menu_url ILIKE '/admin/sys%')
|
||||
AND parent_obj_id IS NOT NULL
|
||||
GROUP BY parent_obj_id
|
||||
ORDER BY cnt DESC NULLS LAST
|
||||
LIMIT 1`,
|
||||
);
|
||||
if (byChildren2.rowCount && byChildren2.rowCount > 0) {
|
||||
return Number(byChildren2.rows[0].parent_obj_id);
|
||||
}
|
||||
|
||||
// (4) 느슨한 이름 매칭
|
||||
const byNameLike = await pool.query<{ objid: number }>(
|
||||
`SELECT objid FROM menu_info
|
||||
WHERE COALESCE(status,'active') = 'active'
|
||||
AND menu_name_kor LIKE '%시스템%'
|
||||
ORDER BY objid ASC
|
||||
LIMIT 1`,
|
||||
);
|
||||
if (byNameLike.rowCount && byNameLike.rowCount > 0) {
|
||||
return Number(byNameLike.rows[0].objid);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function ensureSequenceMngMenu(): Promise<void> {
|
||||
const pool = getPool();
|
||||
try {
|
||||
// (1) 이미 있으면 종료
|
||||
const existing = await pool.query(
|
||||
`SELECT objid FROM menu_info WHERE menu_url = $1 LIMIT 1`,
|
||||
[TARGET_URL],
|
||||
);
|
||||
if (existing.rowCount && existing.rowCount > 0) {
|
||||
await pool.query(
|
||||
`UPDATE menu_info
|
||||
SET menu_name_kor = COALESCE(NULLIF(menu_name_kor,''), $1),
|
||||
menu_name_eng = COALESCE(NULLIF(menu_name_eng,''), $2),
|
||||
status = 'active'
|
||||
WHERE menu_url = $3`,
|
||||
[MENU_NAME_KOR, MENU_NAME_ENG, TARGET_URL],
|
||||
);
|
||||
logger.info(`✅ 시퀀스 관리 메뉴 이미 존재 — 정규화만 수행`);
|
||||
return;
|
||||
}
|
||||
|
||||
// (2) 부모 찾기 (다중 fallback)
|
||||
const parentObjId = await findSystemMngParentObjId(pool);
|
||||
if (parentObjId == null) {
|
||||
logger.warn(
|
||||
`[sequenceMngMenu] '시스템 관리' 메뉴를 찾지 못해 시퀀스 관리 시드를 건너뜁니다. ` +
|
||||
`메뉴 관리 UI 에서 수동 등록하거나, menu_info 에 다음 행을 추가하세요:\n` +
|
||||
` parent_obj_id = (시스템 관리 OBJID), menu_name_kor = '시퀀스 관리',\n` +
|
||||
` menu_url = '/admin/systemMng/sequenceMng', status = 'active'.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// (3) seq 와 objid 산출
|
||||
const maxSeq = await pool.query<{ max_seq: number | null }>(
|
||||
`SELECT COALESCE(MAX(seq), 0)::int AS max_seq
|
||||
FROM menu_info WHERE parent_obj_id = $1`,
|
||||
[parentObjId],
|
||||
);
|
||||
const nextSeq = (Number(maxSeq.rows[0]?.max_seq) || 0) + 1;
|
||||
|
||||
const nextObjid = await pool.query<{ next: number }>(
|
||||
`SELECT (COALESCE(MAX(objid::bigint), 100000) + 1)::int AS next FROM menu_info`,
|
||||
);
|
||||
const objid = Number(nextObjid.rows[0].next);
|
||||
|
||||
// (4) 부모 행에서 company_code/writer 승계 (참조 일관성)
|
||||
const parentRow = await pool.query<{ company_code: string | null; writer: string | null }>(
|
||||
`SELECT company_code, writer FROM menu_info WHERE objid = $1`,
|
||||
[parentObjId],
|
||||
);
|
||||
const companyCode = parentRow.rows[0]?.company_code ?? "*";
|
||||
const writer = parentRow.rows[0]?.writer ?? "system-seed";
|
||||
|
||||
// (5) INSERT
|
||||
await pool.query(
|
||||
`INSERT INTO menu_info
|
||||
(objid, parent_obj_id, menu_name_kor, menu_name_eng, menu_url, seq,
|
||||
menu_type, company_code, writer, regdate, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 2, $7, $8, NOW(), 'active')`,
|
||||
[objid, parentObjId, MENU_NAME_KOR, MENU_NAME_ENG, TARGET_URL, nextSeq, companyCode, writer],
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`🌱 시퀀스 관리 메뉴 등록 완료: objid=${objid}, parent=${parentObjId}, seq=${nextSeq}, company=${companyCode}`,
|
||||
);
|
||||
} catch (e: any) {
|
||||
logger.warn(`[sequenceMngMenu] 메뉴 시드 실패(계속 진행): ${e?.message?.slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
# 테이블 타입 관리 ↔ 화면관리 설계 방향성
|
||||
|
||||
> "저장 타입(테이블 타입 관리)" 과 "표시 UI(화면관리)" 를 명확히 분리한다.
|
||||
> 한 컬럼이 어떤 **데이터인지**는 테이블 타입에서 정하고,
|
||||
> 그 데이터를 화면에서 **어떻게 보여줄지**는 화면관리에서 정한다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 핵심 원칙
|
||||
|
||||
| 레이어 | 무엇을 결정하나 | 누가 정의하나 |
|
||||
|---|---|---|
|
||||
| **테이블 타입 관리** | 저장 단위(타입), 컬럼 코멘트, PK/NN/IDX/UQ, 참조 대상 | 개발/관리자 |
|
||||
| **화면관리** | 입력 UI 컴포넌트(text/textarea/달력/체크박스/라디오/셀렉트…), 화면별 라벨, 정렬, 필수 여부, 기본값, 표시 옵션 | 개발/관리자 |
|
||||
|
||||
저장 타입은 **간단하게**, 화면 UI 는 **풍부하게**. 한 저장 타입에 여러 UI 컴포넌트가 매핑된다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 테이블 생성 규칙
|
||||
|
||||
### 2.1 코멘트 필수
|
||||
- 모든 테이블에는 **코멘트가 필수**. 빈 값이면 저장 차단.
|
||||
- 모든 컬럼에도 코멘트가 필수 (시스템 자동 생성 컬럼 제외).
|
||||
- 코멘트는 PostgreSQL `COMMENT ON TABLE / COLUMN ... IS '...'` 로 DB 에 같이 박아 둔다.
|
||||
- 화면관리는 이 코멘트를 **기본 라벨** 로 가져다 쓴다. 화면별로 라벨을 다르게 쓰고 싶으면 화면관리에서 재정의.
|
||||
|
||||
### 2.2 시스템 자동 생성 컬럼 (테이블 생성 시 자동 부여, 사용자 편집 불가)
|
||||
```
|
||||
id BIGINT PRIMARY KEY -- 행 식별자
|
||||
company_code VARCHAR(40) -- 멀티테넌트 회사 코드
|
||||
writer VARCHAR(80) -- 작성자(사용자 ID)
|
||||
created_date TIMESTAMP DEFAULT NOW() -- 생성 일시
|
||||
updated_date TIMESTAMP -- 수정 일시 (트리거로 갱신)
|
||||
```
|
||||
- 위 5개 컬럼은 테이블 생성 시 자동 삽입.
|
||||
- 테이블 타입 관리 UI 에서 `🔒 잠금` 표시되고, 타입/PK/NN/IDX/UQ/코멘트 모두 수정 불가.
|
||||
- 화면관리에서 이 컬럼들을 화면에 표시할지 여부는 화면 작성자가 결정.
|
||||
|
||||
### 2.3 사용자 정의 컬럼
|
||||
- 위 메타 컬럼을 제외한 나머지는 **모두 사람이 직접 정의**.
|
||||
- 컬럼명, 코멘트(=기본 라벨), 타입, PK/NN/IDX/UQ 를 사람이 입력.
|
||||
|
||||
---
|
||||
|
||||
## 3. 컬럼 타입 (저장 단위)
|
||||
|
||||
테이블 타입 관리에서 선택 가능한 **저장 타입**은 다음 8개로 제한한다.
|
||||
|
||||
| 그룹 | 타입 | 코드값 | 설명 | DB 표현 (PostgreSQL) |
|
||||
|---|---|---|---|---|
|
||||
| **기본** | 텍스트 | `text` | 일반 문자열 | `VARCHAR(N)` 또는 `TEXT` |
|
||||
| | 숫자 | `number` | 정수·소수 | `NUMERIC` / `INTEGER` |
|
||||
| | 날짜 | `date` | 날짜/시간 | `TIMESTAMP` 또는 `DATE` |
|
||||
| **참조** | 코드 | `code` | 공통코드(`comm_code`) 자식 코드 참조 | `VARCHAR` FK to `comm_code.code_id` |
|
||||
| | 테이블 참조 | `entity` | 다른 테이블의 행 참조 | `BIGINT` FK to `<target>.id` |
|
||||
| **자동** | 채번 | `numbering` | 시퀀스 기반 자동 채번 | `VARCHAR` (예: `INSP-2026-0001`) |
|
||||
| **첨부** | 파일 | `file` | 파일 업로드 | `VARCHAR` (파일 메타 OBJID) |
|
||||
| | 이미지 | `image` | 이미지 업로드 | `VARCHAR` (파일 메타 OBJID) |
|
||||
|
||||
### 제외된 타입과 그 이유
|
||||
|
||||
| 제외된 타입 | 이유 | 화면관리에서 어떻게 다루나 |
|
||||
|---|---|---|
|
||||
| 카테고리 | 공통코드(`code`) 와 동작이 동일 — 중복 개념 | `code` 타입으로 통합 |
|
||||
| 체크박스 / 라디오 / 셀렉트 | **표시 UI** 변형. 저장 타입과는 무관 | `code` 또는 `entity` 타입 컬럼에 대해 화면관리에서 컴포넌트 선택 |
|
||||
| 여러 줄 (textarea) | **표시 UI** 변형. 저장은 동일하게 `text` | `text` 타입 컬럼에 대해 화면관리에서 `text input` vs `textarea` 선택 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 화면관리 매핑
|
||||
|
||||
저장 타입별로 화면관리에서 선택 가능한 UI 컴포넌트는 다음과 같다.
|
||||
|
||||
### 4.1 `text` (텍스트)
|
||||
- **단일 행 입력** (`<input type="text">`)
|
||||
- **여러 줄 입력** (`<textarea>`)
|
||||
- **자동 완성** (특정 컬럼 값 목록을 가져와 제안)
|
||||
- **마스킹 입력** (전화번호, 사업자번호 등)
|
||||
|
||||
### 4.2 `number` (숫자)
|
||||
- **숫자 입력** (`<input type="number">`)
|
||||
- **금액 입력** (천단위 콤마, 단위 접미사)
|
||||
- **퍼센트 입력** (0~100 슬라이더 또는 입력)
|
||||
- **스피너** (`+/-` 버튼)
|
||||
|
||||
### 4.3 `date` (날짜)
|
||||
- **달력 picker** (단일 날짜)
|
||||
- **날짜 범위 picker** (from ~ to)
|
||||
- **시간 포함 picker** (`datetime-local`)
|
||||
- **연/월 picker** (정밀도가 낮은 경우)
|
||||
|
||||
### 4.4 `code` (공통코드)
|
||||
> 화면관리에서 가장 풍부한 컴포넌트 선택지가 열리는 타입.
|
||||
- **셀렉트박스** (단일 선택, 옵션이 많을 때)
|
||||
- **체크박스 그룹** (다중 선택)
|
||||
- **라디오 그룹** (단일 선택, 옵션이 적을 때)
|
||||
- **autocomplete** (옵션이 매우 많을 때)
|
||||
- **트리 셀렉트** (계층형 공통코드일 때 — 대/중/소분류)
|
||||
|
||||
### 4.5 `entity` (테이블 참조)
|
||||
- **셀렉트박스** (참조 테이블의 displayName 목록)
|
||||
- **모달 픽커** (검색·페이지네이션이 필요한 경우)
|
||||
- **autocomplete** (옵션이 많을 때)
|
||||
- **마스터 카드 미리보기** (선택 후 참조 행의 주요 필드를 보여주는 UI)
|
||||
|
||||
### 4.6 `numbering` (채번)
|
||||
- **읽기 전용 표시** (저장 시 자동 발번 — 사용자는 입력 불가)
|
||||
- 화면 표시 형식만 결정 (예: `INSP-{YYYY}-{0000}`)
|
||||
|
||||
### 4.7 `file` / `image` (첨부)
|
||||
- **드래그앤드롭 업로드**
|
||||
- **파일 리스트 + 추가/삭제 버튼**
|
||||
- **이미지 미리보기 + 갤러리**
|
||||
- **다중 업로드 vs 단일 업로드**
|
||||
|
||||
---
|
||||
|
||||
## 5. 데이터 흐름 (실제 작동 예시)
|
||||
|
||||
### 5.1 새 테이블 만들기
|
||||
1. 관리자가 **테이블 타입 관리** 에서 `inspection_equipment_mng` 생성
|
||||
2. 테이블 코멘트: `"검사 장비의 정보와 교정 주기를 관리하는 테이블"` (필수)
|
||||
3. 시스템이 자동으로 `id / company_code / writer / created_date / updated_date` 5컬럼 부여 (잠금)
|
||||
4. 관리자가 사용자 컬럼 추가:
|
||||
- `equipment_code` 타입=`text` 코멘트=`"장비 고유 코드(필수)"`
|
||||
- `equipment_name` 타입=`text` 코멘트=`"장비명"`
|
||||
- `calibration_period` 타입=`number` 코멘트=`"교정주기 (개월)"`
|
||||
- `last_calibration_date` 타입=`date` 코멘트=`"최근 교정일"`
|
||||
- `equipment_type` 타입=`code` 코멘트=`"장비유형"` + 코드 카테고리 선택
|
||||
- `manager_id` 타입=`entity` 코멘트=`"담당자"` + 참조 테이블=`user_info`
|
||||
5. 저장 시 DDL 생성:
|
||||
```sql
|
||||
CREATE TABLE inspection_equipment_mng (...);
|
||||
COMMENT ON TABLE inspection_equipment_mng IS '검사 장비의 정보와 교정 주기를 관리하는 테이블';
|
||||
COMMENT ON COLUMN inspection_equipment_mng.equipment_code IS '장비 고유 코드(필수)';
|
||||
...
|
||||
```
|
||||
|
||||
### 5.2 화면 만들기
|
||||
1. 개발자가 **화면관리** 에서 새 화면 작성, 대상 테이블 = `inspection_equipment_mng`
|
||||
2. 시스템이 컬럼 목록 + 각 컬럼의 코멘트(기본 라벨) + 타입을 가져옴
|
||||
3. 개발자가 각 컬럼별 UI 컴포넌트를 선택:
|
||||
- `equipment_code` (`text`) → **단일 행 입력**, 라벨="장비코드"(코멘트에서 가져온 기본값), 필수=Y
|
||||
- `equipment_name` (`text`) → **단일 행 입력**
|
||||
- `calibration_period` (`number`) → **숫자 입력**, 접미사="개월"
|
||||
- `last_calibration_date` (`date`) → **달력 picker**
|
||||
- `equipment_type` (`code`) → **라디오 그룹** (옵션이 4~5개라서)
|
||||
- `manager_id` (`entity`) → **autocomplete** (사용자 검색)
|
||||
4. 라벨은 코멘트가 디폴트로 채워지지만, 화면별로 다르게 쓰고 싶으면 화면관리에서 자유롭게 override
|
||||
5. 필수/읽기전용/기본값 등의 입력 제약도 화면관리에서 화면별로 지정
|
||||
|
||||
---
|
||||
|
||||
## 6. UI 정책 (테이블 타입 관리 화면)
|
||||
|
||||
### 6.1 우측 패널 — 컬럼 상세
|
||||
- **타입**: 셀렉트박스 (위 8개 중 1개)
|
||||
- **타입별 상세**:
|
||||
- `code` 선택 시 → 공통코드 카테고리 픽커 + 계층 역할(대분류/중분류/소분류)
|
||||
- `entity` 선택 시 → 참조 테이블 + 조인 컬럼(값)
|
||||
- `numbering` 선택 시 → 채번 규칙 안내(옵션설정 메뉴 안내)
|
||||
- 그 외 타입 → 별도 설정 없음
|
||||
- **코멘트**: Textarea (필수)
|
||||
- 시스템 컬럼이면 위 모든 입력 비활성화 + 잠금 안내 배너
|
||||
|
||||
### 6.2 우측 패널에서 **제거된 항목**
|
||||
- 표시 이름 (= 라벨) → 코멘트로 대체. 라벨은 화면관리에서.
|
||||
- 필수 입력 / 읽기 전용 → 화면관리에서.
|
||||
- 기본값 / 최대 길이 → 화면관리(또는 DDL 단계) 에서.
|
||||
|
||||
### 6.3 중앙 컬럼 그리드
|
||||
- 시스템 컬럼은 `🔒 자물쇠 아이콘` + 톤다운 + PK/NN/IDX/UQ 토글 비활성화
|
||||
- 일반 컬럼은 행 클릭으로 우측 패널 열림, PK/NN/IDX/UQ 클릭 토글, hover 시 ⋯ 액션 노출
|
||||
|
||||
---
|
||||
|
||||
## 7. 향후 확장 포인트
|
||||
|
||||
| 항목 | 비고 |
|
||||
|---|---|
|
||||
| 채번 규칙 관리 | `옵션설정 > 채번설정` 메뉴로 분리. 본 메뉴에서는 채번 규칙 ID 만 선택. |
|
||||
| 다국어 코멘트 | 코멘트를 i18n 키 + 다국어 테이블로 분리할지 검토. 현재는 단일 언어. |
|
||||
| 변경 이력 | 컬럼 타입/코멘트/제약 변경 이력을 별도 테이블에 적재. |
|
||||
| 컬럼 의존성 그래프 | `entity` 참조가 많아지면 테이블 간 ERD 자동 생성. |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -34,7 +34,7 @@ import { commonCodeApi } from "@/lib/api/commonCode";
|
||||
import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin";
|
||||
import { ddlApi } from "@/lib/api/ddl";
|
||||
import { getSecondLevelMenus, createColumnMapping, deleteColumnMappingsByColumn } from "@/lib/api/tableCategoryValue";
|
||||
import { saveNumberingRuleToTest } from "@/lib/api/numberingRule";
|
||||
import { saveNumberingRuleToTest, getNumberingRules } from "@/lib/api/numberingRule";
|
||||
import { CreateTableModal } from "@/components/admin/CreateTableModal";
|
||||
import { AddColumnModal } from "@/components/admin/AddColumnModal";
|
||||
import { DDLLogViewer } from "@/components/admin/DDLLogViewer";
|
||||
@@ -101,7 +101,14 @@ export default function TableManagementPage() {
|
||||
// 🆕 Category 타입용: 2레벨 메뉴 목록
|
||||
const [secondLevelMenus, setSecondLevelMenus] = useState<SecondLevelMenu[]>([]);
|
||||
|
||||
// 채번 타입은 옵션설정 > 채번설정에서 관리 (별도 선택 불필요)
|
||||
// 채번(numbering) 룰 목록 — 시스템관리 > 시퀀스 관리에서 등록된 룰
|
||||
const [numberingRules, setNumberingRules] = useState<any[]>([]);
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const r = await getNumberingRules();
|
||||
if (r.success) setNumberingRules(r.data ?? []);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// 로그 뷰어 상태
|
||||
const [logViewerOpen, setLogViewerOpen] = useState(false);
|
||||
@@ -746,15 +753,17 @@ export default function TableManagementPage() {
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// 1. 테이블 라벨 저장 (변경된 경우에만)
|
||||
// 1. 테이블 라벨 + 코멘트(description) 저장 — 라벨이 영문명과 다르거나 코멘트가 있으면 호출
|
||||
if (tableLabel !== selectedTable || tableDescription) {
|
||||
try {
|
||||
await apiClient.put(`/table-management/tables/${selectedTable}/label`, {
|
||||
displayName: tableLabel,
|
||||
description: tableDescription,
|
||||
});
|
||||
} catch (error) {
|
||||
// console.warn("테이블 라벨 저장 실패 (API 미구현 가능):", error);
|
||||
} catch (error: any) {
|
||||
// 무음 실패 금지 — 사용자가 왜 안 됐는지 즉시 알 수 있도록 토스트 노출
|
||||
const msg = error?.response?.data?.message || error?.message || "테이블 라벨/코멘트 저장 실패";
|
||||
toast.error(msg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -948,10 +957,12 @@ export default function TableManagementPage() {
|
||||
|
||||
// 필터링 + 한글 우선 정렬 (ㄱ~ㅎ → a~z)
|
||||
const filteredTables = useMemo(() => {
|
||||
const q = searchTerm.toLowerCase();
|
||||
const filtered = tables.filter(
|
||||
(table) =>
|
||||
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
table.displayName.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
table.tableName.toLowerCase().includes(q) ||
|
||||
(table.displayName ?? "").toLowerCase().includes(q) ||
|
||||
(table.description ?? "").toLowerCase().includes(q),
|
||||
);
|
||||
const isKorean = (str: string) => /^[가-힣ㄱ-ㅎ]/.test(str);
|
||||
return filtered.sort((a, b) => {
|
||||
@@ -1458,16 +1469,36 @@ export default function TableManagementPage() {
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
{/* 좌측 한 줄: 표시명 우선 → 없으면 코멘트 → 없으면 영문명.
|
||||
영문명은 어떤 경우든 회색 mono 로 함께 표기 (개발자 식별용). */}
|
||||
{(() => {
|
||||
const labelKo =
|
||||
(table.displayName && table.displayName !== table.tableName ? table.displayName : "") ||
|
||||
(table.description && table.description.trim().length > 0 ? table.description.trim() : "");
|
||||
const tooltip = [
|
||||
table.displayName,
|
||||
table.description,
|
||||
table.tableName,
|
||||
].filter(Boolean).join(" · ");
|
||||
return (
|
||||
<div className="min-w-0 flex-1 truncate" title={tooltip}>
|
||||
{labelKo ? (
|
||||
<>
|
||||
<span className={cn("truncate", isActive ? "font-semibold" : "font-medium")}>
|
||||
{labelKo}
|
||||
</span>
|
||||
<span className="ml-1 truncate font-mono text-[10px] font-normal text-muted-foreground/80">
|
||||
· {table.tableName}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className={cn("truncate font-mono", isActive ? "font-semibold" : "font-medium")}>
|
||||
{table.tableName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<span
|
||||
className={cn(
|
||||
"flex-shrink-0 font-mono text-[10px] tabular-nums",
|
||||
@@ -1512,18 +1543,20 @@ export default function TableManagementPage() {
|
||||
{selectedTable}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid min-w-0 flex-1 grid-cols-[160px_1fr] items-center gap-2">
|
||||
<div className="grid min-w-0 flex-1 grid-cols-[200px_1fr] items-center gap-2">
|
||||
<Input
|
||||
value={tableLabel}
|
||||
onChange={(e) => setTableLabel(e.target.value)}
|
||||
placeholder="표시명"
|
||||
placeholder="표시명 (예: 프로젝트 관리)"
|
||||
className="h-7 text-[11px]"
|
||||
title="좌측 목록과 화면관리에서 한글 라벨로 노출"
|
||||
/>
|
||||
<Input
|
||||
value={tableDescription}
|
||||
onChange={(e) => setTableDescription(e.target.value)}
|
||||
placeholder="설명"
|
||||
placeholder="코멘트 (DB COMMENT — 표시명 비우면 좌측 목록에 대체 표시)"
|
||||
className="h-7 text-[11px]"
|
||||
title="PostgreSQL COMMENT ON TABLE 과 동기화. 화면관리에서 라벨 기본값으로 활용."
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
@@ -1605,7 +1638,31 @@ export default function TableManagementPage() {
|
||||
tables={tables}
|
||||
referenceTableColumns={referenceTableColumns}
|
||||
secondLevelMenus={secondLevelMenus}
|
||||
numberingRules={[]}
|
||||
numberingRules={numberingRules}
|
||||
currentTableName={selectedTable ?? undefined}
|
||||
currentTableColumns={columns}
|
||||
onAddMissingColumns={async (missing) => {
|
||||
if (!selectedTable || !missing.length) return;
|
||||
if (!confirm(`이 테이블에 다음 컬럼을 추가합니다:\n\n${missing.join(", ")}\n\n진행할까요?`)) return;
|
||||
try {
|
||||
// 누락된 각 컬럼을 기본 텍스트 타입으로 추가 (사용자가 추후 타입/코멘트 보강)
|
||||
for (const colName of missing) {
|
||||
await apiClient.post(`/table-management/tables/${selectedTable}/columns`, {
|
||||
columnName: colName,
|
||||
displayName: colName,
|
||||
inputType: "text",
|
||||
dataType: "varchar",
|
||||
isNullable: "YES",
|
||||
isPrimaryKey: false,
|
||||
description: `채번 룰 의존 컬럼 (자동 추가)`,
|
||||
});
|
||||
}
|
||||
await loadColumnTypes(selectedTable, currentPage, pageSize);
|
||||
toast.success(`${missing.length}개 컬럼이 추가되었습니다.`);
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.message || e?.message || "컬럼 추가 실패");
|
||||
}
|
||||
}}
|
||||
onColumnChange={(field, value) => {
|
||||
if (!selectedColumn) return;
|
||||
if (field === "inputType") {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { X, Settings2, MessageSquareText, Lock } from "lucide-react";
|
||||
import { X, Settings2, MessageSquareText, Lock, AlertTriangle, CheckCircle2 } from "lucide-react";
|
||||
import { checkRuleDependencies } from "@/lib/utils/numberingRuleDeps";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
@@ -29,6 +30,11 @@ export interface ColumnDetailPanelProps {
|
||||
referenceTableColumns: Record<string, ReferenceTableColumn[]>;
|
||||
secondLevelMenus: SecondLevelMenu[];
|
||||
numberingRules: any[];
|
||||
/** 지금 편집 중인 테이블의 컬럼 목록 — 채번 룰 의존성 검증에 사용 */
|
||||
currentTableName?: string;
|
||||
currentTableColumns?: Array<{ columnName: string }>;
|
||||
/** 누락 컬럼 자동 추가 콜백 — 테이블 타입 관리 페이지에서 컬럼 일괄 INSERT */
|
||||
onAddMissingColumns?: (columnNames: string[]) => void;
|
||||
onColumnChange: (field: keyof ColumnTypeInfo, value: unknown) => void;
|
||||
onClose: () => void;
|
||||
onLoadReferenceColumns?: (tableName: string) => void;
|
||||
@@ -43,6 +49,9 @@ export function ColumnDetailPanel({
|
||||
tables,
|
||||
referenceTableColumns,
|
||||
numberingRules,
|
||||
currentTableName,
|
||||
currentTableColumns = [],
|
||||
onAddMissingColumns,
|
||||
onColumnChange,
|
||||
onClose,
|
||||
onLoadReferenceColumns,
|
||||
@@ -395,36 +404,108 @@ export function ColumnDetailPanel({
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!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" />
|
||||
<Label className="text-sm font-medium">카테고리 참조</Label>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">참조 (테이블.컬럼)</Label>
|
||||
<Input
|
||||
value={column.categoryRef ?? ""}
|
||||
onChange={(e) => onColumnChange("categoryRef", e.target.value || null)}
|
||||
placeholder="테이블명.컬럼명"
|
||||
className="h-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{/* 카테고리 타입은 코드와 동작이 동일해 별도 입력 폼을 두지 않음.
|
||||
기존 데이터(inputType='category')는 그대로 보존되며, 코드 카테고리 픽커는 코드 타입을 통해 제공. */}
|
||||
|
||||
{!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" />
|
||||
<Label className="text-sm font-medium">채번 규칙</Label>
|
||||
</div>
|
||||
<p className="rounded-md border border-border bg-muted/50 px-3 py-2 text-xs text-muted-foreground">
|
||||
채번 규칙은 옵션설정 > 채번설정에서 관리합니다.
|
||||
타입을 저장하면 자동으로 채번 목록에 표시됩니다.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
{!isSystem && column.inputType === "numbering" && (() => {
|
||||
// 선택된 룰
|
||||
const selectedRule = (numberingRules ?? []).find((r: any) => r.ruleId === column.numberingRuleId) ?? null;
|
||||
// 의존성 분석 — 룰이 요구하는 폼 키 vs 현재 테이블 컬럼
|
||||
const depCheck = checkRuleDependencies(
|
||||
selectedRule,
|
||||
currentTableName ?? "",
|
||||
currentTableColumns,
|
||||
);
|
||||
return (
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings2 className="h-4 w-4 text-muted-foreground" />
|
||||
<Label className="text-sm font-medium">채번 규칙</Label>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">사용할 룰</Label>
|
||||
<Select
|
||||
value={column.numberingRuleId ?? "none"}
|
||||
onValueChange={(v) => onColumnChange("numberingRuleId", v === "none" ? undefined : v)}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue placeholder="룰 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택 안함</SelectItem>
|
||||
{(numberingRules ?? []).map((r: any) => (
|
||||
<SelectItem key={r.ruleId} value={r.ruleId}>
|
||||
<span className="truncate font-medium">{r.ruleName ?? r.ruleId}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[10px] leading-snug text-muted-foreground">
|
||||
룰이 없거나 새 룰이 필요하면 <span className="font-medium">시스템 관리 → 시퀀스 관리</span> 에서 등록.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 의존성 검증 결과 — 룰을 골랐고 요구 컬럼이 1개 이상일 때만 노출 */}
|
||||
{selectedRule && depCheck.required.length > 0 && (
|
||||
<div className={cn(
|
||||
"rounded-md border px-3 py-2 text-[11px]",
|
||||
depCheck.missing.length === 0
|
||||
? "border-emerald-200 bg-emerald-50 dark:border-emerald-900/40 dark:bg-emerald-950/20"
|
||||
: "border-amber-300 bg-amber-50 dark:border-amber-900/40 dark:bg-amber-950/20",
|
||||
)}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{depCheck.missing.length === 0 ? (
|
||||
<>
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
|
||||
<span className="font-semibold text-emerald-700 dark:text-emerald-300">
|
||||
이 룰을 사용할 준비 완료
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-amber-600 dark:text-amber-400" />
|
||||
<span className="font-semibold text-amber-700 dark:text-amber-300">
|
||||
이 룰을 쓰려면 {depCheck.missing.length}개 컬럼이 더 필요해요
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 요구 컬럼 목록 */}
|
||||
<div className="mt-1.5 space-y-0.5">
|
||||
{depCheck.required.map((col) => {
|
||||
const ok = depCheck.satisfied.includes(col);
|
||||
return (
|
||||
<div key={col} className="flex items-center gap-1.5 font-mono text-[10px]">
|
||||
{ok ? (
|
||||
<CheckCircle2 className="h-2.5 w-2.5 flex-shrink-0 text-emerald-600 dark:text-emerald-400" />
|
||||
) : (
|
||||
<span className="inline-block h-2.5 w-2.5 flex-shrink-0 rounded-full border border-amber-400 bg-amber-100 dark:bg-amber-900/40" />
|
||||
)}
|
||||
<span className={cn(ok ? "text-emerald-700 dark:text-emerald-300" : "text-amber-700 dark:text-amber-300")}>
|
||||
{col}
|
||||
</span>
|
||||
{!ok && <span className="text-[9px] text-amber-600/80 dark:text-amber-400/70">(누락)</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 누락 자동 추가 버튼 */}
|
||||
{depCheck.missing.length > 0 && onAddMissingColumns && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAddMissingColumns(depCheck.missing)}
|
||||
className="mt-2 w-full rounded border border-amber-300 bg-white px-2 py-1 text-[10px] font-medium text-amber-700 transition-colors hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-300 dark:hover:bg-amber-900/40"
|
||||
>
|
||||
누락된 {depCheck.missing.length}개 컬럼을 이 테이블에 추가
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* [섹션 3] 코멘트 — PostgreSQL 컬럼 COMMENT 와 동기화.
|
||||
화면관리에서 이 값을 기본 라벨로 가져다 쓰고, 거기서 화면별로 재정의 가능. */}
|
||||
|
||||
@@ -90,11 +90,13 @@ export function getColumnGroup(col: ColumnTypeInfo): ColumnGroup {
|
||||
return "basic";
|
||||
}
|
||||
|
||||
/** 타입 선택 셀렉트박스용 그룹 정의 (저장계층 타입 위주, UI 표시 변형은 화면관리에서) */
|
||||
/** 타입 선택 셀렉트박스용 그룹 정의
|
||||
* - 저장 계층(스토리지 타입) 만 노출. UI 표시 변형(textarea/select/radio/checkbox) 은 화면관리에서.
|
||||
* - 카테고리는 코드와 동작이 동일해 별도 타입으로 두지 않음 (코드로 통합).
|
||||
*/
|
||||
export const INPUT_TYPE_GROUPS: Array<{ groupLabel: string; types: string[] }> = [
|
||||
{ groupLabel: "기본", types: ["text", "number", "date", "checkbox"] },
|
||||
{ groupLabel: "참조", types: ["code", "entity", "category"] },
|
||||
{ groupLabel: "기본", types: ["text", "number", "date"] },
|
||||
{ groupLabel: "참조", types: ["code", "entity"] },
|
||||
{ groupLabel: "자동", types: ["numbering"] },
|
||||
{ groupLabel: "첨부", types: ["file", "image"] },
|
||||
{ groupLabel: "표시 변형", types: ["textarea", "select", "radio"] },
|
||||
];
|
||||
|
||||
@@ -81,6 +81,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||
// 시스템 관리
|
||||
"/admin/systemMng/commonCodeList": dynamic(() => import("@/app/(main)/admin/systemMng/commonCodeList/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/systemMng/tableMngList": dynamic(() => import("@/app/(main)/admin/systemMng/tableMngList/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/systemMng/sequenceMng": dynamic(() => import("@/app/(main)/admin/systemMng/sequenceMng/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/systemMng/i18nList": dynamic(() => import("@/app/(main)/admin/systemMng/i18nList/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/systemMng/collection-managementList": dynamic(() => import("@/app/(main)/admin/systemMng/collection-managementList/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/systemMng/cascading-managementList": dynamic(() => import("@/app/(main)/admin/cascading-management/page"), { ssr: false, loading: LoadingFallback }),
|
||||
@@ -194,6 +195,7 @@ const DYNAMIC_ADMIN_IMPORTS: Record<string, () => Promise<any>> = {
|
||||
"/admin/screenMng/barcodeList": () => import("@/app/(main)/admin/screenMng/barcodeList/page"),
|
||||
"/admin/automaticMng/batchmngList/create": () => import("@/app/(main)/admin/automaticMng/batchmngList/create/page"),
|
||||
"/admin/systemMng/dataflow/node-editorList": () => import("@/app/(main)/admin/systemMng/dataflow/page"),
|
||||
"/admin/systemMng/sequenceMng": () => import("@/app/(main)/admin/systemMng/sequenceMng/page"),
|
||||
"/admin/standards/new": () => import("@/app/(main)/admin/standards/new/page"),
|
||||
|
||||
// === 회사별 커스텀 페이지 (resolvedUrl로 매칭) ===
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 채번 룰 의존성 분석 유틸
|
||||
* - 룰이 동작하려면 적용 테이블에 어떤 폼 키 컬럼들이 있어야 하는지 추출
|
||||
* - 테이블 타입 관리에서 룰 선택 시 검증에 사용
|
||||
*
|
||||
* 의존이 생기는 케이스:
|
||||
* 1) reference 파트 (mode=master) : 폼키(referenceColumnName) 가 적용 테이블의 컬럼이어야 한다
|
||||
* 2) reference 파트 (mode=form) : 폼키(referenceColumnName) 가 적용 테이블의 컬럼이어야 한다
|
||||
* 3) category 파트 : categoryKey="table.col" 일 때, table 이 적용 테이블이면 col 이 의존
|
||||
* (table 이 다른 테이블이면 그 테이블 마스터 조회 — 의존 아님)
|
||||
*
|
||||
* 의존이 아닌 케이스:
|
||||
* - sequence / date / number / text / reference(constant)
|
||||
*/
|
||||
|
||||
export interface NumberingRulePartLite {
|
||||
partType: string;
|
||||
autoConfig?: Record<string, any>;
|
||||
}
|
||||
export interface NumberingRuleLite {
|
||||
ruleId?: string;
|
||||
ruleName?: string;
|
||||
parts?: NumberingRulePartLite[];
|
||||
}
|
||||
|
||||
/** 룰에서 적용 테이블에 있어야 하는 폼 키(컬럼명) 목록을 추출 */
|
||||
export function extractRequiredFormKeys(
|
||||
rule: NumberingRuleLite | null | undefined,
|
||||
tableName?: string,
|
||||
): string[] {
|
||||
if (!rule?.parts) return [];
|
||||
const keys: string[] = [];
|
||||
for (const p of rule.parts) {
|
||||
const c = (p.autoConfig ?? {}) as Record<string, any>;
|
||||
if (p.partType === "reference") {
|
||||
const mode = c.mode ?? (c.sourceTableName ? "master" : c.constantValue ? "constant" : "form");
|
||||
// master/form 모두 폼키(referenceColumnName) 필요
|
||||
if ((mode === "master" || mode === "form") && c.referenceColumnName) {
|
||||
keys.push(String(c.referenceColumnName));
|
||||
}
|
||||
} else if (p.partType === "category") {
|
||||
// categoryKey="table.col" → table 이 적용 테이블이면 col 이 의존
|
||||
const key: string = c.categoryKey ?? "";
|
||||
if (typeof key === "string" && key.includes(".")) {
|
||||
const [tbl, col] = key.split(".");
|
||||
if (tableName && tbl && col && tbl === tableName) {
|
||||
keys.push(col);
|
||||
}
|
||||
// 다른 테이블이면 마스터 조회 — 적용 테이블 의존은 아님
|
||||
} else if (typeof key === "string" && key && tableName) {
|
||||
// table 명시 안 됨 → 적용 테이블의 컬럼명으로 간주
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 중복 제거 + 의미있는 값만
|
||||
return Array.from(new Set(keys.filter(Boolean)));
|
||||
}
|
||||
|
||||
export interface DependencyCheckResult {
|
||||
required: string[];
|
||||
satisfied: string[];
|
||||
missing: string[];
|
||||
}
|
||||
|
||||
/** 룰의 요구 컬럼 vs 실제 테이블 컬럼 → 충족/누락 분석 */
|
||||
export function checkRuleDependencies(
|
||||
rule: NumberingRuleLite | null | undefined,
|
||||
tableName: string,
|
||||
tableColumns: Array<{ columnName: string }>,
|
||||
): DependencyCheckResult {
|
||||
const required = extractRequiredFormKeys(rule, tableName);
|
||||
const colSet = new Set(tableColumns.map((c) => c.columnName.toLowerCase()));
|
||||
const satisfied: string[] = [];
|
||||
const missing: string[] = [];
|
||||
for (const k of required) {
|
||||
if (colSet.has(k.toLowerCase())) satisfied.push(k);
|
||||
else missing.push(k);
|
||||
}
|
||||
return { required, satisfied, missing };
|
||||
}
|
||||
Reference in New Issue
Block a user