feat(WEB): 공사관리 시스템 및 CEO 대시보드 기능 확장
- 공사현장관리: 프로젝트 상세, 공정관리, 칸반보드 구현 - 이슈관리: 현장 이슈 등록/조회 기능 추가 - 근로자현황: 일별 근로자 출역 현황 페이지 추가 - 유틸리티관리: 현장 유틸리티 관리 페이지 추가 - 기성청구: 기성청구 관리 페이지 추가 - CEO 대시보드: 현황판(StatusBoardSection) 추가, 설정 다이얼로그 개선 - 발주관리: 모바일 필터 적용, 리스트 UI 개선 - 공용 컴포넌트: MobileFilter, IntegratedListTemplateV2 개선, CalendarHeader 반응형 개선 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,440 @@
|
||||
'use client';
|
||||
|
||||
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 type { ProgressBilling, ProgressBillingStats } from './types';
|
||||
import {
|
||||
PROGRESS_BILLING_STATUS_OPTIONS,
|
||||
PROGRESS_BILLING_SORT_OPTIONS,
|
||||
PROGRESS_BILLING_STATUS_STYLES,
|
||||
PROGRESS_BILLING_STATUS_LABELS,
|
||||
MOCK_PARTNERS,
|
||||
MOCK_SITES,
|
||||
PARTNER_SITES_MAP,
|
||||
} from './types';
|
||||
import {
|
||||
getProgressBillingList,
|
||||
getProgressBillingStats,
|
||||
} from './actions';
|
||||
|
||||
// 테이블 컬럼 정의
|
||||
const tableColumns: TableColumn[] = [
|
||||
{ key: 'no', label: '번호', className: 'w-[50px] text-center' },
|
||||
{ key: 'billingNumber', label: '기성청구번호', className: 'w-[140px]' },
|
||||
{ key: 'partnerName', label: '거래처', className: 'w-[120px]' },
|
||||
{ key: 'siteName', label: '현장명', className: 'min-w-[150px]' },
|
||||
{ key: 'round', label: '회차', className: 'w-[60px] text-center' },
|
||||
{ key: 'billingYearMonth', label: '기성청구연월', className: 'w-[110px] text-center' },
|
||||
{ key: 'previousBilling', label: '전회기성', className: 'w-[120px] text-right' },
|
||||
{ key: 'currentBilling', label: '금회기성', className: 'w-[120px] text-right' },
|
||||
{ key: 'cumulativeBilling', label: '누계기성', className: 'w-[120px] text-right' },
|
||||
{ key: 'status', label: '상태', className: 'w-[100px] text-center' },
|
||||
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
|
||||
];
|
||||
|
||||
interface ProgressBillingManagementListClientProps {
|
||||
initialData?: ProgressBilling[];
|
||||
initialStats?: ProgressBillingStats;
|
||||
}
|
||||
|
||||
export default function ProgressBillingManagementListClient({
|
||||
initialData = [],
|
||||
initialStats,
|
||||
}: 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');
|
||||
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 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]);
|
||||
|
||||
// 초기 데이터가 없으면 로드
|
||||
useEffect(() => {
|
||||
if (initialData.length === 0) {
|
||||
loadData();
|
||||
}
|
||||
}, [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]);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(billing: ProgressBilling) => {
|
||||
router.push(`/ko/construction/billing/progress-billing-management/${billing.id}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(e: React.MouseEvent, billingId: string) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/ko/construction/billing/progress-billing-management/${billingId}/edit`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
// 금액 포맷
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('ko-KR').format(amount);
|
||||
};
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = useCallback(
|
||||
(billing: ProgressBilling, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(billing.id);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={billing.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleRowClick(billing)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleSelection(billing.id)}
|
||||
/>
|
||||
</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 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>
|
||||
</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, billing.id)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
[selectedItems, handleToggleSelection, handleRowClick, handleEdit]
|
||||
);
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = useCallback(
|
||||
(billing: ProgressBilling, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
|
||||
return (
|
||||
<MobileCard
|
||||
title={billing.siteName}
|
||||
subtitle={billing.billingNumber}
|
||||
badge={PROGRESS_BILLING_STATUS_LABELS[billing.status]}
|
||||
badgeVariant="secondary"
|
||||
isSelected={isSelected}
|
||||
onToggle={onToggle}
|
||||
onClick={() => handleRowClick(billing)}
|
||||
details={[
|
||||
{ label: '거래처', value: billing.partnerName },
|
||||
{ label: '회차', value: `${billing.round}차` },
|
||||
{ label: '금회기성', value: formatCurrency(billing.currentBilling) },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[handleRowClick]
|
||||
);
|
||||
|
||||
// 헤더 액션 (날짜 범위 + 퀵버튼)
|
||||
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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user