Files
sam-react-prod/src/components/quotes/QuoteRegistration.tsx
byeongcheolryu f0e8e51d06 feat: 생산/품질/자재/출고/주문 관리 페이지 구현
- 생산관리: 대시보드, 작업지시, 작업실적, 작업자화면
- 품질관리: 검사관리 (리스트/등록/상세)
- 자재관리: 입고관리, 재고현황
- 출고관리: 출하관리 (리스트/등록/상세/수정)
- 주문관리: 수주관리, 생산의뢰
- 기존 컴포넌트 개선: CardTransactionInquiry, VendorDetail, QuoteRegistration
- IntegratedListTemplateV2 개선
- 공통 컴포넌트 분석 문서 추가

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 21:13:07 +09:00

859 lines
29 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.

/**
* 견적 등록/수정 컴포넌트
*
* ResponsiveFormTemplate 적용
* - 기본 정보 섹션
* - 자동 견적 산출 섹션 (동적 항목 추가/삭제)
*/
"use client";
import { useState, useEffect } from "react";
import { Input } from "../ui/input";
import { Textarea } from "../ui/textarea";
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,
Sparkles,
} 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 {
ResponsiveFormTemplate,
FormSection,
FormFieldGrid,
} from "../templates/ResponsiveFormTemplate";
import { FormField } from "../molecules/FormField";
// 견적 항목 타입
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)
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;
items: QuoteItem[];
}
// 초기 견적 항목
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()],
};
// 샘플 발주처 데이터 (TODO: API에서 가져오기)
const SAMPLE_CLIENTS = [
{ id: "client-1", name: "인천건설 - 최담당" },
{ id: "client-2", name: "ABC건설" },
{ id: "client-3", name: "XYZ산업" },
];
// 제품 카테고리 옵션
const PRODUCT_CATEGORIES = [
{ value: "screen", label: "스크린" },
{ value: "steel", label: "철재" },
{ value: "aluminum", label: "알루미늄" },
{ value: "etc", 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: "기타 제품" },
],
};
// 가이드레일 설치 유형
const GUIDE_RAIL_TYPES = [
{ value: "wall", label: "벽부착형" },
{ value: "ceiling", label: "천장매립형" },
{ value: "floor", label: "바닥매립형" },
];
// 모터 전원
const MOTOR_POWERS = [
{ value: "single", label: "단상 220V" },
{ value: "three", label: "삼상 380V" },
];
// 연동제어기
const CONTROLLERS = [
{ value: "basic", label: "기본 제어기" },
{ value: "smart", label: "스마트 제어기" },
{ value: "premium", 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);
// editingQuote가 변경되면 formData 업데이트
useEffect(() => {
if (editingQuote) {
setFormData(editingQuote);
}
}, [editingQuote]);
// 유효성 검사
const validateForm = (): 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;
};
const handleSubmit = async () => {
if (!validateForm()) {
// 페이지 상단으로 스크롤
window.scrollTo({ top: 0, behavior: 'smooth' });
return;
}
// 에러 초기화
setErrors({});
setIsSaving(true);
try {
await onSave(formData);
toast.success(
editingQuote ? "견적이 수정되었습니다." : "견적이 등록되었습니다."
);
onBack();
} catch (error) {
toast.error("저장 중 오류가 발생했습니다.");
} finally {
setIsSaving(false);
}
};
const handleFieldChange = (
field: keyof QuoteFormData,
value: string | QuoteItem[]
) => {
setFormData({ ...formData, [field]: value });
if (errors[field]) {
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[field];
return newErrors;
});
}
};
// 발주처 선택
const handleClientChange = (clientId: string) => {
const client = SAMPLE_CLIENTS.find((c) => c.id === clientId);
setFormData({
...formData,
clientId,
clientName: client?.name || "",
});
};
// 견적 항목 변경
const handleItemChange = (
index: number,
field: keyof QuoteItem,
value: string | number
) => {
const newItems = [...formData.items];
newItems[index] = { ...newItems[index], [field]: value };
// 제품 카테고리 변경 시 제품명 초기화
if (field === "productCategory") {
newItems[index].productName = "";
}
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 = () => {
toast.info(`자동 견적 산출 (${formData.items.length}개 항목) - API 연동 필요`);
};
// 샘플 데이터 생성
const handleGenerateSample = () => {
toast.info("완벽한 샘플 생성 - API 연동 필요");
};
return (
<ResponsiveFormTemplate
title={editingQuote ? "견적 수정" : "견적 등록"}
description=""
icon={FileText}
onSave={handleSubmit}
onCancel={onBack}
saveLabel="저장"
cancelLabel="취소"
isEditMode={!!editingQuote}
saveLoading={isSaving || isLoading}
saveDisabled={isSaving || isLoading}
maxWidth="2xl"
>
{/* Validation 에러 표시 */}
{Object.keys(errors).length > 0 && (
<Alert className="bg-red-50 border-red-200 mb-6">
<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">
<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="발주처 선택"
required
error={errors.clientId}
htmlFor="clientId"
>
<Select
value={formData.clientId}
onValueChange={handleClientChange}
>
<SelectTrigger id="clientId">
<SelectValue placeholder="발주처를 선택하세요" />
</SelectTrigger>
<SelectContent>
{SAMPLE_CLIENTS.map((client) => (
<SelectItem key={client.id} value={client.id}>
{client.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
</FormFieldGrid>
<FormFieldGrid columns={3}>
<FormField label="현장명" htmlFor="siteName">
<Input
id="siteName"
placeholder="현장명을 입력하세요"
value={formData.siteName}
onChange={(e) => handleFieldChange("siteName", e.target.value)}
/>
</FormField>
<FormField label="발주 담당자" htmlFor="manager">
<Input
id="manager"
placeholder="담당자명을 입력하세요"
value={formData.manager}
onChange={(e) => handleFieldChange("manager", e.target.value)}
/>
</FormField>
<FormField label="연락처" htmlFor="contact">
<Input
id="contact"
placeholder="010-1234-5678"
value={formData.contact}
onChange={(e) => handleFieldChange("contact", e.target.value)}
/>
</FormField>
</FormFieldGrid>
<FormFieldGrid columns={3}>
<FormField label="납기일" htmlFor="dueDate">
<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">
<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}`}>
<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}`}>
<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)"
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="제품명"
required
error={errors[`item-${activeItemIndex}-productName`]}
htmlFor={`productName-${activeItemIndex}`}
>
<Select
value={formData.items[activeItemIndex].productName}
onValueChange={(value) =>
handleItemChange(activeItemIndex, "productName", value)
}
disabled={!formData.items[activeItemIndex].productCategory}
>
<SelectTrigger id={`productName-${activeItemIndex}`}>
<SelectValue placeholder="제품을 선택하세요" />
</SelectTrigger>
<SelectContent>
{(PRODUCTS[formData.items[activeItemIndex].productCategory] || []).map((product) => (
<SelectItem key={product.value} value={product.value}>
{product.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
<FormField
label="오픈사이즈 (W0)"
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)"
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)"
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)"
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)"
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)"
required
error={errors[`item-${activeItemIndex}-quantity`]}
htmlFor={`quantity-${activeItemIndex}`}
>
<Input
id={`quantity-${activeItemIndex}`}
type="number"
min="1"
value={formData.items[activeItemIndex].quantity}
onChange={(e) =>
handleItemChange(activeItemIndex, "quantity", parseInt(e.target.value) || 1)
}
/>
</FormField>
<FormField
label="마구리 날개치수 (WS)"
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)"
htmlFor={`inspectionFee-${activeItemIndex}`}
>
<Input
id={`inspectionFee-${activeItemIndex}`}
type="number"
placeholder="예: 50000"
value={formData.items[activeItemIndex].inspectionFee}
onChange={(e) =>
handleItemChange(activeItemIndex, "inspectionFee", parseInt(e.target.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}
>
({formData.items.length} )
</Button>
</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>
);
}