Files
sam-react-prod/src/components/business/construction/management/ProjectListClient.tsx

662 lines
25 KiB
TypeScript
Raw Normal View History

'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 (
<div className="relative">
<Button
variant="outline"
className="w-[140px] justify-between text-left font-normal"
onClick={() => setOpen(!open)}
>
<span className="truncate">{displayValue}</span>
</Button>
{open && (
<>
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<div className="absolute top-full left-0 z-50 mt-1 w-[200px] rounded-md border bg-popover p-1 shadow-md">
<div
className="flex items-center gap-2 p-2 hover:bg-muted rounded cursor-pointer"
onClick={() => handleToggle('all')}
>
<Checkbox checked={value.includes('all') || value.length === 0} />
<span className="text-sm"></span>
</div>
{options.map((option) => (
<div
key={option.value}
className="flex items-center gap-2 p-2 hover:bg-muted rounded cursor-pointer"
onClick={() => handleToggle(option.value)}
>
<Checkbox checked={value.includes(option.value)} />
<span className="text-sm">{option.label}</span>
</div>
))}
</div>
</>
)}
</div>
);
}
interface ProjectListClientProps {
initialData?: Project[];
initialStats?: ProjectStats;
}
export default function ProjectListClient({ initialData = [], initialStats }: ProjectListClientProps) {
const router = useRouter();
// 상태
const [projects, setProjects] = useState<Project[]>(initialData);
const [stats, setStats] = useState<ProjectStats>(
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<ChartViewMode>('day');
// TODO: 실제 API 연동 시 new Date()로 변경 (현재 목업 데이터가 2025년이라 임시 설정)
const [chartDate, setChartDate] = useState(new Date(2025, 0, 15));
const [chartPartnerFilter, setChartPartnerFilter] = useState<string[]>(['all']);
const [chartSiteFilter, setChartSiteFilter] = useState<string[]>(['all']);
// 테이블 필터
const [partnerFilter, setPartnerFilter] = useState<string[]>(['all']);
const [contractManagerFilter, setContractManagerFilter] = useState<string[]>(['all']);
const [pmFilter, setPmFilter] = useState<string[]>(['all']);
const [statusFilter, setStatusFilter] = useState('all');
const [sortBy, setSortBy] = useState<'latest' | 'progress' | 'register' | 'completion'>('latest');
// 필터 옵션들
const [partnerOptions, setPartnerOptions] = useState<SelectOption[]>([]);
const [siteOptions, setSiteOptions] = useState<SelectOption[]>([]);
const [contractManagerOptions, setContractManagerOptions] = useState<SelectOption[]>([]);
const [pmOptions, setPmOptions] = useState<SelectOption[]>([]);
// 테이블 상태
const [selectedItems, setSelectedItems] = useState<Set<string>>(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 <Badge variant="destructive"></Badge>;
}
switch (status) {
case 'completed':
return <Badge variant="secondary"></Badge>;
case 'in_progress':
return <Badge variant="default"></Badge>;
default:
return <Badge variant="outline">{status}</Badge>;
}
};
const allSelected = selectedItems.size === paginatedData.length && paginatedData.length > 0;
return (
<PageLayout>
{/* 페이지 헤더 */}
<PageHeader
title="프로젝트 관리"
description="계약 완료 시 자동 등록된 프로젝트를 관리합니다"
icon={FolderKanban}
/>
{/* 기간 선택 (달력 + 프리셋 버튼) */}
<DateRangeSelector
startDate={filterStartDate}
endDate={filterEndDate}
onStartDateChange={setFilterStartDate}
onEndDateChange={setFilterEndDate}
/>
{/* 상태 카드 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 rounded-lg">
<ClipboardList className="h-5 w-5 text-blue-600" />
</div>
<div>
<p className="text-sm text-muted-foreground"> </p>
<p className="text-2xl font-bold">{stats.total}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-100 rounded-lg">
<PlayCircle className="h-5 w-5 text-green-600" />
</div>
<div>
<p className="text-sm text-muted-foreground"> </p>
<p className="text-2xl font-bold">{stats.inProgress}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-gray-100 rounded-lg">
<CheckCircle2 className="h-5 w-5 text-gray-600" />
</div>
<div>
<p className="text-sm text-muted-foreground"> </p>
<p className="text-2xl font-bold">{stats.completed}</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* 프로젝트 일정 간트차트 */}
<Card>
<CardContent className="p-4">
<div className="flex flex-col gap-4">
{/* 간트차트 상단 컨트롤 */}
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-2">
<span className="text-sm font-medium"> </span>
</div>
<div className="flex flex-wrap items-center gap-3">
{/* 일/주/월 전환 */}
<div className="flex border rounded-md">
<Button
variant={chartViewMode === 'day' ? 'default' : 'ghost'}
size="sm"
className="rounded-r-none border-r-0"
onClick={() => setChartViewMode('day')}
>
</Button>
<Button
variant={chartViewMode === 'week' ? 'default' : 'ghost'}
size="sm"
className="rounded-none border-r-0"
onClick={() => setChartViewMode('week')}
>
</Button>
<Button
variant={chartViewMode === 'month' ? 'default' : 'ghost'}
size="sm"
className="rounded-l-none"
onClick={() => setChartViewMode('month')}
>
</Button>
</div>
{/* 거래처 필터 */}
<MultiSelectFilter
label="거래처"
options={partnerOptions}
value={chartPartnerFilter}
onChange={setChartPartnerFilter}
/>
{/* 현장 필터 */}
<MultiSelectFilter
label="현장"
options={siteOptions}
value={chartSiteFilter}
onChange={setChartSiteFilter}
/>
</div>
</div>
{/* 간트차트 */}
<ProjectGanttChart
projects={chartProjects}
viewMode={chartViewMode}
currentDate={chartDate}
onProjectClick={handleGanttProjectClick}
onDateChange={setChartDate}
/>
</div>
</CardContent>
</Card>
{/* 테이블 영역 */}
<Card>
<CardContent className="pt-6">
{/* 테이블 헤더 (필터들) */}
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
<span className="text-sm text-muted-foreground whitespace-nowrap">
{projects.length}
</span>
<div className="flex flex-wrap items-center gap-3">
{/* 거래처 필터 */}
<MultiSelectFilter
label="거래처"
options={partnerOptions}
value={partnerFilter}
onChange={(v) => {
setPartnerFilter(v);
setCurrentPage(1);
}}
/>
{/* 계약담당자 필터 */}
<MultiSelectFilter
label="계약담당자"
options={contractManagerOptions}
value={contractManagerFilter}
onChange={(v) => {
setContractManagerFilter(v);
setCurrentPage(1);
}}
/>
{/* 공사PM 필터 */}
<MultiSelectFilter
label="공사PM"
options={pmOptions}
value={pmFilter}
onChange={(v) => {
setPmFilter(v);
setCurrentPage(1);
}}
/>
{/* 상태 필터 */}
<Select value={statusFilter} onValueChange={(v) => { setStatusFilter(v); setCurrentPage(1); }}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 정렬 */}
<Select value={sortBy} onValueChange={(v) => setSortBy(v as typeof sortBy)}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="최신순" />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 데스크톱 테이블 */}
<div className="hidden xl:block rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="h-14">
<TableHead className="w-[50px] text-center">
<Checkbox
checked={allSelected}
onCheckedChange={handleToggleSelectAll}
/>
</TableHead>
<TableHead className="w-[60px] text-center"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="min-w-[150px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[100px]">PM</TableHead>
<TableHead className="w-[80px] text-center"> </TableHead>
<TableHead className="w-[120px] text-right"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
<TableHead className="w-[120px] text-right"> </TableHead>
<TableHead className="w-[180px] text-center"> </TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody className="[&_tr]:h-14 [&_tr]:min-h-[56px] [&_tr]:max-h-[56px]">
{paginatedData.length === 0 ? (
<TableRow>
<TableCell colSpan={14} className="h-24 text-center">
.
</TableCell>
</TableRow>
) : (
paginatedData.map((project, index) => {
const isSelected = selectedItems.has(project.id);
const globalIndex = startIndex + index + 1;
return (
<TableRow
key={project.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleRowClick(project)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={() => handleToggleSelection(project.id)}
/>
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell className="font-medium">{project.contractNumber}</TableCell>
<TableCell>{project.partnerName}</TableCell>
<TableCell>{project.siteName}</TableCell>
<TableCell>{project.contractManager}</TableCell>
<TableCell>{project.constructionPM}</TableCell>
<TableCell className="text-center">{project.totalLocations}</TableCell>
<TableCell className="text-right">{formatAmount(project.contractAmount)}</TableCell>
<TableCell className="text-center">{project.progressRate}%</TableCell>
<TableCell className="text-right">{formatAmount(project.accumulatedPayment)}</TableCell>
<TableCell className="text-center">
{formatDate(project.startDate)} ~ {formatDate(project.endDate)}
</TableCell>
<TableCell className="text-center">
{getStatusBadge(project.status, project.hasUrgentIssue)}
</TableCell>
<TableCell className="text-center">
{isSelected && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => handleEdit(e, project.id)}
>
<Pencil className="h-4 w-4" />
</Button>
)}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
{/* 모바일/태블릿 카드 뷰 */}
<div className="xl:hidden space-y-4 md:grid md:grid-cols-2 md:gap-4 lg:grid-cols-3">
{projects.length === 0 ? (
<div className="text-center py-6 text-muted-foreground border rounded-lg">
.
</div>
) : (
projects.map((project, index) => {
const isSelected = selectedItems.has(project.id);
return (
<MobileCard
key={project.id}
title={project.siteName}
subtitle={project.contractNumber}
badge={project.hasUrgentIssue ? '긴급' : project.status === 'completed' ? '완료' : '진행중'}
badgeVariant={project.hasUrgentIssue ? 'destructive' : project.status === 'completed' ? 'secondary' : 'default'}
isSelected={isSelected}
onToggle={() => 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) },
]}
/>
);
})
)}
</div>
</CardContent>
</Card>
{/* 페이지네이션 */}
{totalPages > 1 && (
<div className="hidden xl:flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{projects.length} {startIndex + 1}-{Math.min(startIndex + itemsPerPage, projects.length)}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage - 1)}
disabled={currentPage === 1}
>
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => {
if (
page === 1 ||
page === totalPages ||
(page >= currentPage - 2 && page <= currentPage + 2)
) {
return (
<Button
key={page}
variant={page === currentPage ? 'default' : 'outline'}
size="sm"
onClick={() => setCurrentPage(page)}
className="min-w-[36px]"
>
{page}
</Button>
);
} else if (page === currentPage - 3 || page === currentPage + 3) {
return <span key={page} className="px-2">...</span>;
}
return null;
})}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage + 1)}
disabled={currentPage === totalPages}
>
</Button>
</div>
</div>
)}
</PageLayout>
);
}