diff --git a/claudedocs/refactoring/[IMPL-2026-02-09] phase1-common-hooks-checklist.md b/claudedocs/refactoring/[IMPL-2026-02-09] phase1-common-hooks-checklist.md index 4f90267d..bf04861c 100644 --- a/claudedocs/refactoring/[IMPL-2026-02-09] phase1-common-hooks-checklist.md +++ b/claudedocs/refactoring/[IMPL-2026-02-09] phase1-common-hooks-checklist.md @@ -1,7 +1,7 @@ # Phase 1 수정: 실제 중복 기반 리팩토링 체크리스트 **작성일**: 2026-02-09 -**상태**: Step 1 완료 / Step 2-4 대기 +**상태**: Step 1-4 전체 완료 ✅ **관련 문서**: `architecture/[PLAN-2026-02-06] refactoring-roadmap.md` --- @@ -139,7 +139,7 @@ export const getRanks = createServerAction({ ### 생성할 훅 -- [ ] `src/hooks/useDeleteDialog.ts` +- [x] `src/hooks/useDeleteDialog.ts` (75줄) ```typescript // 사용 전: ~25줄 @@ -164,13 +164,13 @@ const { single, bulk } = useDeleteDialog({ | # | 파일 | 상태 | |---|------|------| -| 1 | `vehicle-management/VehicleList/index.tsx` | [ ] | -| 2 | `vehicle-management/VehicleLogList/index.tsx` | [ ] | -| 3 | `vehicle-management/ForkliftList/index.tsx` | [ ] | -| 4 | `process-management/ProcessListClient.tsx` | [ ] | -| 5 | `quotes/QuoteManagementClient.tsx` | [ ] | -| 6 | `accounting/BillManagement/BillManagementClient.tsx` | [ ] | -| 7 | `accounting/VendorManagement/VendorManagementClient.tsx` | [ ] | +| 1 | `vehicle-management/VehicleList/index.tsx` | [x] | +| 2 | `vehicle-management/VehicleLogList/index.tsx` | [x] | +| 3 | `vehicle-management/ForkliftList/index.tsx` | [x] | +| 4 | `process-management/ProcessListClient.tsx` | [x] 스킵 (공유 isLoading + 커스텀 stats 업데이트 + 비표준 벌크삭제) | +| 5 | `quotes/QuoteManagementClient.tsx` | [x] | +| 6 | `accounting/BillManagement/BillManagementClient.tsx` | [x] (커스텀 onDelete 콜백) | +| 7 | `accounting/VendorManagement/VendorManagementClient.tsx` | [x] (커스텀 onDelete 콜백) | --- @@ -180,7 +180,7 @@ const { single, bulk } = useDeleteDialog({ ### 생성할 훅 -- [ ] `src/hooks/useStatsLoader.ts` +- [x] `src/hooks/useStatsLoader.ts` (45줄) ```typescript // 사용 전: ~15줄 @@ -206,36 +206,51 @@ const { data: stats } = useStatsLoader(getProcessStats, { total: 0, active: 0, i | # | 파일 | 상태 | |---|------|------| -| 1 | `process-management/ProcessListClient.tsx` | [ ] | -| 2 | `production/WorkOrders/WorkOrderList.tsx` | [ ] | -| 3 | `quality/InspectionManagement/InspectionList.tsx` | [ ] | -| 4 | `outbound/ShipmentManagement/ShipmentList.tsx` | [ ] | -| 5 | `business/construction/contract/ContractListClient.tsx` | [ ] | -| 6 | `business/construction/management/ConstructionManagementListClient.tsx` | [ ] | -| 7 | `business/construction/bidding/BiddingListClient.tsx` | [ ] | -| 8 | `business/construction/estimates/EstimateListClient.tsx` | [ ] | -| 9 | `business/construction/order-management/OrderManagementListClient.tsx` | [ ] | -| 10 | `business/construction/progress-billing/ProgressBillingManagementListClient.tsx` | [ ] | +| 1 | `process-management/ProcessListClient.tsx` | [x] 스킵 (stats UI 미사용, Promise.all 로딩) | +| 2 | `production/WorkOrders/WorkOrderList.tsx` | [x] 스킵 (tabCounts 동기화 복잡도) | +| 3 | `quality/InspectionManagement/InspectionList.tsx` | [x] | +| 4 | `outbound/ShipmentManagement/ShipmentList.tsx` | [x] | +| 5 | `business/construction/contract/ContractListClient.tsx` | [x] | +| 6 | `business/construction/management/ConstructionManagementListClient.tsx` | [x] | +| 7 | `business/construction/bidding/BiddingListClient.tsx` | [x] | +| 8 | `business/construction/estimates/EstimateListClient.tsx` | [x] | +| 9 | `business/construction/order-management/OrderManagementListClient.tsx` | [x] 스킵 (stats 완전 미사용) | +| 10 | `business/construction/progress-billing/ProgressBillingManagementListClient.tsx` | [x] | --- ## Step 4: Phase 5 항목 (성능/타입) -### React.memo 적용 (30+ 컴포넌트) +### React.memo 적용 -- [ ] *Row, *Item, *Card 패턴 컴포넌트 전수 조사 -- [ ] WorkItemCard, CommentItem, ProjectCard 등 우선 적용 -- [ ] 리스트 내 반복 렌더링 컴포넌트에 집중 +> 전수 조사 결과: Card 13개, Item 4개, Row 2개 발견. 리스트 반복 렌더링 3개 우선 적용. -### any 타입 제거 (102곳, 29개 파일) +| # | 컴포넌트 | 파일 | 적용 사유 | 상태 | +|---|---------|------|----------|------| +| 1 | `InfoField` | `organisms/MobileCard.tsx` | 모든 리스트 페이지에서 반복 사용 | [x] | +| 2 | `CommentItem` | `board/CommentSection/CommentItem.tsx` | map() 반복 렌더링 | [x] | +| 3 | `WorkItemCard` | `production/WorkerScreen/WorkItemCard.tsx` | map() 반복 렌더링 | [x] | -- [ ] types/ 파일 (4개) -- [ ] action error handler (50+ 파일) → Step 1 createServerAction에서 자동 해결 -- [ ] 컴포넌트 props (20개) +> 스킵 사유: +> - MobileCard: ReactNode props (headerBadges, statusBadge, infoGrid) 매 렌더 새 참조 → memo 비효율적 +> - DepartmentTreeItem: Set props (expandedIds, selectedIds) 매 렌더 새 참조 → memo 비효율적 +> - construction cards (OrderMemoCard 등 8개): 단일 사용, 폼 카드 → ROI 낮음 -### @ts-ignore 제거 (25개 파일) +### any 타입 제거 -- [ ] 하나씩 확인하며 제거 (숨겨진 에러 확인) +> 전수 조사 결과: 139건/58파일. Step 1에서 action 에러핸들러 50곳+ 자동 해결 완료. + +| # | 대상 | 건수 | 상태 | +|---|------|------|------| +| 1 | action error handler | 50+ | [x] Step 1에서 자동 해결 | +| 2 | `lib/api/logger.ts` (any → unknown) | 7건 | [x] | +| 3 | items/ 도메인 (ItemMasterDataManagement 등) | ~60건 | [x] 스킵 (도메인 복잡도 높음, 별도 작업 필요) | +| 4 | dev/ 대시보드 프로토타입 | 6건 | [x] 스킵 (비프로덕션 코드) | +| 5 | Form 에러 캐스팅 (errors as any) | 26건 | [x] 스킵 (React Hook Form 타입 시스템 변경 필요) | + +### @ts-ignore / @ts-expect-error 제거 + +- [x] 전수 조사 결과: **0건** (이미 제거 완료) --- @@ -266,3 +281,7 @@ const { data: stats } = useStatsLoader(getProcessStats, { total: 0, active: 0, i | 2026-02-09 | 초기 작성 - 39개 페이지 대상 | | 2026-02-09 | **전면 수정** - 실제 코드 분석 결과 UniversalListPage가 이미 처리 중. action.ts 에러처리 래퍼 + 삭제 다이얼로그 훅 + Stats 로딩 훅으로 변경 | | 2026-02-09 | **Step 1 완료** - 전체 action.ts 마이그레이션 완료 (파일럿 4개 + Wave A~G + lib/actions 2개). serverFetch 의도적 유지 2개 파일 외 전부 executeServerAction으로 전환. 타입체크 0 에러. | +| 2026-02-09 | **Step 2 완료** - useDeleteDialog 훅 생성 및 6개 파일 적용 (1개 스킵). 표준 패턴(VehicleList, VehicleLogList, ForkliftList, QuoteManagementClient)은 4 useState+4 핸들러 제거. 커스텀 패턴(BillManagement, VendorManagement)은 커스텀 onDelete 콜백으로 로컬 상태 업데이트 유지. ProcessListClient는 패턴 차이로 스킵. | +| 2026-02-09 | **Step 3 완료** - useStatsLoader 훅 생성(45줄) 및 7개 파일 적용 (3개 스킵). Construction 패턴(5개: Contract, ConstructionManagement, Bidding, Estimate, ProgressBilling) - useState+useEffect→useStatsLoader 1줄 전환. Standard 패턴(2개: InspectionList, ShipmentList) - reload 함수 활용하여 getList 내 stats 리로드 단순화. 스킵: ProcessListClient(stats UI 미사용), WorkOrderList(tabCounts 동기화 복잡), OrderManagementListClient(stats 완전 미사용). 타입체크 0 에러. | +| 2026-02-09 | **Step 4 완료** - React.memo 3개 적용(InfoField, CommentItem, WorkItemCard - 리스트 반복 렌더링 대상). any→unknown 7건(logger.ts). @ts-ignore 0건(이미 제거됨). 잔여 any 92건은 items/ 도메인(60건, 복잡도 높음), dev/ 프로토타입(6건), Form 에러캐스팅(26건, RHF 타입 변경 필요)으로 별도 작업 필요. | +| 2026-02-09 | **Phase 1 전체 완료** - Step 1(executeServerAction 82개 파일) + Step 2(useDeleteDialog 6개 파일) + Step 3(useStatsLoader 7개 파일) + Step 4(React.memo 3개 + any→unknown 7건 + @ts-ignore 0건). | diff --git a/src/components/accounting/BillManagement/BillManagementClient.tsx b/src/components/accounting/BillManagement/BillManagementClient.tsx index 42b70a2f..1b3372e2 100644 --- a/src/components/accounting/BillManagement/BillManagementClient.tsx +++ b/src/components/accounting/BillManagement/BillManagementClient.tsx @@ -21,6 +21,7 @@ import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Badge } from '@/components/ui/badge'; import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog'; +import { useDeleteDialog } from '@/hooks/useDeleteDialog'; import { TableRow, TableCell } from '@/components/ui/table'; import { Select, @@ -89,8 +90,21 @@ export function BillManagementClient({ const itemsPerPage = initialPagination.perPage; // 삭제 다이얼로그 - const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const [deleteTargetId, setDeleteTargetId] = useState(null); + const deleteDialog = useDeleteDialog({ + onDelete: async (id) => { + const result = await deleteBill(id); + if (result.success) { + setData(prev => prev.filter(item => item.id !== id)); + setSelectedItems(prev => { + const newSet = new Set(prev); + newSet.delete(id); + return newSet; + }); + } + return result; + }, + entityName: '어음', + }); // 날짜 범위 상태 const [startDate, setStartDate] = useState('2025-09-01'); @@ -151,32 +165,6 @@ export function BillManagementClient({ router.push(`/ko/accounting/bills/${item.id}?mode=view`); }, [router]); - const handleDeleteClick = useCallback((id: string) => { - setDeleteTargetId(id); - setShowDeleteDialog(true); - }, []); - - const handleConfirmDelete = useCallback(async () => { - if (deleteTargetId) { - setIsLoading(true); - const result = await deleteBill(deleteTargetId); - - if (result.success) { - setData(prev => prev.filter(item => item.id !== deleteTargetId)); - setSelectedItems(prev => { - const newSet = new Set(prev); - newSet.delete(deleteTargetId); - return newSet; - }); - toast.success('삭제되었습니다.'); - } else { - toast.error(result.error || '삭제에 실패했습니다.'); - } - setIsLoading(false); - } - setShowDeleteDialog(false); - setDeleteTargetId(null); - }, [deleteTargetId]); // ===== 페이지 변경 ===== const handlePageChange = useCallback((page: number) => { @@ -521,12 +509,12 @@ export function BillManagementClient({ /> ); diff --git a/src/components/accounting/VendorManagement/VendorManagementClient.tsx b/src/components/accounting/VendorManagement/VendorManagementClient.tsx index a130c5dd..295be279 100644 --- a/src/components/accounting/VendorManagement/VendorManagementClient.tsx +++ b/src/components/accounting/VendorManagement/VendorManagementClient.tsx @@ -21,6 +21,7 @@ import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Badge } from '@/components/ui/badge'; import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog'; +import { useDeleteDialog } from '@/hooks/useDeleteDialog'; import { TableRow, TableCell } from '@/components/ui/table'; import { Select, @@ -83,9 +84,21 @@ export function VendorManagementClient({ initialData, initialTotal }: VendorMana const itemsPerPage = 20; // 삭제 다이얼로그 - const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const [deleteTargetId, setDeleteTargetId] = useState(null); - const [isLoading, setIsLoading] = useState(false); + const deleteDialog = useDeleteDialog({ + onDelete: async (id) => { + const result = await deleteClient(id); + if (result.success) { + setData(prev => prev.filter(item => item.id !== id)); + setSelectedItems(prev => { + const newSet = new Set(prev); + newSet.delete(id); + return newSet; + }); + } + return result; + }, + entityName: '거래처', + }); // API 데이터 상태 const [data, setData] = useState(initialData); @@ -178,33 +191,6 @@ export function VendorManagementClient({ initialData, initialTotal }: VendorMana router.push(`/ko/accounting/vendors/${item.id}?mode=edit`); }, [router]); - const handleDeleteClick = useCallback((id: string) => { - setDeleteTargetId(id); - setShowDeleteDialog(true); - }, []); - - const handleConfirmDelete = useCallback(async () => { - if (!deleteTargetId) return; - - setIsLoading(true); - const result = await deleteClient(deleteTargetId); - - if (result.success) { - setData(prev => prev.filter(item => item.id !== deleteTargetId)); - setSelectedItems(prev => { - const newSet = new Set(prev); - newSet.delete(deleteTargetId); - return newSet; - }); - toast.success('거래처가 삭제되었습니다.'); - } else { - toast.error(result.error || '거래처 삭제에 실패했습니다.'); - } - - setIsLoading(false); - setShowDeleteDialog(false); - setDeleteTargetId(null); - }, [deleteTargetId]); // ===== 통계 카드 ===== const statCards: StatCard[] = useMemo(() => { @@ -309,7 +295,7 @@ export function VendorManagementClient({ initialData, initialTotal }: VendorMana variant="ghost" size="icon" className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50" - onClick={() => handleDeleteClick(item.id)} + onClick={() => deleteDialog.single.open(item.id)} > @@ -318,7 +304,7 @@ export function VendorManagementClient({ initialData, initialTotal }: VendorMana ); - }, [handleRowClick, handleEdit, handleDeleteClick]); + }, [handleRowClick, handleEdit, deleteDialog.single.open]); // ===== 모바일 카드 렌더링 ===== const renderMobileCard = useCallback(( @@ -367,7 +353,7 @@ export function VendorManagementClient({ initialData, initialTotal }: VendorMana @@ -377,7 +363,7 @@ export function VendorManagementClient({ initialData, initialTotal }: VendorMana onClick={() => handleRowClick(item)} /> ); - }, [handleRowClick, handleEdit, handleDeleteClick]); + }, [handleRowClick, handleEdit, deleteDialog.single.open]); // ===== UniversalListPage Config ===== const config: UniversalListConfig = useMemo( @@ -575,12 +561,12 @@ export function VendorManagementClient({ initialData, initialTotal }: VendorMana {/* 삭제 확인 다이얼로그 */} ); diff --git a/src/components/board/CommentSection/CommentItem.tsx b/src/components/board/CommentSection/CommentItem.tsx index 2d1b6c64..c1154bf5 100644 --- a/src/components/board/CommentSection/CommentItem.tsx +++ b/src/components/board/CommentSection/CommentItem.tsx @@ -10,7 +10,7 @@ * - 삭제 클릭 시 "정말 삭제하시겠습니까?" 확인 Alert */ -import { useState, useCallback } from 'react'; +import { useState, useCallback, memo } from 'react'; import { format } from 'date-fns'; import { User, Pencil, Trash2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -25,7 +25,7 @@ interface CommentItemProps { onDelete: (id: string) => void; } -export function CommentItem({ +export const CommentItem = memo(function CommentItem({ comment, currentUserId, onUpdate, @@ -156,6 +156,6 @@ export function CommentItem({ /> ); -} +}); export default CommentItem; \ No newline at end of file diff --git a/src/components/business/construction/bidding/BiddingListClient.tsx b/src/components/business/construction/bidding/BiddingListClient.tsx index 9caa5393..98e3f30c 100644 --- a/src/components/business/construction/bidding/BiddingListClient.tsx +++ b/src/components/business/construction/bidding/BiddingListClient.tsx @@ -11,7 +11,8 @@ * - 등록 버튼 없음 (견적완료 시 자동 등록) */ -import { useState, useMemo, useEffect } from 'react'; +import { useState, useMemo } from 'react'; +import { useStatsLoader } from '@/hooks/useStatsLoader'; import { FileText, Clock, Trophy, Pencil } from 'lucide-react'; import { useListHandlers } from '@/hooks/useListHandlers'; import { Button } from '@/components/ui/button'; @@ -84,18 +85,7 @@ export default function BiddingListClient({ initialData = [], initialStats }: Bi // 검색어 const [searchQuery, setSearchQuery] = useState(''); // Stats 데이터 - const [stats, setStats] = useState(initialStats || null); - - // Stats 로드 - useEffect(() => { - if (!initialStats) { - getBiddingStats().then((result) => { - if (result.success && result.data) { - setStats(result.data); - } - }); - } - }, [initialStats]); + const { data: stats } = useStatsLoader(getBiddingStats, initialStats); // ===== UniversalListPage Config ===== const config: UniversalListConfig = useMemo( diff --git a/src/components/business/construction/contract/ContractListClient.tsx b/src/components/business/construction/contract/ContractListClient.tsx index 55bb2bf2..50016965 100644 --- a/src/components/business/construction/contract/ContractListClient.tsx +++ b/src/components/business/construction/contract/ContractListClient.tsx @@ -10,7 +10,8 @@ * - filterConfig (multi: 거래처, 계약담당자, 공사PM / single: 상태, 정렬) */ -import { useState, useMemo, useEffect } from 'react'; +import { useState, useMemo } from 'react'; +import { useStatsLoader } from '@/hooks/useStatsLoader'; import { FileText, Clock, CheckCircle, Pencil, Trash2 } from 'lucide-react'; import { useListHandlers } from '@/hooks/useListHandlers'; import { Button } from '@/components/ui/button'; @@ -83,18 +84,7 @@ export default function ContractListClient({ initialData = [], initialStats }: C const [startDate, setStartDate] = useState(''); const [endDate, setEndDate] = useState(''); const [searchQuery, setSearchQuery] = useState(''); - const [stats, setStats] = useState(initialStats || null); - - // Stats 로드 - useEffect(() => { - if (!initialStats) { - getContractStats().then((result) => { - if (result.success && result.data) { - setStats(result.data); - } - }); - } - }, [initialStats]); + const { data: stats } = useStatsLoader(getContractStats, initialStats); // ===== UniversalListPage Config ===== const config: UniversalListConfig = useMemo( diff --git a/src/components/business/construction/estimates/EstimateListClient.tsx b/src/components/business/construction/estimates/EstimateListClient.tsx index f58db1fc..67f9c765 100644 --- a/src/components/business/construction/estimates/EstimateListClient.tsx +++ b/src/components/business/construction/estimates/EstimateListClient.tsx @@ -13,6 +13,7 @@ import { useState, useMemo, useEffect } from 'react'; import { FileText, FileTextIcon, Clock, FileCheck, Pencil } from 'lucide-react'; import { useListHandlers } from '@/hooks/useListHandlers'; +import { useStatsLoader } from '@/hooks/useStatsLoader'; import { Button } from '@/components/ui/button'; import { TableCell, TableRow } from '@/components/ui/table'; import { Checkbox } from '@/components/ui/checkbox'; @@ -68,22 +69,11 @@ export default function EstimateListClient({ initialData = [], initialStats }: E // 검색어 const [searchQuery, setSearchQuery] = useState(''); // Stats 데이터 - const [stats, setStats] = useState(initialStats || null); + const { data: stats } = useStatsLoader(getEstimateStats, initialStats); // 필터 옵션 데이터 const [partnerOptions, setPartnerOptions] = useState([]); const [estimatorOptions, setEstimatorOptions] = useState([]); - // Stats 로드 - useEffect(() => { - if (!initialStats) { - getEstimateStats().then((result) => { - if (result.success && result.data) { - setStats(result.data); - } - }); - } - }, [initialStats]); - // 거래처/견적자 옵션 로드 useEffect(() => { // 거래처 옵션 로드 diff --git a/src/components/business/construction/management/ConstructionManagementListClient.tsx b/src/components/business/construction/management/ConstructionManagementListClient.tsx index 7d64c03e..5fc04149 100644 --- a/src/components/business/construction/management/ConstructionManagementListClient.tsx +++ b/src/components/business/construction/management/ConstructionManagementListClient.tsx @@ -14,7 +14,8 @@ * - 삭제 기능 없음 (수정만 가능) */ -import { useState, useMemo, useCallback, useEffect } from 'react'; +import { useState, useMemo, useCallback } from 'react'; +import { useStatsLoader } from '@/hooks/useStatsLoader'; import { HardHat, Pencil, Clock, CheckCircle } from 'lucide-react'; import { useListHandlers } from '@/hooks/useListHandlers'; import { Button } from '@/components/ui/button'; @@ -84,7 +85,7 @@ export default function ConstructionManagementListClient({ const [activeStatTab, setActiveStatTab] = useState<'all' | 'in_progress' | 'completed'>('all'); const [startDate, setStartDate] = useState(''); const [endDate, setEndDate] = useState(''); - const [stats, setStats] = useState(initialStats || null); + const { data: stats } = useStatsLoader(getConstructionManagementStats, initialStats); const [searchQuery, setSearchQuery] = useState(''); // 달력 관련 상태 @@ -97,17 +98,6 @@ export default function ConstructionManagementListClient({ // 전체 데이터 (달력 이벤트용) const [allConstructions, setAllConstructions] = useState(initialData); - // Stats 로드 - useEffect(() => { - if (!initialStats) { - getConstructionManagementStats().then((result) => { - if (result.success && result.data) { - setStats(result.data); - } - }); - } - }, [initialStats]); - // 필터 옵션 (memo) const siteOptions: MultiSelectOption[] = useMemo(() => MOCK_CM_SITES.map(s => ({ value: s.value, label: s.label })), diff --git a/src/components/business/construction/progress-billing/ProgressBillingManagementListClient.tsx b/src/components/business/construction/progress-billing/ProgressBillingManagementListClient.tsx index f0f02292..dc74ef9b 100644 --- a/src/components/business/construction/progress-billing/ProgressBillingManagementListClient.tsx +++ b/src/components/business/construction/progress-billing/ProgressBillingManagementListClient.tsx @@ -11,7 +11,8 @@ * - 삭제 기능 없음 (조회/수정 전용) */ -import { useState, useMemo, useEffect } from 'react'; +import { useState, useMemo } from 'react'; +import { useStatsLoader } from '@/hooks/useStatsLoader'; import { FileText, Pencil } from 'lucide-react'; import { useListHandlers } from '@/hooks/useListHandlers'; import { Button } from '@/components/ui/button'; @@ -77,20 +78,9 @@ export default function ProgressBillingManagementListClient({ const [activeStatTab, setActiveStatTab] = useState<'all' | 'contractWaiting' | 'contractComplete'>('all'); const [startDate, setStartDate] = useState(''); const [endDate, setEndDate] = useState(''); - const [stats, setStats] = useState(initialStats || null); + const { data: stats } = useStatsLoader(getProgressBillingStats, initialStats); const [searchQuery, setSearchQuery] = useState(''); - // Stats 로드 - useEffect(() => { - if (!initialStats) { - getProgressBillingStats().then((result) => { - if (result.success && result.data) { - setStats(result.data); - } - }); - } - }, [initialStats]); - // ===== UniversalListPage Config ===== const config: UniversalListConfig = useMemo( () => ({ diff --git a/src/components/organisms/MobileCard.tsx b/src/components/organisms/MobileCard.tsx index 9fb6304c..746762e0 100644 --- a/src/components/organisms/MobileCard.tsx +++ b/src/components/organisms/MobileCard.tsx @@ -1,6 +1,6 @@ 'use client'; -import { ReactNode, ComponentType } from 'react'; +import { ReactNode, ComponentType, memo } from 'react'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -22,7 +22,7 @@ export interface InfoFieldProps { className?: string; } -export function InfoField({ +export const InfoField = memo(function InfoField({ label, value, valueClassName = '', @@ -34,7 +34,7 @@ export function InfoField({
{value}
); -} +}); /** * 통합 MobileCard Props diff --git a/src/components/outbound/ShipmentManagement/ShipmentList.tsx b/src/components/outbound/ShipmentManagement/ShipmentList.tsx index a28bc660..9235958b 100644 --- a/src/components/outbound/ShipmentManagement/ShipmentList.tsx +++ b/src/components/outbound/ShipmentManagement/ShipmentList.tsx @@ -12,8 +12,9 @@ * - 하단 출고 스케줄 캘린더 (시간축 주간 뷰) */ -import { useState, useEffect, useMemo, useCallback } from 'react'; +import { useState, useMemo, useCallback } from 'react'; import { useRouter } from 'next/navigation'; +import { useStatsLoader } from '@/hooks/useStatsLoader'; import { Truck, Package, @@ -53,7 +54,7 @@ export function ShipmentList() { const router = useRouter(); // ===== 통계 (외부 관리) ===== - const [shipmentStats, setShipmentStats] = useState(null); + const { data: shipmentStats, reload: reloadStats } = useStatsLoader(getShipmentStats); // ===== 날짜 범위 ===== const today = new Date(); @@ -71,22 +72,6 @@ export function ShipmentList() { const [scheduleView, setScheduleView] = useState('day-time'); const [shipmentData, setShipmentData] = useState([]); - // 초기 통계 로드 - useEffect(() => { - const loadStats = async () => { - try { - const statsResult = await getShipmentStats(); - if (statsResult.success && statsResult.data) { - setShipmentStats(statsResult.data); - } - } catch (error) { - if (isNextRedirectError(error)) throw error; - console.error('[ShipmentList] loadStats error:', error); - } - }; - loadStats(); - }, []); - // ===== 행 클릭 핸들러 ===== const handleRowClick = useCallback( (item: ShipmentItem) => { @@ -191,10 +176,7 @@ export function ShipmentList() { if (result.success) { // 통계 다시 로드 - const statsResult = await getShipmentStats(); - if (statsResult.success && statsResult.data) { - setShipmentStats(statsResult.data); - } + await reloadStats(); // 캘린더용 데이터 저장 setShipmentData(result.data); diff --git a/src/components/production/WorkerScreen/WorkItemCard.tsx b/src/components/production/WorkerScreen/WorkItemCard.tsx index 4ebcfd5a..ca1e5a43 100644 --- a/src/components/production/WorkerScreen/WorkItemCard.tsx +++ b/src/components/production/WorkerScreen/WorkItemCard.tsx @@ -13,7 +13,7 @@ * - 자재 투입 목록: 토글 (쉐브론 아이콘 + 텍스트) */ -import { useState, useCallback } from 'react'; +import { useState, useCallback, memo } from 'react'; import { ChevronDown, ChevronUp, Pencil, Trash2, ImageIcon } from 'lucide-react'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; @@ -43,7 +43,7 @@ interface WorkItemCardProps { onInspectionToggle?: (itemId: string, checked: boolean) => void; } -export function WorkItemCard({ +export const WorkItemCard = memo(function WorkItemCard({ item, onStepClick, onEditMaterial, @@ -230,7 +230,7 @@ export function WorkItemCard({ ); -} +}); // ===== 스크린 전용: 절단정보 ===== function ScreenCuttingInfo({ width, sheets }: { width: number; sheets: number }) { diff --git a/src/components/quality/InspectionManagement/InspectionList.tsx b/src/components/quality/InspectionManagement/InspectionList.tsx index 6bee9404..3b81e5fe 100644 --- a/src/components/quality/InspectionManagement/InspectionList.tsx +++ b/src/components/quality/InspectionManagement/InspectionList.tsx @@ -12,6 +12,7 @@ import { useState, useEffect, useMemo, useCallback } from 'react'; import { useRouter } from 'next/navigation'; +import { useStatsLoader } from '@/hooks/useStatsLoader'; import { ClipboardCheck, Plus, @@ -53,11 +54,7 @@ export function InspectionList() { const router = useRouter(); // ===== 통계 ===== - const [statsData, setStatsData] = useState({ - receptionCount: 0, - inProgressCount: 0, - completedCount: 0, - }); + const { data: statsData, reload: reloadStats } = useStatsLoader(getInspectionStats); // ===== 날짜 범위 ===== const today = new Date(); @@ -77,22 +74,6 @@ export function InspectionList() { const [calendarStatusFilter, setCalendarStatusFilter] = useState('전체'); const [calendarInspectorFilter, setCalendarInspectorFilter] = useState('전체'); - // 초기 통계 로드 - useEffect(() => { - const loadStats = async () => { - try { - const result = await getInspectionStats(); - if (result.success && result.data) { - setStatsData(result.data); - } - } catch (error) { - if (isNextRedirectError(error)) throw error; - console.error('[InspectionList] loadStats error:', error); - } - }; - loadStats(); - }, []); - // 캘린더 데이터 로드 const loadCalendarData = useCallback(async () => { try { @@ -164,19 +145,19 @@ export function InspectionList() { () => [ { label: '접수', - value: statsData.receptionCount, + value: statsData?.receptionCount ?? 0, icon: FileInput, iconColor: 'text-gray-600', }, { label: '진행중', - value: statsData.inProgressCount, + value: statsData?.inProgressCount ?? 0, icon: Loader2, iconColor: 'text-blue-600', }, { label: '완료', - value: statsData.completedCount, + value: statsData?.completedCount ?? 0, icon: CheckCircle2, iconColor: 'text-green-600', }, @@ -236,10 +217,7 @@ export function InspectionList() { if (result.success) { // 통계 재로드 - const statsResult = await getInspectionStats(); - if (statsResult.success && statsResult.data) { - setStatsData(statsResult.data); - } + await reloadStats(); return { success: true, diff --git a/src/components/quotes/QuoteManagementClient.tsx b/src/components/quotes/QuoteManagementClient.tsx index 35f3a040..8e79c05c 100644 --- a/src/components/quotes/QuoteManagementClient.tsx +++ b/src/components/quotes/QuoteManagementClient.tsx @@ -10,7 +10,7 @@ * - 삭제/일괄삭제 다이얼로그 */ -import { useState, useMemo, useCallback, useTransition } from 'react'; +import { useState, useMemo, useCallback } from 'react'; import { useRouter } from 'next/navigation'; import { format, startOfMonth, endOfMonth } from 'date-fns'; import { @@ -51,6 +51,7 @@ import { import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard'; import { StandardDialog } from '@/components/molecules/StandardDialog'; import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog'; +import { useDeleteDialog } from '@/hooks/useDeleteDialog'; import { toast } from 'sonner'; import { formatAmount, formatAmountManwon } from '@/utils/formatAmount'; import type { Quote, QuoteFilterType } from './types'; @@ -69,7 +70,12 @@ export function QuoteManagementClient({ initialPagination, }: QuoteManagementClientProps) { const router = useRouter(); - const [isPending, startTransition] = useTransition(); + const deleteDialog = useDeleteDialog({ + onDelete: deleteQuote, + onBulkDelete: bulkDeleteQuotes, + onSuccess: () => window.location.reload(), + entityName: '견적', + }); // ===== 날짜 필터 상태 ===== const today = new Date(); @@ -84,12 +90,6 @@ export function QuoteManagementClient({ const [isCalculationDialogOpen, setIsCalculationDialogOpen] = useState(false); const [calculationQuote, setCalculationQuote] = useState(null); - // ===== 삭제 다이얼로그 상태 ===== - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const [deleteTargetId, setDeleteTargetId] = useState(null); - const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false); - const [bulkDeleteIds, setBulkDeleteIds] = useState([]); - // ===== 전체 데이터 상태 (통계 계산용) ===== const [allQuotes, setAllQuotes] = useState(initialData); @@ -102,57 +102,6 @@ export function QuoteManagementClient({ router.push(`/sales/quote-management/${quote.id}?mode=edit`); }, [router]); - const handleDeleteClick = useCallback((id: string) => { - setDeleteTargetId(id); - setIsDeleteDialogOpen(true); - }, []); - - const handleConfirmDelete = useCallback(async () => { - if (!deleteTargetId) return; - - startTransition(async () => { - const result = await deleteQuote(deleteTargetId); - - if (result.success) { - const quote = allQuotes.find((q) => q.id === deleteTargetId); - setAllQuotes(allQuotes.filter((q) => q.id !== deleteTargetId)); - toast.success(`견적이 삭제되었습니다${quote ? `: ${quote.quoteNumber}` : ''}`); - window.location.reload(); - } else { - toast.error(result.error || '삭제에 실패했습니다.'); - } - - setIsDeleteDialogOpen(false); - setDeleteTargetId(null); - }); - }, [deleteTargetId, allQuotes]); - - const handleBulkDelete = useCallback((selectedIds: string[]) => { - if (selectedIds.length === 0) { - toast.error('삭제할 항목을 선택해주세요'); - return; - } - setBulkDeleteIds(selectedIds); - setIsBulkDeleteDialogOpen(true); - }, []); - - const handleConfirmBulkDelete = useCallback(async () => { - startTransition(async () => { - const result = await bulkDeleteQuotes(bulkDeleteIds); - - if (result.success) { - setAllQuotes(allQuotes.filter((q) => !bulkDeleteIds.includes(q.id))); - toast.success(`${bulkDeleteIds.length}개의 견적이 삭제되었습니다`); - window.location.reload(); - } else { - toast.error(result.error || '일괄 삭제에 실패했습니다.'); - } - - setIsBulkDeleteDialogOpen(false); - setBulkDeleteIds([]); - }); - }, [bulkDeleteIds, allQuotes]); - const handleViewHistory = useCallback((quote: Quote) => { toast.info(`수정 이력: ${quote.quoteNumber} (${quote.currentRevision}차 수정)`); }, []); @@ -413,7 +362,7 @@ export function QuoteManagementClient({ ), // 일괄 삭제 핸들러 - onBulkDelete: handleBulkDelete, + onBulkDelete: deleteDialog.bulk.open, // 테이블 행 렌더링 renderTableRow: ( @@ -489,8 +438,8 @@ export function QuoteManagementClient({ @@ -585,9 +534,9 @@ export function QuoteManagementClient({ className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-transparent" onClick={(e) => { e.stopPropagation(); - handleDeleteClick(quote.id); + deleteDialog.single.open(quote.id); }} - disabled={isPending} + disabled={deleteDialog.isPending} > 삭제 @@ -600,7 +549,7 @@ export function QuoteManagementClient({ ); }, }), - [computeStats, router, handleView, handleEdit, handleDeleteClick, handleViewHistory, handleBulkDelete, getRevisionBadge, isPending, startDate, endDate, productCategoryFilter, statusFilter] + [computeStats, router, handleView, handleEdit, handleViewHistory, getRevisionBadge, deleteDialog, startDate, endDate, productCategoryFilter, statusFilter] ); return ( @@ -735,34 +684,28 @@ export function QuoteManagementClient({ {/* 삭제 확인 다이얼로그 */} - {deleteTargetId - ? `견적번호: ${allQuotes.find((q) => q.id === deleteTargetId)?.quoteNumber || deleteTargetId}` + {deleteDialog.single.targetId + ? `견적번호: ${allQuotes.find((q) => q.id === deleteDialog.single.targetId)?.quoteNumber || deleteDialog.single.targetId}` : ''}
이 견적을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다. } - loading={isPending} - onConfirm={handleConfirmDelete} + loading={deleteDialog.isPending} + onConfirm={deleteDialog.single.confirm} /> {/* 일괄 삭제 확인 다이얼로그 */} - 선택한 {bulkDeleteIds.length}개의 견적을 삭제하시겠습니까? -
- 삭제된 데이터는 복구할 수 없습니다. - - } - loading={isPending} - onConfirm={handleConfirmBulkDelete} + open={deleteDialog.bulk.isOpen} + onOpenChange={deleteDialog.bulk.onOpenChange} + description={`선택한 ${deleteDialog.bulk.ids.length}개의 견적을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.`} + loading={deleteDialog.isPending} + onConfirm={deleteDialog.bulk.confirm} /> ); diff --git a/src/components/settings/RankManagement/actions.ts b/src/components/settings/RankManagement/actions.ts index cd57b9a3..b95bd77a 100644 --- a/src/components/settings/RankManagement/actions.ts +++ b/src/components/settings/RankManagement/actions.ts @@ -1,11 +1,8 @@ 'use server'; - -import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action'; +import { createCrudService, type ActionResult } from '@/lib/api/create-crud-service'; import type { Rank } from './types'; -const API_URL = process.env.NEXT_PUBLIC_API_URL; - // ===== API 응답 타입 ===== interface PositionApiData { id: number; @@ -18,60 +15,45 @@ interface PositionApiData { updated_at?: string; } -// ===== 데이터 변환: API → Frontend ===== -function transformApiToFrontend(apiData: PositionApiData): Rank { - return { - id: apiData.id, - name: apiData.name, - order: apiData.sort_order, - isActive: apiData.is_active, - createdAt: apiData.created_at, - updatedAt: apiData.updated_at, - }; -} +// ===== CRUD 서비스 생성 ===== +const rankService = createCrudService({ + basePath: '/api/v1/positions', + transform: (api) => ({ + id: api.id, + name: api.name, + order: api.sort_order, + isActive: api.is_active, + createdAt: api.created_at, + updatedAt: api.updated_at, + }), + entityName: '직급', + defaultQueryParams: { type: 'rank' }, + defaultCreateBody: { type: 'rank' }, +}); + +// ===== Server Action 래퍼 ===== +// Next.js Server Action은 'use server' 파일에서 직접 선언된 async function만 인식 +// 팩토리 반환 함수를 직접 export하면 Server Action으로 인식 안 될 수 있음 -// ===== 직급 목록 조회 ===== export async function getRanks(params?: { is_active?: boolean; q?: string; }): Promise> { - const searchParams = new URLSearchParams(); - searchParams.set('type', 'rank'); - if (params?.is_active !== undefined) { - searchParams.set('is_active', params.is_active.toString()); - } - if (params?.q) { - searchParams.set('q', params.q); - } - - return executeServerAction({ - url: `${API_URL}/api/v1/positions?${searchParams.toString()}`, - transform: (data: PositionApiData[]) => data.map(transformApiToFrontend), - errorMessage: '직급 목록 조회에 실패했습니다.', - }); + return rankService.getList(params); } -// ===== 직급 생성 ===== export async function createRank(data: { name: string; sort_order?: number; is_active?: boolean; }): Promise> { - return executeServerAction({ - url: `${API_URL}/api/v1/positions`, - method: 'POST', - body: { - type: 'rank', - name: data.name, - sort_order: data.sort_order, - is_active: data.is_active ?? true, - }, - transform: transformApiToFrontend, - errorMessage: '직급 생성에 실패했습니다.', + return rankService.create({ + name: data.name, + sort_order: data.sort_order, + is_active: data.is_active ?? true, }); } -// ===== 직급 수정 ===== export async function updateRank( id: number, data: { @@ -80,32 +62,15 @@ export async function updateRank( is_active?: boolean; } ): Promise> { - return executeServerAction({ - url: `${API_URL}/api/v1/positions/${id}`, - method: 'PUT', - body: data, - transform: transformApiToFrontend, - errorMessage: '직급 수정에 실패했습니다.', - }); + return rankService.update(id, data); } -// ===== 직급 삭제 ===== export async function deleteRank(id: number): Promise { - return executeServerAction({ - url: `${API_URL}/api/v1/positions/${id}`, - method: 'DELETE', - errorMessage: '직급 삭제에 실패했습니다.', - }); + return rankService.remove(id); } -// ===== 직급 순서 변경 ===== export async function reorderRanks( items: { id: number; sort_order: number }[] ): Promise { - return executeServerAction({ - url: `${API_URL}/api/v1/positions/reorder`, - method: 'PUT', - body: { items }, - errorMessage: '순서 변경에 실패했습니다.', - }); -} \ No newline at end of file + return rankService.reorder(items); +} diff --git a/src/components/vehicle-management/ForkliftList/index.tsx b/src/components/vehicle-management/ForkliftList/index.tsx index 63aae4cf..fa19919e 100644 --- a/src/components/vehicle-management/ForkliftList/index.tsx +++ b/src/components/vehicle-management/ForkliftList/index.tsx @@ -5,7 +5,7 @@ * 레거시 5130 사이트 컬럼 구조 기반 */ -import { useState, useMemo, useCallback, useTransition } from 'react'; +import { useState, useMemo, useCallback } from 'react'; import { useRouter } from 'next/navigation'; import { Truck, Edit, Trash2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -20,7 +20,7 @@ import { } from '@/components/templates/UniversalListPage'; import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard'; import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog'; -import { toast } from 'sonner'; +import { useDeleteDialog } from '@/hooks/useDeleteDialog'; import type { Forklift } from '../types'; import { getForklifts, deleteForklift, bulkDeleteForklifts } from './actions'; @@ -30,12 +30,13 @@ interface ForkliftListProps { export function ForkliftList({ initialData }: ForkliftListProps) { const router = useRouter(); - const [isPending, startTransition] = useTransition(); + const deleteDialog = useDeleteDialog({ + onDelete: deleteForklift, + onBulkDelete: bulkDeleteForklifts, + onSuccess: () => window.location.reload(), + entityName: '지게차', + }); - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const [deleteTargetId, setDeleteTargetId] = useState(null); - const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false); - const [bulkDeleteIds, setBulkDeleteIds] = useState([]); const [allData, setAllData] = useState(initialData); const handleView = useCallback( @@ -52,57 +53,6 @@ export function ForkliftList({ initialData }: ForkliftListProps) { [router] ); - const handleDeleteClick = useCallback((id: string) => { - setDeleteTargetId(id); - setIsDeleteDialogOpen(true); - }, []); - - const handleConfirmDelete = useCallback(async () => { - if (!deleteTargetId) return; - - startTransition(async () => { - const result = await deleteForklift(deleteTargetId); - - if (result.success) { - const forklift = allData.find((f) => f.id === deleteTargetId); - setAllData(allData.filter((f) => f.id !== deleteTargetId)); - toast.success(`지게차가 삭제되었습니다${forklift ? `: ${forklift.vehicleNumber}` : ''}`); - window.location.reload(); - } else { - toast.error(result.error || '삭제에 실패했습니다.'); - } - - setIsDeleteDialogOpen(false); - setDeleteTargetId(null); - }); - }, [deleteTargetId, allData]); - - const handleBulkDelete = useCallback((selectedIds: string[]) => { - if (selectedIds.length === 0) { - toast.error('삭제할 항목을 선택해주세요'); - return; - } - setBulkDeleteIds(selectedIds); - setIsBulkDeleteDialogOpen(true); - }, []); - - const handleConfirmBulkDelete = useCallback(async () => { - startTransition(async () => { - const result = await bulkDeleteForklifts(bulkDeleteIds); - - if (result.success) { - setAllData(allData.filter((f) => !bulkDeleteIds.includes(f.id))); - toast.success(`${bulkDeleteIds.length}개의 지게차가 삭제되었습니다`); - window.location.reload(); - } else { - toast.error(result.error || '일괄 삭제에 실패했습니다.'); - } - - setIsBulkDeleteDialogOpen(false); - setBulkDeleteIds([]); - }); - }, [bulkDeleteIds, allData]); - const config: UniversalListConfig = useMemo( () => ({ title: '지게차 관리', @@ -190,7 +140,7 @@ export function ForkliftList({ initialData }: ForkliftListProps) { ), - onBulkDelete: handleBulkDelete, + onBulkDelete: deleteDialog.bulk.open, renderTableRow: ( forklift: Forklift, @@ -314,9 +264,9 @@ export function ForkliftList({ initialData }: ForkliftListProps) { className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-transparent" onClick={(e) => { e.stopPropagation(); - handleDeleteClick(forklift.id); + deleteDialog.single.open(forklift.id); }} - disabled={isPending} + disabled={deleteDialog.isPending} > 삭제 @@ -328,7 +278,7 @@ export function ForkliftList({ initialData }: ForkliftListProps) { ); }, }), - [router, handleView, handleEdit, handleDeleteClick, handleBulkDelete, isPending] + [router, handleView, handleEdit, deleteDialog] ); return ( @@ -336,31 +286,27 @@ export function ForkliftList({ initialData }: ForkliftListProps) { - {deleteTargetId - ? `차량번호: ${allData.find((f) => f.id === deleteTargetId)?.vehicleNumber || deleteTargetId}` + {deleteDialog.single.targetId + ? `차량번호: ${allData.find((f) => f.id === deleteDialog.single.targetId)?.vehicleNumber || deleteDialog.single.targetId}` : ''}
이 지게차를 삭제하시겠습니까? } - loading={isPending} - onConfirm={handleConfirmDelete} + loading={deleteDialog.isPending} + onConfirm={deleteDialog.single.confirm} /> - 선택한 {bulkDeleteIds.length}개의 지게차를 삭제하시겠습니까? - - } - loading={isPending} - onConfirm={handleConfirmBulkDelete} + open={deleteDialog.bulk.isOpen} + onOpenChange={deleteDialog.bulk.onOpenChange} + description={`선택한 ${deleteDialog.bulk.ids.length}개의 지게차를 삭제하시겠습니까?`} + loading={deleteDialog.isPending} + onConfirm={deleteDialog.bulk.confirm} /> ); diff --git a/src/components/vehicle-management/VehicleList/index.tsx b/src/components/vehicle-management/VehicleList/index.tsx index 36786c0d..29834b39 100644 --- a/src/components/vehicle-management/VehicleList/index.tsx +++ b/src/components/vehicle-management/VehicleList/index.tsx @@ -5,7 +5,7 @@ * 레거시 5130 사이트 컬럼 구조 기반 (2025-01-28 스크린샷 검증) */ -import { useState, useMemo, useCallback, useTransition } from 'react'; +import { useState, useMemo, useCallback } from 'react'; import { useRouter } from 'next/navigation'; import { Car, Edit, Trash2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -20,7 +20,7 @@ import { } from '@/components/templates/UniversalListPage'; import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard'; import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog'; -import { toast } from 'sonner'; +import { useDeleteDialog } from '@/hooks/useDeleteDialog'; import type { Vehicle } from '../types'; import { getVehicles, deleteVehicle, bulkDeleteVehicles } from './actions'; @@ -30,14 +30,15 @@ interface VehicleListProps { export function VehicleList({ initialData }: VehicleListProps) { const router = useRouter(); - const [isPending, startTransition] = useTransition(); - - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const [deleteTargetId, setDeleteTargetId] = useState(null); - const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false); - const [bulkDeleteIds, setBulkDeleteIds] = useState([]); const [allData, setAllData] = useState(initialData); + const deleteDialog = useDeleteDialog({ + onDelete: deleteVehicle, + onBulkDelete: bulkDeleteVehicles, + onSuccess: () => window.location.reload(), + entityName: '차량', + }); + const handleView = useCallback( (vehicle: Vehicle) => { router.push(`/vehicle-management/vehicle/${vehicle.id}`); @@ -52,57 +53,6 @@ export function VehicleList({ initialData }: VehicleListProps) { [router] ); - const handleDeleteClick = useCallback((id: string) => { - setDeleteTargetId(id); - setIsDeleteDialogOpen(true); - }, []); - - const handleConfirmDelete = useCallback(async () => { - if (!deleteTargetId) return; - - startTransition(async () => { - const result = await deleteVehicle(deleteTargetId); - - if (result.success) { - const vehicle = allData.find((v) => v.id === deleteTargetId); - setAllData(allData.filter((v) => v.id !== deleteTargetId)); - toast.success(`차량이 삭제되었습니다${vehicle ? `: ${vehicle.vehicleNumber}` : ''}`); - window.location.reload(); - } else { - toast.error(result.error || '삭제에 실패했습니다.'); - } - - setIsDeleteDialogOpen(false); - setDeleteTargetId(null); - }); - }, [deleteTargetId, allData]); - - const handleBulkDelete = useCallback((selectedIds: string[]) => { - if (selectedIds.length === 0) { - toast.error('삭제할 항목을 선택해주세요'); - return; - } - setBulkDeleteIds(selectedIds); - setIsBulkDeleteDialogOpen(true); - }, []); - - const handleConfirmBulkDelete = useCallback(async () => { - startTransition(async () => { - const result = await bulkDeleteVehicles(bulkDeleteIds); - - if (result.success) { - setAllData(allData.filter((v) => !bulkDeleteIds.includes(v.id))); - toast.success(`${bulkDeleteIds.length}개의 차량이 삭제되었습니다`); - window.location.reload(); - } else { - toast.error(result.error || '일괄 삭제에 실패했습니다.'); - } - - setIsBulkDeleteDialogOpen(false); - setBulkDeleteIds([]); - }); - }, [bulkDeleteIds, allData]); - const config: UniversalListConfig = useMemo( () => ({ title: '차량 관리', @@ -188,7 +138,7 @@ export function VehicleList({ initialData }: VehicleListProps) { ), - onBulkDelete: handleBulkDelete, + onBulkDelete: deleteDialog.bulk.open, renderTableRow: ( vehicle: Vehicle, @@ -284,9 +234,9 @@ export function VehicleList({ initialData }: VehicleListProps) { className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-transparent" onClick={(e) => { e.stopPropagation(); - handleDeleteClick(vehicle.id); + deleteDialog.single.open(vehicle.id); }} - disabled={isPending} + disabled={deleteDialog.isPending} > 삭제 @@ -298,7 +248,7 @@ export function VehicleList({ initialData }: VehicleListProps) { ); }, }), - [router, handleView, handleEdit, handleDeleteClick, handleBulkDelete, isPending] + [router, handleView, handleEdit, deleteDialog] ); return ( @@ -306,33 +256,19 @@ export function VehicleList({ initialData }: VehicleListProps) { - {deleteTargetId - ? `차량번호: ${allData.find((v) => v.id === deleteTargetId)?.vehicleNumber || deleteTargetId}` - : ''} -
- 이 차량을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다. - - } - loading={isPending} - onConfirm={handleConfirmDelete} + open={deleteDialog.single.isOpen} + onOpenChange={deleteDialog.single.onOpenChange} + description="이 차량을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다." + loading={deleteDialog.isPending} + onConfirm={deleteDialog.single.confirm} /> - 선택한 {bulkDeleteIds.length}개의 차량을 삭제하시겠습니까? -
- 삭제된 데이터는 복구할 수 없습니다. - - } - loading={isPending} - onConfirm={handleConfirmBulkDelete} + open={deleteDialog.bulk.isOpen} + onOpenChange={deleteDialog.bulk.onOpenChange} + description={`선택한 ${deleteDialog.bulk.ids.length}개의 차량을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.`} + loading={deleteDialog.isPending} + onConfirm={deleteDialog.bulk.confirm} /> ); diff --git a/src/components/vehicle-management/VehicleLogList/index.tsx b/src/components/vehicle-management/VehicleLogList/index.tsx index 917b5376..18e914bb 100644 --- a/src/components/vehicle-management/VehicleLogList/index.tsx +++ b/src/components/vehicle-management/VehicleLogList/index.tsx @@ -4,7 +4,7 @@ * 차량일지/월간사진기록 리스트 - UniversalListPage 기반 */ -import { useState, useMemo, useCallback, useTransition } from 'react'; +import { useState, useMemo, useCallback } from 'react'; import { useRouter } from 'next/navigation'; import { FileText, Edit, Trash2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -19,7 +19,7 @@ import { } from '@/components/templates/UniversalListPage'; import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard'; import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog'; -import { toast } from 'sonner'; +import { useDeleteDialog } from '@/hooks/useDeleteDialog'; import type { VehicleLog } from '../types'; import { getVehicleLogs, deleteVehicleLog, bulkDeleteVehicleLogs } from './actions'; @@ -30,13 +30,12 @@ interface VehicleLogListProps { export function VehicleLogList({ initialData }: VehicleLogListProps) { const router = useRouter(); - const [isPending, startTransition] = useTransition(); - - // ===== 삭제 다이얼로그 상태 ===== - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const [deleteTargetId, setDeleteTargetId] = useState(null); - const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false); - const [bulkDeleteIds, setBulkDeleteIds] = useState([]); + const deleteDialog = useDeleteDialog({ + onDelete: deleteVehicleLog, + onBulkDelete: bulkDeleteVehicleLogs, + onSuccess: () => window.location.reload(), + entityName: '차량일지', + }); // ===== 전체 데이터 상태 ===== const [allData, setAllData] = useState(initialData); @@ -56,57 +55,6 @@ export function VehicleLogList({ initialData }: VehicleLogListProps) { [router] ); - const handleDeleteClick = useCallback((id: string) => { - setDeleteTargetId(id); - setIsDeleteDialogOpen(true); - }, []); - - const handleConfirmDelete = useCallback(async () => { - if (!deleteTargetId) return; - - startTransition(async () => { - const result = await deleteVehicleLog(deleteTargetId); - - if (result.success) { - const log = allData.find((v) => v.id === deleteTargetId); - setAllData(allData.filter((v) => v.id !== deleteTargetId)); - toast.success(`차량일지가 삭제되었습니다${log ? `: ${log.title}` : ''}`); - window.location.reload(); - } else { - toast.error(result.error || '삭제에 실패했습니다.'); - } - - setIsDeleteDialogOpen(false); - setDeleteTargetId(null); - }); - }, [deleteTargetId, allData]); - - const handleBulkDelete = useCallback((selectedIds: string[]) => { - if (selectedIds.length === 0) { - toast.error('삭제할 항목을 선택해주세요'); - return; - } - setBulkDeleteIds(selectedIds); - setIsBulkDeleteDialogOpen(true); - }, []); - - const handleConfirmBulkDelete = useCallback(async () => { - startTransition(async () => { - const result = await bulkDeleteVehicleLogs(bulkDeleteIds); - - if (result.success) { - setAllData(allData.filter((v) => !bulkDeleteIds.includes(v.id))); - toast.success(`${bulkDeleteIds.length}개의 차량일지가 삭제되었습니다`); - window.location.reload(); - } else { - toast.error(result.error || '일괄 삭제에 실패했습니다.'); - } - - setIsBulkDeleteDialogOpen(false); - setBulkDeleteIds([]); - }); - }, [bulkDeleteIds, allData]); - // ===== UniversalListPage Config ===== const config: UniversalListConfig = useMemo( () => ({ @@ -186,7 +134,7 @@ export function VehicleLogList({ initialData }: VehicleLogListProps) { ), - onBulkDelete: handleBulkDelete, + onBulkDelete: deleteDialog.bulk.open, renderTableRow: ( log: VehicleLog, @@ -257,9 +205,9 @@ export function VehicleLogList({ initialData }: VehicleLogListProps) { className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-transparent" onClick={(e) => { e.stopPropagation(); - handleDeleteClick(log.id); + deleteDialog.single.open(log.id); }} - disabled={isPending} + disabled={deleteDialog.isPending} > 삭제 @@ -271,7 +219,7 @@ export function VehicleLogList({ initialData }: VehicleLogListProps) { ); }, }), - [router, handleView, handleEdit, handleDeleteClick, handleBulkDelete, isPending] + [router, handleView, handleEdit, deleteDialog] ); return ( @@ -279,31 +227,27 @@ export function VehicleLogList({ initialData }: VehicleLogListProps) { - {deleteTargetId - ? `제목: ${allData.find((v) => v.id === deleteTargetId)?.title || deleteTargetId}` + {deleteDialog.single.targetId + ? `제목: ${allData.find((v) => v.id === deleteDialog.single.targetId)?.title || deleteDialog.single.targetId}` : ''}
이 차량일지를 삭제하시겠습니까? } - loading={isPending} - onConfirm={handleConfirmDelete} + loading={deleteDialog.isPending} + onConfirm={deleteDialog.single.confirm} /> - 선택한 {bulkDeleteIds.length}개의 차량일지를 삭제하시겠습니까? - - } - loading={isPending} - onConfirm={handleConfirmBulkDelete} + open={deleteDialog.bulk.isOpen} + onOpenChange={deleteDialog.bulk.onOpenChange} + description={`선택한 ${deleteDialog.bulk.ids.length}개의 차량일지를 삭제하시겠습니까?`} + loading={deleteDialog.isPending} + onConfirm={deleteDialog.bulk.confirm} /> ); diff --git a/src/hooks/useDeleteDialog.ts b/src/hooks/useDeleteDialog.ts new file mode 100644 index 00000000..999191f2 --- /dev/null +++ b/src/hooks/useDeleteDialog.ts @@ -0,0 +1,143 @@ +'use client'; + +/** + * useDeleteDialog - 삭제 확인 다이얼로그 상태/핸들러 훅 + * + * 단건 삭제 + 일괄 삭제 패턴을 하나의 훅으로 통합. + * DeleteConfirmDialog props와 직접 연결 가능. + * + * @example + * ```tsx + * const deleteDialog = useDeleteDialog({ + * onDelete: deleteVehicle, + * onBulkDelete: bulkDeleteVehicles, + * onSuccess: () => window.location.reload(), + * entityName: '차량', + * }); + * + * // 단건 삭제 트리거 + * + * + * // 일괄 삭제 트리거 + * + * + * // 다이얼로그 렌더링 + * + * + * ``` + */ + +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(null); + + // 일괄 삭제 상태 + const [isBulkOpen, setIsBulkOpen] = useState(false); + const [bulkIds, setBulkIds] = useState([]); + + // 로딩 상태 + 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, + }; +} diff --git a/src/hooks/useStatsLoader.ts b/src/hooks/useStatsLoader.ts new file mode 100644 index 00000000..6bac5d71 --- /dev/null +++ b/src/hooks/useStatsLoader.ts @@ -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( + loadFn: () => Promise<{ success: boolean; data?: T }>, + initialData?: T | null, +) { + const [data, setData] = useState(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 }; +} diff --git a/src/lib/api/create-crud-service.ts b/src/lib/api/create-crud-service.ts new file mode 100644 index 00000000..ba9aa440 --- /dev/null +++ b/src/lib/api/create-crud-service.ts @@ -0,0 +1,140 @@ +/** + * CRUD Server Action 팩토리 + * + * 정형적인 CRUD actions.ts 파일의 보일러플레이트를 제거합니다. + * executeServerAction 위에 한 단계 더 추상화하여 + * getList / create / update / remove / reorder 함수를 자동 생성합니다. + * + * 주의: 이 파일은 'use server'가 아닙니다. + * 각 도메인의 actions.ts ('use server')에서 import하여 사용합니다. + * + * @example + * ```typescript + * // RankManagement/actions.ts + * 'use server'; + * const service = createCrudService({ + * basePath: '/api/v1/positions', + * transform: (api) => ({ id: api.id, name: api.name, ... }), + * entityName: '직급', + * defaultQueryParams: { type: 'rank' }, + * defaultCreateBody: { type: 'rank' }, + * }); + * export async function getRanks(params?) { return service.getList(params); } + * ``` + */ + +import { executeServerAction, type ActionResult } from './execute-server-action'; + +// ===== 설정 타입 ===== +export interface CrudServiceConfig { + /** API 경로 (예: '/api/v1/positions') */ + basePath: string; + /** API → Frontend 데이터 변환 함수 */ + transform: (apiData: TApi) => TFrontend; + /** 엔티티 한글명 (에러 메시지용, 예: '직급') */ + entityName: string; + /** 목록 조회 시 기본 쿼리 파라미터 (예: { type: 'rank' }) */ + defaultQueryParams?: Record; + /** 생성 시 body에 추가할 기본값 (예: { type: 'rank' }) */ + defaultCreateBody?: Record; +} + +// ===== 서비스 반환 타입 ===== +export interface CrudService { + getList(params?: { + is_active?: boolean; + q?: string; + }): Promise>; + + create(body: Record): Promise>; + + update( + id: number, + body: Record + ): Promise>; + + remove(id: number): Promise; + + reorder( + items: { id: number; sort_order: number }[] + ): Promise; +} + +// ===== 팩토리 함수 ===== +export function createCrudService( + config: CrudServiceConfig +): CrudService { + const { + basePath, + transform, + entityName, + defaultQueryParams, + defaultCreateBody, + } = config; + + // API URL은 호출 시점에 resolve (SSR 안전) + const getBaseUrl = () => `${process.env.NEXT_PUBLIC_API_URL}${basePath}`; + + return { + async getList(params) { + const searchParams = new URLSearchParams(); + if (defaultQueryParams) { + Object.entries(defaultQueryParams).forEach(([k, v]) => + searchParams.set(k, v) + ); + } + if (params?.is_active !== undefined) { + searchParams.set('is_active', params.is_active.toString()); + } + if (params?.q) { + searchParams.set('q', params.q); + } + + return executeServerAction({ + url: `${getBaseUrl()}?${searchParams.toString()}`, + transform: (data: TApi[]) => data.map(transform), + errorMessage: `${entityName} 목록 조회에 실패했습니다.`, + }); + }, + + async create(body) { + return executeServerAction({ + url: getBaseUrl(), + method: 'POST', + body: { ...defaultCreateBody, ...body }, + transform, + errorMessage: `${entityName} 생성에 실패했습니다.`, + }); + }, + + async update(id, body) { + return executeServerAction({ + url: `${getBaseUrl()}/${id}`, + method: 'PUT', + body, + transform, + errorMessage: `${entityName} 수정에 실패했습니다.`, + }); + }, + + async remove(id) { + return executeServerAction({ + url: `${getBaseUrl()}/${id}`, + method: 'DELETE', + errorMessage: `${entityName} 삭제에 실패했습니다.`, + }); + }, + + async reorder(items) { + return executeServerAction({ + url: `${getBaseUrl()}/reorder`, + method: 'PUT', + body: { items }, + errorMessage: '순서 변경에 실패했습니다.', + }); + }, + }; +} + +// ActionResult 재export (actions.ts에서 import 편의) +export type { ActionResult } from './execute-server-action'; diff --git a/src/lib/api/logger.ts b/src/lib/api/logger.ts index c3d535f9..ec7ffcfa 100644 --- a/src/lib/api/logger.ts +++ b/src/lib/api/logger.ts @@ -19,8 +19,8 @@ interface ApiLogEntry { level: LogLevel; method: string; url: string; - requestData?: any; - responseData?: any; + requestData?: unknown; + responseData?: unknown; statusCode?: number; error?: Error; duration?: number; @@ -58,7 +58,7 @@ class ApiLogger { /** * API 요청 시작 로그 */ - logRequest(method: string, url: string, data?: any): number { + logRequest(method: string, url: string, data?: unknown): number { if (!this.enabled) return Date.now(); const startTime = Date.now(); @@ -88,7 +88,7 @@ class ApiLogger { method: string, url: string, statusCode: number, - data: any, + data: unknown, startTime: number ) { if (!this.enabled) return; @@ -154,7 +154,7 @@ class ApiLogger { /** * 경고 로그 */ - logWarning(message: string, data?: any) { + logWarning(message: string, data?: unknown) { if (!this.enabled) return; const entry: ApiLogEntry = { @@ -172,7 +172,7 @@ class ApiLogger { /** * 디버그 로그 */ - logDebug(message: string, data?: any) { + logDebug(message: string, data?: unknown) { if (!this.enabled) return; const entry: ApiLogEntry = { @@ -348,7 +348,7 @@ export async function loggedFetch( // 개발 도구를 window에 노출 (브라우저 콘솔에서 사용 가능) if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') { - (window as any).apiLogger = apiLogger; + (window as unknown as Record).apiLogger = apiLogger; console.log( '💡 API Logger is available in console as "apiLogger"\n' + ' - apiLogger.getLogs() - View all logs\n' +