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:
2026-01-16 15:47:13 +09:00
91 changed files with 21969 additions and 20128 deletions

View File

@@ -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} />;
}

View File

@@ -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} />;
}

View File

@@ -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} />;
}

View File

@@ -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} />;
}

View File

@@ -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>
</>
);
}
}

View File

@@ -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,
}}
/>
);
}

View File

@@ -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} />;
}

View File

@@ -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;

View File

@@ -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} />;
}

View File

@@ -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} />;
}

View File

@@ -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} />;
}

View File

@@ -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} />;
}

View File

@@ -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} />;
}

View File

@@ -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} />;
}

View File

@@ -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} />;
}

View File

@@ -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} />;
}