feat(WEB): 공사관리 시스템 및 CEO 대시보드 기능 확장

- 공사현장관리: 프로젝트 상세, 공정관리, 칸반보드 구현
- 이슈관리: 현장 이슈 등록/조회 기능 추가
- 근로자현황: 일별 근로자 출역 현황 페이지 추가
- 유틸리티관리: 현장 유틸리티 관리 페이지 추가
- 기성청구: 기성청구 관리 페이지 추가
- CEO 대시보드: 현황판(StatusBoardSection) 추가, 설정 다이얼로그 개선
- 발주관리: 모바일 필터 적용, 리스트 UI 개선
- 공용 컴포넌트: MobileFilter, IntegratedListTemplateV2 개선, CalendarHeader 반응형 개선

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2026-01-13 17:18:29 +09:00
parent d036ce4f42
commit db47a15544
85 changed files with 12940 additions and 499 deletions

View File

@@ -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,
}}
/>
);
}