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,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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
317
src/components/business/construction/progress-billing/actions.ts
Normal file
317
src/components/business/construction/progress-billing/actions.ts
Normal 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: '상태 변경에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as ProgressBillingDetailForm } from './ProgressBillingDetailForm';
|
||||
export * from './types';
|
||||
export * from './actions';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
483
src/components/business/construction/progress-billing/types.ts
Normal file
483
src/components/business/construction/progress-billing/types.ts
Normal 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',
|
||||
};
|
||||
Reference in New Issue
Block a user