Files
sam-react-prod/src/components/quotes/QuoteRegistration.tsx
유병철 a38996b751 refactor(WEB): V2 파일 통합, store 구조 정리 및 대시보드 개선
- V2 컴포넌트를 원본에 통합 후 V2 파일 삭제 (InspectionModal, BillDetail, ContractDocumentModal, LaborDetailClient, PricingDetailClient, QuoteRegistration)
- store → stores 디렉토리 이동 및 favoritesStore 추가
- dashboard_type3~5 추가 및 기존 대시보드 차트/훅 분리
- Sidebar 리팩토링 및 HeaderFavoritesBar 추가
- DashboardSwitcher 컴포넌트 추가
- 백업 파일(.v1-backup) 및 불필요 코드 정리
- InspectionPreviewModal 레이아웃 개선

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 15:09:51 +09:00

1023 lines
38 KiB
TypeScript

/**
* 견적 등록/수정 컴포넌트 V2
*
* 새로운 레이아웃:
* - 좌우 분할: 발주 개소 목록 | 선택 개소 상세
* - 하단: 견적 금액 요약 (개소별 + 상세별)
* - 푸터: 총 금액 + 버튼들 (견적서 산출, 임시저장, 최종저장)
*/
"use client";
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
import { FileText, Calculator, Download, Save, Check } from "lucide-react";
import { toast } from "sonner";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
import { Button } from "../ui/button";
import { Badge } from "../ui/badge";
import { Input } from "../ui/input";
import { DatePicker } from "../ui/date-picker";
import { Textarea } from "../ui/textarea";
import { PhoneInput } from "../ui/phone-input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
// FormField는 실제로 사용되지 않으므로 제거
import { LocationListPanel } from "./LocationListPanel";
import { LocationDetailPanel } from "./LocationDetailPanel";
import { QuoteSummaryPanel } from "./QuoteSummaryPanel";
import { QuoteFooterBar } from "./QuoteFooterBar";
import { QuotePreviewModal } from "./QuotePreviewModal";
import { QuoteTransactionModal } from "./QuoteTransactionModal";
import { DiscountModal } from "./DiscountModal";
import { FormulaViewModal } from "./FormulaViewModal";
import {
getFinishedGoods,
calculateBomBulk,
getQuoteReferenceData,
type FinishedGoods,
type BomBulkResponse,
} from "./actions";
import type { BomCalculationResult } from "./types";
import { getClients } from "../accounting/VendorManagement/actions";
import { isNextRedirectError } from "@/lib/utils/redirect-error";
// 실제 로그인 사용자 정보는 localStorage('user')에 저장됨 (LoginPage.tsx 참조)
import { useDevFill } from "@/components/dev/useDevFill";
import type { Vendor } from "../accounting/VendorManagement";
import type { BomMaterial, CalculationResults, BomCalculationResultItem } from "./types";
import { getLocalDateString, getDateAfterDays } from "@/utils/date";
// =============================================================================
// 타입 정의
// =============================================================================
// 발주 개소 항목
export interface LocationItem {
id: string;
floor: string; // 층
code: string; // 부호
openWidth: number; // 가로 (오픈사이즈 W)
openHeight: number; // 세로 (오픈사이즈 H)
productCode: string; // 제품코드
productName: string; // 제품명
quantity: number; // 수량
guideRailType: string; // 가이드레일 설치 유형
motorPower: string; // 모터 전원
controller: string; // 연동제어기
wingSize: number; // 마구리 날개치수
inspectionFee: number; // 검사비
// 계산 결과
manufactureWidth?: number; // 제작사이즈 W
manufactureHeight?: number; // 제작사이즈 H
weight?: number; // 산출중량 (kg)
area?: number; // 산출면적 (m²)
unitPrice?: number; // 단가
totalPrice?: number; // 합계
bomResult?: BomCalculationResult; // BOM 계산 결과
}
// 견적 폼 데이터 V2
export interface QuoteFormDataV2 {
id?: string;
quoteNumber: string; // 견적번호
registrationDate: string; // 접수일
writer: string; // 작성자
clientId: string; // 수주처 ID
clientName: string; // 수주처명
siteName: string; // 현장명
manager: string; // 담당자
contact: string; // 연락처
vatType: "included" | "excluded"; // 부가세 (포함/별도)
remarks: string; // 비고
status: "draft" | "temporary" | "final" | "converted"; // 작성중, 임시저장, 최종저장, 수주전환
discountRate: number; // 할인율 (%)
discountAmount: number; // 할인 금액
locations: LocationItem[];
orderId?: number | null; // 연결된 수주 ID (수주전환 시 설정)
}
// =============================================================================
// 상수
// =============================================================================
// 초기 개소 항목
const createNewLocation = (): LocationItem => ({
id: `loc-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
floor: "",
code: "",
openWidth: 0,
openHeight: 0,
productCode: "",
productName: "",
quantity: 1,
guideRailType: "wall",
motorPower: "single",
controller: "basic",
wingSize: 50,
inspectionFee: 50000,
});
// 초기 폼 데이터
const INITIAL_FORM_DATA: QuoteFormDataV2 = {
quoteNumber: "", // 자동생성 또는 서버에서 부여
registrationDate: getLocalDateString(new Date()),
writer: "", // useAuth()에서 currentUser.name으로 설정됨
clientId: "",
clientName: "",
siteName: "",
manager: "",
contact: "",
vatType: "included", // 기본값: 부가세 포함
remarks: "",
status: "draft",
discountRate: 0,
discountAmount: 0,
locations: [],
};
// =============================================================================
// Props
// =============================================================================
interface QuoteRegistrationProps {
mode: "create" | "view" | "edit";
onBack: () => void;
onSave?: (data: QuoteFormDataV2, saveType: "temporary" | "final") => Promise<void>;
onCalculate?: () => void;
onEdit?: () => void;
onOrderRegister?: () => void;
/** 수주 보기 (이미 수주가 있는 경우) */
onOrderView?: () => void;
initialData?: QuoteFormDataV2 | null;
isLoading?: boolean;
/** IntegratedDetailTemplate 사용 시 타이틀 영역 숨김 */
hideHeader?: boolean;
}
// =============================================================================
// 메인 컴포넌트
// =============================================================================
export function QuoteRegistration({
mode,
onBack,
onSave,
onCalculate,
onEdit,
onOrderRegister,
onOrderView,
initialData,
isLoading = false,
hideHeader = false,
}: QuoteRegistrationProps) {
// ---------------------------------------------------------------------------
// 상태
// ---------------------------------------------------------------------------
const [formData, setFormData] = useState<QuoteFormDataV2>(
initialData || INITIAL_FORM_DATA
);
const [selectedLocationId, setSelectedLocationId] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [isCalculating, setIsCalculating] = useState(false);
const [quotePreviewOpen, setQuotePreviewOpen] = useState(false);
const [transactionPreviewOpen, setTransactionPreviewOpen] = useState(false);
const [discountModalOpen, setDiscountModalOpen] = useState(false);
const [formulaViewOpen, setFormulaViewOpen] = useState(false);
// 할인율/할인금액은 formData에서 관리 (저장/로드 연동)
const discountRate = formData.discountRate ?? 0;
const discountAmount = formData.discountAmount ?? 0;
const pendingAutoCalculateRef = useRef(false);
// API 데이터
const [clients, setClients] = useState<Vendor[]>([]);
const [finishedGoods, setFinishedGoods] = useState<FinishedGoods[]>([]);
const [siteNames, setSiteNames] = useState<string[]>([]);
const [locationCodes, setLocationCodes] = useState<string[]>([]);
const [isLoadingClients, setIsLoadingClients] = useState(false);
const [isLoadingProducts, setIsLoadingProducts] = useState(false);
// handleCalculate 참조 (DevFill에서 사용)
const calculateRef = useRef<(() => Promise<void>) | null>(null);
// 디버그용: formData를 window에 노출
useEffect(() => {
if (typeof window !== "undefined") {
(window as unknown as { __QUOTE_DEBUG__: { formData: QuoteFormDataV2; selectedLocationId: string | null } }).__QUOTE_DEBUG__ = {
formData,
selectedLocationId,
};
}
}, [formData, selectedLocationId]);
// ---------------------------------------------------------------------------
// DevFill (개발/테스트용 자동 채우기)
// ---------------------------------------------------------------------------
useDevFill("quoteV2", useCallback(() => {
// BOM이 있는 제품만 필터링
const productsWithBom = finishedGoods.filter((fg) => fg.has_bom === true || (fg.bom && Array.isArray(fg.bom) && fg.bom.length > 0));
// 랜덤 개소 생성 함수
const createRandomLocation = (index: number): LocationItem => {
const floors = ["B2", "B1", "1F", "2F", "3F", "4F", "5F", "R"];
const codePrefix = ["SD", "FSS", "FD", "SS", "DS"];
const guideRailTypes = ["wall", "floor"];
const motorPowers = ["single", "three"];
const controllers = ["basic", "smart", "premium"];
const randomFloor = floors[Math.floor(Math.random() * floors.length)];
const randomPrefix = codePrefix[Math.floor(Math.random() * codePrefix.length)];
const randomWidth = (Math.floor(Math.random() * 40) + 20) * 100; // 2000~6000 (100단위)
const randomHeight = (Math.floor(Math.random() * 30) + 20) * 100; // 2000~5000 (100단위)
// BOM이 있는 제품 중에서 랜덤 선택 (없으면 전체에서 선택)
const productPool = productsWithBom.length > 0 ? productsWithBom : finishedGoods;
const randomProduct = productPool[Math.floor(Math.random() * productPool.length)];
return {
id: `loc-${Date.now()}-${index}`,
floor: randomFloor,
code: `${randomPrefix}-${String(index + 1).padStart(2, "0")}`,
openWidth: randomWidth,
openHeight: randomHeight,
productCode: randomProduct?.item_code || "FG-SCR-001",
productName: randomProduct?.item_name || "방화 스크린 셔터 (소형)",
quantity: Math.floor(Math.random() * 3) + 1, // 1~3
guideRailType: guideRailTypes[Math.floor(Math.random() * guideRailTypes.length)],
motorPower: motorPowers[Math.floor(Math.random() * motorPowers.length)],
controller: controllers[Math.floor(Math.random() * controllers.length)],
wingSize: [50, 60, 70][Math.floor(Math.random() * 3)],
inspectionFee: [50000, 60000, 70000][Math.floor(Math.random() * 3)],
};
};
// 1~5개 랜덤 개소 생성
const locationCount = Math.floor(Math.random() * 5) + 1;
const testLocations: LocationItem[] = [];
for (let i = 0; i < locationCount; i++) {
testLocations.push(createRandomLocation(i));
}
// 로그인 사용자 정보 가져오기
let writerName = "";
try {
const userStr = localStorage.getItem("user");
if (userStr) {
const user = JSON.parse(userStr);
writerName = user?.name || "";
}
} catch (e) {
console.error("[DevFill] 사용자 정보 로드 실패:", e);
}
const testData: QuoteFormDataV2 = {
quoteNumber: "",
registrationDate: getLocalDateString(new Date()),
writer: writerName,
clientId: clients[0]?.id?.toString() || "",
clientName: clients[0]?.vendorName || "테스트 거래처",
siteName: "테스트 현장",
manager: "홍길동",
contact: "010-1234-5678",
vatType: "included",
remarks: "[DevFill] 테스트 견적입니다.",
status: "draft",
discountRate: 0,
discountAmount: 0,
locations: testLocations,
};
setFormData(testData);
setSelectedLocationId(testLocations[0].id);
toast.success(`[DevFill] 테스트 데이터가 채워졌습니다. (${locationCount}개 개소)`);
// 자동 견적 산출 트리거
pendingAutoCalculateRef.current = true;
}, [clients, finishedGoods]));
// ---------------------------------------------------------------------------
// 계산된 값
// ---------------------------------------------------------------------------
// 선택된 개소
const selectedLocation = useMemo(() => {
return formData.locations.find((loc) => loc.id === selectedLocationId) || null;
}, [formData.locations, selectedLocationId]);
// 총 금액 (할인 전)
const totalAmount = useMemo(() => {
return formData.locations.reduce((sum, loc) => sum + (loc.totalPrice || 0), 0);
}, [formData.locations]);
// 할인 적용 후 총 금액
const discountedTotalAmount = useMemo(() => {
return totalAmount - discountAmount;
}, [totalAmount, discountAmount]);
// 할인 적용 핸들러
const handleApplyDiscount = useCallback((rate: number, amount: number) => {
setFormData(prev => ({ ...prev, discountRate: rate, discountAmount: amount }));
toast.success(`할인이 적용되었습니다. (${rate.toFixed(1)}%, ${amount.toLocaleString()}원)`);
}, []);
// 개소별 합계
const locationTotals = useMemo(() => {
return formData.locations.map((loc) => ({
id: loc.id,
label: `${loc.floor} / ${loc.code}`,
productCode: loc.productCode,
quantity: loc.quantity,
unitPrice: loc.unitPrice || 0,
totalPrice: loc.totalPrice || 0,
}));
}, [formData.locations]);
// BOM 결과 유무
const hasBomResult = useMemo(() => {
return formData.locations.some((loc) => loc.bomResult);
}, [formData.locations]);
// ---------------------------------------------------------------------------
// 작성자 자동 설정 (create 모드에서 로그인 사용자 정보 로드)
// ---------------------------------------------------------------------------
useEffect(() => {
if (mode === "create" && !formData.writer) {
// 실제 로그인 사용자 정보는 localStorage('user')에 저장됨
try {
const userStr = localStorage.getItem("user");
if (userStr) {
const user = JSON.parse(userStr);
if (user?.name) {
setFormData((prev) => ({ ...prev, writer: user.name }));
}
}
} catch (e) {
console.error("[QuoteRegistration] 사용자 정보 로드 실패:", e);
}
}
}, [mode, formData.writer]);
// ---------------------------------------------------------------------------
// 초기 데이터 로드
// ---------------------------------------------------------------------------
useEffect(() => {
const loadInitialData = async () => {
// 거래처 로드
setIsLoadingClients(true);
try {
const result = await getClients();
if (result.success) {
setClients(result.data);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error("거래처 로드 실패:", error);
} finally {
setIsLoadingClients(false);
}
// 완제품 로드
setIsLoadingProducts(true);
try {
const result = await getFinishedGoods();
if (result.success) {
setFinishedGoods(result.data);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error("완제품 로드 실패:", error);
} finally {
setIsLoadingProducts(false);
}
// 참조 데이터 로드 (현장명, 부호)
try {
const result = await getQuoteReferenceData();
if (result.success) {
setSiteNames(result.data.siteNames);
setLocationCodes(result.data.locationCodes);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
}
};
loadInitialData();
}, []);
// initialData 변경 시 formData 업데이트
useEffect(() => {
if (initialData) {
setFormData(initialData);
// 첫 번째 개소 자동 선택
if (initialData.locations.length > 0 && !selectedLocationId) {
setSelectedLocationId(initialData.locations[0].id);
}
}
}, [initialData]);
// ---------------------------------------------------------------------------
// 핸들러
// ---------------------------------------------------------------------------
// 기본 정보 변경
const handleFieldChange = useCallback((field: keyof QuoteFormDataV2, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
}, []);
// 발주처 선택
const handleClientChange = useCallback((clientId: string) => {
const client = clients.find((c) => c.id === clientId);
setFormData((prev) => ({
...prev,
clientId,
clientName: client?.vendorName || "",
}));
}, [clients]);
// 개소 추가 (BOM 계산 성공 시에만 추가, 성공/실패 반환)
const handleAddLocation = useCallback(async (location: Omit<LocationItem, "id">): Promise<boolean> => {
const newLocation: LocationItem = {
...location,
id: `loc-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
};
// BOM 계산 필수 조건 체크
if (!newLocation.productCode || newLocation.openWidth <= 0 || newLocation.openHeight <= 0) {
toast.error("제품, 가로, 세로를 모두 입력해주세요.");
return false;
}
// 먼저 BOM 계산 API 호출
try {
const bomItem = {
finished_goods_code: newLocation.productCode,
openWidth: newLocation.openWidth,
openHeight: newLocation.openHeight,
quantity: newLocation.quantity,
guideRailType: newLocation.guideRailType,
motorPower: newLocation.motorPower,
controller: newLocation.controller,
wingSize: newLocation.wingSize,
inspectionFee: newLocation.inspectionFee,
};
const result = await calculateBomBulk([bomItem]);
if (result.success && result.data) {
const apiData = result.data as BomBulkResponse;
const bomResponseItems = apiData.items || [];
const bomResult = bomResponseItems[0]?.result;
if (bomResult) {
// BOM 계산 성공 시에만 개소 추가
const locationWithBom: LocationItem = {
...newLocation,
unitPrice: bomResult.grand_total,
totalPrice: bomResult.grand_total * newLocation.quantity,
bomResult: bomResult,
};
setFormData((prev) => ({
...prev,
locations: [...prev.locations, locationWithBom],
}));
setSelectedLocationId(newLocation.id);
toast.success("개소가 추가되고 BOM이 계산되었습니다.");
return true;
}
}
// API 에러 메시지 표시 (개소 추가 안 함)
toast.error(result.error || "BOM 계산 실패 - 개소가 추가되지 않았습니다.");
return false;
} catch (error) {
console.error("[handleAddLocation] BOM 계산 실패:", error);
toast.error("BOM 계산 중 오류가 발생했습니다.");
return false;
}
}, []);
// 개소 삭제
// 개소 복제 (부호 자동 채번)
const handleCloneLocation = useCallback((locationId: string) => {
const source = formData.locations.find((loc) => loc.id === locationId);
if (!source) return;
// 부호에서 접두어와 번호 분리 (예: "DS-01" → prefix="DS-", num=1)
const codeMatch = source.code.match(/^(.*?)(\d+)$/);
let newCode = source.code + "-copy";
if (codeMatch) {
const prefix = codeMatch[1]; // "DS-"
const numLength = codeMatch[2].length; // 2 (자릿수 보존)
// 같은 접두어를 가진 부호 중 최대 번호 찾기
let maxNum = 0;
formData.locations.forEach((loc) => {
const m = loc.code.match(new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\d+)$`));
if (m) {
maxNum = Math.max(maxNum, parseInt(m[1], 10));
}
});
newCode = prefix + String(maxNum + 1).padStart(numLength, "0");
}
const clonedLocation: LocationItem = {
...source,
id: `loc-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
code: newCode,
};
setFormData((prev) => ({
...prev,
locations: [...prev.locations, clonedLocation],
}));
setSelectedLocationId(clonedLocation.id);
toast.success(`개소가 복제되었습니다. (${newCode})`);
}, [formData.locations]);
// 개소 삭제
const handleDeleteLocation = useCallback((locationId: string) => {
setFormData((prev) => ({
...prev,
locations: prev.locations.filter((loc) => loc.id !== locationId),
}));
if (selectedLocationId === locationId) {
setSelectedLocationId(formData.locations[0]?.id || null);
}
toast.success("개소가 삭제되었습니다.");
}, [selectedLocationId, formData.locations]);
// 개소 수정
const handleUpdateLocation = useCallback((locationId: string, updates: Partial<LocationItem>) => {
setFormData((prev) => ({
...prev,
locations: prev.locations.map((loc) =>
loc.id === locationId ? { ...loc, ...updates } : loc
),
}));
}, []);
// 엑셀 업로드
const handleExcelUpload = useCallback((locations: Omit<LocationItem, "id">[]) => {
const newLocations: LocationItem[] = locations.map((loc) => ({
...loc,
id: `loc-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
}));
setFormData((prev) => ({
...prev,
locations: [...prev.locations, ...newLocations],
}));
if (newLocations.length > 0) {
setSelectedLocationId(newLocations[0].id);
}
toast.success(`${newLocations.length}개 개소가 추가되었습니다.`);
}, []);
// 견적 산출
const handleCalculate = useCallback(async () => {
if (formData.locations.length === 0) {
toast.error("산출할 개소가 없습니다.");
return;
}
setIsCalculating(true);
try {
const bomItems = formData.locations.map((loc) => ({
finished_goods_code: loc.productCode,
openWidth: loc.openWidth,
openHeight: loc.openHeight,
quantity: loc.quantity,
guideRailType: loc.guideRailType,
motorPower: loc.motorPower,
controller: loc.controller,
wingSize: loc.wingSize,
inspectionFee: loc.inspectionFee,
}));
const result = await calculateBomBulk(bomItems);
if (result.success && result.data) {
// API 응답: { success, summary: { grand_total, ... }, items: [{ index, result: BomCalculationResult }] }
const apiData = result.data as BomBulkResponse;
const bomResponseItems = apiData.items || [];
// 결과 반영 (수동 추가 품목 보존)
const updatedLocations = formData.locations.map((loc, index) => {
const bomItem = bomResponseItems.find((item) => item.index === index);
const bomResult = bomItem?.result;
if (bomResult) {
// 기존 수동 추가 품목 추출 (is_manual: true)
const manualItems = (loc.bomResult?.items || []).filter(
(item: BomCalculationResultItem & { is_manual?: boolean }) => item.is_manual === true
);
// 수동 추가 품목의 총 금액
const manualTotal = manualItems.reduce(
(sum: number, item: BomCalculationResultItem) => sum + (item.total_price || 0),
0
);
// 새 BOM 결과에 수동 품목 병합
const mergedItems = [...(bomResult.items || []), ...manualItems];
const mergedGrandTotal = bomResult.grand_total + manualTotal;
return {
...loc,
unitPrice: mergedGrandTotal,
totalPrice: mergedGrandTotal * loc.quantity,
bomResult: {
...bomResult,
items: mergedItems,
grand_total: mergedGrandTotal,
},
};
}
return loc;
});
setFormData((prev) => ({ ...prev, locations: updatedLocations }));
toast.success(`${formData.locations.length}개 개소의 견적이 산출되었습니다.`);
} else {
toast.error(`견적 산출 실패: ${result.error}`);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
toast.error("견적 산출 중 오류가 발생했습니다.");
} finally {
setIsCalculating(false);
}
}, [formData.locations]);
// handleCalculate 참조 업데이트
useEffect(() => {
calculateRef.current = handleCalculate;
}, [handleCalculate]);
// DevFill 후 자동 견적 산출
useEffect(() => {
if (pendingAutoCalculateRef.current && formData.locations.length > 0) {
pendingAutoCalculateRef.current = false;
// 상태 업데이트 완료 후 산출 실행
setTimeout(() => {
handleCalculate();
}, 50);
}
}, [formData.locations, handleCalculate]);
// 저장 (임시/최종)
const handleSave = useCallback(async (saveType: "temporary" | "final") => {
if (!onSave) return;
// 확정 시 필수 필드 밸리데이션
if (saveType === "final") {
const missing: string[] = [];
if (!formData.clientName?.trim()) missing.push("업체명");
if (!formData.siteName?.trim()) missing.push("현장명");
if (!formData.manager?.trim()) missing.push("담당자");
if (!formData.contact?.trim()) missing.push("연락처");
if (missing.length > 0) {
toast.error(`견적확정을 위해 다음 항목을 입력해주세요: ${missing.join(", ")}`);
return;
}
}
setIsSaving(true);
try {
const dataToSave: QuoteFormDataV2 = {
...formData,
status: saveType === "temporary" ? "temporary" : "final",
};
await onSave(dataToSave, saveType);
// 확정 성공 시 상태 즉시 반영 → 견적확정 버튼 → 수주등록 버튼으로 전환
if (saveType === "final") {
setFormData(prev => ({ ...prev, status: "final" }));
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
const message = error instanceof Error ? error.message : "저장 중 오류가 발생했습니다.";
toast.error(message);
} finally {
setIsSaving(false);
}
}, [formData, onSave]);
// ---------------------------------------------------------------------------
// 렌더링
// ---------------------------------------------------------------------------
const isViewMode = mode === "view";
const pageTitle = mode === "create" ? "견적 등록 (V2 테스트)" : mode === "edit" ? "견적 수정 (V2 테스트)" : "견적 상세 (V2 테스트)";
return (
<div className="flex flex-col h-full">
{/* 기본 정보 섹션 */}
<div className={hideHeader ? "space-y-6" : "p-4 md:p-6 space-y-6"}>
{/* 타이틀 영역 - hideHeader 시 IntegratedDetailTemplate이 담당 */}
{!hideHeader && (
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold flex items-center gap-2">
<FileText className="h-6 w-6" />
{pageTitle}
</h1>
<Badge variant={formData.status === "final" ? "default" : formData.status === "temporary" ? "secondary" : "outline"}>
{formData.status === "final" ? "견적 확정" : formData.status === "temporary" ? "저장됨" : "작성중"}
</Badge>
</div>
)}
{/* 기본 정보 */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<FileText className="h-5 w-5" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 1행: 견적번호 | 접수일 | 수주처 | 현장명 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label className="text-sm font-medium text-gray-700"></label>
<Input
value={formData.quoteNumber || "-"}
disabled
className="bg-gray-50"
/>
</div>
<div>
<label className="text-sm font-medium text-gray-700"></label>
<DatePicker
value={formData.registrationDate}
onChange={(date) => handleFieldChange("registrationDate", date)}
disabled={isViewMode}
/>
</div>
<div>
<label className="text-sm font-medium text-gray-700"> <span className="text-red-500">*</span></label>
<Select
value={formData.clientId}
onValueChange={handleClientChange}
disabled={isViewMode || isLoadingClients}
>
<SelectTrigger>
<SelectValue placeholder={isLoadingClients ? "로딩 중..." : "수주처를 선택하세요"} />
</SelectTrigger>
<SelectContent>
{clients.map((client) => (
<SelectItem key={client.id} value={client.id}>
{client.vendorName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-sm font-medium text-gray-700"></label>
<Input
list="siteNameList"
placeholder="현장명을 입력하세요"
value={formData.siteName}
onChange={(e) => handleFieldChange("siteName", e.target.value)}
disabled={isViewMode}
/>
<datalist id="siteNameList">
{siteNames.map((name) => (
<option key={name} value={name} />
))}
</datalist>
</div>
</div>
{/* 2행: 담당자 | 연락처 | 작성자 | 부가세 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label className="text-sm font-medium text-gray-700"></label>
<Input
placeholder="담당자명을 입력하세요"
value={formData.manager}
onChange={(e) => handleFieldChange("manager", e.target.value)}
disabled={isViewMode}
/>
</div>
<div>
<label className="text-sm font-medium text-gray-700"></label>
<PhoneInput
placeholder="010-1234-5678"
value={formData.contact}
onChange={(value) => handleFieldChange("contact", value)}
disabled={isViewMode}
/>
</div>
<div>
<label className="text-sm font-medium text-gray-700"></label>
<Input
value={formData.writer}
disabled
className="bg-gray-50"
/>
</div>
<div>
<label className="text-sm font-medium text-gray-700"></label>
<Select
value={formData.vatType}
onValueChange={(value) => handleFieldChange("vatType", value)}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="부가세 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="included"> </SelectItem>
<SelectItem value="excluded"> </SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 3행: 상태 | 비고 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label className="text-sm font-medium text-gray-700"></label>
<Input
value={formData.status === "final" ? "견적완료" : formData.status === "temporary" ? "임시저장" : "최초작성"}
disabled
className="bg-gray-50"
/>
</div>
<div className="md:col-span-3">
<label className="text-sm font-medium text-gray-700"></label>
<Input
placeholder="특이사항을 입력하세요"
value={formData.remarks}
onChange={(e) => handleFieldChange("remarks", e.target.value)}
disabled={isViewMode}
/>
</div>
</div>
</CardContent>
</Card>
{/* 자동 견적 산출 섹션 */}
<Card>
<CardHeader className="pb-3 bg-orange-50 border-b border-orange-200">
<CardTitle className="text-base font-semibold flex items-center gap-2 text-orange-800">
<Calculator className="h-5 w-5" />
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{/* 좌우 분할 레이아웃 */}
<div className="grid grid-cols-1 lg:grid-cols-2 min-h-[500px]">
{/* 왼쪽: 발주 개소 목록 + 추가 폼 */}
<LocationListPanel
locations={formData.locations}
selectedLocationId={selectedLocationId}
onSelectLocation={setSelectedLocationId}
onAddLocation={handleAddLocation}
onDeleteLocation={handleDeleteLocation}
onCloneLocation={handleCloneLocation}
onUpdateLocation={handleUpdateLocation}
onExcelUpload={handleExcelUpload}
finishedGoods={finishedGoods}
locationCodes={locationCodes}
disabled={isViewMode}
/>
{/* 오른쪽: 선택 개소 상세 */}
<LocationDetailPanel
location={selectedLocation}
onUpdateLocation={handleUpdateLocation}
onDeleteLocation={handleDeleteLocation}
locationCodes={locationCodes}
onCalculateLocation={async (locationId) => {
// 단일 개소 산출
const location = formData.locations.find((loc) => loc.id === locationId);
if (!location) return;
setIsCalculating(true);
try {
const bomItem = {
finished_goods_code: location.productCode,
openWidth: location.openWidth,
openHeight: location.openHeight,
quantity: location.quantity,
guideRailType: location.guideRailType,
motorPower: location.motorPower,
controller: location.controller,
wingSize: location.wingSize,
inspectionFee: location.inspectionFee,
};
const result = await calculateBomBulk([bomItem]);
if (result.success && result.data) {
const apiData = result.data as BomBulkResponse;
const bomResult = apiData.items?.[0]?.result;
if (bomResult) {
handleUpdateLocation(locationId, {
unitPrice: bomResult.grand_total,
totalPrice: bomResult.grand_total * location.quantity,
bomResult: bomResult,
});
toast.success("견적이 산출되었습니다.");
}
} else {
toast.error(result.error || "산출 실패");
}
} catch (error) {
console.error("산출 오류:", error);
toast.error("산출 중 오류가 발생했습니다.");
} finally {
setIsCalculating(false);
}
}}
onSaveItems={() => {
toast.success("품목이 저장되었습니다.");
}}
finishedGoods={finishedGoods}
disabled={isViewMode}
isCalculating={isCalculating}
/>
</div>
</CardContent>
</Card>
{/* 견적 금액 요약 */}
<QuoteSummaryPanel
locations={formData.locations}
selectedLocationId={selectedLocationId}
onSelectLocation={setSelectedLocationId}
/>
</div>
{/* 푸터 바 (고정) */}
<QuoteFooterBar
totalLocations={formData.locations.length}
totalAmount={discountedTotalAmount}
status={formData.status}
onQuoteView={() => setQuotePreviewOpen(true)}
onTransactionView={() => setTransactionPreviewOpen(true)}
onSave={() => handleSave("temporary")}
onFinalize={() => handleSave("final")}
onBack={onBack}
onEdit={onEdit}
onOrderRegister={onOrderRegister}
onOrderView={onOrderView}
orderId={formData.orderId}
onDiscount={() => setDiscountModalOpen(true)}
onFormulaView={() => setFormulaViewOpen(true)}
hasBomResult={hasBomResult}
isSaving={isSaving}
isViewMode={isViewMode}
/>
{/* 견적서 보기 모달 */}
<QuotePreviewModal
open={quotePreviewOpen}
onOpenChange={setQuotePreviewOpen}
quoteData={formData}
discountRate={discountRate}
discountAmount={discountAmount}
/>
{/* 거래명세서 보기 모달 */}
<QuoteTransactionModal
open={transactionPreviewOpen}
onOpenChange={setTransactionPreviewOpen}
quoteData={formData}
discountRate={discountRate}
discountAmount={discountAmount}
/>
{/* 할인하기 모달 */}
<DiscountModal
open={discountModalOpen}
onOpenChange={setDiscountModalOpen}
supplyAmount={totalAmount}
initialDiscountRate={discountRate}
initialDiscountAmount={discountAmount}
onApply={handleApplyDiscount}
/>
{/* 수식보기 모달 */}
<FormulaViewModal
open={formulaViewOpen}
onOpenChange={setFormulaViewOpen}
locations={formData.locations}
/>
</div>
);
}