'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.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;
})}
)}
);
}