- LocationDetailPanel: 6개 탭 구현 (본체, 가이드레일, 케이스, 하단마감재, 모터&제어기, 부자재) - 각 탭별 다른 테이블 컬럼 구조 적용 - QuoteSummaryPanel: 개소별/상세별 합계 패널 개선 - QuotePreviewModal: EstimateDocumentModal 패턴 적용 (헤더+버튼 영역 분리) - Input value → defaultValue 변경으로 React 경고 해결 - 팩스/카카오톡 버튼 제거 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
662 lines
25 KiB
TypeScript
662 lines
25 KiB
TypeScript
'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>
|
|
);
|
|
}
|