refactor(WEB): 공통 훅(useDeleteDialog, useStatsLoader) 및 CRUD 서비스 추출
- useDeleteDialog 훅 추출로 삭제 다이얼로그 로직 공통화 - useStatsLoader 훅 추출로 통계 로딩 패턴 공통화 - create-crud-service 유틸 추가 - 차량관리/견적/출고/검사 등 리스트 컴포넌트 간소화 - RankManagement actions 정리 - 프로덕션 로거 불필요 출력 제거 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
143
src/hooks/useDeleteDialog.ts
Normal file
143
src/hooks/useDeleteDialog.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* useDeleteDialog - 삭제 확인 다이얼로그 상태/핸들러 훅
|
||||
*
|
||||
* 단건 삭제 + 일괄 삭제 패턴을 하나의 훅으로 통합.
|
||||
* DeleteConfirmDialog props와 직접 연결 가능.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const deleteDialog = useDeleteDialog({
|
||||
* onDelete: deleteVehicle,
|
||||
* onBulkDelete: bulkDeleteVehicles,
|
||||
* onSuccess: () => window.location.reload(),
|
||||
* entityName: '차량',
|
||||
* });
|
||||
*
|
||||
* // 단건 삭제 트리거
|
||||
* <button onClick={() => deleteDialog.single.open(id)}>삭제</button>
|
||||
*
|
||||
* // 일괄 삭제 트리거
|
||||
* <button onClick={() => deleteDialog.bulk.open(selectedIds)}>선택 삭제</button>
|
||||
*
|
||||
* // 다이얼로그 렌더링
|
||||
* <DeleteConfirmDialog
|
||||
* open={deleteDialog.single.isOpen}
|
||||
* onOpenChange={deleteDialog.single.onOpenChange}
|
||||
* onConfirm={deleteDialog.single.confirm}
|
||||
* loading={deleteDialog.isPending}
|
||||
* />
|
||||
* <DeleteConfirmDialog
|
||||
* open={deleteDialog.bulk.isOpen}
|
||||
* onOpenChange={deleteDialog.bulk.onOpenChange}
|
||||
* onConfirm={deleteDialog.bulk.confirm}
|
||||
* loading={deleteDialog.isPending}
|
||||
* description={`선택한 ${deleteDialog.bulk.ids.length}개의 항목을 삭제하시겠습니까?`}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useTransition, useRef } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type DeleteFn = (id: string) => Promise<{ success: boolean; error?: string }>;
|
||||
type BulkDeleteFn = (ids: string[]) => Promise<{ success: boolean; error?: string }>;
|
||||
|
||||
interface UseDeleteDialogOptions {
|
||||
/** 단건 삭제 서버 액션 */
|
||||
onDelete: DeleteFn;
|
||||
/** 일괄 삭제 서버 액션 (없으면 bulk 미사용) */
|
||||
onBulkDelete?: BulkDeleteFn;
|
||||
/** 삭제 성공 후 콜백 (데이터 리로드, 페이지 새로고침 등) */
|
||||
onSuccess?: () => void;
|
||||
/** 엔티티 이름 (토스트 메시지용: "차량", "견적" 등) */
|
||||
entityName?: string;
|
||||
}
|
||||
|
||||
export function useDeleteDialog({ onDelete, onBulkDelete, onSuccess, entityName }: UseDeleteDialogOptions) {
|
||||
// 단건 삭제 상태
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [targetId, setTargetId] = useState<string | null>(null);
|
||||
|
||||
// 일괄 삭제 상태
|
||||
const [isBulkOpen, setIsBulkOpen] = useState(false);
|
||||
const [bulkIds, setBulkIds] = useState<string[]>([]);
|
||||
|
||||
// 로딩 상태
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
// 콜백 안정성 (불필요한 useCallback 재생성 방지)
|
||||
const callbacksRef = useRef({ onDelete, onBulkDelete, onSuccess });
|
||||
callbacksRef.current = { onDelete, onBulkDelete, onSuccess };
|
||||
|
||||
// ===== 단건 삭제 =====
|
||||
|
||||
const openSingle = useCallback((id: string) => {
|
||||
setTargetId(id);
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const confirmSingle = useCallback(async () => {
|
||||
if (!targetId) return;
|
||||
startTransition(async () => {
|
||||
const result = await callbacksRef.current.onDelete(targetId);
|
||||
if (result.success) {
|
||||
toast.success(entityName ? `${entityName} 삭제 완료` : '삭제되었습니다.');
|
||||
callbacksRef.current.onSuccess?.();
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
setIsOpen(false);
|
||||
setTargetId(null);
|
||||
});
|
||||
}, [targetId, entityName]);
|
||||
|
||||
// ===== 일괄 삭제 =====
|
||||
|
||||
const openBulk = useCallback((ids: string[]) => {
|
||||
if (ids.length === 0) {
|
||||
toast.error('삭제할 항목을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
setBulkIds(ids);
|
||||
setIsBulkOpen(true);
|
||||
}, []);
|
||||
|
||||
const confirmBulk = useCallback(async () => {
|
||||
if (!callbacksRef.current.onBulkDelete || bulkIds.length === 0) return;
|
||||
startTransition(async () => {
|
||||
const result = await callbacksRef.current.onBulkDelete!(bulkIds);
|
||||
if (result.success) {
|
||||
const label = entityName || '항목';
|
||||
toast.success(`${bulkIds.length}개의 ${label} 삭제 완료`);
|
||||
callbacksRef.current.onSuccess?.();
|
||||
} else {
|
||||
toast.error(result.error || '일괄 삭제에 실패했습니다.');
|
||||
}
|
||||
setIsBulkOpen(false);
|
||||
setBulkIds([]);
|
||||
});
|
||||
}, [bulkIds, entityName]);
|
||||
|
||||
return {
|
||||
/** 단건 삭제 다이얼로그 */
|
||||
single: {
|
||||
isOpen,
|
||||
targetId,
|
||||
open: openSingle,
|
||||
onOpenChange: setIsOpen,
|
||||
confirm: confirmSingle,
|
||||
},
|
||||
/** 일괄 삭제 다이얼로그 */
|
||||
bulk: {
|
||||
isOpen: isBulkOpen,
|
||||
ids: bulkIds,
|
||||
open: openBulk,
|
||||
onOpenChange: setIsBulkOpen,
|
||||
confirm: confirmBulk,
|
||||
},
|
||||
/** useTransition 로딩 상태 */
|
||||
isPending,
|
||||
};
|
||||
}
|
||||
49
src/hooks/useStatsLoader.ts
Normal file
49
src/hooks/useStatsLoader.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
/**
|
||||
* Stats 데이터 로딩 훅
|
||||
*
|
||||
* 통계 데이터의 초기 로딩, 수동 업데이트, 재로딩을 관리합니다.
|
||||
*
|
||||
* @param loadFn - Stats API 호출 함수
|
||||
* @param initialData - 초기값 (있으면 자동 로딩 스킵)
|
||||
*
|
||||
* @example
|
||||
* // 기본 사용
|
||||
* const { data: stats, reload: reloadStats } = useStatsLoader(getProcessStats);
|
||||
*
|
||||
* @example
|
||||
* // 초기값 제공 (있으면 자동 로딩 스킵)
|
||||
* const { data: stats, reload: reloadStats } = useStatsLoader(getContractStats, initialStats);
|
||||
*/
|
||||
export function useStatsLoader<T>(
|
||||
loadFn: () => Promise<{ success: boolean; data?: T }>,
|
||||
initialData?: T | null,
|
||||
) {
|
||||
const [data, setData] = useState<T | null>(initialData ?? null);
|
||||
const loadFnRef = useRef(loadFn);
|
||||
loadFnRef.current = loadFn;
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
const result = await loadFnRef.current();
|
||||
if (result.success && result.data) {
|
||||
setData(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[useStatsLoader] error:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData != null) return;
|
||||
reload();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return { data, setData, reload };
|
||||
}
|
||||
Reference in New Issue
Block a user