Merge remote-tracking branch 'origin/master'
# Conflicts: # src/components/hr/SalaryManagement/index.tsx # src/components/production/WorkResults/WorkResultList.tsx # tsconfig.tsbuildinfo
This commit is contained in:
@@ -1,26 +1,30 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 입찰관리 리스트 - UniversalListPage 마이그레이션
|
||||
*
|
||||
* 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환
|
||||
* - 클라이언트 사이드 필터링 (검색, 필터, 정렬)
|
||||
* - Stats 카드 클릭 필터링 (activeStatTab)
|
||||
* - DateRangeSelector (headerActions → dateRangeSelector config)
|
||||
* - filterConfig (multi: 거래처, 입찰자 / single: 상태, 정렬)
|
||||
* - 등록 버튼 없음 (견적완료 시 자동 등록)
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FileText, Clock, Trophy, Pencil } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { MultiSelectOption } from '@/components/ui/multi-select-combobox';
|
||||
import { IntegratedListTemplateV2, TableColumn, StatCard, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { MobileCard } from '@/components/molecules/MobileCard';
|
||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
type SelectionHandlers,
|
||||
type RowClickHandlers,
|
||||
type StatCard,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import type { Bidding, BiddingStats } from './types';
|
||||
import {
|
||||
BIDDING_STATUS_OPTIONS,
|
||||
@@ -30,8 +34,8 @@ import {
|
||||
} from './types';
|
||||
import { getBiddingList, getBiddingStats, deleteBidding, deleteBiddings } from './actions';
|
||||
|
||||
// 테이블 컬럼 정의 (체크박스, 번호, 입찰번호, 거래처, 현장명, 입찰자, 총 개소, 입찰금액, 입찰일, 투찰일, 확정일, 상태, 비고, 작업)
|
||||
const tableColumns: TableColumn[] = [
|
||||
// 테이블 컬럼 정의
|
||||
const tableColumns = [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'biddingCode', label: '입찰번호', className: 'w-[120px]' },
|
||||
{ key: 'partnerName', label: '거래처', className: 'w-[100px]' },
|
||||
@@ -47,15 +51,14 @@ const tableColumns: TableColumn[] = [
|
||||
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
|
||||
];
|
||||
|
||||
// 목업 거래처 목록 (다중선택용 - 빈 배열 = 전체)
|
||||
const MOCK_PARTNERS: MultiSelectOption[] = [
|
||||
// 거래처/입찰자 옵션 (다중선택용)
|
||||
const MOCK_PARTNERS = [
|
||||
{ value: '1', label: '이사대표' },
|
||||
{ value: '2', label: '야사건설' },
|
||||
{ value: '3', label: '여의건설' },
|
||||
];
|
||||
|
||||
// 목업 입찰자 목록 (다중선택용 - 빈 배열 = 전체)
|
||||
const MOCK_BIDDERS: MultiSelectOption[] = [
|
||||
const MOCK_BIDDERS = [
|
||||
{ value: 'hong', label: '홍길동' },
|
||||
{ value: 'kim', label: '김철수' },
|
||||
{ value: 'lee', label: '이영희' },
|
||||
@@ -70,11 +73,14 @@ function formatAmount(amount: number): string {
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).replace(/\. /g, '-').replace('.', '');
|
||||
return date
|
||||
.toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
.replace(/\. /g, '-')
|
||||
.replace('.', '');
|
||||
}
|
||||
|
||||
interface BiddingListClientProps {
|
||||
@@ -85,505 +91,327 @@ interface BiddingListClientProps {
|
||||
export default function BiddingListClient({ initialData = [], initialStats }: BiddingListClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// 상태
|
||||
const [biddings, setBiddings] = useState<Bidding[]>(initialData);
|
||||
const [stats, setStats] = useState<BiddingStats | null>(initialStats || null);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [partnerFilters, setPartnerFilters] = useState<string[]>([]);
|
||||
const [bidderFilters, setBidderFilters] = useState<string[]>([]);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [sortBy, setSortBy] = useState<string>('biddingDateDesc');
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
|
||||
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
|
||||
// Stats 카드 클릭 필터용
|
||||
const [activeStatTab, setActiveStatTab] = useState<'all' | 'waiting' | 'awarded'>('all');
|
||||
const itemsPerPage = 20;
|
||||
// 날짜 범위
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
// Stats 데이터
|
||||
const [stats, setStats] = useState<BiddingStats | null>(initialStats || null);
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [listResult, statsResult] = await Promise.all([
|
||||
getBiddingList({
|
||||
size: 1000,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
}),
|
||||
getBiddingStats(),
|
||||
]);
|
||||
|
||||
if (listResult.success && listResult.data) {
|
||||
setBiddings(listResult.data.items);
|
||||
}
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStats(statsResult.data);
|
||||
}
|
||||
} catch {
|
||||
toast.error('데이터 로드에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [startDate, endDate]);
|
||||
|
||||
// 초기 데이터가 없으면 로드
|
||||
// Stats 로드
|
||||
useEffect(() => {
|
||||
if (initialData.length === 0) {
|
||||
loadData();
|
||||
if (!initialStats) {
|
||||
getBiddingStats().then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setStats(result.data);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [initialData.length, loadData]);
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredBiddings = useMemo(() => {
|
||||
return biddings.filter((bidding) => {
|
||||
// 상태 탭 필터
|
||||
if (activeStatTab === 'waiting' && bidding.status !== 'waiting') return false;
|
||||
if (activeStatTab === 'awarded' && bidding.status !== 'awarded') return false;
|
||||
|
||||
// 거래처 필터 (다중선택 - 빈 배열 = 전체)
|
||||
if (partnerFilters.length > 0) {
|
||||
if (!partnerFilters.includes(bidding.partnerId)) return false;
|
||||
}
|
||||
|
||||
// 입찰자 필터 (다중선택 - 빈 배열 = 전체)
|
||||
if (bidderFilters.length > 0) {
|
||||
if (!bidderFilters.includes(bidding.bidderId)) return false;
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (statusFilter !== 'all' && bidding.status !== statusFilter) return false;
|
||||
|
||||
// 검색 필터
|
||||
if (searchValue) {
|
||||
const search = searchValue.toLowerCase();
|
||||
return (
|
||||
bidding.projectName.toLowerCase().includes(search) ||
|
||||
bidding.biddingCode.toLowerCase().includes(search) ||
|
||||
bidding.partnerName.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [biddings, activeStatTab, partnerFilters, bidderFilters, statusFilter, searchValue]);
|
||||
|
||||
// 정렬
|
||||
const sortedBiddings = useMemo(() => {
|
||||
const sorted = [...filteredBiddings];
|
||||
switch (sortBy) {
|
||||
case 'biddingDateDesc':
|
||||
sorted.sort((a, b) => {
|
||||
if (!a.biddingDate) return 1;
|
||||
if (!b.biddingDate) return -1;
|
||||
return new Date(b.biddingDate).getTime() - new Date(a.biddingDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'biddingDateAsc':
|
||||
sorted.sort((a, b) => {
|
||||
if (!a.biddingDate) return 1;
|
||||
if (!b.biddingDate) return -1;
|
||||
return new Date(a.biddingDate).getTime() - new Date(b.biddingDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'submissionDateDesc':
|
||||
sorted.sort((a, b) => {
|
||||
if (!a.submissionDate) return 1;
|
||||
if (!b.submissionDate) return -1;
|
||||
return new Date(b.submissionDate).getTime() - new Date(a.submissionDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'confirmDateDesc':
|
||||
sorted.sort((a, b) => {
|
||||
if (!a.confirmDate) return 1;
|
||||
if (!b.confirmDate) return -1;
|
||||
return new Date(b.confirmDate).getTime() - new Date(a.confirmDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'partnerNameAsc':
|
||||
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
|
||||
break;
|
||||
case 'partnerNameDesc':
|
||||
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
|
||||
break;
|
||||
case 'projectNameAsc':
|
||||
sorted.sort((a, b) => a.projectName.localeCompare(b.projectName, 'ko'));
|
||||
break;
|
||||
case 'projectNameDesc':
|
||||
sorted.sort((a, b) => b.projectName.localeCompare(a.projectName, 'ko'));
|
||||
break;
|
||||
}
|
||||
return sorted;
|
||||
}, [filteredBiddings, sortBy]);
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(sortedBiddings.length / itemsPerPage);
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (currentPage - 1) * itemsPerPage;
|
||||
return sortedBiddings.slice(start, start + itemsPerPage);
|
||||
}, [sortedBiddings, currentPage, itemsPerPage]);
|
||||
|
||||
// 핸들러
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchValue(value);
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const handleToggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggleSelectAll = useCallback(() => {
|
||||
if (selectedItems.size === paginatedData.length) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(paginatedData.map((b) => b.id)));
|
||||
}
|
||||
}, [selectedItems.size, paginatedData]);
|
||||
}, [initialStats]);
|
||||
|
||||
// ===== 핸들러 =====
|
||||
const handleRowClick = useCallback(
|
||||
(bidding: Bidding) => {
|
||||
router.push(`/ko/construction/project/bidding/${bidding.id}`);
|
||||
(item: Bidding) => {
|
||||
router.push(`/ko/construction/project/bidding/${item.id}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(e: React.MouseEvent, biddingId: string) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/ko/construction/project/bidding/${biddingId}/edit`);
|
||||
(item: Bidding) => {
|
||||
router.push(`/ko/construction/project/bidding/${item.id}/edit`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleDeleteClick = useCallback((e: React.MouseEvent, biddingId: string) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTargetId(biddingId);
|
||||
setDeleteDialogOpen(true);
|
||||
}, []);
|
||||
// ===== UniversalListPage Config =====
|
||||
const config: UniversalListConfig<Bidding> = useMemo(
|
||||
() => ({
|
||||
// 페이지 기본 정보
|
||||
title: '입찰관리',
|
||||
description: '입찰을 관리합니다 (견적완료 시 자동 등록)',
|
||||
icon: FileText,
|
||||
basePath: '/construction/project/bidding',
|
||||
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
if (!deleteTargetId) return;
|
||||
// ID 추출
|
||||
idField: 'id',
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await deleteBidding(deleteTargetId);
|
||||
if (result.success) {
|
||||
toast.success('입찰이 삭제되었습니다.');
|
||||
setBiddings((prev) => prev.filter((b) => b.id !== deleteTargetId));
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(deleteTargetId);
|
||||
return newSet;
|
||||
// API 액션
|
||||
actions: {
|
||||
getList: async () => {
|
||||
const result = await getBiddingList({
|
||||
size: 1000,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
});
|
||||
if (result.success && result.data) {
|
||||
return {
|
||||
success: true,
|
||||
data: result.data.items,
|
||||
totalCount: result.data.total,
|
||||
};
|
||||
}
|
||||
return { success: false, error: result.error };
|
||||
},
|
||||
deleteItem: async (id: string) => {
|
||||
const result = await deleteBidding(id);
|
||||
return { success: result.success, error: result.error };
|
||||
},
|
||||
deleteBulk: async (ids: string[]) => {
|
||||
const result = await deleteBiddings(ids);
|
||||
return { success: result.success, error: result.error };
|
||||
},
|
||||
},
|
||||
|
||||
// 테이블 컬럼
|
||||
columns: tableColumns,
|
||||
|
||||
// 클라이언트 사이드 필터링
|
||||
clientSideFiltering: true,
|
||||
itemsPerPage: 20,
|
||||
|
||||
// 검색 필터
|
||||
searchPlaceholder: '입찰번호, 거래처, 현장명 검색',
|
||||
searchFilter: (item, searchValue) => {
|
||||
const search = searchValue.toLowerCase();
|
||||
return (
|
||||
item.projectName.toLowerCase().includes(search) ||
|
||||
item.biddingCode.toLowerCase().includes(search) ||
|
||||
item.partnerName.toLowerCase().includes(search)
|
||||
);
|
||||
},
|
||||
|
||||
// 필터 설정 (PC: 인라인, 모바일: 바텀시트)
|
||||
filterConfig: [
|
||||
{
|
||||
key: 'partner',
|
||||
label: '거래처',
|
||||
type: 'multi',
|
||||
options: MOCK_PARTNERS,
|
||||
},
|
||||
{
|
||||
key: 'bidder',
|
||||
label: '입찰자',
|
||||
type: 'multi',
|
||||
options: MOCK_BIDDERS,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'single',
|
||||
options: BIDDING_STATUS_OPTIONS.filter((o) => o.value !== 'all'),
|
||||
},
|
||||
{
|
||||
key: 'sortBy',
|
||||
label: '정렬',
|
||||
type: 'single',
|
||||
options: BIDDING_SORT_OPTIONS,
|
||||
},
|
||||
],
|
||||
initialFilters: {
|
||||
partner: [],
|
||||
bidder: [],
|
||||
status: 'all',
|
||||
sortBy: 'biddingDateDesc',
|
||||
},
|
||||
filterTitle: '입찰 필터',
|
||||
|
||||
// 커스텀 필터 함수 (activeStatTab + filterValues 기반)
|
||||
customFilterFn: (items, filterValues) => {
|
||||
return items.filter((item) => {
|
||||
// Stats 탭 필터
|
||||
if (activeStatTab === 'waiting' && item.status !== 'waiting') return false;
|
||||
if (activeStatTab === 'awarded' && item.status !== 'awarded') return false;
|
||||
|
||||
// 거래처 필터 (다중선택)
|
||||
const partnerFilters = filterValues.partner as string[];
|
||||
if (partnerFilters?.length > 0 && !partnerFilters.includes(item.partnerId)) return false;
|
||||
|
||||
// 입찰자 필터 (다중선택)
|
||||
const bidderFilters = filterValues.bidder as string[];
|
||||
if (bidderFilters?.length > 0 && !bidderFilters.includes(item.bidderId)) return false;
|
||||
|
||||
// 상태 필터
|
||||
const statusFilter = filterValues.status as string;
|
||||
if (statusFilter && statusFilter !== 'all' && item.status !== statusFilter) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setDeleteDialogOpen(false);
|
||||
setDeleteTargetId(null);
|
||||
}
|
||||
}, [deleteTargetId]);
|
||||
},
|
||||
|
||||
const handleBulkDeleteClick = useCallback(() => {
|
||||
if (selectedItems.size === 0) {
|
||||
toast.warning('삭제할 항목을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
setBulkDeleteDialogOpen(true);
|
||||
}, [selectedItems.size]);
|
||||
// 커스텀 정렬 함수
|
||||
customSortFn: (items, filterValues) => {
|
||||
const sorted = [...items];
|
||||
const sortBy = (filterValues.sortBy as string) || 'biddingDateDesc';
|
||||
|
||||
const handleBulkDeleteConfirm = useCallback(async () => {
|
||||
if (selectedItems.size === 0) return;
|
||||
switch (sortBy) {
|
||||
case 'biddingDateDesc':
|
||||
sorted.sort((a, b) => {
|
||||
if (!a.biddingDate) return 1;
|
||||
if (!b.biddingDate) return -1;
|
||||
return new Date(b.biddingDate).getTime() - new Date(a.biddingDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'biddingDateAsc':
|
||||
sorted.sort((a, b) => {
|
||||
if (!a.biddingDate) return 1;
|
||||
if (!b.biddingDate) return -1;
|
||||
return new Date(a.biddingDate).getTime() - new Date(b.biddingDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'submissionDateDesc':
|
||||
sorted.sort((a, b) => {
|
||||
if (!a.submissionDate) return 1;
|
||||
if (!b.submissionDate) return -1;
|
||||
return new Date(b.submissionDate).getTime() - new Date(a.submissionDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'confirmDateDesc':
|
||||
sorted.sort((a, b) => {
|
||||
if (!a.confirmDate) return 1;
|
||||
if (!b.confirmDate) return -1;
|
||||
return new Date(b.confirmDate).getTime() - new Date(a.confirmDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'partnerNameAsc':
|
||||
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
|
||||
break;
|
||||
case 'partnerNameDesc':
|
||||
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
|
||||
break;
|
||||
case 'projectNameAsc':
|
||||
sorted.sort((a, b) => a.projectName.localeCompare(b.projectName, 'ko'));
|
||||
break;
|
||||
case 'projectNameDesc':
|
||||
sorted.sort((a, b) => b.projectName.localeCompare(a.projectName, 'ko'));
|
||||
break;
|
||||
}
|
||||
return sorted;
|
||||
},
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const ids = Array.from(selectedItems);
|
||||
const result = await deleteBiddings(ids);
|
||||
if (result.success) {
|
||||
toast.success(`${result.deletedCount}개 항목이 삭제되었습니다.`);
|
||||
await loadData();
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
toast.error(result.error || '일괄 삭제에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('일괄 삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setBulkDeleteDialogOpen(false);
|
||||
}
|
||||
}, [selectedItems, loadData]);
|
||||
// 공통 헤더 옵션: 날짜 선택기
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
|
||||
// ===== filterConfig 기반 통합 필터 시스템 =====
|
||||
const filterConfig: FilterFieldConfig[] = useMemo(() => [
|
||||
{
|
||||
key: 'partner',
|
||||
label: '거래처',
|
||||
type: 'multi',
|
||||
options: MOCK_PARTNERS.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'bidder',
|
||||
label: '입찰자',
|
||||
type: 'multi',
|
||||
options: MOCK_BIDDERS.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'single',
|
||||
options: BIDDING_STATUS_OPTIONS.filter(o => o.value !== 'all').map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '전체',
|
||||
},
|
||||
{
|
||||
key: 'sortBy',
|
||||
label: '정렬',
|
||||
type: 'single',
|
||||
options: BIDDING_SORT_OPTIONS.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '최신순 (입찰일)',
|
||||
},
|
||||
], []);
|
||||
// Stats 카드 (동적 계산 with onClick)
|
||||
computeStats: (): StatCard[] => [
|
||||
{
|
||||
label: '전체 입찰',
|
||||
value: stats?.total ?? 0,
|
||||
icon: FileText,
|
||||
iconColor: 'text-blue-600',
|
||||
onClick: () => setActiveStatTab('all'),
|
||||
isActive: activeStatTab === 'all',
|
||||
},
|
||||
{
|
||||
label: '입찰대기',
|
||||
value: stats?.waiting ?? 0,
|
||||
icon: Clock,
|
||||
iconColor: 'text-orange-500',
|
||||
onClick: () => setActiveStatTab('waiting'),
|
||||
isActive: activeStatTab === 'waiting',
|
||||
},
|
||||
{
|
||||
label: '낙찰',
|
||||
value: stats?.awarded ?? 0,
|
||||
icon: Trophy,
|
||||
iconColor: 'text-green-600',
|
||||
onClick: () => setActiveStatTab('awarded'),
|
||||
isActive: activeStatTab === 'awarded',
|
||||
},
|
||||
],
|
||||
|
||||
const filterValues: FilterValues = useMemo(() => ({
|
||||
partner: partnerFilters,
|
||||
bidder: bidderFilters,
|
||||
status: statusFilter,
|
||||
sortBy: sortBy,
|
||||
}), [partnerFilters, bidderFilters, statusFilter, sortBy]);
|
||||
// 삭제 확인 메시지
|
||||
deleteConfirmMessage: {
|
||||
title: '입찰 삭제',
|
||||
description: '선택한 입찰을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.',
|
||||
},
|
||||
|
||||
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
|
||||
switch (key) {
|
||||
case 'partner':
|
||||
setPartnerFilters(value as string[]);
|
||||
break;
|
||||
case 'bidder':
|
||||
setBidderFilters(value as string[]);
|
||||
break;
|
||||
case 'status':
|
||||
setStatusFilter(value as string);
|
||||
break;
|
||||
case 'sortBy':
|
||||
setSortBy(value as string);
|
||||
break;
|
||||
}
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const handleFilterReset = useCallback(() => {
|
||||
setPartnerFilters([]);
|
||||
setBidderFilters([]);
|
||||
setStatusFilter('all');
|
||||
setSortBy('biddingDateDesc');
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = useCallback(
|
||||
(bidding: Bidding, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(bidding.id);
|
||||
|
||||
return (
|
||||
// 테이블 행 렌더링
|
||||
renderTableRow: (
|
||||
item: Bidding,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<Bidding>
|
||||
) => (
|
||||
<TableRow
|
||||
key={bidding.id}
|
||||
key={item.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleRowClick(bidding)}
|
||||
onClick={() => handleRowClick(item)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleSelection(bidding.id)}
|
||||
/>
|
||||
<Checkbox checked={handlers.isSelected} onCheckedChange={handlers.onToggle} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell>{bidding.biddingCode}</TableCell>
|
||||
<TableCell>{bidding.partnerName}</TableCell>
|
||||
<TableCell>{bidding.projectName}</TableCell>
|
||||
<TableCell>{bidding.bidderName}</TableCell>
|
||||
<TableCell className="text-center">{bidding.totalCount}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(bidding.biddingAmount)}</TableCell>
|
||||
<TableCell className="text-center">{formatDate(bidding.bidDate)}</TableCell>
|
||||
<TableCell className="text-center">{formatDate(bidding.submissionDate)}</TableCell>
|
||||
<TableCell className="text-center">{formatDate(bidding.confirmDate)}</TableCell>
|
||||
<TableCell>{item.biddingCode}</TableCell>
|
||||
<TableCell>{item.partnerName}</TableCell>
|
||||
<TableCell>{item.projectName}</TableCell>
|
||||
<TableCell>{item.bidderName}</TableCell>
|
||||
<TableCell className="text-center">{item.totalCount}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(item.biddingAmount)}</TableCell>
|
||||
<TableCell className="text-center">{formatDate(item.bidDate)}</TableCell>
|
||||
<TableCell className="text-center">{formatDate(item.submissionDate)}</TableCell>
|
||||
<TableCell className="text-center">{formatDate(item.confirmDate)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={BIDDING_STATUS_STYLES[bidding.status]}>
|
||||
{BIDDING_STATUS_LABELS[bidding.status]}
|
||||
<span className={BIDDING_STATUS_STYLES[item.status]}>
|
||||
{BIDDING_STATUS_LABELS[item.status]}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="truncate max-w-[120px]" title={bidding.remarks}>
|
||||
{bidding.remarks || '-'}
|
||||
<TableCell className="truncate max-w-[120px]" title={item.remarks}>
|
||||
{item.remarks || '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{isSelected && (
|
||||
{handlers.isSelected && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => handleEdit(e, bidding.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(item);
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
[selectedItems, handleToggleSelection, handleRowClick, handleEdit]
|
||||
);
|
||||
),
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = useCallback(
|
||||
(bidding: Bidding, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
|
||||
return (
|
||||
// 모바일 카드 렌더링
|
||||
renderMobileCard: (
|
||||
item: Bidding,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<Bidding>
|
||||
) => (
|
||||
<MobileCard
|
||||
title={bidding.projectName}
|
||||
subtitle={bidding.biddingCode}
|
||||
badge={BIDDING_STATUS_LABELS[bidding.status]}
|
||||
key={item.id}
|
||||
title={item.projectName}
|
||||
subtitle={item.biddingCode}
|
||||
badge={BIDDING_STATUS_LABELS[item.status]}
|
||||
badgeVariant="secondary"
|
||||
isSelected={isSelected}
|
||||
onToggle={onToggle}
|
||||
onClick={() => handleRowClick(bidding)}
|
||||
isSelected={handlers.isSelected}
|
||||
onToggle={handlers.onToggle}
|
||||
onClick={() => handleRowClick(item)}
|
||||
details={[
|
||||
{ label: '거래처', value: bidding.partnerName },
|
||||
{ label: '입찰금액', value: `${formatAmount(bidding.biddingAmount)}원` },
|
||||
{ label: '입찰일자', value: formatDate(bidding.biddingDate) },
|
||||
{ label: '총 개소', value: `${bidding.totalCount}` },
|
||||
{ label: '거래처', value: item.partnerName },
|
||||
{ label: '입찰금액', value: `${formatAmount(item.biddingAmount)}원` },
|
||||
{ label: '입찰일자', value: formatDate(item.biddingDate) },
|
||||
{ label: '총 개소', value: `${item.totalCount}` },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[handleRowClick]
|
||||
),
|
||||
}),
|
||||
[startDate, endDate, activeStatTab, stats, handleRowClick, handleEdit]
|
||||
);
|
||||
|
||||
// 헤더 액션 (날짜 필터) - 등록 버튼 없음 (견적완료 시 자동 등록)
|
||||
const headerActions = (
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
/>
|
||||
);
|
||||
|
||||
// Stats 카드 데이터 (전체 입찰, 입찰대기, 낙찰)
|
||||
const statsCardsData: StatCard[] = [
|
||||
{
|
||||
label: '전체 입찰',
|
||||
value: stats?.total ?? 0,
|
||||
icon: FileText,
|
||||
iconColor: 'text-blue-600',
|
||||
onClick: () => setActiveStatTab('all'),
|
||||
isActive: activeStatTab === 'all',
|
||||
},
|
||||
{
|
||||
label: '입찰대기',
|
||||
value: stats?.waiting ?? 0,
|
||||
icon: Clock,
|
||||
iconColor: 'text-orange-500',
|
||||
onClick: () => setActiveStatTab('waiting'),
|
||||
isActive: activeStatTab === 'waiting',
|
||||
},
|
||||
{
|
||||
label: '낙찰',
|
||||
value: stats?.awarded ?? 0,
|
||||
icon: Trophy,
|
||||
iconColor: 'text-green-600',
|
||||
onClick: () => setActiveStatTab('awarded'),
|
||||
isActive: activeStatTab === 'awarded',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedListTemplateV2
|
||||
title="입찰관리"
|
||||
description="입찰을 관리합니다 (견적완료 시 자동 등록)"
|
||||
icon={FileText}
|
||||
headerActions={headerActions}
|
||||
stats={statsCardsData}
|
||||
filterConfig={filterConfig}
|
||||
filterValues={filterValues}
|
||||
onFilterChange={handleFilterChange}
|
||||
onFilterReset={handleFilterReset}
|
||||
filterTitle="입찰 필터"
|
||||
searchValue={searchValue}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="입찰번호, 거래처, 현장명 검색"
|
||||
tableColumns={tableColumns}
|
||||
data={paginatedData}
|
||||
allData={sortedBiddings}
|
||||
getItemId={(item) => item.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
onBulkDelete={handleBulkDeleteClick}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: sortedBiddings.length,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 단일 삭제 다이얼로그 */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>입찰 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 입찰을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteConfirm}>삭제</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 일괄 삭제 다이얼로그 */}
|
||||
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>입찰 일괄 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 {selectedItems.size}개 입찰을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleBulkDeleteConfirm}>삭제</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <UniversalListPage config={config} initialData={initialData} />;
|
||||
}
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 계약관리 리스트 - UniversalListPage 마이그레이션
|
||||
*
|
||||
* 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환
|
||||
* - 클라이언트 사이드 필터링 (검색, 필터, 정렬)
|
||||
* - Stats 카드 클릭 필터링 (activeStatTab)
|
||||
* - DateRangeSelector (headerActions → dateRangeSelector config)
|
||||
* - filterConfig (multi: 거래처, 계약담당자, 공사PM / single: 상태, 정렬)
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FileText, Clock, CheckCircle, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { MultiSelectOption } from '@/components/ui/multi-select-combobox';
|
||||
import { IntegratedListTemplateV2, TableColumn, StatCard, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { MobileCard } from '@/components/molecules/MobileCard';
|
||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
type SelectionHandlers,
|
||||
type RowClickHandlers,
|
||||
type StatCard,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import type { Contract, ContractStats } from './types';
|
||||
import {
|
||||
CONTRACT_STATUS_OPTIONS,
|
||||
@@ -31,8 +34,7 @@ import {
|
||||
import { getContractList, getContractStats, deleteContract, deleteContracts } from './actions';
|
||||
|
||||
// 테이블 컬럼 정의
|
||||
// 순서: 체크박스, 번호, 계약번호, 거래처, 현장명, 계약담당자, 공사PM, 총 개소, 계약금액, 계약기간, 상태, 작업
|
||||
const tableColumns: TableColumn[] = [
|
||||
const tableColumns = [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'contractCode', label: '계약번호', className: 'w-[120px]' },
|
||||
{ key: 'partnerName', label: '거래처', className: 'w-[100px]' },
|
||||
@@ -46,22 +48,20 @@ const tableColumns: TableColumn[] = [
|
||||
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
|
||||
];
|
||||
|
||||
// 목업 거래처 목록
|
||||
const MOCK_PARTNERS: MultiSelectOption[] = [
|
||||
// 목업 데이터
|
||||
const MOCK_PARTNERS = [
|
||||
{ value: '1', label: '통신공사' },
|
||||
{ value: '2', label: '야사건설' },
|
||||
{ value: '3', label: '여의건설' },
|
||||
];
|
||||
|
||||
// 목업 계약담당자 목록
|
||||
const MOCK_CONTRACT_MANAGERS: MultiSelectOption[] = [
|
||||
const MOCK_CONTRACT_MANAGERS = [
|
||||
{ value: 'hong', label: '홍길동' },
|
||||
{ value: 'kim', label: '김철수' },
|
||||
{ value: 'lee', label: '이영희' },
|
||||
];
|
||||
|
||||
// 목업 공사PM 목록
|
||||
const MOCK_CONSTRUCTION_PMS: MultiSelectOption[] = [
|
||||
const MOCK_CONSTRUCTION_PMS = [
|
||||
{ value: 'kim', label: '김PM' },
|
||||
{ value: 'lee', label: '이PM' },
|
||||
{ value: 'park', label: '박PM' },
|
||||
@@ -76,11 +76,14 @@ function formatAmount(amount: number): string {
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).replace(/\. /g, '-').replace('.', '');
|
||||
return date
|
||||
.toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
.replace(/\. /g, '-')
|
||||
.replace('.', '');
|
||||
}
|
||||
|
||||
// 계약기간 포맷팅
|
||||
@@ -96,384 +99,294 @@ interface ContractListClientProps {
|
||||
initialStats?: ContractStats;
|
||||
}
|
||||
|
||||
export default function ContractListClient({
|
||||
initialData = [],
|
||||
initialStats,
|
||||
}: ContractListClientProps) {
|
||||
export default function ContractListClient({ initialData = [], initialStats }: ContractListClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// 상태
|
||||
const [contracts, setContracts] = useState<Contract[]>(initialData);
|
||||
const [stats, setStats] = useState<ContractStats | null>(initialStats || null);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [partnerFilters, setPartnerFilters] = useState<string[]>([]);
|
||||
const [contractManagerFilters, setContractManagerFilters] = useState<string[]>([]);
|
||||
const [constructionPMFilters, setConstructionPMFilters] = useState<string[]>([]);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [sortBy, setSortBy] = useState<string>('contractDateDesc');
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
|
||||
// ===== 외부 상태 =====
|
||||
const [activeStatTab, setActiveStatTab] = useState<'all' | 'pending' | 'completed'>('all');
|
||||
const itemsPerPage = 20;
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [stats, setStats] = useState<ContractStats | null>(initialStats || null);
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [listResult, statsResult] = await Promise.all([
|
||||
getContractList({
|
||||
size: 1000,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
}),
|
||||
getContractStats(),
|
||||
]);
|
||||
|
||||
if (listResult.success && listResult.data) {
|
||||
setContracts(listResult.data.items);
|
||||
}
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStats(statsResult.data);
|
||||
}
|
||||
} catch {
|
||||
toast.error('데이터 로드에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [startDate, endDate]);
|
||||
|
||||
// 초기 데이터가 없으면 로드
|
||||
// Stats 로드
|
||||
useEffect(() => {
|
||||
if (initialData.length === 0) {
|
||||
loadData();
|
||||
if (!initialStats) {
|
||||
getContractStats().then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setStats(result.data);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [initialData.length, loadData]);
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredContracts = useMemo(() => {
|
||||
return contracts.filter((contract) => {
|
||||
// 상태 탭 필터
|
||||
if (activeStatTab === 'pending' && contract.status !== 'pending') return false;
|
||||
if (activeStatTab === 'completed' && contract.status !== 'completed') return false;
|
||||
|
||||
// 거래처 필터
|
||||
if (partnerFilters.length > 0) {
|
||||
if (!partnerFilters.includes(contract.partnerId)) return false;
|
||||
}
|
||||
|
||||
// 계약담당자 필터
|
||||
if (contractManagerFilters.length > 0) {
|
||||
if (!contractManagerFilters.includes(contract.contractManagerId)) return false;
|
||||
}
|
||||
|
||||
// 공사PM 필터
|
||||
if (constructionPMFilters.length > 0) {
|
||||
if (!constructionPMFilters.includes(contract.constructionPMId || '')) return false;
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (statusFilter !== 'all' && contract.status !== statusFilter) return false;
|
||||
|
||||
// 검색 필터
|
||||
if (searchValue) {
|
||||
const search = searchValue.toLowerCase();
|
||||
return (
|
||||
contract.projectName.toLowerCase().includes(search) ||
|
||||
contract.contractCode.toLowerCase().includes(search) ||
|
||||
contract.partnerName.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [contracts, activeStatTab, partnerFilters, contractManagerFilters, constructionPMFilters, statusFilter, searchValue]);
|
||||
|
||||
// 정렬
|
||||
const sortedContracts = useMemo(() => {
|
||||
const sorted = [...filteredContracts];
|
||||
switch (sortBy) {
|
||||
case 'contractDateDesc':
|
||||
sorted.sort((a, b) => {
|
||||
if (!a.contractStartDate) return 1;
|
||||
if (!b.contractStartDate) return -1;
|
||||
return new Date(b.contractStartDate).getTime() - new Date(a.contractStartDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'contractDateAsc':
|
||||
sorted.sort((a, b) => {
|
||||
if (!a.contractStartDate) return 1;
|
||||
if (!b.contractStartDate) return -1;
|
||||
return new Date(a.contractStartDate).getTime() - new Date(b.contractStartDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'partnerNameAsc':
|
||||
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
|
||||
break;
|
||||
case 'partnerNameDesc':
|
||||
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
|
||||
break;
|
||||
case 'projectNameAsc':
|
||||
sorted.sort((a, b) => a.projectName.localeCompare(b.projectName, 'ko'));
|
||||
break;
|
||||
case 'projectNameDesc':
|
||||
sorted.sort((a, b) => b.projectName.localeCompare(a.projectName, 'ko'));
|
||||
break;
|
||||
case 'amountDesc':
|
||||
sorted.sort((a, b) => b.contractAmount - a.contractAmount);
|
||||
break;
|
||||
case 'amountAsc':
|
||||
sorted.sort((a, b) => a.contractAmount - b.contractAmount);
|
||||
break;
|
||||
}
|
||||
return sorted;
|
||||
}, [filteredContracts, sortBy]);
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(sortedContracts.length / itemsPerPage);
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (currentPage - 1) * itemsPerPage;
|
||||
return sortedContracts.slice(start, start + itemsPerPage);
|
||||
}, [sortedContracts, currentPage, itemsPerPage]);
|
||||
|
||||
// 핸들러
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchValue(value);
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const handleToggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggleSelectAll = useCallback(() => {
|
||||
if (selectedItems.size === paginatedData.length) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(paginatedData.map((c) => c.id)));
|
||||
}
|
||||
}, [selectedItems.size, paginatedData]);
|
||||
}, [initialStats]);
|
||||
|
||||
// ===== 핸들러 =====
|
||||
const handleRowClick = useCallback(
|
||||
(contract: Contract) => {
|
||||
router.push(`/ko/construction/project/contract/${contract.id}`);
|
||||
(item: Contract) => {
|
||||
router.push(`/ko/construction/project/contract/${item.id}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(e: React.MouseEvent, contractId: string) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/ko/construction/project/contract/${contractId}/edit`);
|
||||
(item: Contract) => {
|
||||
router.push(`/ko/construction/project/contract/${item.id}/edit`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleDeleteClick = useCallback((e: React.MouseEvent, contractId: string) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTargetId(contractId);
|
||||
setDeleteDialogOpen(true);
|
||||
}, []);
|
||||
// ===== UniversalListPage Config =====
|
||||
const config: UniversalListConfig<Contract> = useMemo(
|
||||
() => ({
|
||||
// 페이지 기본 정보
|
||||
title: '계약관리',
|
||||
description: '계약 정보를 관리합니다',
|
||||
icon: FileText,
|
||||
basePath: '/construction/project/contract',
|
||||
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
if (!deleteTargetId) return;
|
||||
// ID 추출
|
||||
idField: 'id',
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await deleteContract(deleteTargetId);
|
||||
if (result.success) {
|
||||
toast.success('계약이 삭제되었습니다.');
|
||||
setContracts((prev) => prev.filter((c) => c.id !== deleteTargetId));
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(deleteTargetId);
|
||||
return newSet;
|
||||
// API 액션
|
||||
actions: {
|
||||
getList: async () => {
|
||||
const result = await getContractList({
|
||||
size: 1000,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
});
|
||||
if (result.success && result.data) {
|
||||
return {
|
||||
success: true,
|
||||
data: result.data.items,
|
||||
totalCount: result.data.total,
|
||||
};
|
||||
}
|
||||
return { success: false, error: result.error };
|
||||
},
|
||||
deleteItem: async (id: string) => {
|
||||
const result = await deleteContract(id);
|
||||
return { success: result.success, error: result.error };
|
||||
},
|
||||
deleteBulk: async (ids: string[]) => {
|
||||
const result = await deleteContracts(ids);
|
||||
return { success: result.success, error: result.error };
|
||||
},
|
||||
},
|
||||
|
||||
// 테이블 컬럼
|
||||
columns: tableColumns,
|
||||
|
||||
// 클라이언트 사이드 필터링
|
||||
clientSideFiltering: true,
|
||||
itemsPerPage: 20,
|
||||
|
||||
// 검색 필터
|
||||
searchPlaceholder: '계약번호, 거래처, 현장명 검색',
|
||||
searchFilter: (item, searchValue) => {
|
||||
const search = searchValue.toLowerCase();
|
||||
return (
|
||||
item.projectName.toLowerCase().includes(search) ||
|
||||
item.contractCode.toLowerCase().includes(search) ||
|
||||
item.partnerName.toLowerCase().includes(search)
|
||||
);
|
||||
},
|
||||
|
||||
// 필터 설정
|
||||
filterConfig: [
|
||||
{
|
||||
key: 'partner',
|
||||
label: '거래처',
|
||||
type: 'multi',
|
||||
options: MOCK_PARTNERS,
|
||||
},
|
||||
{
|
||||
key: 'contractManager',
|
||||
label: '계약담당자',
|
||||
type: 'multi',
|
||||
options: MOCK_CONTRACT_MANAGERS,
|
||||
},
|
||||
{
|
||||
key: 'constructionPM',
|
||||
label: '공사PM',
|
||||
type: 'multi',
|
||||
options: MOCK_CONSTRUCTION_PMS,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'single',
|
||||
options: CONTRACT_STATUS_OPTIONS.filter((o) => o.value !== 'all'),
|
||||
},
|
||||
{
|
||||
key: 'sortBy',
|
||||
label: '정렬',
|
||||
type: 'single',
|
||||
options: CONTRACT_SORT_OPTIONS,
|
||||
},
|
||||
],
|
||||
initialFilters: {
|
||||
partner: [],
|
||||
contractManager: [],
|
||||
constructionPM: [],
|
||||
status: 'all',
|
||||
sortBy: 'contractDateDesc',
|
||||
},
|
||||
filterTitle: '계약 필터',
|
||||
|
||||
// 커스텀 필터 함수
|
||||
customFilterFn: (items, filterValues) => {
|
||||
return items.filter((item) => {
|
||||
// Stats 탭 필터
|
||||
if (activeStatTab === 'pending' && item.status !== 'pending') return false;
|
||||
if (activeStatTab === 'completed' && item.status !== 'completed') return false;
|
||||
|
||||
// 거래처 필터
|
||||
const partnerFilters = filterValues.partner as string[];
|
||||
if (partnerFilters?.length > 0 && !partnerFilters.includes(item.partnerId)) return false;
|
||||
|
||||
// 계약담당자 필터
|
||||
const contractManagerFilters = filterValues.contractManager as string[];
|
||||
if (contractManagerFilters?.length > 0 && !contractManagerFilters.includes(item.contractManagerId))
|
||||
return false;
|
||||
|
||||
// 공사PM 필터
|
||||
const constructionPMFilters = filterValues.constructionPM as string[];
|
||||
if (constructionPMFilters?.length > 0 && !constructionPMFilters.includes(item.constructionPMId || ''))
|
||||
return false;
|
||||
|
||||
// 상태 필터
|
||||
const statusFilter = filterValues.status as string;
|
||||
if (statusFilter && statusFilter !== 'all' && item.status !== statusFilter) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setDeleteDialogOpen(false);
|
||||
setDeleteTargetId(null);
|
||||
}
|
||||
}, [deleteTargetId]);
|
||||
},
|
||||
|
||||
const handleBulkDeleteClick = useCallback(() => {
|
||||
if (selectedItems.size === 0) {
|
||||
toast.warning('삭제할 항목을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
setBulkDeleteDialogOpen(true);
|
||||
}, [selectedItems.size]);
|
||||
// 커스텀 정렬 함수
|
||||
customSortFn: (items, filterValues) => {
|
||||
const sorted = [...items];
|
||||
const sortBy = (filterValues.sortBy as string) || 'contractDateDesc';
|
||||
|
||||
const handleBulkDeleteConfirm = useCallback(async () => {
|
||||
if (selectedItems.size === 0) return;
|
||||
switch (sortBy) {
|
||||
case 'contractDateDesc':
|
||||
sorted.sort((a, b) => {
|
||||
if (!a.contractStartDate) return 1;
|
||||
if (!b.contractStartDate) return -1;
|
||||
return new Date(b.contractStartDate).getTime() - new Date(a.contractStartDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'contractDateAsc':
|
||||
sorted.sort((a, b) => {
|
||||
if (!a.contractStartDate) return 1;
|
||||
if (!b.contractStartDate) return -1;
|
||||
return new Date(a.contractStartDate).getTime() - new Date(b.contractStartDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'partnerNameAsc':
|
||||
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
|
||||
break;
|
||||
case 'partnerNameDesc':
|
||||
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
|
||||
break;
|
||||
case 'projectNameAsc':
|
||||
sorted.sort((a, b) => a.projectName.localeCompare(b.projectName, 'ko'));
|
||||
break;
|
||||
case 'projectNameDesc':
|
||||
sorted.sort((a, b) => b.projectName.localeCompare(a.projectName, 'ko'));
|
||||
break;
|
||||
case 'amountDesc':
|
||||
sorted.sort((a, b) => b.contractAmount - a.contractAmount);
|
||||
break;
|
||||
case 'amountAsc':
|
||||
sorted.sort((a, b) => a.contractAmount - b.contractAmount);
|
||||
break;
|
||||
}
|
||||
return sorted;
|
||||
},
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const ids = Array.from(selectedItems);
|
||||
const result = await deleteContracts(ids);
|
||||
if (result.success) {
|
||||
toast.success(`${result.deletedCount}개 항목이 삭제되었습니다.`);
|
||||
await loadData();
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
toast.error(result.error || '일괄 삭제에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('일괄 삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setBulkDeleteDialogOpen(false);
|
||||
}
|
||||
}, [selectedItems, loadData]);
|
||||
// 공통 헤더 옵션
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
|
||||
// ===== filterConfig 기반 통합 필터 시스템 =====
|
||||
const filterConfig: FilterFieldConfig[] = useMemo(() => [
|
||||
{
|
||||
key: 'partner',
|
||||
label: '거래처',
|
||||
type: 'multi',
|
||||
options: MOCK_PARTNERS.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'contractManager',
|
||||
label: '계약담당자',
|
||||
type: 'multi',
|
||||
options: MOCK_CONTRACT_MANAGERS.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'constructionPM',
|
||||
label: '공사PM',
|
||||
type: 'multi',
|
||||
options: MOCK_CONSTRUCTION_PMS.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'single',
|
||||
options: CONTRACT_STATUS_OPTIONS.filter(o => o.value !== 'all').map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '전체',
|
||||
},
|
||||
{
|
||||
key: 'sortBy',
|
||||
label: '정렬',
|
||||
type: 'single',
|
||||
options: CONTRACT_SORT_OPTIONS.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '최신순 (계약일)',
|
||||
},
|
||||
], []);
|
||||
// Stats 카드
|
||||
computeStats: (): StatCard[] => [
|
||||
{
|
||||
label: '전체 계약',
|
||||
value: stats?.total ?? 0,
|
||||
icon: FileText,
|
||||
iconColor: 'text-blue-600',
|
||||
onClick: () => setActiveStatTab('all'),
|
||||
isActive: activeStatTab === 'all',
|
||||
},
|
||||
{
|
||||
label: '계약대기',
|
||||
value: stats?.pending ?? 0,
|
||||
icon: Clock,
|
||||
iconColor: 'text-orange-500',
|
||||
onClick: () => setActiveStatTab('pending'),
|
||||
isActive: activeStatTab === 'pending',
|
||||
},
|
||||
{
|
||||
label: '계약완료',
|
||||
value: stats?.completed ?? 0,
|
||||
icon: CheckCircle,
|
||||
iconColor: 'text-green-600',
|
||||
onClick: () => setActiveStatTab('completed'),
|
||||
isActive: activeStatTab === 'completed',
|
||||
},
|
||||
],
|
||||
|
||||
const filterValues: FilterValues = useMemo(() => ({
|
||||
partner: partnerFilters,
|
||||
contractManager: contractManagerFilters,
|
||||
constructionPM: constructionPMFilters,
|
||||
status: statusFilter,
|
||||
sortBy: sortBy,
|
||||
}), [partnerFilters, contractManagerFilters, constructionPMFilters, statusFilter, sortBy]);
|
||||
// 삭제 확인 메시지
|
||||
deleteConfirmMessage: {
|
||||
title: '계약 삭제',
|
||||
description: '선택한 계약을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.',
|
||||
},
|
||||
|
||||
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
|
||||
switch (key) {
|
||||
case 'partner':
|
||||
setPartnerFilters(value as string[]);
|
||||
break;
|
||||
case 'contractManager':
|
||||
setContractManagerFilters(value as string[]);
|
||||
break;
|
||||
case 'constructionPM':
|
||||
setConstructionPMFilters(value as string[]);
|
||||
break;
|
||||
case 'status':
|
||||
setStatusFilter(value as string);
|
||||
break;
|
||||
case 'sortBy':
|
||||
setSortBy(value as string);
|
||||
break;
|
||||
}
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const handleFilterReset = useCallback(() => {
|
||||
setPartnerFilters([]);
|
||||
setContractManagerFilters([]);
|
||||
setConstructionPMFilters([]);
|
||||
setStatusFilter('all');
|
||||
setSortBy('contractDateDesc');
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
// 테이블 행 렌더링
|
||||
// 순서: 체크박스, 번호, 계약번호, 거래처, 현장명, 계약담당자, 공사PM, 총 개소, 계약금액, 계약기간, 상태, 작업
|
||||
const renderTableRow = useCallback(
|
||||
(contract: Contract, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(contract.id);
|
||||
|
||||
return (
|
||||
// 테이블 행 렌더링
|
||||
renderTableRow: (
|
||||
item: Contract,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<Contract>
|
||||
) => (
|
||||
<TableRow
|
||||
key={contract.id}
|
||||
key={item.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleRowClick(contract)}
|
||||
onClick={() => handleRowClick(item)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleSelection(contract.id)}
|
||||
/>
|
||||
<Checkbox checked={handlers.isSelected} onCheckedChange={handlers.onToggle} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell>{contract.contractCode}</TableCell>
|
||||
<TableCell>{contract.partnerName}</TableCell>
|
||||
<TableCell>{contract.projectName}</TableCell>
|
||||
<TableCell className="text-center">{contract.contractManagerName}</TableCell>
|
||||
<TableCell className="text-center">{contract.constructionPMName || '-'}</TableCell>
|
||||
<TableCell className="text-center">{contract.totalLocations}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(contract.contractAmount)}원</TableCell>
|
||||
<TableCell>{item.contractCode}</TableCell>
|
||||
<TableCell>{item.partnerName}</TableCell>
|
||||
<TableCell>{item.projectName}</TableCell>
|
||||
<TableCell className="text-center">{item.contractManagerName}</TableCell>
|
||||
<TableCell className="text-center">{item.constructionPMName || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.totalLocations}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(item.contractAmount)}원</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{formatPeriod(contract.contractStartDate, contract.contractEndDate)}
|
||||
{formatPeriod(item.contractStartDate, item.contractEndDate)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={CONTRACT_STATUS_STYLES[contract.status]}>
|
||||
{CONTRACT_STATUS_LABELS[contract.status]}
|
||||
</span>
|
||||
<span className={CONTRACT_STATUS_STYLES[item.status]}>{CONTRACT_STATUS_LABELS[item.status]}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{isSelected && (
|
||||
{handlers.isSelected && (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => handleEdit(e, contract.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(item);
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -481,7 +394,10 @@ export default function ContractListClient({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={(e) => handleDeleteClick(e, contract.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlers.onDelete?.(item);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -489,140 +405,36 @@ export default function ContractListClient({
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
[selectedItems, handleToggleSelection, handleRowClick, handleEdit, handleDeleteClick]
|
||||
);
|
||||
),
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = useCallback(
|
||||
(contract: Contract, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
|
||||
return (
|
||||
// 모바일 카드 렌더링
|
||||
renderMobileCard: (
|
||||
item: Contract,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<Contract>
|
||||
) => (
|
||||
<MobileCard
|
||||
title={contract.projectName}
|
||||
subtitle={contract.contractCode}
|
||||
badge={CONTRACT_STATUS_LABELS[contract.status]}
|
||||
key={item.id}
|
||||
title={item.projectName}
|
||||
subtitle={item.contractCode}
|
||||
badge={CONTRACT_STATUS_LABELS[item.status]}
|
||||
badgeVariant="secondary"
|
||||
isSelected={isSelected}
|
||||
onToggle={onToggle}
|
||||
onClick={() => handleRowClick(contract)}
|
||||
isSelected={handlers.isSelected}
|
||||
onToggle={handlers.onToggle}
|
||||
onClick={() => handleRowClick(item)}
|
||||
details={[
|
||||
{ label: '거래처', value: contract.partnerName },
|
||||
{ label: '총 개소', value: `${contract.totalLocations}개` },
|
||||
{ label: '계약금액', value: `${formatAmount(contract.contractAmount)}원` },
|
||||
{ label: '계약담당자', value: contract.contractManagerName },
|
||||
{ label: '공사PM', value: contract.constructionPMName || '-' },
|
||||
{ label: '거래처', value: item.partnerName },
|
||||
{ label: '총 개소', value: `${item.totalLocations}개` },
|
||||
{ label: '계약금액', value: `${formatAmount(item.contractAmount)}원` },
|
||||
{ label: '계약담당자', value: item.contractManagerName },
|
||||
{ label: '공사PM', value: item.constructionPMName || '-' },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[handleRowClick]
|
||||
),
|
||||
}),
|
||||
[startDate, endDate, activeStatTab, stats, handleRowClick, handleEdit]
|
||||
);
|
||||
|
||||
// 헤더 액션 (날짜 필터만)
|
||||
const headerActions = (
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
/>
|
||||
);
|
||||
|
||||
// Stats 카드 데이터 (전체 계약, 계약대기, 계약완료)
|
||||
const statsCardsData: StatCard[] = [
|
||||
{
|
||||
label: '전체 계약',
|
||||
value: stats?.total ?? 0,
|
||||
icon: FileText,
|
||||
iconColor: 'text-blue-600',
|
||||
onClick: () => setActiveStatTab('all'),
|
||||
isActive: activeStatTab === 'all',
|
||||
},
|
||||
{
|
||||
label: '계약대기',
|
||||
value: stats?.pending ?? 0,
|
||||
icon: Clock,
|
||||
iconColor: 'text-orange-500',
|
||||
onClick: () => setActiveStatTab('pending'),
|
||||
isActive: activeStatTab === 'pending',
|
||||
},
|
||||
{
|
||||
label: '계약완료',
|
||||
value: stats?.completed ?? 0,
|
||||
icon: CheckCircle,
|
||||
iconColor: 'text-green-600',
|
||||
onClick: () => setActiveStatTab('completed'),
|
||||
isActive: activeStatTab === 'completed',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedListTemplateV2
|
||||
title="계약관리"
|
||||
description="계약 정보를 관리합니다"
|
||||
icon={FileText}
|
||||
headerActions={headerActions}
|
||||
stats={statsCardsData}
|
||||
filterConfig={filterConfig}
|
||||
filterValues={filterValues}
|
||||
onFilterChange={handleFilterChange}
|
||||
onFilterReset={handleFilterReset}
|
||||
filterTitle="계약 필터"
|
||||
searchValue={searchValue}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="계약번호, 거래처, 현장명 검색"
|
||||
tableColumns={tableColumns}
|
||||
data={paginatedData}
|
||||
allData={sortedContracts}
|
||||
getItemId={(item) => item.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
onBulkDelete={handleBulkDeleteClick}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: sortedContracts.length,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 단일 삭제 다이얼로그 */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>계약 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 계약을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteConfirm}>삭제</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 일괄 삭제 다이얼로그 */}
|
||||
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>계약 일괄 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 {selectedItems.size}개 계약을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleBulkDeleteConfirm}>삭제</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <UniversalListPage config={config} initialData={initialData} />;
|
||||
}
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 견적관리 리스트 - UniversalListPage 마이그레이션
|
||||
*
|
||||
* 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환
|
||||
* - 클라이언트 사이드 필터링 (검색, 필터, 정렬)
|
||||
* - Stats 카드 클릭 필터링 (activeStatTab)
|
||||
* - DateRangeSelector (headerActions → dateRangeSelector config)
|
||||
* - filterConfig (multi: 거래처, 견적자 / single: 상태, 정렬)
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FileText, FileTextIcon, Clock, FileCheck, Pencil } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { MultiSelectOption } from '@/components/ui/multi-select-combobox';
|
||||
import { IntegratedListTemplateV2, TableColumn, StatCard, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { MobileCard } from '@/components/molecules/MobileCard';
|
||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
type SelectionHandlers,
|
||||
type RowClickHandlers,
|
||||
type StatCard,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import type { Estimate, EstimateStats } from './types';
|
||||
import {
|
||||
ESTIMATE_STATUS_OPTIONS,
|
||||
@@ -31,7 +34,7 @@ import {
|
||||
import { getEstimateList, getEstimateStats, deleteEstimate, deleteEstimates } from './actions';
|
||||
|
||||
// 테이블 컬럼 정의
|
||||
const tableColumns: TableColumn[] = [
|
||||
const tableColumns = [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'estimateCode', label: '견적번호', className: 'w-[100px]' },
|
||||
{ key: 'partnerName', label: '거래처', className: 'w-[120px]' },
|
||||
@@ -45,15 +48,14 @@ const tableColumns: TableColumn[] = [
|
||||
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
|
||||
];
|
||||
|
||||
// 목업 거래처 목록 (다중선택용 - 빈 배열 = 전체)
|
||||
const MOCK_PARTNERS: MultiSelectOption[] = [
|
||||
// 거래처/견적자 옵션 (다중선택용)
|
||||
const MOCK_PARTNERS = [
|
||||
{ value: '1', label: '회사명' },
|
||||
{ value: '2', label: '야사 대림아파트' },
|
||||
{ value: '3', label: '여의 현장아파트' },
|
||||
];
|
||||
|
||||
// 목업 견적자 목록 (다중선택용 - 빈 배열 = 전체)
|
||||
const MOCK_ESTIMATORS: MultiSelectOption[] = [
|
||||
const MOCK_ESTIMATORS = [
|
||||
{ value: 'hong', label: '홍길동' },
|
||||
{ value: 'kim', label: '김철수' },
|
||||
{ value: 'lee', label: '이영희' },
|
||||
@@ -72,356 +74,280 @@ interface EstimateListClientProps {
|
||||
export default function EstimateListClient({ initialData = [], initialStats }: EstimateListClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// 상태
|
||||
const [estimates, setEstimates] = useState<Estimate[]>(initialData);
|
||||
const [stats, setStats] = useState<EstimateStats | null>(initialStats || null);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [partnerFilters, setPartnerFilters] = useState<string[]>([]);
|
||||
const [estimatorFilters, setEstimatorFilters] = useState<string[]>([]);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [sortBy, setSortBy] = useState<string>('latest');
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
|
||||
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
|
||||
// Stats 카드 클릭 필터용
|
||||
const [activeStatTab, setActiveStatTab] = useState<'all' | 'pending' | 'completed'>('all');
|
||||
const itemsPerPage = 20;
|
||||
// 날짜 범위
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
// Stats 데이터
|
||||
const [stats, setStats] = useState<EstimateStats | null>(initialStats || null);
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [listResult, statsResult] = await Promise.all([
|
||||
getEstimateList({
|
||||
size: 100, // API 최대값 100
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
}),
|
||||
getEstimateStats(),
|
||||
]);
|
||||
|
||||
if (listResult.success && listResult.data) {
|
||||
setEstimates(listResult.data.items);
|
||||
}
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStats(statsResult.data);
|
||||
}
|
||||
} catch {
|
||||
toast.error('데이터 로드에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [startDate, endDate]);
|
||||
|
||||
// 초기 데이터가 없으면 로드
|
||||
// Stats 로드
|
||||
useEffect(() => {
|
||||
if (initialData.length === 0) {
|
||||
loadData();
|
||||
if (!initialStats) {
|
||||
getEstimateStats().then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setStats(result.data);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [initialData.length, loadData]);
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredEstimates = useMemo(() => {
|
||||
return estimates.filter((estimate) => {
|
||||
// 상태 탭 필터
|
||||
if (activeStatTab === 'pending' && estimate.status !== 'pending') return false;
|
||||
if (activeStatTab === 'completed' && estimate.status !== 'completed') return false;
|
||||
|
||||
// 거래처 필터 (다중선택 - 빈 배열 = 전체)
|
||||
if (partnerFilters.length > 0) {
|
||||
if (!partnerFilters.includes(estimate.partnerId)) return false;
|
||||
}
|
||||
|
||||
// 견적자 필터 (다중선택 - 빈 배열 = 전체)
|
||||
if (estimatorFilters.length > 0) {
|
||||
if (!estimatorFilters.includes(estimate.estimatorId)) return false;
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (statusFilter !== 'all' && estimate.status !== statusFilter) return false;
|
||||
|
||||
// 검색 필터
|
||||
if (searchValue) {
|
||||
const search = searchValue.toLowerCase();
|
||||
return (
|
||||
estimate.projectName.toLowerCase().includes(search) ||
|
||||
estimate.estimateCode.toLowerCase().includes(search) ||
|
||||
estimate.partnerName.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [estimates, activeStatTab, partnerFilters, estimatorFilters, statusFilter, searchValue]);
|
||||
|
||||
// 정렬
|
||||
const sortedEstimates = useMemo(() => {
|
||||
const sorted = [...filteredEstimates];
|
||||
switch (sortBy) {
|
||||
case 'latest':
|
||||
sorted.sort((a, b) => {
|
||||
const dateA = a.completedDate ? new Date(a.completedDate).getTime() : new Date(a.createdAt).getTime();
|
||||
const dateB = b.completedDate ? new Date(b.completedDate).getTime() : new Date(b.createdAt).getTime();
|
||||
return dateB - dateA;
|
||||
});
|
||||
break;
|
||||
case 'oldest':
|
||||
sorted.sort((a, b) => {
|
||||
const dateA = a.completedDate ? new Date(a.completedDate).getTime() : new Date(a.createdAt).getTime();
|
||||
const dateB = b.completedDate ? new Date(b.completedDate).getTime() : new Date(b.createdAt).getTime();
|
||||
return dateA - dateB;
|
||||
});
|
||||
break;
|
||||
case 'bidDateDesc':
|
||||
sorted.sort((a, b) => {
|
||||
if (!a.bidDate) return 1;
|
||||
if (!b.bidDate) return -1;
|
||||
return new Date(b.bidDate).getTime() - new Date(a.bidDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'partnerNameAsc':
|
||||
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
|
||||
break;
|
||||
case 'partnerNameDesc':
|
||||
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
|
||||
break;
|
||||
case 'projectNameAsc':
|
||||
sorted.sort((a, b) => a.projectName.localeCompare(b.projectName, 'ko'));
|
||||
break;
|
||||
case 'projectNameDesc':
|
||||
sorted.sort((a, b) => b.projectName.localeCompare(a.projectName, 'ko'));
|
||||
break;
|
||||
}
|
||||
return sorted;
|
||||
}, [filteredEstimates, sortBy]);
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(sortedEstimates.length / itemsPerPage);
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (currentPage - 1) * itemsPerPage;
|
||||
return sortedEstimates.slice(start, start + itemsPerPage);
|
||||
}, [sortedEstimates, currentPage, itemsPerPage]);
|
||||
|
||||
// 핸들러
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchValue(value);
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const handleToggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggleSelectAll = useCallback(() => {
|
||||
if (selectedItems.size === paginatedData.length) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(paginatedData.map((e) => e.id)));
|
||||
}
|
||||
}, [selectedItems.size, paginatedData]);
|
||||
}, [initialStats]);
|
||||
|
||||
// ===== 핸들러 =====
|
||||
const handleRowClick = useCallback(
|
||||
(estimate: Estimate) => {
|
||||
router.push(`/ko/construction/project/bidding/estimates/${estimate.id}`);
|
||||
(item: Estimate) => {
|
||||
router.push(`/ko/construction/project/bidding/estimates/${item.id}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(e: React.MouseEvent, estimateId: string) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/ko/construction/project/bidding/estimates/${estimateId}/edit`);
|
||||
(item: Estimate) => {
|
||||
router.push(`/ko/construction/project/bidding/estimates/${item.id}/edit`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleDeleteClick = useCallback((e: React.MouseEvent, estimateId: string) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTargetId(estimateId);
|
||||
setDeleteDialogOpen(true);
|
||||
}, []);
|
||||
// ===== UniversalListPage Config =====
|
||||
const config: UniversalListConfig<Estimate> = useMemo(
|
||||
() => ({
|
||||
// 페이지 기본 정보
|
||||
title: '견적관리',
|
||||
description: '견적을 관리합니다',
|
||||
icon: FileText,
|
||||
basePath: '/construction/project/bidding/estimates',
|
||||
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
if (!deleteTargetId) return;
|
||||
// ID 추출
|
||||
idField: 'id',
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await deleteEstimate(deleteTargetId);
|
||||
if (result.success) {
|
||||
toast.success('견적이 삭제되었습니다.');
|
||||
setEstimates((prev) => prev.filter((e) => e.id !== deleteTargetId));
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(deleteTargetId);
|
||||
return newSet;
|
||||
// API 액션
|
||||
actions: {
|
||||
getList: async () => {
|
||||
const result = await getEstimateList({
|
||||
size: 100,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
});
|
||||
if (result.success && result.data) {
|
||||
return {
|
||||
success: true,
|
||||
data: result.data.items,
|
||||
totalCount: result.data.total,
|
||||
};
|
||||
}
|
||||
return { success: false, error: result.error };
|
||||
},
|
||||
deleteItem: async (id: string) => {
|
||||
const result = await deleteEstimate(id);
|
||||
return { success: result.success, error: result.error };
|
||||
},
|
||||
deleteBulk: async (ids: string[]) => {
|
||||
const result = await deleteEstimates(ids);
|
||||
return { success: result.success, error: result.error };
|
||||
},
|
||||
},
|
||||
|
||||
// 테이블 컬럼
|
||||
columns: tableColumns,
|
||||
|
||||
// 클라이언트 사이드 필터링
|
||||
clientSideFiltering: true,
|
||||
itemsPerPage: 20,
|
||||
|
||||
// 검색 필터
|
||||
searchPlaceholder: '견적번호, 거래처, 현장명 검색',
|
||||
searchFilter: (item, searchValue) => {
|
||||
const search = searchValue.toLowerCase();
|
||||
return (
|
||||
item.projectName.toLowerCase().includes(search) ||
|
||||
item.estimateCode.toLowerCase().includes(search) ||
|
||||
item.partnerName.toLowerCase().includes(search)
|
||||
);
|
||||
},
|
||||
|
||||
// 필터 설정 (PC: 인라인, 모바일: 바텀시트)
|
||||
filterConfig: [
|
||||
{
|
||||
key: 'partner',
|
||||
label: '거래처',
|
||||
type: 'multi',
|
||||
options: MOCK_PARTNERS,
|
||||
},
|
||||
{
|
||||
key: 'estimator',
|
||||
label: '견적자',
|
||||
type: 'multi',
|
||||
options: MOCK_ESTIMATORS,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'single',
|
||||
options: ESTIMATE_STATUS_OPTIONS.filter((o) => o.value !== 'all'),
|
||||
},
|
||||
{
|
||||
key: 'sortBy',
|
||||
label: '정렬',
|
||||
type: 'single',
|
||||
options: ESTIMATE_SORT_OPTIONS,
|
||||
},
|
||||
],
|
||||
initialFilters: {
|
||||
partner: [],
|
||||
estimator: [],
|
||||
status: 'all',
|
||||
sortBy: 'latest',
|
||||
},
|
||||
filterTitle: '견적 필터',
|
||||
|
||||
// 커스텀 필터 함수 (activeStatTab + filterValues 기반)
|
||||
customFilterFn: (items, filterValues) => {
|
||||
return items.filter((item) => {
|
||||
// Stats 탭 필터
|
||||
if (activeStatTab === 'pending' && item.status !== 'pending') return false;
|
||||
if (activeStatTab === 'completed' && item.status !== 'completed') return false;
|
||||
|
||||
// 거래처 필터 (다중선택)
|
||||
const partnerFilters = filterValues.partner as string[];
|
||||
if (partnerFilters?.length > 0 && !partnerFilters.includes(item.partnerId)) return false;
|
||||
|
||||
// 견적자 필터 (다중선택)
|
||||
const estimatorFilters = filterValues.estimator as string[];
|
||||
if (estimatorFilters?.length > 0 && !estimatorFilters.includes(item.estimatorId)) return false;
|
||||
|
||||
// 상태 필터
|
||||
const statusFilter = filterValues.status as string;
|
||||
if (statusFilter && statusFilter !== 'all' && item.status !== statusFilter) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setDeleteDialogOpen(false);
|
||||
setDeleteTargetId(null);
|
||||
}
|
||||
}, [deleteTargetId]);
|
||||
},
|
||||
|
||||
const handleBulkDeleteClick = useCallback(() => {
|
||||
if (selectedItems.size === 0) {
|
||||
toast.warning('삭제할 항목을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
setBulkDeleteDialogOpen(true);
|
||||
}, [selectedItems.size]);
|
||||
// 커스텀 정렬 함수
|
||||
customSortFn: (items, filterValues) => {
|
||||
const sorted = [...items];
|
||||
const sortBy = (filterValues.sortBy as string) || 'latest';
|
||||
|
||||
const handleBulkDeleteConfirm = useCallback(async () => {
|
||||
if (selectedItems.size === 0) return;
|
||||
switch (sortBy) {
|
||||
case 'latest':
|
||||
sorted.sort((a, b) => {
|
||||
const dateA = a.completedDate ? new Date(a.completedDate).getTime() : new Date(a.createdAt).getTime();
|
||||
const dateB = b.completedDate ? new Date(b.completedDate).getTime() : new Date(b.createdAt).getTime();
|
||||
return dateB - dateA;
|
||||
});
|
||||
break;
|
||||
case 'oldest':
|
||||
sorted.sort((a, b) => {
|
||||
const dateA = a.completedDate ? new Date(a.completedDate).getTime() : new Date(a.createdAt).getTime();
|
||||
const dateB = b.completedDate ? new Date(b.completedDate).getTime() : new Date(b.createdAt).getTime();
|
||||
return dateA - dateB;
|
||||
});
|
||||
break;
|
||||
case 'bidDateDesc':
|
||||
sorted.sort((a, b) => {
|
||||
if (!a.bidDate) return 1;
|
||||
if (!b.bidDate) return -1;
|
||||
return new Date(b.bidDate).getTime() - new Date(a.bidDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'partnerNameAsc':
|
||||
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
|
||||
break;
|
||||
case 'partnerNameDesc':
|
||||
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
|
||||
break;
|
||||
case 'projectNameAsc':
|
||||
sorted.sort((a, b) => a.projectName.localeCompare(b.projectName, 'ko'));
|
||||
break;
|
||||
case 'projectNameDesc':
|
||||
sorted.sort((a, b) => b.projectName.localeCompare(a.projectName, 'ko'));
|
||||
break;
|
||||
}
|
||||
return sorted;
|
||||
},
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const ids = Array.from(selectedItems);
|
||||
const result = await deleteEstimates(ids);
|
||||
if (result.success) {
|
||||
toast.success(`${result.deletedCount}개 항목이 삭제되었습니다.`);
|
||||
await loadData();
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
toast.error(result.error || '일괄 삭제에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('일괄 삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setBulkDeleteDialogOpen(false);
|
||||
}
|
||||
}, [selectedItems, loadData]);
|
||||
// 공통 헤더 옵션: 날짜 선택기
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
|
||||
// ===== filterConfig 기반 통합 필터 시스템 =====
|
||||
const filterConfig: FilterFieldConfig[] = useMemo(() => [
|
||||
{
|
||||
key: 'partner',
|
||||
label: '거래처',
|
||||
type: 'multi',
|
||||
options: MOCK_PARTNERS.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'estimator',
|
||||
label: '견적자',
|
||||
type: 'multi',
|
||||
options: MOCK_ESTIMATORS.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'single',
|
||||
options: ESTIMATE_STATUS_OPTIONS.filter(o => o.value !== 'all').map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '전체',
|
||||
},
|
||||
{
|
||||
key: 'sortBy',
|
||||
label: '정렬',
|
||||
type: 'single',
|
||||
options: ESTIMATE_SORT_OPTIONS.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '최신순',
|
||||
},
|
||||
], []);
|
||||
// Stats 카드 (동적 계산 with onClick)
|
||||
computeStats: (): StatCard[] => [
|
||||
{
|
||||
label: '전체 견적',
|
||||
value: stats?.total ?? 0,
|
||||
icon: FileTextIcon,
|
||||
iconColor: 'text-blue-600',
|
||||
onClick: () => setActiveStatTab('all'),
|
||||
isActive: activeStatTab === 'all',
|
||||
},
|
||||
{
|
||||
label: '견적대기',
|
||||
value: stats?.pending ?? 0,
|
||||
icon: Clock,
|
||||
iconColor: 'text-orange-500',
|
||||
onClick: () => setActiveStatTab('pending'),
|
||||
isActive: activeStatTab === 'pending',
|
||||
},
|
||||
{
|
||||
label: '견적완료',
|
||||
value: stats?.completed ?? 0,
|
||||
icon: FileCheck,
|
||||
iconColor: 'text-green-600',
|
||||
onClick: () => setActiveStatTab('completed'),
|
||||
isActive: activeStatTab === 'completed',
|
||||
},
|
||||
],
|
||||
|
||||
const filterValues: FilterValues = useMemo(() => ({
|
||||
partner: partnerFilters,
|
||||
estimator: estimatorFilters,
|
||||
status: statusFilter,
|
||||
sortBy: sortBy,
|
||||
}), [partnerFilters, estimatorFilters, statusFilter, sortBy]);
|
||||
// 삭제 확인 메시지
|
||||
deleteConfirmMessage: {
|
||||
title: '견적 삭제',
|
||||
description: '선택한 견적을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.',
|
||||
},
|
||||
|
||||
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
|
||||
switch (key) {
|
||||
case 'partner':
|
||||
setPartnerFilters(value as string[]);
|
||||
break;
|
||||
case 'estimator':
|
||||
setEstimatorFilters(value as string[]);
|
||||
break;
|
||||
case 'status':
|
||||
setStatusFilter(value as string);
|
||||
break;
|
||||
case 'sortBy':
|
||||
setSortBy(value as string);
|
||||
break;
|
||||
}
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const handleFilterReset = useCallback(() => {
|
||||
setPartnerFilters([]);
|
||||
setEstimatorFilters([]);
|
||||
setStatusFilter('all');
|
||||
setSortBy('latest');
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = useCallback(
|
||||
(estimate: Estimate, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(estimate.id);
|
||||
|
||||
return (
|
||||
// 테이블 행 렌더링
|
||||
renderTableRow: (
|
||||
item: Estimate,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<Estimate>
|
||||
) => (
|
||||
<TableRow
|
||||
key={estimate.id}
|
||||
key={item.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleRowClick(estimate)}
|
||||
onClick={() => handleRowClick(item)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleSelection(estimate.id)}
|
||||
/>
|
||||
<Checkbox checked={handlers.isSelected} onCheckedChange={handlers.onToggle} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell>{estimate.estimateCode}</TableCell>
|
||||
<TableCell>{estimate.partnerName}</TableCell>
|
||||
<TableCell>{estimate.projectName}</TableCell>
|
||||
<TableCell className="text-center">{estimate.estimatorName}</TableCell>
|
||||
<TableCell className="text-center">{estimate.itemCount}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(estimate.estimateAmount)}</TableCell>
|
||||
<TableCell className="text-center">{estimate.completedDate || '-'}</TableCell>
|
||||
<TableCell className="text-center">{estimate.bidDate || '-'}</TableCell>
|
||||
<TableCell>{item.estimateCode}</TableCell>
|
||||
<TableCell>{item.partnerName}</TableCell>
|
||||
<TableCell>{item.projectName}</TableCell>
|
||||
<TableCell className="text-center">{item.estimatorName}</TableCell>
|
||||
<TableCell className="text-center">{item.itemCount}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(item.estimateAmount)}</TableCell>
|
||||
<TableCell className="text-center">{item.completedDate || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.bidDate || '-'}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={STATUS_STYLES[estimate.status]}>
|
||||
{STATUS_LABELS[estimate.status]}
|
||||
</span>
|
||||
<span className={STATUS_STYLES[item.status]}>{STATUS_LABELS[item.status]}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{isSelected && (
|
||||
{handlers.isSelected && (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => handleEdit(e, estimate.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(item);
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -429,138 +355,35 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
[selectedItems, handleToggleSelection, handleRowClick, handleEdit]
|
||||
);
|
||||
),
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = useCallback(
|
||||
(estimate: Estimate, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
|
||||
return (
|
||||
// 모바일 카드 렌더링
|
||||
renderMobileCard: (
|
||||
item: Estimate,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<Estimate>
|
||||
) => (
|
||||
<MobileCard
|
||||
title={estimate.projectName}
|
||||
subtitle={estimate.estimateCode}
|
||||
badge={STATUS_LABELS[estimate.status]}
|
||||
key={item.id}
|
||||
title={item.projectName}
|
||||
subtitle={item.estimateCode}
|
||||
badge={STATUS_LABELS[item.status]}
|
||||
badgeVariant="secondary"
|
||||
isSelected={isSelected}
|
||||
onToggle={onToggle}
|
||||
onClick={() => handleRowClick(estimate)}
|
||||
isSelected={handlers.isSelected}
|
||||
onToggle={handlers.onToggle}
|
||||
onClick={() => handleRowClick(item)}
|
||||
details={[
|
||||
{ label: '거래처', value: estimate.partnerName },
|
||||
{ label: '견적자', value: estimate.estimatorName },
|
||||
{ label: '견적금액', value: `${formatAmount(estimate.estimateAmount)}원` },
|
||||
{ label: '입찰일', value: estimate.bidDate || '-' },
|
||||
{ label: '거래처', value: item.partnerName },
|
||||
{ label: '견적자', value: item.estimatorName },
|
||||
{ label: '견적금액', value: `${formatAmount(item.estimateAmount)}원` },
|
||||
{ label: '입찰일', value: item.bidDate || '-' },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[handleRowClick]
|
||||
),
|
||||
}),
|
||||
[startDate, endDate, activeStatTab, stats, handleRowClick, handleEdit]
|
||||
);
|
||||
|
||||
// 헤더 액션 (날짜 필터만 - 견적등록은 현장설명회 참석완료 시 자동 등록)
|
||||
const headerActions = (
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
/>
|
||||
);
|
||||
|
||||
// Stats 카드 데이터 (StatCards 컴포넌트용)
|
||||
const statsCardsData: StatCard[] = [
|
||||
{
|
||||
label: '전체 견적',
|
||||
value: stats?.total ?? 0,
|
||||
icon: FileTextIcon,
|
||||
iconColor: 'text-blue-600',
|
||||
onClick: () => setActiveStatTab('all'),
|
||||
isActive: activeStatTab === 'all',
|
||||
},
|
||||
{
|
||||
label: '견적대기',
|
||||
value: stats?.pending ?? 0,
|
||||
icon: Clock,
|
||||
iconColor: 'text-orange-500',
|
||||
onClick: () => setActiveStatTab('pending'),
|
||||
isActive: activeStatTab === 'pending',
|
||||
},
|
||||
{
|
||||
label: '견적완료',
|
||||
value: stats?.completed ?? 0,
|
||||
icon: FileCheck,
|
||||
iconColor: 'text-green-600',
|
||||
onClick: () => setActiveStatTab('completed'),
|
||||
isActive: activeStatTab === 'completed',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedListTemplateV2
|
||||
title="견적관리"
|
||||
description="견적을 관리합니다"
|
||||
icon={FileText}
|
||||
headerActions={headerActions}
|
||||
stats={statsCardsData}
|
||||
filterConfig={filterConfig}
|
||||
filterValues={filterValues}
|
||||
onFilterChange={handleFilterChange}
|
||||
onFilterReset={handleFilterReset}
|
||||
filterTitle="견적 필터"
|
||||
searchValue={searchValue}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="견적번호, 거래처, 현장명 검색"
|
||||
tableColumns={tableColumns}
|
||||
data={paginatedData}
|
||||
allData={sortedEstimates}
|
||||
getItemId={(item) => item.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: sortedEstimates.length,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 단일 삭제 다이얼로그 */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>견적 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 견적을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteConfirm}>삭제</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 일괄 삭제 다이얼로그 */}
|
||||
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>견적 일괄 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 {selectedItems.size}개 견적을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleBulkDeleteConfirm}>삭제</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
return <UniversalListPage config={config} initialData={initialData} />;
|
||||
}
|
||||
@@ -1,16 +1,30 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 인수인계보고서 관리 - UniversalListPage 마이그레이션
|
||||
*
|
||||
* 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환
|
||||
* - 클라이언트 사이드 필터링 (검색, 필터, 정렬)
|
||||
* - Stats 카드 클릭 필터링 (activeStatTab)
|
||||
* - DateRangeSelector (headerActions → dateRangeSelector config)
|
||||
* - filterConfig (multi: 거래처, 계약담당자, 공사PM / single: 상태, 정렬)
|
||||
* - 등록 버튼 없음 (계약 종료 시 자동 등록)
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FileText, Clock, CheckCircle, Pencil } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { MultiSelectOption } from '@/components/ui/multi-select-combobox';
|
||||
import { IntegratedListTemplateV2, TableColumn, StatCard, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { MobileCard } from '@/components/molecules/MobileCard';
|
||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
type SelectionHandlers,
|
||||
type RowClickHandlers,
|
||||
type StatCard,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import type { HandoverReport, HandoverReportStats } from './types';
|
||||
import {
|
||||
REPORT_STATUS_OPTIONS,
|
||||
@@ -18,13 +32,10 @@ import {
|
||||
HANDOVER_STATUS_LABELS,
|
||||
HANDOVER_STATUS_STYLES,
|
||||
} from './types';
|
||||
import {
|
||||
getHandoverReportList,
|
||||
getHandoverReportStats,
|
||||
} from './actions';
|
||||
import { getHandoverReportList, getHandoverReportStats } from './actions';
|
||||
|
||||
// 테이블 컬럼 정의
|
||||
const tableColumns: TableColumn[] = [
|
||||
const tableColumns = [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'reportNumber', label: '보고서번호', className: 'w-[100px]' },
|
||||
{ key: 'partnerName', label: '거래처', className: 'w-[100px]' },
|
||||
@@ -39,7 +50,7 @@ const tableColumns: TableColumn[] = [
|
||||
];
|
||||
|
||||
// 목업 거래처 목록
|
||||
const MOCK_PARTNERS: MultiSelectOption[] = [
|
||||
const MOCK_PARTNERS = [
|
||||
{ value: 'partner1', label: '주식회사 한빛' },
|
||||
{ value: 'partner2', label: '대성건설' },
|
||||
{ value: 'partner3', label: '삼성물산' },
|
||||
@@ -47,14 +58,14 @@ const MOCK_PARTNERS: MultiSelectOption[] = [
|
||||
];
|
||||
|
||||
// 목업 계약담당자 목록
|
||||
const MOCK_CONTRACT_MANAGERS: MultiSelectOption[] = [
|
||||
const MOCK_CONTRACT_MANAGERS = [
|
||||
{ value: 'hong', label: '홍길동' },
|
||||
{ value: 'kim', label: '김철수' },
|
||||
{ value: 'lee', label: '이영희' },
|
||||
];
|
||||
|
||||
// 목업 공사PM 목록
|
||||
const MOCK_CONSTRUCTION_PMS: MultiSelectOption[] = [
|
||||
const MOCK_CONSTRUCTION_PMS = [
|
||||
{ value: 'kim', label: '김PM' },
|
||||
{ value: 'lee', label: '이PM' },
|
||||
{ value: 'park', label: '박PM' },
|
||||
@@ -69,11 +80,14 @@ function formatAmount(amount: number): string {
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).replace(/\. /g, '-').replace('.', '');
|
||||
return date
|
||||
.toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
.replace(/\. /g, '-')
|
||||
.replace('.', '');
|
||||
}
|
||||
|
||||
// 계약기간 포맷팅
|
||||
@@ -95,161 +109,24 @@ export default function HandoverReportListClient({
|
||||
}: HandoverReportListClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// 상태
|
||||
const [reports, setReports] = useState<HandoverReport[]>(initialData);
|
||||
const [stats, setStats] = useState<HandoverReportStats | null>(initialStats || null);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [partnerFilters, setPartnerFilters] = useState<string[]>([]);
|
||||
const [contractManagerFilters, setContractManagerFilters] = useState<string[]>([]);
|
||||
const [constructionPMFilters, setConstructionPMFilters] = useState<string[]>([]);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [sortBy, setSortBy] = useState<string>('contractDateDesc');
|
||||
const [startDate, setStartDate] = useState<string>('2025-09-01');
|
||||
const [endDate, setEndDate] = useState<string>('2025-09-03');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
|
||||
const [activeStatTab, setActiveStatTab] = useState<'all' | 'pending' | 'completed'>('all');
|
||||
const itemsPerPage = 20;
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [stats, setStats] = useState<HandoverReportStats | null>(initialStats || null);
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [listResult, statsResult] = await Promise.all([
|
||||
getHandoverReportList({
|
||||
size: 1000,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
}),
|
||||
getHandoverReportStats(),
|
||||
]);
|
||||
|
||||
if (listResult.success && listResult.data) {
|
||||
setReports(listResult.data.items);
|
||||
}
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStats(statsResult.data);
|
||||
}
|
||||
} catch {
|
||||
toast.error('데이터 로드에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [startDate, endDate]);
|
||||
|
||||
// 초기 데이터가 없으면 로드
|
||||
// Stats 로드
|
||||
useEffect(() => {
|
||||
if (initialData.length === 0) {
|
||||
loadData();
|
||||
if (!initialStats) {
|
||||
getHandoverReportStats().then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setStats(result.data);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [initialData.length, loadData]);
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredReports = useMemo(() => {
|
||||
return reports.filter((report) => {
|
||||
// 상태 탭 필터
|
||||
if (activeStatTab === 'pending' && report.status !== 'pending') return false;
|
||||
if (activeStatTab === 'completed' && report.status !== 'completed') return false;
|
||||
|
||||
// 거래처 필터
|
||||
if (partnerFilters.length > 0) {
|
||||
if (!partnerFilters.includes(report.partnerId)) return false;
|
||||
}
|
||||
|
||||
// 계약담당자 필터
|
||||
if (contractManagerFilters.length > 0) {
|
||||
if (!contractManagerFilters.includes(report.contractManagerId)) return false;
|
||||
}
|
||||
|
||||
// 공사PM 필터
|
||||
if (constructionPMFilters.length > 0) {
|
||||
if (!constructionPMFilters.includes(report.constructionPMId || '')) return false;
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (statusFilter !== 'all' && report.status !== statusFilter) return false;
|
||||
|
||||
// 검색 필터
|
||||
if (searchValue) {
|
||||
const search = searchValue.toLowerCase();
|
||||
return (
|
||||
report.reportNumber.toLowerCase().includes(search) ||
|
||||
report.partnerName.toLowerCase().includes(search) ||
|
||||
report.siteName.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [reports, activeStatTab, partnerFilters, contractManagerFilters, constructionPMFilters, statusFilter, searchValue]);
|
||||
|
||||
// 정렬
|
||||
const sortedReports = useMemo(() => {
|
||||
const sorted = [...filteredReports];
|
||||
switch (sortBy) {
|
||||
case 'contractDateDesc':
|
||||
sorted.sort((a, b) => {
|
||||
if (!a.contractStartDate) return 1;
|
||||
if (!b.contractStartDate) return -1;
|
||||
return new Date(b.contractStartDate).getTime() - new Date(a.contractStartDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'contractDateAsc':
|
||||
sorted.sort((a, b) => {
|
||||
if (!a.contractStartDate) return 1;
|
||||
if (!b.contractStartDate) return -1;
|
||||
return new Date(a.contractStartDate).getTime() - new Date(b.contractStartDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'partnerNameAsc':
|
||||
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
|
||||
break;
|
||||
case 'partnerNameDesc':
|
||||
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
|
||||
break;
|
||||
case 'siteNameAsc':
|
||||
sorted.sort((a, b) => a.siteName.localeCompare(b.siteName, 'ko'));
|
||||
break;
|
||||
case 'siteNameDesc':
|
||||
sorted.sort((a, b) => b.siteName.localeCompare(a.siteName, 'ko'));
|
||||
break;
|
||||
}
|
||||
return sorted;
|
||||
}, [filteredReports, sortBy]);
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(sortedReports.length / itemsPerPage);
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (currentPage - 1) * itemsPerPage;
|
||||
return sortedReports.slice(start, start + itemsPerPage);
|
||||
}, [sortedReports, currentPage, itemsPerPage]);
|
||||
|
||||
// 핸들러
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchValue(value);
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const handleToggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggleSelectAll = useCallback(() => {
|
||||
if (selectedItems.size === paginatedData.length) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(paginatedData.map((r) => r.id)));
|
||||
}
|
||||
}, [selectedItems.size, paginatedData]);
|
||||
}, [initialStats]);
|
||||
|
||||
// ===== 핸들러 =====
|
||||
const handleRowClick = useCallback(
|
||||
(report: HandoverReport) => {
|
||||
router.push(`/ko/construction/project/contract/handover-report/${report.id}`);
|
||||
@@ -258,143 +135,247 @@ export default function HandoverReportListClient({
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(e: React.MouseEvent, reportId: string) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/ko/construction/project/contract/handover-report/${reportId}/edit`);
|
||||
(report: HandoverReport) => {
|
||||
router.push(`/ko/construction/project/contract/handover-report/${report.id}/edit`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
// ===== filterConfig 기반 통합 필터 시스템 =====
|
||||
const filterConfig: FilterFieldConfig[] = useMemo(() => [
|
||||
{
|
||||
key: 'partner',
|
||||
label: '거래처',
|
||||
type: 'multi',
|
||||
options: MOCK_PARTNERS.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'contractManager',
|
||||
label: '계약담당자',
|
||||
type: 'multi',
|
||||
options: MOCK_CONTRACT_MANAGERS.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'constructionPM',
|
||||
label: '공사PM',
|
||||
type: 'multi',
|
||||
options: MOCK_CONSTRUCTION_PMS.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'single',
|
||||
options: REPORT_STATUS_OPTIONS.filter(o => o.value !== 'all').map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '전체',
|
||||
},
|
||||
{
|
||||
key: 'sortBy',
|
||||
label: '정렬',
|
||||
type: 'single',
|
||||
options: REPORT_SORT_OPTIONS.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '최신순 (계약시작일)',
|
||||
},
|
||||
], []);
|
||||
// ===== UniversalListPage Config =====
|
||||
const config: UniversalListConfig<HandoverReport> = useMemo(
|
||||
() => ({
|
||||
// 페이지 기본 정보
|
||||
title: '인수인계보고서관리',
|
||||
description: '계약이 종료 상태 시 인수인계보고서 자동 등록',
|
||||
icon: FileText,
|
||||
basePath: '/construction/project/contract/handover-report',
|
||||
|
||||
const filterValues: FilterValues = useMemo(() => ({
|
||||
partner: partnerFilters,
|
||||
contractManager: contractManagerFilters,
|
||||
constructionPM: constructionPMFilters,
|
||||
status: statusFilter,
|
||||
sortBy: sortBy,
|
||||
}), [partnerFilters, contractManagerFilters, constructionPMFilters, statusFilter, sortBy]);
|
||||
// ID 추출
|
||||
idField: 'id',
|
||||
|
||||
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
|
||||
switch (key) {
|
||||
case 'partner':
|
||||
setPartnerFilters(value as string[]);
|
||||
break;
|
||||
case 'contractManager':
|
||||
setContractManagerFilters(value as string[]);
|
||||
break;
|
||||
case 'constructionPM':
|
||||
setConstructionPMFilters(value as string[]);
|
||||
break;
|
||||
case 'status':
|
||||
setStatusFilter(value as string);
|
||||
break;
|
||||
case 'sortBy':
|
||||
setSortBy(value as string);
|
||||
break;
|
||||
}
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
// API 액션
|
||||
actions: {
|
||||
getList: async () => {
|
||||
const result = await getHandoverReportList({
|
||||
size: 1000,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
});
|
||||
if (result.success && result.data) {
|
||||
return {
|
||||
success: true,
|
||||
data: result.data.items,
|
||||
totalCount: result.data.total,
|
||||
};
|
||||
}
|
||||
return { success: false, error: result.error };
|
||||
},
|
||||
},
|
||||
|
||||
const handleFilterReset = useCallback(() => {
|
||||
setPartnerFilters([]);
|
||||
setContractManagerFilters([]);
|
||||
setConstructionPMFilters([]);
|
||||
setStatusFilter('all');
|
||||
setSortBy('contractDateDesc');
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
// 테이블 컬럼
|
||||
columns: tableColumns,
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = useCallback(
|
||||
(report: HandoverReport, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(report.id);
|
||||
// 클라이언트 사이드 필터링
|
||||
clientSideFiltering: true,
|
||||
itemsPerPage: 20,
|
||||
|
||||
return (
|
||||
// 검색 필터
|
||||
searchPlaceholder: '보고서번호, 거래처, 현장명 검색',
|
||||
searchFilter: (item, searchValue) => {
|
||||
const search = searchValue.toLowerCase();
|
||||
return (
|
||||
item.reportNumber.toLowerCase().includes(search) ||
|
||||
item.partnerName.toLowerCase().includes(search) ||
|
||||
item.siteName.toLowerCase().includes(search)
|
||||
);
|
||||
},
|
||||
|
||||
// 필터 설정
|
||||
filterConfig: [
|
||||
{
|
||||
key: 'partner',
|
||||
label: '거래처',
|
||||
type: 'multi',
|
||||
options: MOCK_PARTNERS,
|
||||
},
|
||||
{
|
||||
key: 'contractManager',
|
||||
label: '계약담당자',
|
||||
type: 'multi',
|
||||
options: MOCK_CONTRACT_MANAGERS,
|
||||
},
|
||||
{
|
||||
key: 'constructionPM',
|
||||
label: '공사PM',
|
||||
type: 'multi',
|
||||
options: MOCK_CONSTRUCTION_PMS,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'single',
|
||||
options: REPORT_STATUS_OPTIONS.filter((o) => o.value !== 'all'),
|
||||
},
|
||||
{
|
||||
key: 'sortBy',
|
||||
label: '정렬',
|
||||
type: 'single',
|
||||
options: REPORT_SORT_OPTIONS,
|
||||
},
|
||||
],
|
||||
initialFilters: {
|
||||
partner: [],
|
||||
contractManager: [],
|
||||
constructionPM: [],
|
||||
status: 'all',
|
||||
sortBy: 'contractDateDesc',
|
||||
},
|
||||
filterTitle: '인수인계보고서 필터',
|
||||
|
||||
// 커스텀 필터 함수
|
||||
customFilterFn: (items, filterValues) => {
|
||||
return items.filter((item) => {
|
||||
// Stats 탭 필터
|
||||
if (activeStatTab === 'pending' && item.status !== 'pending') return false;
|
||||
if (activeStatTab === 'completed' && item.status !== 'completed') return false;
|
||||
|
||||
// 거래처 필터
|
||||
const partnerFilters = filterValues.partner as string[];
|
||||
if (partnerFilters?.length > 0 && !partnerFilters.includes(item.partnerId)) return false;
|
||||
|
||||
// 계약담당자 필터
|
||||
const contractManagerFilters = filterValues.contractManager as string[];
|
||||
if (contractManagerFilters?.length > 0 && !contractManagerFilters.includes(item.contractManagerId))
|
||||
return false;
|
||||
|
||||
// 공사PM 필터
|
||||
const constructionPMFilters = filterValues.constructionPM as string[];
|
||||
if (constructionPMFilters?.length > 0 && !constructionPMFilters.includes(item.constructionPMId || ''))
|
||||
return false;
|
||||
|
||||
// 상태 필터
|
||||
const statusFilter = filterValues.status as string;
|
||||
if (statusFilter && statusFilter !== 'all' && item.status !== statusFilter) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
},
|
||||
|
||||
// 커스텀 정렬 함수
|
||||
customSortFn: (items, filterValues) => {
|
||||
const sorted = [...items];
|
||||
const sortBy = (filterValues.sortBy as string) || 'contractDateDesc';
|
||||
|
||||
switch (sortBy) {
|
||||
case 'contractDateDesc':
|
||||
sorted.sort((a, b) => {
|
||||
if (!a.contractStartDate) return 1;
|
||||
if (!b.contractStartDate) return -1;
|
||||
return new Date(b.contractStartDate).getTime() - new Date(a.contractStartDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'contractDateAsc':
|
||||
sorted.sort((a, b) => {
|
||||
if (!a.contractStartDate) return 1;
|
||||
if (!b.contractStartDate) return -1;
|
||||
return new Date(a.contractStartDate).getTime() - new Date(b.contractStartDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'partnerNameAsc':
|
||||
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
|
||||
break;
|
||||
case 'partnerNameDesc':
|
||||
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
|
||||
break;
|
||||
case 'siteNameAsc':
|
||||
sorted.sort((a, b) => a.siteName.localeCompare(b.siteName, 'ko'));
|
||||
break;
|
||||
case 'siteNameDesc':
|
||||
sorted.sort((a, b) => b.siteName.localeCompare(a.siteName, 'ko'));
|
||||
break;
|
||||
}
|
||||
return sorted;
|
||||
},
|
||||
|
||||
// 공통 헤더 옵션
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
|
||||
// Stats 카드
|
||||
computeStats: (): StatCard[] => [
|
||||
{
|
||||
label: '전체 인수인계보고서',
|
||||
value: stats?.total ?? 0,
|
||||
icon: FileText,
|
||||
iconColor: 'text-blue-600',
|
||||
onClick: () => setActiveStatTab('all'),
|
||||
isActive: activeStatTab === 'all',
|
||||
},
|
||||
{
|
||||
label: '인수인계대기',
|
||||
value: stats?.pending ?? 0,
|
||||
icon: Clock,
|
||||
iconColor: 'text-orange-500',
|
||||
onClick: () => setActiveStatTab('pending'),
|
||||
isActive: activeStatTab === 'pending',
|
||||
},
|
||||
{
|
||||
label: '인수인계완료',
|
||||
value: stats?.completed ?? 0,
|
||||
icon: CheckCircle,
|
||||
iconColor: 'text-green-600',
|
||||
onClick: () => setActiveStatTab('completed'),
|
||||
isActive: activeStatTab === 'completed',
|
||||
},
|
||||
],
|
||||
|
||||
// 테이블 행 렌더링
|
||||
renderTableRow: (
|
||||
item: HandoverReport,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<HandoverReport>
|
||||
) => (
|
||||
<TableRow
|
||||
key={report.id}
|
||||
key={item.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleRowClick(report)}
|
||||
onClick={() => handleRowClick(item)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleSelection(report.id)}
|
||||
/>
|
||||
<Checkbox checked={handlers.isSelected} onCheckedChange={handlers.onToggle} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell>{report.reportNumber}</TableCell>
|
||||
<TableCell>{report.partnerName}</TableCell>
|
||||
<TableCell>{report.siteName}</TableCell>
|
||||
<TableCell className="text-center">{report.contractManagerName}</TableCell>
|
||||
<TableCell className="text-center">{report.constructionPMName || '-'}</TableCell>
|
||||
<TableCell className="text-center">{report.totalSites}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(report.contractAmount)}</TableCell>
|
||||
<TableCell>{item.reportNumber}</TableCell>
|
||||
<TableCell>{item.partnerName}</TableCell>
|
||||
<TableCell>{item.siteName}</TableCell>
|
||||
<TableCell className="text-center">{item.contractManagerName}</TableCell>
|
||||
<TableCell className="text-center">{item.constructionPMName || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.totalSites}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(item.contractAmount)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{formatPeriod(report.contractStartDate, report.contractEndDate)}
|
||||
{formatPeriod(item.contractStartDate, item.contractEndDate)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={HANDOVER_STATUS_STYLES[report.status]}>
|
||||
{HANDOVER_STATUS_LABELS[report.status]}
|
||||
<span className={HANDOVER_STATUS_STYLES[item.status]}>
|
||||
{HANDOVER_STATUS_LABELS[item.status]}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{isSelected && (
|
||||
{handlers.isSelected && (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => handleEdit(e, report.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(item);
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -402,108 +383,35 @@ export default function HandoverReportListClient({
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
[selectedItems, handleToggleSelection, handleRowClick, handleEdit]
|
||||
);
|
||||
),
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = useCallback(
|
||||
(report: HandoverReport, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
|
||||
return (
|
||||
// 모바일 카드 렌더링
|
||||
renderMobileCard: (
|
||||
item: HandoverReport,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<HandoverReport>
|
||||
) => (
|
||||
<MobileCard
|
||||
title={report.siteName}
|
||||
subtitle={report.reportNumber}
|
||||
badge={HANDOVER_STATUS_LABELS[report.status]}
|
||||
key={item.id}
|
||||
title={item.siteName}
|
||||
subtitle={item.reportNumber}
|
||||
badge={HANDOVER_STATUS_LABELS[item.status]}
|
||||
badgeVariant="secondary"
|
||||
isSelected={isSelected}
|
||||
onToggle={onToggle}
|
||||
onClick={() => handleRowClick(report)}
|
||||
isSelected={handlers.isSelected}
|
||||
onToggle={handlers.onToggle}
|
||||
onClick={() => handleRowClick(item)}
|
||||
details={[
|
||||
{ label: '거래처', value: report.partnerName },
|
||||
{ label: '계약금액', value: `${formatAmount(report.contractAmount)}원` },
|
||||
{ label: '계약담당자', value: report.contractManagerName },
|
||||
{ label: '총 개소', value: `${report.totalSites}개소` },
|
||||
{ label: '거래처', value: item.partnerName },
|
||||
{ label: '계약금액', value: `${formatAmount(item.contractAmount)}원` },
|
||||
{ label: '계약담당자', value: item.contractManagerName },
|
||||
{ label: '총 개소', value: `${item.totalSites}개소` },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[handleRowClick]
|
||||
),
|
||||
}),
|
||||
[startDate, endDate, activeStatTab, stats, handleRowClick, handleEdit]
|
||||
);
|
||||
|
||||
// 헤더 액션 (날짜 필터)
|
||||
const headerActions = (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Stats 카드 데이터 (전체 인수인계보고서, 인수인계대기, 인수인계완료)
|
||||
const statsCardsData: StatCard[] = [
|
||||
{
|
||||
label: '전체 인수인계보고서',
|
||||
value: stats?.total ?? 0,
|
||||
icon: FileText,
|
||||
iconColor: 'text-blue-600',
|
||||
onClick: () => setActiveStatTab('all'),
|
||||
isActive: activeStatTab === 'all',
|
||||
},
|
||||
{
|
||||
label: '인수인계대기',
|
||||
value: stats?.pending ?? 0,
|
||||
icon: Clock,
|
||||
iconColor: 'text-orange-500',
|
||||
onClick: () => setActiveStatTab('pending'),
|
||||
isActive: activeStatTab === 'pending',
|
||||
},
|
||||
{
|
||||
label: '인수인계완료',
|
||||
value: stats?.completed ?? 0,
|
||||
icon: CheckCircle,
|
||||
iconColor: 'text-green-600',
|
||||
onClick: () => setActiveStatTab('completed'),
|
||||
isActive: activeStatTab === 'completed',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedListTemplateV2
|
||||
title="인수인계보고서관리"
|
||||
description="계약이 종료 상태 시 인수인계보고서 자동 등록"
|
||||
icon={FileText}
|
||||
headerActions={headerActions}
|
||||
stats={statsCardsData}
|
||||
filterConfig={filterConfig}
|
||||
filterValues={filterValues}
|
||||
onFilterChange={handleFilterChange}
|
||||
onFilterReset={handleFilterReset}
|
||||
filterTitle="인수인계보고서 필터"
|
||||
searchValue={searchValue}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="보고서번호, 거래처, 현장명 검색"
|
||||
tableColumns={tableColumns}
|
||||
data={paginatedData}
|
||||
allData={sortedReports}
|
||||
getItemId={(item) => item.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: sortedReports.length,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
return <UniversalListPage config={config} initialData={initialData} />;
|
||||
}
|
||||
@@ -1,15 +1,23 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 이슈관리 - UniversalListPage 마이그레이션
|
||||
*
|
||||
* 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환
|
||||
* - 클라이언트 사이드 필터링 (검색, 필터, 정렬)
|
||||
* - Stats 카드 클릭 필터링 (activeStatTab)
|
||||
* - DateRangeSelector + 등록 버튼 (dateRangeSelector + createButton)
|
||||
* - filterConfig (multi: 거래처, 현장명, 구분, 보고자, 담당자 / single: 중요도, 상태, 정렬)
|
||||
* - 철회 기능 (tableHeaderActions로 구현)
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { AlertTriangle, Pencil, Plus, Inbox, Clock, CheckCircle, XCircle, Undo2 } from 'lucide-react';
|
||||
import { AlertTriangle, Pencil, Inbox, Clock, CheckCircle, XCircle, Undo2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { MultiSelectOption } from '@/components/ui/multi-select-combobox';
|
||||
import { IntegratedListTemplateV2, TableColumn, StatCard, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { MobileCard } from '@/components/molecules/MobileCard';
|
||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -21,10 +29,14 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { toast } from 'sonner';
|
||||
import type {
|
||||
Issue,
|
||||
IssueStats,
|
||||
} from './types';
|
||||
import {
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
type SelectionHandlers,
|
||||
type RowClickHandlers,
|
||||
type StatCard,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import type { Issue, IssueStats } from './types';
|
||||
import {
|
||||
ISSUE_STATUS_OPTIONS,
|
||||
ISSUE_PRIORITY_OPTIONS,
|
||||
@@ -47,8 +59,7 @@ import {
|
||||
} from './actions';
|
||||
|
||||
// 테이블 컬럼 정의
|
||||
// 체크박스, 번호, 이슈번호, 시공번호, 거래처, 현장, 구분, 제목, 보고자, 이슈보고일, 이슈해결일, 담당자, 중요도, 상태, 작업
|
||||
const tableColumns: TableColumn[] = [
|
||||
const tableColumns = [
|
||||
{ key: 'no', label: '번호', className: 'w-[50px] text-center' },
|
||||
{ key: 'issueNumber', label: '이슈번호', className: 'w-[120px]' },
|
||||
{ key: 'constructionNumber', label: '시공번호', className: 'w-[100px]' },
|
||||
@@ -65,6 +76,12 @@ const tableColumns: TableColumn[] = [
|
||||
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
|
||||
];
|
||||
|
||||
// 날짜 포맷
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return '-';
|
||||
return dateStr.split('T')[0];
|
||||
}
|
||||
|
||||
interface IssueManagementListClientProps {
|
||||
initialData?: Issue[];
|
||||
initialStats?: IssueStats;
|
||||
@@ -76,132 +93,116 @@ export default function IssueManagementListClient({
|
||||
}: IssueManagementListClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// 상태
|
||||
const [issues, setIssues] = useState<Issue[]>(initialData);
|
||||
const [stats, setStats] = useState<IssueStats | null>(initialStats || null);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
// 다중선택 필터 (빈 배열 = 전체)
|
||||
const [partnerFilters, setPartnerFilters] = useState<string[]>([]);
|
||||
const [siteFilters, setSiteFilters] = useState<string[]>([]);
|
||||
const [categoryFilters, setCategoryFilters] = useState<string[]>([]);
|
||||
const [reporterFilters, setReporterFilters] = useState<string[]>([]);
|
||||
const [assigneeFilters, setAssigneeFilters] = useState<string[]>([]);
|
||||
// 단일선택 필터
|
||||
const [priorityFilter, setPriorityFilter] = useState<string>('all');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [sortBy, setSortBy] = useState<string>('latest');
|
||||
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
|
||||
const [activeStatTab, setActiveStatTab] = useState<'all' | 'received' | 'in_progress' | 'resolved' | 'unresolved'>('all');
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [activeStatTab, setActiveStatTab] = useState<'all' | 'received' | 'in_progress' | 'resolved' | 'unresolved'>('all');
|
||||
const [stats, setStats] = useState<IssueStats | null>(initialStats || null);
|
||||
const [withdrawDialogOpen, setWithdrawDialogOpen] = useState(false);
|
||||
const itemsPerPage = 20;
|
||||
const [itemsToWithdraw, setItemsToWithdraw] = useState<Set<string>>(new Set());
|
||||
const [clearSelectionFn, setClearSelectionFn] = useState<(() => void) | null>(null);
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
// Stats 로드
|
||||
useEffect(() => {
|
||||
if (!initialStats) {
|
||||
getIssueStats().then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setStats(result.data);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [initialStats]);
|
||||
|
||||
// ===== 핸들러 =====
|
||||
const handleRowClick = useCallback(
|
||||
(item: Issue) => {
|
||||
router.push(`/ko/construction/project/issue-management/${item.id}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(item: Issue) => {
|
||||
router.push(`/ko/construction/project/issue-management/${item.id}/edit`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
router.push('/ko/construction/project/issue-management/new');
|
||||
}, [router]);
|
||||
|
||||
// 철회 다이얼로그 열기
|
||||
const handleWithdrawClick = useCallback((selectedItems: Set<string>, onClearSelection: () => void) => {
|
||||
if (selectedItems.size === 0) {
|
||||
toast.error('철회할 이슈를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
setItemsToWithdraw(new Set(selectedItems));
|
||||
setClearSelectionFn(() => onClearSelection);
|
||||
setWithdrawDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
// 철회 실행
|
||||
const handleWithdraw = useCallback(async () => {
|
||||
try {
|
||||
const [listResult, statsResult] = await Promise.all([
|
||||
getIssueList({
|
||||
size: 1000,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
}),
|
||||
getIssueStats(),
|
||||
]);
|
||||
|
||||
if (listResult.success && listResult.data) {
|
||||
setIssues(listResult.data.items);
|
||||
}
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStats(statsResult.data);
|
||||
const ids = Array.from(itemsToWithdraw);
|
||||
const result = await withdrawIssues(ids);
|
||||
if (result.success) {
|
||||
toast.success(`${ids.length}건의 이슈가 철회되었습니다.`);
|
||||
clearSelectionFn?.();
|
||||
} else {
|
||||
toast.error(result.error || '이슈 철회에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('데이터 로드에 실패했습니다.');
|
||||
toast.error('이슈 철회에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setWithdrawDialogOpen(false);
|
||||
setItemsToWithdraw(new Set());
|
||||
}
|
||||
}, [startDate, endDate]);
|
||||
}, [itemsToWithdraw, clearSelectionFn]);
|
||||
|
||||
// 초기 데이터가 없으면 로드
|
||||
useEffect(() => {
|
||||
if (initialData.length === 0) {
|
||||
loadData();
|
||||
}
|
||||
}, [initialData.length, loadData]);
|
||||
// ===== UniversalListPage Config =====
|
||||
const config: UniversalListConfig<Issue> = useMemo(
|
||||
() => ({
|
||||
// 페이지 기본 정보
|
||||
title: '이슈관리',
|
||||
description: '이슈 목록을 관리합니다',
|
||||
icon: AlertTriangle,
|
||||
basePath: '/construction/project/issue-management',
|
||||
|
||||
// 다중선택 필터 옵션 변환 (MultiSelectCombobox용)
|
||||
const partnerOptions: MultiSelectOption[] = useMemo(() =>
|
||||
MOCK_ISSUE_PARTNERS.map(p => ({ value: p.value, label: p.label })),
|
||||
[]);
|
||||
const siteOptions: MultiSelectOption[] = useMemo(() =>
|
||||
MOCK_ISSUE_SITES.map(s => ({ value: s.value, label: s.label })),
|
||||
[]);
|
||||
const categoryOptions: MultiSelectOption[] = useMemo(() =>
|
||||
ISSUE_CATEGORY_OPTIONS.filter(c => c.value !== 'all').map(c => ({ value: c.value, label: c.label })),
|
||||
[]);
|
||||
const reporterOptions: MultiSelectOption[] = useMemo(() =>
|
||||
MOCK_ISSUE_REPORTERS.map(r => ({ value: r.value, label: r.label })),
|
||||
[]);
|
||||
const assigneeOptions: MultiSelectOption[] = useMemo(() =>
|
||||
MOCK_ISSUE_ASSIGNEES.map(a => ({ value: a.value, label: a.label })),
|
||||
[]);
|
||||
// ID 추출
|
||||
idField: 'id',
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredIssues = useMemo(() => {
|
||||
return issues.filter((item) => {
|
||||
// 상태 탭 필터
|
||||
if (activeStatTab !== 'all' && item.status !== activeStatTab) return false;
|
||||
// API 액션
|
||||
actions: {
|
||||
getList: async () => {
|
||||
const result = await getIssueList({
|
||||
size: 1000,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
});
|
||||
if (result.success && result.data) {
|
||||
return {
|
||||
success: true,
|
||||
data: result.data.items,
|
||||
totalCount: result.data.total,
|
||||
};
|
||||
}
|
||||
return { success: false, error: result.error };
|
||||
},
|
||||
},
|
||||
|
||||
// 상태 필터
|
||||
if (statusFilter !== 'all' && item.status !== statusFilter) return false;
|
||||
// 테이블 컬럼
|
||||
columns: tableColumns,
|
||||
|
||||
// 중요도 필터
|
||||
if (priorityFilter !== 'all' && item.priority !== priorityFilter) return false;
|
||||
|
||||
// 거래처 필터 (다중선택)
|
||||
if (partnerFilters.length > 0) {
|
||||
const matchingPartner = MOCK_ISSUE_PARTNERS.find((p) => p.label === item.partnerName);
|
||||
if (!matchingPartner || !partnerFilters.includes(matchingPartner.value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 현장 필터 (다중선택)
|
||||
if (siteFilters.length > 0) {
|
||||
const matchingSite = MOCK_ISSUE_SITES.find((s) => s.label === item.siteName);
|
||||
if (!matchingSite || !siteFilters.includes(matchingSite.value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 구분 필터 (다중선택)
|
||||
if (categoryFilters.length > 0) {
|
||||
if (!categoryFilters.includes(item.category)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 보고자 필터 (다중선택)
|
||||
if (reporterFilters.length > 0) {
|
||||
const matchingReporter = MOCK_ISSUE_REPORTERS.find((r) => r.label === item.reporter);
|
||||
if (!matchingReporter || !reporterFilters.includes(matchingReporter.value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 담당자 필터 (다중선택)
|
||||
if (assigneeFilters.length > 0) {
|
||||
const matchingAssignee = MOCK_ISSUE_ASSIGNEES.find((a) => a.label === item.assignee);
|
||||
if (!matchingAssignee || !assigneeFilters.includes(matchingAssignee.value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// 클라이언트 사이드 필터링
|
||||
clientSideFiltering: true,
|
||||
itemsPerPage: 20,
|
||||
|
||||
// 검색 필터
|
||||
if (searchValue) {
|
||||
searchPlaceholder: '이슈번호, 시공번호, 거래처, 현장, 제목, 보고자, 담당자 검색',
|
||||
searchFilter: (item, searchValue) => {
|
||||
const search = searchValue.toLowerCase();
|
||||
return (
|
||||
item.issueNumber.toLowerCase().includes(search) ||
|
||||
@@ -212,138 +213,225 @@ export default function IssueManagementListClient({
|
||||
item.reporter.toLowerCase().includes(search) ||
|
||||
item.assignee.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [issues, activeStatTab, statusFilter, priorityFilter, partnerFilters, siteFilters, categoryFilters, reporterFilters, assigneeFilters, searchValue]);
|
||||
},
|
||||
|
||||
// 정렬
|
||||
const sortedIssues = useMemo(() => {
|
||||
const sorted = [...filteredIssues];
|
||||
switch (sortBy) {
|
||||
case 'latest':
|
||||
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
break;
|
||||
case 'oldest':
|
||||
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
break;
|
||||
case 'reportDate':
|
||||
sorted.sort((a, b) => new Date(b.reportDate).getTime() - new Date(a.reportDate).getTime());
|
||||
break;
|
||||
case 'priorityHigh':
|
||||
const priorityOrder: Record<string, number> = { urgent: 0, normal: 1 };
|
||||
sorted.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]);
|
||||
break;
|
||||
case 'priorityLow':
|
||||
const priorityOrderLow: Record<string, number> = { urgent: 1, normal: 0 };
|
||||
sorted.sort((a, b) => priorityOrderLow[a.priority] - priorityOrderLow[b.priority]);
|
||||
break;
|
||||
}
|
||||
return sorted;
|
||||
}, [filteredIssues, sortBy]);
|
||||
// 필터 설정
|
||||
filterConfig: [
|
||||
{
|
||||
key: 'partner',
|
||||
label: '거래처',
|
||||
type: 'multi',
|
||||
options: MOCK_ISSUE_PARTNERS,
|
||||
},
|
||||
{
|
||||
key: 'site',
|
||||
label: '현장명',
|
||||
type: 'multi',
|
||||
options: MOCK_ISSUE_SITES,
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
label: '구분',
|
||||
type: 'multi',
|
||||
options: ISSUE_CATEGORY_OPTIONS.filter((c) => c.value !== 'all'),
|
||||
},
|
||||
{
|
||||
key: 'reporter',
|
||||
label: '보고자',
|
||||
type: 'multi',
|
||||
options: MOCK_ISSUE_REPORTERS,
|
||||
},
|
||||
{
|
||||
key: 'assignee',
|
||||
label: '담당자',
|
||||
type: 'multi',
|
||||
options: MOCK_ISSUE_ASSIGNEES,
|
||||
},
|
||||
{
|
||||
key: 'priority',
|
||||
label: '중요도',
|
||||
type: 'single',
|
||||
options: ISSUE_PRIORITY_OPTIONS.filter((o) => o.value !== 'all'),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'single',
|
||||
options: ISSUE_STATUS_OPTIONS.filter((o) => o.value !== 'all'),
|
||||
},
|
||||
{
|
||||
key: 'sortBy',
|
||||
label: '정렬',
|
||||
type: 'single',
|
||||
options: ISSUE_SORT_OPTIONS,
|
||||
},
|
||||
],
|
||||
initialFilters: {
|
||||
partner: [],
|
||||
site: [],
|
||||
category: [],
|
||||
reporter: [],
|
||||
assignee: [],
|
||||
priority: 'all',
|
||||
status: 'all',
|
||||
sortBy: 'latest',
|
||||
},
|
||||
filterTitle: '이슈 필터',
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(sortedIssues.length / itemsPerPage);
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (currentPage - 1) * itemsPerPage;
|
||||
return sortedIssues.slice(start, start + itemsPerPage);
|
||||
}, [sortedIssues, currentPage, itemsPerPage]);
|
||||
// 커스텀 필터 함수
|
||||
customFilterFn: (items, filterValues) => {
|
||||
return items.filter((item) => {
|
||||
// Stats 탭 필터
|
||||
if (activeStatTab !== 'all' && item.status !== activeStatTab) return false;
|
||||
|
||||
// 핸들러
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchValue(value);
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
// 거래처 필터 (다중선택)
|
||||
const partnerFilters = filterValues.partner as string[];
|
||||
if (partnerFilters?.length > 0) {
|
||||
const matchingPartner = MOCK_ISSUE_PARTNERS.find((p) => p.label === item.partnerName);
|
||||
if (!matchingPartner || !partnerFilters.includes(matchingPartner.value)) return false;
|
||||
}
|
||||
|
||||
const handleToggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
// 현장 필터 (다중선택)
|
||||
const siteFilters = filterValues.site as string[];
|
||||
if (siteFilters?.length > 0) {
|
||||
const matchingSite = MOCK_ISSUE_SITES.find((s) => s.label === item.siteName);
|
||||
if (!matchingSite || !siteFilters.includes(matchingSite.value)) return false;
|
||||
}
|
||||
|
||||
const handleToggleSelectAll = useCallback(() => {
|
||||
if (selectedItems.size === paginatedData.length) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(paginatedData.map((c) => c.id)));
|
||||
}
|
||||
}, [selectedItems.size, paginatedData]);
|
||||
// 구분 필터 (다중선택)
|
||||
const categoryFilters = filterValues.category as string[];
|
||||
if (categoryFilters?.length > 0 && !categoryFilters.includes(item.category)) return false;
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(item: Issue) => {
|
||||
router.push(`/ko/construction/project/issue-management/${item.id}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
// 보고자 필터 (다중선택)
|
||||
const reporterFilters = filterValues.reporter as string[];
|
||||
if (reporterFilters?.length > 0) {
|
||||
const matchingReporter = MOCK_ISSUE_REPORTERS.find((r) => r.label === item.reporter);
|
||||
if (!matchingReporter || !reporterFilters.includes(matchingReporter.value)) return false;
|
||||
}
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(e: React.MouseEvent, itemId: string) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/ko/construction/project/issue-management/${itemId}/edit`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
// 담당자 필터 (다중선택)
|
||||
const assigneeFilters = filterValues.assignee as string[];
|
||||
if (assigneeFilters?.length > 0) {
|
||||
const matchingAssignee = MOCK_ISSUE_ASSIGNEES.find((a) => a.label === item.assignee);
|
||||
if (!matchingAssignee || !assigneeFilters.includes(matchingAssignee.value)) return false;
|
||||
}
|
||||
|
||||
const handleCreateIssue = useCallback(() => {
|
||||
router.push('/ko/construction/project/issue-management/new');
|
||||
}, [router]);
|
||||
// 중요도 필터 (단일선택)
|
||||
const priorityFilter = filterValues.priority as string;
|
||||
if (priorityFilter && priorityFilter !== 'all' && item.priority !== priorityFilter) return false;
|
||||
|
||||
// 철회 다이얼로그 열기
|
||||
const handleWithdrawClick = useCallback(() => {
|
||||
if (selectedItems.size === 0) {
|
||||
toast.error('철회할 이슈를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
setWithdrawDialogOpen(true);
|
||||
}, [selectedItems.size]);
|
||||
// 상태 필터 (단일선택)
|
||||
const statusFilter = filterValues.status as string;
|
||||
if (statusFilter && statusFilter !== 'all' && item.status !== statusFilter) return false;
|
||||
|
||||
// 철회 실행
|
||||
const handleWithdraw = useCallback(async () => {
|
||||
try {
|
||||
const ids = Array.from(selectedItems);
|
||||
const result = await withdrawIssues(ids);
|
||||
if (result.success) {
|
||||
toast.success(`${ids.length}건의 이슈가 철회되었습니다.`);
|
||||
setSelectedItems(new Set());
|
||||
loadData();
|
||||
} else {
|
||||
toast.error(result.error || '이슈 철회에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('이슈 철회에 실패했습니다.');
|
||||
} finally {
|
||||
setWithdrawDialogOpen(false);
|
||||
}
|
||||
}, [selectedItems, loadData]);
|
||||
return true;
|
||||
});
|
||||
},
|
||||
|
||||
// 날짜 포맷
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return '-';
|
||||
return dateStr.split('T')[0];
|
||||
};
|
||||
// 커스텀 정렬 함수
|
||||
customSortFn: (items, filterValues) => {
|
||||
const sorted = [...items];
|
||||
const sortBy = (filterValues.sortBy as string) || 'latest';
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = useCallback(
|
||||
(item: Issue, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(item.id);
|
||||
switch (sortBy) {
|
||||
case 'oldest':
|
||||
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
break;
|
||||
case 'reportDate':
|
||||
sorted.sort((a, b) => new Date(b.reportDate).getTime() - new Date(a.reportDate).getTime());
|
||||
break;
|
||||
case 'priorityHigh':
|
||||
const priorityOrderHigh: Record<string, number> = { urgent: 0, normal: 1 };
|
||||
sorted.sort((a, b) => (priorityOrderHigh[a.priority] ?? 2) - (priorityOrderHigh[b.priority] ?? 2));
|
||||
break;
|
||||
case 'priorityLow':
|
||||
const priorityOrderLow: Record<string, number> = { urgent: 1, normal: 0 };
|
||||
sorted.sort((a, b) => (priorityOrderLow[a.priority] ?? 2) - (priorityOrderLow[b.priority] ?? 2));
|
||||
break;
|
||||
default: // latest
|
||||
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
break;
|
||||
}
|
||||
return sorted;
|
||||
},
|
||||
|
||||
return (
|
||||
// 공통 헤더 옵션
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
createButton: {
|
||||
label: '이슈 등록',
|
||||
onClick: handleCreate,
|
||||
},
|
||||
|
||||
// Stats 카드
|
||||
computeStats: (): StatCard[] => [
|
||||
{
|
||||
label: '접수',
|
||||
value: stats?.received ?? 0,
|
||||
icon: Inbox,
|
||||
iconColor: 'text-blue-600',
|
||||
onClick: () => setActiveStatTab('received'),
|
||||
isActive: activeStatTab === 'received',
|
||||
},
|
||||
{
|
||||
label: '처리중',
|
||||
value: stats?.inProgress ?? 0,
|
||||
icon: Clock,
|
||||
iconColor: 'text-yellow-600',
|
||||
onClick: () => setActiveStatTab('in_progress'),
|
||||
isActive: activeStatTab === 'in_progress',
|
||||
},
|
||||
{
|
||||
label: '해결완료',
|
||||
value: stats?.resolved ?? 0,
|
||||
icon: CheckCircle,
|
||||
iconColor: 'text-green-600',
|
||||
onClick: () => setActiveStatTab('resolved'),
|
||||
isActive: activeStatTab === 'resolved',
|
||||
},
|
||||
{
|
||||
label: '미해결',
|
||||
value: stats?.unresolved ?? 0,
|
||||
icon: XCircle,
|
||||
iconColor: 'text-red-600',
|
||||
onClick: () => setActiveStatTab('unresolved'),
|
||||
isActive: activeStatTab === 'unresolved',
|
||||
},
|
||||
],
|
||||
|
||||
// 테이블 헤더 액션 (철회 버튼)
|
||||
tableHeaderActions: ({ selectedItems, onClearSelection }) =>
|
||||
selectedItems.size > 0 ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleWithdrawClick(selectedItems, onClearSelection)}
|
||||
className="text-orange-600 border-orange-300 hover:bg-orange-50"
|
||||
>
|
||||
<Undo2 className="mr-2 h-4 w-4" />
|
||||
철회 ({selectedItems.size})
|
||||
</Button>
|
||||
) : null,
|
||||
|
||||
// 테이블 행 렌더링
|
||||
renderTableRow: (
|
||||
item: Issue,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<Issue>
|
||||
) => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleRowClick(item)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleSelection(item.id)}
|
||||
/>
|
||||
<Checkbox checked={handlers.isSelected} onCheckedChange={handlers.onToggle} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell>{item.issueNumber}</TableCell>
|
||||
@@ -371,13 +459,16 @@ export default function IssueManagementListClient({
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{isSelected && (
|
||||
{handlers.isSelected && (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => handleEdit(e, item.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(item);
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -385,22 +476,23 @@ export default function IssueManagementListClient({
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
[selectedItems, handleToggleSelection, handleRowClick, handleEdit]
|
||||
);
|
||||
),
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = useCallback(
|
||||
(item: Issue, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
|
||||
return (
|
||||
// 모바일 카드 렌더링
|
||||
renderMobileCard: (
|
||||
item: Issue,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<Issue>
|
||||
) => (
|
||||
<MobileCard
|
||||
key={item.id}
|
||||
title={item.title}
|
||||
subtitle={item.issueNumber}
|
||||
badge={ISSUE_STATUS_LABELS[item.status]}
|
||||
badgeVariant="secondary"
|
||||
isSelected={isSelected}
|
||||
onToggle={onToggle}
|
||||
isSelected={handlers.isSelected}
|
||||
onToggle={handlers.onToggle}
|
||||
onClick={() => handleRowClick(item)}
|
||||
details={[
|
||||
{ label: '거래처', value: item.partnerName },
|
||||
@@ -409,248 +501,14 @@ export default function IssueManagementListClient({
|
||||
{ label: '중요도', value: ISSUE_PRIORITY_LABELS[item.priority] },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[handleRowClick]
|
||||
),
|
||||
}),
|
||||
[startDate, endDate, activeStatTab, stats, handleRowClick, handleEdit, handleCreate, handleWithdrawClick]
|
||||
);
|
||||
|
||||
// 헤더 액션 (DateRangeSelector + 이슈 등록 버튼)
|
||||
const headerActions = (
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
extraActions={
|
||||
<Button onClick={handleCreateIssue}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
이슈 등록
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
// 통계 카드 클릭 핸들러
|
||||
const handleStatClick = useCallback((tab: 'all' | 'received' | 'in_progress' | 'resolved' | 'unresolved') => {
|
||||
setActiveStatTab(tab);
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
// 통계 카드 데이터
|
||||
const statsCardsData: StatCard[] = [
|
||||
{
|
||||
label: '접수',
|
||||
value: stats?.received ?? 0,
|
||||
icon: Inbox,
|
||||
iconColor: 'text-blue-600',
|
||||
onClick: () => handleStatClick('received'),
|
||||
isActive: activeStatTab === 'received',
|
||||
},
|
||||
{
|
||||
label: '처리중',
|
||||
value: stats?.inProgress ?? 0,
|
||||
icon: Clock,
|
||||
iconColor: 'text-yellow-600',
|
||||
onClick: () => handleStatClick('in_progress'),
|
||||
isActive: activeStatTab === 'in_progress',
|
||||
},
|
||||
{
|
||||
label: '해결완료',
|
||||
value: stats?.resolved ?? 0,
|
||||
icon: CheckCircle,
|
||||
iconColor: 'text-green-600',
|
||||
onClick: () => handleStatClick('resolved'),
|
||||
isActive: activeStatTab === 'resolved',
|
||||
},
|
||||
{
|
||||
label: '미해결',
|
||||
value: stats?.unresolved ?? 0,
|
||||
icon: XCircle,
|
||||
iconColor: 'text-red-600',
|
||||
onClick: () => handleStatClick('unresolved'),
|
||||
isActive: activeStatTab === 'unresolved',
|
||||
},
|
||||
];
|
||||
|
||||
// ===== filterConfig 기반 통합 필터 시스템 =====
|
||||
const filterConfig: FilterFieldConfig[] = useMemo(() => [
|
||||
{
|
||||
key: 'partner',
|
||||
label: '거래처',
|
||||
type: 'multi',
|
||||
options: partnerOptions.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'site',
|
||||
label: '현장명',
|
||||
type: 'multi',
|
||||
options: siteOptions.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
label: '구분',
|
||||
type: 'multi',
|
||||
options: categoryOptions.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'reporter',
|
||||
label: '보고자',
|
||||
type: 'multi',
|
||||
options: reporterOptions.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'assignee',
|
||||
label: '담당자',
|
||||
type: 'multi',
|
||||
options: assigneeOptions.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'priority',
|
||||
label: '중요도',
|
||||
type: 'single',
|
||||
options: ISSUE_PRIORITY_OPTIONS.filter(o => o.value !== 'all').map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '전체',
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'single',
|
||||
options: ISSUE_STATUS_OPTIONS.filter(o => o.value !== 'all').map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '전체',
|
||||
},
|
||||
{
|
||||
key: 'sortBy',
|
||||
label: '정렬',
|
||||
type: 'single',
|
||||
options: ISSUE_SORT_OPTIONS.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '최신순',
|
||||
},
|
||||
], [partnerOptions, siteOptions, categoryOptions, reporterOptions, assigneeOptions]);
|
||||
|
||||
const filterValues: FilterValues = useMemo(() => ({
|
||||
partner: partnerFilters,
|
||||
site: siteFilters,
|
||||
category: categoryFilters,
|
||||
reporter: reporterFilters,
|
||||
assignee: assigneeFilters,
|
||||
priority: priorityFilter,
|
||||
status: statusFilter,
|
||||
sortBy: sortBy,
|
||||
}), [partnerFilters, siteFilters, categoryFilters, reporterFilters, assigneeFilters, priorityFilter, statusFilter, sortBy]);
|
||||
|
||||
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
|
||||
switch (key) {
|
||||
case 'partner':
|
||||
setPartnerFilters(value as string[]);
|
||||
break;
|
||||
case 'site':
|
||||
setSiteFilters(value as string[]);
|
||||
break;
|
||||
case 'category':
|
||||
setCategoryFilters(value as string[]);
|
||||
break;
|
||||
case 'reporter':
|
||||
setReporterFilters(value as string[]);
|
||||
break;
|
||||
case 'assignee':
|
||||
setAssigneeFilters(value as string[]);
|
||||
break;
|
||||
case 'priority':
|
||||
setPriorityFilter(value as string);
|
||||
break;
|
||||
case 'status':
|
||||
setStatusFilter(value as string);
|
||||
break;
|
||||
case 'sortBy':
|
||||
setSortBy(value as string);
|
||||
break;
|
||||
}
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const handleFilterReset = useCallback(() => {
|
||||
setPartnerFilters([]);
|
||||
setSiteFilters([]);
|
||||
setCategoryFilters([]);
|
||||
setReporterFilters([]);
|
||||
setAssigneeFilters([]);
|
||||
setPriorityFilter('all');
|
||||
setStatusFilter('all');
|
||||
setSortBy('latest');
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
// 철회 버튼 (bulkActions용)
|
||||
const bulkActions = selectedItems.size > 0 ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleWithdrawClick}
|
||||
className="text-orange-600 border-orange-300 hover:bg-orange-50"
|
||||
>
|
||||
<Undo2 className="mr-2 h-4 w-4" />
|
||||
철회 ({selectedItems.size})
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedListTemplateV2
|
||||
title="이슈관리"
|
||||
description="이슈 목록을 관리합니다"
|
||||
icon={AlertTriangle}
|
||||
headerActions={headerActions}
|
||||
stats={statsCardsData}
|
||||
filterConfig={filterConfig}
|
||||
filterValues={filterValues}
|
||||
onFilterChange={handleFilterChange}
|
||||
onFilterReset={handleFilterReset}
|
||||
filterTitle="이슈 필터"
|
||||
bulkActions={bulkActions}
|
||||
searchValue={searchValue}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="이슈번호, 시공번호, 거래처, 현장, 제목, 보고자, 담당자 검색"
|
||||
tableColumns={tableColumns}
|
||||
data={paginatedData}
|
||||
allData={sortedIssues}
|
||||
getItemId={(item) => item.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: sortedIssues.length,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
<UniversalListPage config={config} initialData={initialData} />
|
||||
|
||||
{/* 철회 확인 다이얼로그 */}
|
||||
<AlertDialog open={withdrawDialogOpen} onOpenChange={setWithdrawDialogOpen}>
|
||||
@@ -658,7 +516,7 @@ export default function IssueManagementListClient({
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>이슈 철회</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 {selectedItems.size}건의 이슈를 철회하시겠습니까?
|
||||
선택한 {itemsToWithdraw.size}건의 이슈를 철회하시겠습니까?
|
||||
<br />
|
||||
철회된 이슈는 복구할 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
@@ -676,4 +534,4 @@ export default function IssueManagementListClient({
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,8 @@ import { Button } from '@/components/ui/button';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { IntegratedListTemplateV2, TableColumn, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { UniversalListPage, type UniversalListConfig, type TableColumn, type FilterFieldConfig, type FilterValues } from '@/components/templates/UniversalListPage';
|
||||
import { MobileCard } from '@/components/molecules/MobileCard';
|
||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -321,8 +320,8 @@ export default function ItemManagementClient({
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = useCallback(
|
||||
(item: Item, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(item.id);
|
||||
(item: Item, index: number, globalIndex: number, handlers: { isSelected: boolean; onToggle: () => void; onRowClick?: () => void }) => {
|
||||
const { isSelected, onToggle } = handlers;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
@@ -333,7 +332,7 @@ export default function ItemManagementClient({
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleSelection(item.id)}
|
||||
onCheckedChange={onToggle}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
@@ -381,7 +380,8 @@ export default function ItemManagementClient({
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = useCallback(
|
||||
(item: Item, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
|
||||
(item: Item, index: number, globalIndex: number, handlers: { isSelected: boolean; onToggle: () => void; onRowClick?: () => void }) => {
|
||||
const { isSelected, onToggle } = handlers;
|
||||
return (
|
||||
<MobileCard
|
||||
title={item.itemName}
|
||||
@@ -404,21 +404,7 @@ export default function ItemManagementClient({
|
||||
[handleRowClick]
|
||||
);
|
||||
|
||||
// 헤더 액션 (날짜선택 + 등록 버튼)
|
||||
const headerActions = (
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
extraActions={
|
||||
<Button onClick={handleCreate}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
품목 등록
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
// 헤더 액션 제거 - dateRangeSelector와 createButton 사용
|
||||
|
||||
// ===== filterConfig 기반 통합 필터 시스템 =====
|
||||
const filterConfig: FilterFieldConfig[] = useMemo(() => [
|
||||
@@ -527,85 +513,127 @@ export default function ItemManagementClient({
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
// ===== UniversalListPage 설정 =====
|
||||
const itemManagementConfig: UniversalListConfig<Item> = {
|
||||
title: '품목관리',
|
||||
description: '품목을 등록하여 관리합니다.',
|
||||
icon: Package,
|
||||
basePath: '/construction/order/base-info/items',
|
||||
|
||||
idField: 'id',
|
||||
|
||||
actions: {
|
||||
getList: async () => ({
|
||||
success: true,
|
||||
data: items,
|
||||
totalCount: items.length,
|
||||
}),
|
||||
},
|
||||
|
||||
columns: tableColumns,
|
||||
|
||||
stats: [
|
||||
{
|
||||
label: '전체 품목',
|
||||
value: stats.total,
|
||||
icon: Package,
|
||||
iconColor: 'text-blue-500',
|
||||
},
|
||||
{
|
||||
label: '사용 품목',
|
||||
value: stats.active,
|
||||
icon: PackageCheck,
|
||||
iconColor: 'text-green-500',
|
||||
},
|
||||
],
|
||||
|
||||
filterConfig: filterConfig,
|
||||
filterTitle: '품목 필터',
|
||||
|
||||
searchPlaceholder: '품목명, 품목번호, 카테고리 검색',
|
||||
|
||||
itemsPerPage: ITEMS_PER_PAGE,
|
||||
|
||||
clientSideFiltering: true,
|
||||
|
||||
// 날짜 범위 선택기
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
|
||||
// 등록 버튼
|
||||
createButton: {
|
||||
label: '품목 등록',
|
||||
onClick: handleCreate,
|
||||
icon: Plus,
|
||||
},
|
||||
|
||||
renderTableRow,
|
||||
renderMobileCard,
|
||||
|
||||
renderDialogs: () => (
|
||||
<>
|
||||
{/* 단일 삭제 다이얼로그 */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>품목 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 품목을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteConfirm}>삭제</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 일괄 삭제 다이얼로그 */}
|
||||
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>품목 일괄 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 {selectedItems.size}개 품목을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleBulkDeleteConfirm}>삭제</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedListTemplateV2
|
||||
title="품목관리"
|
||||
description="품목을 등록하여 관리합니다."
|
||||
icon={Package}
|
||||
headerActions={headerActions}
|
||||
stats={[
|
||||
{
|
||||
label: '전체 품목',
|
||||
value: stats.total,
|
||||
icon: Package,
|
||||
iconColor: 'text-blue-500',
|
||||
},
|
||||
{
|
||||
label: '사용 품목',
|
||||
value: stats.active,
|
||||
icon: PackageCheck,
|
||||
iconColor: 'text-green-500',
|
||||
},
|
||||
]}
|
||||
filterConfig={filterConfig}
|
||||
filterValues={filterValues}
|
||||
onFilterChange={handleFilterChange}
|
||||
onFilterReset={handleFilterReset}
|
||||
filterTitle="품목 필터"
|
||||
searchValue={searchValue}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="품목명, 품목번호, 카테고리 검색"
|
||||
tableColumns={tableColumns}
|
||||
data={paginatedData}
|
||||
allData={sortedItems}
|
||||
getItemId={(item) => item.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
onBulkDelete={handleBulkDeleteClick}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: sortedItems.length,
|
||||
itemsPerPage: ITEMS_PER_PAGE,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 단일 삭제 다이얼로그 */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>품목 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 품목을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteConfirm}>삭제</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 일괄 삭제 다이얼로그 */}
|
||||
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>품목 일괄 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 {selectedItems.size}개 품목을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleBulkDeleteConfirm}>삭제</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
<UniversalListPage<Item>
|
||||
config={itemManagementConfig}
|
||||
initialData={sortedItems}
|
||||
initialTotalCount={sortedItems.length}
|
||||
externalSelection={{
|
||||
selectedItems,
|
||||
setSelectedItems,
|
||||
}}
|
||||
externalSearch={{
|
||||
searchValue,
|
||||
setSearchValue: handleSearchChange,
|
||||
}}
|
||||
externalPagination={{
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
}}
|
||||
externalFilter={{
|
||||
filterValues,
|
||||
onFilterChange: handleFilterChange,
|
||||
onFilterReset: handleFilterReset,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +1,38 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 노임관리 - UniversalListPage 마이그레이션
|
||||
*
|
||||
* 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환
|
||||
* - 클라이언트 사이드 필터링 (검색, 필터, 정렬)
|
||||
* - Stats 카드 (클릭 필터 없음, 단순 표시)
|
||||
* - DateRangeSelector + 등록 버튼 (dateRangeSelector + createButton)
|
||||
* - filterConfig (single 3개: category, status, sortBy)
|
||||
* - 삭제 기능 (deleteConfirmMessage)
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { format, startOfYear, endOfYear } from 'date-fns';
|
||||
import { Hammer, Plus, Pencil, Trash2, HardHat } from 'lucide-react';
|
||||
import { Hammer, Pencil, Trash2, HardHat } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { IntegratedListTemplateV2, TableColumn, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { MobileCard } from '@/components/molecules/MobileCard';
|
||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import type { Labor, LaborStats, LaborCategory, LaborStatus, SortOrder } from './types';
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
type SelectionHandlers,
|
||||
type RowClickHandlers,
|
||||
type StatCard,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import type { Labor, LaborStats, LaborCategory, LaborStatus } from './types';
|
||||
import { CATEGORY_OPTIONS, STATUS_OPTIONS, SORT_OPTIONS, DEFAULT_PAGE_SIZE } from './constants';
|
||||
import { getLaborList, deleteLabor, deleteLaborBulk, getLaborStats } from './actions';
|
||||
|
||||
// 테이블 컬럼 정의
|
||||
const tableColumns: TableColumn[] = [
|
||||
const tableColumns = [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'laborNumber', label: '노임번호', className: 'w-[120px]' },
|
||||
{ key: 'category', label: '구분', className: 'w-[100px] text-center' },
|
||||
@@ -57,217 +55,24 @@ export default function LaborManagementClient({
|
||||
const router = useRouter();
|
||||
const today = new Date();
|
||||
|
||||
// 날짜 상태 (당해년도 기본값)
|
||||
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
|
||||
const [startDate, setStartDate] = useState(format(startOfYear(today), 'yyyy-MM-dd'));
|
||||
const [endDate, setEndDate] = useState(format(endOfYear(today), 'yyyy-MM-dd'));
|
||||
|
||||
// 상태
|
||||
const [labors, setLabors] = useState<Labor[]>(initialData);
|
||||
const [stats, setStats] = useState<LaborStats>(initialStats ?? { total: 0, active: 0 });
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState<LaborCategory | 'all'>('all');
|
||||
const [statusFilter, setStatusFilter] = useState<LaborStatus | 'all'>('all');
|
||||
const [sortBy, setSortBy] = useState<SortOrder>('최신순');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [listResult, statsResult] = await Promise.all([
|
||||
getLaborList({
|
||||
category: categoryFilter,
|
||||
status: statusFilter,
|
||||
sortOrder: sortBy,
|
||||
startDate,
|
||||
endDate,
|
||||
}),
|
||||
getLaborStats(),
|
||||
]);
|
||||
|
||||
if (listResult.success && listResult.data) {
|
||||
setLabors(listResult.data);
|
||||
}
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStats(statsResult.data);
|
||||
}
|
||||
} catch {
|
||||
toast.error('데이터 로드에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [categoryFilter, statusFilter, sortBy, startDate, endDate]);
|
||||
|
||||
// 초기 데이터가 없으면 로드
|
||||
// Stats 로드
|
||||
useEffect(() => {
|
||||
if (initialData.length === 0) {
|
||||
loadData();
|
||||
}
|
||||
}, [initialData.length, loadData]);
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredLabors = useMemo(() => {
|
||||
return labors.filter((labor) => {
|
||||
// 구분 필터
|
||||
if (categoryFilter !== 'all' && labor.category !== categoryFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (statusFilter !== 'all' && labor.status !== statusFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if (searchValue) {
|
||||
const search = searchValue.toLowerCase();
|
||||
return (
|
||||
labor.laborNumber.toLowerCase().includes(search) ||
|
||||
labor.category.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [labors, categoryFilter, statusFilter, searchValue]);
|
||||
|
||||
// 정렬
|
||||
const sortedLabors = useMemo(() => {
|
||||
const sorted = [...filteredLabors];
|
||||
if (sortBy === '등록순') {
|
||||
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
} else {
|
||||
// 기본: 최신순
|
||||
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
}
|
||||
return sorted;
|
||||
}, [filteredLabors, sortBy]);
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(sortedLabors.length / DEFAULT_PAGE_SIZE);
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (currentPage - 1) * DEFAULT_PAGE_SIZE;
|
||||
return sortedLabors.slice(start, start + DEFAULT_PAGE_SIZE);
|
||||
}, [sortedLabors, currentPage]);
|
||||
|
||||
// 핸들러
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchValue(value);
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const handleToggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggleSelectAll = useCallback(() => {
|
||||
if (selectedItems.size === paginatedData.length) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(paginatedData.map((labor) => labor.id)));
|
||||
}
|
||||
}, [selectedItems.size, paginatedData]);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(labor: Labor) => {
|
||||
router.push(`/ko/construction/order/base-info/labor/${labor.id}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
router.push('/ko/construction/order/base-info/labor/new');
|
||||
}, [router]);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(e: React.MouseEvent, laborId: string) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/ko/construction/order/base-info/labor/${laborId}?mode=edit`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleDeleteClick = useCallback((e: React.MouseEvent, laborId: string) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTargetId(laborId);
|
||||
setDeleteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
if (!deleteTargetId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await deleteLabor(deleteTargetId);
|
||||
if (result.success) {
|
||||
toast.success('노임이 삭제되었습니다.');
|
||||
setLabors((prev) => prev.filter((labor) => labor.id !== deleteTargetId));
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(deleteTargetId);
|
||||
return newSet;
|
||||
});
|
||||
// 통계 재조회
|
||||
const statsResult = await getLaborStats();
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStats(statsResult.data);
|
||||
if (!initialStats) {
|
||||
getLaborStats().then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setStats(result.data);
|
||||
}
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setDeleteDialogOpen(false);
|
||||
setDeleteTargetId(null);
|
||||
});
|
||||
}
|
||||
}, [deleteTargetId]);
|
||||
|
||||
const handleBulkDeleteClick = useCallback(() => {
|
||||
if (selectedItems.size === 0) {
|
||||
toast.warning('삭제할 항목을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
setBulkDeleteDialogOpen(true);
|
||||
}, [selectedItems.size]);
|
||||
|
||||
const handleBulkDeleteConfirm = useCallback(async () => {
|
||||
if (selectedItems.size === 0) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const ids = Array.from(selectedItems);
|
||||
const result = await deleteLaborBulk(ids);
|
||||
if (result.success) {
|
||||
toast.success(`${result.deletedCount}개 항목이 삭제되었습니다.`);
|
||||
await loadData();
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
toast.error(result.error || '일괄 삭제에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('일괄 삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setBulkDeleteDialogOpen(false);
|
||||
}
|
||||
}, [selectedItems, loadData]);
|
||||
}, [initialStats]);
|
||||
|
||||
// 상태 배지 색상
|
||||
const getStatusBadgeVariant = (status: string) => {
|
||||
const getStatusBadgeVariant = useCallback((status: string) => {
|
||||
switch (status) {
|
||||
case '사용':
|
||||
return 'default';
|
||||
@@ -276,36 +81,216 @@ export default function LaborManagementClient({
|
||||
default:
|
||||
return 'outline';
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 가격 포맷
|
||||
const formatPrice = (price: number | null) => {
|
||||
const formatPrice = useCallback((price: number | null) => {
|
||||
if (price === null || price === 0) return '-';
|
||||
return price.toLocaleString();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// M 값 포맷
|
||||
const formatM = (value: number) => {
|
||||
const formatM = useCallback((value: number) => {
|
||||
if (value === 0) return '-';
|
||||
return value.toFixed(2);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = useCallback(
|
||||
(labor: Labor, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(labor.id);
|
||||
// ===== 핸들러 =====
|
||||
const handleRowClick = useCallback(
|
||||
(labor: Labor) => {
|
||||
router.push(`/ko/construction/order/base-info/labor/${labor.id}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
return (
|
||||
const handleEdit = useCallback(
|
||||
(labor: Labor) => {
|
||||
router.push(`/ko/construction/order/base-info/labor/${labor.id}?mode=edit`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
router.push('/ko/construction/order/base-info/labor/new');
|
||||
}, [router]);
|
||||
|
||||
// ===== UniversalListPage Config =====
|
||||
const config: UniversalListConfig<Labor> = useMemo(
|
||||
() => ({
|
||||
// 페이지 기본 정보
|
||||
title: '노임관리',
|
||||
description: '노임을 등록하고 관리합니다.',
|
||||
icon: Hammer,
|
||||
basePath: '/construction/order/base-info/labor',
|
||||
|
||||
// ID 추출
|
||||
idField: 'id',
|
||||
|
||||
// API 액션
|
||||
actions: {
|
||||
getList: async () => {
|
||||
const result = await getLaborList({
|
||||
category: 'all',
|
||||
status: 'all',
|
||||
sortOrder: '최신순',
|
||||
startDate,
|
||||
endDate,
|
||||
});
|
||||
if (result.success && result.data) {
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
totalCount: result.data.length,
|
||||
};
|
||||
}
|
||||
return { success: false, error: result.error };
|
||||
},
|
||||
deleteItem: async (id: string) => {
|
||||
const result = await deleteLabor(id);
|
||||
return { success: result.success, error: result.error };
|
||||
},
|
||||
deleteBulk: async (ids: string[]) => {
|
||||
const result = await deleteLaborBulk(ids);
|
||||
return { success: result.success, error: result.error };
|
||||
},
|
||||
},
|
||||
|
||||
// 테이블 컬럼
|
||||
columns: tableColumns,
|
||||
|
||||
// 클라이언트 사이드 필터링
|
||||
clientSideFiltering: true,
|
||||
itemsPerPage: DEFAULT_PAGE_SIZE,
|
||||
|
||||
// 검색 필터
|
||||
searchPlaceholder: '노임번호, 구분 검색',
|
||||
searchFilter: (item, searchValue) => {
|
||||
const search = searchValue.toLowerCase();
|
||||
return (
|
||||
item.laborNumber.toLowerCase().includes(search) ||
|
||||
item.category.toLowerCase().includes(search)
|
||||
);
|
||||
},
|
||||
|
||||
// 필터 설정
|
||||
filterConfig: [
|
||||
{
|
||||
key: 'category',
|
||||
label: '구분',
|
||||
type: 'single',
|
||||
options: CATEGORY_OPTIONS.filter((o) => o.value !== 'all').map((o) => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'single',
|
||||
options: STATUS_OPTIONS.filter((o) => o.value !== 'all').map((o) => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'sortBy',
|
||||
label: '정렬',
|
||||
type: 'single',
|
||||
options: SORT_OPTIONS.map((o) => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
},
|
||||
],
|
||||
initialFilters: {
|
||||
category: 'all',
|
||||
status: 'all',
|
||||
sortBy: '최신순',
|
||||
},
|
||||
filterTitle: '노임 필터',
|
||||
|
||||
// 커스텀 필터 함수
|
||||
customFilterFn: (items, filterValues) => {
|
||||
return items.filter((item) => {
|
||||
// 구분 필터
|
||||
const categoryFilter = filterValues.category as string;
|
||||
if (categoryFilter && categoryFilter !== 'all' && item.category !== categoryFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
const statusFilter = filterValues.status as string;
|
||||
if (statusFilter && statusFilter !== 'all' && item.status !== statusFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
},
|
||||
|
||||
// 커스텀 정렬 함수
|
||||
customSortFn: (items, filterValues) => {
|
||||
const sorted = [...items];
|
||||
const sortBy = (filterValues.sortBy as string) || '최신순';
|
||||
|
||||
if (sortBy === '등록순') {
|
||||
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
} else {
|
||||
// 기본: 최신순
|
||||
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
}
|
||||
return sorted;
|
||||
},
|
||||
|
||||
// 공통 헤더 옵션
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
createButton: {
|
||||
label: '노임 등록',
|
||||
onClick: handleCreate,
|
||||
},
|
||||
|
||||
// Stats 카드 (클릭 필터 없음)
|
||||
computeStats: (): StatCard[] => [
|
||||
{
|
||||
label: '전체 노임',
|
||||
value: stats.total,
|
||||
icon: Hammer,
|
||||
iconColor: 'text-blue-500',
|
||||
},
|
||||
{
|
||||
label: '사용 노임',
|
||||
value: stats.active,
|
||||
icon: HardHat,
|
||||
iconColor: 'text-green-500',
|
||||
},
|
||||
],
|
||||
|
||||
// 삭제 확인 메시지
|
||||
deleteConfirmMessage: {
|
||||
title: '노임 삭제',
|
||||
description: '선택한 노임을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.',
|
||||
},
|
||||
|
||||
// 테이블 행 렌더링
|
||||
renderTableRow: (
|
||||
labor: Labor,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<Labor>
|
||||
) => (
|
||||
<TableRow
|
||||
key={labor.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleRowClick(labor)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleSelection(labor.id)}
|
||||
/>
|
||||
<Checkbox checked={handlers.isSelected} onCheckedChange={handlers.onToggle} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell className="font-medium">{labor.laborNumber}</TableCell>
|
||||
@@ -319,13 +304,16 @@ export default function LaborManagementClient({
|
||||
<Badge variant={getStatusBadgeVariant(labor.status)}>{labor.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{isSelected && (
|
||||
{handlers.isSelected && (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => handleEdit(e, labor.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(labor);
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -333,7 +321,10 @@ export default function LaborManagementClient({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={(e) => handleDeleteClick(e, labor.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlers.onDelete?.(labor);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -341,22 +332,23 @@ export default function LaborManagementClient({
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
[selectedItems, handleToggleSelection, handleRowClick, handleEdit, handleDeleteClick]
|
||||
);
|
||||
),
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = useCallback(
|
||||
(labor: Labor, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
|
||||
return (
|
||||
// 모바일 카드 렌더링
|
||||
renderMobileCard: (
|
||||
labor: Labor,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<Labor>
|
||||
) => (
|
||||
<MobileCard
|
||||
key={labor.id}
|
||||
title={labor.laborNumber}
|
||||
subtitle={labor.category}
|
||||
badge={labor.status}
|
||||
badgeVariant={getStatusBadgeVariant(labor.status) as 'default' | 'secondary' | 'destructive' | 'outline'}
|
||||
isSelected={isSelected}
|
||||
onToggle={onToggle}
|
||||
isSelected={handlers.isSelected}
|
||||
onToggle={handlers.onToggle}
|
||||
onClick={() => handleRowClick(labor)}
|
||||
details={[
|
||||
{ label: '최소 M', value: formatM(labor.minM) },
|
||||
@@ -364,231 +356,20 @@ export default function LaborManagementClient({
|
||||
{ label: '노임단가', value: formatPrice(labor.laborPrice) },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[handleRowClick]
|
||||
),
|
||||
}),
|
||||
[
|
||||
startDate,
|
||||
endDate,
|
||||
stats,
|
||||
handleRowClick,
|
||||
handleEdit,
|
||||
handleCreate,
|
||||
formatPrice,
|
||||
formatM,
|
||||
getStatusBadgeVariant,
|
||||
]
|
||||
);
|
||||
|
||||
// ===== filterConfig 기반 통합 필터 시스템 =====
|
||||
const filterConfig: FilterFieldConfig[] = useMemo(() => [
|
||||
{
|
||||
key: 'category',
|
||||
label: '구분',
|
||||
type: 'single',
|
||||
options: CATEGORY_OPTIONS.filter(o => o.value !== 'all').map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'single',
|
||||
options: STATUS_OPTIONS.filter(o => o.value !== 'all').map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'sortBy',
|
||||
label: '정렬',
|
||||
type: 'single',
|
||||
options: SORT_OPTIONS.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '최신순',
|
||||
},
|
||||
], []);
|
||||
|
||||
const filterValues: FilterValues = useMemo(() => ({
|
||||
category: categoryFilter,
|
||||
status: statusFilter,
|
||||
sortBy: sortBy,
|
||||
}), [categoryFilter, statusFilter, sortBy]);
|
||||
|
||||
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
|
||||
switch (key) {
|
||||
case 'category':
|
||||
setCategoryFilter(value as LaborCategory | 'all');
|
||||
break;
|
||||
case 'status':
|
||||
setStatusFilter(value as LaborStatus | 'all');
|
||||
break;
|
||||
case 'sortBy':
|
||||
setSortBy(value as SortOrder);
|
||||
break;
|
||||
}
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const handleFilterReset = useCallback(() => {
|
||||
setCategoryFilter('all');
|
||||
setStatusFilter('all');
|
||||
setSortBy('최신순');
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
// 헤더 액션 (날짜선택 + 등록 버튼)
|
||||
const headerActions = (
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
extraActions={
|
||||
<Button onClick={handleCreate}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
노임 등록
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
// 테이블 헤더 액션 (필터)
|
||||
const tableHeaderActions = (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* 총 건수 */}
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
총 {sortedLabors.length}건
|
||||
</span>
|
||||
|
||||
{/* 구분 필터 */}
|
||||
<Select
|
||||
value={categoryFilter}
|
||||
onValueChange={(v) => {
|
||||
setCategoryFilter(v as LaborCategory | 'all');
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="구분" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CATEGORY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 상태 필터 */}
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onValueChange={(v) => {
|
||||
setStatusFilter(v as LaborStatus | 'all');
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[80px]">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 정렬 */}
|
||||
<Select value={sortBy} onValueChange={(v) => setSortBy(v as SortOrder)}>
|
||||
<SelectTrigger className="w-[90px]">
|
||||
<SelectValue placeholder="정렬" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedListTemplateV2
|
||||
title="노임관리"
|
||||
description="노임을 등록하고 관리합니다."
|
||||
icon={Hammer}
|
||||
headerActions={headerActions}
|
||||
stats={[
|
||||
{
|
||||
label: '전체 노임',
|
||||
value: stats.total,
|
||||
icon: Hammer,
|
||||
iconColor: 'text-blue-500',
|
||||
},
|
||||
{
|
||||
label: '사용 노임',
|
||||
value: stats.active,
|
||||
icon: HardHat,
|
||||
iconColor: 'text-green-500',
|
||||
},
|
||||
]}
|
||||
tableHeaderActions={tableHeaderActions}
|
||||
filterConfig={filterConfig}
|
||||
filterValues={filterValues}
|
||||
onFilterChange={handleFilterChange}
|
||||
onFilterReset={handleFilterReset}
|
||||
filterTitle="노임 필터"
|
||||
searchValue={searchValue}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="노임번호, 구분 검색"
|
||||
tableColumns={tableColumns}
|
||||
data={paginatedData}
|
||||
allData={sortedLabors}
|
||||
getItemId={(labor) => labor.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
onBulkDelete={handleBulkDeleteClick}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: sortedLabors.length,
|
||||
itemsPerPage: DEFAULT_PAGE_SIZE,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 단일 삭제 다이얼로그 */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>노임 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 노임을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteConfirm}>삭제</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 일괄 삭제 다이얼로그 */}
|
||||
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>노임 일괄 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 {selectedItems.size}개 노임을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleBulkDeleteConfirm}>삭제</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
return <UniversalListPage config={config} initialData={initialData} />;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,640 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 발주관리 리스트 - UniversalListPage 버전
|
||||
*
|
||||
* 특이 케이스:
|
||||
* - ScheduleCalendar 컴포넌트 (beforeTableContent)
|
||||
* - 9개의 다중선택 필터
|
||||
* - 달력 날짜 클릭 시 테이블 필터링
|
||||
* - 클라이언트 사이드 필터링/페이지네이션
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Package, Pencil, Trash2, Plus } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
type SelectionHandlers,
|
||||
type RowClickHandlers,
|
||||
type FilterFieldConfig,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||
import { ScheduleCalendar, ScheduleEvent, DayBadge } from '@/components/common/ScheduleCalendar';
|
||||
import { format, parseISO, isSameDay, startOfDay } from 'date-fns';
|
||||
import type { Order } from './types';
|
||||
import {
|
||||
ORDER_STATUS_OPTIONS,
|
||||
ORDER_SORT_OPTIONS,
|
||||
ORDER_STATUS_STYLES,
|
||||
ORDER_STATUS_LABELS,
|
||||
ORDER_TYPE_OPTIONS,
|
||||
ORDER_TYPE_LABELS,
|
||||
MOCK_PARTNERS,
|
||||
MOCK_SITES,
|
||||
MOCK_CONSTRUCTION_PM,
|
||||
MOCK_ORDER_MANAGERS,
|
||||
MOCK_ORDER_COMPANIES,
|
||||
MOCK_WORK_TEAM_LEADERS,
|
||||
getScheduleColorByManager,
|
||||
} from './types';
|
||||
import {
|
||||
getOrderList,
|
||||
deleteOrder,
|
||||
deleteOrders,
|
||||
} from './actions';
|
||||
|
||||
interface OrderManagementUnifiedProps {
|
||||
initialData?: Order[];
|
||||
}
|
||||
|
||||
export function OrderManagementUnified({ initialData }: OrderManagementUnifiedProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// 달력 관련 상태 (beforeTableContent에서 사용하므로 config 외부에서 관리)
|
||||
const [selectedCalendarDate, setSelectedCalendarDate] = useState<Date | null>(null);
|
||||
const [calendarDate, setCalendarDate] = useState<Date>(new Date());
|
||||
const [siteFilters, setSiteFilters] = useState<string[]>([]);
|
||||
const [workTeamFilters, setWorkTeamFilters] = useState<string[]>([]);
|
||||
|
||||
// 날짜 범위 필터 상태
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
|
||||
// 전체 데이터 (달력 이벤트용)
|
||||
const [allOrders, setAllOrders] = useState<Order[]>(initialData || []);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// 필터 옵션들
|
||||
const siteOptions: MultiSelectOption[] = useMemo(() => MOCK_SITES, []);
|
||||
const workTeamOptions: MultiSelectOption[] = useMemo(() => MOCK_WORK_TEAM_LEADERS, []);
|
||||
const partnerOptions: MultiSelectOption[] = useMemo(() => MOCK_PARTNERS, []);
|
||||
const constructionPMOptions: MultiSelectOption[] = useMemo(() => MOCK_CONSTRUCTION_PM, []);
|
||||
const orderManagerOptions: MultiSelectOption[] = useMemo(() => MOCK_ORDER_MANAGERS, []);
|
||||
const orderCompanyOptions: MultiSelectOption[] = useMemo(() => MOCK_ORDER_COMPANIES, []);
|
||||
const orderTypeOptions: MultiSelectOption[] = useMemo(() => ORDER_TYPE_OPTIONS, []);
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await getOrderList({
|
||||
size: 1000,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
});
|
||||
|
||||
if (result.success && result.data) {
|
||||
setAllOrders(result.data.items);
|
||||
}
|
||||
} catch {
|
||||
console.error('데이터 로드 실패');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [startDate, endDate]);
|
||||
|
||||
// 초기 데이터가 없으면 로드
|
||||
useEffect(() => {
|
||||
if (!initialData || initialData.length === 0) {
|
||||
loadData();
|
||||
}
|
||||
}, [initialData, loadData]);
|
||||
|
||||
// 달력용 이벤트 데이터 변환 (필터 적용)
|
||||
const calendarEvents: ScheduleEvent[] = useMemo(() => {
|
||||
return allOrders
|
||||
.filter((order) => {
|
||||
// 현장 필터
|
||||
if (siteFilters.length > 0) {
|
||||
const matchingSite = MOCK_SITES.find((s) => order.siteName.includes(s.label.split(' ')[0]));
|
||||
if (!matchingSite || !siteFilters.includes(matchingSite.value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 작업반장 필터
|
||||
if (workTeamFilters.length > 0) {
|
||||
const matchingLeader = MOCK_WORK_TEAM_LEADERS.find((l) => order.orderManager.includes(l.label.replace('반장', '')));
|
||||
if (!matchingLeader || !workTeamFilters.includes(matchingLeader.value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.map((order) => ({
|
||||
id: order.id,
|
||||
title: `${order.orderManager} - ${order.siteName} / ${order.orderNumber}`,
|
||||
startDate: order.periodStart,
|
||||
endDate: order.periodEnd,
|
||||
color: getScheduleColorByManager(order.orderManager),
|
||||
status: order.status,
|
||||
data: order,
|
||||
}));
|
||||
}, [allOrders, siteFilters, workTeamFilters]);
|
||||
|
||||
// 달력용 뱃지 데이터 - 사용하지 않음
|
||||
const calendarBadges: DayBadge[] = [];
|
||||
|
||||
// 달력 이벤트 핸들러
|
||||
const handleCalendarDateClick = useCallback((date: Date) => {
|
||||
if (selectedCalendarDate && isSameDay(selectedCalendarDate, date)) {
|
||||
setSelectedCalendarDate(null);
|
||||
} else {
|
||||
setSelectedCalendarDate(date);
|
||||
}
|
||||
}, [selectedCalendarDate]);
|
||||
|
||||
const handleCalendarEventClick = useCallback((event: ScheduleEvent) => {
|
||||
if (event.data) {
|
||||
router.push(`/ko/construction/order/order-management/${event.id}`);
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const handleCalendarMonthChange = useCallback((date: Date) => {
|
||||
setCalendarDate(date);
|
||||
}, []);
|
||||
|
||||
// 날짜 포맷
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return '-';
|
||||
return dateStr.split('T')[0];
|
||||
};
|
||||
|
||||
// 달력 필터 슬롯
|
||||
const calendarFilterSlot = (
|
||||
<div className="flex items-center gap-2">
|
||||
<MultiSelectCombobox
|
||||
options={siteOptions}
|
||||
value={siteFilters}
|
||||
onChange={setSiteFilters}
|
||||
placeholder="현장"
|
||||
searchPlaceholder="현장 검색..."
|
||||
className="w-[160px]"
|
||||
/>
|
||||
<MultiSelectCombobox
|
||||
options={workTeamOptions}
|
||||
value={workTeamFilters}
|
||||
onChange={setWorkTeamFilters}
|
||||
placeholder="작업반장"
|
||||
searchPlaceholder="작업반장 검색..."
|
||||
className="w-[130px]"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
// UniversalListPage Config 정의
|
||||
const config: UniversalListConfig<Order> = useMemo(() => ({
|
||||
// ===== 페이지 기본 정보 =====
|
||||
title: '발주관리',
|
||||
description: '발주 스케줄 및 목록을 관리합니다',
|
||||
icon: Package,
|
||||
basePath: '/construction/order/order-management',
|
||||
|
||||
// ===== ID 추출 =====
|
||||
idField: 'id',
|
||||
|
||||
// ===== API 액션 =====
|
||||
actions: {
|
||||
getList: async () => {
|
||||
const result = await getOrderList({
|
||||
size: 1000,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
});
|
||||
return {
|
||||
success: result.success,
|
||||
data: result.data?.items || [],
|
||||
totalCount: result.data?.items?.length || 0,
|
||||
error: result.error,
|
||||
};
|
||||
},
|
||||
deleteItem: async (id: string) => {
|
||||
return await deleteOrder(id);
|
||||
},
|
||||
deleteBulk: async (ids: string[]) => {
|
||||
return await deleteOrders(ids);
|
||||
},
|
||||
},
|
||||
|
||||
// ===== 테이블 컬럼 =====
|
||||
columns: [
|
||||
{ key: 'no', label: '번호', className: 'w-[50px] text-center' },
|
||||
{ key: 'contractNumber', label: '계약번호', className: 'w-[100px]' },
|
||||
{ key: 'partnerName', label: '거래처', className: 'w-[80px]' },
|
||||
{ key: 'siteName', label: '현장명', className: 'min-w-[100px]' },
|
||||
{ key: 'name', label: '명칭', className: 'w-[80px]' },
|
||||
{ key: 'constructionPM', label: '공사PM', className: 'w-[70px]' },
|
||||
{ key: 'orderManager', label: '발주담당자', className: 'w-[80px]' },
|
||||
{ key: 'orderNumber', label: '발주번호', className: 'w-[100px]' },
|
||||
{ key: 'orderCompany', label: '발주처명', className: 'w-[80px]' },
|
||||
{ key: 'workTeamLeader', label: '작업반장', className: 'w-[70px]' },
|
||||
{ key: 'constructionStartDate', label: '시공투입일', className: 'w-[90px]' },
|
||||
{ key: 'orderType', label: '구분', className: 'w-[80px] text-center' },
|
||||
{ key: 'item', label: '품목', className: 'w-[80px]' },
|
||||
{ key: 'quantity', label: '수량', className: 'w-[60px] text-right' },
|
||||
{ key: 'orderDate', label: '발주일', className: 'w-[90px]' },
|
||||
{ key: 'plannedDeliveryDate', label: '계획인수일', className: 'w-[90px]' },
|
||||
{ key: 'actualDeliveryDate', label: '실제인수일', className: 'w-[90px]' },
|
||||
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
|
||||
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
|
||||
],
|
||||
|
||||
// ===== 클라이언트 사이드 필터링 =====
|
||||
clientSideFiltering: true,
|
||||
|
||||
// 검색 필터 함수
|
||||
searchFilter: (item: Order, searchValue: string) => {
|
||||
const search = searchValue.toLowerCase();
|
||||
return (
|
||||
item.orderNumber.toLowerCase().includes(search) ||
|
||||
item.partnerName.toLowerCase().includes(search) ||
|
||||
item.siteName.toLowerCase().includes(search) ||
|
||||
item.orderManager.toLowerCase().includes(search)
|
||||
);
|
||||
},
|
||||
|
||||
// ===== 필터 설정 =====
|
||||
filterConfig: [
|
||||
{ key: 'partners', label: '거래처', type: 'multi', options: partnerOptions },
|
||||
{ key: 'sites', label: '현장명', type: 'multi', options: siteOptions },
|
||||
{ key: 'constructionPMs', label: '공사PM', type: 'multi', options: constructionPMOptions },
|
||||
{ key: 'orderManagers', label: '발주담당자', type: 'multi', options: orderManagerOptions },
|
||||
{ key: 'orderCompanies', label: '발주처', type: 'multi', options: orderCompanyOptions },
|
||||
{ key: 'workTeamLeaders', label: '작업반장', type: 'multi', options: workTeamOptions },
|
||||
{ key: 'orderTypes', label: '구분', type: 'multi', options: orderTypeOptions },
|
||||
{ key: 'status', label: '상태', type: 'single', options: ORDER_STATUS_OPTIONS.filter(opt => opt.value !== 'all') },
|
||||
{ key: 'sortBy', label: '정렬', type: 'single', options: ORDER_SORT_OPTIONS, allOptionLabel: '최신순' },
|
||||
] as FilterFieldConfig[],
|
||||
|
||||
// 커스텀 필터 적용 함수
|
||||
customFilterFn: (items: Order[], filterValues: Record<string, string | string[]>) => {
|
||||
return items.filter((order) => {
|
||||
// 거래처 필터
|
||||
const partners = filterValues.partners as string[] || [];
|
||||
if (partners.length > 0) {
|
||||
const matchingPartner = MOCK_PARTNERS.find((p) => p.label === order.partnerName);
|
||||
if (!matchingPartner || !partners.includes(matchingPartner.value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 현장명 필터
|
||||
const sites = filterValues.sites as string[] || [];
|
||||
if (sites.length > 0) {
|
||||
const matchingSite = MOCK_SITES.find((s) => s.label === order.siteName);
|
||||
if (!matchingSite || !sites.includes(matchingSite.value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 공사PM 필터
|
||||
const constructionPMs = filterValues.constructionPMs as string[] || [];
|
||||
if (constructionPMs.length > 0) {
|
||||
const matchingPM = MOCK_CONSTRUCTION_PM.find((p) => p.label === order.constructionPM);
|
||||
if (!matchingPM || !constructionPMs.includes(matchingPM.value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 발주담당자 필터
|
||||
const orderManagers = filterValues.orderManagers as string[] || [];
|
||||
if (orderManagers.length > 0) {
|
||||
const matchingManager = MOCK_ORDER_MANAGERS.find((m) => m.label === order.orderManager);
|
||||
if (!matchingManager || !orderManagers.includes(matchingManager.value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 발주처 필터
|
||||
const orderCompanies = filterValues.orderCompanies as string[] || [];
|
||||
if (orderCompanies.length > 0) {
|
||||
const matchingCompany = MOCK_ORDER_COMPANIES.find((c) => c.label === order.orderCompany);
|
||||
if (!matchingCompany || !orderCompanies.includes(matchingCompany.value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 작업반장 필터
|
||||
const workTeamLeaders = filterValues.workTeamLeaders as string[] || [];
|
||||
if (workTeamLeaders.length > 0) {
|
||||
const matchingLeader = MOCK_WORK_TEAM_LEADERS.find((l) => l.label === order.workTeamLeader);
|
||||
if (!matchingLeader || !workTeamLeaders.includes(matchingLeader.value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 구분 필터
|
||||
const orderTypes = filterValues.orderTypes as string[] || [];
|
||||
if (orderTypes.length > 0 && !orderTypes.includes(order.orderType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
const status = filterValues.status as string || 'all';
|
||||
if (status !== 'all' && order.status !== status) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 달력 날짜 필터 (selectedCalendarDate)
|
||||
if (selectedCalendarDate) {
|
||||
const orderStart = startOfDay(parseISO(order.periodStart));
|
||||
const orderEnd = startOfDay(parseISO(order.periodEnd));
|
||||
const selected = startOfDay(selectedCalendarDate);
|
||||
|
||||
if (selected < orderStart || selected > orderEnd) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
},
|
||||
|
||||
// 커스텀 정렬 함수
|
||||
customSortFn: (items: Order[], filterValues: Record<string, string | string[]>) => {
|
||||
const sortBy = filterValues.sortBy as string || 'latest';
|
||||
const sorted = [...items];
|
||||
|
||||
switch (sortBy) {
|
||||
case 'latest':
|
||||
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
break;
|
||||
case 'oldest':
|
||||
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
break;
|
||||
case 'partnerNameAsc':
|
||||
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
|
||||
break;
|
||||
case 'partnerNameDesc':
|
||||
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
|
||||
break;
|
||||
case 'siteNameAsc':
|
||||
sorted.sort((a, b) => a.siteName.localeCompare(b.siteName, 'ko'));
|
||||
break;
|
||||
case 'siteNameDesc':
|
||||
sorted.sort((a, b) => b.siteName.localeCompare(a.siteName, 'ko'));
|
||||
break;
|
||||
case 'deliveryDateAsc':
|
||||
sorted.sort((a, b) => a.plannedDeliveryDate.localeCompare(b.plannedDeliveryDate));
|
||||
break;
|
||||
case 'deliveryDateDesc':
|
||||
sorted.sort((a, b) => b.plannedDeliveryDate.localeCompare(a.plannedDeliveryDate));
|
||||
break;
|
||||
}
|
||||
|
||||
return sorted;
|
||||
},
|
||||
|
||||
// ===== 검색 설정 =====
|
||||
searchPlaceholder: '발주번호, 거래처, 현장명, 발주담당 검색',
|
||||
|
||||
// ===== 상세 보기 모드 =====
|
||||
detailMode: 'page',
|
||||
|
||||
// ===== 헤더 액션 =====
|
||||
headerActions: ({ onCreate }) => (
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
extraActions={
|
||||
<Button onClick={onCreate}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
발주 등록
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
),
|
||||
|
||||
// ===== 테이블 헤더 추가 액션 =====
|
||||
tableHeaderActions: (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{selectedCalendarDate && (
|
||||
<>
|
||||
<span className="text-sm text-primary">
|
||||
({format(selectedCalendarDate, 'M/d')} 필터 적용중)
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSelectedCalendarDate(null)}
|
||||
>
|
||||
날짜 필터 해제
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
|
||||
// ===== 삭제 확인 메시지 =====
|
||||
deleteConfirmMessage: {
|
||||
title: '발주 삭제',
|
||||
description: '선택한 발주를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.',
|
||||
},
|
||||
|
||||
// ===== 테이블 행 렌더링 =====
|
||||
renderTableRow: (
|
||||
item: Order,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<Order>
|
||||
) => {
|
||||
const { isSelected, onToggle, onRowClick, onEdit, onDelete } = handlers;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => onRowClick(item)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={onToggle}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell>{item.contractNumber}</TableCell>
|
||||
<TableCell>{item.partnerName}</TableCell>
|
||||
<TableCell>{item.siteName}</TableCell>
|
||||
<TableCell>{item.name}</TableCell>
|
||||
<TableCell>{item.constructionPM}</TableCell>
|
||||
<TableCell>{item.orderManager}</TableCell>
|
||||
<TableCell>{item.orderNumber}</TableCell>
|
||||
<TableCell>{item.orderCompany}</TableCell>
|
||||
<TableCell>{item.workTeamLeader}</TableCell>
|
||||
<TableCell>{formatDate(item.constructionStartDate)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className="px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
{ORDER_TYPE_LABELS[item.orderType]}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{item.item}</TableCell>
|
||||
<TableCell className="text-right">{item.quantity}</TableCell>
|
||||
<TableCell>{formatDate(item.orderDate)}</TableCell>
|
||||
<TableCell>{formatDate(item.plannedDeliveryDate)}</TableCell>
|
||||
<TableCell>{formatDate(item.actualDeliveryDate)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${ORDER_STATUS_STYLES[item.status]}`}>
|
||||
{ORDER_STATUS_LABELS[item.status]}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
{isSelected && (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onEdit?.(item)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={() => onDelete?.(item)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
|
||||
// ===== 모바일 카드 렌더링 =====
|
||||
renderMobileCard: (
|
||||
item: Order,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<Order>
|
||||
) => {
|
||||
const { isSelected, onToggle, onRowClick, onEdit, onDelete } = handlers;
|
||||
|
||||
return (
|
||||
<ListMobileCard
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
title={item.siteName}
|
||||
headerBadges={
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
#{globalIndex}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{item.orderNumber}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
statusBadge={
|
||||
<Badge className={ORDER_STATUS_STYLES[item.status]}>
|
||||
{ORDER_STATUS_LABELS[item.status]}
|
||||
</Badge>
|
||||
}
|
||||
isSelected={isSelected}
|
||||
onToggleSelection={onToggle}
|
||||
onCardClick={() => onRowClick(item)}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="거래처" value={item.partnerName} />
|
||||
<InfoField label="발주담당" value={item.orderManager} />
|
||||
<InfoField label="계획납품일" value={formatDate(item.plannedDeliveryDate)} />
|
||||
<InfoField label="구분" value={ORDER_TYPE_LABELS[item.orderType]} />
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
isSelected ? (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
variant="default"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11"
|
||||
onClick={(e) => { e.stopPropagation(); onEdit?.(item); }}
|
||||
>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-transparent"
|
||||
onClick={(e) => { e.stopPropagation(); onDelete?.(item); }}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
// ===== 테이블 전 콘텐츠 (달력) =====
|
||||
beforeTableContent: (
|
||||
<div className="w-full flex-shrink-0 mb-6">
|
||||
<ScheduleCalendar
|
||||
events={calendarEvents}
|
||||
badges={calendarBadges}
|
||||
currentDate={calendarDate}
|
||||
selectedDate={selectedCalendarDate}
|
||||
onDateClick={handleCalendarDateClick}
|
||||
onEventClick={handleCalendarEventClick}
|
||||
onMonthChange={handleCalendarMonthChange}
|
||||
titleSlot="발주 스케줄"
|
||||
filterSlot={calendarFilterSlot}
|
||||
maxEventsPerDay={5}
|
||||
weekStartsOn={0}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
|
||||
// ===== 추가 옵션 =====
|
||||
showCheckbox: true,
|
||||
showRowNumber: true,
|
||||
itemsPerPage: 20,
|
||||
}), [
|
||||
startDate,
|
||||
endDate,
|
||||
selectedCalendarDate,
|
||||
calendarDate,
|
||||
calendarEvents,
|
||||
calendarBadges,
|
||||
calendarFilterSlot,
|
||||
isLoading,
|
||||
handleCalendarDateClick,
|
||||
handleCalendarEventClick,
|
||||
handleCalendarMonthChange,
|
||||
partnerOptions,
|
||||
siteOptions,
|
||||
constructionPMOptions,
|
||||
orderManagerOptions,
|
||||
orderCompanyOptions,
|
||||
workTeamOptions,
|
||||
orderTypeOptions,
|
||||
router,
|
||||
]);
|
||||
|
||||
return (
|
||||
<UniversalListPage<Order>
|
||||
config={config}
|
||||
initialData={initialData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default OrderManagementUnified;
|
||||
@@ -1,37 +1,36 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 거래처 관리 - UniversalListPage 마이그레이션
|
||||
*
|
||||
* 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환
|
||||
* - 클라이언트 사이드 필터링 (검색, 필터, 정렬)
|
||||
* - 탭 필터링 (전체/신규)
|
||||
* - createButton (거래처 등록)
|
||||
* - filterConfig (single: 악성채권, 정렬)
|
||||
* - stats 카드 (클릭 없음, 정보 표시용)
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Building2, Plus, Pencil, Trash2, AlertTriangle } from 'lucide-react';
|
||||
import { Building2, AlertTriangle, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { IntegratedListTemplateV2, TabOption, TableColumn, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { MobileCard } from '@/components/molecules/MobileCard';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
type SelectionHandlers,
|
||||
type RowClickHandlers,
|
||||
type StatCard,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import type { Partner, PartnerStats } from './types';
|
||||
import { getPartnerList, deletePartner, deletePartners, getPartnerStats } from './actions';
|
||||
|
||||
// 테이블 컬럼 정의
|
||||
const tableColumns: TableColumn[] = [
|
||||
const tableColumns = [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'partnerCode', label: '거래처번호', className: 'w-[100px]' },
|
||||
{ key: 'category', label: '구분', className: 'w-[80px] text-center' },
|
||||
@@ -52,157 +51,26 @@ interface PartnerListClientProps {
|
||||
export default function PartnerListClient({ initialData = [], initialStats }: PartnerListClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// 상태
|
||||
const [partners, setPartners] = useState<Partner[]>(initialData);
|
||||
// ===== 외부 상태 =====
|
||||
const [stats, setStats] = useState<PartnerStats>(
|
||||
initialStats ?? { total: 0, unregistered: 0, badDebt: 0, normal: 0 }
|
||||
);
|
||||
const [activeTab, setActiveTab] = useState('all');
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [badDebtFilter, setBadDebtFilter] = useState<'all' | 'badDebt' | 'normal'>('all');
|
||||
const [sortBy, setSortBy] = useState<'latest' | 'oldest' | 'nameAsc' | 'nameDesc'>('latest');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [listResult, statsResult] = await Promise.all([
|
||||
getPartnerList({ size: 1000, badDebtFilter, sortBy }),
|
||||
getPartnerStats(),
|
||||
]);
|
||||
|
||||
if (listResult.success && listResult.data) {
|
||||
setPartners(listResult.data.items);
|
||||
}
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStats(statsResult.data);
|
||||
}
|
||||
} catch {
|
||||
toast.error('데이터 로드에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [badDebtFilter, sortBy]);
|
||||
|
||||
// 초기 데이터가 없으면 로드
|
||||
// Stats 로드
|
||||
useEffect(() => {
|
||||
if (initialData.length === 0) {
|
||||
loadData();
|
||||
}
|
||||
}, [initialData.length, loadData]);
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredPartners = useMemo(() => {
|
||||
return partners.filter((partner) => {
|
||||
// 탭 필터 (전체/신규)
|
||||
if (activeTab === 'new') {
|
||||
// 신규 조건 (예: 최근 7일 이내 등록)
|
||||
const sevenDaysAgo = new Date();
|
||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||
if (new Date(partner.createdAt) < sevenDaysAgo) {
|
||||
return false;
|
||||
if (!initialStats) {
|
||||
getPartnerStats().then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setStats(result.data);
|
||||
}
|
||||
}
|
||||
|
||||
// 악성채권 필터
|
||||
if (badDebtFilter === 'badDebt' && !partner.isBadDebt) {
|
||||
return false;
|
||||
}
|
||||
if (badDebtFilter === 'normal' && partner.isBadDebt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if (searchValue) {
|
||||
const search = searchValue.toLowerCase();
|
||||
return (
|
||||
partner.partnerCode.toLowerCase().includes(search) ||
|
||||
partner.partnerName.toLowerCase().includes(search) ||
|
||||
partner.representative.toLowerCase().includes(search) ||
|
||||
partner.manager.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [partners, activeTab, badDebtFilter, searchValue]);
|
||||
|
||||
// 정렬
|
||||
const sortedPartners = useMemo(() => {
|
||||
const sorted = [...filteredPartners];
|
||||
switch (sortBy) {
|
||||
case 'latest':
|
||||
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
break;
|
||||
case 'oldest':
|
||||
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
break;
|
||||
case 'nameAsc':
|
||||
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName));
|
||||
break;
|
||||
case 'nameDesc':
|
||||
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName));
|
||||
break;
|
||||
});
|
||||
}
|
||||
return sorted;
|
||||
}, [filteredPartners, sortBy]);
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(sortedPartners.length / itemsPerPage);
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (currentPage - 1) * itemsPerPage;
|
||||
return sortedPartners.slice(start, start + itemsPerPage);
|
||||
}, [sortedPartners, currentPage, itemsPerPage]);
|
||||
|
||||
// 탭 옵션
|
||||
const tabOptions: TabOption[] = useMemo(
|
||||
() => [
|
||||
{ value: 'all', label: '전체', count: stats.total },
|
||||
{ value: 'new', label: '신규', count: stats.unregistered },
|
||||
],
|
||||
[stats]
|
||||
);
|
||||
|
||||
// 핸들러
|
||||
const handleTabChange = useCallback((value: string) => {
|
||||
setActiveTab(value);
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchValue(value);
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const handleToggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggleSelectAll = useCallback(() => {
|
||||
if (selectedItems.size === paginatedData.length) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(paginatedData.map((p) => p.id)));
|
||||
}
|
||||
}, [selectedItems.size, paginatedData]);
|
||||
}, [initialStats]);
|
||||
|
||||
// ===== 핸들러 =====
|
||||
const handleRowClick = useCallback(
|
||||
(partner: Partner) => {
|
||||
router.push(`/ko/construction/project/bidding/partners/${partner.id}`);
|
||||
(item: Partner) => {
|
||||
router.push(`/ko/construction/project/bidding/partners/${item.id}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
@@ -212,124 +80,217 @@ export default function PartnerListClient({ initialData = [], initialStats }: Pa
|
||||
}, [router]);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(e: React.MouseEvent, partnerId: string) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/ko/construction/project/bidding/partners/${partnerId}/edit`);
|
||||
(item: Partner) => {
|
||||
router.push(`/ko/construction/project/bidding/partners/${item.id}/edit`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleDeleteClick = useCallback((e: React.MouseEvent, partnerId: string) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTargetId(partnerId);
|
||||
setDeleteDialogOpen(true);
|
||||
}, []);
|
||||
// ===== UniversalListPage Config =====
|
||||
const config: UniversalListConfig<Partner> = useMemo(
|
||||
() => ({
|
||||
// 페이지 기본 정보
|
||||
title: '거래처 관리',
|
||||
description: '거래처 정보를 관리합니다',
|
||||
icon: Building2,
|
||||
basePath: '/construction/project/bidding/partners',
|
||||
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
if (!deleteTargetId) return;
|
||||
// ID 추출
|
||||
idField: 'id',
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await deletePartner(deleteTargetId);
|
||||
if (result.success) {
|
||||
toast.success('거래처가 삭제되었습니다.');
|
||||
setPartners((prev) => prev.filter((p) => p.id !== deleteTargetId));
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(deleteTargetId);
|
||||
return newSet;
|
||||
});
|
||||
// 통계 재조회
|
||||
const statsResult = await getPartnerStats();
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStats(statsResult.data);
|
||||
// API 액션
|
||||
actions: {
|
||||
getList: async () => {
|
||||
const result = await getPartnerList({ size: 1000 });
|
||||
if (result.success && result.data) {
|
||||
return {
|
||||
success: true,
|
||||
data: result.data.items,
|
||||
totalCount: result.data.total,
|
||||
};
|
||||
}
|
||||
return { success: false, error: result.error };
|
||||
},
|
||||
deleteItem: async (id: string) => {
|
||||
const result = await deletePartner(id);
|
||||
return { success: result.success, error: result.error };
|
||||
},
|
||||
deleteBulk: async (ids: string[]) => {
|
||||
const result = await deletePartners(ids);
|
||||
return { success: result.success, error: result.error };
|
||||
},
|
||||
},
|
||||
|
||||
// 테이블 컬럼
|
||||
columns: tableColumns,
|
||||
|
||||
// 클라이언트 사이드 필터링
|
||||
clientSideFiltering: true,
|
||||
itemsPerPage: 20,
|
||||
|
||||
// 탭 설정
|
||||
tabs: [
|
||||
{ value: 'all', label: '전체', count: stats.total },
|
||||
{ value: 'new', label: '신규', count: stats.unregistered },
|
||||
],
|
||||
defaultTab: 'all',
|
||||
|
||||
// 탭 필터
|
||||
tabFilter: (item, activeTab) => {
|
||||
if (activeTab === 'new') {
|
||||
const sevenDaysAgo = new Date();
|
||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||
return new Date(item.createdAt) >= sevenDaysAgo;
|
||||
}
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setDeleteDialogOpen(false);
|
||||
setDeleteTargetId(null);
|
||||
}
|
||||
}, [deleteTargetId]);
|
||||
return true;
|
||||
},
|
||||
|
||||
const handleBulkDeleteClick = useCallback(() => {
|
||||
if (selectedItems.size === 0) {
|
||||
toast.warning('삭제할 항목을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
setBulkDeleteDialogOpen(true);
|
||||
}, [selectedItems.size]);
|
||||
// 검색 필터
|
||||
searchPlaceholder: '거래처명, 거래처번호, 대표자 검색',
|
||||
searchFilter: (item, searchValue) => {
|
||||
const search = searchValue.toLowerCase();
|
||||
return (
|
||||
item.partnerCode.toLowerCase().includes(search) ||
|
||||
item.partnerName.toLowerCase().includes(search) ||
|
||||
item.representative.toLowerCase().includes(search) ||
|
||||
item.manager.toLowerCase().includes(search)
|
||||
);
|
||||
},
|
||||
|
||||
const handleBulkDeleteConfirm = useCallback(async () => {
|
||||
if (selectedItems.size === 0) return;
|
||||
// 필터 설정
|
||||
filterConfig: [
|
||||
{
|
||||
key: 'badDebt',
|
||||
label: '악성채권',
|
||||
type: 'single',
|
||||
options: [
|
||||
{ value: 'badDebt', label: '악성채권' },
|
||||
{ value: 'normal', label: '정상' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'sortBy',
|
||||
label: '정렬',
|
||||
type: 'single',
|
||||
options: [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '등록순' },
|
||||
{ value: 'nameAsc', label: '이름 오름차순' },
|
||||
{ value: 'nameDesc', label: '이름 내림차순' },
|
||||
],
|
||||
},
|
||||
],
|
||||
initialFilters: {
|
||||
badDebt: 'all',
|
||||
sortBy: 'latest',
|
||||
},
|
||||
filterTitle: '거래처 필터',
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const ids = Array.from(selectedItems);
|
||||
const result = await deletePartners(ids);
|
||||
if (result.success) {
|
||||
toast.success(`${result.deletedCount}개 항목이 삭제되었습니다.`);
|
||||
await loadData();
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
toast.error(result.error || '일괄 삭제에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('일괄 삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setBulkDeleteDialogOpen(false);
|
||||
}
|
||||
}, [selectedItems, loadData]);
|
||||
// 커스텀 필터 함수
|
||||
customFilterFn: (items, filterValues) => {
|
||||
return items.filter((item) => {
|
||||
// 악성채권 필터
|
||||
const badDebtFilter = filterValues.badDebt as string;
|
||||
if (badDebtFilter === 'badDebt' && !item.isBadDebt) return false;
|
||||
if (badDebtFilter === 'normal' && item.isBadDebt) return false;
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = useCallback(
|
||||
(partner: Partner, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(partner.id);
|
||||
return true;
|
||||
});
|
||||
},
|
||||
|
||||
return (
|
||||
// 커스텀 정렬 함수
|
||||
customSortFn: (items, filterValues) => {
|
||||
const sorted = [...items];
|
||||
const sortBy = (filterValues.sortBy as string) || 'latest';
|
||||
|
||||
switch (sortBy) {
|
||||
case 'latest':
|
||||
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
break;
|
||||
case 'oldest':
|
||||
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
break;
|
||||
case 'nameAsc':
|
||||
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
|
||||
break;
|
||||
case 'nameDesc':
|
||||
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
|
||||
break;
|
||||
}
|
||||
return sorted;
|
||||
},
|
||||
|
||||
// 공통 헤더 옵션
|
||||
createButton: {
|
||||
label: '거래처 등록',
|
||||
onClick: handleCreate,
|
||||
},
|
||||
|
||||
// Stats 카드 (정보 표시용, 클릭 없음)
|
||||
computeStats: (): StatCard[] => [
|
||||
{
|
||||
label: '전체 거래처',
|
||||
value: stats.total,
|
||||
icon: Building2,
|
||||
iconColor: 'text-blue-500',
|
||||
},
|
||||
{
|
||||
label: '미등록',
|
||||
value: stats.unregistered,
|
||||
icon: AlertTriangle,
|
||||
iconColor: 'text-orange-500',
|
||||
},
|
||||
],
|
||||
|
||||
// 삭제 확인 메시지
|
||||
deleteConfirmMessage: {
|
||||
title: '거래처 삭제',
|
||||
description: '선택한 거래처를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.',
|
||||
},
|
||||
|
||||
// 테이블 행 렌더링
|
||||
renderTableRow: (
|
||||
item: Partner,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<Partner>
|
||||
) => (
|
||||
<TableRow
|
||||
key={partner.id}
|
||||
key={item.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleRowClick(partner)}
|
||||
onClick={() => handleRowClick(item)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleSelection(partner.id)}
|
||||
/>
|
||||
<Checkbox checked={handlers.isSelected} onCheckedChange={handlers.onToggle} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell className="font-medium">{partner.partnerCode}</TableCell>
|
||||
<TableCell className="font-medium">{item.partnerCode}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant="secondary">{partner.category}</Badge>
|
||||
<Badge variant="secondary">{item.category}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{partner.partnerName}</TableCell>
|
||||
<TableCell>{partner.representative}</TableCell>
|
||||
<TableCell>{partner.manager}</TableCell>
|
||||
<TableCell>{partner.phone}</TableCell>
|
||||
<TableCell className="font-medium">{item.partnerName}</TableCell>
|
||||
<TableCell>{item.representative}</TableCell>
|
||||
<TableCell>{item.manager}</TableCell>
|
||||
<TableCell>{item.phone}</TableCell>
|
||||
<TableCell className="text-center">{item.paymentDay ? `${item.paymentDay}일` : '-'}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{partner.paymentDay ? `${partner.paymentDay}일` : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{partner.isBadDebt ? (
|
||||
{item.isBadDebt ? (
|
||||
<Badge variant="destructive">악성채권</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{isSelected && (
|
||||
{handlers.isSelected && (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => handleEdit(e, partner.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(item);
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -337,7 +298,10 @@ export default function PartnerListClient({ initialData = [], initialStats }: Pa
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={(e) => handleDeleteClick(e, partner.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlers.onDelete?.(item);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -345,212 +309,36 @@ export default function PartnerListClient({ initialData = [], initialStats }: Pa
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
[selectedItems, handleToggleSelection, handleRowClick, handleEdit, handleDeleteClick]
|
||||
);
|
||||
),
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = useCallback(
|
||||
(partner: Partner, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
|
||||
return (
|
||||
// 모바일 카드 렌더링
|
||||
renderMobileCard: (
|
||||
item: Partner,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<Partner>
|
||||
) => (
|
||||
<MobileCard
|
||||
title={partner.partnerName}
|
||||
subtitle={partner.partnerCode}
|
||||
badge={partner.isBadDebt ? '악성채권' : undefined}
|
||||
badgeVariant={partner.isBadDebt ? 'destructive' : 'secondary'}
|
||||
isSelected={isSelected}
|
||||
onToggle={onToggle}
|
||||
onClick={() => handleRowClick(partner)}
|
||||
key={item.id}
|
||||
title={item.partnerName}
|
||||
subtitle={item.partnerCode}
|
||||
badge={item.isBadDebt ? '악성채권' : undefined}
|
||||
badgeVariant={item.isBadDebt ? 'destructive' : 'secondary'}
|
||||
isSelected={handlers.isSelected}
|
||||
onToggle={handlers.onToggle}
|
||||
onClick={() => handleRowClick(item)}
|
||||
details={[
|
||||
{ label: '구분', value: partner.category },
|
||||
{ label: '대표자', value: partner.representative },
|
||||
{ label: '담당자', value: partner.manager },
|
||||
{ label: '전화번호', value: partner.phone },
|
||||
{ label: '매출 결제일', value: partner.paymentDay ? `${partner.paymentDay}일` : '-' },
|
||||
{ label: '구분', value: item.category },
|
||||
{ label: '대표자', value: item.representative },
|
||||
{ label: '담당자', value: item.manager },
|
||||
{ label: '전화번호', value: item.phone },
|
||||
{ label: '매출 결제일', value: item.paymentDay ? `${item.paymentDay}일` : '-' },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[handleRowClick]
|
||||
),
|
||||
}),
|
||||
[stats, handleRowClick, handleCreate, handleEdit]
|
||||
);
|
||||
|
||||
// ===== filterConfig 기반 통합 필터 시스템 =====
|
||||
const filterConfig: FilterFieldConfig[] = useMemo(() => [
|
||||
{
|
||||
key: 'badDebt',
|
||||
label: '악성채권',
|
||||
type: 'single',
|
||||
options: [
|
||||
{ value: 'badDebt', label: '악성채권' },
|
||||
{ value: 'normal', label: '정상' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'sortBy',
|
||||
label: '정렬',
|
||||
type: 'single',
|
||||
options: [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '등록순' },
|
||||
{ value: 'nameAsc', label: '이름 오름차순' },
|
||||
{ value: 'nameDesc', label: '이름 내림차순' },
|
||||
],
|
||||
allOptionLabel: '최신순',
|
||||
},
|
||||
], []);
|
||||
|
||||
const filterValues: FilterValues = useMemo(() => ({
|
||||
badDebt: badDebtFilter,
|
||||
sortBy: sortBy,
|
||||
}), [badDebtFilter, sortBy]);
|
||||
|
||||
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
|
||||
switch (key) {
|
||||
case 'badDebt':
|
||||
setBadDebtFilter(value as 'all' | 'badDebt' | 'normal');
|
||||
break;
|
||||
case 'sortBy':
|
||||
setSortBy(value as 'latest' | 'oldest' | 'nameAsc' | 'nameDesc');
|
||||
break;
|
||||
}
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const handleFilterReset = useCallback(() => {
|
||||
setBadDebtFilter('all');
|
||||
setSortBy('latest');
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
// 헤더 액션 (등록 버튼만)
|
||||
const headerActions = (
|
||||
<div className="flex items-center justify-end w-full">
|
||||
<Button onClick={handleCreate}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
거래처 등록
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 테이블 헤더 액션 (총 건수 + 필터들)
|
||||
const tableHeaderActions = (
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 총 건수 */}
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
총 {sortedPartners.length}건
|
||||
</span>
|
||||
|
||||
{/* 악성채권 필터 */}
|
||||
<Select value={badDebtFilter} onValueChange={(v) => setBadDebtFilter(v as typeof badDebtFilter)}>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
<SelectItem value="badDebt">악성채권</SelectItem>
|
||||
<SelectItem value="normal">정상</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 정렬 */}
|
||||
<Select value={sortBy} onValueChange={(v) => setSortBy(v as typeof sortBy)}>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="최신순" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="latest">최신순</SelectItem>
|
||||
<SelectItem value="oldest">등록순</SelectItem>
|
||||
<SelectItem value="nameAsc">이름 오름차순</SelectItem>
|
||||
<SelectItem value="nameDesc">이름 내림차순</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedListTemplateV2
|
||||
title="거래처 관리"
|
||||
description="거래처 정보를 관리합니다"
|
||||
icon={Building2}
|
||||
headerActions={headerActions}
|
||||
stats={[
|
||||
{
|
||||
label: '전체 거래처',
|
||||
value: stats.total,
|
||||
icon: Building2,
|
||||
iconColor: 'text-blue-500',
|
||||
},
|
||||
{
|
||||
label: '미등록',
|
||||
value: stats.unregistered,
|
||||
icon: AlertTriangle,
|
||||
iconColor: 'text-orange-500',
|
||||
},
|
||||
]}
|
||||
tabs={tabOptions}
|
||||
activeTab={activeTab}
|
||||
onTabChange={handleTabChange}
|
||||
tableHeaderActions={tableHeaderActions}
|
||||
filterConfig={filterConfig}
|
||||
filterValues={filterValues}
|
||||
onFilterChange={handleFilterChange}
|
||||
onFilterReset={handleFilterReset}
|
||||
filterTitle="거래처 필터"
|
||||
searchValue={searchValue}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="거래처명, 거래처번호, 대표자 검색"
|
||||
tableColumns={tableColumns}
|
||||
data={paginatedData}
|
||||
allData={sortedPartners}
|
||||
getItemId={(item) => item.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
onBulkDelete={handleBulkDeleteClick}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: sortedPartners.length,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 단일 삭제 다이얼로그 */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>거래처 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 거래처를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteConfirm}>삭제</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 일괄 삭제 다이얼로그 */}
|
||||
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>거래처 일괄 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 {selectedItems.size}개 거래처를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleBulkDeleteConfirm}>삭제</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <UniversalListPage config={config} initialData={initialData} />;
|
||||
}
|
||||
|
||||
@@ -1,26 +1,32 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 단가관리 - UniversalListPage 마이그레이션
|
||||
*
|
||||
* 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환
|
||||
* - 클라이언트 사이드 필터링 (검색, 필터, 정렬)
|
||||
* - Stats 카드 클릭 필터링 (activeStatTab)
|
||||
* - DateRangeSelector + 등록 버튼 (dateRangeSelector + createButton)
|
||||
* - 동적 컬럼 (renderCustomTableHeader + onDataChange)
|
||||
* - filterConfig (single: 품목유형, 카테고리, 규격, 구분, 상태, 정렬)
|
||||
* - 삭제 기능 (deleteConfirmMessage로 AlertDialog 대체)
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { DollarSign, Package, CheckCircle, AlertCircle, Pencil, Trash2, Plus } from 'lucide-react';
|
||||
import { DollarSign, Package, CheckCircle, AlertCircle, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TableCell, TableRow, TableHead } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { IntegratedListTemplateV2, StatCard, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { MobileCard } from '@/components/molecules/MobileCard';
|
||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
type SelectionHandlers,
|
||||
type RowClickHandlers,
|
||||
type StatCard,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import type { Pricing, PricingStats, OrderItem } from './types';
|
||||
import {
|
||||
ITEM_TYPE_OPTIONS,
|
||||
@@ -50,157 +56,47 @@ export default function PricingListClient({
|
||||
}: PricingListClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// 상태
|
||||
const [pricingList, setPricingList] = useState<Pricing[]>(initialData);
|
||||
const [stats, setStats] = useState<PricingStats | null>(initialStats || null);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [itemTypeFilter, setItemTypeFilter] = useState<string>('all');
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('all');
|
||||
const [specFilter, setSpecFilter] = useState<string>('all');
|
||||
const [divisionFilter, setDivisionFilter] = useState<string>('all');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [sortBy, setSortBy] = useState<string>('latest');
|
||||
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
|
||||
const [activeStatTab, setActiveStatTab] = useState<'all' | 'in_use' | 'not_registered'>('all');
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
|
||||
const [activeStatTab, setActiveStatTab] = useState<'all' | 'in_use' | 'not_registered'>('all');
|
||||
const itemsPerPage = 20;
|
||||
const [stats, setStats] = useState<PricingStats | null>(initialStats || null);
|
||||
const [pricingData, setPricingData] = useState<Pricing[]>(initialData);
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [listResult, statsResult] = await Promise.all([
|
||||
getPricingList({
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
itemType: itemTypeFilter,
|
||||
category: categoryFilter,
|
||||
spec: specFilter,
|
||||
division: divisionFilter,
|
||||
status: statusFilter,
|
||||
sort: sortBy,
|
||||
search: searchValue,
|
||||
}),
|
||||
getPricingStats(),
|
||||
]);
|
||||
|
||||
if (listResult.success && listResult.data) {
|
||||
setPricingList(listResult.data.items);
|
||||
}
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStats(statsResult.data);
|
||||
}
|
||||
} catch {
|
||||
toast.error('데이터 로드에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [startDate, endDate, itemTypeFilter, categoryFilter, specFilter, divisionFilter, statusFilter, sortBy, searchValue]);
|
||||
|
||||
// 초기 데이터가 없으면 로드
|
||||
// Stats 로드
|
||||
useEffect(() => {
|
||||
if (initialData.length === 0) {
|
||||
loadData();
|
||||
if (!initialStats) {
|
||||
getPricingStats().then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setStats(result.data);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [initialData.length, loadData]);
|
||||
}, [initialStats]);
|
||||
|
||||
// 동적 항목명 컬럼 추출
|
||||
// 동적 항목명 컬럼 추출 (전체 데이터 기반)
|
||||
const dynamicOrderItemColumns = useMemo(() => {
|
||||
const columnSet = new Set<string>();
|
||||
pricingList.forEach((pricing) => {
|
||||
pricingData.forEach((pricing) => {
|
||||
pricing.orderItems.forEach((item) => {
|
||||
columnSet.add(item.name);
|
||||
});
|
||||
});
|
||||
return Array.from(columnSet);
|
||||
}, [pricingList]);
|
||||
}, [pricingData]);
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredPricing = useMemo(() => {
|
||||
return pricingList.filter((pricing) => {
|
||||
// 상태 탭 필터
|
||||
if (activeStatTab === 'in_use' && pricing.status !== 'in_use') return false;
|
||||
if (activeStatTab === 'not_registered' && pricing.status !== 'not_registered') return false;
|
||||
|
||||
// 품목유형 필터
|
||||
if (itemTypeFilter !== 'all') {
|
||||
const typeMap: Record<string, string> = {
|
||||
box: '박스',
|
||||
parts: '부속',
|
||||
consumables: '소모품',
|
||||
utility: '공과',
|
||||
};
|
||||
if (pricing.itemType !== typeMap[itemTypeFilter]) return false;
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if (searchValue) {
|
||||
const search = searchValue.toLowerCase();
|
||||
return (
|
||||
pricing.pricingNumber.toLowerCase().includes(search) ||
|
||||
pricing.itemName.toLowerCase().includes(search) ||
|
||||
pricing.category.toLowerCase().includes(search) ||
|
||||
pricing.vendor.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [pricingList, activeStatTab, itemTypeFilter, searchValue]);
|
||||
|
||||
// 정렬
|
||||
const sortedPricing = useMemo(() => {
|
||||
const sorted = [...filteredPricing];
|
||||
if (sortBy === 'oldest') {
|
||||
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
} else if (sortBy === 'price_high') {
|
||||
sorted.sort((a, b) => b.sellingPrice - a.sellingPrice);
|
||||
} else if (sortBy === 'price_low') {
|
||||
sorted.sort((a, b) => a.sellingPrice - b.sellingPrice);
|
||||
} else {
|
||||
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
}
|
||||
return sorted;
|
||||
}, [filteredPricing, sortBy]);
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(sortedPricing.length / itemsPerPage);
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (currentPage - 1) * itemsPerPage;
|
||||
return sortedPricing.slice(start, start + itemsPerPage);
|
||||
}, [sortedPricing, currentPage, itemsPerPage]);
|
||||
|
||||
// 핸들러
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchValue(value);
|
||||
setCurrentPage(1);
|
||||
// 동적 항목값 가져오기
|
||||
const getOrderItemValue = useCallback((orderItems: OrderItem[], columnName: string): string => {
|
||||
const item = orderItems.find((oi) => oi.name === columnName);
|
||||
return item?.value || '-';
|
||||
}, []);
|
||||
|
||||
const handleToggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
// 숫자 포맷
|
||||
const formatNumber = useCallback((num: number) => {
|
||||
return num.toLocaleString('ko-KR');
|
||||
}, []);
|
||||
|
||||
const handleToggleSelectAll = useCallback(() => {
|
||||
if (selectedItems.size === paginatedData.length) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(paginatedData.map((p) => p.id)));
|
||||
}
|
||||
}, [selectedItems.size, paginatedData]);
|
||||
|
||||
// ===== 핸들러 =====
|
||||
const handleRowClick = useCallback(
|
||||
(pricing: Pricing) => {
|
||||
router.push(`/ko/construction/order/base-info/pricing/${pricing.id}`);
|
||||
@@ -209,140 +105,293 @@ export default function PricingListClient({
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(e: React.MouseEvent, pricingId: string) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/ko/construction/order/base-info/pricing/${pricingId}/edit`);
|
||||
(pricing: Pricing) => {
|
||||
router.push(`/ko/construction/order/base-info/pricing/${pricing.id}/edit`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleDeleteClick = useCallback((e: React.MouseEvent, pricingId: string) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTargetId(pricingId);
|
||||
setDeleteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
if (!deleteTargetId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await deletePricing(deleteTargetId);
|
||||
if (result.success) {
|
||||
toast.success('단가가 삭제되었습니다.');
|
||||
setPricingList((prev) => prev.filter((p) => p.id !== deleteTargetId));
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(deleteTargetId);
|
||||
return newSet;
|
||||
});
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setDeleteDialogOpen(false);
|
||||
setDeleteTargetId(null);
|
||||
}
|
||||
}, [deleteTargetId]);
|
||||
|
||||
const handleBulkDeleteClick = useCallback(() => {
|
||||
if (selectedItems.size === 0) {
|
||||
toast.warning('삭제할 항목을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
setBulkDeleteDialogOpen(true);
|
||||
}, [selectedItems.size]);
|
||||
|
||||
const handleBulkDeleteConfirm = useCallback(async () => {
|
||||
if (selectedItems.size === 0) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const ids = Array.from(selectedItems);
|
||||
const result = await deletePricings(ids);
|
||||
if (result.success) {
|
||||
toast.success(`${result.deletedCount}개 항목이 삭제되었습니다.`);
|
||||
await loadData();
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
toast.error(result.error || '일괄 삭제에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('일괄 삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setBulkDeleteDialogOpen(false);
|
||||
}
|
||||
}, [selectedItems, loadData]);
|
||||
|
||||
const handleRegister = useCallback(() => {
|
||||
const handleCreate = useCallback(() => {
|
||||
router.push('/ko/construction/order/base-info/pricing/new');
|
||||
}, [router]);
|
||||
|
||||
// 숫자 포맷
|
||||
const formatNumber = (num: number) => {
|
||||
return num.toLocaleString('ko-KR');
|
||||
};
|
||||
// ===== UniversalListPage Config =====
|
||||
const config: UniversalListConfig<Pricing> = useMemo(
|
||||
() => ({
|
||||
// 페이지 기본 정보
|
||||
title: '단가관리',
|
||||
description: '단가를 등록하고 관리합니다',
|
||||
icon: DollarSign,
|
||||
basePath: '/construction/order/base-info/pricing',
|
||||
|
||||
// 동적 항목값 가져오기
|
||||
const getOrderItemValue = (orderItems: OrderItem[], columnName: string): string => {
|
||||
const item = orderItems.find((oi) => oi.name === columnName);
|
||||
return item?.value || '-';
|
||||
};
|
||||
// ID 추출
|
||||
idField: 'id',
|
||||
|
||||
// 커스텀 테이블 헤더 렌더링
|
||||
const renderCustomTableHeader = useCallback(() => {
|
||||
return (
|
||||
<>
|
||||
<TableHead className="w-[40px] text-center">
|
||||
<Checkbox
|
||||
checked={selectedItems.size === paginatedData.length && paginatedData.length > 0}
|
||||
onCheckedChange={handleToggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-[50px] text-center">번호</TableHead>
|
||||
<TableHead className="w-[120px]">단가번호</TableHead>
|
||||
<TableHead className="w-[80px]">품목유형</TableHead>
|
||||
<TableHead className="w-[140px]">카테고리</TableHead>
|
||||
<TableHead className="min-w-[120px]">품목명</TableHead>
|
||||
<TableHead className="w-[100px]">규격</TableHead>
|
||||
{/* 동적 항목명 컬럼 */}
|
||||
{dynamicOrderItemColumns.map((colName) => (
|
||||
<TableHead key={colName} className="w-[80px]">
|
||||
{colName}
|
||||
// API 액션
|
||||
actions: {
|
||||
getList: async () => {
|
||||
const result = await getPricingList({
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
});
|
||||
if (result.success && result.data) {
|
||||
return {
|
||||
success: true,
|
||||
data: result.data.items,
|
||||
totalCount: result.data.total,
|
||||
};
|
||||
}
|
||||
return { success: false, error: result.error };
|
||||
},
|
||||
deleteItem: async (id: string) => {
|
||||
const result = await deletePricing(id);
|
||||
return { success: result.success, error: result.error };
|
||||
},
|
||||
deleteBulk: async (ids: string[]) => {
|
||||
const result = await deletePricings(ids);
|
||||
return { success: result.success, error: result.error };
|
||||
},
|
||||
},
|
||||
|
||||
// 빈 테이블 컬럼 (동적 컬럼은 renderCustomTableHeader에서 처리)
|
||||
columns: [],
|
||||
|
||||
// 클라이언트 사이드 필터링
|
||||
clientSideFiltering: true,
|
||||
itemsPerPage: 20,
|
||||
|
||||
// 데이터 변경 콜백 (동적 컬럼 계산용)
|
||||
onDataChange: (data) => setPricingData(data),
|
||||
|
||||
// 검색 필터
|
||||
searchPlaceholder: '단가번호, 품목명, 카테고리, 거래처 검색',
|
||||
searchFilter: (item, searchValue) => {
|
||||
const search = searchValue.toLowerCase();
|
||||
return (
|
||||
item.pricingNumber.toLowerCase().includes(search) ||
|
||||
item.itemName.toLowerCase().includes(search) ||
|
||||
item.category.toLowerCase().includes(search) ||
|
||||
item.vendor.toLowerCase().includes(search)
|
||||
);
|
||||
},
|
||||
|
||||
// 필터 설정
|
||||
filterConfig: [
|
||||
{
|
||||
key: 'itemType',
|
||||
label: '품목유형',
|
||||
type: 'single',
|
||||
options: ITEM_TYPE_OPTIONS.filter((o) => o.value !== 'all'),
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
label: '카테고리',
|
||||
type: 'single',
|
||||
options: CATEGORY_OPTIONS.filter((o) => o.value !== 'all'),
|
||||
},
|
||||
{
|
||||
key: 'spec',
|
||||
label: '규격',
|
||||
type: 'single',
|
||||
options: SPEC_OPTIONS.filter((o) => o.value !== 'all'),
|
||||
},
|
||||
{
|
||||
key: 'division',
|
||||
label: '구분',
|
||||
type: 'single',
|
||||
options: DIVISION_OPTIONS.filter((o) => o.value !== 'all'),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'single',
|
||||
options: STATUS_OPTIONS.filter((o) => o.value !== 'all'),
|
||||
},
|
||||
{
|
||||
key: 'sortBy',
|
||||
label: '정렬',
|
||||
type: 'single',
|
||||
options: SORT_OPTIONS,
|
||||
},
|
||||
],
|
||||
initialFilters: {
|
||||
itemType: 'all',
|
||||
category: 'all',
|
||||
spec: 'all',
|
||||
division: 'all',
|
||||
status: 'all',
|
||||
sortBy: 'latest',
|
||||
},
|
||||
filterTitle: '단가 필터',
|
||||
|
||||
// 커스텀 필터 함수
|
||||
customFilterFn: (items, filterValues) => {
|
||||
return items.filter((item) => {
|
||||
// Stats 탭 필터
|
||||
if (activeStatTab === 'in_use' && item.status !== 'in_use') return false;
|
||||
if (activeStatTab === 'not_registered' && item.status !== 'not_registered') return false;
|
||||
|
||||
// 품목유형 필터
|
||||
const itemTypeFilter = filterValues.itemType as string;
|
||||
if (itemTypeFilter && itemTypeFilter !== 'all') {
|
||||
const typeMap: Record<string, string> = {
|
||||
box: '박스',
|
||||
parts: '부속',
|
||||
consumables: '소모품',
|
||||
utility: '공과',
|
||||
};
|
||||
if (item.itemType !== typeMap[itemTypeFilter]) return false;
|
||||
}
|
||||
|
||||
// 카테고리 필터
|
||||
const categoryFilter = filterValues.category as string;
|
||||
if (categoryFilter && categoryFilter !== 'all') {
|
||||
const categoryMap: Record<string, string> = {
|
||||
steel: '철물',
|
||||
electrical: '전기',
|
||||
plumbing: '배관',
|
||||
interior: '내장',
|
||||
};
|
||||
if (item.category !== categoryMap[categoryFilter]) return false;
|
||||
}
|
||||
|
||||
// 규격 필터
|
||||
const specFilter = filterValues.spec as string;
|
||||
if (specFilter && specFilter !== 'all' && item.spec !== specFilter) return false;
|
||||
|
||||
// 구분 필터
|
||||
const divisionFilter = filterValues.division as string;
|
||||
if (divisionFilter && divisionFilter !== 'all') {
|
||||
const divisionMap: Record<string, string> = {
|
||||
domestic: '국산',
|
||||
imported: '수입',
|
||||
};
|
||||
if (item.division !== divisionMap[divisionFilter]) return false;
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
const statusFilter = filterValues.status as string;
|
||||
if (statusFilter && statusFilter !== 'all' && item.status !== statusFilter) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
},
|
||||
|
||||
// 커스텀 정렬 함수
|
||||
customSortFn: (items, filterValues) => {
|
||||
const sorted = [...items];
|
||||
const sortBy = (filterValues.sortBy as string) || 'latest';
|
||||
|
||||
switch (sortBy) {
|
||||
case 'oldest':
|
||||
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
break;
|
||||
case 'price_high':
|
||||
sorted.sort((a, b) => b.sellingPrice - a.sellingPrice);
|
||||
break;
|
||||
case 'price_low':
|
||||
sorted.sort((a, b) => a.sellingPrice - b.sellingPrice);
|
||||
break;
|
||||
default: // latest
|
||||
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
break;
|
||||
}
|
||||
return sorted;
|
||||
},
|
||||
|
||||
// 공통 헤더 옵션
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
createButton: {
|
||||
label: '단가 등록',
|
||||
onClick: handleCreate,
|
||||
},
|
||||
|
||||
// Stats 카드
|
||||
computeStats: (): StatCard[] => [
|
||||
{
|
||||
label: '전체 단가',
|
||||
value: stats?.total ?? 0,
|
||||
icon: Package,
|
||||
iconColor: 'text-gray-600',
|
||||
onClick: () => setActiveStatTab('all'),
|
||||
isActive: activeStatTab === 'all',
|
||||
},
|
||||
{
|
||||
label: '사용 단가',
|
||||
value: stats?.inUse ?? 0,
|
||||
icon: CheckCircle,
|
||||
iconColor: 'text-green-600',
|
||||
onClick: () => setActiveStatTab('in_use'),
|
||||
isActive: activeStatTab === 'in_use',
|
||||
},
|
||||
{
|
||||
label: '미등록 단가',
|
||||
value: stats?.notRegistered ?? 0,
|
||||
icon: AlertCircle,
|
||||
iconColor: 'text-gray-400',
|
||||
onClick: () => setActiveStatTab('not_registered'),
|
||||
isActive: activeStatTab === 'not_registered',
|
||||
},
|
||||
],
|
||||
|
||||
// 삭제 확인 메시지 (AlertDialog 대체)
|
||||
deleteConfirmMessage: {
|
||||
title: '단가 삭제',
|
||||
description: '선택한 단가를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.',
|
||||
},
|
||||
|
||||
// 커스텀 테이블 헤더 (동적 컬럼)
|
||||
renderCustomTableHeader: ({ displayData, selectedItems, onToggleSelectAll }) => (
|
||||
<>
|
||||
<TableHead className="w-[40px] text-center">
|
||||
<Checkbox
|
||||
checked={selectedItems.size === displayData.length && displayData.length > 0}
|
||||
onCheckedChange={onToggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
))}
|
||||
<TableHead className="w-[60px]">단위</TableHead>
|
||||
<TableHead className="w-[60px]">구분</TableHead>
|
||||
<TableHead className="w-[120px]">거래처</TableHead>
|
||||
<TableHead className="w-[100px] text-right">판매단가</TableHead>
|
||||
<TableHead className="w-[70px] text-center">상태</TableHead>
|
||||
{selectedItems.size > 0 && (
|
||||
<TableHead className="w-[100px] text-center">작업</TableHead>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}, [selectedItems.size, paginatedData.length, handleToggleSelectAll, dynamicOrderItemColumns]);
|
||||
<TableHead className="w-[50px] text-center">번호</TableHead>
|
||||
<TableHead className="w-[120px]">단가번호</TableHead>
|
||||
<TableHead className="w-[80px]">품목유형</TableHead>
|
||||
<TableHead className="w-[140px]">카테고리</TableHead>
|
||||
<TableHead className="min-w-[120px]">품목명</TableHead>
|
||||
<TableHead className="w-[100px]">규격</TableHead>
|
||||
{/* 동적 항목명 컬럼 */}
|
||||
{dynamicOrderItemColumns.map((colName) => (
|
||||
<TableHead key={colName} className="w-[80px]">
|
||||
{colName}
|
||||
</TableHead>
|
||||
))}
|
||||
<TableHead className="w-[60px]">단위</TableHead>
|
||||
<TableHead className="w-[60px]">구분</TableHead>
|
||||
<TableHead className="w-[120px]">거래처</TableHead>
|
||||
<TableHead className="w-[100px] text-right">판매단가</TableHead>
|
||||
<TableHead className="w-[70px] text-center">상태</TableHead>
|
||||
{selectedItems.size > 0 && (
|
||||
<TableHead className="w-[100px] text-center">작업</TableHead>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = useCallback(
|
||||
(pricing: Pricing, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(pricing.id);
|
||||
|
||||
return (
|
||||
// 테이블 행 렌더링
|
||||
renderTableRow: (
|
||||
pricing: Pricing,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<Pricing>
|
||||
) => (
|
||||
<TableRow
|
||||
key={pricing.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleRowClick(pricing)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleSelection(pricing.id)}
|
||||
/>
|
||||
<Checkbox checked={handlers.isSelected} onCheckedChange={handlers.onToggle} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell>{pricing.pricingNumber}</TableCell>
|
||||
@@ -365,47 +414,52 @@ export default function PricingListClient({
|
||||
{PRICING_STATUS_LABELS[pricing.status]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
{selectedItems.size > 0 && (
|
||||
{handlers.isSelected && (
|
||||
<TableCell className="text-center">
|
||||
{isSelected && (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => handleEdit(e, pricing.id)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={(e) => handleDeleteClick(e, pricing.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(pricing);
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlers.onDelete?.(pricing);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
[selectedItems, handleToggleSelection, handleRowClick, handleEdit, handleDeleteClick, dynamicOrderItemColumns]
|
||||
);
|
||||
),
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = useCallback(
|
||||
(pricing: Pricing, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
|
||||
return (
|
||||
// 모바일 카드 렌더링
|
||||
renderMobileCard: (
|
||||
pricing: Pricing,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<Pricing>
|
||||
) => (
|
||||
<MobileCard
|
||||
key={pricing.id}
|
||||
title={pricing.itemName}
|
||||
subtitle={pricing.pricingNumber}
|
||||
badge={PRICING_STATUS_LABELS[pricing.status]}
|
||||
badgeVariant="secondary"
|
||||
isSelected={isSelected}
|
||||
onToggle={onToggle}
|
||||
isSelected={handlers.isSelected}
|
||||
onToggle={handlers.onToggle}
|
||||
onClick={() => handleRowClick(pricing)}
|
||||
details={[
|
||||
{ label: '품목유형', value: pricing.itemType },
|
||||
@@ -413,233 +467,21 @@ export default function PricingListClient({
|
||||
{ label: '판매단가', value: formatNumber(pricing.sellingPrice) },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[handleRowClick]
|
||||
),
|
||||
}),
|
||||
[
|
||||
startDate,
|
||||
endDate,
|
||||
activeStatTab,
|
||||
stats,
|
||||
dynamicOrderItemColumns,
|
||||
handleRowClick,
|
||||
handleEdit,
|
||||
handleCreate,
|
||||
getOrderItemValue,
|
||||
formatNumber,
|
||||
]
|
||||
);
|
||||
|
||||
// 헤더 액션 (달력 + 등록버튼)
|
||||
const headerActions = (
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
extraActions={
|
||||
<Button onClick={handleRegister}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
단가 등록
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
// Stats 카드 데이터
|
||||
const statsCardsData: StatCard[] = [
|
||||
{
|
||||
label: '전체 단가',
|
||||
value: stats?.total ?? 0,
|
||||
icon: Package,
|
||||
iconColor: 'text-gray-600',
|
||||
onClick: () => setActiveStatTab('all'),
|
||||
isActive: activeStatTab === 'all',
|
||||
},
|
||||
{
|
||||
label: '사용 단가',
|
||||
value: stats?.inUse ?? 0,
|
||||
icon: CheckCircle,
|
||||
iconColor: 'text-green-600',
|
||||
onClick: () => setActiveStatTab('in_use'),
|
||||
isActive: activeStatTab === 'in_use',
|
||||
},
|
||||
{
|
||||
label: '미등록 단가',
|
||||
value: stats?.notRegistered ?? 0,
|
||||
icon: AlertCircle,
|
||||
iconColor: 'text-gray-400',
|
||||
onClick: () => setActiveStatTab('not_registered'),
|
||||
isActive: activeStatTab === 'not_registered',
|
||||
},
|
||||
];
|
||||
|
||||
// ===== filterConfig 기반 통합 필터 시스템 =====
|
||||
const filterConfig: FilterFieldConfig[] = useMemo(() => [
|
||||
{
|
||||
key: 'itemType',
|
||||
label: '품목유형',
|
||||
type: 'single',
|
||||
options: ITEM_TYPE_OPTIONS.filter(o => o.value !== 'all').map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '전체',
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
label: '카테고리',
|
||||
type: 'single',
|
||||
options: CATEGORY_OPTIONS.filter(o => o.value !== 'all').map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '전체',
|
||||
},
|
||||
{
|
||||
key: 'spec',
|
||||
label: '규격',
|
||||
type: 'single',
|
||||
options: SPEC_OPTIONS.filter(o => o.value !== 'all').map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '전체',
|
||||
},
|
||||
{
|
||||
key: 'division',
|
||||
label: '구분',
|
||||
type: 'single',
|
||||
options: DIVISION_OPTIONS.filter(o => o.value !== 'all').map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '전체',
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'single',
|
||||
options: STATUS_OPTIONS.filter(o => o.value !== 'all').map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '전체',
|
||||
},
|
||||
{
|
||||
key: 'sortBy',
|
||||
label: '정렬',
|
||||
type: 'single',
|
||||
options: SORT_OPTIONS.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '최신순',
|
||||
},
|
||||
], []);
|
||||
|
||||
const filterValues: FilterValues = useMemo(() => ({
|
||||
itemType: itemTypeFilter,
|
||||
category: categoryFilter,
|
||||
spec: specFilter,
|
||||
division: divisionFilter,
|
||||
status: statusFilter,
|
||||
sortBy: sortBy,
|
||||
}), [itemTypeFilter, categoryFilter, specFilter, divisionFilter, statusFilter, sortBy]);
|
||||
|
||||
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
|
||||
switch (key) {
|
||||
case 'itemType':
|
||||
setItemTypeFilter(value as string);
|
||||
break;
|
||||
case 'category':
|
||||
setCategoryFilter(value as string);
|
||||
break;
|
||||
case 'spec':
|
||||
setSpecFilter(value as string);
|
||||
break;
|
||||
case 'division':
|
||||
setDivisionFilter(value as string);
|
||||
break;
|
||||
case 'status':
|
||||
setStatusFilter(value as string);
|
||||
break;
|
||||
case 'sortBy':
|
||||
setSortBy(value as string);
|
||||
break;
|
||||
}
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const handleFilterReset = useCallback(() => {
|
||||
setItemTypeFilter('all');
|
||||
setCategoryFilter('all');
|
||||
setSpecFilter('all');
|
||||
setDivisionFilter('all');
|
||||
setStatusFilter('all');
|
||||
setSortBy('latest');
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
// 동적 컬럼으로 인해 tableColumns를 사용하지 않고 커스텀 헤더 사용
|
||||
const emptyTableColumns: { key: string; label: string; className?: string }[] = [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedListTemplateV2
|
||||
title="단가관리"
|
||||
description="단가를 등록하고 관리합니다"
|
||||
icon={DollarSign}
|
||||
headerActions={headerActions}
|
||||
stats={statsCardsData}
|
||||
filterConfig={filterConfig}
|
||||
filterValues={filterValues}
|
||||
onFilterChange={handleFilterChange}
|
||||
onFilterReset={handleFilterReset}
|
||||
filterTitle="단가 필터"
|
||||
searchValue={searchValue}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="단가번호, 품목명, 카테고리, 거래처 검색"
|
||||
tableColumns={emptyTableColumns}
|
||||
renderCustomTableHeader={renderCustomTableHeader}
|
||||
data={paginatedData}
|
||||
allData={sortedPricing}
|
||||
getItemId={(item) => item.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
onBulkDelete={handleBulkDeleteClick}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: sortedPricing.length,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
{/* 단일 삭제 다이얼로그 */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>단가 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 단가를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteConfirm}>삭제</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 일괄 삭제 다이얼로그 */}
|
||||
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>단가 일괄 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 {selectedItems.size}개 단가를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleBulkDeleteConfirm}>삭제</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <UniversalListPage config={config} initialData={initialData} />;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 기성청구관리 - UniversalListPage 마이그레이션
|
||||
*
|
||||
* 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환
|
||||
* - 클라이언트 사이드 필터링 (검색, 필터, 정렬)
|
||||
* - Stats 카드 클릭 필터링 (activeStatTab)
|
||||
* - DateRangeSelector with showQuickButtons
|
||||
* - filterConfig (multi: 거래처, 현장명 / single: 상태, 정렬)
|
||||
* - 삭제 기능 없음 (조회/수정 전용)
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FileText, Pencil } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { MultiSelectOption } from '@/components/ui/multi-select-combobox';
|
||||
import { IntegratedListTemplateV2, TableColumn, StatCard, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { MobileCard } from '@/components/molecules/MobileCard';
|
||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
type SelectionHandlers,
|
||||
type RowClickHandlers,
|
||||
type StatCard,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import type { ProgressBilling, ProgressBillingStats } from './types';
|
||||
import {
|
||||
PROGRESS_BILLING_STATUS_OPTIONS,
|
||||
@@ -19,7 +33,6 @@ import {
|
||||
PROGRESS_BILLING_STATUS_LABELS,
|
||||
MOCK_PARTNERS,
|
||||
MOCK_SITES,
|
||||
PARTNER_SITES_MAP,
|
||||
} from './types';
|
||||
import {
|
||||
getProgressBillingList,
|
||||
@@ -27,7 +40,7 @@ import {
|
||||
} from './actions';
|
||||
|
||||
// 테이블 컬럼 정의
|
||||
const tableColumns: TableColumn[] = [
|
||||
const tableColumns = [
|
||||
{ key: 'no', label: '번호', className: 'w-[50px] text-center' },
|
||||
{ key: 'billingNumber', label: '기성청구번호', className: 'w-[140px]' },
|
||||
{ key: 'partnerName', label: '거래처', className: 'w-[120px]' },
|
||||
@@ -41,6 +54,11 @@ const tableColumns: TableColumn[] = [
|
||||
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
|
||||
];
|
||||
|
||||
// 금액 포맷
|
||||
function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat('ko-KR').format(amount);
|
||||
}
|
||||
|
||||
interface ProgressBillingManagementListClientProps {
|
||||
initialData?: ProgressBilling[];
|
||||
initialStats?: ProgressBillingStats;
|
||||
@@ -52,225 +70,253 @@ export default function ProgressBillingManagementListClient({
|
||||
}: ProgressBillingManagementListClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// 상태
|
||||
const [billings, setBillings] = useState<ProgressBilling[]>(initialData);
|
||||
const [stats, setStats] = useState<ProgressBillingStats | null>(initialStats || null);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
// 다중선택 필터
|
||||
const [partnerFilters, setPartnerFilters] = useState<string[]>([]);
|
||||
const [siteFilters, setSiteFilters] = useState<string[]>([]);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [sortBy, setSortBy] = useState<string>('latest');
|
||||
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
|
||||
const [activeStatTab, setActiveStatTab] = useState<'all' | 'contractWaiting' | 'contractComplete'>('all');
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [activeStatTab, setActiveStatTab] = useState<'all' | 'contractWaiting' | 'contractComplete'>('all');
|
||||
const itemsPerPage = 20;
|
||||
const [stats, setStats] = useState<ProgressBillingStats | null>(initialStats || null);
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [listResult, statsResult] = await Promise.all([
|
||||
getProgressBillingList({
|
||||
size: 1000,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
}),
|
||||
getProgressBillingStats(),
|
||||
]);
|
||||
|
||||
if (listResult.success && listResult.data) {
|
||||
setBillings(listResult.data.items);
|
||||
}
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStats(statsResult.data);
|
||||
}
|
||||
} catch {
|
||||
toast.error('데이터 로드에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [startDate, endDate]);
|
||||
|
||||
// 초기 데이터가 없으면 로드
|
||||
// Stats 로드
|
||||
useEffect(() => {
|
||||
if (initialData.length === 0) {
|
||||
loadData();
|
||||
if (!initialStats) {
|
||||
getProgressBillingStats().then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setStats(result.data);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [initialData.length, loadData]);
|
||||
|
||||
// 거래처 선택에 따른 현장 옵션 필터링
|
||||
const filteredSiteOptions: MultiSelectOption[] = useMemo(() => {
|
||||
if (partnerFilters.length === 0) {
|
||||
return MOCK_SITES;
|
||||
}
|
||||
|
||||
// 선택된 거래처들의 현장 ID 수집
|
||||
const availableSiteIds = new Set<string>();
|
||||
partnerFilters.forEach((partnerId) => {
|
||||
const siteIds = PARTNER_SITES_MAP[partnerId] || [];
|
||||
siteIds.forEach((siteId) => availableSiteIds.add(siteId));
|
||||
});
|
||||
|
||||
return MOCK_SITES.filter((site) => availableSiteIds.has(site.value));
|
||||
}, [partnerFilters]);
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredBillings = useMemo(() => {
|
||||
return billings.filter((billing) => {
|
||||
// 상태 탭 필터
|
||||
if (activeStatTab === 'contractWaiting' &&
|
||||
billing.status !== 'billing_waiting' &&
|
||||
billing.status !== 'approval_waiting') return false;
|
||||
if (activeStatTab === 'contractComplete' && billing.status !== 'billing_complete') return false;
|
||||
|
||||
// 상태 필터
|
||||
if (statusFilter !== 'all' && billing.status !== statusFilter) return false;
|
||||
|
||||
// 거래처 필터 (다중선택)
|
||||
if (partnerFilters.length > 0 && !partnerFilters.includes(billing.partnerId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 현장 필터 (다중선택)
|
||||
if (siteFilters.length > 0 && !siteFilters.includes(billing.siteId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if (searchValue) {
|
||||
const search = searchValue.toLowerCase();
|
||||
return (
|
||||
billing.billingNumber.toLowerCase().includes(search) ||
|
||||
billing.partnerName.toLowerCase().includes(search) ||
|
||||
billing.siteName.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [billings, activeStatTab, statusFilter, partnerFilters, siteFilters, searchValue]);
|
||||
|
||||
// 정렬
|
||||
const sortedBillings = useMemo(() => {
|
||||
const sorted = [...filteredBillings];
|
||||
switch (sortBy) {
|
||||
case 'latest':
|
||||
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
break;
|
||||
case 'oldest':
|
||||
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
break;
|
||||
case 'partnerNameAsc':
|
||||
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
|
||||
break;
|
||||
case 'partnerNameDesc':
|
||||
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
|
||||
break;
|
||||
case 'siteNameAsc':
|
||||
sorted.sort((a, b) => a.siteName.localeCompare(b.siteName, 'ko'));
|
||||
break;
|
||||
case 'siteNameDesc':
|
||||
sorted.sort((a, b) => b.siteName.localeCompare(a.siteName, 'ko'));
|
||||
break;
|
||||
}
|
||||
return sorted;
|
||||
}, [filteredBillings, sortBy]);
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(sortedBillings.length / itemsPerPage);
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (currentPage - 1) * itemsPerPage;
|
||||
return sortedBillings.slice(start, start + itemsPerPage);
|
||||
}, [sortedBillings, currentPage, itemsPerPage]);
|
||||
|
||||
// 핸들러
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchValue(value);
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const handleToggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggleSelectAll = useCallback(() => {
|
||||
if (selectedItems.size === paginatedData.length) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(paginatedData.map((b) => b.id)));
|
||||
}
|
||||
}, [selectedItems.size, paginatedData]);
|
||||
}, [initialStats]);
|
||||
|
||||
// ===== 핸들러 =====
|
||||
const handleRowClick = useCallback(
|
||||
(billing: ProgressBilling) => {
|
||||
router.push(`/ko/construction/billing/progress-billing-management/${billing.id}`);
|
||||
(item: ProgressBilling) => {
|
||||
router.push(`/ko/construction/billing/progress-billing-management/${item.id}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(e: React.MouseEvent, billingId: string) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/ko/construction/billing/progress-billing-management/${billingId}/edit`);
|
||||
(item: ProgressBilling) => {
|
||||
router.push(`/ko/construction/billing/progress-billing-management/${item.id}/edit`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
// 금액 포맷
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('ko-KR').format(amount);
|
||||
};
|
||||
// ===== UniversalListPage Config =====
|
||||
const config: UniversalListConfig<ProgressBilling> = useMemo(
|
||||
() => ({
|
||||
// 페이지 기본 정보
|
||||
title: '기성청구관리',
|
||||
description: '기성청구를 등록하고 관리합니다.',
|
||||
icon: FileText,
|
||||
basePath: '/construction/billing/progress-billing-management',
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = useCallback(
|
||||
(billing: ProgressBilling, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(billing.id);
|
||||
// ID 추출
|
||||
idField: 'id',
|
||||
|
||||
return (
|
||||
// API 액션
|
||||
actions: {
|
||||
getList: async () => {
|
||||
const result = await getProgressBillingList({
|
||||
size: 1000,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
});
|
||||
if (result.success && result.data) {
|
||||
return {
|
||||
success: true,
|
||||
data: result.data.items,
|
||||
totalCount: result.data.total,
|
||||
};
|
||||
}
|
||||
return { success: false, error: result.error };
|
||||
},
|
||||
},
|
||||
|
||||
// 테이블 컬럼
|
||||
columns: tableColumns,
|
||||
|
||||
// 클라이언트 사이드 필터링
|
||||
clientSideFiltering: true,
|
||||
itemsPerPage: 20,
|
||||
|
||||
// 검색 필터
|
||||
searchPlaceholder: '기성청구번호, 거래처, 현장명 검색',
|
||||
searchFilter: (item, searchValue) => {
|
||||
const search = searchValue.toLowerCase();
|
||||
return (
|
||||
item.billingNumber.toLowerCase().includes(search) ||
|
||||
item.partnerName.toLowerCase().includes(search) ||
|
||||
item.siteName.toLowerCase().includes(search)
|
||||
);
|
||||
},
|
||||
|
||||
// 필터 설정
|
||||
filterConfig: [
|
||||
{
|
||||
key: 'partners',
|
||||
label: '거래처',
|
||||
type: 'multi',
|
||||
options: MOCK_PARTNERS,
|
||||
},
|
||||
{
|
||||
key: 'sites',
|
||||
label: '현장명',
|
||||
type: 'multi',
|
||||
options: MOCK_SITES,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'single',
|
||||
options: PROGRESS_BILLING_STATUS_OPTIONS.filter((o) => o.value !== 'all'),
|
||||
},
|
||||
{
|
||||
key: 'sortBy',
|
||||
label: '정렬',
|
||||
type: 'single',
|
||||
options: PROGRESS_BILLING_SORT_OPTIONS,
|
||||
},
|
||||
],
|
||||
initialFilters: {
|
||||
partners: [],
|
||||
sites: [],
|
||||
status: 'all',
|
||||
sortBy: 'latest',
|
||||
},
|
||||
filterTitle: '기성청구 필터',
|
||||
|
||||
// 커스텀 필터 함수
|
||||
customFilterFn: (items, filterValues) => {
|
||||
return items.filter((item) => {
|
||||
// Stats 탭 필터
|
||||
if (activeStatTab === 'contractWaiting' &&
|
||||
item.status !== 'billing_waiting' &&
|
||||
item.status !== 'approval_waiting') return false;
|
||||
if (activeStatTab === 'contractComplete' && item.status !== 'billing_complete') return false;
|
||||
|
||||
// 거래처 필터 (다중선택)
|
||||
const partnerFilters = filterValues.partners as string[];
|
||||
if (partnerFilters?.length > 0 && !partnerFilters.includes(item.partnerId)) return false;
|
||||
|
||||
// 현장 필터 (다중선택)
|
||||
const siteFilters = filterValues.sites as string[];
|
||||
if (siteFilters?.length > 0 && !siteFilters.includes(item.siteId)) return false;
|
||||
|
||||
// 상태 필터 (단일선택)
|
||||
const statusFilter = filterValues.status as string;
|
||||
if (statusFilter && statusFilter !== 'all' && item.status !== statusFilter) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
},
|
||||
|
||||
// 커스텀 정렬 함수
|
||||
customSortFn: (items, filterValues) => {
|
||||
const sorted = [...items];
|
||||
const sortBy = (filterValues.sortBy as string) || 'latest';
|
||||
|
||||
switch (sortBy) {
|
||||
case 'latest':
|
||||
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
break;
|
||||
case 'oldest':
|
||||
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
break;
|
||||
case 'partnerNameAsc':
|
||||
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
|
||||
break;
|
||||
case 'partnerNameDesc':
|
||||
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
|
||||
break;
|
||||
case 'siteNameAsc':
|
||||
sorted.sort((a, b) => a.siteName.localeCompare(b.siteName, 'ko'));
|
||||
break;
|
||||
case 'siteNameDesc':
|
||||
sorted.sort((a, b) => b.siteName.localeCompare(a.siteName, 'ko'));
|
||||
break;
|
||||
}
|
||||
return sorted;
|
||||
},
|
||||
|
||||
// 공통 헤더 옵션
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
showQuickButtons: true,
|
||||
},
|
||||
|
||||
// Stats 카드
|
||||
computeStats: (): StatCard[] => [
|
||||
{
|
||||
label: '전체 계약',
|
||||
value: stats?.total ?? 0,
|
||||
icon: FileText,
|
||||
iconColor: 'text-blue-600',
|
||||
onClick: () => setActiveStatTab('all'),
|
||||
isActive: activeStatTab === 'all',
|
||||
},
|
||||
{
|
||||
label: '계약대기',
|
||||
value: stats?.contractWaiting ?? 0,
|
||||
icon: FileText,
|
||||
iconColor: 'text-yellow-600',
|
||||
onClick: () => setActiveStatTab('contractWaiting'),
|
||||
isActive: activeStatTab === 'contractWaiting',
|
||||
},
|
||||
{
|
||||
label: '계약완료',
|
||||
value: stats?.contractComplete ?? 0,
|
||||
icon: FileText,
|
||||
iconColor: 'text-green-600',
|
||||
onClick: () => setActiveStatTab('contractComplete'),
|
||||
isActive: activeStatTab === 'contractComplete',
|
||||
},
|
||||
],
|
||||
|
||||
// 테이블 행 렌더링
|
||||
renderTableRow: (
|
||||
item: ProgressBilling,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<ProgressBilling>
|
||||
) => (
|
||||
<TableRow
|
||||
key={billing.id}
|
||||
key={item.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleRowClick(billing)}
|
||||
onClick={() => handleRowClick(item)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleSelection(billing.id)}
|
||||
/>
|
||||
<Checkbox checked={handlers.isSelected} onCheckedChange={handlers.onToggle} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell>{billing.billingNumber}</TableCell>
|
||||
<TableCell>{billing.partnerName}</TableCell>
|
||||
<TableCell>{billing.siteName}</TableCell>
|
||||
<TableCell className="text-center">{billing.round}차</TableCell>
|
||||
<TableCell className="text-center">{billing.billingYearMonth}</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(billing.previousBilling)}</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(billing.currentBilling)}</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(billing.cumulativeBilling)}</TableCell>
|
||||
<TableCell>{item.billingNumber}</TableCell>
|
||||
<TableCell>{item.partnerName}</TableCell>
|
||||
<TableCell>{item.siteName}</TableCell>
|
||||
<TableCell className="text-center">{item.round}차</TableCell>
|
||||
<TableCell className="text-center">{item.billingYearMonth}</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(item.previousBilling)}</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(item.currentBilling)}</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(item.cumulativeBilling)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${PROGRESS_BILLING_STATUS_STYLES[billing.status]}`}>
|
||||
{PROGRESS_BILLING_STATUS_LABELS[billing.status]}
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${PROGRESS_BILLING_STATUS_STYLES[item.status]}`}>
|
||||
{PROGRESS_BILLING_STATUS_LABELS[item.status]}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{isSelected && (
|
||||
{handlers.isSelected && (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => handleEdit(e, billing.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(item);
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -278,163 +324,34 @@ export default function ProgressBillingManagementListClient({
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
[selectedItems, handleToggleSelection, handleRowClick, handleEdit]
|
||||
);
|
||||
),
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = useCallback(
|
||||
(billing: ProgressBilling, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
|
||||
return (
|
||||
// 모바일 카드 렌더링
|
||||
renderMobileCard: (
|
||||
item: ProgressBilling,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<ProgressBilling>
|
||||
) => (
|
||||
<MobileCard
|
||||
title={billing.siteName}
|
||||
subtitle={billing.billingNumber}
|
||||
badge={PROGRESS_BILLING_STATUS_LABELS[billing.status]}
|
||||
key={item.id}
|
||||
title={item.siteName}
|
||||
subtitle={item.billingNumber}
|
||||
badge={PROGRESS_BILLING_STATUS_LABELS[item.status]}
|
||||
badgeVariant="secondary"
|
||||
isSelected={isSelected}
|
||||
onToggle={onToggle}
|
||||
onClick={() => handleRowClick(billing)}
|
||||
isSelected={handlers.isSelected}
|
||||
onToggle={handlers.onToggle}
|
||||
onClick={() => handleRowClick(item)}
|
||||
details={[
|
||||
{ label: '거래처', value: billing.partnerName },
|
||||
{ label: '회차', value: `${billing.round}차` },
|
||||
{ label: '금회기성', value: formatCurrency(billing.currentBilling) },
|
||||
{ label: '거래처', value: item.partnerName },
|
||||
{ label: '회차', value: `${item.round}차` },
|
||||
{ label: '금회기성', value: formatCurrency(item.currentBilling) },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[handleRowClick]
|
||||
),
|
||||
}),
|
||||
[startDate, endDate, activeStatTab, stats, handleRowClick, handleEdit]
|
||||
);
|
||||
|
||||
// 헤더 액션 (날짜 범위 + 퀵버튼)
|
||||
const headerActions = (
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
showQuickButtons={true}
|
||||
/>
|
||||
);
|
||||
|
||||
// Stats 카드 데이터
|
||||
const statsCardsData: StatCard[] = [
|
||||
{
|
||||
label: '전체 계약',
|
||||
value: stats?.total ?? 0,
|
||||
icon: FileText,
|
||||
iconColor: 'text-blue-600',
|
||||
onClick: () => setActiveStatTab('all'),
|
||||
isActive: activeStatTab === 'all',
|
||||
},
|
||||
{
|
||||
label: '계약대기',
|
||||
value: stats?.contractWaiting ?? 0,
|
||||
icon: FileText,
|
||||
iconColor: 'text-yellow-600',
|
||||
onClick: () => setActiveStatTab('contractWaiting'),
|
||||
isActive: activeStatTab === 'contractWaiting',
|
||||
},
|
||||
{
|
||||
label: '계약완료',
|
||||
value: stats?.contractComplete ?? 0,
|
||||
icon: FileText,
|
||||
iconColor: 'text-green-600',
|
||||
onClick: () => setActiveStatTab('contractComplete'),
|
||||
isActive: activeStatTab === 'contractComplete',
|
||||
},
|
||||
];
|
||||
|
||||
// 필터 옵션들
|
||||
const partnerOptions: MultiSelectOption[] = useMemo(() => MOCK_PARTNERS, []);
|
||||
|
||||
// filterConfig 기반 통합 필터 시스템
|
||||
const filterConfig: FilterFieldConfig[] = useMemo(() => [
|
||||
{ key: 'partners', label: '거래처', type: 'multi', options: partnerOptions },
|
||||
{ key: 'sites', label: '현장명', type: 'multi', options: filteredSiteOptions },
|
||||
{ key: 'status', label: '상태', type: 'single', options: PROGRESS_BILLING_STATUS_OPTIONS.filter(opt => opt.value !== 'all') },
|
||||
{ key: 'sortBy', label: '정렬', type: 'single', options: PROGRESS_BILLING_SORT_OPTIONS.map(opt => ({ value: opt.value, label: opt.label })), allOptionLabel: '최신순' },
|
||||
], [partnerOptions, filteredSiteOptions]);
|
||||
|
||||
// filterValues 객체
|
||||
const filterValues: FilterValues = useMemo(() => ({
|
||||
partners: partnerFilters,
|
||||
sites: siteFilters,
|
||||
status: statusFilter,
|
||||
sortBy: sortBy,
|
||||
}), [partnerFilters, siteFilters, statusFilter, sortBy]);
|
||||
|
||||
// 필터 변경 핸들러
|
||||
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
|
||||
switch (key) {
|
||||
case 'partners':
|
||||
setPartnerFilters(value as string[]);
|
||||
// 거래처 변경 시 현장 필터 초기화
|
||||
setSiteFilters([]);
|
||||
break;
|
||||
case 'sites':
|
||||
setSiteFilters(value as string[]);
|
||||
break;
|
||||
case 'status':
|
||||
setStatusFilter(value as string);
|
||||
break;
|
||||
case 'sortBy':
|
||||
setSortBy(value as string);
|
||||
break;
|
||||
}
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
// 필터 초기화 핸들러
|
||||
const handleFilterReset = useCallback(() => {
|
||||
setPartnerFilters([]);
|
||||
setSiteFilters([]);
|
||||
setStatusFilter('all');
|
||||
setSortBy('latest');
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
// 테이블 헤더 추가 액션
|
||||
const tableHeaderActions = (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
총 {sortedBillings.length}건
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<IntegratedListTemplateV2
|
||||
title="기성청구관리"
|
||||
description="기성청구를 등록하고 관리합니다."
|
||||
icon={FileText}
|
||||
headerActions={headerActions}
|
||||
stats={statsCardsData}
|
||||
filterConfig={filterConfig}
|
||||
filterValues={filterValues}
|
||||
onFilterChange={handleFilterChange}
|
||||
onFilterReset={handleFilterReset}
|
||||
filterTitle="기성청구 필터"
|
||||
tableHeaderActions={tableHeaderActions}
|
||||
searchValue={searchValue}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="기성청구번호, 거래처, 현장명 검색"
|
||||
tableColumns={tableColumns}
|
||||
data={paginatedData}
|
||||
allData={sortedBillings}
|
||||
getItemId={(item) => item.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: sortedBillings.length,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <UniversalListPage config={config} initialData={initialData} />;
|
||||
}
|
||||
|
||||
@@ -1,31 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
/**
|
||||
* 현장설명회 관리 - UniversalListPage 마이그레이션
|
||||
*
|
||||
* - 클라이언트 사이드 필터링 (검색, 필터, 정렬)
|
||||
* - Stats 카드 클릭 필터링 (activeStatTab)
|
||||
* - DateRangeSelector (공통 헤더)
|
||||
* - filterConfig (multi: 거래처, 참석자 / single: 구분, 상태, 정렬)
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Calendar, CalendarDays, CalendarCheck, CalendarClock, Plus, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Calendar, CalendarDays, CalendarCheck, CalendarClock, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { MultiSelectOption } from '@/components/ui/multi-select-combobox';
|
||||
import { IntegratedListTemplateV2, TableColumn, StatCard, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { MobileCard } from '@/components/molecules/MobileCard';
|
||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
type SelectionHandlers,
|
||||
type RowClickHandlers,
|
||||
type StatCard,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import type { SiteBriefing } from './types';
|
||||
import { getSiteBriefingList, deleteSiteBriefing, deleteSiteBriefings } from './actions';
|
||||
|
||||
// 테이블 컬럼 정의 (스크린샷 기준)
|
||||
const tableColumns: TableColumn[] = [
|
||||
// 테이블 컬럼 정의
|
||||
const tableColumns = [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'briefingCode', label: '현설번호', className: 'w-[100px]' },
|
||||
{ key: 'partnerName', label: '거래처', className: 'w-[120px]' },
|
||||
@@ -38,32 +40,6 @@ const tableColumns: TableColumn[] = [
|
||||
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
|
||||
];
|
||||
|
||||
// 상태 옵션
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'scheduled', label: '참석예정' },
|
||||
{ value: 'attended', label: '참석완료' },
|
||||
{ value: 'absent', label: '불참' },
|
||||
];
|
||||
|
||||
// 구분 옵션
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'online', label: '온라인' },
|
||||
{ value: 'offline', label: '오프라인' },
|
||||
];
|
||||
|
||||
// 정렬 옵션
|
||||
const SORT_OPTIONS = [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '등록순' },
|
||||
{ value: 'bidDateDesc', label: '입찰일 최신순' },
|
||||
{ value: 'partnerAsc', label: '거래처명 오름차순' },
|
||||
{ value: 'partnerDesc', label: '거래처명 내림차순' },
|
||||
{ value: 'projectAsc', label: '현장명 오름차순' },
|
||||
{ value: 'projectDesc', label: '현장명 내림차순' },
|
||||
];
|
||||
|
||||
// 상태별 스타일
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
scheduled: 'text-red-500 font-medium',
|
||||
@@ -77,15 +53,33 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
absent: '불참',
|
||||
};
|
||||
|
||||
// 목업 거래처 목록 (다중선택용 - 빈 배열 = 전체)
|
||||
const MOCK_PARTNERS: MultiSelectOption[] = [
|
||||
// 상태 옵션
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'scheduled', label: '참석예정' },
|
||||
{ value: 'attended', label: '참석완료' },
|
||||
{ value: 'absent', label: '불참' },
|
||||
];
|
||||
|
||||
// 구분 옵션
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'online', label: '온라인' },
|
||||
{ value: 'offline', label: '오프라인' },
|
||||
];
|
||||
|
||||
// 정렬 옵션
|
||||
const SORT_OPTIONS = [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '등록순' },
|
||||
];
|
||||
|
||||
// 목업 데이터
|
||||
const MOCK_PARTNERS = [
|
||||
{ value: '1', label: '대한건설' },
|
||||
{ value: '2', label: '삼성시공' },
|
||||
{ value: '3', label: 'LG건설' },
|
||||
];
|
||||
|
||||
// 목업 참석자 목록 (다중선택용 - 빈 배열 = 전체)
|
||||
const MOCK_ATTENDEES: MultiSelectOption[] = [
|
||||
const MOCK_ATTENDEES = [
|
||||
{ value: 'hong', label: '홍길동' },
|
||||
{ value: 'kim', label: '김철수' },
|
||||
{ value: 'lee', label: '이영희' },
|
||||
@@ -98,166 +92,23 @@ interface SiteBriefingListClientProps {
|
||||
export default function SiteBriefingListClient({ initialData = [] }: SiteBriefingListClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// 상태
|
||||
const [briefings, setBriefings] = useState<SiteBriefing[]>(initialData);
|
||||
const [statsData, setStatsData] = useState({ total: 0, scheduled: 0, attended: 0 });
|
||||
// Stats 탭 상태
|
||||
const [activeStatTab, setActiveStatTab] = useState<'all' | 'scheduled' | 'attended'>('all');
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [partnerFilters, setPartnerFilters] = useState<string[]>([]);
|
||||
const [typeFilter, setTypeFilter] = useState<string>('all');
|
||||
const [attendeeFilters, setAttendeeFilters] = useState<string[]>([]);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [sortBy, setSortBy] = useState<string>('latest');
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const listResult = await getSiteBriefingList({
|
||||
size: 1000,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
});
|
||||
|
||||
if (listResult.success && listResult.data) {
|
||||
setBriefings(listResult.data.items);
|
||||
// 통계 계산 (참석 상태 기준)
|
||||
const items = listResult.data.items;
|
||||
const total = items.length;
|
||||
const scheduled = items.filter((b) => b.attendanceStatus === 'scheduled').length;
|
||||
const attended = items.filter((b) => b.attendanceStatus === 'attended').length;
|
||||
setStatsData({ total, scheduled, attended });
|
||||
}
|
||||
} catch {
|
||||
toast.error('데이터 로드에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [startDate, endDate]);
|
||||
|
||||
// 초기 데이터가 없으면 로드
|
||||
useEffect(() => {
|
||||
if (initialData.length === 0) {
|
||||
loadData();
|
||||
}
|
||||
}, [initialData.length, loadData]);
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredBriefings = useMemo(() => {
|
||||
return briefings.filter((briefing) => {
|
||||
// Stats 탭 필터 (참석 상태 기준)
|
||||
if (activeStatTab === 'scheduled' && briefing.attendanceStatus !== 'scheduled') return false;
|
||||
if (activeStatTab === 'attended' && briefing.attendanceStatus !== 'attended') return false;
|
||||
|
||||
// 거래처 필터 (다중선택 - 빈 배열 = 전체)
|
||||
if (partnerFilters.length > 0) {
|
||||
if (!partnerFilters.includes(briefing.partnerId)) return false;
|
||||
}
|
||||
|
||||
// 구분 필터 (목업에서는 모두 통과)
|
||||
// if (typeFilter !== 'all') return false;
|
||||
|
||||
// 참석자 필터 (다중선택 - 빈 배열 = 전체)
|
||||
if (attendeeFilters.length > 0) {
|
||||
// 목업 데이터에 attendeeId가 없으므로 임시로 'hong'으로 처리
|
||||
const attendeeId = 'hong';
|
||||
if (!attendeeFilters.includes(attendeeId)) return false;
|
||||
}
|
||||
|
||||
// 상태 필터 (참석 상태 기준)
|
||||
if (statusFilter !== 'all' && briefing.attendanceStatus !== statusFilter) return false;
|
||||
|
||||
// 검색 필터
|
||||
if (searchValue) {
|
||||
const search = searchValue.toLowerCase();
|
||||
return (
|
||||
briefing.title.toLowerCase().includes(search) ||
|
||||
briefing.briefingCode.toLowerCase().includes(search) ||
|
||||
briefing.partnerName.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [briefings, activeStatTab, partnerFilters, attendeeFilters, statusFilter, searchValue]);
|
||||
|
||||
// 정렬
|
||||
const sortedBriefings = useMemo(() => {
|
||||
const sorted = [...filteredBriefings];
|
||||
switch (sortBy) {
|
||||
case 'latest':
|
||||
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
break;
|
||||
case 'oldest':
|
||||
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
break;
|
||||
case 'bidDateDesc':
|
||||
sorted.sort((a, b) => {
|
||||
if (!a.bidDate) return 1;
|
||||
if (!b.bidDate) return -1;
|
||||
return new Date(b.bidDate).getTime() - new Date(a.bidDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'partnerAsc':
|
||||
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName));
|
||||
break;
|
||||
case 'partnerDesc':
|
||||
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName));
|
||||
break;
|
||||
case 'projectAsc':
|
||||
sorted.sort((a, b) => a.title.localeCompare(b.title));
|
||||
break;
|
||||
case 'projectDesc':
|
||||
sorted.sort((a, b) => b.title.localeCompare(a.title));
|
||||
break;
|
||||
}
|
||||
return sorted;
|
||||
}, [filteredBriefings, sortBy]);
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(sortedBriefings.length / itemsPerPage);
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (currentPage - 1) * itemsPerPage;
|
||||
return sortedBriefings.slice(start, start + itemsPerPage);
|
||||
}, [sortedBriefings, currentPage, itemsPerPage]);
|
||||
|
||||
// 핸들러
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchValue(value);
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const handleToggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggleSelectAll = useCallback(() => {
|
||||
if (selectedItems.size === paginatedData.length) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(paginatedData.map((b) => b.id)));
|
||||
}
|
||||
}, [selectedItems.size, paginatedData]);
|
||||
// 날짜 범위
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
|
||||
// ===== 핸들러 =====
|
||||
const handleRowClick = useCallback(
|
||||
(briefing: SiteBriefing) => {
|
||||
router.push(`/ko/construction/project/bidding/site-briefings/${briefing.id}`);
|
||||
(item: SiteBriefing) => {
|
||||
router.push(`/ko/construction/project/bidding/site-briefings/${item.id}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(item: SiteBriefing) => {
|
||||
router.push(`/ko/construction/project/bidding/site-briefings/${item.id}/edit`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
@@ -266,363 +117,241 @@ export default function SiteBriefingListClient({ initialData = [] }: SiteBriefin
|
||||
router.push('/ko/construction/project/bidding/site-briefings/new');
|
||||
}, [router]);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(e: React.MouseEvent, briefingId: string) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/ko/construction/project/bidding/site-briefings/${briefingId}/edit`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
// ===== UniversalListPage Config (최소 버전) =====
|
||||
const config: UniversalListConfig<SiteBriefing> = useMemo(
|
||||
() => ({
|
||||
// 페이지 기본 정보
|
||||
title: '현장설명회 관리',
|
||||
description: '현장설명회를 등록하고 관리합니다',
|
||||
icon: Calendar,
|
||||
basePath: '/construction/project/bidding/site-briefings',
|
||||
|
||||
const handleDeleteClick = useCallback((e: React.MouseEvent, briefingId: string) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTargetId(briefingId);
|
||||
setDeleteDialogOpen(true);
|
||||
}, []);
|
||||
// ID 추출
|
||||
idField: 'id',
|
||||
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
if (!deleteTargetId) return;
|
||||
// API 액션
|
||||
actions: {
|
||||
getList: async () => {
|
||||
const result = await getSiteBriefingList({ size: 100 });
|
||||
if (result.success && result.data) {
|
||||
return {
|
||||
success: true,
|
||||
data: result.data.items,
|
||||
totalCount: result.data.total,
|
||||
};
|
||||
}
|
||||
return { success: false, error: result.error };
|
||||
},
|
||||
deleteItem: async (id: string) => {
|
||||
const result = await deleteSiteBriefing(id);
|
||||
return { success: result.success, error: result.error };
|
||||
},
|
||||
deleteBulk: async (ids: string[]) => {
|
||||
const result = await deleteSiteBriefings(ids);
|
||||
return { success: result.success, error: result.error };
|
||||
},
|
||||
},
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await deleteSiteBriefing(deleteTargetId);
|
||||
if (result.success) {
|
||||
toast.success('현장설명회가 삭제되었습니다.');
|
||||
setBriefings((prev) => prev.filter((b) => b.id !== deleteTargetId));
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(deleteTargetId);
|
||||
return newSet;
|
||||
// 삭제 확인 메시지
|
||||
deleteConfirmMessage: {
|
||||
title: '현장설명회 삭제',
|
||||
description: '선택한 현장설명회를 삭제하시겠습니까?',
|
||||
},
|
||||
|
||||
// 테이블 컬럼
|
||||
columns: tableColumns,
|
||||
|
||||
// 클라이언트 사이드 필터링
|
||||
clientSideFiltering: true,
|
||||
itemsPerPage: 20,
|
||||
|
||||
// 검색 플레이스홀더
|
||||
searchPlaceholder: '현장번호, 거래처, 현장명 검색',
|
||||
|
||||
// 필터 설정
|
||||
filterConfig: [
|
||||
{ key: 'partner', label: '거래처', type: 'multi', options: MOCK_PARTNERS },
|
||||
{ key: 'type', label: '구분', type: 'single', options: TYPE_OPTIONS },
|
||||
{ key: 'attendee', label: '참석자', type: 'multi', options: MOCK_ATTENDEES },
|
||||
{ key: 'status', label: '상태', type: 'single', options: STATUS_OPTIONS },
|
||||
{ key: 'sortBy', label: '정렬', type: 'single', options: SORT_OPTIONS },
|
||||
],
|
||||
initialFilters: {
|
||||
partner: [],
|
||||
type: 'all',
|
||||
attendee: [],
|
||||
status: 'all',
|
||||
sortBy: 'latest',
|
||||
},
|
||||
filterTitle: '현장설명회 필터',
|
||||
|
||||
// 커스텀 필터 함수 (activeStatTab 필터링 포함)
|
||||
customFilterFn: (items, filterValues) => {
|
||||
return items.filter((item) => {
|
||||
// Stats 탭 필터
|
||||
if (activeStatTab === 'scheduled' && item.attendanceStatus !== 'scheduled') return false;
|
||||
if (activeStatTab === 'attended' && item.attendanceStatus !== 'attended') return false;
|
||||
|
||||
// 거래처 필터 (다중선택)
|
||||
const partnerFilters = filterValues.partner as string[];
|
||||
if (partnerFilters?.length > 0 && !partnerFilters.includes(item.partnerId)) return false;
|
||||
|
||||
// 상태 필터
|
||||
const statusFilter = filterValues.status as string;
|
||||
if (statusFilter && statusFilter !== 'all' && item.attendanceStatus !== statusFilter) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setDeleteDialogOpen(false);
|
||||
setDeleteTargetId(null);
|
||||
}
|
||||
}, [deleteTargetId]);
|
||||
},
|
||||
|
||||
const handleBulkDeleteClick = useCallback(() => {
|
||||
if (selectedItems.size === 0) {
|
||||
toast.warning('삭제할 항목을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
setBulkDeleteDialogOpen(true);
|
||||
}, [selectedItems.size]);
|
||||
// 커스텀 정렬 함수
|
||||
customSortFn: (items, filterValues) => {
|
||||
const sorted = [...items];
|
||||
const sortBy = (filterValues.sortBy as string) || 'latest';
|
||||
if (sortBy === 'latest') {
|
||||
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
} else if (sortBy === 'oldest') {
|
||||
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
}
|
||||
return sorted;
|
||||
},
|
||||
|
||||
const handleBulkDeleteConfirm = useCallback(async () => {
|
||||
if (selectedItems.size === 0) return;
|
||||
// 날짜 범위 선택기
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const ids = Array.from(selectedItems);
|
||||
const result = await deleteSiteBriefings(ids);
|
||||
if (result.success) {
|
||||
toast.success(`${result.deletedCount}개 항목이 삭제되었습니다.`);
|
||||
await loadData();
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
toast.error(result.error || '일괄 삭제에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('일괄 삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setBulkDeleteDialogOpen(false);
|
||||
}
|
||||
}, [selectedItems, loadData]);
|
||||
// 등록 버튼
|
||||
createButton: {
|
||||
label: '현장설명회 등록',
|
||||
onClick: handleCreate,
|
||||
},
|
||||
|
||||
// ===== filterConfig 기반 통합 필터 시스템 =====
|
||||
const filterConfig: FilterFieldConfig[] = useMemo(() => [
|
||||
{
|
||||
key: 'partner',
|
||||
label: '거래처',
|
||||
type: 'multi',
|
||||
options: MOCK_PARTNERS.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
label: '구분',
|
||||
type: 'single',
|
||||
options: TYPE_OPTIONS.filter(o => o.value !== 'all').map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '전체',
|
||||
},
|
||||
{
|
||||
key: 'attendee',
|
||||
label: '참석자',
|
||||
type: 'multi',
|
||||
options: MOCK_ATTENDEES.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'single',
|
||||
options: STATUS_OPTIONS.filter(o => o.value !== 'all').map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '전체',
|
||||
},
|
||||
{
|
||||
key: 'sortBy',
|
||||
label: '정렬',
|
||||
type: 'single',
|
||||
options: SORT_OPTIONS.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '최신순',
|
||||
},
|
||||
], []);
|
||||
// Stats 카드
|
||||
computeStats: (): StatCard[] => [
|
||||
{
|
||||
label: '전체 현장설명회',
|
||||
value: 0,
|
||||
icon: CalendarDays,
|
||||
iconColor: 'text-blue-600',
|
||||
onClick: () => setActiveStatTab('all'),
|
||||
isActive: activeStatTab === 'all',
|
||||
},
|
||||
{
|
||||
label: '참석예정',
|
||||
value: 0,
|
||||
icon: CalendarClock,
|
||||
iconColor: 'text-orange-500',
|
||||
onClick: () => setActiveStatTab('scheduled'),
|
||||
isActive: activeStatTab === 'scheduled',
|
||||
},
|
||||
{
|
||||
label: '참석완료',
|
||||
value: 0,
|
||||
icon: CalendarCheck,
|
||||
iconColor: 'text-green-600',
|
||||
onClick: () => setActiveStatTab('attended'),
|
||||
isActive: activeStatTab === 'attended',
|
||||
},
|
||||
],
|
||||
|
||||
const filterValues: FilterValues = useMemo(() => ({
|
||||
partner: partnerFilters,
|
||||
type: typeFilter,
|
||||
attendee: attendeeFilters,
|
||||
status: statusFilter,
|
||||
sortBy: sortBy,
|
||||
}), [partnerFilters, typeFilter, attendeeFilters, statusFilter, sortBy]);
|
||||
// 테이블 행 렌더링 (최소)
|
||||
renderTableRow: (
|
||||
item: SiteBriefing,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<SiteBriefing>
|
||||
) => {
|
||||
const displayStatus = item.attendanceStatus || 'scheduled';
|
||||
|
||||
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
|
||||
switch (key) {
|
||||
case 'partner':
|
||||
setPartnerFilters(value as string[]);
|
||||
break;
|
||||
case 'type':
|
||||
setTypeFilter(value as string);
|
||||
break;
|
||||
case 'attendee':
|
||||
setAttendeeFilters(value as string[]);
|
||||
break;
|
||||
case 'status':
|
||||
setStatusFilter(value as string);
|
||||
break;
|
||||
case 'sortBy':
|
||||
setSortBy(value as string);
|
||||
break;
|
||||
}
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
return (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleRowClick(item)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={handlers.isSelected} onCheckedChange={handlers.onToggle} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell>{item.briefingCode}</TableCell>
|
||||
<TableCell>{item.partnerName}</TableCell>
|
||||
<TableCell>{item.title}</TableCell>
|
||||
<TableCell className="text-center">{item.briefingDate}</TableCell>
|
||||
<TableCell className="text-center">온라인</TableCell>
|
||||
<TableCell className="text-center">홍길동</TableCell>
|
||||
<TableCell className="text-center">{item.bidDate || '-'}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={STATUS_STYLES[displayStatus]}>{STATUS_LABELS[displayStatus]}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{handlers.isSelected && (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(item);
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlers.onDelete?.(item);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
|
||||
const handleFilterReset = useCallback(() => {
|
||||
setPartnerFilters([]);
|
||||
setTypeFilter('all');
|
||||
setAttendeeFilters([]);
|
||||
setStatusFilter('all');
|
||||
setSortBy('latest');
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
// 모바일 카드 렌더링 (최소)
|
||||
renderMobileCard: (
|
||||
item: SiteBriefing,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<SiteBriefing>
|
||||
) => {
|
||||
const displayStatus = item.attendanceStatus || 'scheduled';
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = useCallback(
|
||||
(briefing: SiteBriefing, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(briefing.id);
|
||||
// 참석 상태 표시
|
||||
const displayStatus = briefing.attendanceStatus || 'scheduled';
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={briefing.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleRowClick(briefing)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleSelection(briefing.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell>{briefing.briefingCode}</TableCell>
|
||||
<TableCell>{briefing.partnerName}</TableCell>
|
||||
<TableCell>{briefing.title}</TableCell>
|
||||
<TableCell className="text-center">{briefing.briefingDate}</TableCell>
|
||||
<TableCell className="text-center">온라인</TableCell>
|
||||
<TableCell className="text-center">홍길동</TableCell>
|
||||
<TableCell className="text-center">{briefing.bidDate || '-'}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={STATUS_STYLES[displayStatus]}>
|
||||
{STATUS_LABELS[displayStatus]}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{isSelected && (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => handleEdit(e, briefing.id)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={(e) => handleDeleteClick(e, briefing.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
[selectedItems, handleToggleSelection, handleRowClick, handleEdit, handleDeleteClick]
|
||||
return (
|
||||
<MobileCard
|
||||
key={item.id}
|
||||
title={item.title}
|
||||
subtitle={item.briefingCode}
|
||||
badge={STATUS_LABELS[displayStatus]}
|
||||
badgeVariant="secondary"
|
||||
isSelected={handlers.isSelected}
|
||||
onToggle={handlers.onToggle}
|
||||
onClick={() => handleRowClick(item)}
|
||||
details={[
|
||||
{ label: '거래처', value: item.partnerName },
|
||||
{ label: '현장설명회일', value: item.briefingDate },
|
||||
{ label: '구분', value: '온라인' },
|
||||
{ label: '참석자', value: '홍길동' },
|
||||
{ label: '입찰일', value: item.bidDate || '-' },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}),
|
||||
[handleRowClick, handleEdit, handleCreate, activeStatTab, startDate, endDate]
|
||||
);
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = useCallback(
|
||||
(briefing: SiteBriefing, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
|
||||
const displayStatus = briefing.attendanceStatus || 'scheduled';
|
||||
|
||||
return (
|
||||
<MobileCard
|
||||
title={briefing.title}
|
||||
subtitle={briefing.briefingCode}
|
||||
badge={STATUS_LABELS[displayStatus]}
|
||||
badgeVariant="secondary"
|
||||
isSelected={isSelected}
|
||||
onToggle={onToggle}
|
||||
onClick={() => handleRowClick(briefing)}
|
||||
details={[
|
||||
{ label: '거래처', value: briefing.partnerName },
|
||||
{ label: '현장설명회일', value: briefing.briefingDate },
|
||||
{ label: '구분', value: '온라인' },
|
||||
{ label: '참석자', value: '홍길동' },
|
||||
{ label: '입찰일', value: briefing.bidDate || '-' },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[handleRowClick]
|
||||
);
|
||||
|
||||
// 헤더 액션 (등록 버튼 + 날짜 필터)
|
||||
const headerActions = (
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
extraActions={
|
||||
<Button onClick={handleCreate}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
현장설명회 등록
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
// Stats 카드 데이터 (StatCards 컴포넌트용)
|
||||
const statsCardsData: StatCard[] = [
|
||||
{
|
||||
label: '전체 현장설명회',
|
||||
value: statsData.total,
|
||||
icon: CalendarDays,
|
||||
iconColor: 'text-blue-600',
|
||||
onClick: () => setActiveStatTab('all'),
|
||||
isActive: activeStatTab === 'all',
|
||||
},
|
||||
{
|
||||
label: '참석예정 현장설명회',
|
||||
value: statsData.scheduled,
|
||||
icon: CalendarClock,
|
||||
iconColor: 'text-orange-500',
|
||||
onClick: () => setActiveStatTab('scheduled'),
|
||||
isActive: activeStatTab === 'scheduled',
|
||||
},
|
||||
{
|
||||
label: '참석완료 현장설명회',
|
||||
value: statsData.attended,
|
||||
icon: CalendarCheck,
|
||||
iconColor: 'text-green-600',
|
||||
onClick: () => setActiveStatTab('attended'),
|
||||
isActive: activeStatTab === 'attended',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedListTemplateV2
|
||||
title="현장설명회 관리"
|
||||
description="현장설명회를 등록하고 관리합니다"
|
||||
icon={Calendar}
|
||||
headerActions={headerActions}
|
||||
stats={statsCardsData}
|
||||
filterConfig={filterConfig}
|
||||
filterValues={filterValues}
|
||||
onFilterChange={handleFilterChange}
|
||||
onFilterReset={handleFilterReset}
|
||||
filterTitle="현장설명회 필터"
|
||||
searchValue={searchValue}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="현장번호, 거래처, 현장명 검색"
|
||||
tableColumns={tableColumns}
|
||||
data={paginatedData}
|
||||
allData={sortedBriefings}
|
||||
getItemId={(item) => item.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
onBulkDelete={handleBulkDeleteClick}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: sortedBriefings.length,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 단일 삭제 다이얼로그 */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>현장설명회 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 현장설명회를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteConfirm}>삭제</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 일괄 삭제 다이얼로그 */}
|
||||
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>현장설명회 일괄 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 {selectedItems.size}개 현장설명회를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleBulkDeleteConfirm}>삭제</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <UniversalListPage config={config} initialData={initialData} />;
|
||||
}
|
||||
|
||||
@@ -1,33 +1,30 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 현장관리 - UniversalListPage 마이그레이션
|
||||
*
|
||||
* 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환
|
||||
* - 클라이언트 사이드 필터링 (검색, 필터, 정렬)
|
||||
* - Stats 카드 클릭 필터링 (activeStatTab)
|
||||
* - DateRangeSelector (headerActions → dateRangeSelector config)
|
||||
* - filterConfig (multi: 거래처 / single: 상태, 정렬)
|
||||
* - 삭제 기능 (deleteConfirmMessage로 AlertDialog 대체)
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Building2, HardHat, AlertCircle, Pencil } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox';
|
||||
import { IntegratedListTemplateV2, TableColumn, StatCard, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { MobileCard } from '@/components/molecules/MobileCard';
|
||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
type SelectionHandlers,
|
||||
type RowClickHandlers,
|
||||
type StatCard,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import type { Site, SiteStats } from './types';
|
||||
import {
|
||||
SITE_STATUS_OPTIONS,
|
||||
@@ -38,8 +35,7 @@ import {
|
||||
import { getSiteList, getSiteStats, deleteSite, deleteSites } from './actions';
|
||||
|
||||
// 테이블 컬럼 정의
|
||||
// 순서: 체크박스, 번호, 현장번호, 거래처, 현장명, 위치, 상태, 작업
|
||||
const tableColumns: TableColumn[] = [
|
||||
const tableColumns = [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'siteCode', label: '현장번호', className: 'w-[120px]' },
|
||||
{ key: 'partnerName', label: '거래처', className: 'w-[100px]' },
|
||||
@@ -50,7 +46,7 @@ const tableColumns: TableColumn[] = [
|
||||
];
|
||||
|
||||
// 목업 거래처 목록
|
||||
const MOCK_PARTNERS: MultiSelectOption[] = [
|
||||
const MOCK_PARTNERS = [
|
||||
{ value: '1', label: '회사명A' },
|
||||
{ value: '2', label: '회사명B' },
|
||||
{ value: '3', label: '회사명C' },
|
||||
@@ -67,145 +63,24 @@ export default function SiteManagementListClient({
|
||||
}: SiteManagementListClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// 상태
|
||||
const [sites, setSites] = useState<Site[]>(initialData);
|
||||
const [stats, setStats] = useState<SiteStats | null>(initialStats || null);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [partnerFilters, setPartnerFilters] = useState<string[]>([]);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [sortBy, setSortBy] = useState<string>('latest');
|
||||
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
|
||||
const [activeStatTab, setActiveStatTab] = useState<'all' | 'construction' | 'unregistered'>('all');
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
|
||||
const [activeStatTab, setActiveStatTab] = useState<'all' | 'construction' | 'unregistered'>('all');
|
||||
const itemsPerPage = 20;
|
||||
const [stats, setStats] = useState<SiteStats | null>(initialStats || null);
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [listResult, statsResult] = await Promise.all([
|
||||
getSiteList({
|
||||
size: 1000,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
}),
|
||||
getSiteStats(),
|
||||
]);
|
||||
|
||||
if (listResult.success && listResult.data) {
|
||||
setSites(listResult.data.items);
|
||||
}
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStats(statsResult.data);
|
||||
}
|
||||
} catch {
|
||||
toast.error('데이터 로드에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [startDate, endDate]);
|
||||
|
||||
// 초기 데이터가 없으면 로드
|
||||
// Stats 로드
|
||||
useEffect(() => {
|
||||
if (initialData.length === 0) {
|
||||
loadData();
|
||||
if (!initialStats) {
|
||||
getSiteStats().then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setStats(result.data);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [initialData.length, loadData]);
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredSites = useMemo(() => {
|
||||
return sites.filter((site) => {
|
||||
// 상태 탭 필터
|
||||
if (activeStatTab === 'construction' && site.status !== 'active') return false;
|
||||
if (activeStatTab === 'unregistered' && site.status !== 'unregistered') return false;
|
||||
|
||||
// 거래처 필터
|
||||
if (partnerFilters.length > 0) {
|
||||
if (!partnerFilters.includes(site.partnerId)) return false;
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (statusFilter !== 'all' && site.status !== statusFilter) return false;
|
||||
|
||||
// 검색 필터
|
||||
if (searchValue) {
|
||||
const search = searchValue.toLowerCase();
|
||||
return (
|
||||
site.siteName.toLowerCase().includes(search) ||
|
||||
site.siteCode.toLowerCase().includes(search) ||
|
||||
site.partnerName.toLowerCase().includes(search) ||
|
||||
site.address.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [sites, activeStatTab, partnerFilters, statusFilter, searchValue]);
|
||||
|
||||
// 정렬
|
||||
const sortedSites = useMemo(() => {
|
||||
const sorted = [...filteredSites];
|
||||
switch (sortBy) {
|
||||
case 'latest':
|
||||
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
break;
|
||||
case 'oldest':
|
||||
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
break;
|
||||
case 'partnerNameAsc':
|
||||
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
|
||||
break;
|
||||
case 'partnerNameDesc':
|
||||
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
|
||||
break;
|
||||
case 'siteNameAsc':
|
||||
sorted.sort((a, b) => a.siteName.localeCompare(b.siteName, 'ko'));
|
||||
break;
|
||||
case 'siteNameDesc':
|
||||
sorted.sort((a, b) => b.siteName.localeCompare(a.siteName, 'ko'));
|
||||
break;
|
||||
}
|
||||
return sorted;
|
||||
}, [filteredSites, sortBy]);
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(sortedSites.length / itemsPerPage);
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (currentPage - 1) * itemsPerPage;
|
||||
return sortedSites.slice(start, start + itemsPerPage);
|
||||
}, [sortedSites, currentPage, itemsPerPage]);
|
||||
|
||||
// 핸들러
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchValue(value);
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const handleToggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggleSelectAll = useCallback(() => {
|
||||
if (selectedItems.size === paginatedData.length) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(paginatedData.map((s) => s.id)));
|
||||
}
|
||||
}, [selectedItems.size, paginatedData]);
|
||||
}, [initialStats]);
|
||||
|
||||
// ===== 핸들러 =====
|
||||
const handleRowClick = useCallback(
|
||||
(site: Site) => {
|
||||
router.push(`/ko/construction/order/site-management/${site.id}`);
|
||||
@@ -214,355 +89,256 @@ export default function SiteManagementListClient({
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(e: React.MouseEvent, siteId: string) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/ko/construction/order/site-management/${siteId}/edit`);
|
||||
(site: Site) => {
|
||||
router.push(`/ko/construction/order/site-management/${site.id}/edit`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleDeleteClick = useCallback((e: React.MouseEvent, siteId: string) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTargetId(siteId);
|
||||
setDeleteDialogOpen(true);
|
||||
}, []);
|
||||
// ===== UniversalListPage Config =====
|
||||
const config: UniversalListConfig<Site> = useMemo(
|
||||
() => ({
|
||||
// 페이지 기본 정보
|
||||
title: '현장관리',
|
||||
description: '현장을 관리합니다',
|
||||
icon: Building2,
|
||||
basePath: '/construction/order/site-management',
|
||||
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
if (!deleteTargetId) return;
|
||||
// ID 추출
|
||||
idField: 'id',
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await deleteSite(deleteTargetId);
|
||||
if (result.success) {
|
||||
toast.success('현장이 삭제되었습니다.');
|
||||
setSites((prev) => prev.filter((s) => s.id !== deleteTargetId));
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(deleteTargetId);
|
||||
return newSet;
|
||||
// API 액션
|
||||
actions: {
|
||||
getList: async () => {
|
||||
const result = await getSiteList({
|
||||
size: 1000,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
});
|
||||
if (result.success && result.data) {
|
||||
return {
|
||||
success: true,
|
||||
data: result.data.items,
|
||||
totalCount: result.data.total,
|
||||
};
|
||||
}
|
||||
return { success: false, error: result.error };
|
||||
},
|
||||
deleteItem: async (id: string) => {
|
||||
const result = await deleteSite(id);
|
||||
return { success: result.success, error: result.error };
|
||||
},
|
||||
deleteBulk: async (ids: string[]) => {
|
||||
const result = await deleteSites(ids);
|
||||
return { success: result.success, error: result.error };
|
||||
},
|
||||
},
|
||||
|
||||
// 테이블 컬럼
|
||||
columns: tableColumns,
|
||||
|
||||
// 클라이언트 사이드 필터링
|
||||
clientSideFiltering: true,
|
||||
itemsPerPage: 20,
|
||||
|
||||
// 검색 필터
|
||||
searchPlaceholder: '현장번호, 거래처, 현장명, 위치 검색',
|
||||
searchFilter: (item, searchValue) => {
|
||||
const search = searchValue.toLowerCase();
|
||||
return (
|
||||
item.siteName.toLowerCase().includes(search) ||
|
||||
item.siteCode.toLowerCase().includes(search) ||
|
||||
item.partnerName.toLowerCase().includes(search) ||
|
||||
item.address.toLowerCase().includes(search)
|
||||
);
|
||||
},
|
||||
|
||||
// 필터 설정
|
||||
filterConfig: [
|
||||
{
|
||||
key: 'partner',
|
||||
label: '거래처',
|
||||
type: 'multi',
|
||||
options: MOCK_PARTNERS,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'single',
|
||||
options: SITE_STATUS_OPTIONS.filter((o) => o.value !== 'all'),
|
||||
},
|
||||
{
|
||||
key: 'sortBy',
|
||||
label: '정렬',
|
||||
type: 'single',
|
||||
options: SITE_SORT_OPTIONS,
|
||||
},
|
||||
],
|
||||
initialFilters: {
|
||||
partner: [],
|
||||
status: 'all',
|
||||
sortBy: 'latest',
|
||||
},
|
||||
filterTitle: '현장 필터',
|
||||
|
||||
// 커스텀 필터 함수
|
||||
customFilterFn: (items, filterValues) => {
|
||||
return items.filter((item) => {
|
||||
// Stats 탭 필터
|
||||
if (activeStatTab === 'construction' && item.status !== 'active') return false;
|
||||
if (activeStatTab === 'unregistered' && item.status !== 'unregistered') return false;
|
||||
|
||||
// 거래처 필터 (다중선택)
|
||||
const partnerFilters = filterValues.partner as string[];
|
||||
if (partnerFilters?.length > 0 && !partnerFilters.includes(item.partnerId)) return false;
|
||||
|
||||
// 상태 필터 (단일선택)
|
||||
const statusFilter = filterValues.status as string;
|
||||
if (statusFilter && statusFilter !== 'all' && item.status !== statusFilter) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setDeleteDialogOpen(false);
|
||||
setDeleteTargetId(null);
|
||||
}
|
||||
}, [deleteTargetId]);
|
||||
},
|
||||
|
||||
const handleBulkDeleteClick = useCallback(() => {
|
||||
if (selectedItems.size === 0) {
|
||||
toast.warning('삭제할 항목을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
setBulkDeleteDialogOpen(true);
|
||||
}, [selectedItems.size]);
|
||||
// 커스텀 정렬 함수
|
||||
customSortFn: (items, filterValues) => {
|
||||
const sorted = [...items];
|
||||
const sortBy = (filterValues.sortBy as string) || 'latest';
|
||||
|
||||
const handleBulkDeleteConfirm = useCallback(async () => {
|
||||
if (selectedItems.size === 0) return;
|
||||
switch (sortBy) {
|
||||
case 'latest':
|
||||
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
break;
|
||||
case 'oldest':
|
||||
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
break;
|
||||
case 'partnerNameAsc':
|
||||
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
|
||||
break;
|
||||
case 'partnerNameDesc':
|
||||
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
|
||||
break;
|
||||
case 'siteNameAsc':
|
||||
sorted.sort((a, b) => a.siteName.localeCompare(b.siteName, 'ko'));
|
||||
break;
|
||||
case 'siteNameDesc':
|
||||
sorted.sort((a, b) => b.siteName.localeCompare(a.siteName, 'ko'));
|
||||
break;
|
||||
}
|
||||
return sorted;
|
||||
},
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const ids = Array.from(selectedItems);
|
||||
const result = await deleteSites(ids);
|
||||
if (result.success) {
|
||||
toast.success(`${result.deletedCount}개 항목이 삭제되었습니다.`);
|
||||
await loadData();
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
toast.error(result.error || '일괄 삭제에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('일괄 삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setBulkDeleteDialogOpen(false);
|
||||
}
|
||||
}, [selectedItems, loadData]);
|
||||
// 공통 헤더 옵션
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
|
||||
// 테이블 행 렌더링
|
||||
// 순서: 체크박스, 번호, 현장번호, 거래처, 현장명, 위치, 상태, 작업
|
||||
const renderTableRow = useCallback(
|
||||
(site: Site, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(site.id);
|
||||
// Stats 카드
|
||||
computeStats: (): StatCard[] => [
|
||||
{
|
||||
label: '전체 현장',
|
||||
value: stats?.total ?? 0,
|
||||
icon: Building2,
|
||||
iconColor: 'text-blue-600',
|
||||
onClick: () => setActiveStatTab('all'),
|
||||
isActive: activeStatTab === 'all',
|
||||
},
|
||||
{
|
||||
label: '시공 현장',
|
||||
value: stats?.construction ?? 0,
|
||||
icon: HardHat,
|
||||
iconColor: 'text-green-600',
|
||||
onClick: () => setActiveStatTab('construction'),
|
||||
isActive: activeStatTab === 'construction',
|
||||
},
|
||||
{
|
||||
label: '미등록 현장',
|
||||
value: stats?.unregistered ?? 0,
|
||||
icon: AlertCircle,
|
||||
iconColor: 'text-red-500',
|
||||
onClick: () => setActiveStatTab('unregistered'),
|
||||
isActive: activeStatTab === 'unregistered',
|
||||
},
|
||||
],
|
||||
|
||||
return (
|
||||
// 삭제 확인 메시지 (AlertDialog 대체)
|
||||
deleteConfirmMessage: {
|
||||
title: '현장 삭제',
|
||||
description: '선택한 현장을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.',
|
||||
},
|
||||
|
||||
// 테이블 행 렌더링
|
||||
renderTableRow: (
|
||||
item: Site,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<Site>
|
||||
) => (
|
||||
<TableRow
|
||||
key={site.id}
|
||||
key={item.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleRowClick(site)}
|
||||
onClick={() => handleRowClick(item)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleSelection(site.id)}
|
||||
/>
|
||||
<Checkbox checked={handlers.isSelected} onCheckedChange={handlers.onToggle} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell>{site.siteCode}</TableCell>
|
||||
<TableCell>{site.partnerName}</TableCell>
|
||||
<TableCell>{site.siteName}</TableCell>
|
||||
<TableCell>{site.address || '-'}</TableCell>
|
||||
<TableCell>{item.siteCode}</TableCell>
|
||||
<TableCell>{item.partnerName}</TableCell>
|
||||
<TableCell>{item.siteName}</TableCell>
|
||||
<TableCell>{item.address || '-'}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={SITE_STATUS_STYLES[site.status]}>
|
||||
{SITE_STATUS_LABELS[site.status]}
|
||||
<span className={SITE_STATUS_STYLES[item.status]}>
|
||||
{SITE_STATUS_LABELS[item.status]}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{isSelected && (
|
||||
{handlers.isSelected && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => handleEdit(e, site.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(item);
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
[selectedItems, handleToggleSelection, handleRowClick, handleEdit]
|
||||
);
|
||||
),
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = useCallback(
|
||||
(site: Site, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
|
||||
return (
|
||||
// 모바일 카드 렌더링
|
||||
renderMobileCard: (
|
||||
item: Site,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<Site>
|
||||
) => (
|
||||
<MobileCard
|
||||
title={site.siteName}
|
||||
subtitle={site.siteCode}
|
||||
badge={SITE_STATUS_LABELS[site.status]}
|
||||
key={item.id}
|
||||
title={item.siteName}
|
||||
subtitle={item.siteCode}
|
||||
badge={SITE_STATUS_LABELS[item.status]}
|
||||
badgeVariant="secondary"
|
||||
isSelected={isSelected}
|
||||
onToggle={onToggle}
|
||||
onClick={() => handleRowClick(site)}
|
||||
isSelected={handlers.isSelected}
|
||||
onToggle={handlers.onToggle}
|
||||
onClick={() => handleRowClick(item)}
|
||||
details={[
|
||||
{ label: '거래처', value: site.partnerName },
|
||||
{ label: '위치', value: site.address || '-' },
|
||||
{ label: '거래처', value: item.partnerName },
|
||||
{ label: '위치', value: item.address || '-' },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[handleRowClick]
|
||||
),
|
||||
}),
|
||||
[startDate, endDate, activeStatTab, stats, handleRowClick, handleEdit]
|
||||
);
|
||||
|
||||
// 헤더 액션 (날짜 필터만)
|
||||
const headerActions = (
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
/>
|
||||
);
|
||||
|
||||
// Stats 카드 데이터 (전체 현장, 시공 현장, 미등록 현장)
|
||||
const statsCardsData: StatCard[] = [
|
||||
{
|
||||
label: '전체 현장',
|
||||
value: stats?.total ?? 0,
|
||||
icon: Building2,
|
||||
iconColor: 'text-blue-600',
|
||||
onClick: () => setActiveStatTab('all'),
|
||||
isActive: activeStatTab === 'all',
|
||||
},
|
||||
{
|
||||
label: '시공 현장',
|
||||
value: stats?.construction ?? 0,
|
||||
icon: HardHat,
|
||||
iconColor: 'text-green-600',
|
||||
onClick: () => setActiveStatTab('construction'),
|
||||
isActive: activeStatTab === 'construction',
|
||||
},
|
||||
{
|
||||
label: '미등록 현장',
|
||||
value: stats?.unregistered ?? 0,
|
||||
icon: AlertCircle,
|
||||
iconColor: 'text-red-500',
|
||||
onClick: () => setActiveStatTab('unregistered'),
|
||||
isActive: activeStatTab === 'unregistered',
|
||||
},
|
||||
];
|
||||
|
||||
// ===== filterConfig 기반 통합 필터 시스템 =====
|
||||
const filterConfig: FilterFieldConfig[] = useMemo(() => [
|
||||
{
|
||||
key: 'partner',
|
||||
label: '거래처',
|
||||
type: 'multi',
|
||||
options: MOCK_PARTNERS.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'single',
|
||||
options: SITE_STATUS_OPTIONS.filter(o => o.value !== 'all').map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'sortBy',
|
||||
label: '정렬',
|
||||
type: 'single',
|
||||
options: SITE_SORT_OPTIONS.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '최신순',
|
||||
},
|
||||
], []);
|
||||
|
||||
const filterValues: FilterValues = useMemo(() => ({
|
||||
partner: partnerFilters,
|
||||
status: statusFilter,
|
||||
sortBy: sortBy,
|
||||
}), [partnerFilters, statusFilter, sortBy]);
|
||||
|
||||
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
|
||||
switch (key) {
|
||||
case 'partner':
|
||||
setPartnerFilters(value as string[]);
|
||||
break;
|
||||
case 'status':
|
||||
setStatusFilter(value as string);
|
||||
break;
|
||||
case 'sortBy':
|
||||
setSortBy(value as string);
|
||||
break;
|
||||
}
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const handleFilterReset = useCallback(() => {
|
||||
setPartnerFilters([]);
|
||||
setStatusFilter('all');
|
||||
setSortBy('latest');
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
// 테이블 헤더 액션 (총 건수 + 필터들)
|
||||
const tableHeaderActions = (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
총 {sortedSites.length}건
|
||||
</span>
|
||||
|
||||
{/* 거래처 필터 */}
|
||||
<MultiSelectCombobox
|
||||
options={MOCK_PARTNERS}
|
||||
value={partnerFilters}
|
||||
onChange={setPartnerFilters}
|
||||
placeholder="거래처"
|
||||
searchPlaceholder="거래처 검색..."
|
||||
className="w-[120px]"
|
||||
/>
|
||||
|
||||
{/* 상태 필터 */}
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SITE_STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 정렬 */}
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="최신순" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SITE_SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedListTemplateV2
|
||||
title="현장관리"
|
||||
description="현장을 관리합니다"
|
||||
icon={Building2}
|
||||
headerActions={headerActions}
|
||||
stats={statsCardsData}
|
||||
tableHeaderActions={tableHeaderActions}
|
||||
filterConfig={filterConfig}
|
||||
filterValues={filterValues}
|
||||
onFilterChange={handleFilterChange}
|
||||
onFilterReset={handleFilterReset}
|
||||
filterTitle="현장 필터"
|
||||
searchValue={searchValue}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="현장번호, 거래처, 현장명, 위치 검색"
|
||||
tableColumns={tableColumns}
|
||||
data={paginatedData}
|
||||
allData={sortedSites}
|
||||
getItemId={(item) => item.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
onBulkDelete={handleBulkDeleteClick}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: sortedSites.length,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 단일 삭제 다이얼로그 */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>현장 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 현장을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteConfirm}>삭제</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 일괄 삭제 다이얼로그 */}
|
||||
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>현장 일괄 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 {selectedItems.size}개 현장을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleBulkDeleteConfirm}>삭제</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <UniversalListPage config={config} initialData={initialData} />;
|
||||
}
|
||||
|
||||
@@ -1,33 +1,30 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 구조검토관리 - UniversalListPage 마이그레이션
|
||||
*
|
||||
* 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환
|
||||
* - 클라이언트 사이드 필터링 (검색, 필터, 정렬)
|
||||
* - Stats 카드 클릭 필터링 (activeStatTab)
|
||||
* - DateRangeSelector + 등록 버튼 (headerActions → dateRangeSelector + createButton)
|
||||
* - filterConfig (multi: 거래처 / single: 상태, 정렬)
|
||||
* - 삭제 기능 (deleteConfirmMessage로 AlertDialog 대체)
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ClipboardCheck, Clock, CheckCircle, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox';
|
||||
import { IntegratedListTemplateV2, TableColumn, StatCard, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { MobileCard } from '@/components/molecules/MobileCard';
|
||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
type SelectionHandlers,
|
||||
type RowClickHandlers,
|
||||
type StatCard,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import type { StructureReview, StructureReviewStats } from './types';
|
||||
import {
|
||||
STRUCTURE_REVIEW_STATUS_OPTIONS,
|
||||
@@ -43,7 +40,7 @@ import {
|
||||
} from './actions';
|
||||
|
||||
// 테이블 컬럼 정의
|
||||
const tableColumns: TableColumn[] = [
|
||||
const tableColumns = [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'reviewNumber', label: '검토번호', className: 'w-[100px]' },
|
||||
{ key: 'partnerName', label: '거래처', className: 'w-[100px]' },
|
||||
@@ -57,12 +54,18 @@ const tableColumns: TableColumn[] = [
|
||||
];
|
||||
|
||||
// 목업 거래처 목록
|
||||
const MOCK_PARTNERS: MultiSelectOption[] = [
|
||||
const MOCK_PARTNERS = [
|
||||
{ value: '1', label: '회사명A' },
|
||||
{ value: '2', label: '회사명B' },
|
||||
{ value: '3', label: '회사명C' },
|
||||
];
|
||||
|
||||
// 날짜 포맷
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return '-';
|
||||
return dateStr.split('T')[0];
|
||||
}
|
||||
|
||||
interface StructureReviewListClientProps {
|
||||
initialData?: StructureReview[];
|
||||
initialStats?: StructureReviewStats;
|
||||
@@ -74,270 +77,261 @@ export default function StructureReviewListClient({
|
||||
}: StructureReviewListClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// 상태
|
||||
const [reviews, setReviews] = useState<StructureReview[]>(initialData);
|
||||
const [stats, setStats] = useState<StructureReviewStats | null>(initialStats || null);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [partnerFilters, setPartnerFilters] = useState<string[]>([]);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [sortBy, setSortBy] = useState<string>('latest');
|
||||
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
|
||||
const [activeStatTab, setActiveStatTab] = useState<'all' | 'pending' | 'completed'>('all');
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
|
||||
const [activeStatTab, setActiveStatTab] = useState<'all' | 'pending' | 'completed'>('all');
|
||||
const itemsPerPage = 20;
|
||||
const [stats, setStats] = useState<StructureReviewStats | null>(initialStats || null);
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [listResult, statsResult] = await Promise.all([
|
||||
getStructureReviewList({
|
||||
size: 1000,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
}),
|
||||
getStructureReviewStats(),
|
||||
]);
|
||||
|
||||
if (listResult.success && listResult.data) {
|
||||
setReviews(listResult.data.items);
|
||||
}
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStats(statsResult.data);
|
||||
}
|
||||
} catch {
|
||||
toast.error('데이터 로드에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [startDate, endDate]);
|
||||
|
||||
// 초기 데이터가 없으면 로드
|
||||
// Stats 로드
|
||||
useEffect(() => {
|
||||
if (initialData.length === 0) {
|
||||
loadData();
|
||||
if (!initialStats) {
|
||||
getStructureReviewStats().then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setStats(result.data);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [initialData.length, loadData]);
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredReviews = useMemo(() => {
|
||||
return reviews.filter((review) => {
|
||||
// 상태 탭 필터
|
||||
if (activeStatTab === 'pending' && review.status !== 'pending') return false;
|
||||
if (activeStatTab === 'completed' && review.status !== 'completed') return false;
|
||||
|
||||
// 거래처 필터
|
||||
if (partnerFilters.length > 0) {
|
||||
if (!partnerFilters.includes(review.partnerId)) return false;
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (statusFilter !== 'all' && review.status !== statusFilter) return false;
|
||||
|
||||
// 검색 필터
|
||||
if (searchValue) {
|
||||
const search = searchValue.toLowerCase();
|
||||
return (
|
||||
review.reviewNumber.toLowerCase().includes(search) ||
|
||||
review.partnerName.toLowerCase().includes(search) ||
|
||||
review.siteName.toLowerCase().includes(search) ||
|
||||
review.reviewCompany.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [reviews, activeStatTab, partnerFilters, statusFilter, searchValue]);
|
||||
|
||||
// 정렬
|
||||
const sortedReviews = useMemo(() => {
|
||||
const sorted = [...filteredReviews];
|
||||
switch (sortBy) {
|
||||
case 'latest':
|
||||
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
break;
|
||||
case 'oldest':
|
||||
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
break;
|
||||
case 'partnerNameAsc':
|
||||
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
|
||||
break;
|
||||
case 'partnerNameDesc':
|
||||
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
|
||||
break;
|
||||
case 'siteNameAsc':
|
||||
sorted.sort((a, b) => a.siteName.localeCompare(b.siteName, 'ko'));
|
||||
break;
|
||||
case 'siteNameDesc':
|
||||
sorted.sort((a, b) => b.siteName.localeCompare(a.siteName, 'ko'));
|
||||
break;
|
||||
}
|
||||
return sorted;
|
||||
}, [filteredReviews, sortBy]);
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(sortedReviews.length / itemsPerPage);
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (currentPage - 1) * itemsPerPage;
|
||||
return sortedReviews.slice(start, start + itemsPerPage);
|
||||
}, [sortedReviews, currentPage, itemsPerPage]);
|
||||
|
||||
// 핸들러
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchValue(value);
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const handleToggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggleSelectAll = useCallback(() => {
|
||||
if (selectedItems.size === paginatedData.length) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(paginatedData.map((r) => r.id)));
|
||||
}
|
||||
}, [selectedItems.size, paginatedData]);
|
||||
}, [initialStats]);
|
||||
|
||||
// ===== 핸들러 =====
|
||||
const handleRowClick = useCallback(
|
||||
(review: StructureReview) => {
|
||||
router.push(`/ko/construction/order/structure-review/${review.id}`);
|
||||
(item: StructureReview) => {
|
||||
router.push(`/ko/construction/order/structure-review/${item.id}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(e: React.MouseEvent, reviewId: string) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/ko/construction/order/structure-review/${reviewId}/edit`);
|
||||
(item: StructureReview) => {
|
||||
router.push(`/ko/construction/order/structure-review/${item.id}/edit`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleDeleteClick = useCallback((e: React.MouseEvent, reviewId: string) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTargetId(reviewId);
|
||||
setDeleteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
if (!deleteTargetId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await deleteStructureReview(deleteTargetId);
|
||||
if (result.success) {
|
||||
toast.success('구조검토가 삭제되었습니다.');
|
||||
setReviews((prev) => prev.filter((r) => r.id !== deleteTargetId));
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(deleteTargetId);
|
||||
return newSet;
|
||||
});
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setDeleteDialogOpen(false);
|
||||
setDeleteTargetId(null);
|
||||
}
|
||||
}, [deleteTargetId]);
|
||||
|
||||
const handleBulkDeleteClick = useCallback(() => {
|
||||
if (selectedItems.size === 0) {
|
||||
toast.warning('삭제할 항목을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
setBulkDeleteDialogOpen(true);
|
||||
}, [selectedItems.size]);
|
||||
|
||||
const handleBulkDeleteConfirm = useCallback(async () => {
|
||||
if (selectedItems.size === 0) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const ids = Array.from(selectedItems);
|
||||
const result = await deleteStructureReviews(ids);
|
||||
if (result.success) {
|
||||
toast.success(`${result.deletedCount}개 항목이 삭제되었습니다.`);
|
||||
await loadData();
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
toast.error(result.error || '일괄 삭제에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('일괄 삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setBulkDeleteDialogOpen(false);
|
||||
}
|
||||
}, [selectedItems, loadData]);
|
||||
|
||||
const handleRegister = useCallback(() => {
|
||||
const handleCreate = useCallback(() => {
|
||||
router.push('/ko/construction/order/structure-review/new');
|
||||
}, [router]);
|
||||
|
||||
// 날짜 포맷
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return '-';
|
||||
return dateStr.split('T')[0];
|
||||
};
|
||||
// ===== UniversalListPage Config =====
|
||||
const config: UniversalListConfig<StructureReview> = useMemo(
|
||||
() => ({
|
||||
// 페이지 기본 정보
|
||||
title: '구조검토관리',
|
||||
description: '구조검토 의뢰를 관리합니다',
|
||||
icon: ClipboardCheck,
|
||||
basePath: '/construction/order/structure-review',
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = useCallback(
|
||||
(review: StructureReview, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(review.id);
|
||||
// ID 추출
|
||||
idField: 'id',
|
||||
|
||||
return (
|
||||
// API 액션
|
||||
actions: {
|
||||
getList: async () => {
|
||||
const result = await getStructureReviewList({
|
||||
size: 1000,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
});
|
||||
if (result.success && result.data) {
|
||||
return {
|
||||
success: true,
|
||||
data: result.data.items,
|
||||
totalCount: result.data.total,
|
||||
};
|
||||
}
|
||||
return { success: false, error: result.error };
|
||||
},
|
||||
deleteItem: async (id: string) => {
|
||||
const result = await deleteStructureReview(id);
|
||||
return { success: result.success, error: result.error };
|
||||
},
|
||||
deleteBulk: async (ids: string[]) => {
|
||||
const result = await deleteStructureReviews(ids);
|
||||
return { success: result.success, error: result.error };
|
||||
},
|
||||
},
|
||||
|
||||
// 테이블 컬럼
|
||||
columns: tableColumns,
|
||||
|
||||
// 클라이언트 사이드 필터링
|
||||
clientSideFiltering: true,
|
||||
itemsPerPage: 20,
|
||||
|
||||
// 검색 필터
|
||||
searchPlaceholder: '검토번호, 거래처, 현장명, 검토회사 검색',
|
||||
searchFilter: (item, searchValue) => {
|
||||
const search = searchValue.toLowerCase();
|
||||
return (
|
||||
item.reviewNumber.toLowerCase().includes(search) ||
|
||||
item.partnerName.toLowerCase().includes(search) ||
|
||||
item.siteName.toLowerCase().includes(search) ||
|
||||
item.reviewCompany.toLowerCase().includes(search)
|
||||
);
|
||||
},
|
||||
|
||||
// 필터 설정
|
||||
filterConfig: [
|
||||
{
|
||||
key: 'partner',
|
||||
label: '거래처',
|
||||
type: 'multi',
|
||||
options: MOCK_PARTNERS,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'single',
|
||||
options: STRUCTURE_REVIEW_STATUS_OPTIONS.filter((o) => o.value !== 'all'),
|
||||
},
|
||||
{
|
||||
key: 'sortBy',
|
||||
label: '정렬',
|
||||
type: 'single',
|
||||
options: STRUCTURE_REVIEW_SORT_OPTIONS,
|
||||
},
|
||||
],
|
||||
initialFilters: {
|
||||
partner: [],
|
||||
status: 'all',
|
||||
sortBy: 'latest',
|
||||
},
|
||||
filterTitle: '구조검토 필터',
|
||||
|
||||
// 커스텀 필터 함수
|
||||
customFilterFn: (items, filterValues) => {
|
||||
return items.filter((item) => {
|
||||
// Stats 탭 필터
|
||||
if (activeStatTab === 'pending' && item.status !== 'pending') return false;
|
||||
if (activeStatTab === 'completed' && item.status !== 'completed') return false;
|
||||
|
||||
// 거래처 필터 (다중선택)
|
||||
const partnerFilters = filterValues.partner as string[];
|
||||
if (partnerFilters?.length > 0 && !partnerFilters.includes(item.partnerId)) return false;
|
||||
|
||||
// 상태 필터 (단일선택)
|
||||
const statusFilter = filterValues.status as string;
|
||||
if (statusFilter && statusFilter !== 'all' && item.status !== statusFilter) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
},
|
||||
|
||||
// 커스텀 정렬 함수
|
||||
customSortFn: (items, filterValues) => {
|
||||
const sorted = [...items];
|
||||
const sortBy = (filterValues.sortBy as string) || 'latest';
|
||||
|
||||
switch (sortBy) {
|
||||
case 'latest':
|
||||
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
break;
|
||||
case 'oldest':
|
||||
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
break;
|
||||
case 'partnerNameAsc':
|
||||
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
|
||||
break;
|
||||
case 'partnerNameDesc':
|
||||
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
|
||||
break;
|
||||
case 'siteNameAsc':
|
||||
sorted.sort((a, b) => a.siteName.localeCompare(b.siteName, 'ko'));
|
||||
break;
|
||||
case 'siteNameDesc':
|
||||
sorted.sort((a, b) => b.siteName.localeCompare(a.siteName, 'ko'));
|
||||
break;
|
||||
}
|
||||
return sorted;
|
||||
},
|
||||
|
||||
// 공통 헤더 옵션
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
createButton: {
|
||||
label: '구조검토 등록',
|
||||
onClick: handleCreate,
|
||||
},
|
||||
|
||||
// Stats 카드
|
||||
computeStats: (): StatCard[] => [
|
||||
{
|
||||
label: '전체 구조검토',
|
||||
value: stats?.total ?? 0,
|
||||
icon: ClipboardCheck,
|
||||
iconColor: 'text-blue-600',
|
||||
onClick: () => setActiveStatTab('all'),
|
||||
isActive: activeStatTab === 'all',
|
||||
},
|
||||
{
|
||||
label: '검토대기',
|
||||
value: stats?.pending ?? 0,
|
||||
icon: Clock,
|
||||
iconColor: 'text-yellow-600',
|
||||
onClick: () => setActiveStatTab('pending'),
|
||||
isActive: activeStatTab === 'pending',
|
||||
},
|
||||
{
|
||||
label: '검토완료',
|
||||
value: stats?.completed ?? 0,
|
||||
icon: CheckCircle,
|
||||
iconColor: 'text-green-600',
|
||||
onClick: () => setActiveStatTab('completed'),
|
||||
isActive: activeStatTab === 'completed',
|
||||
},
|
||||
],
|
||||
|
||||
// 삭제 확인 메시지 (AlertDialog 대체)
|
||||
deleteConfirmMessage: {
|
||||
title: '구조검토 삭제',
|
||||
description: '선택한 구조검토를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.',
|
||||
},
|
||||
|
||||
// 테이블 행 렌더링
|
||||
renderTableRow: (
|
||||
item: StructureReview,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<StructureReview>
|
||||
) => (
|
||||
<TableRow
|
||||
key={review.id}
|
||||
key={item.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleRowClick(review)}
|
||||
onClick={() => handleRowClick(item)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleSelection(review.id)}
|
||||
/>
|
||||
<Checkbox checked={handlers.isSelected} onCheckedChange={handlers.onToggle} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell>{review.reviewNumber}</TableCell>
|
||||
<TableCell>{review.partnerName}</TableCell>
|
||||
<TableCell>{review.siteName}</TableCell>
|
||||
<TableCell>{formatDate(review.requestDate)}</TableCell>
|
||||
<TableCell>{review.reviewCompany}</TableCell>
|
||||
<TableCell>{review.reviewerName}</TableCell>
|
||||
<TableCell>{formatDate(review.completionDate)}</TableCell>
|
||||
<TableCell>{item.reviewNumber}</TableCell>
|
||||
<TableCell>{item.partnerName}</TableCell>
|
||||
<TableCell>{item.siteName}</TableCell>
|
||||
<TableCell>{formatDate(item.requestDate)}</TableCell>
|
||||
<TableCell>{item.reviewCompany}</TableCell>
|
||||
<TableCell>{item.reviewerName}</TableCell>
|
||||
<TableCell>{formatDate(item.completionDate)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={STRUCTURE_REVIEW_STATUS_STYLES[review.status]}>
|
||||
{STRUCTURE_REVIEW_STATUS_LABELS[review.status]}
|
||||
<span className={STRUCTURE_REVIEW_STATUS_STYLES[item.status]}>
|
||||
{STRUCTURE_REVIEW_STATUS_LABELS[item.status]}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{isSelected && (
|
||||
{handlers.isSelected && (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => handleEdit(e, review.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(item);
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -345,7 +339,10 @@ export default function StructureReviewListClient({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={(e) => handleDeleteClick(e, review.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlers.onDelete?.(item);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -353,249 +350,34 @@ export default function StructureReviewListClient({
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
[selectedItems, handleToggleSelection, handleRowClick, handleEdit, handleDeleteClick]
|
||||
);
|
||||
),
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = useCallback(
|
||||
(review: StructureReview, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
|
||||
return (
|
||||
// 모바일 카드 렌더링
|
||||
renderMobileCard: (
|
||||
item: StructureReview,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<StructureReview>
|
||||
) => (
|
||||
<MobileCard
|
||||
title={review.siteName}
|
||||
subtitle={review.reviewNumber}
|
||||
badge={STRUCTURE_REVIEW_STATUS_LABELS[review.status]}
|
||||
key={item.id}
|
||||
title={item.siteName}
|
||||
subtitle={item.reviewNumber}
|
||||
badge={STRUCTURE_REVIEW_STATUS_LABELS[item.status]}
|
||||
badgeVariant="secondary"
|
||||
isSelected={isSelected}
|
||||
onToggle={onToggle}
|
||||
onClick={() => handleRowClick(review)}
|
||||
isSelected={handlers.isSelected}
|
||||
onToggle={handlers.onToggle}
|
||||
onClick={() => handleRowClick(item)}
|
||||
details={[
|
||||
{ label: '거래처', value: review.partnerName },
|
||||
{ label: '의뢰일', value: formatDate(review.requestDate) },
|
||||
{ label: '검토회사', value: review.reviewCompany },
|
||||
{ label: '거래처', value: item.partnerName },
|
||||
{ label: '의뢰일', value: formatDate(item.requestDate) },
|
||||
{ label: '검토회사', value: item.reviewCompany },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[handleRowClick]
|
||||
),
|
||||
}),
|
||||
[startDate, endDate, activeStatTab, stats, handleRowClick, handleEdit, handleCreate]
|
||||
);
|
||||
|
||||
// 헤더 액션
|
||||
const headerActions = (
|
||||
<div className="flex items-center gap-2">
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
/>
|
||||
<Button onClick={handleRegister}>구조검토 등록</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Stats 카드 데이터
|
||||
const statsCardsData: StatCard[] = [
|
||||
{
|
||||
label: '전체 구조검토',
|
||||
value: stats?.total ?? 0,
|
||||
icon: ClipboardCheck,
|
||||
iconColor: 'text-blue-600',
|
||||
onClick: () => setActiveStatTab('all'),
|
||||
isActive: activeStatTab === 'all',
|
||||
},
|
||||
{
|
||||
label: '검토대기',
|
||||
value: stats?.pending ?? 0,
|
||||
icon: Clock,
|
||||
iconColor: 'text-yellow-600',
|
||||
onClick: () => setActiveStatTab('pending'),
|
||||
isActive: activeStatTab === 'pending',
|
||||
},
|
||||
{
|
||||
label: '검토완료',
|
||||
value: stats?.completed ?? 0,
|
||||
icon: CheckCircle,
|
||||
iconColor: 'text-green-600',
|
||||
onClick: () => setActiveStatTab('completed'),
|
||||
isActive: activeStatTab === 'completed',
|
||||
},
|
||||
];
|
||||
|
||||
// ===== filterConfig 기반 통합 필터 시스템 =====
|
||||
const filterConfig: FilterFieldConfig[] = useMemo(() => [
|
||||
{
|
||||
key: 'partner',
|
||||
label: '거래처',
|
||||
type: 'multi',
|
||||
options: MOCK_PARTNERS.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'single',
|
||||
options: STRUCTURE_REVIEW_STATUS_OPTIONS.filter(o => o.value !== 'all').map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'sortBy',
|
||||
label: '정렬',
|
||||
type: 'single',
|
||||
options: STRUCTURE_REVIEW_SORT_OPTIONS.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '최신순',
|
||||
},
|
||||
], []);
|
||||
|
||||
const filterValues: FilterValues = useMemo(() => ({
|
||||
partner: partnerFilters,
|
||||
status: statusFilter,
|
||||
sortBy: sortBy,
|
||||
}), [partnerFilters, statusFilter, sortBy]);
|
||||
|
||||
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
|
||||
switch (key) {
|
||||
case 'partner':
|
||||
setPartnerFilters(value as string[]);
|
||||
break;
|
||||
case 'status':
|
||||
setStatusFilter(value as string);
|
||||
break;
|
||||
case 'sortBy':
|
||||
setSortBy(value as string);
|
||||
break;
|
||||
}
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const handleFilterReset = useCallback(() => {
|
||||
setPartnerFilters([]);
|
||||
setStatusFilter('all');
|
||||
setSortBy('latest');
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
// 테이블 헤더 액션
|
||||
const tableHeaderActions = (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
총 {sortedReviews.length}건
|
||||
</span>
|
||||
|
||||
{/* 거래처 필터 */}
|
||||
<MultiSelectCombobox
|
||||
options={MOCK_PARTNERS}
|
||||
value={partnerFilters}
|
||||
onChange={setPartnerFilters}
|
||||
placeholder="거래처"
|
||||
searchPlaceholder="거래처 검색..."
|
||||
className="w-[120px]"
|
||||
/>
|
||||
|
||||
{/* 상태 필터 */}
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STRUCTURE_REVIEW_STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 정렬 */}
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="최신순" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STRUCTURE_REVIEW_SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedListTemplateV2
|
||||
title="구조검토관리"
|
||||
description="구조검토 의뢰를 관리합니다"
|
||||
icon={ClipboardCheck}
|
||||
headerActions={headerActions}
|
||||
stats={statsCardsData}
|
||||
tableHeaderActions={tableHeaderActions}
|
||||
filterConfig={filterConfig}
|
||||
filterValues={filterValues}
|
||||
onFilterChange={handleFilterChange}
|
||||
onFilterReset={handleFilterReset}
|
||||
filterTitle="구조검토 필터"
|
||||
searchValue={searchValue}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="검토번호, 거래처, 현장명, 검토회사 검색"
|
||||
tableColumns={tableColumns}
|
||||
data={paginatedData}
|
||||
allData={sortedReviews}
|
||||
getItemId={(item) => item.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
onBulkDelete={handleBulkDeleteClick}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: sortedReviews.length,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 단일 삭제 다이얼로그 */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>구조검토 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 구조검토를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteConfirm}>삭제</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 일괄 삭제 다이얼로그 */}
|
||||
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>구조검토 일괄 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 {selectedItems.size}개 구조검토를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleBulkDeleteConfirm}>삭제</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <UniversalListPage config={config} initialData={initialData} />;
|
||||
}
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 공과관리 - UniversalListPage 마이그레이션
|
||||
*
|
||||
* 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환
|
||||
* - 클라이언트 사이드 필터링 (검색, 필터, 정렬)
|
||||
* - Stats 카드 클릭 필터링 (activeStatTab)
|
||||
* - DateRangeSelector (headerActions → dateRangeSelector config)
|
||||
* - filterConfig (multi: 거래처, 현장명, 공사PM, 작업반장 / single: 공과, 상태, 정렬)
|
||||
* - 삭제 기능 (deleteConfirmMessage로 AlertDialog 대체)
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Zap, Pencil, Trash2, FileText, CheckCircle, Clock } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { MultiSelectOption } from '@/components/ui/multi-select-combobox';
|
||||
import { IntegratedListTemplateV2, TableColumn, StatCard, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { MobileCard } from '@/components/molecules/MobileCard';
|
||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
type SelectionHandlers,
|
||||
type RowClickHandlers,
|
||||
type StatCard,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import type { Utility, UtilityStats } from './types';
|
||||
import {
|
||||
UTILITY_STATUS_OPTIONS,
|
||||
@@ -41,7 +45,7 @@ import {
|
||||
} from './actions';
|
||||
|
||||
// 테이블 컬럼 정의
|
||||
const tableColumns: TableColumn[] = [
|
||||
const tableColumns = [
|
||||
{ key: 'no', label: '번호', className: 'w-[50px] text-center' },
|
||||
{ key: 'utilityNumber', label: '공과번호', className: 'w-[120px]' },
|
||||
{ key: 'partnerName', label: '거래처', className: 'w-[100px]' },
|
||||
@@ -56,6 +60,17 @@ const tableColumns: TableColumn[] = [
|
||||
{ key: 'actions', label: '작업', className: 'w-[100px] text-center' },
|
||||
];
|
||||
|
||||
// 날짜 포맷
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return '-';
|
||||
return dateStr.split('T')[0];
|
||||
}
|
||||
|
||||
// 금액 포맷
|
||||
function formatAmount(amount: number): string {
|
||||
return amount.toLocaleString('ko-KR') + '원';
|
||||
}
|
||||
|
||||
interface UtilityManagementListClientProps {
|
||||
initialData?: Utility[];
|
||||
initialStats?: UtilityStats;
|
||||
@@ -67,302 +82,305 @@ export default function UtilityManagementListClient({
|
||||
}: UtilityManagementListClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// 상태
|
||||
const [utilities, setUtilities] = useState<Utility[]>(initialData);
|
||||
const [stats, setStats] = useState<UtilityStats | null>(initialStats || null);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
// 다중선택 필터 (빈 배열 = 전체)
|
||||
const [partnerFilters, setPartnerFilters] = useState<string[]>([]);
|
||||
const [siteFilters, setSiteFilters] = useState<string[]>([]);
|
||||
const [constructionPMFilters, setConstructionPMFilters] = useState<string[]>([]);
|
||||
const [utilityTypeFilter, setUtilityTypeFilter] = useState<string>('all');
|
||||
const [workTeamFilters, setWorkTeamFilters] = useState<string[]>([]);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [sortBy, setSortBy] = useState<string>('latest');
|
||||
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
|
||||
const [activeStatTab, setActiveStatTab] = useState<'all' | 'waiting' | 'complete'>('all');
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
|
||||
const [activeStatTab, setActiveStatTab] = useState<'all' | 'waiting' | 'complete'>('all');
|
||||
const itemsPerPage = 20;
|
||||
const [stats, setStats] = useState<UtilityStats | null>(initialStats || null);
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [listResult, statsResult] = await Promise.all([
|
||||
getUtilityList({
|
||||
size: 1000,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
}),
|
||||
getUtilityStats(),
|
||||
]);
|
||||
|
||||
if (listResult.success && listResult.data) {
|
||||
setUtilities(listResult.data.items);
|
||||
}
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStats(statsResult.data);
|
||||
}
|
||||
} catch {
|
||||
toast.error('데이터 로드에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [startDate, endDate]);
|
||||
|
||||
// 초기 데이터가 없으면 로드
|
||||
// Stats 로드
|
||||
useEffect(() => {
|
||||
if (initialData.length === 0) {
|
||||
loadData();
|
||||
}
|
||||
}, [initialData.length, loadData]);
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredUtilities = useMemo(() => {
|
||||
return utilities.filter((utility) => {
|
||||
// 상태 탭 필터
|
||||
if (activeStatTab === 'waiting' && utility.status !== 'scheduled' && utility.status !== 'issued') return false;
|
||||
if (activeStatTab === 'complete' && utility.status !== 'completed') return false;
|
||||
|
||||
// 상태 필터
|
||||
if (statusFilter !== 'all' && utility.status !== statusFilter) return false;
|
||||
|
||||
// 거래처 필터 (다중선택)
|
||||
if (partnerFilters.length > 0 && !partnerFilters.includes(utility.partnerId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 현장명 필터 (다중선택)
|
||||
if (siteFilters.length > 0 && !siteFilters.includes(utility.siteId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 공사PM 필터 (다중선택)
|
||||
if (constructionPMFilters.length > 0 && !constructionPMFilters.includes(utility.constructionPMId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 공과 유형 필터 (단일선택)
|
||||
if (utilityTypeFilter !== 'all') {
|
||||
const matchingType = MOCK_UTILITY_TYPES.find((t) => t.value === utilityTypeFilter);
|
||||
if (!matchingType || utility.utilityType !== matchingType.label) {
|
||||
return false;
|
||||
if (!initialStats) {
|
||||
getUtilityStats().then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setStats(result.data);
|
||||
}
|
||||
}
|
||||
|
||||
// 작업반장 필터 (다중선택)
|
||||
if (workTeamFilters.length > 0 && !workTeamFilters.includes(utility.workTeamLeaderId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if (searchValue) {
|
||||
const search = searchValue.toLowerCase();
|
||||
return (
|
||||
utility.utilityNumber.toLowerCase().includes(search) ||
|
||||
utility.partnerName.toLowerCase().includes(search) ||
|
||||
utility.siteName.toLowerCase().includes(search) ||
|
||||
utility.constructionPM.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [utilities, activeStatTab, statusFilter, partnerFilters, siteFilters, constructionPMFilters, utilityTypeFilter, workTeamFilters, searchValue]);
|
||||
|
||||
// 정렬
|
||||
const sortedUtilities = useMemo(() => {
|
||||
const sorted = [...filteredUtilities];
|
||||
switch (sortBy) {
|
||||
case 'latest':
|
||||
sorted.sort((a, b) => new Date(b.scheduledDate).getTime() - new Date(a.scheduledDate).getTime());
|
||||
break;
|
||||
case 'oldest':
|
||||
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
break;
|
||||
case 'partnerNameDesc':
|
||||
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
|
||||
break;
|
||||
case 'issuedDate':
|
||||
sorted.sort((a, b) => new Date(b.scheduledDate).getTime() - new Date(a.scheduledDate).getTime());
|
||||
break;
|
||||
case 'completedDate':
|
||||
sorted.sort((a, b) => {
|
||||
if (a.status === 'completed' && b.status !== 'completed') return -1;
|
||||
if (a.status !== 'completed' && b.status === 'completed') return 1;
|
||||
return 0;
|
||||
});
|
||||
break;
|
||||
});
|
||||
}
|
||||
return sorted;
|
||||
}, [filteredUtilities, sortBy]);
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(sortedUtilities.length / itemsPerPage);
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (currentPage - 1) * itemsPerPage;
|
||||
return sortedUtilities.slice(start, start + itemsPerPage);
|
||||
}, [sortedUtilities, currentPage, itemsPerPage]);
|
||||
|
||||
// 핸들러
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchValue(value);
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const handleToggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggleSelectAll = useCallback(() => {
|
||||
if (selectedItems.size === paginatedData.length) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(paginatedData.map((u) => u.id)));
|
||||
}
|
||||
}, [selectedItems.size, paginatedData]);
|
||||
}, [initialStats]);
|
||||
|
||||
// ===== 핸들러 =====
|
||||
const handleRowClick = useCallback(
|
||||
(utility: Utility) => {
|
||||
router.push(`/ko/construction/project/utility-management/${utility.id}`);
|
||||
(item: Utility) => {
|
||||
router.push(`/ko/construction/project/utility-management/${item.id}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(e: React.MouseEvent, utilityId: string) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/ko/construction/project/utility-management/${utilityId}/edit`);
|
||||
(item: Utility) => {
|
||||
router.push(`/ko/construction/project/utility-management/${item.id}/edit`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleDeleteClick = useCallback((e: React.MouseEvent, utilityId: string) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTargetId(utilityId);
|
||||
setDeleteDialogOpen(true);
|
||||
}, []);
|
||||
// ===== UniversalListPage Config =====
|
||||
const config: UniversalListConfig<Utility> = useMemo(
|
||||
() => ({
|
||||
// 페이지 기본 정보
|
||||
title: '공과관리',
|
||||
description: '공과 목록을 관리합니다',
|
||||
icon: Zap,
|
||||
basePath: '/construction/project/utility-management',
|
||||
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
if (!deleteTargetId) return;
|
||||
// ID 추출
|
||||
idField: 'id',
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await deleteUtility(deleteTargetId);
|
||||
if (result.success) {
|
||||
toast.success('공과가 삭제되었습니다.');
|
||||
setUtilities((prev) => prev.filter((u) => u.id !== deleteTargetId));
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(deleteTargetId);
|
||||
return newSet;
|
||||
// API 액션
|
||||
actions: {
|
||||
getList: async () => {
|
||||
const result = await getUtilityList({
|
||||
size: 1000,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
});
|
||||
if (result.success && result.data) {
|
||||
return {
|
||||
success: true,
|
||||
data: result.data.items,
|
||||
totalCount: result.data.total,
|
||||
};
|
||||
}
|
||||
return { success: false, error: result.error };
|
||||
},
|
||||
deleteItem: async (id: string) => {
|
||||
const result = await deleteUtility(id);
|
||||
return { success: result.success, error: result.error };
|
||||
},
|
||||
deleteBulk: async (ids: string[]) => {
|
||||
const result = await deleteUtilities(ids);
|
||||
return { success: result.success, error: result.error };
|
||||
},
|
||||
},
|
||||
|
||||
// 테이블 컬럼
|
||||
columns: tableColumns,
|
||||
|
||||
// 클라이언트 사이드 필터링
|
||||
clientSideFiltering: true,
|
||||
itemsPerPage: 20,
|
||||
|
||||
// 검색 필터
|
||||
searchPlaceholder: '공과번호, 거래처, 현장명, 공사PM 검색',
|
||||
searchFilter: (item, searchValue) => {
|
||||
const search = searchValue.toLowerCase();
|
||||
return (
|
||||
item.utilityNumber.toLowerCase().includes(search) ||
|
||||
item.partnerName.toLowerCase().includes(search) ||
|
||||
item.siteName.toLowerCase().includes(search) ||
|
||||
item.constructionPM.toLowerCase().includes(search)
|
||||
);
|
||||
},
|
||||
|
||||
// 필터 설정
|
||||
filterConfig: [
|
||||
{
|
||||
key: 'partners',
|
||||
label: '거래처',
|
||||
type: 'multi',
|
||||
options: MOCK_PARTNERS,
|
||||
},
|
||||
{
|
||||
key: 'sites',
|
||||
label: '현장명',
|
||||
type: 'multi',
|
||||
options: MOCK_SITES,
|
||||
},
|
||||
{
|
||||
key: 'constructionPMs',
|
||||
label: '공사PM',
|
||||
type: 'multi',
|
||||
options: MOCK_CONSTRUCTION_PM,
|
||||
},
|
||||
{
|
||||
key: 'utilityType',
|
||||
label: '공과',
|
||||
type: 'single',
|
||||
options: MOCK_UTILITY_TYPES,
|
||||
},
|
||||
{
|
||||
key: 'workTeamLeaders',
|
||||
label: '작업반장',
|
||||
type: 'multi',
|
||||
options: MOCK_WORK_TEAM_LEADERS,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'single',
|
||||
options: UTILITY_STATUS_OPTIONS.filter((o) => o.value !== 'all'),
|
||||
},
|
||||
{
|
||||
key: 'sortBy',
|
||||
label: '정렬',
|
||||
type: 'single',
|
||||
options: UTILITY_SORT_OPTIONS,
|
||||
},
|
||||
],
|
||||
initialFilters: {
|
||||
partners: [],
|
||||
sites: [],
|
||||
constructionPMs: [],
|
||||
utilityType: 'all',
|
||||
workTeamLeaders: [],
|
||||
status: 'all',
|
||||
sortBy: 'latest',
|
||||
},
|
||||
filterTitle: '공과 필터',
|
||||
|
||||
// 커스텀 필터 함수
|
||||
customFilterFn: (items, filterValues) => {
|
||||
return items.filter((item) => {
|
||||
// Stats 탭 필터
|
||||
if (activeStatTab === 'waiting' && item.status !== 'scheduled' && item.status !== 'issued') return false;
|
||||
if (activeStatTab === 'complete' && item.status !== 'completed') return false;
|
||||
|
||||
// 거래처 필터 (다중선택)
|
||||
const partnerFilters = filterValues.partners as string[];
|
||||
if (partnerFilters?.length > 0 && !partnerFilters.includes(item.partnerId)) return false;
|
||||
|
||||
// 현장명 필터 (다중선택)
|
||||
const siteFilters = filterValues.sites as string[];
|
||||
if (siteFilters?.length > 0 && !siteFilters.includes(item.siteId)) return false;
|
||||
|
||||
// 공사PM 필터 (다중선택)
|
||||
const constructionPMFilters = filterValues.constructionPMs as string[];
|
||||
if (constructionPMFilters?.length > 0 && !constructionPMFilters.includes(item.constructionPMId)) return false;
|
||||
|
||||
// 공과 유형 필터 (단일선택)
|
||||
const utilityTypeFilter = filterValues.utilityType as string;
|
||||
if (utilityTypeFilter && utilityTypeFilter !== 'all') {
|
||||
const matchingType = MOCK_UTILITY_TYPES.find((t) => t.value === utilityTypeFilter);
|
||||
if (!matchingType || item.utilityType !== matchingType.label) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 작업반장 필터 (다중선택)
|
||||
const workTeamFilters = filterValues.workTeamLeaders as string[];
|
||||
if (workTeamFilters?.length > 0 && !workTeamFilters.includes(item.workTeamLeaderId)) return false;
|
||||
|
||||
// 상태 필터 (단일선택)
|
||||
const statusFilter = filterValues.status as string;
|
||||
if (statusFilter && statusFilter !== 'all' && item.status !== statusFilter) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setDeleteDialogOpen(false);
|
||||
setDeleteTargetId(null);
|
||||
}
|
||||
}, [deleteTargetId]);
|
||||
},
|
||||
|
||||
const handleBulkDeleteClick = useCallback(() => {
|
||||
if (selectedItems.size === 0) {
|
||||
toast.warning('삭제할 항목을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
setBulkDeleteDialogOpen(true);
|
||||
}, [selectedItems.size]);
|
||||
// 커스텀 정렬 함수
|
||||
customSortFn: (items, filterValues) => {
|
||||
const sorted = [...items];
|
||||
const sortBy = (filterValues.sortBy as string) || 'latest';
|
||||
|
||||
const handleBulkDeleteConfirm = useCallback(async () => {
|
||||
if (selectedItems.size === 0) return;
|
||||
switch (sortBy) {
|
||||
case 'latest':
|
||||
sorted.sort((a, b) => new Date(b.scheduledDate).getTime() - new Date(a.scheduledDate).getTime());
|
||||
break;
|
||||
case 'oldest':
|
||||
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
break;
|
||||
case 'partnerNameDesc':
|
||||
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
|
||||
break;
|
||||
case 'issuedDate':
|
||||
sorted.sort((a, b) => new Date(b.scheduledDate).getTime() - new Date(a.scheduledDate).getTime());
|
||||
break;
|
||||
case 'completedDate':
|
||||
sorted.sort((a, b) => {
|
||||
if (a.status === 'completed' && b.status !== 'completed') return -1;
|
||||
if (a.status !== 'completed' && b.status === 'completed') return 1;
|
||||
return 0;
|
||||
});
|
||||
break;
|
||||
}
|
||||
return sorted;
|
||||
},
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const ids = Array.from(selectedItems);
|
||||
const result = await deleteUtilities(ids);
|
||||
if (result.success) {
|
||||
toast.success(`${result.deletedCount}개 항목이 삭제되었습니다.`);
|
||||
await loadData();
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
toast.error(result.error || '일괄 삭제에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('일괄 삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setBulkDeleteDialogOpen(false);
|
||||
}
|
||||
}, [selectedItems, loadData]);
|
||||
// 공통 헤더 옵션
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
|
||||
// 날짜 포맷
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return '-';
|
||||
return dateStr.split('T')[0];
|
||||
};
|
||||
// Stats 카드
|
||||
computeStats: (): StatCard[] => [
|
||||
{
|
||||
label: '전체 계약',
|
||||
value: stats?.totalContract ?? 0,
|
||||
icon: FileText,
|
||||
iconColor: 'text-blue-600',
|
||||
onClick: () => setActiveStatTab('all'),
|
||||
isActive: activeStatTab === 'all',
|
||||
},
|
||||
{
|
||||
label: '계약 대기',
|
||||
value: stats?.contractWaiting ?? 0,
|
||||
icon: Clock,
|
||||
iconColor: 'text-yellow-600',
|
||||
onClick: () => setActiveStatTab('waiting'),
|
||||
isActive: activeStatTab === 'waiting',
|
||||
},
|
||||
{
|
||||
label: '계약 완료',
|
||||
value: stats?.contractComplete ?? 0,
|
||||
icon: CheckCircle,
|
||||
iconColor: 'text-green-600',
|
||||
onClick: () => setActiveStatTab('complete'),
|
||||
isActive: activeStatTab === 'complete',
|
||||
},
|
||||
],
|
||||
|
||||
// 금액 포맷
|
||||
const formatAmount = (amount: number) => {
|
||||
return amount.toLocaleString('ko-KR') + '원';
|
||||
};
|
||||
// 삭제 확인 메시지 (AlertDialog 대체)
|
||||
deleteConfirmMessage: {
|
||||
title: '공과 삭제',
|
||||
description: '선택한 공과를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.',
|
||||
},
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = useCallback(
|
||||
(utility: Utility, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(utility.id);
|
||||
|
||||
return (
|
||||
// 테이블 행 렌더링
|
||||
renderTableRow: (
|
||||
item: Utility,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<Utility>
|
||||
) => (
|
||||
<TableRow
|
||||
key={utility.id}
|
||||
key={item.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleRowClick(utility)}
|
||||
onClick={() => handleRowClick(item)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleSelection(utility.id)}
|
||||
/>
|
||||
<Checkbox checked={handlers.isSelected} onCheckedChange={handlers.onToggle} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell>{utility.utilityNumber}</TableCell>
|
||||
<TableCell>{utility.partnerName}</TableCell>
|
||||
<TableCell>{utility.siteName}</TableCell>
|
||||
<TableCell>{utility.constructionPM}</TableCell>
|
||||
<TableCell>{utility.utilityType}</TableCell>
|
||||
<TableCell>{formatDate(utility.scheduledDate)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(utility.amount)}</TableCell>
|
||||
<TableCell>{utility.workTeamLeader}</TableCell>
|
||||
<TableCell>{formatDate(utility.constructionStartDate)}</TableCell>
|
||||
<TableCell>{item.utilityNumber}</TableCell>
|
||||
<TableCell>{item.partnerName}</TableCell>
|
||||
<TableCell>{item.siteName}</TableCell>
|
||||
<TableCell>{item.constructionPM}</TableCell>
|
||||
<TableCell>{item.utilityType}</TableCell>
|
||||
<TableCell>{formatDate(item.scheduledDate)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(item.amount)}</TableCell>
|
||||
<TableCell>{item.workTeamLeader}</TableCell>
|
||||
<TableCell>{formatDate(item.constructionStartDate)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${UTILITY_STATUS_STYLES[utility.status]}`}>
|
||||
{UTILITY_STATUS_LABELS[utility.status]}
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${UTILITY_STATUS_STYLES[item.status]}`}>
|
||||
{UTILITY_STATUS_LABELS[item.status]}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{isSelected && (
|
||||
{handlers.isSelected && (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => handleEdit(e, utility.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(item);
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -370,7 +388,10 @@ export default function UtilityManagementListClient({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={(e) => handleDeleteClick(e, utility.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlers.onDelete?.(item);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -378,215 +399,34 @@ export default function UtilityManagementListClient({
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
[selectedItems, handleToggleSelection, handleRowClick, handleEdit, handleDeleteClick]
|
||||
);
|
||||
),
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = useCallback(
|
||||
(utility: Utility, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
|
||||
return (
|
||||
// 모바일 카드 렌더링
|
||||
renderMobileCard: (
|
||||
item: Utility,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<Utility>
|
||||
) => (
|
||||
<MobileCard
|
||||
title={utility.siteName}
|
||||
subtitle={utility.utilityNumber}
|
||||
badge={UTILITY_STATUS_LABELS[utility.status]}
|
||||
key={item.id}
|
||||
title={item.siteName}
|
||||
subtitle={item.utilityNumber}
|
||||
badge={UTILITY_STATUS_LABELS[item.status]}
|
||||
badgeVariant="secondary"
|
||||
isSelected={isSelected}
|
||||
onToggle={onToggle}
|
||||
onClick={() => handleRowClick(utility)}
|
||||
isSelected={handlers.isSelected}
|
||||
onToggle={handlers.onToggle}
|
||||
onClick={() => handleRowClick(item)}
|
||||
details={[
|
||||
{ label: '거래처', value: utility.partnerName },
|
||||
{ label: '공사PM', value: utility.constructionPM },
|
||||
{ label: '금액', value: formatAmount(utility.amount) },
|
||||
{ label: '거래처', value: item.partnerName },
|
||||
{ label: '공사PM', value: item.constructionPM },
|
||||
{ label: '금액', value: formatAmount(item.amount) },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[handleRowClick]
|
||||
),
|
||||
}),
|
||||
[startDate, endDate, activeStatTab, stats, handleRowClick, handleEdit]
|
||||
);
|
||||
|
||||
// 헤더 액션 (날짜 선택 + 날짜 버튼 - DateRangeSelector에 내장)
|
||||
const headerActions = (
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
/>
|
||||
);
|
||||
|
||||
// Stats 카드 데이터 (전체 계약, 계약 대기, 계약 완료)
|
||||
const statsCardsData: StatCard[] = [
|
||||
{
|
||||
label: '전체 계약',
|
||||
value: stats?.totalContract ?? 0,
|
||||
icon: FileText,
|
||||
iconColor: 'text-blue-600',
|
||||
onClick: () => setActiveStatTab('all'),
|
||||
isActive: activeStatTab === 'all',
|
||||
},
|
||||
{
|
||||
label: '계약 대기',
|
||||
value: stats?.contractWaiting ?? 0,
|
||||
icon: Clock,
|
||||
iconColor: 'text-yellow-600',
|
||||
onClick: () => setActiveStatTab('waiting'),
|
||||
isActive: activeStatTab === 'waiting',
|
||||
},
|
||||
{
|
||||
label: '계약 완료',
|
||||
value: stats?.contractComplete ?? 0,
|
||||
icon: CheckCircle,
|
||||
iconColor: 'text-green-600',
|
||||
onClick: () => setActiveStatTab('complete'),
|
||||
isActive: activeStatTab === 'complete',
|
||||
},
|
||||
];
|
||||
|
||||
// 필터 옵션들
|
||||
const partnerOptions: MultiSelectOption[] = useMemo(() => MOCK_PARTNERS, []);
|
||||
const siteOptions: MultiSelectOption[] = useMemo(() => MOCK_SITES, []);
|
||||
const constructionPMOptions: MultiSelectOption[] = useMemo(() => MOCK_CONSTRUCTION_PM, []);
|
||||
const utilityTypeOptions: MultiSelectOption[] = useMemo(() => MOCK_UTILITY_TYPES, []);
|
||||
const workTeamOptions: MultiSelectOption[] = useMemo(() => MOCK_WORK_TEAM_LEADERS, []);
|
||||
|
||||
// 필터 설정 (7개)
|
||||
const filterConfig: FilterFieldConfig[] = useMemo(() => [
|
||||
{ key: 'partners', label: '거래처', type: 'multi', options: partnerOptions },
|
||||
{ key: 'sites', label: '현장명', type: 'multi', options: siteOptions },
|
||||
{ key: 'constructionPMs', label: '공사PM', type: 'multi', options: constructionPMOptions },
|
||||
{ key: 'utilityType', label: '공과', type: 'single', options: utilityTypeOptions },
|
||||
{ key: 'workTeamLeaders', label: '작업반장', type: 'multi', options: workTeamOptions },
|
||||
{ key: 'status', label: '상태', type: 'single', options: UTILITY_STATUS_OPTIONS.filter(opt => opt.value !== 'all') },
|
||||
{ key: 'sortBy', label: '정렬', type: 'single', options: UTILITY_SORT_OPTIONS.map(o => ({ value: o.value, label: o.label })), allOptionLabel: '최신순' },
|
||||
], [partnerOptions, siteOptions, constructionPMOptions, utilityTypeOptions, workTeamOptions]);
|
||||
|
||||
// filterValues 객체
|
||||
const filterValues: FilterValues = useMemo(() => ({
|
||||
partners: partnerFilters,
|
||||
sites: siteFilters,
|
||||
constructionPMs: constructionPMFilters,
|
||||
utilityType: utilityTypeFilter,
|
||||
workTeamLeaders: workTeamFilters,
|
||||
status: statusFilter,
|
||||
sortBy: sortBy,
|
||||
}), [partnerFilters, siteFilters, constructionPMFilters, utilityTypeFilter, workTeamFilters, statusFilter, sortBy]);
|
||||
|
||||
// 필터 변경 핸들러
|
||||
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
|
||||
switch (key) {
|
||||
case 'partners':
|
||||
setPartnerFilters(value as string[]);
|
||||
break;
|
||||
case 'sites':
|
||||
setSiteFilters(value as string[]);
|
||||
break;
|
||||
case 'constructionPMs':
|
||||
setConstructionPMFilters(value as string[]);
|
||||
break;
|
||||
case 'utilityType':
|
||||
setUtilityTypeFilter(value as string);
|
||||
break;
|
||||
case 'workTeamLeaders':
|
||||
setWorkTeamFilters(value as string[]);
|
||||
break;
|
||||
case 'status':
|
||||
setStatusFilter(value as string);
|
||||
break;
|
||||
case 'sortBy':
|
||||
setSortBy(value as string);
|
||||
break;
|
||||
}
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
// 필터 초기화 핸들러
|
||||
const handleFilterReset = useCallback(() => {
|
||||
setPartnerFilters([]);
|
||||
setSiteFilters([]);
|
||||
setConstructionPMFilters([]);
|
||||
setUtilityTypeFilter('all');
|
||||
setWorkTeamFilters([]);
|
||||
setStatusFilter('all');
|
||||
setSortBy('latest');
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
// 테이블 헤더 추가 액션
|
||||
const tableHeaderActions = (
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
총 {sortedUtilities.length}건
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedListTemplateV2
|
||||
title="공과관리"
|
||||
description="공과 목록을 관리합니다"
|
||||
icon={Zap}
|
||||
headerActions={headerActions}
|
||||
stats={statsCardsData}
|
||||
filterConfig={filterConfig}
|
||||
filterValues={filterValues}
|
||||
onFilterChange={handleFilterChange}
|
||||
onFilterReset={handleFilterReset}
|
||||
filterTitle="공과 필터"
|
||||
tableHeaderActions={tableHeaderActions}
|
||||
searchValue={searchValue}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="공과번호, 거래처, 현장명, 공사PM 검색"
|
||||
tableColumns={tableColumns}
|
||||
data={paginatedData}
|
||||
allData={sortedUtilities}
|
||||
getItemId={(item) => item.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
onBulkDelete={handleBulkDeleteClick}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: sortedUtilities.length,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 단일 삭제 다이얼로그 */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>공과 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 공과를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteConfirm}>삭제</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 일괄 삭제 다이얼로그 */}
|
||||
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>공과 일괄 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 {selectedItems.size}개 공과를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleBulkDeleteConfirm}>삭제</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <UniversalListPage config={config} initialData={initialData} />;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 작업인력현황 - UniversalListPage 마이그레이션
|
||||
*
|
||||
* 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환
|
||||
* - 클라이언트 사이드 필터링 (검색, 필터, 정렬)
|
||||
* - Stats 카드 클릭 필터링 (activeStatTab)
|
||||
* - DateRangeSelector (headerActions → dateRangeSelector config)
|
||||
* - filterConfig (multi: 거래처, 현장, 부서, 이름 / single: 구분, 상태, 정렬)
|
||||
* - 등록/삭제 버튼 없음 (조회 전용)
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Users, Eye, FileText, Clock, CheckCircle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { MultiSelectOption } from '@/components/ui/multi-select-combobox';
|
||||
import { IntegratedListTemplateV2, TableColumn, StatCard, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { MobileCard } from '@/components/molecules/MobileCard';
|
||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
type SelectionHandlers,
|
||||
type RowClickHandlers,
|
||||
type StatCard,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import type { WorkerStatus, WorkerStatusStats } from './types';
|
||||
import {
|
||||
WORKER_CATEGORY_OPTIONS,
|
||||
@@ -27,7 +41,7 @@ import {
|
||||
import { getWorkerStatusList, getWorkerStatusStats } from './actions';
|
||||
|
||||
// 테이블 컬럼 정의
|
||||
const tableColumns: TableColumn[] = [
|
||||
const tableColumns = [
|
||||
{ key: 'no', label: '번호', className: 'w-[50px] text-center' },
|
||||
{ key: 'partnerName', label: '거래처', className: 'w-[100px]' },
|
||||
{ key: 'siteName', label: '현장', className: 'min-w-[100px]' },
|
||||
@@ -43,6 +57,17 @@ const tableColumns: TableColumn[] = [
|
||||
{ key: 'actions', label: '작업', className: 'w-[60px] text-center' },
|
||||
];
|
||||
|
||||
// 시간 포맷
|
||||
function formatTime(timeStr: string | null): string {
|
||||
if (!timeStr) return '-';
|
||||
return timeStr;
|
||||
}
|
||||
|
||||
// 금액 포맷
|
||||
function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat('ko-KR').format(amount);
|
||||
}
|
||||
|
||||
interface WorkerStatusListClientProps {
|
||||
initialData?: WorkerStatus[];
|
||||
initialStats?: WorkerStatusStats;
|
||||
@@ -54,120 +79,79 @@ export default function WorkerStatusListClient({
|
||||
}: WorkerStatusListClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// 상태
|
||||
const [workers, setWorkers] = useState<WorkerStatus[]>(initialData);
|
||||
const [stats, setStats] = useState<WorkerStatusStats | null>(initialStats || null);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
// 다중선택 필터 (빈 배열 = 전체)
|
||||
const [partnerFilters, setPartnerFilters] = useState<string[]>([]);
|
||||
const [siteFilters, setSiteFilters] = useState<string[]>([]);
|
||||
const [departmentFilters, setDepartmentFilters] = useState<string[]>([]);
|
||||
const [nameFilters, setNameFilters] = useState<string[]>([]);
|
||||
// 단일선택 필터
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('all');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [sortBy, setSortBy] = useState<string>('latest');
|
||||
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
|
||||
const [activeStatTab, setActiveStatTab] = useState<'all' | 'all_contract' | 'pending' | 'completed'>('all');
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [activeStatTab, setActiveStatTab] = useState<'all' | 'all_contract' | 'pending' | 'completed'>('all');
|
||||
const itemsPerPage = 20;
|
||||
const [stats, setStats] = useState<WorkerStatusStats | null>(initialStats || null);
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [listResult, statsResult] = await Promise.all([
|
||||
getWorkerStatusList({
|
||||
size: 1000,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
}),
|
||||
getWorkerStatusStats(),
|
||||
]);
|
||||
|
||||
if (listResult.success && listResult.data) {
|
||||
setWorkers(listResult.data.items);
|
||||
}
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStats(statsResult.data);
|
||||
}
|
||||
} catch {
|
||||
toast.error('데이터 로드에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [startDate, endDate]);
|
||||
|
||||
// 초기 데이터가 없으면 로드
|
||||
// Stats 로드
|
||||
useEffect(() => {
|
||||
if (initialData.length === 0) {
|
||||
loadData();
|
||||
if (!initialStats) {
|
||||
getWorkerStatusStats().then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setStats(result.data);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [initialData.length, loadData]);
|
||||
}, [initialStats]);
|
||||
|
||||
// 다중선택 필터 옵션 변환 (MultiSelectCombobox용)
|
||||
const partnerOptions: MultiSelectOption[] = useMemo(() =>
|
||||
MOCK_WORKER_PARTNERS.map(p => ({ value: p.value, label: p.label })),
|
||||
[]);
|
||||
const siteOptions: MultiSelectOption[] = useMemo(() =>
|
||||
MOCK_WORKER_SITES.map(s => ({ value: s.value, label: s.label })),
|
||||
[]);
|
||||
const departmentOptions: MultiSelectOption[] = useMemo(() =>
|
||||
MOCK_WORKER_DEPARTMENTS.map(d => ({ value: d.value, label: d.label })),
|
||||
[]);
|
||||
const nameOptions: MultiSelectOption[] = useMemo(() =>
|
||||
MOCK_WORKER_NAMES.map(n => ({ value: n.value, label: n.label })),
|
||||
[]);
|
||||
// ===== 핸들러 =====
|
||||
const handleRowClick = useCallback(
|
||||
(item: WorkerStatus) => {
|
||||
router.push(`/ko/construction/project/worker-status/${item.id}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredWorkers = useMemo(() => {
|
||||
return workers.filter((item) => {
|
||||
// 상태 탭 필터 (계약상태)
|
||||
if (activeStatTab !== 'all' && item.contractStatus !== activeStatTab) return false;
|
||||
const handleViewDetail = useCallback(
|
||||
(item: WorkerStatus) => {
|
||||
router.push(`/ko/construction/project/worker-status/${item.id}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
// 구분 필터
|
||||
if (categoryFilter !== 'all' && item.category !== categoryFilter) return false;
|
||||
// ===== UniversalListPage Config =====
|
||||
const config: UniversalListConfig<WorkerStatus> = useMemo(
|
||||
() => ({
|
||||
// 페이지 기본 정보
|
||||
title: '작업인력현황',
|
||||
description: '작업인력현황을 확인합니다',
|
||||
icon: Users,
|
||||
basePath: '/construction/project/worker-status',
|
||||
|
||||
// 상태 필터 (출근상태)
|
||||
if (statusFilter !== 'all' && item.status !== statusFilter) return false;
|
||||
// ID 추출
|
||||
idField: 'id',
|
||||
|
||||
// 거래처 필터 (다중선택)
|
||||
if (partnerFilters.length > 0) {
|
||||
const matchingPartner = MOCK_WORKER_PARTNERS.find((p) => p.label === item.partnerName);
|
||||
if (!matchingPartner || !partnerFilters.includes(matchingPartner.value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// API 액션
|
||||
actions: {
|
||||
getList: async () => {
|
||||
const result = await getWorkerStatusList({
|
||||
size: 1000,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
});
|
||||
if (result.success && result.data) {
|
||||
return {
|
||||
success: true,
|
||||
data: result.data.items,
|
||||
totalCount: result.data.total,
|
||||
};
|
||||
}
|
||||
return { success: false, error: result.error };
|
||||
},
|
||||
},
|
||||
|
||||
// 현장 필터 (다중선택)
|
||||
if (siteFilters.length > 0) {
|
||||
const matchingSite = MOCK_WORKER_SITES.find((s) => s.label === item.siteName);
|
||||
if (!matchingSite || !siteFilters.includes(matchingSite.value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// 테이블 컬럼
|
||||
columns: tableColumns,
|
||||
|
||||
// 부서 필터 (다중선택)
|
||||
if (departmentFilters.length > 0) {
|
||||
const matchingDept = MOCK_WORKER_DEPARTMENTS.find((d) => d.label === item.department);
|
||||
if (!matchingDept || !departmentFilters.includes(matchingDept.value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 이름 필터 (다중선택)
|
||||
if (nameFilters.length > 0) {
|
||||
const matchingName = MOCK_WORKER_NAMES.find((n) => n.label === item.workerName);
|
||||
if (!matchingName || !nameFilters.includes(matchingName.value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// 클라이언트 사이드 필터링
|
||||
clientSideFiltering: true,
|
||||
itemsPerPage: 20,
|
||||
|
||||
// 검색 필터
|
||||
if (searchValue) {
|
||||
searchPlaceholder: '거래처, 현장, 부서, 이름, 시공번호 검색',
|
||||
searchFilter: (item, searchValue) => {
|
||||
const search = searchValue.toLowerCase();
|
||||
return (
|
||||
item.partnerName.toLowerCase().includes(search) ||
|
||||
@@ -176,112 +160,197 @@ export default function WorkerStatusListClient({
|
||||
item.workerName.toLowerCase().includes(search) ||
|
||||
item.constructionNumber.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [workers, activeStatTab, categoryFilter, statusFilter, partnerFilters, siteFilters, departmentFilters, nameFilters, searchValue]);
|
||||
},
|
||||
|
||||
// 정렬
|
||||
const sortedWorkers = useMemo(() => {
|
||||
const sorted = [...filteredWorkers];
|
||||
switch (sortBy) {
|
||||
case 'latest':
|
||||
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
break;
|
||||
case 'oldest':
|
||||
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
break;
|
||||
case 'partnerAsc':
|
||||
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName));
|
||||
break;
|
||||
case 'partnerDesc':
|
||||
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName));
|
||||
break;
|
||||
case 'siteAsc':
|
||||
sorted.sort((a, b) => a.siteName.localeCompare(b.siteName));
|
||||
break;
|
||||
case 'siteDesc':
|
||||
sorted.sort((a, b) => b.siteName.localeCompare(a.siteName));
|
||||
break;
|
||||
}
|
||||
return sorted;
|
||||
}, [filteredWorkers, sortBy]);
|
||||
// 필터 설정
|
||||
filterConfig: [
|
||||
{
|
||||
key: 'partner',
|
||||
label: '거래처',
|
||||
type: 'multi',
|
||||
options: MOCK_WORKER_PARTNERS,
|
||||
},
|
||||
{
|
||||
key: 'site',
|
||||
label: '현장명',
|
||||
type: 'multi',
|
||||
options: MOCK_WORKER_SITES,
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
label: '구분',
|
||||
type: 'single',
|
||||
options: WORKER_CATEGORY_OPTIONS.filter((o) => o.value !== 'all'),
|
||||
},
|
||||
{
|
||||
key: 'department',
|
||||
label: '부서',
|
||||
type: 'multi',
|
||||
options: MOCK_WORKER_DEPARTMENTS,
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
label: '이름',
|
||||
type: 'multi',
|
||||
options: MOCK_WORKER_NAMES,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'single',
|
||||
options: WORKER_STATUS_OPTIONS.filter((o) => o.value !== 'all'),
|
||||
},
|
||||
{
|
||||
key: 'sortBy',
|
||||
label: '정렬',
|
||||
type: 'single',
|
||||
options: WORKER_SORT_OPTIONS,
|
||||
},
|
||||
],
|
||||
initialFilters: {
|
||||
partner: [],
|
||||
site: [],
|
||||
category: 'all',
|
||||
department: [],
|
||||
name: [],
|
||||
status: 'all',
|
||||
sortBy: 'latest',
|
||||
},
|
||||
filterTitle: '작업인력 필터',
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(sortedWorkers.length / itemsPerPage);
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (currentPage - 1) * itemsPerPage;
|
||||
return sortedWorkers.slice(start, start + itemsPerPage);
|
||||
}, [sortedWorkers, currentPage, itemsPerPage]);
|
||||
// 커스텀 필터 함수
|
||||
customFilterFn: (items, filterValues) => {
|
||||
return items.filter((item) => {
|
||||
// Stats 탭 필터 (계약상태)
|
||||
if (activeStatTab !== 'all' && item.contractStatus !== activeStatTab) return false;
|
||||
|
||||
// 핸들러
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchValue(value);
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
// 구분 필터
|
||||
const categoryFilter = filterValues.category as string;
|
||||
if (categoryFilter && categoryFilter !== 'all' && item.category !== categoryFilter) return false;
|
||||
|
||||
const handleToggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
// 상태 필터 (출근상태)
|
||||
const statusFilter = filterValues.status as string;
|
||||
if (statusFilter && statusFilter !== 'all' && item.status !== statusFilter) return false;
|
||||
|
||||
const handleToggleSelectAll = useCallback(() => {
|
||||
if (selectedItems.size === paginatedData.length) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(paginatedData.map((c) => c.id)));
|
||||
}
|
||||
}, [selectedItems.size, paginatedData]);
|
||||
// 거래처 필터 (다중선택)
|
||||
const partnerFilters = filterValues.partner as string[];
|
||||
if (partnerFilters?.length > 0) {
|
||||
const matchingPartner = MOCK_WORKER_PARTNERS.find((p) => p.label === item.partnerName);
|
||||
if (!matchingPartner || !partnerFilters.includes(matchingPartner.value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewDetail = useCallback(
|
||||
(e: React.MouseEvent, itemId: string) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/ko/construction/project/worker-status/${itemId}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
// 현장 필터 (다중선택)
|
||||
const siteFilters = filterValues.site as string[];
|
||||
if (siteFilters?.length > 0) {
|
||||
const matchingSite = MOCK_WORKER_SITES.find((s) => s.label === item.siteName);
|
||||
if (!matchingSite || !siteFilters.includes(matchingSite.value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(item: WorkerStatus) => {
|
||||
router.push(`/ko/construction/project/worker-status/${item.id}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
// 부서 필터 (다중선택)
|
||||
const departmentFilters = filterValues.department as string[];
|
||||
if (departmentFilters?.length > 0) {
|
||||
const matchingDept = MOCK_WORKER_DEPARTMENTS.find((d) => d.label === item.department);
|
||||
if (!matchingDept || !departmentFilters.includes(matchingDept.value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 시간 포맷
|
||||
const formatTime = (timeStr: string | null) => {
|
||||
if (!timeStr) return '-';
|
||||
return timeStr;
|
||||
};
|
||||
// 이름 필터 (다중선택)
|
||||
const nameFilters = filterValues.name as string[];
|
||||
if (nameFilters?.length > 0) {
|
||||
const matchingName = MOCK_WORKER_NAMES.find((n) => n.label === item.workerName);
|
||||
if (!matchingName || !nameFilters.includes(matchingName.value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 금액 포맷
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('ko-KR').format(amount);
|
||||
};
|
||||
return true;
|
||||
});
|
||||
},
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = useCallback(
|
||||
(item: WorkerStatus, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(item.id);
|
||||
// 커스텀 정렬 함수
|
||||
customSortFn: (items, filterValues) => {
|
||||
const sorted = [...items];
|
||||
const sortBy = (filterValues.sortBy as string) || 'latest';
|
||||
|
||||
return (
|
||||
switch (sortBy) {
|
||||
case 'latest':
|
||||
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
break;
|
||||
case 'oldest':
|
||||
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
break;
|
||||
case 'partnerAsc':
|
||||
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
|
||||
break;
|
||||
case 'partnerDesc':
|
||||
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
|
||||
break;
|
||||
case 'siteAsc':
|
||||
sorted.sort((a, b) => a.siteName.localeCompare(b.siteName, 'ko'));
|
||||
break;
|
||||
case 'siteDesc':
|
||||
sorted.sort((a, b) => b.siteName.localeCompare(a.siteName, 'ko'));
|
||||
break;
|
||||
}
|
||||
return sorted;
|
||||
},
|
||||
|
||||
// 공통 헤더 옵션
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
|
||||
// Stats 카드
|
||||
computeStats: (): StatCard[] => [
|
||||
{
|
||||
label: '전체 계약',
|
||||
value: stats?.allContract ?? 0,
|
||||
icon: FileText,
|
||||
iconColor: 'text-blue-600',
|
||||
onClick: () => setActiveStatTab('all_contract'),
|
||||
isActive: activeStatTab === 'all_contract',
|
||||
},
|
||||
{
|
||||
label: '계약대기',
|
||||
value: stats?.pending ?? 0,
|
||||
icon: Clock,
|
||||
iconColor: 'text-yellow-600',
|
||||
onClick: () => setActiveStatTab('pending'),
|
||||
isActive: activeStatTab === 'pending',
|
||||
},
|
||||
{
|
||||
label: '계약완료',
|
||||
value: stats?.completed ?? 0,
|
||||
icon: CheckCircle,
|
||||
iconColor: 'text-green-600',
|
||||
onClick: () => setActiveStatTab('completed'),
|
||||
isActive: activeStatTab === 'completed',
|
||||
},
|
||||
],
|
||||
|
||||
// 테이블 행 렌더링
|
||||
renderTableRow: (
|
||||
item: WorkerStatus,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<WorkerStatus>
|
||||
) => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleRowClick(item)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleSelection(item.id)}
|
||||
/>
|
||||
<Checkbox checked={handlers.isSelected} onCheckedChange={handlers.onToggle} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell>{item.partnerName}</TableCell>
|
||||
@@ -304,34 +373,38 @@ export default function WorkerStatusListClient({
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{isSelected && (
|
||||
{handlers.isSelected && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => handleViewDetail(e, item.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleViewDetail(item);
|
||||
}}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
[selectedItems, handleToggleSelection, handleRowClick, handleViewDetail]
|
||||
);
|
||||
),
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = useCallback(
|
||||
(item: WorkerStatus, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
|
||||
return (
|
||||
// 모바일 카드 렌더링
|
||||
renderMobileCard: (
|
||||
item: WorkerStatus,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<WorkerStatus>
|
||||
) => (
|
||||
<MobileCard
|
||||
key={item.id}
|
||||
title={item.workerName}
|
||||
subtitle={`${item.partnerName} - ${item.siteName}`}
|
||||
badge={WORKER_STATUS_LABELS[item.status]}
|
||||
badgeVariant="secondary"
|
||||
isSelected={isSelected}
|
||||
onToggle={onToggle}
|
||||
isSelected={handlers.isSelected}
|
||||
onToggle={handlers.onToggle}
|
||||
onClick={() => handleRowClick(item)}
|
||||
details={[
|
||||
{ label: '구분', value: WORKER_CATEGORY_LABELS[item.category] },
|
||||
@@ -340,204 +413,10 @@ export default function WorkerStatusListClient({
|
||||
{ label: '노임', value: formatCurrency(item.laborCost) },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[handleRowClick]
|
||||
),
|
||||
}),
|
||||
[startDate, endDate, activeStatTab, stats, handleRowClick, handleViewDetail]
|
||||
);
|
||||
|
||||
// 헤더 액션 (DateRangeSelector)
|
||||
const headerActions = (
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
/>
|
||||
);
|
||||
|
||||
// 통계 카드 클릭 핸들러
|
||||
const handleStatClick = useCallback((tab: 'all' | 'all_contract' | 'pending' | 'completed') => {
|
||||
setActiveStatTab(tab);
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
// 통계 카드 데이터
|
||||
const statsCardsData: StatCard[] = [
|
||||
{
|
||||
label: '전체 계약',
|
||||
value: stats?.allContract ?? 0,
|
||||
icon: FileText,
|
||||
iconColor: 'text-blue-600',
|
||||
onClick: () => handleStatClick('all_contract'),
|
||||
isActive: activeStatTab === 'all_contract',
|
||||
},
|
||||
{
|
||||
label: '계약대기',
|
||||
value: stats?.pending ?? 0,
|
||||
icon: Clock,
|
||||
iconColor: 'text-yellow-600',
|
||||
onClick: () => handleStatClick('pending'),
|
||||
isActive: activeStatTab === 'pending',
|
||||
},
|
||||
{
|
||||
label: '계약완료',
|
||||
value: stats?.completed ?? 0,
|
||||
icon: CheckCircle,
|
||||
iconColor: 'text-green-600',
|
||||
onClick: () => handleStatClick('completed'),
|
||||
isActive: activeStatTab === 'completed',
|
||||
},
|
||||
];
|
||||
|
||||
// ===== filterConfig 기반 통합 필터 시스템 =====
|
||||
const filterConfig: FilterFieldConfig[] = useMemo(() => [
|
||||
{
|
||||
key: 'partner',
|
||||
label: '거래처',
|
||||
type: 'multi',
|
||||
options: partnerOptions.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'site',
|
||||
label: '현장명',
|
||||
type: 'multi',
|
||||
options: siteOptions.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
label: '구분',
|
||||
type: 'single',
|
||||
options: WORKER_CATEGORY_OPTIONS.filter(o => o.value !== 'all').map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '전체',
|
||||
},
|
||||
{
|
||||
key: 'department',
|
||||
label: '부서',
|
||||
type: 'multi',
|
||||
options: departmentOptions.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
label: '이름',
|
||||
type: 'multi',
|
||||
options: nameOptions.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'single',
|
||||
options: WORKER_STATUS_OPTIONS.filter(o => o.value !== 'all').map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '전체',
|
||||
},
|
||||
{
|
||||
key: 'sortBy',
|
||||
label: '정렬',
|
||||
type: 'single',
|
||||
options: WORKER_SORT_OPTIONS.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '최신순',
|
||||
},
|
||||
], [partnerOptions, siteOptions, departmentOptions, nameOptions]);
|
||||
|
||||
const filterValues: FilterValues = useMemo(() => ({
|
||||
partner: partnerFilters,
|
||||
site: siteFilters,
|
||||
category: categoryFilter,
|
||||
department: departmentFilters,
|
||||
name: nameFilters,
|
||||
status: statusFilter,
|
||||
sortBy: sortBy,
|
||||
}), [partnerFilters, siteFilters, categoryFilter, departmentFilters, nameFilters, statusFilter, sortBy]);
|
||||
|
||||
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
|
||||
switch (key) {
|
||||
case 'partner':
|
||||
setPartnerFilters(value as string[]);
|
||||
break;
|
||||
case 'site':
|
||||
setSiteFilters(value as string[]);
|
||||
break;
|
||||
case 'category':
|
||||
setCategoryFilter(value as string);
|
||||
break;
|
||||
case 'department':
|
||||
setDepartmentFilters(value as string[]);
|
||||
break;
|
||||
case 'name':
|
||||
setNameFilters(value as string[]);
|
||||
break;
|
||||
case 'status':
|
||||
setStatusFilter(value as string);
|
||||
break;
|
||||
case 'sortBy':
|
||||
setSortBy(value as string);
|
||||
break;
|
||||
}
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const handleFilterReset = useCallback(() => {
|
||||
setPartnerFilters([]);
|
||||
setSiteFilters([]);
|
||||
setCategoryFilter('all');
|
||||
setDepartmentFilters([]);
|
||||
setNameFilters([]);
|
||||
setStatusFilter('all');
|
||||
setSortBy('latest');
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<IntegratedListTemplateV2
|
||||
title="작업인력현황"
|
||||
description="작업인력현황을 확인합니다"
|
||||
icon={Users}
|
||||
headerActions={headerActions}
|
||||
stats={statsCardsData}
|
||||
filterConfig={filterConfig}
|
||||
filterValues={filterValues}
|
||||
onFilterChange={handleFilterChange}
|
||||
onFilterReset={handleFilterReset}
|
||||
filterTitle="작업인력 필터"
|
||||
searchValue={searchValue}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="거래처, 현장, 부서, 이름, 시공번호 검색"
|
||||
tableColumns={tableColumns}
|
||||
data={paginatedData}
|
||||
allData={sortedWorkers}
|
||||
getItemId={(item) => item.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: sortedWorkers.length,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return <UniversalListPage config={config} initialData={initialData} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user