14 Commits

Author SHA1 Message Date
johngreen 33f0647c61 fix(테이블관리): 심각 5건 일괄 수정 (PR-B)
1. setTimeout 1초 reload 제거 — saveAllSettings / handleSaveColumn 의
   setTimeout(loadColumnTypes, 1000) 두 곳을 await 직접 호출로. 1초 깜빡임
   + race 해소.

2. typeFilter 자동 해제 시 input_type 변경 — 도넛 차트 필터가 켜진 상태에서
   상세 패널로 input_type 을 다른 타입으로 바꾸면 그 행이 그리드에서 갑자기
   사라지는 혼란. 변경된 타입이 필터와 다르면 자동으로 필터 해제.

3. composite PK 다이얼로그 — 매 PK 토글마다 확인 다이얼로그 뜨던 답답함.
   "이번 세션 다시 묻지 않기" 체크박스 추가. 체크 시 applyPkChange 즉시 호출.

4. 카테고리 매핑 병렬화 — for-of await 직렬 호출을 Promise.allSettled 로
   변경. 메뉴 수십 개 × 컬럼 수 = 수십 초 멈춤 → 한 번의 병렬 호출.

5. 좁은 화면 상세 패널 stuck 해소 — 패널이 열린 상태에서 column=null 이 되면
   X 버튼이 사라져 닫을 수단이 없던 문제. 빈 상태 UI 에도 X 버튼 유지 +
   ESC 키 핸들러 추가.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:50:12 +09:00
