Compare commits

2 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
2 changed files with 84 additions and 67 deletions
@@ -135,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[]>([]);
// 선택된 테이블 목록 (체크박스)
@@ -399,6 +401,16 @@ 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;
@@ -441,6 +453,11 @@ export default function TableManagementPage() {
// 입력 타입 변경 - 이전 타입의 설정값 초기화 포함
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) {
@@ -713,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}개 메뉴 매핑이 저장되었습니다.`);
@@ -755,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: "입력 데이터를 확인하고 다시 시도해 주세요.",
@@ -910,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;
}
}
@@ -963,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: "잠시 후 다시 시도해 주세요.",
@@ -1077,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);
@@ -1106,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) => {
@@ -2089,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">
@@ -116,14 +116,23 @@ export function ColumnDetailPanel({
// 컬럼 선택 안 한 상태에서도 패널이 항상 보이는 와이드 레이아웃 대응 — 빈 상태 안내 UI 표시.
if (!column) {
return (
<div className="flex h-full w-full flex-col items-center justify-center border-l bg-card px-6 text-center">
<div className="rounded-full bg-muted/60 p-4">
<Settings2 className="h-8 w-8 text-muted-foreground/60" />
<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>
<p className="mt-4 text-sm font-medium text-foreground"> </p>
<p className="mt-1 text-xs text-muted-foreground">
.
</p>
</div>
);
}