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,246 @@
'use client';
import { FileText, List, Eye, Edit } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type { ProgressBillingDetail } from './types';
import { useProgressBillingDetailForm } from './hooks/useProgressBillingDetailForm';
import { ProgressBillingInfoCard } from './cards/ProgressBillingInfoCard';
import { ContractInfoCard } from './cards/ContractInfoCard';
import { ProgressBillingItemTable } from './tables/ProgressBillingItemTable';
import { PhotoTable } from './tables/PhotoTable';
import { DirectConstructionModal } from './modals/DirectConstructionModal';
import { IndirectConstructionModal } from './modals/IndirectConstructionModal';
import { PhotoDocumentModal } from './modals/PhotoDocumentModal';
interface ProgressBillingDetailFormProps {
mode: 'view' | 'edit';
billingId: string;
initialData?: ProgressBillingDetail;
}
export default function ProgressBillingDetailForm({
mode,
billingId,
initialData,
}: ProgressBillingDetailFormProps) {
const {
// Mode flags
isViewMode,
isEditMode,
// Form data
formData,
// Loading state
isLoading,
// Dialog states
showSaveDialog,
setShowSaveDialog,
showDeleteDialog,
setShowDeleteDialog,
// Modal states
showDirectConstructionModal,
setShowDirectConstructionModal,
showIndirectConstructionModal,
setShowIndirectConstructionModal,
showPhotoDocumentModal,
setShowPhotoDocumentModal,
// Selection states
selectedBillingItems,
selectedPhotoItems,
// Navigation handlers
handleBack,
handleEdit,
handleCancel,
// Form handlers
handleFieldChange,
// CRUD handlers
handleSave,
handleConfirmSave,
handleDelete,
handleConfirmDelete,
// Billing item handlers
handleBillingItemChange,
handleToggleBillingItemSelection,
handleToggleSelectAllBillingItems,
handleApplySelectedBillingItems,
// Photo item handlers
handleTogglePhotoItemSelection,
handleToggleSelectAllPhotoItems,
handleApplySelectedPhotoItems,
handlePhotoSelect,
// Modal handlers
handleViewDirectConstruction,
handleViewIndirectConstruction,
handleViewPhotoDocument,
} = useProgressBillingDetailForm({ mode, billingId, initialData });
// 헤더 액션 버튼
const headerActions = isViewMode ? (
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleBack}>
<List className="h-4 w-4 mr-2" />
</Button>
<Button variant="outline" onClick={handleViewDirectConstruction}>
<Eye className="h-4 w-4 mr-2" />
</Button>
<Button variant="outline" onClick={handleViewIndirectConstruction}>
<Eye className="h-4 w-4 mr-2" />
</Button>
<Button variant="outline" onClick={handleViewPhotoDocument}>
<Eye className="h-4 w-4 mr-2" />
</Button>
<Button onClick={handleEdit}>
<Edit className="h-4 w-4 mr-2" />
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleBack}>
<List className="h-4 w-4 mr-2" />
</Button>
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button onClick={handleSave} disabled={isLoading}>
</Button>
</div>
);
return (
<PageLayout>
<PageHeader
title="기성청구 상세"
description="기성청구를 등록하고 관리합니다"
icon={FileText}
onBack={handleBack}
actions={headerActions}
/>
<div className="space-y-6">
{/* 기성청구 정보 */}
<ProgressBillingInfoCard
formData={formData}
isViewMode={isViewMode}
onFieldChange={handleFieldChange}
/>
{/* 계약 정보 */}
<ContractInfoCard formData={formData} />
{/* 기성청구 내역 */}
<ProgressBillingItemTable
items={formData.billingItems}
isViewMode={isViewMode}
isEditMode={isEditMode}
selectedItems={selectedBillingItems}
onToggleSelection={handleToggleBillingItemSelection}
onToggleSelectAll={handleToggleSelectAllBillingItems}
onApplySelected={handleApplySelectedBillingItems}
onItemChange={handleBillingItemChange}
/>
{/* 사진대지 */}
<PhotoTable
items={formData.photoItems}
isViewMode={isViewMode}
isEditMode={isEditMode}
selectedItems={selectedPhotoItems}
onToggleSelection={handleTogglePhotoItemSelection}
onToggleSelectAll={handleToggleSelectAllPhotoItems}
onApplySelected={handleApplySelectedPhotoItems}
onPhotoSelect={handlePhotoSelect}
/>
</div>
{/* 저장 확인 다이얼로그 */}
<AlertDialog open={showSaveDialog} onOpenChange={setShowSaveDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmSave} disabled={isLoading}>
{isLoading ? '저장 중...' : '저장'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
disabled={isLoading}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isLoading ? '삭제 중...' : '삭제'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 직접 공사 내역서 모달 */}
<DirectConstructionModal
open={showDirectConstructionModal}
onOpenChange={setShowDirectConstructionModal}
data={formData}
/>
{/* 간접 공사 내역서 모달 */}
<IndirectConstructionModal
open={showIndirectConstructionModal}
onOpenChange={setShowIndirectConstructionModal}
data={formData}
/>
{/* 사진대지 모달 */}
<PhotoDocumentModal
open={showPhotoDocumentModal}
onOpenChange={setShowPhotoDocumentModal}
data={formData}
/>
</PageLayout>
);
}

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

View File