johngreen 24106929fa fix(테이블관리): 블로커 5건 (PR-A) (#28)
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m26s
PR-A: 블로커 5건 일괄 수정
2026-05-22 05:44:32 +00:00
johngreen f530b3cf31 fix(테이블관리): 블로커 5건 일괄 수정 (PR-A)
종합 감사 결과 발견된 블로커 5건:

1. AddColumnModal / CreateTableModal prop 미스매치 — 호출부 tableName/sourceTableName(camelCase)
   vs 인터페이스 table_name/source_table_name(snake_case). 결과적으로 컬럼 추가 기능이 실제로
   동작하지 않았음 (/api/ddl/tables/undefined/columns). 호출부를 인터페이스에 맞춰 통일.

2. IS_NULLABLE 강제 'Y' 덮어쓰기 (backend mapper) — upsertColumnSettings 의 VALUES 절이
   모든 케이스에서 'Y' 리터럴을 박음. NN 토글 후 "컬럼 설정 저장" 누르면 필수 입력 제약이
   조용히 사라짐. COALESCE(#{is_nullable}, 'Y') 로 변경 + ON CONFLICT 절에 IS_NULLABLE
   COALESCE 추가. Service 도 settings 에서 is_nullable 을 읽어 'Y'/'N' 으로 정규화 후 전달.

3. 저장 안 한 편집 침묵 손실 — 우측 패널에서 편집 후 저장 안 누르고 다른 테이블로 이동 시
   변경 사항 소실. hasUnsavedChanges memo 추가 (columns vs originalColumns JSON 비교).
   handleTableSelect 에서 dirty 면 confirm 다이얼로그.

4. PK / NN / IDX / UQ 위치별 비대칭 저장 — 그리드 행 칩은 즉시 저장하는데 우측 상세 패널
   토글은 메모리만 변경하고 저장 버튼 필요. 두 위치 모두 즉시 저장으로 통일 (상세 패널의
   onColumnChange 가 is_nullable / is_unique 필드를 받으면 handleNullableToggle /
   handleUniqueToggle 호출하도록).

5. SUPER_ADMIN role 검증 누락 (backend 보안) — DdlController 의 isSuperAdmin 이 company_code
   == "*" 만 보고 role 클레임 무시. 토큰 변조 또는 "*" 회사 소속만으로 운영 DB DROP / ALTER
   가능. company_code "*" AND role "SUPER_ADMIN" 둘 다 충족 시에만 통과로 변경. 11개 엔드포인트
   에 @RequestAttribute("role") 추가.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:43:15 +09:00
johngreen 99487049fb style(테이블타입): 탭 스타일 Google 식 underline (#27)
Build & Deploy to K8s / build-and-deploy (push) Successful in 6m22s
johngreen → main: 탭 스타일 Google 식 underline 으로 변경
2026-05-22 05:04:26 +00:00
johngreen 6233877029 style(테이블타입): 탭 스타일 Google 식 underline 으로 변경 — 가시성 ↑
기존 shadcn 기본 Tabs 가 둥근 알약 (segmented control) 스타일이라 탭이 아니라 토글처럼
보임. 사용자가 "탭이 눈에 잘 안 띈다" 지적.

Gmail/Drive/GitHub/Vercel 공통 패턴인 underline 스타일로 변경:
- TabsList: 전체 폭 + 하단 1px 구분선, 배경/padding 제거
- TabsTrigger: 평면 + 활성 시 2px primary 밑줄 + 글자 색 강조, 비활성은 muted
- 글자 / 아이콘 크기 한 단계 ↑ (text-xs→text-sm, h-3.5→h-4)
- 비활성에도 transparent border-b-2 줘서 layout shift 방지

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:03:52 +09:00
johngreen 4031fe8b60 chore: retrigger CI (gradle plugin download timed out)
Build & Deploy to K8s / build-and-deploy (push) Successful in 13m24s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:21:01 +09:00
johngreen a5288647c9 feat(테이블타입): 와이드 모니터 우측 패널 고정 표시 (#26)
Build & Deploy to K8s / build-and-deploy (push) Failing after 5m9s
johngreen → main: 우측 상세 패널 와이드 모니터에서 고정 표시 + 빈 상태 안내
2026-05-22 04:09:46 +00:00
johngreen 7dbeccc182 feat(테이블타입): 와이드 모니터에서 우측 상세 패널 고정 표시 — 슬라이드 in 제거
기존엔 컬럼 행 클릭해야 우측 패널이 슬라이드로 들어옴. 와이드 모니터에선 우측에 큰 빈 공간이
남는데도 패널이 숨어있어 화면이 흔들려 보임 → 일관성 ↓

와이드(xl 이상)에선 패널을 일반 flex child 로 고정해서 항상 표시. 좁은 화면은 기존
오버레이 슬라이드 유지 (가운데 영역 좁아지는 것 방지).

- page.tsx: 우측 패널 컨테이너에 xl: 상태로 relative + 슬라이드 무력화
- ColumnDetailPanel: 컬럼 선택 안 한 상태일 때 "컬럼을 선택해주세요" 빈 상태 안내 UI 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:09:08 +09:00
johngreen c857e4f715 fix(테이블타입): 컬럼 탭 그룹 정리 (#25)
Build & Deploy to K8s / build-and-deploy (push) Successful in 6m33s
johngreen → main: 컬럼 탭 그룹 헤더 사용자/시스템 2그룹으로 정리
2026-05-22 03:32:37 +00:00
johngreen cf5f7ef9af fix(테이블타입): 컬럼 탭 그룹 헤더 사용자/시스템 2그룹으로 정리 — 참조 탭과 중복 해소
PR #24 에서 컬럼/참조 탭은 분리했지만 ColumnGrid 의 기존 3그룹 (기본/참조/메타) 헤더가
그대로 남아있어 "참조 정보" 그룹이 컬럼 탭에서도 보이고 참조 탭에서도 보이는 시각적 중복
발생.

DBeaver 의 Columns 탭처럼 모든 컬럼은 컬럼 탭에서 보여야 함 (참조 컬럼 포함). 단,
"참조 정보" 그룹 헤더는 참조 탭이 같은 역할을 하므로 제거.

- "기본 정보" + "참조 정보" → "사용자 컬럼" 으로 합침
- "메타 정보" → "시스템 컬럼" 으로 이름만 평이하게
- 참조 컬럼은 평범한 한 행으로 표시 (타입 칩으로 종류는 식별)
- 미사용 Layers 아이콘 import 제거

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:31:59 +09:00
johngreen 7e71730015 feat(테이블타입): DBeaver 식 탭 분리 + 입력 타입 아이콘 lucide 통일 (#24)
Build & Deploy to K8s / build-and-deploy (push) Successful in 11m4s
johngreen → main: 컬럼 그리드 탭 분리 + 아이콘 통일 + CLAUDE.md 컨벤션
2026-05-21 10:22:54 +00:00
johngreen 2d39d17428 feat(테이블타입): 컬럼 그리드 DBeaver 식 탭 분리 — 컬럼 / 참조
ColumnGrid 의 "참조/설정" 컬럼이 두 가지 다른 역할 (entity/code/numbering 의 참조 대상 표시
vs 그 외 타입의 default_value 표시) 을 한 셀에 욱여넣고 있었음. text/number/date 행에선
대부분 — 빈 칸. 진단 결과는 architect 가 동의한 대로 컬럼 통째 제거가 답.

DBeaver 의 Columns / Foreign Keys 탭 분리 패턴 차용:
- 컬럼 탭 (기본 활성) — 컬럼 본연의 속성만 (라벨 / 타입 / PK NN IDX UQ / ⋯). 참조/설정 컬럼
  통째 제거, grid 6→5 컬럼으로 슬림화. 라벨 셀 폭 1fr 로 확보
- 참조 탭 — entity / code / category / numbering 컬럼만 모아 표 형태로. 종류별 그룹 헤더
  + (소스 컬럼 / 참조 종류 / 참조 대상) 3컬럼 그리드. code 행에서 누락되어있던 코드 그룹
  표시도 같이 채움

ReferenceListView 신규 컴포넌트로 분리. 사용자가 행 클릭하면 우측 상세 패널 슬라이드 in
하는 기존 동작은 양 탭 모두 동일.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:21:55 +09:00
johngreen 30ebb14023 fix(테이블타입): 입력 타입 아이콘 lucide 로 통일 — letter/symbol/emoji 혼재 정리
기존엔 iconChar 문자열에 'T'(글자) / '#'(기호) / '{}'(코드) / '📎'(emoji) 식으로 일관성 없게
표시. 카드 그리드에서 시각 노이즈 큼.

- TypeColorConfig.iconChar(string) → Icon(LucideIcon) 으로 교체
- 13개 입력 타입 전부 lucide 아이콘 매핑 (Type/Hash/Calendar/Braces/Link2 등)
- FALLBACK_TYPE_CONFIG export 해서 TypeOverviewStrip 의 legacy/unknown 도 같은 인터페이스 따름

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:21:30 +09:00
johngreen 895cb48ee0 docs(claude): UI/구조 변경 제안 시 ASCII Before/After 그림 의무화
기존 컨벤션에 § 💬 추가. 글로만 설명하면 사용자가 이해 못 함 — 변경 전/후 두 그림을 무조건
그려서 보여줘야 함. 코드 인용 (file:line, CSS class) 최소화하고 영문/SQL/전문용어 풀어쓰기.
3줄 패턴 (무슨 일 / 사용자 영향 / 어떻게 고치는지) 권장.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:21:13 +09:00
10 changed files with 633 additions and 228 deletions
+79
View File
@@ -1,3 +1,82 @@
<!-- User customizations -->
# 절대 규칙: 검증 없는 주장 금지
내가 출력하는 모든 발언은 근거가 있어야 한다. 근거가 없으면 그 말을 하지 않는다. 위로·추정·일반론·"보통 그렇다"로 채우지 않는다.
## 위반 사례 (절대 하지 말 것)
- "100명 중 5명도 안 된다" 같은 통계를 출처 없이 만들어내기
- "통과 확률 70~80%" 같은 수치를 추정으로 제시하기
- "보통", "일반적으로", "대부분" 으로 시작하는 일반론
- 본인이 검증 안 한 SDK/API 동작을 단정적으로 설명하기
- 위로·격려를 위해 사실이 아닌 것을 끼워넣기
## 발화 전 자기 검증
한 문장이라도 출력하기 전에 다음을 확인:
1. **출처가 있는가?** — 코드(파일:라인), 명령 결과, 공식 문서, 사용자가 준 정보, 도구 호출 결과 중 하나
2. **출처가 없다면 추정인가?** — 추정이면 명시적으로 "추정이지만…" 또는 "확인 안 됐지만…" 으로 시작
3. **추정도 근거가 없으면?** — 말하지 않는다. "모릅니다" 또는 "확인이 필요합니다" 라고 한다
## 모를 때의 정답
- 검색·문서 조회·코드 읽기로 확인 가능하면 확인부터 한다
- 확인이 불가능하면 "모릅니다" 가 정답. 그럴듯한 답을 만들지 않는다
- 사용자 의사결정에 영향을 주는 사실일수록 더 엄격하게 적용
## 어겼을 때
사용자가 "그 근거 뭐야" 라고 묻거나 잘못된 사실을 지적하면:
- 즉시 인정. "맞습니다. 그 수치 제가 지어냈습니다." 같이 명시적으로 시인
- 변명·재포장 금지
- 무엇이 검증된 사실이고 무엇이 추정/날조였는지 다시 분리해서 제시
# 💬 사용자에게 설명할 때 — 그림으로 (★ 중요)
UI 변경 제안, 디자인 토론, 코드 구조 설명 등을 할 때는 **반드시 변경 전/후를 ASCII 표나 도식으로 그려서** 보여준다. 글로만 설명하면 사용자가 이해 못 한다.
## 원칙
1. **변경 제안은 무조건 Before / After 두 그림**
2. **코드 인용 (file:line, 변수명, CSS class) 최소화** — 결론과 시각적 영향 위주
3. **평어, 한국어, 짧은 문장**
4. **영문/SQL/전문용어 풀어쓰기** — "grid template" 대신 "표 컬럼 배치", "stopPropagation" 대신 "클릭이 위로 새는 거 막기"
5. **3줄 패턴 권장** — 무슨 일 / 사용자한테 보이는 영향 / 어떻게 고치는지
## 나쁜 예시 ❌
> "ColumnGrid.tsx:93-103 의 `grid-cols-[4px_140px_1fr_100px_160px_40px]` 를 5컬럼으로 축소하고, 라벨 셀에 sub-line 을 추가하면 entity/code/numbering 의 메타가 inline 으로..."
(사용자: "뭐라는지 모르겠어")
## 좋은 예시 ⭕
> **지금 모양:**
> ```
> 라벨·컬럼명 │ 참조/설정 │ 타입
> 거래처명 │ — │ 텍스트 ← 빈 칸
> 거래처ID │ customer_mng → ... │ 테이블참조
> ```
>
> **바꿔서:**
> ```
> 라벨·컬럼명 │ 타입
> 거래처명 │ 텍스트
> 거래처ID │ 테이블참조
> → customer_mng.id ← 정보 있을 때만 작게 밑에
> ```
## 옵션 제시할 땐 표로
```
| 옵션 | 핵심 | 단점 |
| A안 | 이름만 바꾸기 | 가장 가벼움 |
| B안 | 그룹을 잘게 쪼개기 | 그룹 수 늘어남 |
```
## 우선 순위
- 첫 시도에 글만 쓰지 말 것. 그림부터 그리고 글은 짧게 보충.
- 사용자가 "무슨 말인지 모르겠어" 하면 → 더 분해해서 다시 그림 그리기. 글 길어지면 더 헷갈림.
---
# INVYONE — Claude 작업 컨벤션
이 파일은 git 에 올라가는 **프로젝트 공용** Claude 가이드입니다. 모든 머신/팀원의 Claude Code 인스턴스가 이 컨벤션을 따라야 합니다.
@@ -29,10 +29,11 @@ public class DdlController {
@PostMapping("/tables")
public ResponseEntity<ApiResponse<?>> createTable(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "role", required = false) String role,
@RequestAttribute("user_id") String userId,
@RequestBody Map<String, Object> body) {
if (!isSuperAdmin(companyCode)) {
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
}
@@ -65,10 +66,11 @@ public class DdlController {
public ResponseEntity<ApiResponse<?>> addColumn(
@PathVariable String tableName,
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "role", required = false) String role,
@RequestAttribute("user_id") String userId,
@RequestBody Map<String, Object> body) {
if (!isSuperAdmin(companyCode)) {
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
}
@@ -99,9 +101,10 @@ public class DdlController {
@PathVariable String tableName,
@PathVariable String columnName,
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "role", required = false) String role,
@RequestAttribute("user_id") String userId) {
if (!isSuperAdmin(companyCode)) {
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
}
@@ -124,9 +127,10 @@ public class DdlController {
public ResponseEntity<ApiResponse<?>> dropTable(
@PathVariable String tableName,
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "role", required = false) String role,
@RequestAttribute("user_id") String userId) {
if (!isSuperAdmin(companyCode)) {
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
}
@@ -147,9 +151,10 @@ public class DdlController {
@PostMapping("/validate/table")
public ResponseEntity<ApiResponse<?>> validateTableCreation(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "role", required = false) String role,
@RequestBody Map<String, Object> body) {
if (!isSuperAdmin(companyCode)) {
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
}
@@ -176,11 +181,12 @@ public class DdlController {
@GetMapping("/logs")
public ResponseEntity<ApiResponse<?>> getDdlLogs(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "role", required = false) String role,
@RequestParam(required = false, defaultValue = "50") int limit,
@RequestParam(required = false) String userId,
@RequestParam(required = false) String ddlType) {
if (!isSuperAdmin(companyCode)) {
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
}
@@ -195,10 +201,11 @@ public class DdlController {
@GetMapping("/statistics")
public ResponseEntity<ApiResponse<?>> getDdlStatistics(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "role", required = false) String role,
@RequestParam(required = false) String fromDate,
@RequestParam(required = false) String toDate) {
if (!isSuperAdmin(companyCode)) {
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
}
@@ -212,9 +219,10 @@ public class DdlController {
@GetMapping("/tables/{tableName}/history")
public ResponseEntity<ApiResponse<?>> getTableDdlHistory(
@PathVariable String tableName,
@RequestAttribute("company_code") String companyCode) {
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "role", required = false) String role) {
if (!isSuperAdmin(companyCode)) {
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
}
@@ -230,9 +238,10 @@ public class DdlController {
@GetMapping("/tables/{tableName}/info")
public ResponseEntity<ApiResponse<?>> getTableInfo(
@PathVariable String tableName,
@RequestAttribute("company_code") String companyCode) {
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "role", required = false) String role) {
if (!isSuperAdmin(companyCode)) {
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
}
@@ -255,9 +264,10 @@ public class DdlController {
@DeleteMapping("/logs/cleanup")
public ResponseEntity<ApiResponse<?>> cleanupOldLogs(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "role", required = false) String role,
@RequestParam(required = false, defaultValue = "90") int retentionDays) {
if (!isSuperAdmin(companyCode)) {
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
}
@@ -272,9 +282,10 @@ public class DdlController {
*/
@GetMapping("/info")
public ResponseEntity<ApiResponse<?>> getInfo(
@RequestAttribute("company_code") String companyCode) {
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "role", required = false) String role) {
if (!isSuperAdmin(companyCode)) {
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
}
@@ -318,7 +329,9 @@ public class DdlController {
// 내부 유틸
// ─────────────────────────────────────────────────────────────────────────
private boolean isSuperAdmin(String companyCode) {
return "*".equals(companyCode);
private boolean isSuperAdmin(String companyCode, String role) {
// company_code 가 '*' 이고 role 이 SUPER_ADMIN 둘 다 충족해야 통과 (이중 체크).
// 토큰 변조 또는 회사코드만으로 super 권한이 발급되는 사고 방지.
return "*".equals(companyCode) && "SUPER_ADMIN".equals(role);
}
}
@@ -176,6 +176,21 @@ public class TableManagementService extends BaseService {
params.put("display_column", "entity".equals(inputType) ? settings.get("display_column") : null);
params.put("display_order", settings.getOrDefault("display_order", 0));
params.put("is_visible", settings.getOrDefault("is_visible", true));
// is_nullable: 'Y'/'N' 또는 null. null 이면 mapper 의 COALESCE 로 기존 값 유지.
Object rawIsNullable = settings.get("is_nullable");
if (rawIsNullable != null) {
String s = rawIsNullable.toString();
// 프론트가 'YES'/'NO' 또는 'Y'/'N' 어느 쪽이든 보낼 수 있어 정규화
if ("NO".equalsIgnoreCase(s) || "N".equalsIgnoreCase(s) || "FALSE".equalsIgnoreCase(s)) {
params.put("is_nullable", "N");
} else if ("YES".equalsIgnoreCase(s) || "Y".equalsIgnoreCase(s) || "TRUE".equalsIgnoreCase(s)) {
params.put("is_nullable", "Y");
} else {
params.put("is_nullable", null);
}
} else {
params.put("is_nullable", null);
}
params.put("company_code", companyCode);
params.put("category_ref", "category".equals(inputType) ? settings.get("category_ref") : null);
sqlSession.update(NS + "upsertColumnSettings", params);
@@ -300,7 +300,7 @@
, #{display_column}
, #{display_order}
, #{is_visible}
, 'Y'
, COALESCE(#{is_nullable}, 'Y')
, #{company_code}
, #{category_ref}
, NOW()
@@ -318,6 +318,7 @@
, DISPLAY_COLUMN = EXCLUDED.DISPLAY_COLUMN
, DISPLAY_ORDER = COALESCE(EXCLUDED.DISPLAY_ORDER, TABLE_TYPE_COLUMNS.DISPLAY_ORDER)
, IS_VISIBLE = COALESCE(EXCLUDED.IS_VISIBLE, TABLE_TYPE_COLUMNS.IS_VISIBLE)
, IS_NULLABLE = COALESCE(EXCLUDED.IS_NULLABLE, TABLE_TYPE_COLUMNS.IS_NULLABLE)
, CATEGORY_REF = EXCLUDED.CATEGORY_REF
, UPDATED_DATE = NOW()
</insert>
@@ -21,7 +21,10 @@ import {
ChevronsUpDown,
Loader2,
Pencil,
Columns3,
Link2,
} from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { toast } from "sonner";
@@ -56,6 +59,7 @@ import type { TableInfo, ColumnTypeInfo, SecondLevelMenu } from "@/components/ad
import { TypeOverviewStrip } from "@/components/admin/table-type/TypeOverviewStrip";
import { ColumnGrid } from "@/components/admin/table-type/ColumnGrid";
import { ColumnDetailPanel } from "@/components/admin/table-type/ColumnDetailPanel";
import { ReferenceListView } from "@/components/admin/table-type/ReferenceListView";
export default function TableManagementPage() {
const { userLang, getText } = useMultiLang({ companyCode: "*" });
@@ -131,6 +135,8 @@ export default function TableManagementPage() {
indexes: Array<{ name: string; columns: string[]; is_unique: boolean }>;
}>({ primaryKey: { name: "", columns: [] }, indexes: [] });
const [pkDialogOpen, setPkDialogOpen] = useState(false);
// 이번 세션 동안 PK 변경 확인 다이얼로그 건너뛰기 (composite PK 만들 때 매번 다이얼로그 뜨는 답답함 해소)
const [pkSkipConfirmSession, setPkSkipConfirmSession] = useState(false);
const [pendingPkColumns, setPendingPkColumns] = useState<string[]>([]);
// 선택된 테이블 목록 (체크박스)
@@ -395,9 +401,38 @@ export default function TableManagementPage() {
}
}, []);
// ESC 키로 우측 상세 패널 닫기 (좁은 화면에서 stuck 방지)
useEffect(() => {
if (!selectedColumn) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") setSelectedColumn(null);
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [selectedColumn]);
// 저장 안 한 변경 사항이 있는지 — columns 와 originalColumns 의 reference 비교 (immutable update 패턴 의존)
const hasUnsavedChanges = useMemo(() => {
if (columns.length === 0 || originalColumns.length === 0) return false;
if (columns.length !== originalColumns.length) return true;
// 직렬화 비교 (얕은 ref 만으론 부족 — handleColumnChange 가 새 객체를 만들지만 다른 필드는 같은 ref 일 수 있어서)
try {
return JSON.stringify(columns) !== JSON.stringify(originalColumns);
} catch {
return false;
}
}, [columns, originalColumns]);
// 테이블 선택
const handleTableSelect = useCallback(
(tableName: string) => {
if (tableName === selectedTable) return;
if (hasUnsavedChanges) {
const ok = typeof window !== "undefined"
? window.confirm("저장하지 않은 컬럼 변경 사항이 있습니다. 이동하면 변경 내용이 사라집니다. 계속할까요?")
: true;
if (!ok) return;
}
setSelectedTable(tableName);
setCurrentPage(1);
setColumns([]);
@@ -412,12 +447,17 @@ export default function TableManagementPage() {
loadColumnTypes(tableName, 1, pageSize);
loadConstraints(tableName);
},
[loadColumnTypes, loadConstraints, pageSize, tables],
[hasUnsavedChanges, loadColumnTypes, loadConstraints, pageSize, selectedTable, tables],
);
// 입력 타입 변경 - 이전 타입의 설정값 초기화 포함
const handleInputTypeChange = useCallback(
(columnName: string, newInputType: string) => {
// typeFilter 가 활성화된 상태에서 변경된 input_type 이 필터와 불일치하면 자동으로 필터 해제
// (그렇지 않으면 사용자가 방금 편집한 행이 그리드에서 갑자기 사라져 혼란)
if (typeFilter && typeFilter !== newInputType) {
setTypeFilter(null);
}
setColumns((prev) =>
prev.map((col) => {
if (col.column_name === columnName) {
@@ -690,30 +730,22 @@ export default function TableManagementPage() {
count: column.category_menus.length,
});
let successCount = 0;
let failCount = 0;
for (const menuObjid of column.category_menus) {
try {
const mappingResponse = await createColumnMapping({
// 직렬 await 대신 Promise.allSettled 로 병렬 호출 (메뉴가 많으면 직렬은 수십 초 멈춤)
const mappingResults = await Promise.allSettled(
column.category_menus.map((menuObjid) =>
createColumnMapping({
tableName: selectedTable,
logicalColumnName: column.column_name,
physicalColumnName: column.column_name,
menuObjid,
description: `${column.display_name} (메뉴별 카테고리)`,
});
if (mappingResponse.success) {
successCount++;
} else {
console.error("❌ 매핑 생성 실패:", mappingResponse);
failCount++;
}
} catch (error) {
console.error(`❌ 메뉴 ${menuObjid}에 대한 매핑 생성 실패:`, error);
failCount++;
}
}
}),
),
);
const successCount = mappingResults.filter(
(r) => r.status === "fulfilled" && r.value.success,
).length;
const failCount = mappingResults.length - successCount;
if (successCount > 0 && failCount === 0) {
toast.success(`컬럼 설정 및 ${successCount}개 메뉴 매핑이 저장되었습니다.`);
@@ -732,10 +764,8 @@ export default function TableManagementPage() {
// 원본 데이터 업데이트
setOriginalColumns((prev) => prev.map((col) => (col.column_name === column.column_name ? column : col)));
// 저장 후 데이터 확인을 위해 다시 로드
setTimeout(() => {
loadColumnTypes(selectedTable);
}, 1000);
// 저장 후 데이터 확인을 위해 다시 로드 (await 로 즉시 reload — race + 깜빡임 회피)
await loadColumnTypes(selectedTable);
} else {
showErrorToast("컬럼 설정 저장에 실패했습니다", response.data.message, {
guidance: "입력 데이터를 확인하고 다시 시도해 주세요.",
@@ -887,37 +917,24 @@ export default function TableManagementPage() {
console.error("❌ 기존 매핑 삭제 실패:", error);
}
// 2. 새로운 매핑 추가 (선택된 메뉴가 있는 경우만)
// 2. 새로운 매핑 추가 (선택된 메뉴가 있는 경우만) — 직렬 await 대신 Promise.allSettled 병렬 호출
if (column.category_menus && column.category_menus.length > 0) {
for (const menuObjid of column.category_menus) {
try {
console.log("🔄 매핑 API 호출:", {
tableName: selectedTable,
columnName: column.column_name,
menuObjid,
});
const mappingResponse = await createColumnMapping({
const mappingResults = await Promise.allSettled(
column.category_menus.map((menuObjid) =>
createColumnMapping({
tableName: selectedTable,
logicalColumnName: column.column_name,
physicalColumnName: column.column_name,
menuObjid,
description: `${column.display_name} (메뉴별 카테고리)`,
});
console.log("✅ 매핑 API 응답:", mappingResponse);
if (mappingResponse.success) {
totalSuccessCount++;
} else {
console.error("❌ 매핑 생성 실패:", mappingResponse);
totalFailCount++;
}
} catch (error) {
console.error(`❌ 메뉴 ${menuObjid}에 대한 매핑 생성 실패:`, error);
totalFailCount++;
}
}
}),
),
);
const colSuccess = mappingResults.filter(
(r) => r.status === "fulfilled" && r.value.success,
).length;
totalSuccessCount += colSuccess;
totalFailCount += mappingResults.length - colSuccess;
}
}
@@ -940,10 +957,8 @@ export default function TableManagementPage() {
// 테이블 목록 새로고침 (라벨 변경 반영)
loadTables();
// 저장 후 데이터 다시 로드
setTimeout(() => {
loadColumnTypes(selectedTable, 1, pageSize);
}, 1000);
// 저장 후 데이터 다시 로드 (await 로 즉시 reload — race + 깜빡임 회피)
await loadColumnTypes(selectedTable, 1, pageSize);
} else {
showErrorToast("설정 저장에 실패했습니다", response.data.message, {
guidance: "잠시 후 다시 시도해 주세요.",
@@ -1054,24 +1069,28 @@ export default function TableManagementPage() {
} else {
newPkCols = currentPkCols.filter((c) => c !== columnName);
}
// 이번 세션 동안 묻지 않기로 한 경우 즉시 적용
if (pkSkipConfirmSession) {
applyPkChange(newPkCols);
return;
}
// PK 변경은 확인 다이얼로그 표시
setPendingPkColumns(newPkCols);
setPkDialogOpen(true);
},
[constraints.primaryKey?.columns],
[constraints.primaryKey?.columns, pkSkipConfirmSession],
);
// PK 변경 확인
const handlePkConfirm = async () => {
// PK 변경 실제 적용 (다이얼로그 거치지 않거나 거친 후 호출)
const applyPkChange = async (newPkCols: string[]) => {
if (!selectedTable) return;
try {
if (pendingPkColumns.length === 0) {
if (newPkCols.length === 0) {
toast.error("PK 컬럼을 최소 1개 이상 선택해야 합니다.");
setPkDialogOpen(false);
return;
}
const response = await apiClient.put(`/table-management/tables/${selectedTable}/primary-key`, {
columns: pendingPkColumns,
columns: newPkCols,
});
if (response.data.success) {
toast.success(response.data.message);
@@ -1083,11 +1102,15 @@ export default function TableManagementPage() {
showErrorToast("PK 설정에 실패했습니다", error, {
guidance: "컬럼 정보를 확인하고 다시 시도해 주세요.",
});
} finally {
setPkDialogOpen(false);
}
};
// PK 변경 확인 (다이얼로그에서 호출)
const handlePkConfirm = async () => {
setPkDialogOpen(false);
await applyPkChange(pendingPkColumns);
};
// 인덱스 토글 핸들러 (일반 인덱스만 DB 레벨 - 유니크는 앱 레벨 소프트 제약조건으로 분리됨)
const handleIndexToggle = useCallback(
async (columnName: string, indexType: "index", checked: boolean) => {
@@ -1690,56 +1713,106 @@ export default function TableManagementPage() {
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_COLUMNS, "컬럼이 없습니다")}
</div>
) : (
<>
<TypeOverviewStrip
columns={columns}
activeFilter={typeFilter}
onFilterChange={setTypeFilter}
/>
<ColumnGrid
columns={columns}
selectedColumn={selectedColumn}
onSelectColumn={(c) => setSelectedColumn((prev) => (prev === c ? null : c))}
onColumnChange={(columnName, field, value) => {
if (field === "is_unique") {
const currentColumn = columns.find((c) => c.column_name === columnName);
if (currentColumn) {
handleUniqueToggle(columnName, currentColumn.is_unique || "NO");
<Tabs defaultValue="columns" className="flex min-h-0 flex-1 flex-col">
<TabsList className="h-auto w-full shrink-0 justify-start gap-1 rounded-none border-b bg-transparent p-0">
<TabsTrigger
value="columns"
className={cn(
"flex items-center gap-2 rounded-none border-b-2 border-transparent bg-transparent px-4 py-2.5 text-sm font-medium text-muted-foreground transition-colors",
"hover:text-foreground",
"data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:text-primary data-[state=active]:shadow-none",
)}
>
<Columns3 className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger
value="references"
className={cn(
"flex items-center gap-2 rounded-none border-b-2 border-transparent bg-transparent px-4 py-2.5 text-sm font-medium text-muted-foreground transition-colors",
"hover:text-foreground",
"data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:text-primary data-[state=active]:shadow-none",
)}
>
<Link2 className="h-4 w-4" />
{(() => {
const refCount = columns.filter((c) =>
["entity", "code", "category", "numbering"].includes(c.input_type),
).length;
return refCount > 0 ? (
<Badge variant="secondary" className="ml-1.5 h-5 px-1.5 text-[11px]">
{refCount}
</Badge>
) : null;
})()}
</TabsTrigger>
</TabsList>
<TabsContent value="columns" className="mt-0 flex min-h-0 flex-1 flex-col">
<TypeOverviewStrip
columns={columns}
activeFilter={typeFilter}
onFilterChange={setTypeFilter}
/>
<ColumnGrid
columns={columns}
selectedColumn={selectedColumn}
onSelectColumn={(c) => setSelectedColumn((prev) => (prev === c ? null : c))}
onColumnChange={(columnName, field, value) => {
if (field === "is_unique") {
const currentColumn = columns.find((c) => c.column_name === columnName);
if (currentColumn) {
handleUniqueToggle(columnName, currentColumn.is_unique || "NO");
}
return;
}
return;
}
if (field === "is_nullable") {
const currentColumn = columns.find((c) => c.column_name === columnName);
if (currentColumn) {
handleNullableToggle(columnName, currentColumn.is_nullable || "YES");
if (field === "is_nullable") {
const currentColumn = columns.find((c) => c.column_name === columnName);
if (currentColumn) {
handleNullableToggle(columnName, currentColumn.is_nullable || "YES");
}
return;
}
return;
const idx = columns.findIndex((c) => c.column_name === columnName);
if (idx >= 0) handleColumnChange(idx, field, value);
}}
constraints={constraints}
typeFilter={typeFilter}
getColumnIndexState={getColumnIndexState}
onPkToggle={handlePkToggle}
onIndexToggle={(columnName, checked) =>
handleIndexToggle(columnName, "index", checked)
}
const idx = columns.findIndex((c) => c.column_name === columnName);
if (idx >= 0) handleColumnChange(idx, field, value);
}}
constraints={constraints}
typeFilter={typeFilter}
getColumnIndexState={getColumnIndexState}
onPkToggle={handlePkToggle}
onIndexToggle={(columnName, checked) =>
handleIndexToggle(columnName, "index", checked)
}
onDeleteColumn={handleDeleteColumnClick}
tables={tables}
referenceTableColumns={referenceTableColumns}
/>
</>
onDeleteColumn={handleDeleteColumnClick}
tables={tables}
referenceTableColumns={referenceTableColumns}
/>
</TabsContent>
<TabsContent value="references" className="mt-0 flex min-h-0 flex-1 flex-col">
<ReferenceListView
columns={columns}
tables={tables}
referenceTableColumns={referenceTableColumns}
selectedColumn={selectedColumn}
onSelectColumn={(c) => setSelectedColumn((prev) => (prev === c ? null : c))}
/>
</TabsContent>
</Tabs>
)}
</>
)}
</div>
{/* 우측: 상세 패널 (overlay slide-in/out — 가운데 본문 위에 부드럽게 등장) */}
{/* 우측: 상세 패널
- 와이드 모니터 (xl 이상): 항상 보이는 고정 3-pane
- 좁은 화면: 기존처럼 슬라이드 in 오버레이 */}
<div
className={cn(
"bg-card absolute top-0 right-0 bottom-0 z-20 flex w-[380px] flex-col overflow-hidden border-l shadow-2xl transition-transform duration-300 ease-out",
selectedColumn ? "translate-x-0" : "pointer-events-none translate-x-full",
"xl:relative xl:z-0 xl:flex-shrink-0 xl:translate-x-0 xl:pointer-events-auto xl:shadow-none xl:transition-none",
)}
>
<ColumnDetailPanel
@@ -1754,6 +1827,21 @@ export default function TableManagementPage() {
handleInputTypeChange(selectedColumn, value as string);
return;
}
// 그리드 칩과 동일하게 is_nullable/is_unique 는 즉시 저장
if (field === "is_nullable") {
const currentColumn = columns.find((c) => c.column_name === selectedColumn);
if (currentColumn) {
handleNullableToggle(selectedColumn, currentColumn.is_nullable || "YES");
}
return;
}
if (field === "is_unique") {
const currentColumn = columns.find((c) => c.column_name === selectedColumn);
if (currentColumn) {
handleUniqueToggle(selectedColumn, currentColumn.is_unique || "NO");
}
return;
}
if (field === "reference_table" && value) {
loadReferenceTableColumns(value as string);
}
@@ -1803,13 +1891,13 @@ export default function TableManagementPage() {
setDuplicateSourceTable(null);
}}
mode={duplicateModalMode}
sourceTableName={duplicateSourceTable || undefined}
source_table_name={duplicateSourceTable || undefined}
/>
<AddColumnModal
isOpen={addColumnModalOpen}
onClose={() => setAddColumnModalOpen(false)}
tableName={selectedTable || ""}
table_name={selectedTable || ""}
onSuccess={async (result) => {
toast.success("컬럼이 성공적으로 추가되었습니다!");
// 테이블 목록 새로고침 (컬럼 수 업데이트)
@@ -2001,6 +2089,14 @@ export default function TableManagementPage() {
<p className="text-destructive mt-2 text-sm">PK가 </p>
)}
</div>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer select-none">
<Checkbox
checked={pkSkipConfirmSession}
onCheckedChange={(v) => setPkSkipConfirmSession(v === true)}
/>
PK (composite PK )
</label>
</div>
<DialogFooter className="gap-2 sm:gap-0">
@@ -113,8 +113,29 @@ export function ColumnDetailPanel({
}, [referenceTableOptions, tables]);
// early return 은 반드시 모든 hook 호출 뒤에 (Rules of Hooks).
// overlay 패턴으로 항상 마운트되므로 column null 케이스가 정상적으로 들어옴.
if (!column) return null;
// 컬럼 선택 안 한 상태에서도 패널이 항상 보이는 와이드 레이아웃 대응 — 빈 상태 안내 UI 표시.
if (!column) {
return (
<div className="flex h-full w-full flex-col border-l bg-card">
{/* 좁은 화면에서 패널이 슬라이드 in 된 상태로 column=null 이 되면 닫을 수단이 없어
stuck 되는 문제 방지 — 빈 상태에도 X 버튼 유지 */}
<div className="flex flex-shrink-0 items-center justify-end px-4 py-3">
<Button variant="ghost" size="icon" onClick={onClose} aria-label="닫기" className="h-7 w-7">
<X className="h-4 w-4" />
</Button>
</div>
<div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
<div className="rounded-full bg-muted/60 p-4">
<Settings2 className="h-8 w-8 text-muted-foreground/60" />
</div>
<p className="mt-4 text-sm font-medium text-foreground"> </p>
<p className="mt-1 text-xs text-muted-foreground">
.
</p>
</div>
</div>
);
}
return (
<div className="flex h-full w-full flex-col border-l bg-card">
@@ -183,12 +204,12 @@ export function ColumnDetailPanel({
isLegacy && "cursor-not-allowed",
)}
>
<span className={cn(
"text-base font-bold leading-none",
isSelected ? "text-primary" : conf.color,
)}>
{conf.iconChar}
</span>
<conf.Icon
className={cn(
"h-4 w-4",
isSelected ? "text-primary" : conf.color,
)}
/>
<span className={cn(
"text-[16px] font-semibold leading-tight",
isSelected ? "text-primary" : "text-foreground",
@@ -1,7 +1,7 @@
"use client";
import React, { useMemo } from "react";
import { MoreHorizontal, Database, Layers, FileStack, Trash2 } from "lucide-react";
import { MoreHorizontal, Database, FileStack, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
@@ -47,11 +47,10 @@ function getIndexState(
return { isPk, hasIndex };
}
/** 그룹 헤더 라벨 */
const GROUP_LABELS: Record<string, { icon: React.FC<{ className?: string }>; label: string }> = {
basic: { icon: FileStack, label: "기본 정보" },
reference: { icon: Layers, label: "참조 정보" },
meta: { icon: Database, label: "메타 정보" },
/** 그룹 헤더 라벨 — 참조 컬럼은 별도 "참조" 탭에서 보여주므로 컬럼 탭에서는 사용자/시스템 2그룹으로만 분류 */
const GROUP_LABELS: Record<"user" | "system", { icon: React.FC<{ className?: string }>; label: string }> = {
user: { icon: FileStack, label: "사용자 컬럼" },
system: { icon: Database, label: "시스템 컬럼" },
};
export function ColumnGrid({
@@ -73,30 +72,28 @@ export function ColumnGrid({
[constraints, externalGetIndexState],
);
/** typeFilter 적용 후 그룹정렬 */
/** typeFilter 적용 후 사용자/시스템 그룹분류 (참조 컬럼은 참조 탭으로 분리됐으므로 사용자 컬럼에 합침) */
const filteredAndGrouped = useMemo(() => {
const filtered =
typeFilter != null ? columns.filter((c) => (c.input_type || "text") === typeFilter) : columns;
const groups = { basic: [] as ColumnTypeInfo[], reference: [] as ColumnTypeInfo[], meta: [] as ColumnTypeInfo[] };
const groups = { user: [] as ColumnTypeInfo[], system: [] as ColumnTypeInfo[] };
for (const col of filtered) {
const group = getColumnGroup(col);
groups[group].push(col);
const g = getColumnGroup(col) === "meta" ? "system" : "user";
groups[g].push(col);
}
return groups;
}, [columns, typeFilter]);
const totalFiltered =
filteredAndGrouped.basic.length + filteredAndGrouped.reference.length + filteredAndGrouped.meta.length;
const totalFiltered = filteredAndGrouped.user.length + filteredAndGrouped.system.length;
return (
<div className="flex flex-1 flex-col overflow-hidden">
<div
className="grid flex-shrink-0 items-center border-b bg-muted/50 px-4 py-2 text-xs font-semibold text-foreground"
style={{ gridTemplateColumns: "4px 140px 1fr 100px 160px 40px" }}
style={{ gridTemplateColumns: "4px 1fr 100px 160px 40px" }}
>
<span />
<span> · </span>
<span>/</span>
<span></span>
<span className="text-center">PK / NN / IDX / UQ</span>
<span />
@@ -108,7 +105,7 @@ export function ColumnGrid({
{typeFilter ? "해당 타입의 컬럼이 없습니다." : "컬럼이 없습니다."}
</div>
) : (
(["basic", "reference", "meta"] as const).map((groupKey) => {
(["user", "system"] as const).map((groupKey) => {
const list = filteredAndGrouped[groupKey];
if (list.length === 0) return null;
const { icon: Icon, label } = GROUP_LABELS[groupKey];
@@ -142,7 +139,7 @@ export function ColumnGrid({
}}
className={cn(
"grid min-h-12 cursor-pointer items-center gap-2 rounded-md border px-4 py-2 transition-colors",
"grid-cols-[4px_140px_1fr_100px_160px_40px]",
"grid-cols-[4px_1fr_100px_160px_40px]",
"bg-card border-transparent hover:border-border hover:shadow-sm",
isSelected && "border-primary/30 bg-primary/5 shadow-sm",
)}
@@ -159,66 +156,6 @@ export function ColumnGrid({
</div>
</div>
{/* 참조/설정 칩 */}
<div className="flex min-w-0 flex-wrap gap-1">
{column.input_type === "entity" && column.reference_table && column.reference_table !== "none" && (
<>
<Badge
variant="outline"
className="text-xs font-normal"
title={
tables
? (() => {
const t = tables.find((tb) => tb.table_name === column.reference_table);
return t?.display_name && t.display_name !== t.table_name
? `${t.display_name} (${column.reference_table})`
: column.reference_table;
})()
: column.reference_table
}
>
{column.reference_table}
</Badge>
<span className="text-muted-foreground text-xs"></span>
<Badge
variant="outline"
className="text-xs font-normal"
title={
referenceTableColumns?.[column.reference_table]
? (() => {
const refCols = referenceTableColumns[column.reference_table];
const c = refCols.find((rc) => rc.column_name === (column.reference_column ?? ""));
return c?.display_name && c.display_name !== c.column_name
? `${c.display_name} (${column.reference_column})`
: column.reference_column ?? "—";
})()
: column.reference_column ?? "—"
}
>
{column.reference_column || "—"}
</Badge>
</>
)}
{column.input_type === "code" && (
<span className="text-muted-foreground truncate text-xs">
{column.code_info ?? "—"} · {column.default_value ?? ""}
</span>
)}
{column.input_type === "numbering" && column.numbering_rule_id && (
<Badge variant="outline" className="text-xs font-normal">
{column.numbering_rule_id}
</Badge>
)}
{column.input_type !== "entity" &&
column.input_type !== "code" &&
column.input_type !== "numbering" &&
(column.default_value ? (
<span className="text-muted-foreground truncate text-xs">{column.default_value}</span>
) : (
<span className="text-muted-foreground/60 text-xs"></span>
))}
</div>
{/* 타입 뱃지 */}
<div className={cn("rounded-md border px-2 py-0.5 text-xs", typeConf.bgColor, typeConf.color)}>
<span className="mr-1 inline-block h-1.5 w-1.5 rounded-full bg-current opacity-70" />
@@ -0,0 +1,223 @@
"use client";
import React, { useMemo } from "react";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { Database, FolderTree, Hash, Link2, FileCode2 } from "lucide-react";
import type { ColumnTypeInfo, TableInfo } from "./types";
import { INPUT_TYPE_COLORS } from "./types";
import type { ReferenceTableColumn } from "@/lib/api/entityJoin";
export interface ReferenceListViewProps {
columns: ColumnTypeInfo[];
tables?: TableInfo[];
referenceTableColumns?: Record<string, ReferenceTableColumn[]>;
onSelectColumn?: (columnName: string) => void;
selectedColumn?: string | null;
}
type RefKind = "entity" | "code" | "category" | "numbering";
const KIND_META: Record<
RefKind,
{ icon: React.FC<{ className?: string }>; label: string; color: string; bgColor: string }
> = {
entity: { icon: Link2, label: "테이블 참조", color: "text-violet-600", bgColor: "bg-violet-50" },
code: { icon: FileCode2, label: "공통코드", color: "text-emerald-600", bgColor: "bg-emerald-50" },
category: { icon: FolderTree, label: "카테고리", color: "text-teal-600", bgColor: "bg-teal-50" },
numbering: { icon: Hash, label: "채번", color: "text-orange-600", bgColor: "bg-orange-50" },
};
function getRefKind(col: ColumnTypeInfo): RefKind | null {
const t = col.input_type;
if (t === "entity" || t === "code" || t === "category" || t === "numbering") return t;
return null;
}
export function ReferenceListView({
columns,
tables,
referenceTableColumns,
onSelectColumn,
selectedColumn = null,
}: ReferenceListViewProps) {
const grouped = useMemo(() => {
const groups: Record<RefKind, ColumnTypeInfo[]> = {
entity: [],
code: [],
category: [],
numbering: [],
};
for (const col of columns) {
const kind = getRefKind(col);
if (kind) groups[kind].push(col);
}
return groups;
}, [columns]);
const totalRefs =
grouped.entity.length + grouped.code.length + grouped.category.length + grouped.numbering.length;
if (totalRefs === 0) {
return (
<div className="flex flex-1 items-center justify-center py-12 text-sm text-muted-foreground">
<div className="flex flex-col items-center gap-2">
<Database className="h-8 w-8 text-muted-foreground/50" />
<span> .</span>
</div>
</div>
);
}
return (
<div className="flex flex-1 flex-col overflow-hidden">
{/* 헤더 */}
<div
className="grid flex-shrink-0 items-center border-b bg-muted/50 px-4 py-2 text-xs font-semibold text-foreground"
style={{ gridTemplateColumns: "4px 220px 110px 1fr" }}
>
<span />
<span> </span>
<span> </span>
<span> </span>
</div>
{/* 그룹별 행 */}
<div className="flex-1 overflow-y-auto">
{(["entity", "code", "category", "numbering"] as const).map((kind) => {
const list = grouped[kind];
if (list.length === 0) return null;
const meta = KIND_META[kind];
const KindIcon = meta.icon;
return (
<div key={kind} className="space-y-1 py-2">
<div className="flex items-center gap-2 border-b border-border/60 px-4 pb-1.5">
<KindIcon className={cn("h-4 w-4", meta.color)} />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{meta.label}
</span>
<Badge variant="secondary" className="text-xs">
{list.length}
</Badge>
</div>
{list.map((column) => {
const typeConf = INPUT_TYPE_COLORS[column.input_type || "text"] || INPUT_TYPE_COLORS.text;
const isSelected = selectedColumn === column.column_name;
return (
<div
key={column.column_name}
role="button"
tabIndex={0}
onClick={() => onSelectColumn?.(column.column_name)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelectColumn?.(column.column_name);
}
}}
className={cn(
"grid min-h-10 cursor-pointer items-center gap-2 rounded-md border px-4 py-2 transition-colors",
"bg-card border-transparent hover:border-border hover:shadow-sm",
isSelected && "border-primary/30 bg-primary/5 shadow-sm",
)}
style={{ gridTemplateColumns: "4px 220px 110px 1fr" }}
>
{/* 색상바 */}
<div className={cn("h-full min-h-8 w-1 rounded-full", typeConf.barColor)} />
{/* 소스 컬럼명 */}
<div className="min-w-0">
<div className="truncate text-xs font-medium">
{column.display_name && column.display_name !== column.column_name
? `${column.display_name} (${column.column_name})`
: column.column_name}
</div>
</div>
{/* 참조 종류 칩 */}
<div className={cn("inline-flex w-fit items-center gap-1 rounded-md border px-2 py-0.5 text-xs", meta.bgColor, meta.color)}>
<span className="h-1.5 w-1.5 rounded-full bg-current opacity-70" />
{meta.label}
</div>
{/* 참조 대상 */}
<div className="flex min-w-0 flex-wrap items-center gap-1">
{kind === "entity" && column.reference_table && column.reference_table !== "none" ? (
<>
<Badge
variant="outline"
className="text-xs font-normal"
title={
tables
? (() => {
const t = tables.find((tb) => tb.table_name === column.reference_table);
return t?.display_name && t.display_name !== t.table_name
? `${t.display_name} (${column.reference_table})`
: column.reference_table;
})()
: column.reference_table
}
>
{column.reference_table}
</Badge>
<span className="text-muted-foreground text-xs"></span>
<Badge
variant="outline"
className="text-xs font-normal"
title={
referenceTableColumns?.[column.reference_table]
? (() => {
const refCols = referenceTableColumns[column.reference_table];
const c = refCols.find((rc) => rc.column_name === (column.reference_column ?? ""));
return c?.display_name && c.display_name !== c.column_name
? `${c.display_name} (${column.reference_column})`
: column.reference_column ?? "—";
})()
: column.reference_column ?? "—"
}
>
{column.reference_column || "—"}
</Badge>
</>
) : kind === "code" ? (
column.code_info ? (
<Badge variant="outline" className="text-xs font-normal">
: {column.code_info}
</Badge>
) : (
<span className="text-muted-foreground/60 text-xs"> ( )</span>
)
) : kind === "category" ? (
column.category_ref ? (
<Badge variant="outline" className="text-xs font-normal">
: {column.category_ref}
</Badge>
) : column.category_menus && column.category_menus.length > 0 ? (
<Badge variant="outline" className="text-xs font-normal">
{column.category_menus.length}
</Badge>
) : (
<span className="text-muted-foreground/60 text-xs"> ( )</span>
)
) : kind === "numbering" ? (
column.numbering_rule_id ? (
<Badge variant="outline" className="text-xs font-normal">
: {column.numbering_rule_id}
</Badge>
) : (
<span className="text-muted-foreground/60 text-xs"> ( )</span>
)
) : (
<span className="text-muted-foreground/60 text-xs"></span>
)}
</div>
</div>
);
})}
</div>
);
})}
</div>
</div>
);
}
@@ -3,7 +3,7 @@
import React, { useMemo } from "react";
import { cn } from "@/lib/utils";
import type { ColumnTypeInfo } from "./types";
import { INPUT_TYPE_COLORS } from "./types";
import { INPUT_TYPE_COLORS, FALLBACK_TYPE_CONFIG } from "./types";
import { USER_SELECTABLE_INPUT_TYPE_ORDER } from "@/types/input-types";
export interface TypeOverviewStripProps {
@@ -57,20 +57,13 @@ export function TypeOverviewStrip({
/** stroke-dasharray: 비율만큼 둘레에 할당 (둘레 100 기준) */
const circumference = 100;
let offset = 0;
const LEGACY_CONF = {
color: "text-amber-600",
bgColor: "bg-amber-50",
barColor: "bg-amber-400",
label: "Legacy",
desc: "구버전 타입",
iconChar: "?",
};
const LEGACY_CONF = { ...FALLBACK_TYPE_CONFIG, color: "text-amber-600", bgColor: "bg-amber-50", barColor: "bg-amber-400" };
const segmentPaths = segments.map(({ type, ratio, isLegacy }) => {
const length = ratio * circumference;
const dashArray = `${length} ${circumference - length}`;
const dashOffset = -offset;
offset += length;
const conf = isLegacy ? LEGACY_CONF : (INPUT_TYPE_COLORS[type] || { color: "text-muted-foreground", bgColor: "bg-muted" });
const conf = isLegacy ? LEGACY_CONF : (INPUT_TYPE_COLORS[type] || FALLBACK_TYPE_CONFIG);
return {
type,
dashArray,
@@ -112,7 +105,7 @@ export function TypeOverviewStrip({
.filter((type) => (counts[type] || 0) > 0)
.sort((a, b) => (counts[b] ?? 0) - (counts[a] ?? 0))
.map((type) => {
const conf = INPUT_TYPE_COLORS[type] || { color: "text-muted-foreground", bgColor: "bg-muted", label: type };
const conf = INPUT_TYPE_COLORS[type] || { ...FALLBACK_TYPE_CONFIG, label: type };
const isActive = activeFilter === null || activeFilter === type;
return (
<button
+42 -15
View File
@@ -3,6 +3,23 @@
* page.tsx에서 추출한 인터페이스 및 타입별 색상/그룹 유틸
*/
import {
AlignLeft,
Braces,
Calendar,
CheckSquare,
ChevronDown,
CircleDot,
FolderTree,
Hash,
HelpCircle,
Image as ImageIcon,
Link2,
ListOrdered,
Paperclip,
Type,
type LucideIcon,
} from "lucide-react";
import { USER_SELECTABLE_INPUT_TYPE_ORDER } from "@/types/input-types";
export interface TableInfo {
@@ -52,24 +69,34 @@ export interface TypeColorConfig {
barColor: string;
label: string;
desc: string;
iconChar: string;
Icon: LucideIcon;
}
/** 입력 타입별 색상 맵 - iconChar는 카드 선택용 시각 아이콘 */
/** Legacy/알 수 없는 타입용 fallback config */
export const FALLBACK_TYPE_CONFIG: TypeColorConfig = {
color: "text-muted-foreground",
bgColor: "bg-muted",
barColor: "bg-muted",
label: "Legacy",
desc: "구버전 타입",
Icon: HelpCircle,
};
/** 입력 타입별 색상 맵 - Icon 은 lucide 컴포넌트로 통일 (letter/symbol/emoji 혼재 방지) */
export const INPUT_TYPE_COLORS: Record<string, TypeColorConfig> = {
text: { color: "text-slate-600", bgColor: "bg-slate-50", barColor: "bg-slate-400", label: "텍스트", desc: "일반 텍스트 입력", iconChar: "T" },
number: { color: "text-indigo-600", bgColor: "bg-indigo-50", barColor: "bg-indigo-500", label: "숫자", desc: "숫자만 입력", iconChar: "#" },
date: { color: "text-amber-600", bgColor: "bg-amber-50", barColor: "bg-amber-500", label: "날짜", desc: "날짜 선택", iconChar: "D" },
code: { color: "text-emerald-600", bgColor: "bg-emerald-50", barColor: "bg-emerald-500", label: "코드", desc: "공통코드 선택", iconChar: "{}" },
entity: { color: "text-violet-600", bgColor: "bg-violet-50", barColor: "bg-violet-500", label: "테이블 참조", desc: "다른 테이블 연결", iconChar: "⊞" },
select: { color: "text-cyan-600", bgColor: "bg-cyan-50", barColor: "bg-cyan-500", label: "셀렉트", desc: "직접 옵션 선택", iconChar: "☰" },
checkbox: { color: "text-pink-600", bgColor: "bg-pink-50", barColor: "bg-pink-500", label: "체크박스", desc: "예/아니오 선택", iconChar: "☑" },
numbering: { color: "text-orange-600", bgColor: "bg-orange-50", barColor: "bg-orange-500", label: "채번", desc: "자동 번호 생성", iconChar: "≡" },
category: { color: "text-teal-600", bgColor: "bg-teal-50", barColor: "bg-teal-500", label: "카테고리", desc: "등록된 선택지", iconChar: "⊟" },
textarea: { color: "text-indigo-600", bgColor: "bg-indigo-50", barColor: "bg-indigo-400", label: "여러 줄", desc: "긴 텍스트 입력", iconChar: "≡" },
radio: { color: "text-rose-600", bgColor: "bg-rose-50", barColor: "bg-rose-500", label: "라디오", desc: "하나만 선택", iconChar: "◉" },
file: { color: "text-amber-600", bgColor: "bg-amber-50", barColor: "bg-amber-500", label: "파일", desc: "파일 업로드", iconChar: "📎" },
image: { color: "text-sky-600", bgColor: "bg-sky-50", barColor: "bg-sky-500", label: "이미지", desc: "이미지 표시", iconChar: "🖼" },
text: { color: "text-slate-600", bgColor: "bg-slate-50", barColor: "bg-slate-400", label: "텍스트", desc: "일반 텍스트 입력", Icon: Type },
number: { color: "text-indigo-600", bgColor: "bg-indigo-50", barColor: "bg-indigo-500", label: "숫자", desc: "숫자만 입력", Icon: Hash },
date: { color: "text-amber-600", bgColor: "bg-amber-50", barColor: "bg-amber-500", label: "날짜", desc: "날짜 선택", Icon: Calendar },
code: { color: "text-emerald-600", bgColor: "bg-emerald-50", barColor: "bg-emerald-500", label: "코드", desc: "공통코드 선택", Icon: Braces },
entity: { color: "text-violet-600", bgColor: "bg-violet-50", barColor: "bg-violet-500", label: "테이블 참조", desc: "다른 테이블 연결", Icon: Link2 },
select: { color: "text-cyan-600", bgColor: "bg-cyan-50", barColor: "bg-cyan-500", label: "셀렉트", desc: "직접 옵션 선택", Icon: ChevronDown },
checkbox: { color: "text-pink-600", bgColor: "bg-pink-50", barColor: "bg-pink-500", label: "체크박스", desc: "예/아니오 선택", Icon: CheckSquare },
numbering: { color: "text-orange-600", bgColor: "bg-orange-50", barColor: "bg-orange-500", label: "채번", desc: "자동 번호 생성", Icon: ListOrdered },
category: { color: "text-teal-600", bgColor: "bg-teal-50", barColor: "bg-teal-500", label: "카테고리", desc: "등록된 선택지", Icon: FolderTree },
textarea: { color: "text-indigo-600", bgColor: "bg-indigo-50", barColor: "bg-indigo-400", label: "여러 줄", desc: "긴 텍스트 입력", Icon: AlignLeft },
radio: { color: "text-rose-600", bgColor: "bg-rose-50", barColor: "bg-rose-500", label: "라디오", desc: "하나만 선택", Icon: CircleDot },
file: { color: "text-amber-600", bgColor: "bg-amber-50", barColor: "bg-amber-500", label: "파일", desc: "파일 업로드", Icon: Paperclip },
image: { color: "text-sky-600", bgColor: "bg-sky-50", barColor: "bg-sky-500", label: "이미지", desc: "이미지 표시", Icon: ImageIcon },
};
/** v3.2 — 사용자 선택 가능한 8개 입력 타입 색상 맵 (T2 드롭다운/카드 그리드용) */