2025-12-04 20:52:42 +09:00
|
|
|
|
/**
|
|
|
|
|
|
* 견적 등록/수정 컴포넌트
|
|
|
|
|
|
*
|
2026-01-20 20:41:45 +09:00
|
|
|
|
* IntegratedDetailTemplate 마이그레이션 (2026-01-20)
|
2025-12-04 20:52:42 +09:00
|
|
|
|
* - 기본 정보 섹션
|
|
|
|
|
|
* - 자동 견적 산출 섹션 (동적 항목 추가/삭제)
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
"use client";
|
|
|
|
|
|
|
2026-01-20 20:41:45 +09:00
|
|
|
|
import { useState, useEffect, useMemo, useCallback } from "react";
|
2025-12-04 20:52:42 +09:00
|
|
|
|
import { Input } from "../ui/input";
|
|
|
|
|
|
import { Textarea } from "../ui/textarea";
|
2026-01-21 20:56:17 +09:00
|
|
|
|
import { QuantityInput } from "../ui/quantity-input";
|
|
|
|
|
|
import { CurrencyInput } from "../ui/currency-input";
|
|
|
|
|
|
import { PhoneInput } from "../ui/phone-input";
|
2025-12-04 20:52:42 +09:00
|
|
|
|
import {
|
|
|
|
|
|
Select,
|
|
|
|
|
|
SelectContent,
|
|
|
|
|
|
SelectItem,
|
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
|
SelectValue,
|
|
|
|
|
|
} from "../ui/select";
|
|
|
|
|
|
import { Button } from "../ui/button";
|
|
|
|
|
|
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
|
|
|
|
|
|
import { Badge } from "../ui/badge";
|
2025-12-23 21:13:07 +09:00
|
|
|
|
import { Alert, AlertDescription } from "../ui/alert";
|
2025-12-04 20:52:42 +09:00
|
|
|
|
import {
|
|
|
|
|
|
FileText,
|
|
|
|
|
|
Calculator,
|
|
|
|
|
|
Plus,
|
|
|
|
|
|
Copy,
|
|
|
|
|
|
Trash2,
|
2026-01-06 21:20:49 +09:00
|
|
|
|
Loader2,
|
2025-12-04 20:52:42 +09:00
|
|
|
|
} from "lucide-react";
|
|
|
|
|
|
import { toast } from "sonner";
|
fix(date): UTC 기반 날짜를 로컬 타임존으로 변경
- 공통 날짜 유틸리티 함수 추가 (src/utils/date.ts)
- getLocalDateString(): 로컬 타임존 YYYY-MM-DD 포맷
- getTodayString(): 오늘 날짜 반환
- getDateAfterDays(): N일 후 날짜 계산
- formatDateForInput(): API 응답 → input 포맷 변환
- toISOString().split('T')[0] 패턴을 공통 함수로 교체
- 견적: QuoteRegistration, QuoteRegistrationV2, types
- 건설: contract, site-briefings, estimates, bidding types
- 건설: IssueDetailForm, ConstructionDetailClient, ProjectEndDialog
- 자재: InspectionCreate, ReceivingReceiptContent, StockStatus/mockData
- 품질: InspectionManagement/mockData
- 기타: PricingFormClient, ShipmentCreate, PurchaseOrderDocument
- 기타: MainDashboard, attendance/actions, dev/generators
문제: toISOString()은 UTC 기준이라 한국(UTC+9)에서 오전 9시 이전에
전날 날짜가 표시되는 버그 발생
해결: 로컬 타임존 기반 날짜 포맷 함수로 통일
2026-01-26 17:15:22 +09:00
|
|
|
|
import { getTodayString } from "@/utils/date";
|
2025-12-23 21:13:07 +09:00
|
|
|
|
|
|
|
|
|
|
// 필드명 매핑
|
|
|
|
|
|
const FIELD_NAME_MAP: Record<string, string> = {
|
|
|
|
|
|
clientId: "발주처",
|
|
|
|
|
|
productCategory: "제품 카테고리",
|
|
|
|
|
|
productName: "제품명",
|
|
|
|
|
|
openWidth: "오픈사이즈(W)",
|
|
|
|
|
|
openHeight: "오픈사이즈(H)",
|
|
|
|
|
|
guideRailType: "가이드레일 설치 유형",
|
|
|
|
|
|
motorPower: "모터 전원",
|
|
|
|
|
|
controller: "연동제어기",
|
|
|
|
|
|
quantity: "수량",
|
|
|
|
|
|
};
|
2026-01-20 20:41:45 +09:00
|
|
|
|
|
|
|
|
|
|
import { IntegratedDetailTemplate } from "@/components/templates/IntegratedDetailTemplate";
|
|
|
|
|
|
import { quoteRegistrationCreateConfig, quoteRegistrationEditConfig } from "./quoteConfig";
|
|
|
|
|
|
import { FormSection } from "@/components/organisms/FormSection";
|
|
|
|
|
|
import { FormFieldGrid } from "@/components/organisms/FormFieldGrid";
|
2025-12-04 20:52:42 +09:00
|
|
|
|
import { FormField } from "../molecules/FormField";
|
2026-01-06 21:20:49 +09:00
|
|
|
|
import { getFinishedGoods, calculateBomBulk, getSiteNames, type FinishedGoods, type BomCalculationResult } from "./actions";
|
|
|
|
|
|
import { getClients } from "../accounting/VendorManagement/actions";
|
2026-01-11 17:19:11 +09:00
|
|
|
|
import { isNextRedirectError } from "@/lib/utils/redirect-error";
|
2026-01-06 21:20:49 +09:00
|
|
|
|
import type { Vendor } from "../accounting/VendorManagement";
|
|
|
|
|
|
import type { BomMaterial, CalculationResults } from "./types";
|
2026-01-20 20:38:29 +09:00
|
|
|
|
import { useDevFill } from "@/components/dev";
|
|
|
|
|
|
import { generateQuoteData } from "@/components/dev/generators/quoteData";
|
2025-12-04 20:52:42 +09:00
|
|
|
|
|
|
|
|
|
|
// 견적 항목 타입
|
|
|
|
|
|
export interface QuoteItem {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
floor: string; // 층수
|
|
|
|
|
|
code: string; // 부호
|
|
|
|
|
|
productCategory: string; // 제품 카테고리 (PC)
|
|
|
|
|
|
productName: string; // 제품명
|
|
|
|
|
|
openWidth: string; // 오픈사이즈 W0
|
|
|
|
|
|
openHeight: string; // 오픈사이즈 H0
|
|
|
|
|
|
guideRailType: string; // 가이드레일 설치 유형 (GT)
|
|
|
|
|
|
motorPower: string; // 모터 전원 (MP)
|
|
|
|
|
|
controller: string; // 연동제어기 (CT)
|
|
|
|
|
|
quantity: number; // 수량 (QTY)
|
2026-01-06 21:20:49 +09:00
|
|
|
|
unit?: string; // 품목 단위
|
2025-12-04 20:52:42 +09:00
|
|
|
|
wingSize: string; // 마구리 날개치수 (WS)
|
|
|
|
|
|
inspectionFee: number; // 검사비 (INSP)
|
2025-12-09 18:07:47 +09:00
|
|
|
|
unitPrice?: number; // 단가
|
|
|
|
|
|
totalAmount?: number; // 합계
|
|
|
|
|
|
installType?: string; // 설치유형
|
2025-12-04 20:52:42 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 견적 폼 데이터 타입
|
|
|
|
|
|
export interface QuoteFormData {
|
|
|
|
|
|
id?: string;
|
|
|
|
|
|
registrationDate: string;
|
|
|
|
|
|
writer: string;
|
|
|
|
|
|
clientId: string;
|
|
|
|
|
|
clientName: string;
|
|
|
|
|
|
siteName: string; // 현장명 (직접 입력)
|
|
|
|
|
|
manager: string;
|
|
|
|
|
|
contact: string;
|
|
|
|
|
|
dueDate: string;
|
|
|
|
|
|
remarks: string;
|
2026-01-06 21:20:49 +09:00
|
|
|
|
unitSymbol?: string; // 단위 (EA, 개소 등) - quotes.unit_symbol
|
2025-12-04 20:52:42 +09:00
|
|
|
|
items: QuoteItem[];
|
2026-01-06 21:20:49 +09:00
|
|
|
|
bomMaterials?: BomMaterial[]; // BOM 자재 목록
|
|
|
|
|
|
calculationResults?: CalculationResults; // 견적 산출 결과 (저장 시 BOM 자재 변환용)
|
2025-12-04 20:52:42 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 초기 견적 항목
|
|
|
|
|
|
const createNewItem = (): QuoteItem => ({
|
|
|
|
|
|
id: `item-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
|
|
|
|
floor: "",
|
|
|
|
|
|
code: "",
|
|
|
|
|
|
productCategory: "",
|
|
|
|
|
|
productName: "",
|
|
|
|
|
|
openWidth: "",
|
|
|
|
|
|
openHeight: "",
|
|
|
|
|
|
guideRailType: "",
|
|
|
|
|
|
motorPower: "",
|
|
|
|
|
|
controller: "",
|
|
|
|
|
|
quantity: 1,
|
|
|
|
|
|
wingSize: "50",
|
|
|
|
|
|
inspectionFee: 50000,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 초기 폼 데이터
|
|
|
|
|
|
export const INITIAL_QUOTE_FORM: QuoteFormData = {
|
fix(date): UTC 기반 날짜를 로컬 타임존으로 변경
- 공통 날짜 유틸리티 함수 추가 (src/utils/date.ts)
- getLocalDateString(): 로컬 타임존 YYYY-MM-DD 포맷
- getTodayString(): 오늘 날짜 반환
- getDateAfterDays(): N일 후 날짜 계산
- formatDateForInput(): API 응답 → input 포맷 변환
- toISOString().split('T')[0] 패턴을 공통 함수로 교체
- 견적: QuoteRegistration, QuoteRegistrationV2, types
- 건설: contract, site-briefings, estimates, bidding types
- 건설: IssueDetailForm, ConstructionDetailClient, ProjectEndDialog
- 자재: InspectionCreate, ReceivingReceiptContent, StockStatus/mockData
- 품질: InspectionManagement/mockData
- 기타: PricingFormClient, ShipmentCreate, PurchaseOrderDocument
- 기타: MainDashboard, attendance/actions, dev/generators
문제: toISOString()은 UTC 기준이라 한국(UTC+9)에서 오전 9시 이전에
전날 날짜가 표시되는 버그 발생
해결: 로컬 타임존 기반 날짜 포맷 함수로 통일
2026-01-26 17:15:22 +09:00
|
|
|
|
registrationDate: getTodayString(),
|
2025-12-04 20:52:42 +09:00
|
|
|
|
writer: "드미트리", // TODO: 로그인 사용자 정보로 대체
|
|
|
|
|
|
clientId: "",
|
|
|
|
|
|
clientName: "",
|
|
|
|
|
|
siteName: "", // 현장명 (직접 입력)
|
|
|
|
|
|
manager: "",
|
|
|
|
|
|
contact: "",
|
|
|
|
|
|
dueDate: "",
|
|
|
|
|
|
remarks: "",
|
|
|
|
|
|
items: [createNewItem()],
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-06 21:20:49 +09:00
|
|
|
|
// 제품 카테고리 옵션 (MNG 시뮬레이터와 동일)
|
2025-12-04 20:52:42 +09:00
|
|
|
|
const PRODUCT_CATEGORIES = [
|
2026-01-06 21:20:49 +09:00
|
|
|
|
{ value: "ALL", label: "전체" },
|
|
|
|
|
|
{ value: "SCREEN", label: "스크린" },
|
|
|
|
|
|
{ value: "STEEL", label: "철재" },
|
|
|
|
|
|
{ value: "BENDING", label: "절곡" },
|
|
|
|
|
|
{ value: "ALUMINUM", label: "알루미늄" },
|
2025-12-04 20:52:42 +09:00
|
|
|
|
];
|
|
|
|
|
|
|
2026-01-06 21:20:49 +09:00
|
|
|
|
// 가이드레일 설치 유형 (API: wall, ceiling, floor)
|
2025-12-04 20:52:42 +09:00
|
|
|
|
const GUIDE_RAIL_TYPES = [
|
2026-01-06 21:20:49 +09:00
|
|
|
|
{ value: "wall", label: "벽면형" },
|
|
|
|
|
|
{ value: "floor", label: "측면형" },
|
2025-12-04 20:52:42 +09:00
|
|
|
|
];
|
|
|
|
|
|
|
2026-01-06 21:20:49 +09:00
|
|
|
|
// 모터 전원 (API: single=단상220V, three=삼상380V)
|
2025-12-04 20:52:42 +09:00
|
|
|
|
const MOTOR_POWERS = [
|
2026-01-06 21:20:49 +09:00
|
|
|
|
{ value: "single", label: "220V (단상)" },
|
|
|
|
|
|
{ value: "three", label: "380V (삼상)" },
|
2025-12-04 20:52:42 +09:00
|
|
|
|
];
|
|
|
|
|
|
|
2026-01-06 21:20:49 +09:00
|
|
|
|
// 연동제어기 (API: basic, smart, premium)
|
2025-12-04 20:52:42 +09:00
|
|
|
|
const CONTROLLERS = [
|
2026-01-06 21:20:49 +09:00
|
|
|
|
{ value: "basic", label: "단독" },
|
|
|
|
|
|
{ value: "smart", label: "연동" },
|
2025-12-04 20:52:42 +09:00
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
interface QuoteRegistrationProps {
|
|
|
|
|
|
onBack: () => void;
|
2026-01-22 19:31:19 +09:00
|
|
|
|
onSave: (quote: QuoteFormData) => Promise<{ success: boolean; error?: string }>;
|
2025-12-04 20:52:42 +09:00
|
|
|
|
editingQuote?: QuoteFormData | null;
|
|
|
|
|
|
isLoading?: boolean;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function QuoteRegistration({
|
|
|
|
|
|
onBack,
|
|
|
|
|
|
onSave,
|
|
|
|
|
|
editingQuote,
|
|
|
|
|
|
isLoading = false,
|
|
|
|
|
|
}: QuoteRegistrationProps) {
|
|
|
|
|
|
const [formData, setFormData] = useState<QuoteFormData>(
|
|
|
|
|
|
editingQuote || INITIAL_QUOTE_FORM
|
|
|
|
|
|
);
|
|
|
|
|
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
|
|
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
|
|
|
|
const [activeItemIndex, setActiveItemIndex] = useState(0);
|
|
|
|
|
|
|
2026-01-20 20:41:45 +09:00
|
|
|
|
// Config 선택
|
|
|
|
|
|
const config = editingQuote ? quoteRegistrationEditConfig : quoteRegistrationCreateConfig;
|
|
|
|
|
|
|
2026-01-06 21:20:49 +09:00
|
|
|
|
// 완제품 목록 상태 (API에서 로드)
|
|
|
|
|
|
const [finishedGoods, setFinishedGoods] = useState<FinishedGoods[]>([]);
|
|
|
|
|
|
const [isLoadingProducts, setIsLoadingProducts] = useState(false);
|
|
|
|
|
|
const [isCalculating, setIsCalculating] = useState(false);
|
|
|
|
|
|
|
2026-01-16 15:39:02 +09:00
|
|
|
|
// 카테고리별 완제품 캐시 (API 재호출 최소화)
|
|
|
|
|
|
const [categoryProductsCache, setCategoryProductsCache] = useState<Record<string, FinishedGoods[]>>({});
|
|
|
|
|
|
|
2026-01-06 21:20:49 +09:00
|
|
|
|
// 거래처 목록 상태 (API에서 로드)
|
|
|
|
|
|
const [clients, setClients] = useState<Vendor[]>([]);
|
|
|
|
|
|
const [isLoadingClients, setIsLoadingClients] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
// 견적 산출 결과 상태
|
|
|
|
|
|
const [calculationResults, setCalculationResults] = useState<{
|
|
|
|
|
|
summary: { grand_total: number };
|
|
|
|
|
|
items: Array<{
|
|
|
|
|
|
index: number;
|
|
|
|
|
|
result: BomCalculationResult;
|
|
|
|
|
|
}>;
|
|
|
|
|
|
} | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
// 현장명 자동완성 목록 상태
|
|
|
|
|
|
const [siteNames, setSiteNames] = useState<string[]>([]);
|
|
|
|
|
|
|
2026-01-20 20:38:29 +09:00
|
|
|
|
// DevToolbar용 폼 자동 채우기 등록
|
|
|
|
|
|
useDevFill(
|
|
|
|
|
|
'quote',
|
2026-01-22 19:31:19 +09:00
|
|
|
|
useCallback(async () => {
|
|
|
|
|
|
// 1. 카테고리 랜덤 선택
|
|
|
|
|
|
const categories = ['SCREEN', 'STEEL'];
|
|
|
|
|
|
const selectedCategory = categories[Math.floor(Math.random() * categories.length)];
|
|
|
|
|
|
|
|
|
|
|
|
// 2. 해당 카테고리 제품 로드 (캐시에 없으면 API 호출)
|
|
|
|
|
|
let categoryProducts = categoryProductsCache[selectedCategory];
|
|
|
|
|
|
if (!categoryProducts) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await getFinishedGoods(selectedCategory);
|
|
|
|
|
|
if (result.success) {
|
|
|
|
|
|
categoryProducts = result.data;
|
|
|
|
|
|
setCategoryProductsCache(prev => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
[selectedCategory]: result.data
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('[DevFill] 카테고리별 제품 로드 실패:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 3. 로드된 제품 목록으로 샘플 데이터 생성
|
|
|
|
|
|
const productsToUse = categoryProducts || finishedGoods;
|
2026-01-20 20:38:29 +09:00
|
|
|
|
const sampleData = generateQuoteData({
|
|
|
|
|
|
clients: clients.map(c => ({ id: c.id, name: c.vendorName })),
|
2026-01-22 19:31:19 +09:00
|
|
|
|
products: productsToUse.map(p => ({ code: p.item_code, name: p.item_name, category: p.category })),
|
|
|
|
|
|
category: selectedCategory,
|
2026-01-20 20:38:29 +09:00
|
|
|
|
});
|
2026-01-22 19:31:19 +09:00
|
|
|
|
|
2026-01-20 20:38:29 +09:00
|
|
|
|
setFormData(sampleData);
|
|
|
|
|
|
toast.success('[Dev] 견적 폼이 자동으로 채워졌습니다.');
|
2026-01-22 19:31:19 +09:00
|
|
|
|
}, [clients, finishedGoods, categoryProductsCache])
|
2026-01-20 20:38:29 +09:00
|
|
|
|
);
|
|
|
|
|
|
|
2026-01-06 21:20:49 +09:00
|
|
|
|
// 수량 반영 총합계 계산 (useMemo로 최적화)
|
|
|
|
|
|
const calculatedGrandTotal = useMemo(() => {
|
|
|
|
|
|
if (!calculationResults?.items) return 0;
|
|
|
|
|
|
return calculationResults.items.reduce((sum, itemResult) => {
|
|
|
|
|
|
const formItem = formData.items[itemResult.index];
|
|
|
|
|
|
return sum + (itemResult.result.grand_total * (formItem?.quantity || 1));
|
|
|
|
|
|
}, 0);
|
|
|
|
|
|
}, [calculationResults, formData.items]);
|
|
|
|
|
|
|
2026-01-16 15:39:02 +09:00
|
|
|
|
// 컴포넌트 마운트 시 완제품 목록 로드 (초기 로드는 size 제한 없이 - 카테고리별 호출로 대체됨)
|
2025-12-04 20:52:42 +09:00
|
|
|
|
useEffect(() => {
|
2026-01-16 15:39:02 +09:00
|
|
|
|
const loadInitialProducts = async () => {
|
2026-01-06 21:20:49 +09:00
|
|
|
|
setIsLoadingProducts(true);
|
|
|
|
|
|
try {
|
2026-01-16 15:39:02 +09:00
|
|
|
|
// 초기에는 ALL 카테고리로 로드 (size 제한 내에서)
|
2026-01-06 21:20:49 +09:00
|
|
|
|
const result = await getFinishedGoods();
|
|
|
|
|
|
if (result.success) {
|
|
|
|
|
|
setFinishedGoods(result.data);
|
2026-01-16 15:39:02 +09:00
|
|
|
|
// 캐시에도 저장
|
|
|
|
|
|
setCategoryProductsCache(prev => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
"ALL": result.data
|
|
|
|
|
|
}));
|
2026-01-06 21:20:49 +09:00
|
|
|
|
} else {
|
|
|
|
|
|
toast.error(`완제품 목록 로드 실패: ${result.error}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
2026-01-11 17:19:11 +09:00
|
|
|
|
if (isNextRedirectError(error)) throw error;
|
2026-01-06 21:20:49 +09:00
|
|
|
|
toast.error("완제품 목록을 불러오는데 실패했습니다.");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsLoadingProducts(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-01-16 15:39:02 +09:00
|
|
|
|
loadInitialProducts();
|
2026-01-06 21:20:49 +09:00
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
// 컴포넌트 마운트 시 거래처 목록 로드
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const loadClients = async () => {
|
|
|
|
|
|
setIsLoadingClients(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await getClients();
|
|
|
|
|
|
if (result.success) {
|
|
|
|
|
|
setClients(result.data);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
toast.error(`거래처 목록 로드 실패: ${result.error}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
2026-01-11 17:19:11 +09:00
|
|
|
|
if (isNextRedirectError(error)) throw error;
|
2026-01-06 21:20:49 +09:00
|
|
|
|
toast.error("거래처 목록을 불러오는데 실패했습니다.");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsLoadingClients(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
loadClients();
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
// 컴포넌트 마운트 시 현장명 목록 로드 (자동완성용)
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const loadSiteNames = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await getSiteNames();
|
|
|
|
|
|
if (result.success) {
|
|
|
|
|
|
setSiteNames(result.data);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
2026-01-11 17:19:11 +09:00
|
|
|
|
if (isNextRedirectError(error)) throw error;
|
2026-01-06 21:20:49 +09:00
|
|
|
|
// 현장명 로드 실패는 무시 (선택적 기능)
|
|
|
|
|
|
console.error("현장명 목록 로드 실패:", error);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
loadSiteNames();
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
// editingQuote가 변경되면 formData 업데이트 및 calculationResults 초기화
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
console.log('[QuoteRegistration] useEffect editingQuote:', JSON.stringify({
|
|
|
|
|
|
hasEditingQuote: !!editingQuote,
|
|
|
|
|
|
itemCount: editingQuote?.items?.length,
|
|
|
|
|
|
item0: editingQuote?.items?.[0] ? {
|
|
|
|
|
|
quantity: editingQuote.items[0].quantity,
|
|
|
|
|
|
wingSize: editingQuote.items[0].wingSize,
|
|
|
|
|
|
inspectionFee: editingQuote.items[0].inspectionFee,
|
|
|
|
|
|
} : null,
|
|
|
|
|
|
}, null, 2));
|
2025-12-04 20:52:42 +09:00
|
|
|
|
if (editingQuote) {
|
|
|
|
|
|
setFormData(editingQuote);
|
2026-01-06 21:20:49 +09:00
|
|
|
|
// 수정 모드 진입 시 이전 산출 결과 초기화
|
|
|
|
|
|
setCalculationResults(null);
|
2025-12-04 20:52:42 +09:00
|
|
|
|
}
|
|
|
|
|
|
}, [editingQuote]);
|
|
|
|
|
|
|
2026-01-16 15:39:02 +09:00
|
|
|
|
// 카테고리별 완제품 로드 (API 호출)
|
|
|
|
|
|
const loadProductsByCategory = async (category: string) => {
|
|
|
|
|
|
// 이미 캐시에 있으면 스킵
|
|
|
|
|
|
if (categoryProductsCache[category]) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setIsLoadingProducts(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 카테고리가 ALL이면 전체, 아니면 해당 카테고리만 조회
|
|
|
|
|
|
const result = await getFinishedGoods(category === "ALL" ? undefined : category);
|
|
|
|
|
|
if (result.success) {
|
|
|
|
|
|
setCategoryProductsCache(prev => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
[category]: result.data
|
|
|
|
|
|
}));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
toast.error(`완제품 목록 로드 실패: ${result.error}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if (isNextRedirectError(error)) throw error;
|
|
|
|
|
|
toast.error("완제품 목록을 불러오는데 실패했습니다.");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsLoadingProducts(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 카테고리별 완제품 조회 (캐시 기반)
|
2026-01-06 21:20:49 +09:00
|
|
|
|
const getFilteredProducts = (category: string) => {
|
|
|
|
|
|
if (!category || category === "ALL") {
|
2026-01-16 15:39:02 +09:00
|
|
|
|
// 전체 선택 시 캐시된 ALL 데이터 또는 초기 finishedGoods
|
|
|
|
|
|
return categoryProductsCache["ALL"] || finishedGoods;
|
2026-01-06 21:20:49 +09:00
|
|
|
|
}
|
2026-01-16 15:39:02 +09:00
|
|
|
|
// 카테고리별 캐시 반환
|
|
|
|
|
|
return categoryProductsCache[category] || [];
|
2026-01-06 21:20:49 +09:00
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-04 20:52:42 +09:00
|
|
|
|
// 유효성 검사
|
2026-01-20 20:41:45 +09:00
|
|
|
|
const validateForm = useCallback((): boolean => {
|
2025-12-04 20:52:42 +09:00
|
|
|
|
const newErrors: Record<string, string> = {};
|
|
|
|
|
|
|
|
|
|
|
|
if (!formData.clientId) {
|
|
|
|
|
|
newErrors.clientId = "발주처를 선택해주세요";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 견적 항목 검사
|
|
|
|
|
|
formData.items.forEach((item, index) => {
|
|
|
|
|
|
if (!item.productCategory) {
|
|
|
|
|
|
newErrors[`item-${index}-productCategory`] = "제품 카테고리를 선택해주세요";
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!item.productName) {
|
|
|
|
|
|
newErrors[`item-${index}-productName`] = "제품명을 선택해주세요";
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!item.openWidth) {
|
|
|
|
|
|
newErrors[`item-${index}-openWidth`] = "오픈사이즈(W)를 입력해주세요";
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!item.openHeight) {
|
|
|
|
|
|
newErrors[`item-${index}-openHeight`] = "오픈사이즈(H)를 입력해주세요";
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!item.guideRailType) {
|
|
|
|
|
|
newErrors[`item-${index}-guideRailType`] = "설치 유형을 선택해주세요";
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!item.motorPower) {
|
|
|
|
|
|
newErrors[`item-${index}-motorPower`] = "모터 전원을 선택해주세요";
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!item.controller) {
|
|
|
|
|
|
newErrors[`item-${index}-controller`] = "제어기를 선택해주세요";
|
|
|
|
|
|
}
|
|
|
|
|
|
if (item.quantity < 1) {
|
|
|
|
|
|
newErrors[`item-${index}-quantity`] = "수량은 1 이상이어야 합니다";
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
setErrors(newErrors);
|
|
|
|
|
|
return Object.keys(newErrors).length === 0;
|
2026-01-20 20:41:45 +09:00
|
|
|
|
}, [formData]);
|
2025-12-04 20:52:42 +09:00
|
|
|
|
|
2026-01-22 19:31:19 +09:00
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
|
|
|
|
const handleSubmit = useCallback(async (_data?: Record<string, unknown>): Promise<{ success: boolean; error?: string }> => {
|
2025-12-04 20:52:42 +09:00
|
|
|
|
if (!validateForm()) {
|
2025-12-23 21:13:07 +09:00
|
|
|
|
// 페이지 상단으로 스크롤
|
|
|
|
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
2026-01-22 19:31:19 +09:00
|
|
|
|
return { success: false, error: '입력 정보를 확인해주세요.' };
|
2025-12-04 20:52:42 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-23 21:13:07 +09:00
|
|
|
|
// 에러 초기화
|
|
|
|
|
|
setErrors({});
|
2025-12-04 20:52:42 +09:00
|
|
|
|
setIsSaving(true);
|
|
|
|
|
|
try {
|
2026-01-06 21:20:49 +09:00
|
|
|
|
// calculationResults를 formData에 포함하여 저장
|
|
|
|
|
|
// transformFormDataToApi에서 BOM 자재의 base_quantity, calculated_quantity를 제대로 설정하기 위함
|
|
|
|
|
|
const dataToSave: QuoteFormData = {
|
|
|
|
|
|
...formData,
|
|
|
|
|
|
calculationResults: calculationResults || undefined,
|
|
|
|
|
|
};
|
2026-01-22 19:31:19 +09:00
|
|
|
|
const result = await onSave(dataToSave);
|
|
|
|
|
|
// IntegratedDetailTemplate에서 toast 처리 및 navigation 처리
|
|
|
|
|
|
return result;
|
2025-12-04 20:52:42 +09:00
|
|
|
|
} catch (error) {
|
2026-01-11 17:19:11 +09:00
|
|
|
|
if (isNextRedirectError(error)) throw error;
|
2026-01-22 19:31:19 +09:00
|
|
|
|
return { success: false, error: '저장 중 오류가 발생했습니다.' };
|
2025-12-04 20:52:42 +09:00
|
|
|
|
} finally {
|
|
|
|
|
|
setIsSaving(false);
|
|
|
|
|
|
}
|
2026-01-22 19:31:19 +09:00
|
|
|
|
}, [formData, calculationResults, validateForm, onSave]);
|
2025-12-04 20:52:42 +09:00
|
|
|
|
|
|
|
|
|
|
const handleFieldChange = (
|
|
|
|
|
|
field: keyof QuoteFormData,
|
|
|
|
|
|
value: string | QuoteItem[]
|
|
|
|
|
|
) => {
|
2026-01-06 21:20:49 +09:00
|
|
|
|
// DEBUG: manager, contact, remarks 필드 변경 추적
|
|
|
|
|
|
if (field === 'manager' || field === 'contact' || field === 'remarks') {
|
|
|
|
|
|
console.log(`[handleFieldChange] ${field} 변경:`, value);
|
|
|
|
|
|
}
|
2025-12-04 20:52:42 +09:00
|
|
|
|
setFormData({ ...formData, [field]: value });
|
|
|
|
|
|
if (errors[field]) {
|
|
|
|
|
|
setErrors((prev) => {
|
|
|
|
|
|
const newErrors = { ...prev };
|
|
|
|
|
|
delete newErrors[field];
|
|
|
|
|
|
return newErrors;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 발주처 선택
|
|
|
|
|
|
const handleClientChange = (clientId: string) => {
|
2026-01-06 21:20:49 +09:00
|
|
|
|
const client = clients.find((c) => c.id === clientId);
|
2025-12-04 20:52:42 +09:00
|
|
|
|
setFormData({
|
|
|
|
|
|
...formData,
|
|
|
|
|
|
clientId,
|
2026-01-06 21:20:49 +09:00
|
|
|
|
clientName: client?.vendorName || "",
|
2025-12-04 20:52:42 +09:00
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 견적 항목 변경
|
|
|
|
|
|
const handleItemChange = (
|
|
|
|
|
|
index: number,
|
|
|
|
|
|
field: keyof QuoteItem,
|
|
|
|
|
|
value: string | number
|
|
|
|
|
|
) => {
|
|
|
|
|
|
const newItems = [...formData.items];
|
|
|
|
|
|
newItems[index] = { ...newItems[index], [field]: value };
|
|
|
|
|
|
|
2026-01-16 15:39:02 +09:00
|
|
|
|
// 제품 카테고리 변경 시 제품명 초기화 및 해당 카테고리 제품 로드
|
|
|
|
|
|
if (field === "productCategory" && typeof value === "string") {
|
2025-12-04 20:52:42 +09:00
|
|
|
|
newItems[index].productName = "";
|
2026-01-16 15:39:02 +09:00
|
|
|
|
// 해당 카테고리 제품 목록 API 호출 (캐시 없으면)
|
|
|
|
|
|
loadProductsByCategory(value);
|
2025-12-04 20:52:42 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setFormData({ ...formData, items: newItems });
|
|
|
|
|
|
|
|
|
|
|
|
// 에러 클리어
|
|
|
|
|
|
const errorKey = `item-${index}-${field}`;
|
|
|
|
|
|
if (errors[errorKey]) {
|
|
|
|
|
|
setErrors((prev) => {
|
|
|
|
|
|
const newErrors = { ...prev };
|
|
|
|
|
|
delete newErrors[errorKey];
|
|
|
|
|
|
return newErrors;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 견적 항목 추가
|
|
|
|
|
|
const handleAddItem = () => {
|
|
|
|
|
|
const newItems = [...formData.items, createNewItem()];
|
|
|
|
|
|
setFormData({ ...formData, items: newItems });
|
|
|
|
|
|
setActiveItemIndex(newItems.length - 1);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 견적 항목 복사
|
|
|
|
|
|
const handleCopyItem = (index: number) => {
|
|
|
|
|
|
const itemToCopy = formData.items[index];
|
|
|
|
|
|
const newItem: QuoteItem = {
|
|
|
|
|
|
...itemToCopy,
|
|
|
|
|
|
id: `item-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
|
|
|
|
};
|
|
|
|
|
|
const newItems = [...formData.items, newItem];
|
|
|
|
|
|
setFormData({ ...formData, items: newItems });
|
|
|
|
|
|
setActiveItemIndex(newItems.length - 1);
|
|
|
|
|
|
toast.success("견적 항목이 복사되었습니다.");
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 견적 항목 삭제
|
|
|
|
|
|
const handleDeleteItem = (index: number) => {
|
|
|
|
|
|
if (formData.items.length === 1) {
|
|
|
|
|
|
toast.error("최소 1개의 견적 항목이 필요합니다.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const newItems = formData.items.filter((_, i) => i !== index);
|
|
|
|
|
|
setFormData({ ...formData, items: newItems });
|
|
|
|
|
|
if (activeItemIndex >= newItems.length) {
|
|
|
|
|
|
setActiveItemIndex(newItems.length - 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
toast.success("견적 항목이 삭제되었습니다.");
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 자동 견적 산출
|
2026-01-06 21:20:49 +09:00
|
|
|
|
const handleAutoCalculate = async () => {
|
|
|
|
|
|
// 필수 입력값 검사
|
|
|
|
|
|
const incompleteItems = formData.items.filter(
|
|
|
|
|
|
(item) => !item.productName || !item.openWidth || !item.openHeight
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
if (incompleteItems.length > 0) {
|
|
|
|
|
|
toast.error("모든 견적 항목의 필수 입력값(제품명, 오픈사이즈)을 입력해주세요.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setIsCalculating(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
// BOM 계산 요청 데이터 구성 (API는 플랫한 구조 기대)
|
|
|
|
|
|
const bomItems = formData.items.map((item) => ({
|
|
|
|
|
|
finished_goods_code: item.productName, // item_code가 productName에 저장됨
|
|
|
|
|
|
// React 필드명 (camelCase) 사용 - API가 W0/H0로 변환
|
|
|
|
|
|
openWidth: parseFloat(item.openWidth) || 0,
|
|
|
|
|
|
openHeight: parseFloat(item.openHeight) || 0,
|
|
|
|
|
|
quantity: item.quantity,
|
|
|
|
|
|
guideRailType: item.guideRailType || undefined,
|
|
|
|
|
|
motorPower: item.motorPower || undefined,
|
|
|
|
|
|
controller: item.controller || undefined,
|
|
|
|
|
|
wingSize: parseFloat(item.wingSize) || undefined,
|
|
|
|
|
|
inspectionFee: item.inspectionFee || undefined,
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
const result = await calculateBomBulk(bomItems);
|
|
|
|
|
|
|
|
|
|
|
|
if (result.success && result.data) {
|
|
|
|
|
|
// API 응답: { success, summary, items: [{ index, result: BomCalculationResult }] }
|
|
|
|
|
|
const apiData = result.data as {
|
|
|
|
|
|
summary?: { grand_total: number };
|
|
|
|
|
|
items?: Array<{ index: number; result: BomCalculationResult }>;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const bomItems = apiData.items || [];
|
2025-12-04 20:52:42 +09:00
|
|
|
|
|
2026-01-06 21:20:49 +09:00
|
|
|
|
// 계산 결과를 폼 데이터에 반영
|
|
|
|
|
|
const updatedItems = formData.items.map((item, index) => {
|
|
|
|
|
|
const bomResult = bomItems.find((b) => b.index === index);
|
|
|
|
|
|
if (bomResult?.result) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
...item,
|
|
|
|
|
|
unitPrice: bomResult.result.grand_total,
|
|
|
|
|
|
totalAmount: bomResult.result.grand_total * item.quantity,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
return item;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
setFormData({ ...formData, items: updatedItems });
|
|
|
|
|
|
|
|
|
|
|
|
// 전체 계산 결과 저장
|
|
|
|
|
|
setCalculationResults({
|
|
|
|
|
|
summary: apiData.summary || { grand_total: 0 },
|
|
|
|
|
|
items: bomItems,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
toast.success(`${formData.items.length}개 항목의 견적이 산출되었습니다.`);
|
|
|
|
|
|
|
|
|
|
|
|
// 계산 결과 요약 표시 (수량 반영 총합계 계산 - updatedItems 사용)
|
|
|
|
|
|
const totalWithQuantity = bomItems.reduce((sum, itemResult) => {
|
|
|
|
|
|
const formItem = updatedItems[itemResult.index];
|
|
|
|
|
|
return sum + (itemResult.result.grand_total * (formItem?.quantity || 1));
|
|
|
|
|
|
}, 0);
|
|
|
|
|
|
toast.info(`총 견적 금액: ${totalWithQuantity.toLocaleString()}원`);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
toast.error(`견적 산출 실패: ${result.error || "알 수 없는 오류"}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
2026-01-11 17:19:11 +09:00
|
|
|
|
if (isNextRedirectError(error)) throw error;
|
2026-01-06 21:20:49 +09:00
|
|
|
|
console.error("견적 산출 오류:", error);
|
|
|
|
|
|
toast.error("견적 산출 중 오류가 발생했습니다.");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsCalculating(false);
|
|
|
|
|
|
}
|
2025-12-04 20:52:42 +09:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-06 21:20:49 +09:00
|
|
|
|
// 렌더링 직전 디버그 로그
|
|
|
|
|
|
console.log('[QuoteRegistration] 렌더링 직전 formData.items[0]:', JSON.stringify({
|
|
|
|
|
|
quantity: formData.items[0]?.quantity,
|
|
|
|
|
|
wingSize: formData.items[0]?.wingSize,
|
|
|
|
|
|
inspectionFee: formData.items[0]?.inspectionFee,
|
|
|
|
|
|
}, null, 2));
|
|
|
|
|
|
|
2026-01-20 20:41:45 +09:00
|
|
|
|
// 폼 콘텐츠 렌더링
|
|
|
|
|
|
const renderFormContent = useCallback(
|
|
|
|
|
|
() => (
|
|
|
|
|
|
<div className="space-y-6 max-w-4xl">
|
|
|
|
|
|
{/* Validation 에러 표시 */}
|
|
|
|
|
|
{Object.keys(errors).length > 0 && (
|
|
|
|
|
|
<Alert className="bg-red-50 border-red-200">
|
|
|
|
|
|
<AlertDescription className="text-red-900">
|
|
|
|
|
|
<div className="flex items-start gap-2">
|
|
|
|
|
|
<span className="text-lg">⚠️</span>
|
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
|
<strong className="block mb-2">
|
|
|
|
|
|
입력 내용을 확인해주세요 ({Object.keys(errors).length}개 오류)
|
|
|
|
|
|
</strong>
|
|
|
|
|
|
<ul className="space-y-1 text-sm">
|
|
|
|
|
|
{Object.entries(errors).map(([field, message]) => {
|
|
|
|
|
|
// item-0-productCategory 형태의 키에서 필드명 추출
|
|
|
|
|
|
const fieldParts = field.split("-");
|
|
|
|
|
|
let fieldName = field;
|
|
|
|
|
|
if (fieldParts.length === 3) {
|
|
|
|
|
|
const itemIndex = parseInt(fieldParts[1]) + 1;
|
|
|
|
|
|
const fieldKey = fieldParts[2];
|
|
|
|
|
|
fieldName = `견적 ${itemIndex} - ${FIELD_NAME_MAP[fieldKey] || fieldKey}`;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
fieldName = FIELD_NAME_MAP[field] || field;
|
|
|
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
|
|
|
<li key={field} className="flex items-start gap-1">
|
|
|
|
|
|
<span>•</span>
|
|
|
|
|
|
<span>
|
|
|
|
|
|
<strong>{fieldName}</strong>: {message}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
</div>
|
2025-12-23 21:13:07 +09:00
|
|
|
|
</div>
|
2026-01-20 20:41:45 +09:00
|
|
|
|
</AlertDescription>
|
|
|
|
|
|
</Alert>
|
|
|
|
|
|
)}
|
2025-12-04 20:52:42 +09:00
|
|
|
|
|
2026-01-20 20:41:45 +09:00
|
|
|
|
{/* 1. 기본 정보 */}
|
|
|
|
|
|
<FormSection
|
|
|
|
|
|
title="기본 정보"
|
|
|
|
|
|
description="견적의 기본 정보를 입력하세요"
|
|
|
|
|
|
icon={FileText}
|
|
|
|
|
|
>
|
|
|
|
|
|
<FormFieldGrid columns={3}>
|
|
|
|
|
|
<FormField label="등록일" htmlFor="registrationDate" type="custom">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="registrationDate"
|
|
|
|
|
|
type="date"
|
|
|
|
|
|
value={formData.registrationDate}
|
|
|
|
|
|
disabled
|
|
|
|
|
|
className="bg-gray-50"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</FormField>
|
|
|
|
|
|
|
|
|
|
|
|
<FormField label="작성자" htmlFor="writer">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="writer"
|
|
|
|
|
|
value={formData.writer}
|
|
|
|
|
|
disabled
|
|
|
|
|
|
className="bg-gray-50"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</FormField>
|
|
|
|
|
|
|
|
|
|
|
|
<FormField
|
|
|
|
|
|
label="발주처 선택"
|
|
|
|
|
|
type="custom"
|
|
|
|
|
|
required
|
|
|
|
|
|
error={errors.clientId}
|
|
|
|
|
|
htmlFor="clientId"
|
2025-12-04 20:52:42 +09:00
|
|
|
|
>
|
2026-01-20 20:41:45 +09:00
|
|
|
|
<Select
|
|
|
|
|
|
value={formData.clientId}
|
|
|
|
|
|
onValueChange={handleClientChange}
|
|
|
|
|
|
disabled={isLoadingClients}
|
|
|
|
|
|
>
|
|
|
|
|
|
<SelectTrigger id="clientId">
|
|
|
|
|
|
<SelectValue placeholder={isLoadingClients ? "로딩 중..." : "발주처를 선택하세요"} />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
{clients.map((client) => (
|
|
|
|
|
|
<SelectItem key={client.id} value={client.id}>
|
|
|
|
|
|
{client.vendorName}
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</FormField>
|
|
|
|
|
|
</FormFieldGrid>
|
|
|
|
|
|
|
|
|
|
|
|
<FormFieldGrid columns={3}>
|
|
|
|
|
|
<FormField label="현장명" htmlFor="siteName" type="custom">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="siteName"
|
|
|
|
|
|
list="siteNameList"
|
|
|
|
|
|
placeholder="현장명을 입력 또는 선택하세요"
|
|
|
|
|
|
value={formData.siteName}
|
|
|
|
|
|
onChange={(e) => handleFieldChange("siteName", e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<datalist id="siteNameList">
|
|
|
|
|
|
{siteNames.map((name) => (
|
|
|
|
|
|
<option key={name} value={name} />
|
2025-12-04 20:52:42 +09:00
|
|
|
|
))}
|
2026-01-20 20:41:45 +09:00
|
|
|
|
</datalist>
|
|
|
|
|
|
</FormField>
|
|
|
|
|
|
|
|
|
|
|
|
<FormField label="발주 담당자" htmlFor="manager" type="custom">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="manager"
|
|
|
|
|
|
placeholder="담당자명을 입력하세요"
|
|
|
|
|
|
value={formData.manager}
|
|
|
|
|
|
onChange={(e) => handleFieldChange("manager", e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</FormField>
|
|
|
|
|
|
|
|
|
|
|
|
<FormField label="연락처" htmlFor="contact" type="custom">
|
2026-01-21 20:56:17 +09:00
|
|
|
|
<PhoneInput
|
2026-01-20 20:41:45 +09:00
|
|
|
|
id="contact"
|
|
|
|
|
|
placeholder="010-1234-5678"
|
|
|
|
|
|
value={formData.contact}
|
2026-01-21 20:56:17 +09:00
|
|
|
|
onChange={(value) => handleFieldChange("contact", value)}
|
2026-01-20 20:41:45 +09:00
|
|
|
|
/>
|
|
|
|
|
|
</FormField>
|
|
|
|
|
|
</FormFieldGrid>
|
|
|
|
|
|
|
|
|
|
|
|
<FormFieldGrid columns={3}>
|
|
|
|
|
|
<FormField label="납기일" htmlFor="dueDate" type="custom">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="dueDate"
|
|
|
|
|
|
type="date"
|
|
|
|
|
|
value={formData.dueDate}
|
|
|
|
|
|
onChange={(e) => handleFieldChange("dueDate", e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</FormField>
|
|
|
|
|
|
<div className="col-span-2" />
|
|
|
|
|
|
</FormFieldGrid>
|
|
|
|
|
|
|
|
|
|
|
|
<FormField label="비고" htmlFor="remarks" type="custom">
|
|
|
|
|
|
<Textarea
|
|
|
|
|
|
id="remarks"
|
|
|
|
|
|
placeholder="특이사항을 입력하세요"
|
|
|
|
|
|
value={formData.remarks}
|
|
|
|
|
|
onChange={(e) => handleFieldChange("remarks", e.target.value)}
|
|
|
|
|
|
rows={3}
|
2025-12-04 20:52:42 +09:00
|
|
|
|
/>
|
|
|
|
|
|
</FormField>
|
2026-01-20 20:41:45 +09:00
|
|
|
|
</FormSection>
|
2025-12-04 20:52:42 +09:00
|
|
|
|
|
2026-01-20 20:41:45 +09:00
|
|
|
|
{/* 2. 자동 견적 산출 */}
|
|
|
|
|
|
<FormSection
|
|
|
|
|
|
title="자동 견적 산출"
|
|
|
|
|
|
description="입력값을 기반으로 견적을 자동으로 산출합니다"
|
|
|
|
|
|
icon={Calculator}
|
|
|
|
|
|
>
|
|
|
|
|
|
{/* 견적 탭 */}
|
|
|
|
|
|
<Card className="border-gray-200">
|
|
|
|
|
|
<CardHeader className="pb-3">
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<div className="flex gap-2 flex-wrap">
|
|
|
|
|
|
{formData.items.map((item, index) => (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
key={item.id}
|
|
|
|
|
|
variant={activeItemIndex === index ? "default" : "outline"}
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={() => setActiveItemIndex(index)}
|
|
|
|
|
|
className="min-w-[70px]"
|
|
|
|
|
|
>
|
|
|
|
|
|
견적 {index + 1}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex gap-1">
|
2025-12-04 20:52:42 +09:00
|
|
|
|
<Button
|
2026-01-20 20:41:45 +09:00
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
onClick={() => handleCopyItem(activeItemIndex)}
|
|
|
|
|
|
title="복사"
|
2025-12-04 20:52:42 +09:00
|
|
|
|
>
|
2026-01-20 20:41:45 +09:00
|
|
|
|
<Copy className="h-4 w-4" />
|
2025-12-04 20:52:42 +09:00
|
|
|
|
</Button>
|
2026-01-20 20:41:45 +09:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
onClick={() => handleDeleteItem(activeItemIndex)}
|
|
|
|
|
|
title="삭제"
|
|
|
|
|
|
className="text-red-500 hover:text-red-600"
|
|
|
|
|
|
disabled={formData.items.length === 1}
|
2025-12-04 20:52:42 +09:00
|
|
|
|
>
|
2026-01-20 20:41:45 +09:00
|
|
|
|
<Trash2 className="h-4 w-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</CardHeader>
|
|
|
|
|
|
<CardContent className="space-y-4">
|
|
|
|
|
|
{formData.items[activeItemIndex] && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<FormFieldGrid columns={3}>
|
|
|
|
|
|
<FormField label="층수" htmlFor={`floor-${activeItemIndex}`} type="custom">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id={`floor-${activeItemIndex}`}
|
|
|
|
|
|
placeholder="예: 1층, B1, 지하1층"
|
|
|
|
|
|
value={formData.items[activeItemIndex].floor}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
handleItemChange(activeItemIndex, "floor", e.target.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</FormField>
|
|
|
|
|
|
|
|
|
|
|
|
<FormField label="부호" htmlFor={`code-${activeItemIndex}`} type="custom">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id={`code-${activeItemIndex}`}
|
|
|
|
|
|
placeholder="예: A, B, C"
|
|
|
|
|
|
value={formData.items[activeItemIndex].code}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
handleItemChange(activeItemIndex, "code", e.target.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</FormField>
|
|
|
|
|
|
|
|
|
|
|
|
<FormField
|
|
|
|
|
|
label="제품 카테고리 (PC)"
|
|
|
|
|
|
type="custom"
|
|
|
|
|
|
required
|
|
|
|
|
|
error={errors[`item-${activeItemIndex}-productCategory`]}
|
|
|
|
|
|
htmlFor={`productCategory-${activeItemIndex}`}
|
2025-12-04 20:52:42 +09:00
|
|
|
|
>
|
2026-01-20 20:41:45 +09:00
|
|
|
|
<Select
|
|
|
|
|
|
value={formData.items[activeItemIndex].productCategory}
|
|
|
|
|
|
onValueChange={(value) =>
|
|
|
|
|
|
handleItemChange(activeItemIndex, "productCategory", value)
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<SelectTrigger id={`productCategory-${activeItemIndex}`}>
|
|
|
|
|
|
<SelectValue placeholder="카테고리 선택" />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
{PRODUCT_CATEGORIES.map((cat) => (
|
|
|
|
|
|
<SelectItem key={cat.value} value={cat.value}>
|
|
|
|
|
|
{cat.label}
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</FormField>
|
|
|
|
|
|
</FormFieldGrid>
|
|
|
|
|
|
|
|
|
|
|
|
<FormFieldGrid columns={3}>
|
|
|
|
|
|
<FormField
|
|
|
|
|
|
label="제품명"
|
|
|
|
|
|
type="custom"
|
|
|
|
|
|
required
|
|
|
|
|
|
error={errors[`item-${activeItemIndex}-productName`]}
|
|
|
|
|
|
htmlFor={`productName-${activeItemIndex}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Select
|
|
|
|
|
|
value={formData.items[activeItemIndex].productName}
|
|
|
|
|
|
onValueChange={(value) =>
|
|
|
|
|
|
handleItemChange(activeItemIndex, "productName", value)
|
|
|
|
|
|
}
|
|
|
|
|
|
disabled={isLoadingProducts}
|
|
|
|
|
|
>
|
|
|
|
|
|
<SelectTrigger id={`productName-${activeItemIndex}`}>
|
|
|
|
|
|
<SelectValue placeholder={isLoadingProducts ? "로딩 중..." : "제품을 선택하세요"} />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
{getFilteredProducts(formData.items[activeItemIndex].productCategory)
|
|
|
|
|
|
.filter((product) => product.item_code) // null/undefined 제외
|
|
|
|
|
|
.filter((product, index, self) =>
|
|
|
|
|
|
index === self.findIndex(p => p.item_code === product.item_code)
|
|
|
|
|
|
)
|
|
|
|
|
|
.map((product, index) => (
|
|
|
|
|
|
<SelectItem key={`${product.item_code}-${index}`} value={product.item_code}>
|
|
|
|
|
|
{product.item_name} ({product.item_code})
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</FormField>
|
|
|
|
|
|
|
|
|
|
|
|
<FormField
|
|
|
|
|
|
label="오픈사이즈 (W0)"
|
|
|
|
|
|
type="custom"
|
|
|
|
|
|
required
|
|
|
|
|
|
error={errors[`item-${activeItemIndex}-openWidth`]}
|
|
|
|
|
|
htmlFor={`openWidth-${activeItemIndex}`}
|
2025-12-04 20:52:42 +09:00
|
|
|
|
>
|
2026-01-20 20:41:45 +09:00
|
|
|
|
<Input
|
|
|
|
|
|
id={`openWidth-${activeItemIndex}`}
|
|
|
|
|
|
placeholder="예: 2000"
|
|
|
|
|
|
value={formData.items[activeItemIndex].openWidth}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
handleItemChange(activeItemIndex, "openWidth", e.target.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</FormField>
|
|
|
|
|
|
|
|
|
|
|
|
<FormField
|
|
|
|
|
|
label="오픈사이즈 (H0)"
|
|
|
|
|
|
type="custom"
|
|
|
|
|
|
required
|
|
|
|
|
|
error={errors[`item-${activeItemIndex}-openHeight`]}
|
|
|
|
|
|
htmlFor={`openHeight-${activeItemIndex}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id={`openHeight-${activeItemIndex}`}
|
|
|
|
|
|
placeholder="예: 2500"
|
|
|
|
|
|
value={formData.items[activeItemIndex].openHeight}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
handleItemChange(activeItemIndex, "openHeight", e.target.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</FormField>
|
|
|
|
|
|
</FormFieldGrid>
|
|
|
|
|
|
|
|
|
|
|
|
<FormFieldGrid columns={3}>
|
|
|
|
|
|
<FormField
|
|
|
|
|
|
label="가이드레일 설치 유형 (GT)"
|
|
|
|
|
|
type="custom"
|
|
|
|
|
|
required
|
|
|
|
|
|
error={errors[`item-${activeItemIndex}-guideRailType`]}
|
|
|
|
|
|
htmlFor={`guideRailType-${activeItemIndex}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Select
|
|
|
|
|
|
value={formData.items[activeItemIndex].guideRailType}
|
|
|
|
|
|
onValueChange={(value) =>
|
|
|
|
|
|
handleItemChange(activeItemIndex, "guideRailType", value)
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<SelectTrigger id={`guideRailType-${activeItemIndex}`}>
|
|
|
|
|
|
<SelectValue placeholder="설치 유형 선택" />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
{GUIDE_RAIL_TYPES.map((type) => (
|
|
|
|
|
|
<SelectItem key={type.value} value={type.value}>
|
|
|
|
|
|
{type.label}
|
2026-01-15 16:30:12 +09:00
|
|
|
|
</SelectItem>
|
|
|
|
|
|
))}
|
2026-01-20 20:41:45 +09:00
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</FormField>
|
|
|
|
|
|
|
|
|
|
|
|
<FormField
|
|
|
|
|
|
label="모터 전원 (MP)"
|
|
|
|
|
|
type="custom"
|
|
|
|
|
|
required
|
|
|
|
|
|
error={errors[`item-${activeItemIndex}-motorPower`]}
|
|
|
|
|
|
htmlFor={`motorPower-${activeItemIndex}`}
|
2025-12-04 20:52:42 +09:00
|
|
|
|
>
|
2026-01-20 20:41:45 +09:00
|
|
|
|
<Select
|
|
|
|
|
|
value={formData.items[activeItemIndex].motorPower}
|
|
|
|
|
|
onValueChange={(value) =>
|
|
|
|
|
|
handleItemChange(activeItemIndex, "motorPower", value)
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<SelectTrigger id={`motorPower-${activeItemIndex}`}>
|
|
|
|
|
|
<SelectValue placeholder="전원 선택" />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
{MOTOR_POWERS.map((power) => (
|
|
|
|
|
|
<SelectItem key={power.value} value={power.value}>
|
|
|
|
|
|
{power.label}
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</FormField>
|
|
|
|
|
|
|
|
|
|
|
|
<FormField
|
|
|
|
|
|
label="연동제어기 (CT)"
|
|
|
|
|
|
type="custom"
|
|
|
|
|
|
required
|
|
|
|
|
|
error={errors[`item-${activeItemIndex}-controller`]}
|
|
|
|
|
|
htmlFor={`controller-${activeItemIndex}`}
|
2025-12-04 20:52:42 +09:00
|
|
|
|
>
|
2026-01-20 20:41:45 +09:00
|
|
|
|
<Select
|
|
|
|
|
|
value={formData.items[activeItemIndex].controller}
|
|
|
|
|
|
onValueChange={(value) =>
|
|
|
|
|
|
handleItemChange(activeItemIndex, "controller", value)
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<SelectTrigger id={`controller-${activeItemIndex}`}>
|
|
|
|
|
|
<SelectValue placeholder="제어기 선택" />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
{CONTROLLERS.map((ctrl) => (
|
|
|
|
|
|
<SelectItem key={ctrl.value} value={ctrl.value}>
|
|
|
|
|
|
{ctrl.label}
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</FormField>
|
|
|
|
|
|
</FormFieldGrid>
|
|
|
|
|
|
|
|
|
|
|
|
<FormFieldGrid columns={3}>
|
|
|
|
|
|
<FormField
|
|
|
|
|
|
label="수량 (QTY)"
|
|
|
|
|
|
type="custom"
|
|
|
|
|
|
required
|
|
|
|
|
|
error={errors[`item-${activeItemIndex}-quantity`]}
|
|
|
|
|
|
htmlFor={`quantity-${activeItemIndex}`}
|
2025-12-04 20:52:42 +09:00
|
|
|
|
>
|
2026-01-21 20:56:17 +09:00
|
|
|
|
<QuantityInput
|
2026-01-20 20:41:45 +09:00
|
|
|
|
id={`quantity-${activeItemIndex}`}
|
|
|
|
|
|
value={formData.items[activeItemIndex].quantity}
|
2026-01-21 20:56:17 +09:00
|
|
|
|
onChange={(value) =>
|
|
|
|
|
|
handleItemChange(activeItemIndex, "quantity", value ?? 1)
|
2026-01-20 20:41:45 +09:00
|
|
|
|
}
|
2026-01-21 20:56:17 +09:00
|
|
|
|
min={1}
|
2026-01-20 20:41:45 +09:00
|
|
|
|
/>
|
|
|
|
|
|
</FormField>
|
|
|
|
|
|
|
|
|
|
|
|
<FormField
|
|
|
|
|
|
label="마구리 날개치수 (WS)"
|
|
|
|
|
|
type="custom"
|
|
|
|
|
|
htmlFor={`wingSize-${activeItemIndex}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id={`wingSize-${activeItemIndex}`}
|
|
|
|
|
|
placeholder="예: 50"
|
|
|
|
|
|
value={formData.items[activeItemIndex].wingSize}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
handleItemChange(activeItemIndex, "wingSize", e.target.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</FormField>
|
|
|
|
|
|
|
|
|
|
|
|
<FormField
|
|
|
|
|
|
label="검사비 (INSP)"
|
|
|
|
|
|
type="custom"
|
|
|
|
|
|
htmlFor={`inspectionFee-${activeItemIndex}`}
|
|
|
|
|
|
>
|
2026-01-21 20:56:17 +09:00
|
|
|
|
<CurrencyInput
|
2026-01-20 20:41:45 +09:00
|
|
|
|
id={`inspectionFee-${activeItemIndex}`}
|
|
|
|
|
|
placeholder="예: 50000"
|
|
|
|
|
|
value={formData.items[activeItemIndex].inspectionFee}
|
2026-01-21 20:56:17 +09:00
|
|
|
|
onChange={(value) =>
|
|
|
|
|
|
handleItemChange(activeItemIndex, "inspectionFee", value ?? 0)
|
2026-01-20 20:41:45 +09:00
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</FormField>
|
|
|
|
|
|
</FormFieldGrid>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
2025-12-04 20:52:42 +09:00
|
|
|
|
|
2026-01-20 20:41:45 +09:00
|
|
|
|
{/* 견적 추가 버튼 */}
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
className="w-full"
|
|
|
|
|
|
onClick={handleAddItem}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
|
|
|
|
견적 추가
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 자동 견적 산출 버튼 */}
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="default"
|
|
|
|
|
|
className="w-full bg-blue-600 hover:bg-blue-700"
|
|
|
|
|
|
onClick={handleAutoCalculate}
|
|
|
|
|
|
disabled={isCalculating || isLoadingProducts}
|
|
|
|
|
|
>
|
|
|
|
|
|
{isCalculating ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
|
|
|
|
견적 산출 중...
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Calculator className="h-4 w-4 mr-2" />
|
|
|
|
|
|
자동 견적 산출 ({formData.items.length}개 항목)
|
2025-12-04 20:52:42 +09:00
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-01-20 20:41:45 +09:00
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 견적 산출 결과 표시 */}
|
|
|
|
|
|
{calculationResults && calculationResults.items.length > 0 && (
|
|
|
|
|
|
<Card className="border-green-200 bg-green-50/50">
|
|
|
|
|
|
<CardHeader className="pb-3">
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
|
|
|
|
|
<Calculator className="h-5 w-5 text-green-600" />
|
|
|
|
|
|
견적 산출 결과
|
|
|
|
|
|
</CardTitle>
|
|
|
|
|
|
<Badge variant="default" className="bg-green-600">
|
|
|
|
|
|
총 {calculatedGrandTotal.toLocaleString()}원
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</CardHeader>
|
|
|
|
|
|
<CardContent className="space-y-4">
|
|
|
|
|
|
{/* 항목별 결과 */}
|
|
|
|
|
|
{calculationResults.items.map((itemResult, idx) => {
|
|
|
|
|
|
const formItem = formData.items[itemResult.index];
|
|
|
|
|
|
const product = finishedGoods.find(fg => fg.item_code === formItem?.productName);
|
|
|
|
|
|
|
2026-01-06 21:20:49 +09:00
|
|
|
|
return (
|
|
|
|
|
|
<div key={idx} className="border border-green-200 rounded-lg p-4 bg-white">
|
|
|
|
|
|
<div className="flex items-center justify-between mb-3">
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<Badge variant="outline" className="bg-green-100">
|
|
|
|
|
|
견적 {itemResult.index + 1}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
<span className="font-medium">
|
|
|
|
|
|
{itemResult.result.finished_goods?.name || product?.item_name || formItem?.productName || "-"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="text-sm text-muted-foreground">
|
|
|
|
|
|
({itemResult.result.finished_goods?.code || formItem?.productName || "-"})
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="text-right">
|
|
|
|
|
|
<div className="text-sm text-muted-foreground">
|
2026-01-20 20:38:29 +09:00
|
|
|
|
단가: {(itemResult.result.grand_total || 0).toLocaleString()}원
|
2026-01-20 20:41:45 +09:00
|
|
|
|
</div>
|
2026-01-06 21:20:49 +09:00
|
|
|
|
<div className="font-semibold text-green-700">
|
|
|
|
|
|
합계: {((itemResult.result.grand_total || 0) * (formItem?.quantity || 1)).toLocaleString()}원
|
|
|
|
|
|
<span className="text-xs text-muted-foreground ml-1">
|
|
|
|
|
|
(×{formItem?.quantity || 1})
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-01-20 20:41:45 +09:00
|
|
|
|
{/* BOM 상세 내역 */}
|
|
|
|
|
|
{itemResult.result.items && itemResult.result.items.length > 0 && (
|
|
|
|
|
|
<div className="mt-3">
|
|
|
|
|
|
<details className="group">
|
|
|
|
|
|
<summary className="cursor-pointer text-sm text-muted-foreground hover:text-foreground">
|
|
|
|
|
|
BOM 상세 내역 ({itemResult.result.items.length}개 항목)
|
|
|
|
|
|
</summary>
|
|
|
|
|
|
<div className="mt-2 overflow-x-auto">
|
|
|
|
|
|
<table className="w-full text-sm">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr className="border-b bg-gray-50">
|
|
|
|
|
|
<th className="text-left py-2 px-2">품목코드</th>
|
|
|
|
|
|
<th className="text-left py-2 px-2">품목명</th>
|
|
|
|
|
|
<th className="text-right py-2 px-2">수량</th>
|
|
|
|
|
|
<th className="text-right py-2 px-2">단가</th>
|
|
|
|
|
|
<th className="text-right py-2 px-2">금액</th>
|
|
|
|
|
|
<th className="text-left py-2 px-2">공정</th>
|
2026-01-06 21:20:49 +09:00
|
|
|
|
</tr>
|
2026-01-20 20:41:45 +09:00
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{itemResult.result.items.map((bomItem, bomIdx) => (
|
|
|
|
|
|
<tr key={bomIdx} className="border-b last:border-0">
|
|
|
|
|
|
<td className="py-1.5 px-2 font-mono text-xs">
|
|
|
|
|
|
{bomItem.item_code}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td className="py-1.5 px-2">{bomItem.item_name}</td>
|
|
|
|
|
|
<td className="py-1.5 px-2 text-right">
|
|
|
|
|
|
{bomItem.unit === 'EA'
|
|
|
|
|
|
? Math.round((bomItem.quantity || 0) * (formItem?.quantity || 1))
|
|
|
|
|
|
: parseFloat(((bomItem.quantity || 0) * (formItem?.quantity || 1)).toFixed(2))
|
|
|
|
|
|
} {bomItem.unit || ""}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td className="py-1.5 px-2 text-right">
|
|
|
|
|
|
{bomItem.unit_price?.toLocaleString()}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td className="py-1.5 px-2 text-right font-medium">
|
|
|
|
|
|
{((bomItem.total_price || 0) * (formItem?.quantity || 1)).toLocaleString()}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td className="py-1.5 px-2">
|
|
|
|
|
|
<Badge variant="secondary" className="text-xs">
|
|
|
|
|
|
{bomItem.process_group || "-"}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
2026-01-06 21:20:49 +09:00
|
|
|
|
</div>
|
2026-01-20 20:41:45 +09:00
|
|
|
|
</details>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 공정별 소계 */}
|
|
|
|
|
|
{itemResult.result.subtotals && Object.keys(itemResult.result.subtotals).length > 0 && (
|
|
|
|
|
|
<div className="mt-2 pt-2 border-t">
|
|
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
|
|
|
|
{Object.entries(itemResult.result.subtotals).map(([process, data]) => {
|
|
|
|
|
|
// data는 객체 {name, count, subtotal} 또는 숫자일 수 있음
|
|
|
|
|
|
const subtotalData = data as { name?: string; count?: number; subtotal?: number } | number;
|
|
|
|
|
|
const amount = typeof subtotalData === 'object' ? subtotalData.subtotal : subtotalData;
|
|
|
|
|
|
const name = typeof subtotalData === 'object' ? subtotalData.name : process;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Badge key={process} variant="outline" className="text-xs">
|
|
|
|
|
|
{name || process}: {(amount || 0).toLocaleString()}원
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 총합계 (수량 반영) */}
|
|
|
|
|
|
<div className="border-t pt-4 flex justify-between items-center">
|
|
|
|
|
|
<span className="text-lg font-semibold">총 견적 금액</span>
|
|
|
|
|
|
<span className="text-2xl font-bold text-green-700">
|
|
|
|
|
|
{calculatedGrandTotal.toLocaleString()}원
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</FormSection>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
),
|
|
|
|
|
|
[
|
|
|
|
|
|
formData,
|
|
|
|
|
|
errors,
|
|
|
|
|
|
activeItemIndex,
|
|
|
|
|
|
clients,
|
|
|
|
|
|
isLoadingClients,
|
|
|
|
|
|
isLoadingProducts,
|
|
|
|
|
|
isCalculating,
|
|
|
|
|
|
calculationResults,
|
|
|
|
|
|
calculatedGrandTotal,
|
|
|
|
|
|
siteNames,
|
|
|
|
|
|
finishedGoods,
|
|
|
|
|
|
handleFieldChange,
|
|
|
|
|
|
handleClientChange,
|
|
|
|
|
|
handleItemChange,
|
|
|
|
|
|
handleAddItem,
|
|
|
|
|
|
handleCopyItem,
|
|
|
|
|
|
handleDeleteItem,
|
|
|
|
|
|
handleAutoCalculate,
|
|
|
|
|
|
getFilteredProducts,
|
|
|
|
|
|
]
|
|
|
|
|
|
);
|
2025-12-04 20:52:42 +09:00
|
|
|
|
|
2026-01-20 20:41:45 +09:00
|
|
|
|
return (
|
|
|
|
|
|
<IntegratedDetailTemplate
|
|
|
|
|
|
config={config}
|
|
|
|
|
|
mode={editingQuote ? "edit" : "create"}
|
|
|
|
|
|
isLoading={isLoading}
|
|
|
|
|
|
isSubmitting={isSaving}
|
|
|
|
|
|
onBack={onBack}
|
|
|
|
|
|
onCancel={onBack}
|
|
|
|
|
|
onSubmit={handleSubmit}
|
|
|
|
|
|
renderForm={renderFormContent}
|
|
|
|
|
|
/>
|
2025-12-04 20:52:42 +09:00
|
|
|
|
);
|
2026-01-20 20:41:45 +09:00
|
|
|
|
}
|