- 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>
1023 lines
38 KiB
TypeScript
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>
|
|
);
|
|
}
|