Merge remote-tracking branch 'origin/master'
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
# Phase 1 수정: 실제 중복 기반 리팩토링 체크리스트
|
# Phase 1 수정: 실제 중복 기반 리팩토링 체크리스트
|
||||||
|
|
||||||
**작성일**: 2026-02-09
|
**작성일**: 2026-02-09
|
||||||
**상태**: Step 1 완료 / Step 2-4 대기
|
**상태**: Step 1-4 전체 완료 ✅
|
||||||
**관련 문서**: `architecture/[PLAN-2026-02-06] refactoring-roadmap.md`
|
**관련 문서**: `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
|
```typescript
|
||||||
// 사용 전: ~25줄
|
// 사용 전: ~25줄
|
||||||
@@ -164,13 +164,13 @@ const { single, bulk } = useDeleteDialog({
|
|||||||
|
|
||||||
| # | 파일 | 상태 |
|
| # | 파일 | 상태 |
|
||||||
|---|------|------|
|
|---|------|------|
|
||||||
| 1 | `vehicle-management/VehicleList/index.tsx` | [ ] |
|
| 1 | `vehicle-management/VehicleList/index.tsx` | [x] |
|
||||||
| 2 | `vehicle-management/VehicleLogList/index.tsx` | [ ] |
|
| 2 | `vehicle-management/VehicleLogList/index.tsx` | [x] |
|
||||||
| 3 | `vehicle-management/ForkliftList/index.tsx` | [ ] |
|
| 3 | `vehicle-management/ForkliftList/index.tsx` | [x] |
|
||||||
| 4 | `process-management/ProcessListClient.tsx` | [ ] |
|
| 4 | `process-management/ProcessListClient.tsx` | [x] 스킵 (공유 isLoading + 커스텀 stats 업데이트 + 비표준 벌크삭제) |
|
||||||
| 5 | `quotes/QuoteManagementClient.tsx` | [ ] |
|
| 5 | `quotes/QuoteManagementClient.tsx` | [x] |
|
||||||
| 6 | `accounting/BillManagement/BillManagementClient.tsx` | [ ] |
|
| 6 | `accounting/BillManagement/BillManagementClient.tsx` | [x] (커스텀 onDelete 콜백) |
|
||||||
| 7 | `accounting/VendorManagement/VendorManagementClient.tsx` | [ ] |
|
| 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
|
```typescript
|
||||||
// 사용 전: ~15줄
|
// 사용 전: ~15줄
|
||||||
@@ -206,36 +206,51 @@ const { data: stats } = useStatsLoader(getProcessStats, { total: 0, active: 0, i
|
|||||||
|
|
||||||
| # | 파일 | 상태 |
|
| # | 파일 | 상태 |
|
||||||
|---|------|------|
|
|---|------|------|
|
||||||
| 1 | `process-management/ProcessListClient.tsx` | [ ] |
|
| 1 | `process-management/ProcessListClient.tsx` | [x] 스킵 (stats UI 미사용, Promise.all 로딩) |
|
||||||
| 2 | `production/WorkOrders/WorkOrderList.tsx` | [ ] |
|
| 2 | `production/WorkOrders/WorkOrderList.tsx` | [x] 스킵 (tabCounts 동기화 복잡도) |
|
||||||
| 3 | `quality/InspectionManagement/InspectionList.tsx` | [ ] |
|
| 3 | `quality/InspectionManagement/InspectionList.tsx` | [x] |
|
||||||
| 4 | `outbound/ShipmentManagement/ShipmentList.tsx` | [ ] |
|
| 4 | `outbound/ShipmentManagement/ShipmentList.tsx` | [x] |
|
||||||
| 5 | `business/construction/contract/ContractListClient.tsx` | [ ] |
|
| 5 | `business/construction/contract/ContractListClient.tsx` | [x] |
|
||||||
| 6 | `business/construction/management/ConstructionManagementListClient.tsx` | [ ] |
|
| 6 | `business/construction/management/ConstructionManagementListClient.tsx` | [x] |
|
||||||
| 7 | `business/construction/bidding/BiddingListClient.tsx` | [ ] |
|
| 7 | `business/construction/bidding/BiddingListClient.tsx` | [x] |
|
||||||
| 8 | `business/construction/estimates/EstimateListClient.tsx` | [ ] |
|
| 8 | `business/construction/estimates/EstimateListClient.tsx` | [x] |
|
||||||
| 9 | `business/construction/order-management/OrderManagementListClient.tsx` | [ ] |
|
| 9 | `business/construction/order-management/OrderManagementListClient.tsx` | [x] 스킵 (stats 완전 미사용) |
|
||||||
| 10 | `business/construction/progress-billing/ProgressBillingManagementListClient.tsx` | [ ] |
|
| 10 | `business/construction/progress-billing/ProgressBillingManagementListClient.tsx` | [x] |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 4: Phase 5 항목 (성능/타입)
|
## Step 4: Phase 5 항목 (성능/타입)
|
||||||
|
|
||||||
### React.memo 적용 (30+ 컴포넌트)
|
### React.memo 적용
|
||||||
|
|
||||||
- [ ] *Row, *Item, *Card 패턴 컴포넌트 전수 조사
|
> 전수 조사 결과: Card 13개, Item 4개, Row 2개 발견. 리스트 반복 렌더링 3개 우선 적용.
|
||||||
- [ ] WorkItemCard, CommentItem, ProjectCard 등 우선 적용
|
|
||||||
- [ ] 리스트 내 반복 렌더링 컴포넌트에 집중
|
|
||||||
|
|
||||||
### 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에서 자동 해결
|
> - MobileCard: ReactNode props (headerBadges, statusBadge, infoGrid) 매 렌더 새 참조 → memo 비효율적
|
||||||
- [ ] 컴포넌트 props (20개)
|
> - 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 | 초기 작성 - 39개 페이지 대상 |
|
||||||
| 2026-02-09 | **전면 수정** - 실제 코드 분석 결과 UniversalListPage가 이미 처리 중. action.ts 에러처리 래퍼 + 삭제 다이얼로그 훅 + Stats 로딩 훅으로 변경 |
|
| 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 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건). |
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||||
|
import { useDeleteDialog } from '@/hooks/useDeleteDialog';
|
||||||
import { TableRow, TableCell } from '@/components/ui/table';
|
import { TableRow, TableCell } from '@/components/ui/table';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -89,8 +90,21 @@ export function BillManagementClient({
|
|||||||
const itemsPerPage = initialPagination.perPage;
|
const itemsPerPage = initialPagination.perPage;
|
||||||
|
|
||||||
// 삭제 다이얼로그
|
// 삭제 다이얼로그
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const deleteDialog = useDeleteDialog({
|
||||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
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');
|
const [startDate, setStartDate] = useState('2025-09-01');
|
||||||
@@ -151,32 +165,6 @@ export function BillManagementClient({
|
|||||||
router.push(`/ko/accounting/bills/${item.id}?mode=view`);
|
router.push(`/ko/accounting/bills/${item.id}?mode=view`);
|
||||||
}, [router]);
|
}, [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) => {
|
const handlePageChange = useCallback((page: number) => {
|
||||||
@@ -521,12 +509,12 @@ export function BillManagementClient({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<DeleteConfirmDialog
|
<DeleteConfirmDialog
|
||||||
open={showDeleteDialog}
|
open={deleteDialog.single.isOpen}
|
||||||
onOpenChange={setShowDeleteDialog}
|
onOpenChange={deleteDialog.single.onOpenChange}
|
||||||
onConfirm={handleConfirmDelete}
|
onConfirm={deleteDialog.single.confirm}
|
||||||
title="어음 삭제"
|
title="어음 삭제"
|
||||||
description="이 어음을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다."
|
description="이 어음을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다."
|
||||||
loading={isLoading}
|
loading={deleteDialog.isPending}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||||
|
import { useDeleteDialog } from '@/hooks/useDeleteDialog';
|
||||||
import { TableRow, TableCell } from '@/components/ui/table';
|
import { TableRow, TableCell } from '@/components/ui/table';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -83,9 +84,21 @@ export function VendorManagementClient({ initialData, initialTotal }: VendorMana
|
|||||||
const itemsPerPage = 20;
|
const itemsPerPage = 20;
|
||||||
|
|
||||||
// 삭제 다이얼로그
|
// 삭제 다이얼로그
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const deleteDialog = useDeleteDialog({
|
||||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
onDelete: async (id) => {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
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 데이터 상태
|
// API 데이터 상태
|
||||||
const [data, setData] = useState<Vendor[]>(initialData);
|
const [data, setData] = useState<Vendor[]>(initialData);
|
||||||
@@ -178,33 +191,6 @@ export function VendorManagementClient({ initialData, initialTotal }: VendorMana
|
|||||||
router.push(`/ko/accounting/vendors/${item.id}?mode=edit`);
|
router.push(`/ko/accounting/vendors/${item.id}?mode=edit`);
|
||||||
}, [router]);
|
}, [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(() => {
|
const statCards: StatCard[] = useMemo(() => {
|
||||||
@@ -309,7 +295,7 @@ export function VendorManagementClient({ initialData, initialTotal }: VendorMana
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50"
|
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)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -318,7 +304,7 @@ export function VendorManagementClient({ initialData, initialTotal }: VendorMana
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
}, [handleRowClick, handleEdit, handleDeleteClick]);
|
}, [handleRowClick, handleEdit, deleteDialog.single.open]);
|
||||||
|
|
||||||
// ===== 모바일 카드 렌더링 =====
|
// ===== 모바일 카드 렌더링 =====
|
||||||
const renderMobileCard = useCallback((
|
const renderMobileCard = useCallback((
|
||||||
@@ -367,7 +353,7 @@ export function VendorManagementClient({ initialData, initialTotal }: VendorMana
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="flex-1 text-red-500 border-red-200 hover:bg-red-50 hover:text-red-600"
|
className="flex-1 text-red-500 border-red-200 hover:bg-red-50 hover:text-red-600"
|
||||||
onClick={() => handleDeleteClick(item.id)}
|
onClick={() => deleteDialog.single.open(item.id)}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4 mr-2" /> 삭제
|
<Trash2 className="w-4 h-4 mr-2" /> 삭제
|
||||||
</Button>
|
</Button>
|
||||||
@@ -377,7 +363,7 @@ export function VendorManagementClient({ initialData, initialTotal }: VendorMana
|
|||||||
onClick={() => handleRowClick(item)}
|
onClick={() => handleRowClick(item)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}, [handleRowClick, handleEdit, handleDeleteClick]);
|
}, [handleRowClick, handleEdit, deleteDialog.single.open]);
|
||||||
|
|
||||||
// ===== UniversalListPage Config =====
|
// ===== UniversalListPage Config =====
|
||||||
const config: UniversalListConfig<Vendor> = useMemo(
|
const config: UniversalListConfig<Vendor> = useMemo(
|
||||||
@@ -575,12 +561,12 @@ export function VendorManagementClient({ initialData, initialTotal }: VendorMana
|
|||||||
|
|
||||||
{/* 삭제 확인 다이얼로그 */}
|
{/* 삭제 확인 다이얼로그 */}
|
||||||
<DeleteConfirmDialog
|
<DeleteConfirmDialog
|
||||||
open={showDeleteDialog}
|
open={deleteDialog.single.isOpen}
|
||||||
onOpenChange={setShowDeleteDialog}
|
onOpenChange={deleteDialog.single.onOpenChange}
|
||||||
onConfirm={handleConfirmDelete}
|
onConfirm={deleteDialog.single.confirm}
|
||||||
title="거래처 삭제"
|
title="거래처 삭제"
|
||||||
description="이 거래처를 삭제하시겠습니까? 이 작업은 취소할 수 없습니다."
|
description="이 거래처를 삭제하시겠습니까? 이 작업은 취소할 수 없습니다."
|
||||||
loading={isLoading}
|
loading={deleteDialog.isPending}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
* - 삭제 클릭 시 "정말 삭제하시겠습니까?" 확인 Alert
|
* - 삭제 클릭 시 "정말 삭제하시겠습니까?" 확인 Alert
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback, memo } from 'react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { User, Pencil, Trash2 } from 'lucide-react';
|
import { User, Pencil, Trash2 } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -25,7 +25,7 @@ interface CommentItemProps {
|
|||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CommentItem({
|
export const CommentItem = memo(function CommentItem({
|
||||||
comment,
|
comment,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
@@ -156,6 +156,6 @@ export function CommentItem({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
export default CommentItem;
|
export default CommentItem;
|
||||||
@@ -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 { FileText, Clock, Trophy, Pencil } from 'lucide-react';
|
||||||
import { useListHandlers } from '@/hooks/useListHandlers';
|
import { useListHandlers } from '@/hooks/useListHandlers';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -84,18 +85,7 @@ export default function BiddingListClient({ initialData = [], initialStats }: Bi
|
|||||||
// 검색어
|
// 검색어
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
// Stats 데이터
|
// Stats 데이터
|
||||||
const [stats, setStats] = useState<BiddingStats | null>(initialStats || null);
|
const { data: stats } = useStatsLoader(getBiddingStats, initialStats);
|
||||||
|
|
||||||
// Stats 로드
|
|
||||||
useEffect(() => {
|
|
||||||
if (!initialStats) {
|
|
||||||
getBiddingStats().then((result) => {
|
|
||||||
if (result.success && result.data) {
|
|
||||||
setStats(result.data);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [initialStats]);
|
|
||||||
|
|
||||||
// ===== UniversalListPage Config =====
|
// ===== UniversalListPage Config =====
|
||||||
const config: UniversalListConfig<Bidding> = useMemo(
|
const config: UniversalListConfig<Bidding> = useMemo(
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
* - filterConfig (multi: 거래처, 계약담당자, 공사PM / single: 상태, 정렬)
|
* - 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 { FileText, Clock, CheckCircle, Pencil, Trash2 } from 'lucide-react';
|
||||||
import { useListHandlers } from '@/hooks/useListHandlers';
|
import { useListHandlers } from '@/hooks/useListHandlers';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -83,18 +84,7 @@ export default function ContractListClient({ initialData = [], initialStats }: C
|
|||||||
const [startDate, setStartDate] = useState('');
|
const [startDate, setStartDate] = useState('');
|
||||||
const [endDate, setEndDate] = useState('');
|
const [endDate, setEndDate] = useState('');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [stats, setStats] = useState<ContractStats | null>(initialStats || null);
|
const { data: stats } = useStatsLoader(getContractStats, initialStats);
|
||||||
|
|
||||||
// Stats 로드
|
|
||||||
useEffect(() => {
|
|
||||||
if (!initialStats) {
|
|
||||||
getContractStats().then((result) => {
|
|
||||||
if (result.success && result.data) {
|
|
||||||
setStats(result.data);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [initialStats]);
|
|
||||||
|
|
||||||
// ===== UniversalListPage Config =====
|
// ===== UniversalListPage Config =====
|
||||||
const config: UniversalListConfig<Contract> = useMemo(
|
const config: UniversalListConfig<Contract> = useMemo(
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
import { useState, useMemo, useEffect } from 'react';
|
import { useState, useMemo, useEffect } from 'react';
|
||||||
import { FileText, FileTextIcon, Clock, FileCheck, Pencil } from 'lucide-react';
|
import { FileText, FileTextIcon, Clock, FileCheck, Pencil } from 'lucide-react';
|
||||||
import { useListHandlers } from '@/hooks/useListHandlers';
|
import { useListHandlers } from '@/hooks/useListHandlers';
|
||||||
|
import { useStatsLoader } from '@/hooks/useStatsLoader';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { TableCell, TableRow } from '@/components/ui/table';
|
import { TableCell, TableRow } from '@/components/ui/table';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
@@ -68,22 +69,11 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
|
|||||||
// 검색어
|
// 검색어
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
// Stats 데이터
|
// Stats 데이터
|
||||||
const [stats, setStats] = useState<EstimateStats | null>(initialStats || null);
|
const { data: stats } = useStatsLoader(getEstimateStats, initialStats);
|
||||||
// 필터 옵션 데이터
|
// 필터 옵션 데이터
|
||||||
const [partnerOptions, setPartnerOptions] = useState<ClientOption[]>([]);
|
const [partnerOptions, setPartnerOptions] = useState<ClientOption[]>([]);
|
||||||
const [estimatorOptions, setEstimatorOptions] = useState<UserOption[]>([]);
|
const [estimatorOptions, setEstimatorOptions] = useState<UserOption[]>([]);
|
||||||
|
|
||||||
// Stats 로드
|
|
||||||
useEffect(() => {
|
|
||||||
if (!initialStats) {
|
|
||||||
getEstimateStats().then((result) => {
|
|
||||||
if (result.success && result.data) {
|
|
||||||
setStats(result.data);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [initialStats]);
|
|
||||||
|
|
||||||
// 거래처/견적자 옵션 로드
|
// 거래처/견적자 옵션 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 거래처 옵션 로드
|
// 거래처 옵션 로드
|
||||||
|
|||||||
@@ -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 { HardHat, Pencil, Clock, CheckCircle } from 'lucide-react';
|
||||||
import { useListHandlers } from '@/hooks/useListHandlers';
|
import { useListHandlers } from '@/hooks/useListHandlers';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -84,7 +85,7 @@ export default function ConstructionManagementListClient({
|
|||||||
const [activeStatTab, setActiveStatTab] = useState<'all' | 'in_progress' | 'completed'>('all');
|
const [activeStatTab, setActiveStatTab] = useState<'all' | 'in_progress' | 'completed'>('all');
|
||||||
const [startDate, setStartDate] = useState<string>('');
|
const [startDate, setStartDate] = useState<string>('');
|
||||||
const [endDate, setEndDate] = useState<string>('');
|
const [endDate, setEndDate] = useState<string>('');
|
||||||
const [stats, setStats] = useState<ConstructionManagementStats | null>(initialStats || null);
|
const { data: stats } = useStatsLoader(getConstructionManagementStats, initialStats);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
// 달력 관련 상태
|
// 달력 관련 상태
|
||||||
@@ -97,17 +98,6 @@ export default function ConstructionManagementListClient({
|
|||||||
// 전체 데이터 (달력 이벤트용)
|
// 전체 데이터 (달력 이벤트용)
|
||||||
const [allConstructions, setAllConstructions] = useState<ConstructionManagement[]>(initialData);
|
const [allConstructions, setAllConstructions] = useState<ConstructionManagement[]>(initialData);
|
||||||
|
|
||||||
// Stats 로드
|
|
||||||
useEffect(() => {
|
|
||||||
if (!initialStats) {
|
|
||||||
getConstructionManagementStats().then((result) => {
|
|
||||||
if (result.success && result.data) {
|
|
||||||
setStats(result.data);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [initialStats]);
|
|
||||||
|
|
||||||
// 필터 옵션 (memo)
|
// 필터 옵션 (memo)
|
||||||
const siteOptions: MultiSelectOption[] = useMemo(() =>
|
const siteOptions: MultiSelectOption[] = useMemo(() =>
|
||||||
MOCK_CM_SITES.map(s => ({ value: s.value, label: s.label })),
|
MOCK_CM_SITES.map(s => ({ value: s.value, label: s.label })),
|
||||||
|
|||||||
@@ -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 { FileText, Pencil } from 'lucide-react';
|
||||||
import { useListHandlers } from '@/hooks/useListHandlers';
|
import { useListHandlers } from '@/hooks/useListHandlers';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -77,20 +78,9 @@ export default function ProgressBillingManagementListClient({
|
|||||||
const [activeStatTab, setActiveStatTab] = useState<'all' | 'contractWaiting' | 'contractComplete'>('all');
|
const [activeStatTab, setActiveStatTab] = useState<'all' | 'contractWaiting' | 'contractComplete'>('all');
|
||||||
const [startDate, setStartDate] = useState<string>('');
|
const [startDate, setStartDate] = useState<string>('');
|
||||||
const [endDate, setEndDate] = useState<string>('');
|
const [endDate, setEndDate] = useState<string>('');
|
||||||
const [stats, setStats] = useState<ProgressBillingStats | null>(initialStats || null);
|
const { data: stats } = useStatsLoader(getProgressBillingStats, initialStats);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
// Stats 로드
|
|
||||||
useEffect(() => {
|
|
||||||
if (!initialStats) {
|
|
||||||
getProgressBillingStats().then((result) => {
|
|
||||||
if (result.success && result.data) {
|
|
||||||
setStats(result.data);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [initialStats]);
|
|
||||||
|
|
||||||
// ===== UniversalListPage Config =====
|
// ===== UniversalListPage Config =====
|
||||||
const config: UniversalListConfig<ProgressBilling> = useMemo(
|
const config: UniversalListConfig<ProgressBilling> = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ReactNode, ComponentType } from 'react';
|
import { ReactNode, ComponentType, memo } from 'react';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -22,7 +22,7 @@ export interface InfoFieldProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InfoField({
|
export const InfoField = memo(function InfoField({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
valueClassName = '',
|
valueClassName = '',
|
||||||
@@ -34,7 +34,7 @@ export function InfoField({
|
|||||||
<div className={cn('text-sm font-medium', valueClassName)}>{value}</div>
|
<div className={cn('text-sm font-medium', valueClassName)}>{value}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 통합 MobileCard Props
|
* 통합 MobileCard Props
|
||||||
|
|||||||
@@ -12,8 +12,9 @@
|
|||||||
* - 하단 출고 스케줄 캘린더 (시간축 주간 뷰)
|
* - 하단 출고 스케줄 캘린더 (시간축 주간 뷰)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useMemo, useCallback } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useStatsLoader } from '@/hooks/useStatsLoader';
|
||||||
import {
|
import {
|
||||||
Truck,
|
Truck,
|
||||||
Package,
|
Package,
|
||||||
@@ -53,7 +54,7 @@ export function ShipmentList() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// ===== 통계 (외부 관리) =====
|
// ===== 통계 (외부 관리) =====
|
||||||
const [shipmentStats, setShipmentStats] = useState<ShipmentStats | null>(null);
|
const { data: shipmentStats, reload: reloadStats } = useStatsLoader(getShipmentStats);
|
||||||
|
|
||||||
// ===== 날짜 범위 =====
|
// ===== 날짜 범위 =====
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
@@ -71,22 +72,6 @@ export function ShipmentList() {
|
|||||||
const [scheduleView, setScheduleView] = useState<CalendarView>('day-time');
|
const [scheduleView, setScheduleView] = useState<CalendarView>('day-time');
|
||||||
const [shipmentData, setShipmentData] = useState<ShipmentItem[]>([]);
|
const [shipmentData, setShipmentData] = useState<ShipmentItem[]>([]);
|
||||||
|
|
||||||
// 초기 통계 로드
|
|
||||||
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(
|
const handleRowClick = useCallback(
|
||||||
(item: ShipmentItem) => {
|
(item: ShipmentItem) => {
|
||||||
@@ -191,10 +176,7 @@ export function ShipmentList() {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// 통계 다시 로드
|
// 통계 다시 로드
|
||||||
const statsResult = await getShipmentStats();
|
await reloadStats();
|
||||||
if (statsResult.success && statsResult.data) {
|
|
||||||
setShipmentStats(statsResult.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 캘린더용 데이터 저장
|
// 캘린더용 데이터 저장
|
||||||
setShipmentData(result.data);
|
setShipmentData(result.data);
|
||||||
|
|||||||
@@ -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 { ChevronDown, ChevronUp, Pencil, Trash2, ImageIcon } from 'lucide-react';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -43,7 +43,7 @@ interface WorkItemCardProps {
|
|||||||
onInspectionToggle?: (itemId: string, checked: boolean) => void;
|
onInspectionToggle?: (itemId: string, checked: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorkItemCard({
|
export const WorkItemCard = memo(function WorkItemCard({
|
||||||
item,
|
item,
|
||||||
onStepClick,
|
onStepClick,
|
||||||
onEditMaterial,
|
onEditMaterial,
|
||||||
@@ -230,7 +230,7 @@ export function WorkItemCard({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
// ===== 스크린 전용: 절단정보 =====
|
// ===== 스크린 전용: 절단정보 =====
|
||||||
function ScreenCuttingInfo({ width, sheets }: { width: number; sheets: number }) {
|
function ScreenCuttingInfo({ width, sheets }: { width: number; sheets: number }) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useStatsLoader } from '@/hooks/useStatsLoader';
|
||||||
import {
|
import {
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -53,11 +54,7 @@ export function InspectionList() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// ===== 통계 =====
|
// ===== 통계 =====
|
||||||
const [statsData, setStatsData] = useState<InspectionStats>({
|
const { data: statsData, reload: reloadStats } = useStatsLoader(getInspectionStats);
|
||||||
receptionCount: 0,
|
|
||||||
inProgressCount: 0,
|
|
||||||
completedCount: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
// ===== 날짜 범위 =====
|
// ===== 날짜 범위 =====
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
@@ -77,22 +74,6 @@ export function InspectionList() {
|
|||||||
const [calendarStatusFilter, setCalendarStatusFilter] = useState<string>('전체');
|
const [calendarStatusFilter, setCalendarStatusFilter] = useState<string>('전체');
|
||||||
const [calendarInspectorFilter, setCalendarInspectorFilter] = useState<string>('전체');
|
const [calendarInspectorFilter, setCalendarInspectorFilter] = useState<string>('전체');
|
||||||
|
|
||||||
// 초기 통계 로드
|
|
||||||
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 () => {
|
const loadCalendarData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -164,19 +145,19 @@ export function InspectionList() {
|
|||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
label: '접수',
|
label: '접수',
|
||||||
value: statsData.receptionCount,
|
value: statsData?.receptionCount ?? 0,
|
||||||
icon: FileInput,
|
icon: FileInput,
|
||||||
iconColor: 'text-gray-600',
|
iconColor: 'text-gray-600',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '진행중',
|
label: '진행중',
|
||||||
value: statsData.inProgressCount,
|
value: statsData?.inProgressCount ?? 0,
|
||||||
icon: Loader2,
|
icon: Loader2,
|
||||||
iconColor: 'text-blue-600',
|
iconColor: 'text-blue-600',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '완료',
|
label: '완료',
|
||||||
value: statsData.completedCount,
|
value: statsData?.completedCount ?? 0,
|
||||||
icon: CheckCircle2,
|
icon: CheckCircle2,
|
||||||
iconColor: 'text-green-600',
|
iconColor: 'text-green-600',
|
||||||
},
|
},
|
||||||
@@ -236,10 +217,7 @@ export function InspectionList() {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// 통계 재로드
|
// 통계 재로드
|
||||||
const statsResult = await getInspectionStats();
|
await reloadStats();
|
||||||
if (statsResult.success && statsResult.data) {
|
|
||||||
setStatsData(statsResult.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
* - 삭제/일괄삭제 다이얼로그
|
* - 삭제/일괄삭제 다이얼로그
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useMemo, useCallback, useTransition } from 'react';
|
import { useState, useMemo, useCallback } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { format, startOfMonth, endOfMonth } from 'date-fns';
|
import { format, startOfMonth, endOfMonth } from 'date-fns';
|
||||||
import {
|
import {
|
||||||
@@ -51,6 +51,7 @@ import {
|
|||||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||||
import { StandardDialog } from '@/components/molecules/StandardDialog';
|
import { StandardDialog } from '@/components/molecules/StandardDialog';
|
||||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||||
|
import { useDeleteDialog } from '@/hooks/useDeleteDialog';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { formatAmount, formatAmountManwon } from '@/utils/formatAmount';
|
import { formatAmount, formatAmountManwon } from '@/utils/formatAmount';
|
||||||
import type { Quote, QuoteFilterType } from './types';
|
import type { Quote, QuoteFilterType } from './types';
|
||||||
@@ -69,7 +70,12 @@ export function QuoteManagementClient({
|
|||||||
initialPagination,
|
initialPagination,
|
||||||
}: QuoteManagementClientProps) {
|
}: QuoteManagementClientProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isPending, startTransition] = useTransition();
|
const deleteDialog = useDeleteDialog({
|
||||||
|
onDelete: deleteQuote,
|
||||||
|
onBulkDelete: bulkDeleteQuotes,
|
||||||
|
onSuccess: () => window.location.reload(),
|
||||||
|
entityName: '견적',
|
||||||
|
});
|
||||||
|
|
||||||
// ===== 날짜 필터 상태 =====
|
// ===== 날짜 필터 상태 =====
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
@@ -84,12 +90,6 @@ export function QuoteManagementClient({
|
|||||||
const [isCalculationDialogOpen, setIsCalculationDialogOpen] = useState(false);
|
const [isCalculationDialogOpen, setIsCalculationDialogOpen] = useState(false);
|
||||||
const [calculationQuote, setCalculationQuote] = useState<Quote | null>(null);
|
const [calculationQuote, setCalculationQuote] = useState<Quote | null>(null);
|
||||||
|
|
||||||
// ===== 삭제 다이얼로그 상태 =====
|
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
|
||||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
|
||||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
|
||||||
const [bulkDeleteIds, setBulkDeleteIds] = useState<string[]>([]);
|
|
||||||
|
|
||||||
// ===== 전체 데이터 상태 (통계 계산용) =====
|
// ===== 전체 데이터 상태 (통계 계산용) =====
|
||||||
const [allQuotes, setAllQuotes] = useState<Quote[]>(initialData);
|
const [allQuotes, setAllQuotes] = useState<Quote[]>(initialData);
|
||||||
|
|
||||||
@@ -102,57 +102,6 @@ export function QuoteManagementClient({
|
|||||||
router.push(`/sales/quote-management/${quote.id}?mode=edit`);
|
router.push(`/sales/quote-management/${quote.id}?mode=edit`);
|
||||||
}, [router]);
|
}, [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) => {
|
const handleViewHistory = useCallback((quote: Quote) => {
|
||||||
toast.info(`수정 이력: ${quote.quoteNumber} (${quote.currentRevision}차 수정)`);
|
toast.info(`수정 이력: ${quote.quoteNumber} (${quote.currentRevision}차 수정)`);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -413,7 +362,7 @@ export function QuoteManagementClient({
|
|||||||
),
|
),
|
||||||
|
|
||||||
// 일괄 삭제 핸들러
|
// 일괄 삭제 핸들러
|
||||||
onBulkDelete: handleBulkDelete,
|
onBulkDelete: deleteDialog.bulk.open,
|
||||||
|
|
||||||
// 테이블 행 렌더링
|
// 테이블 행 렌더링
|
||||||
renderTableRow: (
|
renderTableRow: (
|
||||||
@@ -489,8 +438,8 @@ export function QuoteManagementClient({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleDeleteClick(quote.id)}
|
onClick={() => deleteDialog.single.open(quote.id)}
|
||||||
disabled={isPending}
|
disabled={deleteDialog.isPending}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 text-red-500" />
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -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"
|
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-transparent"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleDeleteClick(quote.id);
|
deleteDialog.single.open(quote.id);
|
||||||
}}
|
}}
|
||||||
disabled={isPending}
|
disabled={deleteDialog.isPending}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
삭제
|
삭제
|
||||||
@@ -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 (
|
return (
|
||||||
@@ -735,34 +684,28 @@ export function QuoteManagementClient({
|
|||||||
|
|
||||||
{/* 삭제 확인 다이얼로그 */}
|
{/* 삭제 확인 다이얼로그 */}
|
||||||
<DeleteConfirmDialog
|
<DeleteConfirmDialog
|
||||||
open={isDeleteDialogOpen}
|
open={deleteDialog.single.isOpen}
|
||||||
onOpenChange={setIsDeleteDialogOpen}
|
onOpenChange={deleteDialog.single.onOpenChange}
|
||||||
description={
|
description={
|
||||||
<>
|
<>
|
||||||
{deleteTargetId
|
{deleteDialog.single.targetId
|
||||||
? `견적번호: ${allQuotes.find((q) => q.id === deleteTargetId)?.quoteNumber || deleteTargetId}`
|
? `견적번호: ${allQuotes.find((q) => q.id === deleteDialog.single.targetId)?.quoteNumber || deleteDialog.single.targetId}`
|
||||||
: ''}
|
: ''}
|
||||||
<br />
|
<br />
|
||||||
이 견적을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.
|
이 견적을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
loading={isPending}
|
loading={deleteDialog.isPending}
|
||||||
onConfirm={handleConfirmDelete}
|
onConfirm={deleteDialog.single.confirm}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 일괄 삭제 확인 다이얼로그 */}
|
{/* 일괄 삭제 확인 다이얼로그 */}
|
||||||
<DeleteConfirmDialog
|
<DeleteConfirmDialog
|
||||||
open={isBulkDeleteDialogOpen}
|
open={deleteDialog.bulk.isOpen}
|
||||||
onOpenChange={setIsBulkDeleteDialogOpen}
|
onOpenChange={deleteDialog.bulk.onOpenChange}
|
||||||
description={
|
description={`선택한 ${deleteDialog.bulk.ids.length}개의 견적을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.`}
|
||||||
<>
|
loading={deleteDialog.isPending}
|
||||||
선택한 {bulkDeleteIds.length}개의 견적을 삭제하시겠습니까?
|
onConfirm={deleteDialog.bulk.confirm}
|
||||||
<br />
|
|
||||||
삭제된 데이터는 복구할 수 없습니다.
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
loading={isPending}
|
|
||||||
onConfirm={handleConfirmBulkDelete}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
|
import { createCrudService, type ActionResult } from '@/lib/api/create-crud-service';
|
||||||
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
|
|
||||||
import type { Rank } from './types';
|
import type { Rank } from './types';
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
|
||||||
|
|
||||||
// ===== API 응답 타입 =====
|
// ===== API 응답 타입 =====
|
||||||
interface PositionApiData {
|
interface PositionApiData {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -18,60 +15,45 @@ interface PositionApiData {
|
|||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 데이터 변환: API → Frontend =====
|
// ===== CRUD 서비스 생성 =====
|
||||||
function transformApiToFrontend(apiData: PositionApiData): Rank {
|
const rankService = createCrudService<PositionApiData, Rank>({
|
||||||
return {
|
basePath: '/api/v1/positions',
|
||||||
id: apiData.id,
|
transform: (api) => ({
|
||||||
name: apiData.name,
|
id: api.id,
|
||||||
order: apiData.sort_order,
|
name: api.name,
|
||||||
isActive: apiData.is_active,
|
order: api.sort_order,
|
||||||
createdAt: apiData.created_at,
|
isActive: api.is_active,
|
||||||
updatedAt: apiData.updated_at,
|
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?: {
|
export async function getRanks(params?: {
|
||||||
is_active?: boolean;
|
is_active?: boolean;
|
||||||
q?: string;
|
q?: string;
|
||||||
}): Promise<ActionResult<Rank[]>> {
|
}): Promise<ActionResult<Rank[]>> {
|
||||||
const searchParams = new URLSearchParams();
|
return rankService.getList(params);
|
||||||
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: '직급 목록 조회에 실패했습니다.',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 직급 생성 =====
|
|
||||||
export async function createRank(data: {
|
export async function createRank(data: {
|
||||||
name: string;
|
name: string;
|
||||||
sort_order?: number;
|
sort_order?: number;
|
||||||
is_active?: boolean;
|
is_active?: boolean;
|
||||||
}): Promise<ActionResult<Rank>> {
|
}): Promise<ActionResult<Rank>> {
|
||||||
return executeServerAction({
|
return rankService.create({
|
||||||
url: `${API_URL}/api/v1/positions`,
|
name: data.name,
|
||||||
method: 'POST',
|
sort_order: data.sort_order,
|
||||||
body: {
|
is_active: data.is_active ?? true,
|
||||||
type: 'rank',
|
|
||||||
name: data.name,
|
|
||||||
sort_order: data.sort_order,
|
|
||||||
is_active: data.is_active ?? true,
|
|
||||||
},
|
|
||||||
transform: transformApiToFrontend,
|
|
||||||
errorMessage: '직급 생성에 실패했습니다.',
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 직급 수정 =====
|
|
||||||
export async function updateRank(
|
export async function updateRank(
|
||||||
id: number,
|
id: number,
|
||||||
data: {
|
data: {
|
||||||
@@ -80,32 +62,15 @@ export async function updateRank(
|
|||||||
is_active?: boolean;
|
is_active?: boolean;
|
||||||
}
|
}
|
||||||
): Promise<ActionResult<Rank>> {
|
): Promise<ActionResult<Rank>> {
|
||||||
return executeServerAction({
|
return rankService.update(id, data);
|
||||||
url: `${API_URL}/api/v1/positions/${id}`,
|
|
||||||
method: 'PUT',
|
|
||||||
body: data,
|
|
||||||
transform: transformApiToFrontend,
|
|
||||||
errorMessage: '직급 수정에 실패했습니다.',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 직급 삭제 =====
|
|
||||||
export async function deleteRank(id: number): Promise<ActionResult> {
|
export async function deleteRank(id: number): Promise<ActionResult> {
|
||||||
return executeServerAction({
|
return rankService.remove(id);
|
||||||
url: `${API_URL}/api/v1/positions/${id}`,
|
|
||||||
method: 'DELETE',
|
|
||||||
errorMessage: '직급 삭제에 실패했습니다.',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 직급 순서 변경 =====
|
|
||||||
export async function reorderRanks(
|
export async function reorderRanks(
|
||||||
items: { id: number; sort_order: number }[]
|
items: { id: number; sort_order: number }[]
|
||||||
): Promise<ActionResult> {
|
): Promise<ActionResult> {
|
||||||
return executeServerAction({
|
return rankService.reorder(items);
|
||||||
url: `${API_URL}/api/v1/positions/reorder`,
|
}
|
||||||
method: 'PUT',
|
|
||||||
body: { items },
|
|
||||||
errorMessage: '순서 변경에 실패했습니다.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* 레거시 5130 사이트 컬럼 구조 기반
|
* 레거시 5130 사이트 컬럼 구조 기반
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useMemo, useCallback, useTransition } from 'react';
|
import { useState, useMemo, useCallback } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Truck, Edit, Trash2 } from 'lucide-react';
|
import { Truck, Edit, Trash2 } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
} from '@/components/templates/UniversalListPage';
|
} from '@/components/templates/UniversalListPage';
|
||||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||||
import { toast } from 'sonner';
|
import { useDeleteDialog } from '@/hooks/useDeleteDialog';
|
||||||
import type { Forklift } from '../types';
|
import type { Forklift } from '../types';
|
||||||
import { getForklifts, deleteForklift, bulkDeleteForklifts } from './actions';
|
import { getForklifts, deleteForklift, bulkDeleteForklifts } from './actions';
|
||||||
|
|
||||||
@@ -30,12 +30,13 @@ interface ForkliftListProps {
|
|||||||
|
|
||||||
export function ForkliftList({ initialData }: ForkliftListProps) {
|
export function ForkliftList({ initialData }: ForkliftListProps) {
|
||||||
const router = useRouter();
|
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<string | null>(null);
|
|
||||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
|
||||||
const [bulkDeleteIds, setBulkDeleteIds] = useState<string[]>([]);
|
|
||||||
const [allData, setAllData] = useState<Forklift[]>(initialData);
|
const [allData, setAllData] = useState<Forklift[]>(initialData);
|
||||||
|
|
||||||
const handleView = useCallback(
|
const handleView = useCallback(
|
||||||
@@ -52,57 +53,6 @@ export function ForkliftList({ initialData }: ForkliftListProps) {
|
|||||||
[router]
|
[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<Forklift> = useMemo(
|
const config: UniversalListConfig<Forklift> = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
title: '지게차 관리',
|
title: '지게차 관리',
|
||||||
@@ -190,7 +140,7 @@ export function ForkliftList({ initialData }: ForkliftListProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
|
|
||||||
onBulkDelete: handleBulkDelete,
|
onBulkDelete: deleteDialog.bulk.open,
|
||||||
|
|
||||||
renderTableRow: (
|
renderTableRow: (
|
||||||
forklift: Forklift,
|
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"
|
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-transparent"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleDeleteClick(forklift.id);
|
deleteDialog.single.open(forklift.id);
|
||||||
}}
|
}}
|
||||||
disabled={isPending}
|
disabled={deleteDialog.isPending}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
삭제
|
삭제
|
||||||
@@ -328,7 +278,7 @@ export function ForkliftList({ initialData }: ForkliftListProps) {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[router, handleView, handleEdit, handleDeleteClick, handleBulkDelete, isPending]
|
[router, handleView, handleEdit, deleteDialog]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -336,31 +286,27 @@ export function ForkliftList({ initialData }: ForkliftListProps) {
|
|||||||
<UniversalListPage config={config} initialData={initialData} />
|
<UniversalListPage config={config} initialData={initialData} />
|
||||||
|
|
||||||
<DeleteConfirmDialog
|
<DeleteConfirmDialog
|
||||||
open={isDeleteDialogOpen}
|
open={deleteDialog.single.isOpen}
|
||||||
onOpenChange={setIsDeleteDialogOpen}
|
onOpenChange={deleteDialog.single.onOpenChange}
|
||||||
description={
|
description={
|
||||||
<>
|
<>
|
||||||
{deleteTargetId
|
{deleteDialog.single.targetId
|
||||||
? `차량번호: ${allData.find((f) => f.id === deleteTargetId)?.vehicleNumber || deleteTargetId}`
|
? `차량번호: ${allData.find((f) => f.id === deleteDialog.single.targetId)?.vehicleNumber || deleteDialog.single.targetId}`
|
||||||
: ''}
|
: ''}
|
||||||
<br />
|
<br />
|
||||||
이 지게차를 삭제하시겠습니까?
|
이 지게차를 삭제하시겠습니까?
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
loading={isPending}
|
loading={deleteDialog.isPending}
|
||||||
onConfirm={handleConfirmDelete}
|
onConfirm={deleteDialog.single.confirm}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DeleteConfirmDialog
|
<DeleteConfirmDialog
|
||||||
open={isBulkDeleteDialogOpen}
|
open={deleteDialog.bulk.isOpen}
|
||||||
onOpenChange={setIsBulkDeleteDialogOpen}
|
onOpenChange={deleteDialog.bulk.onOpenChange}
|
||||||
description={
|
description={`선택한 ${deleteDialog.bulk.ids.length}개의 지게차를 삭제하시겠습니까?`}
|
||||||
<>
|
loading={deleteDialog.isPending}
|
||||||
선택한 {bulkDeleteIds.length}개의 지게차를 삭제하시겠습니까?
|
onConfirm={deleteDialog.bulk.confirm}
|
||||||
</>
|
|
||||||
}
|
|
||||||
loading={isPending}
|
|
||||||
onConfirm={handleConfirmBulkDelete}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* 레거시 5130 사이트 컬럼 구조 기반 (2025-01-28 스크린샷 검증)
|
* 레거시 5130 사이트 컬럼 구조 기반 (2025-01-28 스크린샷 검증)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useMemo, useCallback, useTransition } from 'react';
|
import { useState, useMemo, useCallback } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Car, Edit, Trash2 } from 'lucide-react';
|
import { Car, Edit, Trash2 } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
} from '@/components/templates/UniversalListPage';
|
} from '@/components/templates/UniversalListPage';
|
||||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||||
import { toast } from 'sonner';
|
import { useDeleteDialog } from '@/hooks/useDeleteDialog';
|
||||||
import type { Vehicle } from '../types';
|
import type { Vehicle } from '../types';
|
||||||
import { getVehicles, deleteVehicle, bulkDeleteVehicles } from './actions';
|
import { getVehicles, deleteVehicle, bulkDeleteVehicles } from './actions';
|
||||||
|
|
||||||
@@ -30,14 +30,15 @@ interface VehicleListProps {
|
|||||||
|
|
||||||
export function VehicleList({ initialData }: VehicleListProps) {
|
export function VehicleList({ initialData }: VehicleListProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isPending, startTransition] = useTransition();
|
|
||||||
|
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
|
||||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
|
||||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
|
||||||
const [bulkDeleteIds, setBulkDeleteIds] = useState<string[]>([]);
|
|
||||||
const [allData, setAllData] = useState<Vehicle[]>(initialData);
|
const [allData, setAllData] = useState<Vehicle[]>(initialData);
|
||||||
|
|
||||||
|
const deleteDialog = useDeleteDialog({
|
||||||
|
onDelete: deleteVehicle,
|
||||||
|
onBulkDelete: bulkDeleteVehicles,
|
||||||
|
onSuccess: () => window.location.reload(),
|
||||||
|
entityName: '차량',
|
||||||
|
});
|
||||||
|
|
||||||
const handleView = useCallback(
|
const handleView = useCallback(
|
||||||
(vehicle: Vehicle) => {
|
(vehicle: Vehicle) => {
|
||||||
router.push(`/vehicle-management/vehicle/${vehicle.id}`);
|
router.push(`/vehicle-management/vehicle/${vehicle.id}`);
|
||||||
@@ -52,57 +53,6 @@ export function VehicleList({ initialData }: VehicleListProps) {
|
|||||||
[router]
|
[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<Vehicle> = useMemo(
|
const config: UniversalListConfig<Vehicle> = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
title: '차량 관리',
|
title: '차량 관리',
|
||||||
@@ -188,7 +138,7 @@ export function VehicleList({ initialData }: VehicleListProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
|
|
||||||
onBulkDelete: handleBulkDelete,
|
onBulkDelete: deleteDialog.bulk.open,
|
||||||
|
|
||||||
renderTableRow: (
|
renderTableRow: (
|
||||||
vehicle: Vehicle,
|
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"
|
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-transparent"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleDeleteClick(vehicle.id);
|
deleteDialog.single.open(vehicle.id);
|
||||||
}}
|
}}
|
||||||
disabled={isPending}
|
disabled={deleteDialog.isPending}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
삭제
|
삭제
|
||||||
@@ -298,7 +248,7 @@ export function VehicleList({ initialData }: VehicleListProps) {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[router, handleView, handleEdit, handleDeleteClick, handleBulkDelete, isPending]
|
[router, handleView, handleEdit, deleteDialog]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -306,33 +256,19 @@ export function VehicleList({ initialData }: VehicleListProps) {
|
|||||||
<UniversalListPage config={config} initialData={initialData} />
|
<UniversalListPage config={config} initialData={initialData} />
|
||||||
|
|
||||||
<DeleteConfirmDialog
|
<DeleteConfirmDialog
|
||||||
open={isDeleteDialogOpen}
|
open={deleteDialog.single.isOpen}
|
||||||
onOpenChange={setIsDeleteDialogOpen}
|
onOpenChange={deleteDialog.single.onOpenChange}
|
||||||
description={
|
description="이 차량을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다."
|
||||||
<>
|
loading={deleteDialog.isPending}
|
||||||
{deleteTargetId
|
onConfirm={deleteDialog.single.confirm}
|
||||||
? `차량번호: ${allData.find((v) => v.id === deleteTargetId)?.vehicleNumber || deleteTargetId}`
|
|
||||||
: ''}
|
|
||||||
<br />
|
|
||||||
이 차량을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
loading={isPending}
|
|
||||||
onConfirm={handleConfirmDelete}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DeleteConfirmDialog
|
<DeleteConfirmDialog
|
||||||
open={isBulkDeleteDialogOpen}
|
open={deleteDialog.bulk.isOpen}
|
||||||
onOpenChange={setIsBulkDeleteDialogOpen}
|
onOpenChange={deleteDialog.bulk.onOpenChange}
|
||||||
description={
|
description={`선택한 ${deleteDialog.bulk.ids.length}개의 차량을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.`}
|
||||||
<>
|
loading={deleteDialog.isPending}
|
||||||
선택한 {bulkDeleteIds.length}개의 차량을 삭제하시겠습니까?
|
onConfirm={deleteDialog.bulk.confirm}
|
||||||
<br />
|
|
||||||
삭제된 데이터는 복구할 수 없습니다.
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
loading={isPending}
|
|
||||||
onConfirm={handleConfirmBulkDelete}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* 차량일지/월간사진기록 리스트 - UniversalListPage 기반
|
* 차량일지/월간사진기록 리스트 - UniversalListPage 기반
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useMemo, useCallback, useTransition } from 'react';
|
import { useState, useMemo, useCallback } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { FileText, Edit, Trash2 } from 'lucide-react';
|
import { FileText, Edit, Trash2 } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
} from '@/components/templates/UniversalListPage';
|
} from '@/components/templates/UniversalListPage';
|
||||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||||
import { toast } from 'sonner';
|
import { useDeleteDialog } from '@/hooks/useDeleteDialog';
|
||||||
import type { VehicleLog } from '../types';
|
import type { VehicleLog } from '../types';
|
||||||
import { getVehicleLogs, deleteVehicleLog, bulkDeleteVehicleLogs } from './actions';
|
import { getVehicleLogs, deleteVehicleLog, bulkDeleteVehicleLogs } from './actions';
|
||||||
|
|
||||||
@@ -30,13 +30,12 @@ interface VehicleLogListProps {
|
|||||||
|
|
||||||
export function VehicleLogList({ initialData }: VehicleLogListProps) {
|
export function VehicleLogList({ initialData }: VehicleLogListProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isPending, startTransition] = useTransition();
|
const deleteDialog = useDeleteDialog({
|
||||||
|
onDelete: deleteVehicleLog,
|
||||||
// ===== 삭제 다이얼로그 상태 =====
|
onBulkDelete: bulkDeleteVehicleLogs,
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
onSuccess: () => window.location.reload(),
|
||||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
entityName: '차량일지',
|
||||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
});
|
||||||
const [bulkDeleteIds, setBulkDeleteIds] = useState<string[]>([]);
|
|
||||||
|
|
||||||
// ===== 전체 데이터 상태 =====
|
// ===== 전체 데이터 상태 =====
|
||||||
const [allData, setAllData] = useState<VehicleLog[]>(initialData);
|
const [allData, setAllData] = useState<VehicleLog[]>(initialData);
|
||||||
@@ -56,57 +55,6 @@ export function VehicleLogList({ initialData }: VehicleLogListProps) {
|
|||||||
[router]
|
[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 =====
|
// ===== UniversalListPage Config =====
|
||||||
const config: UniversalListConfig<VehicleLog> = useMemo(
|
const config: UniversalListConfig<VehicleLog> = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -186,7 +134,7 @@ export function VehicleLogList({ initialData }: VehicleLogListProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
|
|
||||||
onBulkDelete: handleBulkDelete,
|
onBulkDelete: deleteDialog.bulk.open,
|
||||||
|
|
||||||
renderTableRow: (
|
renderTableRow: (
|
||||||
log: VehicleLog,
|
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"
|
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-transparent"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleDeleteClick(log.id);
|
deleteDialog.single.open(log.id);
|
||||||
}}
|
}}
|
||||||
disabled={isPending}
|
disabled={deleteDialog.isPending}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
삭제
|
삭제
|
||||||
@@ -271,7 +219,7 @@ export function VehicleLogList({ initialData }: VehicleLogListProps) {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[router, handleView, handleEdit, handleDeleteClick, handleBulkDelete, isPending]
|
[router, handleView, handleEdit, deleteDialog]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -279,31 +227,27 @@ export function VehicleLogList({ initialData }: VehicleLogListProps) {
|
|||||||
<UniversalListPage config={config} initialData={initialData} />
|
<UniversalListPage config={config} initialData={initialData} />
|
||||||
|
|
||||||
<DeleteConfirmDialog
|
<DeleteConfirmDialog
|
||||||
open={isDeleteDialogOpen}
|
open={deleteDialog.single.isOpen}
|
||||||
onOpenChange={setIsDeleteDialogOpen}
|
onOpenChange={deleteDialog.single.onOpenChange}
|
||||||
description={
|
description={
|
||||||
<>
|
<>
|
||||||
{deleteTargetId
|
{deleteDialog.single.targetId
|
||||||
? `제목: ${allData.find((v) => v.id === deleteTargetId)?.title || deleteTargetId}`
|
? `제목: ${allData.find((v) => v.id === deleteDialog.single.targetId)?.title || deleteDialog.single.targetId}`
|
||||||
: ''}
|
: ''}
|
||||||
<br />
|
<br />
|
||||||
이 차량일지를 삭제하시겠습니까?
|
이 차량일지를 삭제하시겠습니까?
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
loading={isPending}
|
loading={deleteDialog.isPending}
|
||||||
onConfirm={handleConfirmDelete}
|
onConfirm={deleteDialog.single.confirm}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DeleteConfirmDialog
|
<DeleteConfirmDialog
|
||||||
open={isBulkDeleteDialogOpen}
|
open={deleteDialog.bulk.isOpen}
|
||||||
onOpenChange={setIsBulkDeleteDialogOpen}
|
onOpenChange={deleteDialog.bulk.onOpenChange}
|
||||||
description={
|
description={`선택한 ${deleteDialog.bulk.ids.length}개의 차량일지를 삭제하시겠습니까?`}
|
||||||
<>
|
loading={deleteDialog.isPending}
|
||||||
선택한 {bulkDeleteIds.length}개의 차량일지를 삭제하시겠습니까?
|
onConfirm={deleteDialog.bulk.confirm}
|
||||||
</>
|
|
||||||
}
|
|
||||||
loading={isPending}
|
|
||||||
onConfirm={handleConfirmBulkDelete}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
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 };
|
||||||
|
}
|
||||||
140
src/lib/api/create-crud-service.ts
Normal file
140
src/lib/api/create-crud-service.ts
Normal file
@@ -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<PositionApiData, Rank>({
|
||||||
|
* 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<TApi, TFrontend> {
|
||||||
|
/** API 경로 (예: '/api/v1/positions') */
|
||||||
|
basePath: string;
|
||||||
|
/** API → Frontend 데이터 변환 함수 */
|
||||||
|
transform: (apiData: TApi) => TFrontend;
|
||||||
|
/** 엔티티 한글명 (에러 메시지용, 예: '직급') */
|
||||||
|
entityName: string;
|
||||||
|
/** 목록 조회 시 기본 쿼리 파라미터 (예: { type: 'rank' }) */
|
||||||
|
defaultQueryParams?: Record<string, string>;
|
||||||
|
/** 생성 시 body에 추가할 기본값 (예: { type: 'rank' }) */
|
||||||
|
defaultCreateBody?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 서비스 반환 타입 =====
|
||||||
|
export interface CrudService<TFrontend> {
|
||||||
|
getList(params?: {
|
||||||
|
is_active?: boolean;
|
||||||
|
q?: string;
|
||||||
|
}): Promise<ActionResult<TFrontend[]>>;
|
||||||
|
|
||||||
|
create(body: Record<string, unknown>): Promise<ActionResult<TFrontend>>;
|
||||||
|
|
||||||
|
update(
|
||||||
|
id: number,
|
||||||
|
body: Record<string, unknown>
|
||||||
|
): Promise<ActionResult<TFrontend>>;
|
||||||
|
|
||||||
|
remove(id: number): Promise<ActionResult>;
|
||||||
|
|
||||||
|
reorder(
|
||||||
|
items: { id: number; sort_order: number }[]
|
||||||
|
): Promise<ActionResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 팩토리 함수 =====
|
||||||
|
export function createCrudService<TApi, TFrontend>(
|
||||||
|
config: CrudServiceConfig<TApi, TFrontend>
|
||||||
|
): CrudService<TFrontend> {
|
||||||
|
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';
|
||||||
@@ -19,8 +19,8 @@ interface ApiLogEntry {
|
|||||||
level: LogLevel;
|
level: LogLevel;
|
||||||
method: string;
|
method: string;
|
||||||
url: string;
|
url: string;
|
||||||
requestData?: any;
|
requestData?: unknown;
|
||||||
responseData?: any;
|
responseData?: unknown;
|
||||||
statusCode?: number;
|
statusCode?: number;
|
||||||
error?: Error;
|
error?: Error;
|
||||||
duration?: number;
|
duration?: number;
|
||||||
@@ -58,7 +58,7 @@ class ApiLogger {
|
|||||||
/**
|
/**
|
||||||
* API 요청 시작 로그
|
* API 요청 시작 로그
|
||||||
*/
|
*/
|
||||||
logRequest(method: string, url: string, data?: any): number {
|
logRequest(method: string, url: string, data?: unknown): number {
|
||||||
if (!this.enabled) return Date.now();
|
if (!this.enabled) return Date.now();
|
||||||
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
@@ -88,7 +88,7 @@ class ApiLogger {
|
|||||||
method: string,
|
method: string,
|
||||||
url: string,
|
url: string,
|
||||||
statusCode: number,
|
statusCode: number,
|
||||||
data: any,
|
data: unknown,
|
||||||
startTime: number
|
startTime: number
|
||||||
) {
|
) {
|
||||||
if (!this.enabled) return;
|
if (!this.enabled) return;
|
||||||
@@ -154,7 +154,7 @@ class ApiLogger {
|
|||||||
/**
|
/**
|
||||||
* 경고 로그
|
* 경고 로그
|
||||||
*/
|
*/
|
||||||
logWarning(message: string, data?: any) {
|
logWarning(message: string, data?: unknown) {
|
||||||
if (!this.enabled) return;
|
if (!this.enabled) return;
|
||||||
|
|
||||||
const entry: ApiLogEntry = {
|
const entry: ApiLogEntry = {
|
||||||
@@ -172,7 +172,7 @@ class ApiLogger {
|
|||||||
/**
|
/**
|
||||||
* 디버그 로그
|
* 디버그 로그
|
||||||
*/
|
*/
|
||||||
logDebug(message: string, data?: any) {
|
logDebug(message: string, data?: unknown) {
|
||||||
if (!this.enabled) return;
|
if (!this.enabled) return;
|
||||||
|
|
||||||
const entry: ApiLogEntry = {
|
const entry: ApiLogEntry = {
|
||||||
@@ -348,7 +348,7 @@ export async function loggedFetch<T>(
|
|||||||
|
|
||||||
// 개발 도구를 window에 노출 (브라우저 콘솔에서 사용 가능)
|
// 개발 도구를 window에 노출 (브라우저 콘솔에서 사용 가능)
|
||||||
if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
|
if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
|
||||||
(window as any).apiLogger = apiLogger;
|
(window as unknown as Record<string, unknown>).apiLogger = apiLogger;
|
||||||
console.log(
|
console.log(
|
||||||
'💡 API Logger is available in console as "apiLogger"\n' +
|
'💡 API Logger is available in console as "apiLogger"\n' +
|
||||||
' - apiLogger.getLogs() - View all logs\n' +
|
' - apiLogger.getLogs() - View all logs\n' +
|
||||||
|
|||||||
Reference in New Issue
Block a user