시퀀스 관리 메뉴 + 테이블 타입관리 코멘트/검증 + 설계 문서
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:
chpark
2026-05-12 11:45:49 +09:00
parent b4ed5f3a2a
commit 945b65b870
9 changed files with 1694 additions and 55 deletions
+8
View File
@@ -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)}`);
}
}
+185
View File
@@ -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">
&gt; .
.
</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로 매칭) ===
+81
View File
@@ -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 };
}