'use client'; import { useState, useMemo, useCallback, useEffect, Fragment } from 'react'; import { useRouter } from 'next/navigation'; import { FolderKanban, Pencil, ClipboardList, PlayCircle, CheckCircle2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Checkbox } from '@/components/ui/checkbox'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent } from '@/components/ui/card'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { PageLayout } from '@/components/organisms/PageLayout'; import { PageHeader } from '@/components/organisms/PageHeader'; import { MobileCard } from '@/components/molecules/MobileCard'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; import type { Project, ProjectStats, ChartViewMode, SelectOption } from './types'; import { STATUS_OPTIONS, SORT_OPTIONS } from './types'; import { DateRangeSelector } from '@/components/molecules/DateRangeSelector'; import { format, startOfMonth, endOfMonth } from 'date-fns'; import { getProjectList, getProjectStats, getPartnerOptions, getSiteOptions, getContractManagerOptions, getConstructionPMOptions, } from './actions'; import ProjectGanttChart from './ProjectGanttChart'; // 다중 선택 셀렉트 컴포넌트 function MultiSelectFilter({ label, options, value, onChange, }: { label: string; options: SelectOption[]; value: string[]; onChange: (value: string[]) => void; }) { const [open, setOpen] = useState(false); const handleToggle = (optionValue: string) => { if (optionValue === 'all') { onChange(['all']); } else { const newValue = value.includes(optionValue) ? value.filter((v) => v !== optionValue && v !== 'all') : [...value.filter((v) => v !== 'all'), optionValue]; onChange(newValue.length === 0 ? ['all'] : newValue); } }; const displayValue = value.includes('all') || value.length === 0 ? '전체' : value.length === 1 ? options.find((o) => o.value === value[0])?.label || value[0] : `${value.length}개 선택`; return (
{open && ( <>
setOpen(false)} />
handleToggle('all')} > 전체
{options.map((option) => (
handleToggle(option.value)} > {option.label}
))}
)}
); } interface ProjectListClientProps { initialData?: Project[]; initialStats?: ProjectStats; } export default function ProjectListClient({ initialData = [], initialStats }: ProjectListClientProps) { const router = useRouter(); // 상태 const [projects, setProjects] = useState(initialData); const [stats, setStats] = useState( initialStats ?? { total: 0, inProgress: 0, completed: 0 } ); const [isLoading, setIsLoading] = useState(false); // 날짜 범위 (기간 선택) const [filterStartDate, setFilterStartDate] = useState(() => format(startOfMonth(new Date()), 'yyyy-MM-dd')); const [filterEndDate, setFilterEndDate] = useState(() => format(endOfMonth(new Date()), 'yyyy-MM-dd')); // 간트차트 상태 const [chartViewMode, setChartViewMode] = useState('day'); // TODO: 실제 API 연동 시 new Date()로 변경 (현재 목업 데이터가 2025년이라 임시 설정) const [chartDate, setChartDate] = useState(new Date(2025, 0, 15)); const [chartPartnerFilter, setChartPartnerFilter] = useState(['all']); const [chartSiteFilter, setChartSiteFilter] = useState(['all']); // 테이블 필터 const [partnerFilter, setPartnerFilter] = useState(['all']); const [contractManagerFilter, setContractManagerFilter] = useState(['all']); const [pmFilter, setPmFilter] = useState(['all']); const [statusFilter, setStatusFilter] = useState('all'); const [sortBy, setSortBy] = useState<'latest' | 'progress' | 'register' | 'completion'>('latest'); // 필터 옵션들 const [partnerOptions, setPartnerOptions] = useState([]); const [siteOptions, setSiteOptions] = useState([]); const [contractManagerOptions, setContractManagerOptions] = useState([]); const [pmOptions, setPmOptions] = useState([]); // 테이블 상태 const [selectedItems, setSelectedItems] = useState>(new Set()); const [currentPage, setCurrentPage] = useState(1); const itemsPerPage = 20; // 데이터 로드 const loadData = useCallback(async () => { setIsLoading(true); try { const [listResult, statsResult, partners, sites, managers, pms] = await Promise.all([ getProjectList({ partners: partnerFilter.includes('all') ? undefined : partnerFilter, contractManagers: contractManagerFilter.includes('all') ? undefined : contractManagerFilter, constructionPMs: pmFilter.includes('all') ? undefined : pmFilter, status: statusFilter === 'all' ? undefined : statusFilter, sortBy, size: 1000, }), getProjectStats(), getPartnerOptions(), getSiteOptions(), getContractManagerOptions(), getConstructionPMOptions(), ]); if (listResult.success && listResult.data) { setProjects(listResult.data.items); } if (statsResult.success && statsResult.data) { setStats(statsResult.data); } if (partners.success && partners.data) { setPartnerOptions(partners.data); } if (sites.success && sites.data) { setSiteOptions(sites.data); } if (managers.success && managers.data) { setContractManagerOptions(managers.data); } if (pms.success && pms.data) { setPmOptions(pms.data); } } catch { toast.error('데이터 로드에 실패했습니다.'); } finally { setIsLoading(false); } }, [partnerFilter, contractManagerFilter, pmFilter, statusFilter, sortBy]); useEffect(() => { loadData(); }, [loadData]); // 간트차트용 필터링된 프로젝트 const chartProjects = useMemo(() => { return projects.filter((project) => { if (!chartPartnerFilter.includes('all') && !chartPartnerFilter.includes(project.partnerName)) { return false; } if (!chartSiteFilter.includes('all') && !chartSiteFilter.includes(project.siteName)) { return false; } return true; }); }, [projects, chartPartnerFilter, chartSiteFilter]); // 페이지네이션 const totalPages = Math.ceil(projects.length / itemsPerPage); const paginatedData = useMemo(() => { const start = (currentPage - 1) * itemsPerPage; return projects.slice(start, start + itemsPerPage); }, [projects, currentPage, itemsPerPage]); const startIndex = (currentPage - 1) * itemsPerPage; // 핸들러 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]); const handleRowClick = useCallback( (project: Project) => { router.push(`/ko/construction/project/management/${project.id}`); }, [router] ); const handleEdit = useCallback( (e: React.MouseEvent, projectId: string) => { e.stopPropagation(); router.push(`/ko/construction/project/management/${projectId}/edit`); }, [router] ); const handleGanttProjectClick = useCallback( (project: Project) => { router.push(`/ko/construction/project/management/${project.id}`); }, [router] ); // 금액 포맷 const formatAmount = (amount: number) => { return amount.toLocaleString() + '원'; }; // 날짜 포맷 const formatDate = (dateStr: string) => { return dateStr.replace(/-/g, '.'); }; // 상태 뱃지 const getStatusBadge = (status: string, hasUrgentIssue: boolean) => { if (hasUrgentIssue) { return 긴급; } switch (status) { case 'completed': return 완료; case 'in_progress': return 진행중; default: return {status}; } }; const allSelected = selectedItems.size === paginatedData.length && paginatedData.length > 0; return ( {/* 페이지 헤더 */} {/* 기간 선택 (달력 + 프리셋 버튼) */} {/* 상태 카드 */}

전체 프로젝트

{stats.total}

프로젝트 진행

{stats.inProgress}

프로젝트 완료

{stats.completed}

{/* 프로젝트 일정 간트차트 */}
{/* 간트차트 상단 컨트롤 */}
프로젝트 일정
{/* 일/주/월 전환 */}
{/* 거래처 필터 */} {/* 현장 필터 */}
{/* 간트차트 */}
{/* 테이블 영역 */} {/* 테이블 헤더 (필터들) */}
총 {projects.length}건
{/* 거래처 필터 */} { setPartnerFilter(v); setCurrentPage(1); }} /> {/* 계약담당자 필터 */} { setContractManagerFilter(v); setCurrentPage(1); }} /> {/* 공사PM 필터 */} { setPmFilter(v); setCurrentPage(1); }} /> {/* 상태 필터 */} {/* 정렬 */}
{/* 데스크톱 테이블 */}
번호 계약번호 거래처 현장명 계약담당자 공사PM 총 개소 계약금액 진행률 누계 기성 프로젝트 기간 상태 작업 {paginatedData.length === 0 ? ( 검색 결과가 없습니다. ) : ( paginatedData.map((project, index) => { const isSelected = selectedItems.has(project.id); const globalIndex = startIndex + index + 1; return ( handleRowClick(project)} > e.stopPropagation()}> handleToggleSelection(project.id)} /> {globalIndex} {project.contractNumber} {project.partnerName} {project.siteName} {project.contractManager} {project.constructionPM} {project.totalLocations} {formatAmount(project.contractAmount)} {project.progressRate}% {formatAmount(project.accumulatedPayment)} {formatDate(project.startDate)} ~ {formatDate(project.endDate)} {getStatusBadge(project.status, project.hasUrgentIssue)} {isSelected && ( )} ); }) )}
{/* 모바일/태블릿 카드 뷰 */}
{projects.length === 0 ? (
검색 결과가 없습니다.
) : ( projects.map((project, index) => { const isSelected = selectedItems.has(project.id); return ( handleToggleSelection(project.id)} onClick={() => handleRowClick(project)} details={[ { label: '거래처', value: project.partnerName }, { label: '공사PM', value: project.constructionPM }, { label: '진행률', value: `${project.progressRate}%` }, { label: '계약금액', value: formatAmount(project.contractAmount) }, ]} /> ); }) )}
{/* 페이지네이션 */} {totalPages > 1 && (
전체 {projects.length}개 중 {startIndex + 1}-{Math.min(startIndex + itemsPerPage, projects.length)}개 표시
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => { if ( page === 1 || page === totalPages || (page >= currentPage - 2 && page <= currentPage + 2) ) { return ( ); } else if (page === currentPage - 3 || page === currentPage + 3) { return ...; } return null; })}
)}
); }