이슈 #3: 상세 견적서 담당자/연락처 표시 이슈 #4: 품목내역 올바른 단위 표시 주요 변경: - QuoteDocument.tsx: 품목별 unit 필드 사용하여 올바른 단위 표시 - QuoteRegistration.tsx: manager, contact, remarks 필드 폼에 반영
This commit is contained in:
@@ -11,12 +11,14 @@
|
||||
*/
|
||||
|
||||
import { QuoteFormData } from "./QuoteRegistration";
|
||||
import type { CompanyFormData } from "@/components/settings/CompanyInfoManagement/types";
|
||||
|
||||
interface QuoteDocumentProps {
|
||||
quote: QuoteFormData;
|
||||
companyInfo?: CompanyFormData | null;
|
||||
}
|
||||
|
||||
export function QuoteDocument({ quote }: QuoteDocumentProps) {
|
||||
export function QuoteDocument({ quote, companyInfo }: QuoteDocumentProps) {
|
||||
const formatAmount = (amount: number | undefined) => {
|
||||
if (amount === undefined || amount === null) return '0';
|
||||
return amount.toLocaleString('ko-KR');
|
||||
@@ -34,7 +36,7 @@ export function QuoteDocument({ quote }: QuoteDocumentProps) {
|
||||
itemName: item.productName || '스크린셔터',
|
||||
spec: `${item.openWidth}×${item.openHeight}`,
|
||||
quantity: item.quantity || 1,
|
||||
unit: '개소',
|
||||
unit: item.unit || '', // 각 품목의 단위 사용, 없으면 빈 문자열
|
||||
unitPrice: item.unitPrice || 0,
|
||||
totalPrice: item.totalAmount || 0,
|
||||
})) || [];
|
||||
@@ -292,29 +294,29 @@ export function QuoteDocument({ quote }: QuoteDocumentProps) {
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>상호</th>
|
||||
<td>동호기업</td>
|
||||
<td>{companyInfo?.companyName || '-'}</td>
|
||||
<th>사업자등록번호</th>
|
||||
<td>139-87-00333</td>
|
||||
<td>{companyInfo?.businessNumber || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>대표자</th>
|
||||
<td>이 광 호</td>
|
||||
<td>{companyInfo?.representativeName || '-'}</td>
|
||||
<th>업태</th>
|
||||
<td>제조</td>
|
||||
<td>{companyInfo?.businessType || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>종목</th>
|
||||
<td colSpan={3}>방창, 셔터, 금속성호</td>
|
||||
<td colSpan={3}>{companyInfo?.businessCategory || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>사업장주소</th>
|
||||
<td colSpan={3}>경기도 안성시 공업용지 오성길 45-22</td>
|
||||
<td colSpan={3}>{companyInfo?.address || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>전화</th>
|
||||
<td>031-983-5130</td>
|
||||
<th>팩스</th>
|
||||
<td>02-6911-6315</td>
|
||||
<td>{companyInfo?.managerPhone || '-'}</td>
|
||||
<th>이메일</th>
|
||||
<td>{companyInfo?.email || '-'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -340,7 +342,7 @@ export function QuoteDocument({ quote }: QuoteDocumentProps) {
|
||||
<th>모델</th>
|
||||
<td>{quote.items[0]?.productName || '스크린셔터'}</td>
|
||||
<th>총 수량</th>
|
||||
<td>{quote.items.reduce((sum, item) => sum + (item.quantity || 0), 0)}개소</td>
|
||||
<td>{quote.items[0]?.quantity || ''}{quote.unitSymbol ? ` ${quote.unitSymbol}` : ''}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>오픈사이즈</th>
|
||||
@@ -432,7 +434,7 @@ export function QuoteDocument({ quote }: QuoteDocumentProps) {
|
||||
<div>
|
||||
<div style={{ fontSize: '13px', marginBottom: '5px' }}>{formatDate(quote.registrationDate || '')}</div>
|
||||
<div style={{ fontSize: '15px', fontWeight: '600' }}>
|
||||
공급자: 동호기업 (인)
|
||||
공급자: {companyInfo?.companyName || '-'} (인)
|
||||
</div>
|
||||
</div>
|
||||
<div className="stamp-area">
|
||||
@@ -452,7 +454,7 @@ export function QuoteDocument({ quote }: QuoteDocumentProps) {
|
||||
<p>3. 제작 사양 및 수량 변경 시 견적 금액이 변동될 수 있습니다.</p>
|
||||
<p>4. 현장 여건에 따라 추가 비용이 발생할 수 있습니다.</p>
|
||||
<p style={{ marginTop: '12px', textAlign: 'center', fontWeight: '600' }}>
|
||||
문의: {quote.writer || '담당자'} | {quote.contact || '031-983-5130'}
|
||||
문의: {companyInfo?.managerName || quote.writer || '담당자'} | {companyInfo?.managerPhone || '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { Input } from "../ui/input";
|
||||
import { Textarea } from "../ui/textarea";
|
||||
import {
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
Plus,
|
||||
Copy,
|
||||
Trash2,
|
||||
Sparkles,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -50,6 +50,10 @@ import {
|
||||
FormFieldGrid,
|
||||
} from "../templates/ResponsiveFormTemplate";
|
||||
import { FormField } from "../molecules/FormField";
|
||||
import { getFinishedGoods, calculateBomBulk, getSiteNames, type FinishedGoods, type BomCalculationResult } from "./actions";
|
||||
import { getClients } from "../accounting/VendorManagement/actions";
|
||||
import type { Vendor } from "../accounting/VendorManagement";
|
||||
import type { BomMaterial, CalculationResults } from "./types";
|
||||
|
||||
// 견적 항목 타입
|
||||
export interface QuoteItem {
|
||||
@@ -64,6 +68,7 @@ export interface QuoteItem {
|
||||
motorPower: string; // 모터 전원 (MP)
|
||||
controller: string; // 연동제어기 (CT)
|
||||
quantity: number; // 수량 (QTY)
|
||||
unit?: string; // 품목 단위
|
||||
wingSize: string; // 마구리 날개치수 (WS)
|
||||
inspectionFee: number; // 검사비 (INSP)
|
||||
unitPrice?: number; // 단가
|
||||
@@ -83,7 +88,10 @@ export interface QuoteFormData {
|
||||
contact: string;
|
||||
dueDate: string;
|
||||
remarks: string;
|
||||
unitSymbol?: string; // 단위 (EA, 개소 등) - quotes.unit_symbol
|
||||
items: QuoteItem[];
|
||||
bomMaterials?: BomMaterial[]; // BOM 자재 목록
|
||||
calculationResults?: CalculationResults; // 견적 산출 결과 (저장 시 BOM 자재 변환용)
|
||||
}
|
||||
|
||||
// 초기 견적 항목
|
||||
@@ -117,58 +125,31 @@ export const INITIAL_QUOTE_FORM: QuoteFormData = {
|
||||
items: [createNewItem()],
|
||||
};
|
||||
|
||||
// 샘플 발주처 데이터 (TODO: API에서 가져오기)
|
||||
const SAMPLE_CLIENTS = [
|
||||
{ id: "client-1", name: "인천건설 - 최담당" },
|
||||
{ id: "client-2", name: "ABC건설" },
|
||||
{ id: "client-3", name: "XYZ산업" },
|
||||
];
|
||||
|
||||
// 제품 카테고리 옵션
|
||||
// 제품 카테고리 옵션 (MNG 시뮬레이터와 동일)
|
||||
const PRODUCT_CATEGORIES = [
|
||||
{ value: "screen", label: "스크린" },
|
||||
{ value: "steel", label: "철재" },
|
||||
{ value: "aluminum", label: "알루미늄" },
|
||||
{ value: "etc", label: "기타" },
|
||||
{ value: "ALL", label: "전체" },
|
||||
{ value: "SCREEN", label: "스크린" },
|
||||
{ value: "STEEL", label: "철재" },
|
||||
{ value: "BENDING", label: "절곡" },
|
||||
{ value: "ALUMINUM", label: "알루미늄" },
|
||||
];
|
||||
|
||||
// 제품명 옵션 (카테고리별)
|
||||
const PRODUCTS: Record<string, { value: string; label: string }[]> = {
|
||||
screen: [
|
||||
{ value: "SCR-001", label: "스크린 A형" },
|
||||
{ value: "SCR-002", label: "스크린 B형" },
|
||||
{ value: "SCR-003", label: "스크린 C형" },
|
||||
],
|
||||
steel: [
|
||||
{ value: "STL-001", label: "철재 도어 A" },
|
||||
{ value: "STL-002", label: "철재 도어 B" },
|
||||
],
|
||||
aluminum: [
|
||||
{ value: "ALU-001", label: "알루미늄 프레임" },
|
||||
],
|
||||
etc: [
|
||||
{ value: "ETC-001", label: "기타 제품" },
|
||||
],
|
||||
};
|
||||
|
||||
// 가이드레일 설치 유형
|
||||
// 가이드레일 설치 유형 (API: wall, ceiling, floor)
|
||||
const GUIDE_RAIL_TYPES = [
|
||||
{ value: "wall", label: "벽부착형" },
|
||||
{ value: "ceiling", label: "천장매립형" },
|
||||
{ value: "floor", label: "바닥매립형" },
|
||||
{ value: "wall", label: "벽면형" },
|
||||
{ value: "floor", label: "측면형" },
|
||||
];
|
||||
|
||||
// 모터 전원
|
||||
// 모터 전원 (API: single=단상220V, three=삼상380V)
|
||||
const MOTOR_POWERS = [
|
||||
{ value: "single", label: "단상 220V" },
|
||||
{ value: "three", label: "삼상 380V" },
|
||||
{ value: "single", label: "220V (단상)" },
|
||||
{ value: "three", label: "380V (삼상)" },
|
||||
];
|
||||
|
||||
// 연동제어기
|
||||
// 연동제어기 (API: basic, smart, premium)
|
||||
const CONTROLLERS = [
|
||||
{ value: "basic", label: "기본 제어기" },
|
||||
{ value: "smart", label: "스마트 제어기" },
|
||||
{ value: "premium", label: "프리미엄 제어기" },
|
||||
{ value: "basic", label: "단독" },
|
||||
{ value: "smart", label: "연동" },
|
||||
];
|
||||
|
||||
interface QuoteRegistrationProps {
|
||||
@@ -191,13 +172,118 @@ export function QuoteRegistration({
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [activeItemIndex, setActiveItemIndex] = useState(0);
|
||||
|
||||
// editingQuote가 변경되면 formData 업데이트
|
||||
// 완제품 목록 상태 (API에서 로드)
|
||||
const [finishedGoods, setFinishedGoods] = useState<FinishedGoods[]>([]);
|
||||
const [isLoadingProducts, setIsLoadingProducts] = useState(false);
|
||||
const [isCalculating, setIsCalculating] = useState(false);
|
||||
|
||||
// 거래처 목록 상태 (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[]>([]);
|
||||
|
||||
// 수량 반영 총합계 계산 (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]);
|
||||
|
||||
// 컴포넌트 마운트 시 완제품 목록 로드
|
||||
useEffect(() => {
|
||||
const loadFinishedGoods = async () => {
|
||||
setIsLoadingProducts(true);
|
||||
try {
|
||||
const result = await getFinishedGoods();
|
||||
if (result.success) {
|
||||
setFinishedGoods(result.data);
|
||||
} else {
|
||||
toast.error(`완제품 목록 로드 실패: ${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("완제품 목록을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setIsLoadingProducts(false);
|
||||
}
|
||||
};
|
||||
loadFinishedGoods();
|
||||
}, []);
|
||||
|
||||
// 컴포넌트 마운트 시 거래처 목록 로드
|
||||
useEffect(() => {
|
||||
const loadClients = async () => {
|
||||
setIsLoadingClients(true);
|
||||
try {
|
||||
const result = await getClients();
|
||||
if (result.success) {
|
||||
setClients(result.data);
|
||||
} else {
|
||||
toast.error(`거래처 목록 로드 실패: ${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("거래처 목록을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setIsLoadingClients(false);
|
||||
}
|
||||
};
|
||||
loadClients();
|
||||
}, []);
|
||||
|
||||
// 컴포넌트 마운트 시 현장명 목록 로드 (자동완성용)
|
||||
useEffect(() => {
|
||||
const loadSiteNames = async () => {
|
||||
try {
|
||||
const result = await getSiteNames();
|
||||
if (result.success) {
|
||||
setSiteNames(result.data);
|
||||
}
|
||||
} catch (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]);
|
||||
|
||||
// 카테고리별 완제품 필터링
|
||||
const getFilteredProducts = (category: string) => {
|
||||
if (!category || category === "ALL") {
|
||||
return finishedGoods; // 전체 선택 시 모든 완제품
|
||||
}
|
||||
return finishedGoods.filter(fg => fg.item_category === category);
|
||||
};
|
||||
|
||||
// 유효성 검사
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
@@ -249,7 +335,13 @@ export function QuoteRegistration({
|
||||
setErrors({});
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSave(formData);
|
||||
// calculationResults를 formData에 포함하여 저장
|
||||
// transformFormDataToApi에서 BOM 자재의 base_quantity, calculated_quantity를 제대로 설정하기 위함
|
||||
const dataToSave: QuoteFormData = {
|
||||
...formData,
|
||||
calculationResults: calculationResults || undefined,
|
||||
};
|
||||
await onSave(dataToSave);
|
||||
toast.success(
|
||||
editingQuote ? "견적이 수정되었습니다." : "견적이 등록되었습니다."
|
||||
);
|
||||
@@ -265,6 +357,10 @@ export function QuoteRegistration({
|
||||
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) => {
|
||||
@@ -277,11 +373,11 @@ export function QuoteRegistration({
|
||||
|
||||
// 발주처 선택
|
||||
const handleClientChange = (clientId: string) => {
|
||||
const client = SAMPLE_CLIENTS.find((c) => c.id === clientId);
|
||||
const client = clients.find((c) => c.id === clientId);
|
||||
setFormData({
|
||||
...formData,
|
||||
clientId,
|
||||
clientName: client?.name || "",
|
||||
clientName: client?.vendorName || "",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -347,14 +443,90 @@ export function QuoteRegistration({
|
||||
};
|
||||
|
||||
// 자동 견적 산출
|
||||
const handleAutoCalculate = () => {
|
||||
toast.info(`자동 견적 산출 (${formData.items.length}개 항목) - API 연동 필요`);
|
||||
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) {
|
||||
console.error("견적 산출 오류:", error);
|
||||
toast.error("견적 산출 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsCalculating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 샘플 데이터 생성
|
||||
const handleGenerateSample = () => {
|
||||
toast.info("완벽한 샘플 생성 - API 연동 필요");
|
||||
};
|
||||
// 렌더링 직전 디버그 로그
|
||||
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));
|
||||
|
||||
return (
|
||||
<ResponsiveFormTemplate
|
||||
@@ -415,7 +587,7 @@ export function QuoteRegistration({
|
||||
icon={FileText}
|
||||
>
|
||||
<FormFieldGrid columns={3}>
|
||||
<FormField label="등록일" htmlFor="registrationDate">
|
||||
<FormField label="등록일" htmlFor="registrationDate" type="custom">
|
||||
<Input
|
||||
id="registrationDate"
|
||||
type="date"
|
||||
@@ -436,6 +608,7 @@ export function QuoteRegistration({
|
||||
|
||||
<FormField
|
||||
label="발주처 선택"
|
||||
type="custom"
|
||||
required
|
||||
error={errors.clientId}
|
||||
htmlFor="clientId"
|
||||
@@ -443,14 +616,15 @@ export function QuoteRegistration({
|
||||
<Select
|
||||
value={formData.clientId}
|
||||
onValueChange={handleClientChange}
|
||||
disabled={isLoadingClients}
|
||||
>
|
||||
<SelectTrigger id="clientId">
|
||||
<SelectValue placeholder="발주처를 선택하세요" />
|
||||
<SelectValue placeholder={isLoadingClients ? "로딩 중..." : "발주처를 선택하세요"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SAMPLE_CLIENTS.map((client) => (
|
||||
{clients.map((client) => (
|
||||
<SelectItem key={client.id} value={client.id}>
|
||||
{client.name}
|
||||
{client.vendorName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -459,16 +633,22 @@ export function QuoteRegistration({
|
||||
</FormFieldGrid>
|
||||
|
||||
<FormFieldGrid columns={3}>
|
||||
<FormField label="현장명" htmlFor="siteName">
|
||||
<FormField label="현장명" htmlFor="siteName" type="custom">
|
||||
<Input
|
||||
id="siteName"
|
||||
placeholder="현장명을 입력하세요"
|
||||
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">
|
||||
<FormField label="발주 담당자" htmlFor="manager" type="custom">
|
||||
<Input
|
||||
id="manager"
|
||||
placeholder="담당자명을 입력하세요"
|
||||
@@ -477,7 +657,7 @@ export function QuoteRegistration({
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="연락처" htmlFor="contact">
|
||||
<FormField label="연락처" htmlFor="contact" type="custom">
|
||||
<Input
|
||||
id="contact"
|
||||
placeholder="010-1234-5678"
|
||||
@@ -488,7 +668,7 @@ export function QuoteRegistration({
|
||||
</FormFieldGrid>
|
||||
|
||||
<FormFieldGrid columns={3}>
|
||||
<FormField label="납기일" htmlFor="dueDate">
|
||||
<FormField label="납기일" htmlFor="dueDate" type="custom">
|
||||
<Input
|
||||
id="dueDate"
|
||||
type="date"
|
||||
@@ -499,7 +679,7 @@ export function QuoteRegistration({
|
||||
<div className="col-span-2" />
|
||||
</FormFieldGrid>
|
||||
|
||||
<FormField label="비고" htmlFor="remarks">
|
||||
<FormField label="비고" htmlFor="remarks" type="custom">
|
||||
<Textarea
|
||||
id="remarks"
|
||||
placeholder="특이사항을 입력하세요"
|
||||
@@ -559,7 +739,7 @@ export function QuoteRegistration({
|
||||
{formData.items[activeItemIndex] && (
|
||||
<>
|
||||
<FormFieldGrid columns={3}>
|
||||
<FormField label="층수" htmlFor={`floor-${activeItemIndex}`}>
|
||||
<FormField label="층수" htmlFor={`floor-${activeItemIndex}`} type="custom">
|
||||
<Input
|
||||
id={`floor-${activeItemIndex}`}
|
||||
placeholder="예: 1층, B1, 지하1층"
|
||||
@@ -570,7 +750,7 @@ export function QuoteRegistration({
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="부호" htmlFor={`code-${activeItemIndex}`}>
|
||||
<FormField label="부호" htmlFor={`code-${activeItemIndex}`} type="custom">
|
||||
<Input
|
||||
id={`code-${activeItemIndex}`}
|
||||
placeholder="예: A, B, C"
|
||||
@@ -583,6 +763,7 @@ export function QuoteRegistration({
|
||||
|
||||
<FormField
|
||||
label="제품 카테고리 (PC)"
|
||||
type="custom"
|
||||
required
|
||||
error={errors[`item-${activeItemIndex}-productCategory`]}
|
||||
htmlFor={`productCategory-${activeItemIndex}`}
|
||||
@@ -610,6 +791,7 @@ export function QuoteRegistration({
|
||||
<FormFieldGrid columns={3}>
|
||||
<FormField
|
||||
label="제품명"
|
||||
type="custom"
|
||||
required
|
||||
error={errors[`item-${activeItemIndex}-productName`]}
|
||||
htmlFor={`productName-${activeItemIndex}`}
|
||||
@@ -619,15 +801,15 @@ export function QuoteRegistration({
|
||||
onValueChange={(value) =>
|
||||
handleItemChange(activeItemIndex, "productName", value)
|
||||
}
|
||||
disabled={!formData.items[activeItemIndex].productCategory}
|
||||
disabled={isLoadingProducts}
|
||||
>
|
||||
<SelectTrigger id={`productName-${activeItemIndex}`}>
|
||||
<SelectValue placeholder="제품을 선택하세요" />
|
||||
<SelectValue placeholder={isLoadingProducts ? "로딩 중..." : "제품을 선택하세요"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(PRODUCTS[formData.items[activeItemIndex].productCategory] || []).map((product) => (
|
||||
<SelectItem key={product.value} value={product.value}>
|
||||
{product.label}
|
||||
{getFilteredProducts(formData.items[activeItemIndex].productCategory).map((product) => (
|
||||
<SelectItem key={product.item_code} value={product.item_code}>
|
||||
{product.item_name} ({product.item_code})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -636,6 +818,7 @@ export function QuoteRegistration({
|
||||
|
||||
<FormField
|
||||
label="오픈사이즈 (W0)"
|
||||
type="custom"
|
||||
required
|
||||
error={errors[`item-${activeItemIndex}-openWidth`]}
|
||||
htmlFor={`openWidth-${activeItemIndex}`}
|
||||
@@ -652,6 +835,7 @@ export function QuoteRegistration({
|
||||
|
||||
<FormField
|
||||
label="오픈사이즈 (H0)"
|
||||
type="custom"
|
||||
required
|
||||
error={errors[`item-${activeItemIndex}-openHeight`]}
|
||||
htmlFor={`openHeight-${activeItemIndex}`}
|
||||
@@ -670,6 +854,7 @@ export function QuoteRegistration({
|
||||
<FormFieldGrid columns={3}>
|
||||
<FormField
|
||||
label="가이드레일 설치 유형 (GT)"
|
||||
type="custom"
|
||||
required
|
||||
error={errors[`item-${activeItemIndex}-guideRailType`]}
|
||||
htmlFor={`guideRailType-${activeItemIndex}`}
|
||||
@@ -695,6 +880,7 @@ export function QuoteRegistration({
|
||||
|
||||
<FormField
|
||||
label="모터 전원 (MP)"
|
||||
type="custom"
|
||||
required
|
||||
error={errors[`item-${activeItemIndex}-motorPower`]}
|
||||
htmlFor={`motorPower-${activeItemIndex}`}
|
||||
@@ -720,6 +906,7 @@ export function QuoteRegistration({
|
||||
|
||||
<FormField
|
||||
label="연동제어기 (CT)"
|
||||
type="custom"
|
||||
required
|
||||
error={errors[`item-${activeItemIndex}-controller`]}
|
||||
htmlFor={`controller-${activeItemIndex}`}
|
||||
@@ -747,6 +934,7 @@ export function QuoteRegistration({
|
||||
<FormFieldGrid columns={3}>
|
||||
<FormField
|
||||
label="수량 (QTY)"
|
||||
type="custom"
|
||||
required
|
||||
error={errors[`item-${activeItemIndex}-quantity`]}
|
||||
htmlFor={`quantity-${activeItemIndex}`}
|
||||
@@ -764,6 +952,7 @@ export function QuoteRegistration({
|
||||
|
||||
<FormField
|
||||
label="마구리 날개치수 (WS)"
|
||||
type="custom"
|
||||
htmlFor={`wingSize-${activeItemIndex}`}
|
||||
>
|
||||
<Input
|
||||
@@ -778,6 +967,7 @@ export function QuoteRegistration({
|
||||
|
||||
<FormField
|
||||
label="검사비 (INSP)"
|
||||
type="custom"
|
||||
htmlFor={`inspectionFee-${activeItemIndex}`}
|
||||
>
|
||||
<Input
|
||||
@@ -811,49 +1001,154 @@ export function QuoteRegistration({
|
||||
variant="default"
|
||||
className="w-full bg-blue-600 hover:bg-blue-700"
|
||||
onClick={handleAutoCalculate}
|
||||
disabled={isCalculating || isLoadingProducts}
|
||||
>
|
||||
자동 견적 산출 ({formData.items.length}개 항목)
|
||||
{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.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>
|
||||
|
||||
{/* 3. 샘플 데이터 (개발용) */}
|
||||
<Card className="border-blue-200 bg-blue-50/50">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
견적 산출 샘플 데이터 (완전판)
|
||||
</CardTitle>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
자동 견적 산출 기능을 테스트하기 위한 완벽한 샘플 데이터를 생성합니다.
|
||||
완제품 14종(스크린 5종, 철재 5종, 절곡 4종), 반제품 40종, 부자재 25종, 원자재 20종이
|
||||
생성되며, 모든 제품에 실제 BOM 구조(2~3단계 계층)와 단가 정보가 포함되어
|
||||
즉시 견적 산출이 가능합니다.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleGenerateSample}
|
||||
className="bg-white"
|
||||
>
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
완벽한 샘플 생성
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="secondary">완제품 14종</Badge>
|
||||
<Badge variant="secondary">반제품 40종</Badge>
|
||||
<Badge variant="secondary">부자재 25종</Badge>
|
||||
<Badge variant="secondary">원자재 20종</Badge>
|
||||
<Badge variant="outline">BOM 2~3단계 계층 ▾</Badge>
|
||||
<Badge variant="outline">단가 정보 포함 ▾</Badge>
|
||||
<Badge variant="outline">절곡 제품 포함 ▾</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ResponsiveFormTemplate>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user