Files
sam-react-prod/src/components/quotes/QuoteRegistration.tsx

1250 lines
48 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 견적 등록/수정 컴포넌트
*
* IntegratedDetailTemplate 마이그레이션 (2026-01-20)
* - 기본 정보 섹션
* - 자동 견적 산출 섹션 (동적 항목 추가/삭제)
*/
"use client";
import { useState, useEffect, useMemo, useCallback } from "react";
import { Input } from "../ui/input";
import { Textarea } from "../ui/textarea";
import { QuantityInput } from "../ui/quantity-input";
import { CurrencyInput } from "../ui/currency-input";
import { PhoneInput } from "../ui/phone-input";
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";
import { Alert, AlertDescription } from "../ui/alert";
import {
FileText,
Calculator,
Plus,
Copy,
Trash2,
Loader2,
} from "lucide-react";
import { toast } from "sonner";
// 필드명 매핑
const FIELD_NAME_MAP: Record<string, string> = {
clientId: "발주처",
productCategory: "제품 카테고리",
productName: "제품명",
openWidth: "오픈사이즈(W)",
openHeight: "오픈사이즈(H)",
guideRailType: "가이드레일 설치 유형",
motorPower: "모터 전원",
controller: "연동제어기",
quantity: "수량",
};
import { IntegratedDetailTemplate } from "@/components/templates/IntegratedDetailTemplate";
import { quoteRegistrationCreateConfig, quoteRegistrationEditConfig } from "./quoteConfig";
import { FormSection } from "@/components/organisms/FormSection";
import { FormFieldGrid } from "@/components/organisms/FormFieldGrid";
import { FormField } from "../molecules/FormField";
import { getFinishedGoods, calculateBomBulk, getSiteNames, type FinishedGoods, type BomCalculationResult } from "./actions";
import { getClients } from "../accounting/VendorManagement/actions";
import { isNextRedirectError } from "@/lib/utils/redirect-error";
import type { Vendor } from "../accounting/VendorManagement";
import type { BomMaterial, CalculationResults } from "./types";
import { useDevFill } from "@/components/dev";
import { generateQuoteData } from "@/components/dev/generators/quoteData";
// 견적 항목 타입
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)
unit?: string; // 품목 단위
wingSize: string; // 마구리 날개치수 (WS)
inspectionFee: number; // 검사비 (INSP)
unitPrice?: number; // 단가
totalAmount?: number; // 합계
installType?: string; // 설치유형
}
// 견적 폼 데이터 타입
export interface QuoteFormData {
id?: string;
registrationDate: string;
writer: string;
clientId: string;
clientName: string;
siteName: string; // 현장명 (직접 입력)
manager: string;
contact: string;
dueDate: string;
remarks: string;
unitSymbol?: string; // 단위 (EA, 개소 등) - quotes.unit_symbol
items: QuoteItem[];
bomMaterials?: BomMaterial[]; // BOM 자재 목록
calculationResults?: CalculationResults; // 견적 산출 결과 (저장 시 BOM 자재 변환용)
}
// 초기 견적 항목
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 = {
registrationDate: new Date().toISOString().split("T")[0],
writer: "드미트리", // TODO: 로그인 사용자 정보로 대체
clientId: "",
clientName: "",
siteName: "", // 현장명 (직접 입력)
manager: "",
contact: "",
dueDate: "",
remarks: "",
items: [createNewItem()],
};
// 제품 카테고리 옵션 (MNG 시뮬레이터와 동일)
const PRODUCT_CATEGORIES = [
{ value: "ALL", label: "전체" },
{ value: "SCREEN", label: "스크린" },
{ value: "STEEL", label: "철재" },
{ value: "BENDING", label: "절곡" },
{ value: "ALUMINUM", label: "알루미늄" },
];
// 가이드레일 설치 유형 (API: wall, ceiling, floor)
const GUIDE_RAIL_TYPES = [
{ value: "wall", label: "벽면형" },
{ value: "floor", label: "측면형" },
];
// 모터 전원 (API: single=단상220V, three=삼상380V)
const MOTOR_POWERS = [
{ value: "single", label: "220V (단상)" },
{ value: "three", label: "380V (삼상)" },
];
// 연동제어기 (API: basic, smart, premium)
const CONTROLLERS = [
{ value: "basic", label: "단독" },
{ value: "smart", label: "연동" },
];
interface QuoteRegistrationProps {
onBack: () => void;
onSave: (quote: QuoteFormData) => Promise<void>;
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);
// Config 선택
const config = editingQuote ? quoteRegistrationEditConfig : quoteRegistrationCreateConfig;
// 완제품 목록 상태 (API에서 로드)
const [finishedGoods, setFinishedGoods] = useState<FinishedGoods[]>([]);
const [isLoadingProducts, setIsLoadingProducts] = useState(false);
const [isCalculating, setIsCalculating] = useState(false);
// 카테고리별 완제품 캐시 (API 재호출 최소화)
const [categoryProductsCache, setCategoryProductsCache] = useState<Record<string, FinishedGoods[]>>({});
// 거래처 목록 상태 (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[]>([]);
// DevToolbar용 폼 자동 채우기 등록
useDevFill(
'quote',
useCallback(() => {
// 실제 로드된 데이터를 기반으로 샘플 데이터 생성
const sampleData = generateQuoteData({
clients: clients.map(c => ({ id: c.id, name: c.vendorName })),
products: finishedGoods.map(p => ({ code: p.item_code, name: p.item_name, category: p.category })),
});
setFormData(sampleData);
toast.success('[Dev] 견적 폼이 자동으로 채워졌습니다.');
}, [clients, finishedGoods])
);
// 수량 반영 총합계 계산 (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]);
// 컴포넌트 마운트 시 완제품 목록 로드 (초기 로드는 size 제한 없이 - 카테고리별 호출로 대체됨)
useEffect(() => {
const loadInitialProducts = async () => {
setIsLoadingProducts(true);
try {
// 초기에는 ALL 카테고리로 로드 (size 제한 내에서)
const result = await getFinishedGoods();
if (result.success) {
setFinishedGoods(result.data);
// 캐시에도 저장
setCategoryProductsCache(prev => ({
...prev,
"ALL": result.data
}));
} else {
toast.error(`완제품 목록 로드 실패: ${result.error}`);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
toast.error("완제품 목록을 불러오는데 실패했습니다.");
} finally {
setIsLoadingProducts(false);
}
};
loadInitialProducts();
}, []);
// 컴포넌트 마운트 시 거래처 목록 로드
useEffect(() => {
const loadClients = async () => {
setIsLoadingClients(true);
try {
const result = await getClients();
if (result.success) {
setClients(result.data);
} else {
toast.error(`거래처 목록 로드 실패: ${result.error}`);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
toast.error("거래처 목록을 불러오는데 실패했습니다.");
} finally {
setIsLoadingClients(false);
}
};
loadClients();
}, []);
// 컴포넌트 마운트 시 현장명 목록 로드 (자동완성용)
useEffect(() => {
const loadSiteNames = async () => {
try {
const result = await getSiteNames();
if (result.success) {
setSiteNames(result.data);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
// 현장명 로드 실패는 무시 (선택적 기능)
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));
if (editingQuote) {
setFormData(editingQuote);
// 수정 모드 진입 시 이전 산출 결과 초기화
setCalculationResults(null);
}
}, [editingQuote]);
// 카테고리별 완제품 로드 (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);
}
};
// 카테고리별 완제품 조회 (캐시 기반)
const getFilteredProducts = (category: string) => {
if (!category || category === "ALL") {
// 전체 선택 시 캐시된 ALL 데이터 또는 초기 finishedGoods
return categoryProductsCache["ALL"] || finishedGoods;
}
// 카테고리별 캐시 반환
return categoryProductsCache[category] || [];
};
// 유효성 검사
const validateForm = useCallback((): boolean => {
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;
}, [formData]);
const handleSubmit = useCallback(async () => {
if (!validateForm()) {
// 페이지 상단으로 스크롤
window.scrollTo({ top: 0, behavior: 'smooth' });
return;
}
// 에러 초기화
setErrors({});
setIsSaving(true);
try {
// calculationResults를 formData에 포함하여 저장
// transformFormDataToApi에서 BOM 자재의 base_quantity, calculated_quantity를 제대로 설정하기 위함
const dataToSave: QuoteFormData = {
...formData,
calculationResults: calculationResults || undefined,
};
await onSave(dataToSave);
toast.success(
editingQuote ? "견적이 수정되었습니다." : "견적이 등록되었습니다."
);
onBack();
} catch (error) {
if (isNextRedirectError(error)) throw error;
toast.error("저장 중 오류가 발생했습니다.");
} finally {
setIsSaving(false);
}
}, [formData, calculationResults, validateForm, onSave, editingQuote, onBack]);
const handleFieldChange = (
field: keyof QuoteFormData,
value: string | QuoteItem[]
) => {
// DEBUG: manager, contact, remarks 필드 변경 추적
if (field === 'manager' || field === 'contact' || field === 'remarks') {
console.log(`[handleFieldChange] ${field} 변경:`, value);
}
setFormData({ ...formData, [field]: value });
if (errors[field]) {
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[field];
return newErrors;
});
}
};
// 발주처 선택
const handleClientChange = (clientId: string) => {
const client = clients.find((c) => c.id === clientId);
setFormData({
...formData,
clientId,
clientName: client?.vendorName || "",
});
};
// 견적 항목 변경
const handleItemChange = (
index: number,
field: keyof QuoteItem,
value: string | number
) => {
const newItems = [...formData.items];
newItems[index] = { ...newItems[index], [field]: value };
// 제품 카테고리 변경 시 제품명 초기화 및 해당 카테고리 제품 로드
if (field === "productCategory" && typeof value === "string") {
newItems[index].productName = "";
// 해당 카테고리 제품 목록 API 호출 (캐시 없으면)
loadProductsByCategory(value);
}
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("견적 항목이 삭제되었습니다.");
};
// 자동 견적 산출
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 || [];
// 계산 결과를 폼 데이터에 반영
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) {
if (isNextRedirectError(error)) throw error;
console.error("견적 산출 오류:", error);
toast.error("견적 산출 중 오류가 발생했습니다.");
} finally {
setIsCalculating(false);
}
};
// 렌더링 직전 디버그 로그
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));
// 폼 콘텐츠 렌더링
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>
</div>
</AlertDescription>
</Alert>
)}
{/* 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"
>
<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} />
))}
</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">
<PhoneInput
id="contact"
placeholder="010-1234-5678"
value={formData.contact}
onChange={(value) => handleFieldChange("contact", value)}
/>
</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}
/>
</FormField>
</FormSection>
{/* 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">
<Button
variant="ghost"
size="icon"
onClick={() => handleCopyItem(activeItemIndex)}
title="복사"
>
<Copy className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteItem(activeItemIndex)}
title="삭제"
className="text-red-500 hover:text-red-600"
disabled={formData.items.length === 1}
>
<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}`}
>
<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}`}
>
<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}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
<FormField
label="모터 전원 (MP)"
type="custom"
required
error={errors[`item-${activeItemIndex}-motorPower`]}
htmlFor={`motorPower-${activeItemIndex}`}
>
<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}`}
>
<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}`}
>
<QuantityInput
id={`quantity-${activeItemIndex}`}
value={formData.items[activeItemIndex].quantity}
onChange={(value) =>
handleItemChange(activeItemIndex, "quantity", value ?? 1)
}
min={1}
/>
</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}`}
>
<CurrencyInput
id={`inspectionFee-${activeItemIndex}`}
placeholder="예: 50000"
value={formData.items[activeItemIndex].inspectionFee}
onChange={(value) =>
handleItemChange(activeItemIndex, "inspectionFee", value ?? 0)
}
/>
</FormField>
</FormFieldGrid>
</>
)}
</CardContent>
</Card>
{/* 견적 추가 버튼 */}
<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} )
</>
)}
</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);
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">
: {(itemResult.result.grand_total || 0).toLocaleString()}
</div>
<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>
{/* 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>
</tr>
</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>
</div>
</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,
]
);
return (
<IntegratedDetailTemplate
config={config}
mode={editingQuote ? "edit" : "create"}
isLoading={isLoading}
isSubmitting={isSaving}
onBack={onBack}
onCancel={onBack}
onSubmit={handleSubmit}
renderForm={renderFormContent}
/>
);
}