@@ -0,0 +1,317 @@
'use server';
import type {
ProgressBilling,
ProgressBillingStats,
ProgressBillingStatus,
ProgressBillingDetail,
ProgressBillingDetailFormData,
} from './types';
import { MOCK_PROGRESS_BILLING_DETAIL } from './types';
import { format, subMonths } from 'date-fns';
/**
* 목업 기성청구 데이터 생성
*/
function generateMockProgressBillings(): ProgressBilling[] {
const partners = [
{ id: '1', name: '(주)대한건설' },
{ id: '2', name: '삼성물산' },
{ id: '3', name: '현대건설' },
{ id: '4', name: 'GS건설' },
{ id: '5', name: '대림산업' },
];
const sites = [
{ id: '1', name: '강남 오피스빌딩 신축' },
{ id: '2', name: '판교 데이터센터' },
{ id: '3', name: '송도 물류센터' },
{ id: '4', name: '인천공항 터미널' },
{ id: '5', name: '부산항 창고' },
];
const statuses: ProgressBillingStatus[] = ['billing_waiting', 'approval_waiting', 'constructor_sent', 'billing_complete'];
const billings: ProgressBilling[] = [];
const baseDate = new Date(2026, 0, 1);
for (let i = 0; i < 50; i++) {
const partner = partners[i % partners.length];
const site = sites[i % sites.length];
const status = statuses[i % statuses.length];
const round = (i % 12) + 1;
const monthOffset = i % 6;
const billingDate = subMonths(baseDate, monthOffset);
// 기성 금액 계산 (회차에 따라 누적)
const baseAmount = 10000000 + (i * 500000);
const previousBilling = round > 1 ? baseAmount * (round - 1) : 0;
const currentBilling = baseAmount;
const cumulativeBilling = previousBilling + currentBilling;
billings.push({
id: `billing-${i + 1}`,
billingNumber: `PB-${2026}-${String(i + 1).padStart(4, '0')}`,
partnerId: partner.id,
partnerName: partner.name,
siteId: site.id,
siteName: site.name,
round,
billingYearMonth: format(billingDate, 'yyyy-MM'),
previousBilling,
currentBilling,
cumulativeBilling,
status,
createdAt: format(billingDate, "yyyy-MM-dd'T'HH:mm:ss"),
updatedAt: format(baseDate, "yyyy-MM-dd'T'HH:mm:ss"),
});
}
return billings;
}
// 캐시된 목업 데이터
let cachedBillings: ProgressBilling[] | null = null;
function getMockBillings(): ProgressBilling[] {
if (!cachedBillings) {
cachedBillings = generateMockProgressBillings();
}
return cachedBillings;
}
/**
* 기성청구 목록 조회
*/
export async function getProgressBillingList(params?: {
size?: number;
page?: number;
startDate?: string;
endDate?: string;
status?: string;
partnerIds?: string[];
siteIds?: string[];
search?: string;
}): Promise<{
success: boolean;
data?: { items: ProgressBilling[]; total: number };
error?: string;
}> {
try {
let billings = getMockBillings();
// 날짜 필터
if (params?.startDate && params?.endDate) {
billings = billings.filter((billing) => {
const billingDate = billing.billingYearMonth;
return billingDate >= params.startDate!.slice(0, 7) && billingDate <= params.endDate!.slice(0, 7);
});
}
// 상태 필터
if (params?.status && params.status !== 'all') {
billings = billings.filter((billing) => billing.status === params.status);
}
// 거래처 필터 (다중선택)
if (params?.partnerIds && params.partnerIds.length > 0) {
billings = billings.filter((billing) => params.partnerIds!.includes(billing.partnerId));
}
// 현장 필터 (다중선택)
if (params?.siteIds && params.siteIds.length > 0) {
billings = billings.filter((billing) => params.siteIds!.includes(billing.siteId));
}
// 검색
if (params?.search) {
const search = params.search.toLowerCase();
billings = billings.filter(
(billing) =>
billing.billingNumber.toLowerCase().includes(search) ||
billing.partnerName.toLowerCase().includes(search) ||
billing.siteName.toLowerCase().includes(search)
);
}
// 페이지네이션
const page = params?.page || 1;
const size = params?.size || 1000;
const start = (page - 1) * size;
const paginatedBillings = billings.slice(start, start + size);
return {
success: true,
data: {
items: paginatedBillings,
total: billings.length,
},
};
} catch {
return {
success: false,
error: '기성청구 목록 조회에 실패했습니다.',
};
}
}
/**
* 기성청구 통계 조회
*/
export async function getProgressBillingStats(): Promise<{
success: boolean;
data?: ProgressBillingStats;
error?: string;
}> {
try {
const billings = getMockBillings();
const stats: ProgressBillingStats = {
total: billings.length,
contractWaiting: billings.filter((b) => b.status === 'billing_waiting' || b.status === 'approval_waiting').length,
contractComplete: billings.filter((b) => b.status === 'billing_complete').length,
};
return {
success: true,
data: stats,
};
} catch {
return {
success: false,
error: '기성청구 통계 조회에 실패했습니다.',
};
}
}
/**
* 기성청구 상세 조회
*/
export async function getProgressBillingDetail(id: string): Promise<{
success: boolean;
data?: ProgressBillingDetail;
error?: string;
}> {
try {
// TODO: 실제 API 호출로 대체
// const response = await apiClient.get(`/progress-billing/${id}`);
// 목업 데이터 반환
await new Promise((resolve) => setTimeout(resolve, 300));
return {
success: true,
data: {
...MOCK_PROGRESS_BILLING_DETAIL,
id,
},
};
} catch (error) {
console.error('Failed to fetch progress billing detail:', error);
return {
success: false,
error: '기성청구 정보를 불러오는데 실패했습니다.',
};
}
}
/**
* 기성청구 저장 (생성/수정)
*/
export async function saveProgressBilling(
id: string | null,
data: ProgressBillingDetailFormData
): Promise<{
success: boolean;
data?: ProgressBillingDetail;
error?: string;
}> {
try {
// TODO: 실제 API 호출로 대체
// const response = id
// ? await apiClient.put(`/progress-billing/${id}`, data)
// : await apiClient.post('/progress-billing', data);
await new Promise((resolve) => setTimeout(resolve, 500));
console.log('Save progress billing:', { id, data });
return {
success: true,
data: {
...MOCK_PROGRESS_BILLING_DETAIL,
id: id || String(Date.now()),
...data,
},
};
} catch (error) {
console.error('Failed to save progress billing:', error);
return {
success: false,
error: '기성청구 저장에 실패했습니다.',
};
}
}
/**
* 기성청구 삭제
*/
export async function deleteProgressBilling(id: string): Promise<{
success: boolean;
error?: string;
}> {
try {
// TODO: 실제 API 호출로 대체
// await apiClient.delete(`/progress-billing/${id}`);
await new Promise((resolve) => setTimeout(resolve, 500));
console.log('Delete progress billing:', id);
return {
success: true,
};
} catch (error) {
console.error('Failed to delete progress billing:', error);
return {
success: false,
error: '기성청구 삭제에 실패했습니다.',
};
}
}
/**
* 기성청구 상태 변경
*/
export async function updateProgressBillingStatus(
id: string,
status: string
): Promise<{
success: boolean;
error?: string;
}> {
try {
// TODO: 실제 API 호출로 대체
// await apiClient.patch(`/progress-billing/${id}/status`, { status });
await new Promise((resolve) => setTimeout(resolve, 300));
console.log('Update progress billing status:', { id, status });
// 기성청구완료 시 매출 자동 등록 로직
if (status === 'completed') {
console.log('Auto-register sales for completed billing:', id);
// TODO: 매출 자동 등록 API 호출
}
return {
success: true,
};
} catch (error) {
console.error('Failed to update progress billing status:', error);
return {
success: false,
error: '상태 변경에 실패했습니다.',
};
}
}

View File

