Files
sam-react-prod/src/components/business/construction/estimates/EstimateDetailForm.tsx
유병철 0db6302652 refactor(WEB): 코드 품질 개선 및 불필요 코드 제거
- 미사용 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>
2026-02-10 20:55:11 +09:00

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