Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 33f0647c61 | |||
| 24106929fa | |||
| f530b3cf31 | |||
| 99487049fb | |||
| 6233877029 | |||
| 4031fe8b60 | |||
| a5288647c9 | |||
| 7dbeccc182 | |||
| c857e4f715 | |||
| cf5f7ef9af | |||
| 7e71730015 | |||
| 2d39d17428 | |||
| 30ebb14023 | |||
| 895cb48ee0 |
@@ -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
|
||||
|
||||
@@ -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 드롭다운/카드 그리드용) */
|
||||
|
||||
Reference in New Issue
Block a user