- 미사용 import/변수/console.log 대량 정리 (100+개 파일) - ItemMasterContext 간소화 (미사용 로직 제거) - IntegratedListTemplateV2 / UniversalListPage 개선 - 결재 컴포넌트(ApprovalBox, DraftBox, ReferenceBox) 정리 - HR 컴포넌트(급여/휴가/부서) 코드 간소화 - globals.css 스타일 정리 및 개선 - AuthenticatedLayout 개선 - middleware CSP 정리 - proxy route 불필요 로깅 제거 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
761 lines
25 KiB
TypeScript
761 lines
25 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useCallback, useMemo, useEffect } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { Loader2 } from 'lucide-react';
|
|
import { getExpenseItemOptions, updateEstimate, type ExpenseItemOption } from './actions';
|
|
import { createBiddingFromEstimate } from '../bidding/actions';
|
|
import { useAuth } from '@/contexts/AuthContext';
|
|
import { Button } from '@/components/ui/button';
|
|
import { ConfirmDialog, DeleteConfirmDialog, SaveConfirmDialog } from '@/components/ui/confirm-dialog';
|
|
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
|
import { estimateConfig } from './estimateConfig';
|
|
import { toast } from 'sonner';
|
|
import type {
|
|
EstimateDetail,
|
|
EstimateDetailFormData,
|
|
EstimateSummaryItem,
|
|
ExpenseItem,
|
|
EstimateDetailItem,
|
|
BidDocument,
|
|
PriceAdjustmentData,
|
|
} from './types';
|
|
import { getEmptyEstimateDetailFormData, estimateDetailToFormData } from './types';
|
|
import { ElectronicApprovalModal } from './modals/ElectronicApprovalModal';
|
|
import { EstimateDocumentModal } from './modals/EstimateDocumentModal';
|
|
// MOCK_MATERIALS 제거됨 - API 데이터 사용
|
|
import {
|
|
EstimateInfoSection,
|
|
EstimateSummarySection,
|
|
ExpenseDetailSection,
|
|
PriceAdjustmentSection,
|
|
EstimateDetailTableSection,
|
|
} from './sections';
|
|
|
|
interface EstimateDetailFormProps {
|
|
mode: 'view' | 'edit';
|
|
estimateId: string;
|
|
initialData?: EstimateDetail;
|
|
}
|
|
|
|
export default function EstimateDetailForm({
|
|
mode,
|
|
estimateId,
|
|
initialData,
|
|
}: EstimateDetailFormProps) {
|
|
const router = useRouter();
|
|
const { currentUser } = useAuth();
|
|
const isViewMode = mode === 'view';
|
|
const isEditMode = mode === 'edit';
|
|
|
|
// 폼 데이터
|
|
const [formData, setFormData] = useState<EstimateDetailFormData>(
|
|
initialData ? estimateDetailToFormData(initialData) : getEmptyEstimateDetailFormData()
|
|
);
|
|
|
|
// 로딩 상태
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
// 다이얼로그 상태
|
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
|
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
|
const [showBiddingDialog, setShowBiddingDialog] = useState(false);
|
|
|
|
// 모달 상태
|
|
const [showApprovalModal, setShowApprovalModal] = useState(false);
|
|
const [showDocumentModal, setShowDocumentModal] = useState(false);
|
|
|
|
// 공과 품목 옵션 (Items API에서 조회)
|
|
const [expenseOptions, setExpenseOptions] = useState<ExpenseItemOption[]>([]);
|
|
|
|
// 공과 품목 옵션 조회
|
|
useEffect(() => {
|
|
async function fetchExpenseOptions() {
|
|
const result = await getExpenseItemOptions();
|
|
if (result.success && result.data) {
|
|
setExpenseOptions(result.data);
|
|
}
|
|
}
|
|
fetchExpenseOptions();
|
|
}, []);
|
|
|
|
// 적용된 조정단가 (전체 적용 버튼 클릭 시 복사됨)
|
|
const [appliedPrices, setAppliedPrices] = useState<{
|
|
caulking: number;
|
|
rail: number;
|
|
bottom: number;
|
|
boxReinforce: number;
|
|
shaft: number;
|
|
painting: number;
|
|
motor: number;
|
|
controller: number;
|
|
} | null>(null);
|
|
|
|
// ===== 네비게이션 핸들러 =====
|
|
const handleBack = useCallback(() => {
|
|
router.push('/ko/construction/project/bidding/estimates');
|
|
}, [router]);
|
|
|
|
const handleEdit = useCallback(() => {
|
|
router.push(`/ko/construction/project/bidding/estimates/${estimateId}?mode=edit`);
|
|
}, [router, estimateId]);
|
|
|
|
// ===== 저장/삭제 핸들러 =====
|
|
const handleSave = useCallback(() => {
|
|
setShowSaveDialog(true);
|
|
}, []);
|
|
|
|
const handleConfirmSave = useCallback(async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
// 🔍 디버깅: 저장 전 formData 확인 (브라우저 콘솔)
|
|
|
|
// 현재 사용자 이름을 견적자로 설정하고 저장 (상태는 사용자 선택값 유지)
|
|
const result = await updateEstimate(estimateId, {
|
|
...formData,
|
|
estimatorName: currentUser!.name,
|
|
// status는 formData에 포함되어 있으므로 사용자가 선택한 값 그대로 전송
|
|
});
|
|
|
|
if (result.success) {
|
|
toast.success('수정이 완료되었습니다.');
|
|
setShowSaveDialog(false);
|
|
router.push(`/ko/construction/project/bidding/estimates/${estimateId}?mode=view`);
|
|
router.refresh();
|
|
} else {
|
|
toast.error(result.error || '저장에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : '저장에 실패했습니다.');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [router, estimateId, formData, currentUser]);
|
|
|
|
const handleDelete = useCallback(() => {
|
|
setShowDeleteDialog(true);
|
|
}, []);
|
|
|
|
const handleConfirmDelete = useCallback(async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
toast.success('견적이 삭제되었습니다.');
|
|
setShowDeleteDialog(false);
|
|
router.push('/ko/construction/project/bidding/estimates');
|
|
router.refresh();
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : '삭제에 실패했습니다.');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [router]);
|
|
|
|
// ===== 입찰 등록 핸들러 =====
|
|
const handleRegisterBidding = useCallback(() => {
|
|
setShowBiddingDialog(true);
|
|
}, []);
|
|
|
|
const handleConfirmBidding = useCallback(async () => {
|
|
if (!initialData) {
|
|
toast.error('견적 데이터가 없습니다.');
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
try {
|
|
const result = await createBiddingFromEstimate({
|
|
id: estimateId,
|
|
partnerId: initialData.partnerId,
|
|
partnerName: initialData.partnerName,
|
|
projectName: initialData.projectName,
|
|
estimateAmount: formData.estimateAmount,
|
|
itemCount: initialData.itemCount,
|
|
bidDate: initialData.bidDate,
|
|
bidInfo: {
|
|
projectName: formData.bidInfo.projectName,
|
|
bidDate: formData.bidInfo.bidDate,
|
|
siteCount: formData.bidInfo.siteCount,
|
|
constructionStartDate: formData.bidInfo.constructionStartDate,
|
|
constructionEndDate: formData.bidInfo.constructionEndDate,
|
|
vatType: formData.bidInfo.vatType,
|
|
},
|
|
expenseItems: formData.expenseItems,
|
|
detailItems: formData.detailItems as unknown as import('../bidding/types').EstimateDetailItem[],
|
|
});
|
|
|
|
if (result.success && result.data) {
|
|
toast.success('입찰이 등록되었습니다.');
|
|
setShowBiddingDialog(false);
|
|
// 입찰 상세 페이지로 이동
|
|
router.push(`/ko/construction/project/bidding/${result.data.id}?mode=view`);
|
|
} else {
|
|
toast.error(result.error || '입찰 등록에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : '입찰 등록에 실패했습니다.');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [initialData, estimateId, formData, router]);
|
|
|
|
// ===== 입찰 정보 핸들러 =====
|
|
const handleBidInfoChange = useCallback((field: string, value: string | number) => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
bidInfo: { ...prev.bidInfo, [field]: value },
|
|
}));
|
|
}, []);
|
|
|
|
// ===== 견적 요약 정보 핸들러 =====
|
|
const handleAddSummaryItem = useCallback(() => {
|
|
const newItem: EstimateSummaryItem = {
|
|
id: String(Date.now()),
|
|
name: '',
|
|
quantity: 1,
|
|
unit: '식',
|
|
materialCost: 0,
|
|
laborCost: 0,
|
|
totalCost: 0,
|
|
remarks: '',
|
|
};
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
summaryItems: [...prev.summaryItems, newItem],
|
|
}));
|
|
}, []);
|
|
|
|
const handleRemoveSummaryItem = useCallback((itemId: string) => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
summaryItems: prev.summaryItems.filter((item) => item.id !== itemId),
|
|
}));
|
|
}, []);
|
|
|
|
const handleSummaryItemChange = useCallback(
|
|
(itemId: string, field: keyof EstimateSummaryItem, value: string | number) => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
summaryItems: prev.summaryItems.map((item) => {
|
|
if (item.id === itemId) {
|
|
const updated = { ...item, [field]: value };
|
|
if (field === 'materialCost' || field === 'laborCost') {
|
|
updated.totalCost = updated.materialCost + updated.laborCost;
|
|
}
|
|
return updated;
|
|
}
|
|
return item;
|
|
}),
|
|
}));
|
|
},
|
|
[]
|
|
);
|
|
|
|
const handleSummaryMemoChange = useCallback((memo: string) => {
|
|
setFormData((prev) => ({ ...prev, summaryMemo: memo }));
|
|
}, []);
|
|
|
|
// ===== 공과 상세 핸들러 =====
|
|
const handleAddExpenseItems = useCallback((count: number) => {
|
|
const newItems = Array.from({ length: count }, () => ({
|
|
id: String(Date.now() + Math.random()),
|
|
name: expenseOptions[0]?.value || '',
|
|
amount: 100000,
|
|
selected: false,
|
|
}));
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
expenseItems: [...prev.expenseItems, ...newItems],
|
|
}));
|
|
}, [expenseOptions]);
|
|
|
|
const handleRemoveSelectedExpenseItems = useCallback(() => {
|
|
const selectedIds = formData.expenseItems
|
|
.filter((item) => item.selected)
|
|
.map((item) => item.id);
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
expenseItems: prev.expenseItems.filter((item) => !selectedIds.includes(item.id)),
|
|
}));
|
|
}, [formData.expenseItems]);
|
|
|
|
const handleExpenseItemChange = useCallback(
|
|
(itemId: string, field: keyof ExpenseItem, value: string | number) => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
expenseItems: prev.expenseItems.map((item) =>
|
|
item.id === itemId ? { ...item, [field]: value } : item
|
|
),
|
|
}));
|
|
},
|
|
[]
|
|
);
|
|
|
|
const handleExpenseSelectItem = useCallback((id: string, selected: boolean) => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
expenseItems: prev.expenseItems.map((item) =>
|
|
item.id === id ? { ...item, selected } : item
|
|
),
|
|
}));
|
|
}, []);
|
|
|
|
const handleExpenseSelectAll = useCallback((selected: boolean) => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
expenseItems: prev.expenseItems.map((item) => ({ ...item, selected })),
|
|
}));
|
|
}, []);
|
|
|
|
// ===== 품목 단가 조정 핸들러 =====
|
|
const handlePriceAdjustmentChange = useCallback(
|
|
(key: keyof PriceAdjustmentData, value: number) => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
priceAdjustmentData: {
|
|
...prev.priceAdjustmentData,
|
|
[key]: {
|
|
...prev.priceAdjustmentData[key],
|
|
adjustedPrice: value,
|
|
},
|
|
},
|
|
}));
|
|
},
|
|
[]
|
|
);
|
|
|
|
const handlePriceAdjustmentSave = useCallback(() => {
|
|
toast.success('단가가 저장되었습니다.');
|
|
}, []);
|
|
|
|
const handlePriceAdjustmentApplyAll = useCallback(() => {
|
|
const adjPrices = formData.priceAdjustmentData;
|
|
setAppliedPrices({
|
|
caulking: adjPrices.caulking.adjustedPrice,
|
|
rail: adjPrices.rail.adjustedPrice,
|
|
bottom: adjPrices.bottom.adjustedPrice,
|
|
boxReinforce: adjPrices.boxReinforce.adjustedPrice,
|
|
shaft: adjPrices.shaft.adjustedPrice,
|
|
painting: adjPrices.painting.adjustedPrice,
|
|
motor: adjPrices.motor.adjustedPrice,
|
|
controller: adjPrices.controller.adjustedPrice,
|
|
});
|
|
toast.success('조정단가가 견적 상세에 적용되었습니다.');
|
|
}, [formData.priceAdjustmentData]);
|
|
|
|
const handlePriceAdjustmentReset = useCallback(() => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
priceAdjustmentData: {
|
|
caulking: { ...prev.priceAdjustmentData.caulking, adjustedPrice: prev.priceAdjustmentData.caulking.sellingPrice },
|
|
rail: { ...prev.priceAdjustmentData.rail, adjustedPrice: prev.priceAdjustmentData.rail.sellingPrice },
|
|
bottom: { ...prev.priceAdjustmentData.bottom, adjustedPrice: prev.priceAdjustmentData.bottom.sellingPrice },
|
|
boxReinforce: { ...prev.priceAdjustmentData.boxReinforce, adjustedPrice: prev.priceAdjustmentData.boxReinforce.sellingPrice },
|
|
shaft: { ...prev.priceAdjustmentData.shaft, adjustedPrice: prev.priceAdjustmentData.shaft.sellingPrice },
|
|
painting: { ...prev.priceAdjustmentData.painting, adjustedPrice: prev.priceAdjustmentData.painting.sellingPrice },
|
|
motor: { ...prev.priceAdjustmentData.motor, adjustedPrice: prev.priceAdjustmentData.motor.sellingPrice },
|
|
controller: { ...prev.priceAdjustmentData.controller, adjustedPrice: prev.priceAdjustmentData.controller.sellingPrice },
|
|
},
|
|
}));
|
|
toast.success('조정단가가 판매단가로 초기화되었습니다.');
|
|
}, []);
|
|
|
|
// ===== 견적 상세 테이블 핸들러 =====
|
|
const handleAddDetailItems = useCallback((count: number) => {
|
|
const currentLength = formData.detailItems.length;
|
|
const newItems: EstimateDetailItem[] = Array.from({ length: count }, (_, i) => ({
|
|
id: String(Date.now() + Math.random() + i),
|
|
no: currentLength + i + 1,
|
|
name: '',
|
|
material: 'screen', // 기본값: 스크린 (API 옵션 첫번째 값)
|
|
width: 0,
|
|
height: 0,
|
|
quantity: 1,
|
|
box: 0,
|
|
assembly: 0,
|
|
coating: 0,
|
|
batting: 0,
|
|
mounting: 0,
|
|
fitting: 0,
|
|
controller: 0,
|
|
widthConstruction: 0,
|
|
heightConstruction: 0,
|
|
materialCost: 0,
|
|
laborCost: 0,
|
|
quantityPrice: 0,
|
|
expenseQuantity: 0,
|
|
expenseTotal: 0,
|
|
totalCost: 0,
|
|
otherCost: 0,
|
|
marginCost: 0,
|
|
totalPrice: 0,
|
|
unitPrice: 0,
|
|
expense: 0,
|
|
marginRate: 1.03,
|
|
unitQuantity: 0,
|
|
expenseResult: 0,
|
|
marginActual: 0,
|
|
}));
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
detailItems: [...prev.detailItems, ...newItems],
|
|
}));
|
|
}, [formData.detailItems.length]);
|
|
|
|
const handleRemoveDetailItem = useCallback((itemId: string) => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
detailItems: prev.detailItems.filter((item) => item.id !== itemId),
|
|
}));
|
|
}, []);
|
|
|
|
const handleRemoveSelectedDetailItems = useCallback(() => {
|
|
const selectedIds = formData.detailItems
|
|
.filter((item) => (item as unknown as { selected?: boolean }).selected)
|
|
.map((item) => item.id);
|
|
if (selectedIds.length === 0) {
|
|
toast.error('삭제할 항목을 선택해주세요.');
|
|
return;
|
|
}
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
detailItems: prev.detailItems.filter((item) => !selectedIds.includes(item.id)),
|
|
}));
|
|
toast.success(`${selectedIds.length}건이 삭제되었습니다.`);
|
|
}, [formData.detailItems]);
|
|
|
|
const handleDetailItemChange = useCallback(
|
|
(itemId: string, field: keyof EstimateDetailItem, value: string | number) => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
detailItems: prev.detailItems.map((item) =>
|
|
item.id === itemId ? { ...item, [field]: value } : item
|
|
),
|
|
}));
|
|
},
|
|
[]
|
|
);
|
|
|
|
const handleDetailSelectItem = useCallback((id: string, selected: boolean) => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
detailItems: prev.detailItems.map((item) =>
|
|
item.id === id ? { ...item, selected } as EstimateDetailItem : item
|
|
),
|
|
}));
|
|
}, []);
|
|
|
|
const handleDetailSelectAll = useCallback((selected: boolean) => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
detailItems: prev.detailItems.map((item) => ({ ...item, selected } as EstimateDetailItem)),
|
|
}));
|
|
}, []);
|
|
|
|
const handleApplyAdjustedPriceToSelected = useCallback(() => {
|
|
const selectedItems = formData.detailItems.filter(
|
|
(item) => (item as unknown as { selected?: boolean }).selected
|
|
);
|
|
if (selectedItems.length === 0) {
|
|
toast.error('적용할 항목을 선택해주세요.');
|
|
return;
|
|
}
|
|
const adjustedPrices = formData.priceAdjustmentData;
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
detailItems: prev.detailItems.map((item) => {
|
|
if ((item as unknown as { selected?: boolean }).selected) {
|
|
return {
|
|
...item,
|
|
adjustedCaulking: adjustedPrices.caulking.adjustedPrice,
|
|
adjustedRail: adjustedPrices.rail.adjustedPrice,
|
|
adjustedBottom: adjustedPrices.bottom.adjustedPrice,
|
|
adjustedBoxReinforce: adjustedPrices.boxReinforce.adjustedPrice,
|
|
adjustedShaft: adjustedPrices.shaft.adjustedPrice,
|
|
adjustedPainting: adjustedPrices.painting.adjustedPrice,
|
|
adjustedMotor: adjustedPrices.motor.adjustedPrice,
|
|
adjustedController: adjustedPrices.controller.adjustedPrice,
|
|
};
|
|
}
|
|
return item;
|
|
}),
|
|
}));
|
|
toast.success(`${selectedItems.length}건에 조정 단가가 적용되었습니다.`);
|
|
}, [formData.detailItems, formData.priceAdjustmentData]);
|
|
|
|
// 견적 상세 초기화: 각 항목의 사용자 수정값(calcXxx)을 초기화하여 자동 계산값으로 복원
|
|
const handleDetailReset = useCallback(() => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
detailItems: prev.detailItems.map((item) => ({
|
|
...item,
|
|
selected: false,
|
|
// 계산 필드 초기화 (undefined로 설정하면 자동 계산값 사용)
|
|
calcWeight: undefined,
|
|
calcArea: undefined,
|
|
calcSteelScreen: undefined,
|
|
calcCaulking: undefined,
|
|
calcRail: undefined,
|
|
calcBottom: undefined,
|
|
calcBoxReinforce: undefined,
|
|
calcShaft: undefined,
|
|
calcUnitPrice: undefined,
|
|
calcExpense: undefined,
|
|
} as EstimateDetailItem)),
|
|
}));
|
|
toast.success('견적 상세가 초기화되었습니다.');
|
|
}, []);
|
|
|
|
// ===== 파일 업로드 핸들러 =====
|
|
const handleFilesSelect = useCallback((files: File[]) => {
|
|
files.forEach((file) => {
|
|
const doc: BidDocument = {
|
|
id: String(Date.now() + Math.random()),
|
|
fileName: file.name,
|
|
fileUrl: URL.createObjectURL(file),
|
|
fileSize: file.size,
|
|
};
|
|
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
bidInfo: {
|
|
...prev.bidInfo,
|
|
documents: [...prev.bidInfo.documents, doc],
|
|
},
|
|
}));
|
|
});
|
|
}, []);
|
|
|
|
const handleDocumentRemove = useCallback((docId: string | number) => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
bidInfo: {
|
|
...prev.bidInfo,
|
|
documents: prev.bidInfo.documents.filter((d) => d.id !== String(docId)),
|
|
},
|
|
}));
|
|
}, []);
|
|
|
|
// ===== 헤더 버튼 =====
|
|
const renderHeaderActions = useCallback(() => {
|
|
if (isViewMode) {
|
|
return (
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" onClick={() => setShowDocumentModal(true)}>
|
|
견적서 보기
|
|
</Button>
|
|
<Button variant="outline" onClick={() => setShowApprovalModal(true)}>
|
|
전자결재
|
|
</Button>
|
|
<Button variant="outline" onClick={handleRegisterBidding} className="text-green-600 border-green-200 hover:bg-green-50">
|
|
입찰 등록
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
className="text-red-500 border-red-200 hover:bg-red-50"
|
|
onClick={handleDelete}
|
|
>
|
|
삭제
|
|
</Button>
|
|
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600" disabled={isLoading}>
|
|
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
|
저장
|
|
</Button>
|
|
</div>
|
|
);
|
|
}, [isViewMode, isLoading, handleDelete, handleSave, handleRegisterBidding]);
|
|
|
|
// ===== 컨텐츠 렌더링 =====
|
|
const renderContent = useCallback(() => {
|
|
return (
|
|
<div className="space-y-8">
|
|
{/* 견적 정보 + 현장설명회 + 입찰 정보 */}
|
|
<EstimateInfoSection
|
|
formData={formData}
|
|
isViewMode={isViewMode}
|
|
onFormDataChange={(updates) => setFormData((prev) => ({ ...prev, ...updates }))}
|
|
onBidInfoChange={handleBidInfoChange}
|
|
onFilesSelect={handleFilesSelect}
|
|
onDocumentRemove={handleDocumentRemove}
|
|
/>
|
|
|
|
{/* 견적 요약 정보 */}
|
|
<EstimateSummarySection
|
|
summaryItems={formData.summaryItems}
|
|
summaryMemo={formData.summaryMemo}
|
|
isViewMode={isViewMode}
|
|
onAddItem={handleAddSummaryItem}
|
|
onRemoveItem={handleRemoveSummaryItem}
|
|
onItemChange={handleSummaryItemChange}
|
|
onMemoChange={handleSummaryMemoChange}
|
|
/>
|
|
|
|
{/* 공과 상세 */}
|
|
<ExpenseDetailSection
|
|
expenseItems={formData.expenseItems}
|
|
expenseOptions={expenseOptions.map((opt) => ({ value: opt.value, label: opt.label }))}
|
|
isViewMode={isViewMode}
|
|
onAddItems={handleAddExpenseItems}
|
|
onRemoveSelected={handleRemoveSelectedExpenseItems}
|
|
onItemChange={handleExpenseItemChange}
|
|
onSelectItem={handleExpenseSelectItem}
|
|
onSelectAll={handleExpenseSelectAll}
|
|
/>
|
|
|
|
{/* 품목 단가 조정 */}
|
|
<PriceAdjustmentSection
|
|
priceAdjustmentData={formData.priceAdjustmentData}
|
|
isViewMode={isViewMode}
|
|
onPriceChange={handlePriceAdjustmentChange}
|
|
onSave={handlePriceAdjustmentSave}
|
|
onApplyAll={handlePriceAdjustmentApplyAll}
|
|
onReset={handlePriceAdjustmentReset}
|
|
/>
|
|
|
|
{/* 견적 상세 테이블 */}
|
|
<EstimateDetailTableSection
|
|
detailItems={formData.detailItems}
|
|
appliedPrices={appliedPrices}
|
|
isViewMode={isViewMode}
|
|
onAddItems={handleAddDetailItems}
|
|
onRemoveItem={handleRemoveDetailItem}
|
|
onRemoveSelected={handleRemoveSelectedDetailItems}
|
|
onItemChange={handleDetailItemChange}
|
|
onSelectItem={handleDetailSelectItem}
|
|
onSelectAll={handleDetailSelectAll}
|
|
onApplyAdjustedPrice={handleApplyAdjustedPriceToSelected}
|
|
onReset={handleDetailReset}
|
|
/>
|
|
</div>
|
|
);
|
|
}, [
|
|
formData,
|
|
isViewMode,
|
|
expenseOptions,
|
|
appliedPrices,
|
|
handleBidInfoChange,
|
|
handleFilesSelect,
|
|
handleDocumentRemove,
|
|
handleAddSummaryItem,
|
|
handleRemoveSummaryItem,
|
|
handleSummaryItemChange,
|
|
handleSummaryMemoChange,
|
|
handleAddExpenseItems,
|
|
handleRemoveSelectedExpenseItems,
|
|
handleExpenseItemChange,
|
|
handleExpenseSelectItem,
|
|
handleExpenseSelectAll,
|
|
handlePriceAdjustmentChange,
|
|
handlePriceAdjustmentSave,
|
|
handlePriceAdjustmentApplyAll,
|
|
handlePriceAdjustmentReset,
|
|
handleAddDetailItems,
|
|
handleRemoveDetailItem,
|
|
handleRemoveSelectedDetailItems,
|
|
handleDetailItemChange,
|
|
handleDetailSelectItem,
|
|
handleDetailSelectAll,
|
|
handleApplyAdjustedPriceToSelected,
|
|
handleDetailReset,
|
|
]);
|
|
|
|
// Edit 모드용 config (타이틀 변경)
|
|
// Note: IntegratedDetailTemplate이 모드에 따라 '상세'/'수정' 자동 추가
|
|
const currentConfig = useMemo(() => {
|
|
if (isEditMode) {
|
|
return {
|
|
...estimateConfig,
|
|
title: '견적',
|
|
description: '견적 정보를 수정합니다',
|
|
};
|
|
}
|
|
return estimateConfig;
|
|
}, [isEditMode]);
|
|
|
|
return (
|
|
<>
|
|
<IntegratedDetailTemplate
|
|
config={currentConfig}
|
|
mode={mode}
|
|
initialData={formData as unknown as Record<string, unknown>}
|
|
itemId={estimateId}
|
|
isLoading={false}
|
|
onBack={handleBack}
|
|
onEdit={handleEdit}
|
|
renderView={renderContent}
|
|
renderForm={renderContent}
|
|
headerActions={renderHeaderActions()}
|
|
/>
|
|
|
|
{/* 전자결재 모달 */}
|
|
<ElectronicApprovalModal
|
|
isOpen={showApprovalModal}
|
|
onClose={() => setShowApprovalModal(false)}
|
|
approval={formData.approval}
|
|
onSave={(approval) => {
|
|
setFormData((prev) => ({ ...prev, approval }));
|
|
setShowApprovalModal(false);
|
|
toast.success('결재선이 저장되었습니다.');
|
|
}}
|
|
/>
|
|
|
|
{/* 견적서 모달 */}
|
|
<EstimateDocumentModal
|
|
isOpen={showDocumentModal}
|
|
onClose={() => setShowDocumentModal(false)}
|
|
formData={formData}
|
|
estimateId={estimateId}
|
|
/>
|
|
|
|
{/* 삭제 확인 다이얼로그 */}
|
|
<DeleteConfirmDialog
|
|
open={showDeleteDialog}
|
|
onOpenChange={setShowDeleteDialog}
|
|
onConfirm={handleConfirmDelete}
|
|
title="견적 삭제"
|
|
description={
|
|
<>
|
|
이 견적을 삭제하시겠습니까?
|
|
<br />
|
|
삭제된 견적은 복구할 수 없습니다.
|
|
</>
|
|
}
|
|
loading={isLoading}
|
|
/>
|
|
|
|
{/* 저장 확인 다이얼로그 */}
|
|
<SaveConfirmDialog
|
|
open={showSaveDialog}
|
|
onOpenChange={setShowSaveDialog}
|
|
onConfirm={handleConfirmSave}
|
|
title="수정 확인"
|
|
description="견적 정보를 수정하시겠습니까?"
|
|
loading={isLoading}
|
|
/>
|
|
|
|
{/* 입찰 등록 확인 다이얼로그 */}
|
|
<ConfirmDialog
|
|
open={showBiddingDialog}
|
|
onOpenChange={setShowBiddingDialog}
|
|
onConfirm={handleConfirmBidding}
|
|
title="입찰 등록"
|
|
description={
|
|
<>
|
|
이 견적을 입찰로 등록하시겠습니까?
|
|
<br />
|
|
견적 정보가 입찰 관리로 전환됩니다.
|
|
</>
|
|
}
|
|
variant="success"
|
|
confirmText="등록"
|
|
loading={isLoading}
|
|
/>
|
|
</>
|
|
);
|
|
}
|