@@ -0,0 +1,57 @@
'use client';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { ProgressBillingDetailFormData } from '../types';
interface ContractInfoCardProps {
formData: ProgressBillingDetailFormData;
}
export function ContractInfoCard({ formData }: ContractInfoCardProps) {
return (
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* 거래처명 */}
<div className="space-y-2">
<Label></Label>
<Input value={formData.partnerName} placeholder="회사명" disabled className="bg-muted" />
</div>
{/* 현장명 */}
<div className="space-y-2">
<Label></Label>
<Input value={formData.siteName} disabled className="bg-muted" />
</div>
{/* 계약번호 */}
<div className="space-y-2">
<Label></Label>
<Input value={formData.contractNumber} disabled className="bg-muted" />
</div>
{/* 공사PM */}
<div className="space-y-2">
<Label>PM</Label>
<Input value={formData.constructionPM} disabled className="bg-muted" />
</div>
{/* 공사담당자 */}
<div className="space-y-2 md:col-span-2">
<Label></Label>
<Input
value={formData.constructionManagers.join(', ')}
disabled
className="bg-muted"
/>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,77 @@
'use client';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { ProgressBillingDetailFormData, ProgressBillingStatus } from '../types';
import { PROGRESS_BILLING_STATUS_OPTIONS } from '../types';
interface ProgressBillingInfoCardProps {
formData: ProgressBillingDetailFormData;
isViewMode: boolean;
onFieldChange: (field: keyof ProgressBillingDetailFormData, value: string | number) => void;
}
export function ProgressBillingInfoCard({
formData,
isViewMode,
onFieldChange,
}: ProgressBillingInfoCardProps) {
return (
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* 기성청구번호 */}
<div className="space-y-2">
<Label></Label>
<Input value={formData.billingNumber} disabled className="bg-muted" />
</div>
{/* 회차 */}
<div className="space-y-2">
<Label></Label>
<Input value={`${formData.billingRound}회차`} disabled className="bg-muted" />
</div>
{/* 기성청구연월 */}
<div className="space-y-2">
<Label></Label>
<Input value={formData.billingYearMonth} disabled className="bg-muted" />
</div>
{/* 상태 */}
<div className="space-y-2">
<Label></Label>
<Select
key={`status-${formData.status}`}
value={formData.status}
onValueChange={(value) => onFieldChange('status', value as ProgressBillingStatus)}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="상태 선택" />
</SelectTrigger>
<SelectContent>
{PROGRESS_BILLING_STATUS_OPTIONS.filter((o) => o.value !== 'all').map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,273 @@
'use client';
import { useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import type {
ProgressBillingDetail,
ProgressBillingDetailFormData,
ProgressBillingItem,
} from '../types';
import {
progressBillingDetailToFormData,
getEmptyProgressBillingDetailFormData,
MOCK_PROGRESS_BILLING_DETAIL,
} from '../types';
interface UseProgressBillingDetailFormProps {
mode: 'view' | 'edit';
billingId: string;
initialData?: ProgressBillingDetail;
}
export function useProgressBillingDetailForm({
mode,
billingId,
initialData,
}: UseProgressBillingDetailFormProps) {
const router = useRouter();
// Mode flags
const isViewMode = mode === 'view';
const isEditMode = mode === 'edit';
// Form data state
const [formData, setFormData] = useState<ProgressBillingDetailFormData>(() => {
if (initialData) {
return progressBillingDetailToFormData(initialData);
}
// 목업 데이터 사용
return progressBillingDetailToFormData(MOCK_PROGRESS_BILLING_DETAIL);
});
// Loading state
const [isLoading, setIsLoading] = useState(false);
// Dialog states
const [showSaveDialog, setShowSaveDialog] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
// Selection states for billing items
const [selectedBillingItems, setSelectedBillingItems] = useState<Set<string>>(new Set());
// Selection states for photo items
const [selectedPhotoItems, setSelectedPhotoItems] = useState<Set<string>>(new Set());
// Modal states
const [showDirectConstructionModal, setShowDirectConstructionModal] = useState(false);
const [showIndirectConstructionModal, setShowIndirectConstructionModal] = useState(false);
const [showPhotoDocumentModal, setShowPhotoDocumentModal] = useState(false);
// Navigation handlers
const handleBack = useCallback(() => {
router.push('/construction/billing/progress-billing-management');
}, [router]);
const handleEdit = useCallback(() => {
router.push('/construction/billing/progress-billing-management/' + billingId + '/edit');
}, [router, billingId]);
const handleCancel = useCallback(() => {
router.push('/construction/billing/progress-billing-management/' + billingId);
}, [router, billingId]);
// Form handlers
const handleFieldChange = useCallback(
(field: keyof ProgressBillingDetailFormData, value: string | number) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
},
[]
);
// Save handlers
const handleSave = useCallback(() => {
setShowSaveDialog(true);
}, []);
const handleConfirmSave = useCallback(async () => {
setIsLoading(true);
try {
// TODO: API 호출
console.log('Save billing data:', formData);
await new Promise((resolve) => setTimeout(resolve, 500));
setShowSaveDialog(false);
router.push('/construction/billing/progress-billing-management/' + billingId);
} catch (error) {
console.error('Save failed:', error);
} finally {
setIsLoading(false);
}
}, [formData, router, billingId]);
// Delete handlers
const handleDelete = useCallback(() => {
setShowDeleteDialog(true);
}, []);
const handleConfirmDelete = useCallback(async () => {
setIsLoading(true);
try {
// TODO: API 호출
console.log('Delete billing:', billingId);
await new Promise((resolve) => setTimeout(resolve, 500));
setShowDeleteDialog(false);
router.push('/construction/billing/progress-billing-management');
} catch (error) {
console.error('Delete failed:', error);
} finally {
setIsLoading(false);
}
}, [router, billingId]);
// Billing item handlers
const handleBillingItemChange = useCallback(
(itemId: string, field: keyof ProgressBillingItem, value: string | number) => {
setFormData((prev) => ({
...prev,
billingItems: prev.billingItems.map((item) =>
item.id === itemId ? { ...item, [field]: value } : item
),
}));
},
[]
);
const handleToggleBillingItemSelection = useCallback((itemId: string) => {
setSelectedBillingItems((prev) => {
const newSet = new Set(prev);
if (newSet.has(itemId)) {
newSet.delete(itemId);
} else {
newSet.add(itemId);
}
return newSet;
});
}, []);
const handleToggleSelectAllBillingItems = useCallback(() => {
setSelectedBillingItems((prev) => {
if (prev.size === formData.billingItems.length) {
return new Set();
}
return new Set(formData.billingItems.map((item) => item.id));
});
}, [formData.billingItems]);
const handleApplySelectedBillingItems = useCallback(() => {
// 선택된 항목의 변경사항 적용 (현재는 선택 해제만)
setSelectedBillingItems(new Set());
}, []);
// Photo item handlers
const handleTogglePhotoItemSelection = useCallback((itemId: string) => {
setSelectedPhotoItems((prev) => {
const newSet = new Set(prev);
if (newSet.has(itemId)) {
newSet.delete(itemId);
} else {
newSet.add(itemId);
}
return newSet;
});
}, []);
const handleToggleSelectAllPhotoItems = useCallback(() => {
setSelectedPhotoItems((prev) => {
if (prev.size === formData.photoItems.length) {
return new Set();
}
return new Set(formData.photoItems.map((item) => item.id));
});
}, [formData.photoItems]);
const handleApplySelectedPhotoItems = useCallback(() => {
// 선택된 항목의 변경사항 적용 (현재는 선택 해제만)
setSelectedPhotoItems(new Set());
}, []);
// Photo select handler (라디오 버튼으로 사진 선택)
const handlePhotoSelect = useCallback((itemId: string, photoIndex: number) => {
setFormData((prev) => ({
...prev,
photoItems: prev.photoItems.map((item) =>
item.id === itemId ? { ...item, selectedPhotoIndex: photoIndex } : item
),
}));
}, []);
// Modal handlers
const handleViewDirectConstruction = useCallback(() => {
setShowDirectConstructionModal(true);
}, []);
const handleViewIndirectConstruction = useCallback(() => {
setShowIndirectConstructionModal(true);
}, []);
const handleViewPhotoDocument = useCallback(() => {
setShowPhotoDocumentModal(true);
}, []);
return {
// Mode flags
isViewMode,
isEditMode,
// Form data
formData,
// Loading state
isLoading,
// Dialog states
showSaveDialog,
setShowSaveDialog,
showDeleteDialog,
setShowDeleteDialog,
// Modal states
showDirectConstructionModal,
setShowDirectConstructionModal,
showIndirectConstructionModal,
setShowIndirectConstructionModal,
showPhotoDocumentModal,
setShowPhotoDocumentModal,
// Selection states
selectedBillingItems,
selectedPhotoItems,
// Navigation handlers
handleBack,
handleEdit,
handleCancel,
// Form handlers
handleFieldChange,
// CRUD handlers
handleSave,
handleConfirmSave,
handleDelete,
handleConfirmDelete,
// Billing item handlers
handleBillingItemChange,
handleToggleBillingItemSelection,
handleToggleSelectAllBillingItems,
handleApplySelectedBillingItems,
// Photo item handlers
handleTogglePhotoItemSelection,
handleToggleSelectAllPhotoItems,
handleApplySelectedPhotoItems,
handlePhotoSelect,
// Modal handlers
handleViewDirectConstruction,
handleViewIndirectConstruction,
handleViewPhotoDocument,
};
}

View File

@@ -0,0 +1,3 @@
export { default as ProgressBillingDetailForm } from './ProgressBillingDetailForm';
export * from './types';
export * from './actions';

View File

@@ -0,0 +1,268 @@
'use client';
import {
Dialog,
DialogContent,
DialogTitle,
VisuallyHidden,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Edit, Trash2, Printer, X } from 'lucide-react';
import { toast } from 'sonner';
import { printArea } from '@/lib/print-utils';
import type { ProgressBillingDetailFormData } from '../types';
// 숫자 포맷팅 (천단위 콤마)
function formatNumber(num: number | undefined): string {
if (num === undefined || num === null) return '-';
return num.toLocaleString('ko-KR');
}
interface DirectConstructionModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
data: ProgressBillingDetailFormData;
}
// 직접 공사 내역 아이템 타입
interface DirectConstructionItem {
id: string;
name: string;
product: string;
width: number;
height: number;
quantity: number;
unit: string;
contractUnitPrice: number;
contractAmount: number;
prevQuantity: number;
prevAmount: number;
currentQuantity: number;
currentAmount: number;
cumulativeQuantity: number;
cumulativeAmount: number;
remark?: string;
}
// 목업 데이터 생성
function generateMockItems(billingItems: ProgressBillingDetailFormData['billingItems']): DirectConstructionItem[] {
return billingItems.map((item, index) => ({
id: item.id,
name: item.name || '명칭',
product: item.product || '제품명',
width: item.width || 2500,
height: item.height || 3200,
quantity: 1,
unit: 'EA',
contractUnitPrice: 2500000,
contractAmount: 2500000,
prevQuantity: index < 4 ? 0 : 0.8,
prevAmount: index < 4 ? 0 : 1900000,
currentQuantity: 0.8,
currentAmount: 1900000,
cumulativeQuantity: 0.8,
cumulativeAmount: 1900000,
remark: '',
}));
}
export function DirectConstructionModal({
open,
onOpenChange,
data,
}: DirectConstructionModalProps) {
// 핸들러
const handleEdit = () => {
toast.info('수정 기능은 준비 중입니다.');
};
const handleDelete = () => {
toast.info('삭제 기능은 준비 중입니다.');
};
const handlePrint = () => {
printArea({ title: '직접 공사 내역서 인쇄' });
};
// 목업 데이터
const items = generateMockItems(data.billingItems);
// 합계 계산
const totalContractAmount = items.reduce((sum, item) => sum + item.contractAmount, 0);
const totalCurrentAmount = items.reduce((sum, item) => sum + item.currentAmount, 0);
const totalCumulativeAmount = items.reduce((sum, item) => sum + item.cumulativeAmount, 0);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] md:max-w-[1000px] lg:max-w-[1200px] h-[95vh] p-0 flex flex-col gap-0 overflow-hidden [&>button]:hidden">
<VisuallyHidden>
<DialogTitle> </DialogTitle>
</VisuallyHidden>
{/* 헤더 영역 */}
<div className="print-hidden flex-shrink-0 flex items-center justify-between px-6 py-4 border-b bg-white">
<h2 className="text-lg font-semibold"> </h2>
<Button
variant="ghost"
size="icon"
onClick={() => onOpenChange(false)}
className="h-8 w-8"
>
<X className="h-4 w-4" />
</Button>
</div>
{/* 버튼 영역 */}
<div className="print-hidden flex-shrink-0 flex items-center gap-2 px-6 py-3 bg-muted/30 border-b">
<Button variant="outline" size="sm" onClick={handleEdit}>
<Edit className="h-4 w-4 mr-1" />
</Button>
<Button variant="outline" size="sm" onClick={handleDelete}>
<Trash2 className="h-4 w-4 mr-1" />
</Button>
<Button variant="outline" size="sm" onClick={handlePrint}>
<Printer className="h-4 w-4 mr-1" />
</Button>
</div>
{/* 문서 영역 */}
<div className="print-area flex-1 overflow-y-auto bg-gray-100 p-6">
<div className="max-w-[297mm] mx-auto bg-white shadow-lg rounded-lg p-8">
{/* 상단: 제목 + 결재란 */}
<div className="flex items-start justify-between mb-6">
{/* 좌측: 제목 및 문서 정보 */}
<div className="flex-1">
<h1 className="text-2xl font-bold mb-2"> </h1>
<div className="text-sm text-gray-600">
: {data.billingNumber || 'ABC123'} | 작성일자: 2025년 11 11
</div>
</div>
{/* 우측: 결재란 */}
<table className="border-collapse border border-gray-400 text-sm">
<tbody>
<tr>
<th rowSpan={3} className="border border-gray-400 px-3 py-1 bg-gray-50 text-center align-middle w-12">
<br />
</th>
<th className="border border-gray-400 px-6 py-1 bg-gray-50 text-center whitespace-nowrap"></th>
<th className="border border-gray-400 px-6 py-1 bg-gray-50 text-center whitespace-nowrap"></th>
</tr>
<tr>
<td className="border border-gray-400 px-6 py-2 text-center h-10 whitespace-nowrap"></td>
<td className="border border-gray-400 px-6 py-2 text-center h-10 whitespace-nowrap"></td>
</tr>
<tr>
<td className="border border-gray-400 px-6 py-1 text-center text-xs text-gray-500 whitespace-nowrap"></td>
<td className="border border-gray-400 px-6 py-1 text-center text-xs text-gray-500 whitespace-nowrap"></td>
</tr>
</tbody>
</table>
</div>
{/* 기성내역 제목 */}
<div className="text-center font-bold text-lg mb-4">
{data.billingRound || 1} ({data.billingYearMonth || '2025년 11월'})
</div>
{/* 현장 정보 */}
<div className="mb-4">
<span className="font-bold"> : {data.siteName || '현장명'}</span>
</div>
{/* 테이블 */}
<div className="overflow-x-auto">
<table className="w-full border-collapse border border-gray-400 text-xs">
<thead>
{/* 1행: 상위 헤더 */}
<tr className="bg-gray-50">
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center w-10">No.</th>
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center min-w-[80px]"></th>
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center min-w-[60px]"></th>
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
<span className="inline-flex items-center gap-1">
mm
</span>
</th>
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center w-12"></th>
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center w-12"></th>
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
<span className="inline-flex items-center gap-1">
</span>
</th>
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
<span className="inline-flex items-center gap-1">
</span>
</th>
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
<span className="inline-flex items-center gap-1">
</span>
</th>
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
<span className="inline-flex items-center gap-1">
</span>
</th>
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center w-16"></th>
</tr>
{/* 2행: 하위 헤더 */}
<tr className="bg-gray-50">
<th className="border border-gray-400 px-2 py-1 text-center w-14"></th>
<th className="border border-gray-400 px-2 py-1 text-center w-14"></th>
<th className="border border-gray-400 px-2 py-1 text-center w-16"></th>
<th className="border border-gray-400 px-2 py-1 text-center w-20"></th>
<th className="border border-gray-400 px-2 py-1 text-center w-12"></th>
<th className="border border-gray-400 px-2 py-1 text-center w-20"></th>
<th className="border border-gray-400 px-2 py-1 text-center w-12"></th>
<th className="border border-gray-400 px-2 py-1 text-center w-20"></th>
<th className="border border-gray-400 px-2 py-1 text-center w-12"></th>
<th className="border border-gray-400 px-2 py-1 text-center w-20"></th>
</tr>
</thead>
<tbody>
{items.map((item, index) => (
<tr key={item.id}>
<td className="border border-gray-400 px-2 py-2 text-center">{index + 1}</td>
<td className="border border-gray-400 px-2 py-2">{item.name}</td>
<td className="border border-gray-400 px-2 py-2">{item.product}</td>
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.width)}</td>
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.height)}</td>
<td className="border border-gray-400 px-2 py-2 text-center">{item.quantity}</td>
<td className="border border-gray-400 px-2 py-2 text-center">{item.unit}</td>
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.contractUnitPrice)}</td>
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.contractAmount)}</td>
<td className="border border-gray-400 px-2 py-2 text-center">{item.prevQuantity || '-'}</td>
<td className="border border-gray-400 px-2 py-2 text-right">{item.prevAmount ? formatNumber(item.prevAmount) : '-'}</td>
<td className="border border-gray-400 px-2 py-2 text-center">{item.currentQuantity}</td>
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.currentAmount)}</td>
<td className="border border-gray-400 px-2 py-2 text-center">{item.cumulativeQuantity}</td>
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.cumulativeAmount)}</td>
<td className="border border-gray-400 px-2 py-2">{item.remark || ''}</td>
</tr>
))}
{/* 합계 행 */}
<tr className="bg-gray-50 font-bold">
<td colSpan={8} className="border border-gray-400 px-2 py-2 text-center"></td>
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(totalContractAmount)}</td>
<td colSpan={2} className="border border-gray-400 px-2 py-2"></td>
<td className="border border-gray-400 px-2 py-2"></td>
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(totalCurrentAmount)}</td>
<td className="border border-gray-400 px-2 py-2"></td>
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(totalCumulativeAmount)}</td>
<td className="border border-gray-400 px-2 py-2"></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,382 @@
'use client';
import {
Dialog,
DialogContent,
DialogTitle,
VisuallyHidden,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Edit, Trash2, Printer, X } from 'lucide-react';
import { toast } from 'sonner';
import { printArea } from '@/lib/print-utils';
import type { ProgressBillingDetailFormData } from '../types';
// 숫자 포맷팅 (천단위 콤마)
function formatNumber(num: number | undefined): string {
if (num === undefined || num === null) return '-';
return num.toLocaleString('ko-KR');
}
interface IndirectConstructionModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
data: ProgressBillingDetailFormData;
}
// 간접 공사 내역 아이템 타입
interface IndirectConstructionItem {
id: string;
name: string;
spec: string;
unit: string;
contractQuantity: number;
contractAmount: number;
prevQuantity: number;
prevAmount: number;
currentQuantity: number;
currentAmount: number;
cumulativeQuantity: number;
cumulativeAmount: number;
remark?: string;
}
// 목업 데이터 생성
function generateMockItems(): IndirectConstructionItem[] {
return [
{
id: '1',
name: '국민연금',
spec: '직접노무비 × 4.50%',
unit: '식',
contractQuantity: 1,
contractAmount: 2500000,
prevQuantity: 0,
prevAmount: 0,
currentQuantity: 0,
currentAmount: 2500000,
cumulativeQuantity: 0,
cumulativeAmount: 2500000,
},
{
id: '2',
name: '건강보험',
spec: '직접노무비 × 3.545%',
unit: '식',
contractQuantity: 1,
contractAmount: 2500000,
prevQuantity: 0,
prevAmount: 0,
currentQuantity: 0,
currentAmount: 2500000,
cumulativeQuantity: 0,
cumulativeAmount: 2500000,
},
{
id: '3',
name: '노인장기요양보험료',
spec: '건강보험료 × 12.81%',
unit: '식',
contractQuantity: 1,
contractAmount: 2500000,
prevQuantity: 0,
prevAmount: 0,
currentQuantity: 0,
currentAmount: 2500000,
cumulativeQuantity: 0,
cumulativeAmount: 2500000,
},
{
id: '4',
name: '고용보험',
spec: '직접공사비 × 30% × 1.57%',
unit: '식',
contractQuantity: 1,
contractAmount: 2500000,
prevQuantity: 0,
prevAmount: 0,
currentQuantity: 0,
currentAmount: 2500000,
cumulativeQuantity: 0,
cumulativeAmount: 2500000,
},
{
id: '5',
name: '일반관리비',
spec: '1) 직접공사비 × 업체요율\n2) 공과물비+작업비 시공비 포함',
unit: '식',
contractQuantity: 1,
contractAmount: 2500000,
prevQuantity: 0,
prevAmount: 0,
currentQuantity: 0,
currentAmount: 2500000,
cumulativeQuantity: 0,
cumulativeAmount: 2500000,
},
{
id: '6',
name: '안전관리비',
spec: '직접공사비 × 0.3%(일반건산)',
unit: '식',
contractQuantity: 1,
contractAmount: 2500000,
prevQuantity: 0,
prevAmount: 0,
currentQuantity: 0,
currentAmount: 2500000,
cumulativeQuantity: 0,
cumulativeAmount: 2500000,
},
{
id: '7',
name: '안전검사자',
spec: '실투입 × 양정실시',
unit: 'M/D',
contractQuantity: 1,
contractAmount: 2500000,
prevQuantity: 0,
prevAmount: 0,
currentQuantity: 0,
currentAmount: 2500000,
cumulativeQuantity: 0,
cumulativeAmount: 2500000,
},
{
id: '8',
name: '신호수 및 위기감시자',
spec: '실투입 × 양정실시',
unit: 'M/D',
contractQuantity: 1,
contractAmount: 2500000,
prevQuantity: 0,
prevAmount: 0,
currentQuantity: 0,
currentAmount: 2500000,
cumulativeQuantity: 0,
cumulativeAmount: 2500000,
},
{
id: '9',
name: '퇴직공제부금',
spec: '직접노무비 × 2.3%',
unit: '식',
contractQuantity: 1,
contractAmount: 2500000,
prevQuantity: 0,
prevAmount: 0,
currentQuantity: 0,
currentAmount: 2500000,
cumulativeQuantity: 0,
cumulativeAmount: 2500000,
},
{
id: '10',
name: '폐기물처리비',
spec: '직접공사비 × 요제요율이상',
unit: '식',
contractQuantity: 1,
contractAmount: 2500000,
prevQuantity: 0,
prevAmount: 0,
currentQuantity: 0,
currentAmount: 2500000,
cumulativeQuantity: 0,
cumulativeAmount: 2500000,
},
{
id: '11',
name: '건설기계대여자금보증료',
spec: '(직접비+간접공사비) × 0.07%',
unit: '식',
contractQuantity: 1,
contractAmount: 2500000,
prevQuantity: 0,
prevAmount: 0,
currentQuantity: 0,
currentAmount: 2500000,
cumulativeQuantity: 0,
cumulativeAmount: 2500000,
},
];
}
export function IndirectConstructionModal({
open,
onOpenChange,
data,
}: IndirectConstructionModalProps) {
// 핸들러
const handleEdit = () => {
toast.info('수정 기능은 준비 중입니다.');
};
const handleDelete = () => {
toast.info('삭제 기능은 준비 중입니다.');
};
const handlePrint = () => {
printArea({ title: '간접 공사 내역서 인쇄' });
};
// 목업 데이터
const items = generateMockItems();
// 합계 계산
const totalContractAmount = items.reduce((sum, item) => sum + item.contractAmount, 0);
const totalCurrentAmount = items.reduce((sum, item) => sum + item.currentAmount, 0);
const totalCumulativeAmount = items.reduce((sum, item) => sum + item.cumulativeAmount, 0);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] md:max-w-[1000px] lg:max-w-[1200px] h-[95vh] p-0 flex flex-col gap-0 overflow-hidden [&>button]:hidden">
<VisuallyHidden>
<DialogTitle> </DialogTitle>
</VisuallyHidden>
{/* 헤더 영역 */}
<div className="print-hidden flex-shrink-0 flex items-center justify-between px-6 py-4 border-b bg-white">
<h2 className="text-lg font-semibold"> </h2>
<Button
variant="ghost"
size="icon"
onClick={() => onOpenChange(false)}
className="h-8 w-8"
>
<X className="h-4 w-4" />
</Button>
</div>
{/* 버튼 영역 */}
<div className="print-hidden flex-shrink-0 flex items-center gap-2 px-6 py-3 bg-muted/30 border-b">
<Button variant="outline" size="sm" onClick={handleEdit}>
<Edit className="h-4 w-4 mr-1" />
</Button>
<Button variant="outline" size="sm" onClick={handleDelete}>
<Trash2 className="h-4 w-4 mr-1" />
</Button>
<Button variant="outline" size="sm" onClick={handlePrint}>
<Printer className="h-4 w-4 mr-1" />
</Button>
</div>
{/* 문서 영역 */}
<div className="print-area flex-1 overflow-y-auto bg-gray-100 p-6">
<div className="max-w-[297mm] mx-auto bg-white shadow-lg rounded-lg p-8">
{/* 상단: 제목 + 결재란 */}
<div className="flex items-start justify-between mb-6">
{/* 좌측: 제목 및 문서 정보 */}
<div className="flex-1">
<h1 className="text-2xl font-bold mb-2"> </h1>
<div className="text-sm text-gray-600">
: {data.billingNumber || 'ABC123'} | 작성일자: 2025년 11 11
</div>
</div>
{/* 우측: 결재란 */}
<table className="border-collapse border border-gray-400 text-sm">
<tbody>
<tr>
<th rowSpan={3} className="border border-gray-400 px-3 py-1 bg-gray-50 text-center align-middle w-12">
<br />
</th>
<th className="border border-gray-400 px-6 py-1 bg-gray-50 text-center whitespace-nowrap"></th>
<th className="border border-gray-400 px-6 py-1 bg-gray-50 text-center whitespace-nowrap"></th>
</tr>
<tr>
<td className="border border-gray-400 px-6 py-2 text-center h-10 whitespace-nowrap"></td>
<td className="border border-gray-400 px-6 py-2 text-center h-10 whitespace-nowrap"></td>
</tr>
<tr>
<td className="border border-gray-400 px-6 py-1 text-center text-xs text-gray-500 whitespace-nowrap"></td>
<td className="border border-gray-400 px-6 py-1 text-center text-xs text-gray-500 whitespace-nowrap"></td>
</tr>
</tbody>
</table>
</div>
{/* 기성내역 제목 */}
<div className="text-center font-bold text-lg mb-4">
{data.billingRound || 1} ({data.billingYearMonth || '2025년 11월'})
</div>
{/* 현장 정보 */}
<div className="mb-4">
<span className="font-bold"> : {data.siteName || '현장명'}</span>
</div>
{/* 테이블 */}
<div className="overflow-x-auto">
<table className="w-full border-collapse border border-gray-400 text-xs">
<thead>
{/* 1행: 상위 헤더 */}
<tr className="bg-gray-50">
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center w-10">No.</th>
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center min-w-[100px]"></th>
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center min-w-[180px]"></th>
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center w-14"></th>
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
</th>
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
</th>
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
</th>
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
</th>
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center w-16"></th>
</tr>
{/* 2행: 하위 헤더 */}
<tr className="bg-gray-50">
<th className="border border-gray-400 px-2 py-1 text-center w-12"></th>
<th className="border border-gray-400 px-2 py-1 text-center w-20"></th>
<th className="border border-gray-400 px-2 py-1 text-center w-12"></th>
<th className="border border-gray-400 px-2 py-1 text-center w-20"></th>
<th className="border border-gray-400 px-2 py-1 text-center w-12"></th>
<th className="border border-gray-400 px-2 py-1 text-center w-20"></th>
<th className="border border-gray-400 px-2 py-1 text-center w-12"></th>
<th className="border border-gray-400 px-2 py-1 text-center w-20"></th>
</tr>
</thead>
<tbody>
{items.map((item, index) => (
<tr key={item.id}>
<td className="border border-gray-400 px-2 py-2 text-center">{index + 1}</td>
<td className="border border-gray-400 px-2 py-2">{item.name}</td>
<td className="border border-gray-400 px-2 py-2 whitespace-pre-line text-xs">{item.spec}</td>
<td className="border border-gray-400 px-2 py-2 text-center">{item.unit}</td>
<td className="border border-gray-400 px-2 py-2 text-center">{item.contractQuantity}</td>
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.contractAmount)}</td>
<td className="border border-gray-400 px-2 py-2 text-center">{item.prevQuantity || '-'}</td>
<td className="border border-gray-400 px-2 py-2 text-right">{item.prevAmount ? formatNumber(item.prevAmount) : '-'}</td>
<td className="border border-gray-400 px-2 py-2 text-center">{item.currentQuantity || '-'}</td>
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.currentAmount)}</td>
<td className="border border-gray-400 px-2 py-2 text-center">{item.cumulativeQuantity || '-'}</td>
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.cumulativeAmount)}</td>
<td className="border border-gray-400 px-2 py-2">{item.remark || ''}</td>
</tr>
))}
{/* 합계 행 */}
<tr className="bg-gray-50 font-bold">
<td colSpan={5} className="border border-gray-400 px-2 py-2 text-center"></td>
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(totalContractAmount)}</td>
<td colSpan={2} className="border border-gray-400 px-2 py-2"></td>
<td colSpan={2} className="border border-gray-400 px-2 py-2 text-right">{formatNumber(totalCurrentAmount)}</td>
<td colSpan={2} className="border border-gray-400 px-2 py-2 text-right">{formatNumber(totalCumulativeAmount)}</td>
<td className="border border-gray-400 px-2 py-2"></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,210 @@
'use client';
import {
Dialog,
DialogContent,
DialogTitle,
VisuallyHidden,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Edit, Trash2, Printer, X } from 'lucide-react';
import { toast } from 'sonner';
import { printArea } from '@/lib/print-utils';
import type { ProgressBillingDetailFormData } from '../types';
interface PhotoDocumentModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
data: ProgressBillingDetailFormData;
}
// 사진대지 아이템 타입
interface PhotoDocumentItem {
id: string;
imageUrl: string;
name: string;
}
// 목업 데이터 생성
function generateMockPhotos(photoItems: ProgressBillingDetailFormData['photoItems']): PhotoDocumentItem[] {
// 기존 photoItems에서 선택된 사진들을 가져오거나 목업 생성
const photos: PhotoDocumentItem[] = [];
photoItems.forEach((item) => {
if (item.photos && item.photos.length > 0) {
const selectedIndex = item.selectedPhotoIndex ?? 0;
photos.push({
id: item.id,
imageUrl: item.photos[selectedIndex] || item.photos[0],
name: item.name,
});
}
});
// 최소 6개 항목 채우기 (2열 × 3행)
while (photos.length < 6) {
photos.push({
id: `mock-${photos.length}`,
imageUrl: '',
name: '명칭',
});
}
return photos;
}
export function PhotoDocumentModal({
open,
onOpenChange,
data,
}: PhotoDocumentModalProps) {
// 핸들러
const handleEdit = () => {
toast.info('수정 기능은 준비 중입니다.');
};
const handleDelete = () => {
toast.info('삭제 기능은 준비 중입니다.');
};
const handlePrint = () => {
printArea({ title: '사진대지 인쇄' });
};
// 목업 데이터
const photos = generateMockPhotos(data.photoItems);
// 2열로 그룹화
const photoRows: PhotoDocumentItem[][] = [];
for (let i = 0; i < photos.length; i += 2) {
photoRows.push(photos.slice(i, i + 2));
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] md:max-w-[900px] lg:max-w-[1000px] h-[95vh] p-0 flex flex-col gap-0 overflow-hidden [&>button]:hidden">
<VisuallyHidden>
<DialogTitle></DialogTitle>
</VisuallyHidden>
{/* 헤더 영역 */}
<div className="print-hidden flex-shrink-0 flex items-center justify-between px-6 py-4 border-b bg-white">
<h2 className="text-lg font-semibold"></h2>
<Button
variant="ghost"
size="icon"
onClick={() => onOpenChange(false)}
className="h-8 w-8"
>
<X className="h-4 w-4" />
</Button>
</div>
{/* 버튼 영역 */}
<div className="print-hidden flex-shrink-0 flex items-center gap-2 px-6 py-3 bg-muted/30 border-b">
<Button variant="outline" size="sm" onClick={handleEdit}>
<Edit className="h-4 w-4 mr-1" />
</Button>
<Button variant="outline" size="sm" onClick={handleDelete}>
<Trash2 className="h-4 w-4 mr-1" />
</Button>
<Button variant="outline" size="sm" onClick={handlePrint}>
<Printer className="h-4 w-4 mr-1" />
</Button>
</div>
{/* 문서 영역 */}
<div className="print-area flex-1 overflow-y-auto bg-gray-100 p-6">
<div className="max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg p-8">
{/* 상단: 제목 + 결재란 */}
<div className="flex items-start justify-between mb-6">
{/* 좌측: 제목 및 문서 정보 */}
<div className="flex-1">
<h1 className="text-2xl font-bold mb-2"></h1>
<div className="text-sm text-gray-600">
: {data.billingNumber || 'ABC123'} | 작성일자: 2025년 11 11
</div>
</div>
{/* 우측: 결재란 */}
<table className="border-collapse border border-gray-400 text-sm">
<tbody>
<tr>
<th rowSpan={3} className="border border-gray-400 px-3 py-1 bg-gray-50 text-center align-middle w-12">
<br />
</th>
<th className="border border-gray-400 px-6 py-1 bg-gray-50 text-center whitespace-nowrap"></th>
<th className="border border-gray-400 px-6 py-1 bg-gray-50 text-center whitespace-nowrap"></th>
</tr>
<tr>
<td className="border border-gray-400 px-6 py-2 text-center h-10 whitespace-nowrap"></td>
<td className="border border-gray-400 px-6 py-2 text-center h-10 whitespace-nowrap"></td>
</tr>
<tr>
<td className="border border-gray-400 px-6 py-1 text-center text-xs text-gray-500 whitespace-nowrap"></td>
<td className="border border-gray-400 px-6 py-1 text-center text-xs text-gray-500 whitespace-nowrap"></td>
</tr>
</tbody>
</table>
</div>
{/* 기성신청 사진대지 제목 */}
<div className="text-center font-bold text-lg mb-4">
{data.billingRound || 1} ({data.billingYearMonth || '2025년 11월'})
</div>
{/* 현장 정보 */}
<div className="mb-6">
<span className="font-bold"> : {data.siteName || '현장명'}</span>
</div>
{/* 사진 그리드 */}
<div className="border border-gray-400">
{photoRows.map((row, rowIndex) => (
<div key={rowIndex} className="grid grid-cols-2">
{row.map((photo, colIndex) => (
<div
key={photo.id}
className={`border border-gray-400 ${colIndex === 0 ? 'border-l-0' : ''} ${rowIndex === 0 ? 'border-t-0' : ''}`}
>
{/* 이미지 영역 */}
<div className="aspect-[4/3] bg-gray-100 flex items-center justify-center overflow-hidden">
{photo.imageUrl ? (
<img
src={photo.imageUrl}
alt={photo.name}
className="w-full h-full object-cover"
/>
) : (
<span className="text-gray-400 text-lg">IMG</span>
)}
</div>
{/* 명칭 라벨 */}
<div className="border-t border-gray-400 px-4 py-3 text-center bg-white">
<span className="text-sm font-medium">{photo.name}</span>
</div>
</div>
))}
{/* 홀수 개일 때 빈 셀 채우기 */}
{row.length === 1 && (
<div className="border border-gray-400 border-t-0">
<div className="aspect-[4/3] bg-gray-100 flex items-center justify-center">
<span className="text-gray-400 text-lg">IMG</span>
</div>
<div className="border-t border-gray-400 px-4 py-3 text-center bg-white">
<span className="text-sm font-medium"></span>
</div>
</div>
)}
</div>
))}
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,147 @@
'use client';
import Image from 'next/image';
import { Check } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import type { PhotoItem } from '../types';
interface PhotoTableProps {
items: PhotoItem[];
isViewMode: boolean;
isEditMode: boolean;
selectedItems: Set<string>;
onToggleSelection: (itemId: string) => void;
onToggleSelectAll: () => void;
onApplySelected: () => void;
onPhotoSelect?: (itemId: string, photoIndex: number) => void;
}
export function PhotoTable({
items,
isViewMode,
isEditMode,
selectedItems,
onToggleSelection,
onToggleSelectAll,
onApplySelected,
onPhotoSelect,
}: PhotoTableProps) {
const allSelected = items.length > 0 && items.every((item) => selectedItems.has(item.id));
const handleApply = () => {
onApplySelected();
toast.success('적용이 완료되었습니다.');
};
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div className="flex items-center gap-2">
<CardTitle className="text-lg"></CardTitle>
<span className="text-sm text-muted-foreground">
{items.length}{selectedItems.size > 0 && ', ' + selectedItems.size + '건 선택'}
</span>
</div>
{isEditMode && selectedItems.size > 0 && (
<Button size="sm" onClick={handleApply}>
<Check className="h-4 w-4 mr-1" />
</Button>
)}
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[40px]">
<Checkbox
checked={allSelected}
onCheckedChange={onToggleSelectAll}
disabled={isViewMode}
/>
</TableHead>
<TableHead className="w-[40px]"></TableHead>
<TableHead className="w-[70px]"></TableHead>
<TableHead className="w-[90px]"></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, index) => (
<TableRow key={item.id} className="h-[280px]">
<TableCell className="align-middle">
<Checkbox
checked={selectedItems.has(item.id)}
onCheckedChange={() => onToggleSelection(item.id)}
disabled={isViewMode}
/>
</TableCell>
<TableCell className="align-middle">{index + 1}</TableCell>
<TableCell className="align-middle">{item.constructionNumber}</TableCell>
<TableCell className="align-middle">{item.name}</TableCell>
<TableCell>
{item.photos && item.photos.length > 0 ? (
<div className="flex gap-8 flex-1">
{item.photos.map((photo, photoIdx) => (
<label
key={photoIdx}
className="flex flex-col items-center gap-3 cursor-pointer flex-1"
>
<div
className={`relative w-full min-w-[280px] h-[200px] border-2 rounded overflow-hidden transition-all bg-muted ${
item.selectedPhotoIndex === photoIdx
? 'border-primary ring-2 ring-primary'
: 'border-border hover:border-primary/50'
}`}
>
<Image
src={photo}
alt={item.name + ' 사진 ' + (photoIdx + 1)}
fill
className="object-contain"
/>
</div>
{isEditMode && (
<input
type="radio"
name={`photo-select-${item.id}`}
checked={item.selectedPhotoIndex === photoIdx}
onChange={() => onPhotoSelect?.(item.id, photoIdx)}
className="w-5 h-5 accent-primary"
/>
)}
</label>
))}
</div>
) : (
<span className="text-muted-foreground text-sm">-</span>
)}
</TableCell>
</TableRow>
))}
{items.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,188 @@
'use client';
import { Check } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import type { ProgressBillingItem } from '../types';
import { MOCK_BILLING_NAMES } from '../types';
interface ProgressBillingItemTableProps {
items: ProgressBillingItem[];
isViewMode: boolean;
isEditMode: boolean;
selectedItems: Set<string>;
onToggleSelection: (itemId: string) => void;
onToggleSelectAll: () => void;
onApplySelected: () => void;
onItemChange: (itemId: string, field: keyof ProgressBillingItem, value: string | number) => void;
}
export function ProgressBillingItemTable({
items,
isViewMode,
isEditMode,
selectedItems,
onToggleSelection,
onToggleSelectAll,
onApplySelected,
onItemChange,
}: ProgressBillingItemTableProps) {
const allSelected = items.length > 0 && items.every((item) => selectedItems.has(item.id));
const handleApply = () => {
onApplySelected();
toast.success('적용이 완료되었습니다.');
};
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div className="flex items-center gap-2">
<CardTitle className="text-lg"> </CardTitle>
<span className="text-sm text-muted-foreground">
{items.length}{selectedItems.size > 0 && `, ${selectedItems.size}건 선택`}
</span>
</div>
{isEditMode && selectedItems.size > 0 && (
<Button size="sm" onClick={handleApply}>
<Check className="h-4 w-4 mr-1" />
</Button>
)}
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px]">
<Checkbox
checked={allSelected}
onCheckedChange={onToggleSelectAll}
disabled={isViewMode}
/>
</TableHead>
<TableHead className="w-[60px]"></TableHead>
<TableHead className="min-w-[120px]"></TableHead>
<TableHead className="min-w-[100px]"></TableHead>
<TableHead className="min-w-[80px]"></TableHead>
<TableHead className="min-w-[60px]"></TableHead>
<TableHead className="min-w-[60px]"></TableHead>
<TableHead className="min-w-[80px]"></TableHead>
<TableHead className="min-w-[100px]"></TableHead>
<TableHead className="min-w-[100px]"></TableHead>
<TableHead className="min-w-[80px]"></TableHead>
<TableHead className="min-w-[100px]"></TableHead>
<TableHead className="min-w-[60px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, index) => (
<TableRow key={item.id}>
<TableCell>
<Checkbox
checked={selectedItems.has(item.id)}
onCheckedChange={() => onToggleSelection(item.id)}
disabled={isViewMode}
/>
</TableCell>
<TableCell>{index + 1}</TableCell>
<TableCell>{item.constructionNumber}</TableCell>
<TableCell>{item.name}</TableCell>
<TableCell>
{isEditMode ? (
<Select
value={item.product}
onValueChange={(value) => onItemChange(item.id, 'product', value)}
>
<SelectTrigger className="min-w-[80px]">
<SelectValue placeholder="제품 선택" />
</SelectTrigger>
<SelectContent>
{MOCK_BILLING_NAMES.map((option) => (
<SelectItem key={option.value} value={option.label}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
item.product
)}
</TableCell>
<TableCell>
{isEditMode ? (
<Input
type="number"
value={item.width}
onChange={(e) => onItemChange(item.id, 'width', Number(e.target.value))}
className="min-w-[50px]"
/>
) : (
item.width.toLocaleString()
)}
</TableCell>
<TableCell>
{isEditMode ? (
<Input
type="number"
value={item.height}
onChange={(e) => onItemChange(item.id, 'height', Number(e.target.value))}
className="min-w-[50px]"
/>
) : (
item.height.toLocaleString()
)}
</TableCell>
<TableCell>{item.workTeamLeader}</TableCell>
<TableCell>{item.constructionStartDate}</TableCell>
<TableCell>{item.constructionEndDate || '-'}</TableCell>
<TableCell>
{isEditMode ? (
<Input
type="number"
step="0.01"
value={item.quantity}
onChange={(e) => onItemChange(item.id, 'quantity', Number(e.target.value))}
className="min-w-[60px]"
/>
) : (
item.quantity.toLocaleString()
)}
</TableCell>
<TableCell>{item.currentBilling.toLocaleString()}</TableCell>
<TableCell>{item.status}</TableCell>
</TableRow>
))}
{items.length === 0 && (
<TableRow>
<TableCell colSpan={13} className="text-center text-muted-foreground py-8">
.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,483 @@
/**
* 기성청구관리 타입 정의
*/
/**
* 기성청구 상태
*/
export type ProgressBillingStatus =
| 'billing_waiting' // 기성청구대기
| 'approval_waiting' // 승인대기
| 'constructor_sent' // 건설사전송
| 'billing_complete'; // 기성청구완료
/**
* 기성청구 상태 옵션
*/
export const PROGRESS_BILLING_STATUS_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: 'billing_waiting', label: '기성청구대기' },
{ value: 'approval_waiting', label: '승인대기' },
{ value: 'constructor_sent', label: '건설사전송' },
{ value: 'billing_complete', label: '기성청구완료' },
] as const;
/**
* 기성청구 상태 라벨
*/
export const PROGRESS_BILLING_STATUS_LABELS: Record<ProgressBillingStatus, string> = {
billing_waiting: '기성청구대기',
approval_waiting: '승인대기',
constructor_sent: '건설사전송',
billing_complete: '기성청구완료',
};
/**
* 기성청구 상태 스타일
*/
export const PROGRESS_BILLING_STATUS_STYLES: Record<ProgressBillingStatus, string> = {
billing_waiting: 'bg-yellow-100 text-yellow-800',
approval_waiting: 'bg-blue-100 text-blue-800',
constructor_sent: 'bg-purple-100 text-purple-800',
billing_complete: 'bg-green-100 text-green-800',
};
/**
* 정렬 옵션
*/
export const PROGRESS_BILLING_SORT_OPTIONS = [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '등록순' },
{ value: 'partnerNameAsc', label: '거래처명 오름차순' },
{ value: 'partnerNameDesc', label: '거래처명 내림차순' },
{ value: 'siteNameAsc', label: '현장명 오름차순' },
{ value: 'siteNameDesc', label: '현장명 내림차순' },
] as const;
/**
* 기성청구 항목 인터페이스
*/
export interface ProgressBilling {
id: string;
billingNumber: string;
partnerId: string;
partnerName: string;
siteId: string;
siteName: string;
round: number;
billingYearMonth: string;
previousBilling: number;
currentBilling: number;
cumulativeBilling: number;
status: ProgressBillingStatus;
createdAt: string;
updatedAt: string;
}
/**
* 기성청구 통계 인터페이스
*/
export interface ProgressBillingStats {
total: number;
contractWaiting: number;
contractComplete: number;
}
/**
* 목업 거래처 목록
*/
export const MOCK_PARTNERS = [
{ value: '1', label: '(주)대한건설' },
{ value: '2', label: '삼성물산' },
{ value: '3', label: '현대건설' },
{ value: '4', label: 'GS건설' },
{ value: '5', label: '대림산업' },
];
/**
* 목업 현장 목록
*/
export const MOCK_SITES = [
{ value: '1', label: '강남 오피스빌딩 신축' },
{ value: '2', label: '판교 데이터센터' },
{ value: '3', label: '송도 물류센터' },
{ value: '4', label: '인천공항 터미널' },
{ value: '5', label: '부산항 창고' },
];
/**
* 거래처별 현장 매핑
*/
export const PARTNER_SITES_MAP: Record<string, typeof MOCK_SITES> = {
'1': [
{ value: '1', label: '강남 오피스빌딩 신축' },
{ value: '2', label: '판교 데이터센터' },
],
'2': [
{ value: '3', label: '송도 물류센터' },
],
'3': [
{ value: '4', label: '인천공항 터미널' },
{ value: '5', label: '부산항 창고' },
],
'4': [
{ value: '1', label: '강남 오피스빌딩 신축' },
{ value: '3', label: '송도 물류센터' },
],
'5': [
{ value: '2', label: '판교 데이터센터' },
{ value: '4', label: '인천공항 터미널' },
],
};
/**
* 필터 옵션 공통 타입
*/
export interface FilterOption {
value: string;
label: string;
}
/**
* 기성청구 내역 아이템 (테이블 로우)
*/
export interface ProgressBillingItem {
id: string;
/** 시공번호 */
constructionNumber: string;
/** 명칭 */
name: string;
/** 제품 */
product: string;
/** 가로 */
width: number;
/** 세로 */
height: number;
/** 작업반장 */
workTeamLeader: string;
/** 시공투입일 */
constructionStartDate: string;
/** 시공완료일 */
constructionEndDate: string;
/** 수량 */
quantity: number;
/** 금회기성 */
currentBilling: number;
/** 상태 */
status: string;
/** 사진 URL 목록 (최대 2장) */
photos: string[];
}
/**
* 사진대지 아이템 (테이블 로우)
*/
export interface PhotoItem {
id: string;
/** 시공번호 */
constructionNumber: string;
/** 명칭 */
name: string;
/** 사진 URL 목록 (2장) */
photos: string[];
/** 선택된 사진 인덱스 */
selectedPhotoIndex?: number;
}
/**
* 기성청구 상세 데이터
*/
export interface ProgressBillingDetail {
/** ID */
id: string;
/** 기성청구번호 */
billingNumber: string;
/** 회차 */
billingRound: number;
/** 기성청구연월 */
billingYearMonth: string;
/** 상태 */
status: ProgressBillingStatus;
/** 거래처 ID */
partnerId: string;
/** 거래처명 */
partnerName: string;
/** 현장명 */
siteName: string;
/** 계약번호 */
contractNumber: string;
/** 계약 ID */
contractId: string;
/** 공사PM */
constructionPM: string;
/** 공사담당자 목록 */
constructionManagers: string[];
/** 기성청구 내역 목록 */
billingItems: ProgressBillingItem[];
/** 사진대지 목록 */
photoItems: PhotoItem[];
/** 생성일 */
createdAt: string;
/** 수정일 */
updatedAt: string;
}
/**
* 기성청구 상세 폼 데이터
*/
export interface ProgressBillingDetailFormData {
/** 기성청구번호 */
billingNumber: string;
/** 회차 */
billingRound: number;
/** 기성청구연월 */
billingYearMonth: string;
/** 상태 */
status: ProgressBillingStatus;
/** 거래처 ID */
partnerId: string;
/** 거래처명 */
partnerName: string;
/** 현장명 */
siteName: string;
/** 계약번호 */
contractNumber: string;
/** 계약 ID */
contractId: string;
/** 공사PM */
constructionPM: string;
/** 공사담당자 목록 */
constructionManagers: string[];
/** 기성청구 내역 목록 */
billingItems: ProgressBillingItem[];
/** 사진대지 목록 */
photoItems: PhotoItem[];
}
/**
* 목업 명칭 목록
*/
export const MOCK_BILLING_NAMES: FilterOption[] = [
{ value: '1', label: '제품명 ▼' },
{ value: '2', label: '강봉A' },
{ value: '3', label: '강봉B' },
{ value: '4', label: '철근A' },
{ value: '5', label: '철근B' },
];
/**
* 빈 기성청구 내역 아이템 생성
*/
export function getEmptyProgressBillingItem(): ProgressBillingItem {
return {
id: String(Date.now()),
constructionNumber: '',
name: '',
product: '',
width: 0,
height: 0,
workTeamLeader: '',
constructionStartDate: '',
constructionEndDate: '',
quantity: 0,
currentBilling: 0,
status: '',
photos: [],
};
}
/**
* 빈 사진대지 아이템 생성
*/
export function getEmptyPhotoItem(): PhotoItem {
return {
id: String(Date.now()),
constructionNumber: '',
name: '',
photos: [],
selectedPhotoIndex: undefined,
};
}
/**
* 빈 기성청구 상세 폼 데이터 생성
*/
export function getEmptyProgressBillingDetailFormData(): ProgressBillingDetailFormData {
return {
billingNumber: '',
billingRound: 1,
billingYearMonth: '',
status: 'billing_waiting',
partnerId: '',
partnerName: '',
siteName: '',
contractNumber: '',
contractId: '',
constructionPM: '',
constructionManagers: [],
billingItems: [],
photoItems: [],
};
}
/**
* ProgressBillingDetail을 폼 데이터로 변환
*/
export function progressBillingDetailToFormData(
detail: ProgressBillingDetail
): ProgressBillingDetailFormData {
return {
billingNumber: detail.billingNumber,
billingRound: detail.billingRound,
billingYearMonth: detail.billingYearMonth,
status: detail.status,
partnerId: detail.partnerId,
partnerName: detail.partnerName,
siteName: detail.siteName,
contractNumber: detail.contractNumber,
contractId: detail.contractId,
constructionPM: detail.constructionPM,
constructionManagers: detail.constructionManagers,
billingItems: detail.billingItems,
photoItems: detail.photoItems,
};
}
/**
* 목업 기성청구 상세 데이터
*/
export const MOCK_PROGRESS_BILLING_DETAIL: ProgressBillingDetail = {
id: '1',
billingNumber: '123123',
billingRound: 1,
billingYearMonth: '2025년 10월',
status: 'billing_waiting',
partnerId: '1',
partnerName: '현장명',
siteName: '현장명',
contractNumber: '123123',
contractId: '1',
constructionPM: '이름',
constructionManagers: ['이름', '이름', '이름'],
billingItems: [
{
id: '1',
constructionNumber: '123123',
name: 'FSS801(주차장)',
product: '제품명 ▼',
width: 2500,
height: 3200,
workTeamLeader: '홍길동',
constructionStartDate: '2025-12-15',
constructionEndDate: '2025-12-15',
quantity: 100,
currentBilling: 1000000,
status: '시공완료',
photos: [],
},
{
id: '2',
constructionNumber: '123123',
name: 'FSS801(주차장)',
product: '제품명 ▼',
width: 2500,
height: 3200,
workTeamLeader: '홍길동',
constructionStartDate: '2025-12-15',
constructionEndDate: '2025-12-15',
quantity: 100,
currentBilling: 1000000,
status: '시공진행',
photos: [],
},
{
id: '3',
constructionNumber: '123123',
name: 'FSS801(주차장)',
product: '제품명 ▼',
width: 2500,
height: 3200,
workTeamLeader: '홍길동',
constructionStartDate: '2025-12-15',
constructionEndDate: '2025-12-15',
quantity: 100,
currentBilling: 1000000,
status: '시공완료',
photos: [],
},
{
id: '4',
constructionNumber: '123123',
name: 'FSS801(주차장)',
product: '제품명 ▼',
width: 2500,
height: 3200,
workTeamLeader: '홍길동',
constructionStartDate: '2025-12-15',
constructionEndDate: '2025-12-15',
quantity: 100,
currentBilling: 1000000,
status: '시공진행',
photos: [],
},
{
id: '5',
constructionNumber: '123123',
name: 'FSS801(주차장)',
product: '제품명 ▼',
width: 2500,
height: 3200,
workTeamLeader: '홍길동',
constructionStartDate: '2025-12-15',
constructionEndDate: '',
quantity: 100,
currentBilling: 1000000,
status: '시공완료',
photos: [],
},
{
id: '6',
constructionNumber: '123123',
name: 'FSS801(주차장)',
product: '제품명 ▼',
width: 2500,
height: 3200,
workTeamLeader: '홍길동',
constructionStartDate: '2025-12-15',
constructionEndDate: '2025-12-15',
quantity: 100,
currentBilling: 1000000,
status: '시공완료',
photos: [],
},
{
id: '7',
constructionNumber: '123123',
name: 'FSS801(주차장)',
product: '제품명 ▼',
width: 2500,
height: 3200,
workTeamLeader: '홍길동',
constructionStartDate: '2025-12-15',
constructionEndDate: '2025-12-15',
quantity: 100,
currentBilling: 1000000,
status: '시공진행',
photos: [],
},
],
photoItems: [
{
id: '1',
constructionNumber: '123123',
name: 'FSS801(주차장)',
photos: [
'https://placehold.co/200x150/e2e8f0/64748b?text=IMG',
'https://placehold.co/200x150/e2e8f0/64748b?text=IMG',
],
selectedPhotoIndex: 0,
},
],
createdAt: '2025-01-10T09:00:00Z',
updatedAt: '2025-01-10T09:00:00Z',
};