- 입고관리: 상세/목록 UI 개선, actions 로직 강화 - 재고현황: 상세/목록 개선, StockAuditModal 신규 추가 - 영업주문관리: 페이지 구조 개선, OrderSalesDetailEdit 기능 강화 - 주문: OrderRegistration 개선, SalesOrderDocument 신규 추가 - 견적: QuoteTransactionModal 기능 개선 - 품질: InspectionModalV2, ImportInspectionDocument 대폭 개선 - UniversalListPage: 템플릿 기능 확장 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
921 lines
30 KiB
TypeScript
921 lines
30 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* 수주 등록 컴포넌트
|
||
*
|
||
* IntegratedDetailTemplate 마이그레이션 (2026-01-20)
|
||
* - 견적 불러오기 섹션
|
||
* - 기본 정보 섹션
|
||
* - 수주/배송 정보 섹션 (주소 포함)
|
||
* - 비고 섹션
|
||
* - 품목 내역 섹션
|
||
*/
|
||
|
||
import { useState, useEffect, useCallback } from "react";
|
||
import { useDaumPostcode } from "@/hooks/useDaumPostcode";
|
||
import { useClientList } from "@/hooks/useClientList";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { Button } from "@/components/ui/button";
|
||
import { QuantityInput } from "@/components/ui/quantity-input";
|
||
import { NumberInput } from "@/components/ui/number-input";
|
||
import { PhoneInput } from "@/components/ui/phone-input";
|
||
import { Checkbox } from "@/components/ui/checkbox";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "@/components/ui/table";
|
||
import {
|
||
FileText,
|
||
Search,
|
||
X,
|
||
Plus,
|
||
Trash2,
|
||
Truck,
|
||
Package,
|
||
MessageSquare,
|
||
} from "lucide-react";
|
||
import { toast } from "sonner";
|
||
import { IntegratedDetailTemplate } from "@/components/templates/IntegratedDetailTemplate";
|
||
import { orderCreateConfig, orderEditConfig } from "./orderConfig";
|
||
import { FormSection } from "@/components/organisms/FormSection";
|
||
import { QuotationSelectDialog } from "./QuotationSelectDialog";
|
||
import { type QuotationForSelect, type QuotationItem } from "./actions";
|
||
import { ItemAddDialog, OrderItem } from "./ItemAddDialog";
|
||
import { formatAmount } from "@/utils/formatAmount";
|
||
import { cn } from "@/lib/utils";
|
||
import { useDevFill } from "@/components/dev";
|
||
import { generateOrderData } from "@/components/dev/generators/orderData";
|
||
|
||
// 수주 폼 데이터 타입
|
||
export interface OrderFormData {
|
||
// 견적 정보
|
||
selectedQuotation?: QuotationForSelect;
|
||
|
||
// 기본 정보
|
||
clientId: string;
|
||
clientName: string;
|
||
siteName: string;
|
||
manager: string;
|
||
contact: string;
|
||
|
||
// 수주/배송 정보
|
||
expectedShipDate: string;
|
||
expectedShipDateUndecided: boolean;
|
||
deliveryRequestDate: string;
|
||
deliveryRequestDateUndecided: boolean;
|
||
deliveryMethod: string;
|
||
shippingCost: string;
|
||
receiver: string;
|
||
receiverContact: string;
|
||
|
||
// 수신처 주소
|
||
zipCode: string;
|
||
address: string;
|
||
addressDetail: string;
|
||
|
||
// 비고
|
||
remarks: string;
|
||
|
||
// 품목 내역
|
||
items: OrderItem[];
|
||
|
||
// 금액 정보
|
||
subtotal: number;
|
||
discountRate: number;
|
||
totalAmount: number;
|
||
}
|
||
|
||
// 초기 폼 데이터
|
||
const INITIAL_FORM: OrderFormData = {
|
||
clientId: "",
|
||
clientName: "",
|
||
siteName: "",
|
||
manager: "",
|
||
contact: "",
|
||
expectedShipDate: "",
|
||
expectedShipDateUndecided: false,
|
||
deliveryRequestDate: "",
|
||
deliveryRequestDateUndecided: false,
|
||
deliveryMethod: "",
|
||
shippingCost: "",
|
||
receiver: "",
|
||
receiverContact: "",
|
||
zipCode: "",
|
||
address: "",
|
||
addressDetail: "",
|
||
remarks: "",
|
||
items: [],
|
||
subtotal: 0,
|
||
discountRate: 0,
|
||
totalAmount: 0,
|
||
};
|
||
|
||
// 배송방식 옵션
|
||
const DELIVERY_METHODS = [
|
||
{ value: "direct", label: "직접배차" },
|
||
{ value: "pickup", label: "상차" },
|
||
{ value: "courier", label: "택배" },
|
||
{ value: "self", label: "직접수령" },
|
||
{ value: "freight", label: "화물" },
|
||
];
|
||
|
||
// 운임비용 옵션
|
||
const SHIPPING_COSTS = [
|
||
{ value: "free", label: "무료" },
|
||
{ value: "prepaid", label: "선불" },
|
||
{ value: "collect", label: "착불" },
|
||
{ value: "negotiable", label: "협의" },
|
||
];
|
||
|
||
interface OrderRegistrationProps {
|
||
onBack: () => void;
|
||
onSave: (formData: OrderFormData) => Promise<void>;
|
||
initialData?: Partial<OrderFormData>;
|
||
isEditMode?: boolean;
|
||
}
|
||
|
||
// 필드별 에러 타입
|
||
interface FieldErrors {
|
||
clientName?: string;
|
||
siteName?: string;
|
||
deliveryRequestDate?: string;
|
||
receiver?: string;
|
||
receiverContact?: string;
|
||
items?: string;
|
||
}
|
||
|
||
// 필드명 한글 매핑
|
||
const FIELD_NAME_MAP: Record<string, string> = {
|
||
clientName: "수주처",
|
||
siteName: "현장명",
|
||
deliveryRequestDate: "납품요청일",
|
||
receiver: "수신자",
|
||
receiverContact: "수신처",
|
||
items: "품목 내역",
|
||
};
|
||
|
||
export function OrderRegistration({
|
||
onBack,
|
||
onSave,
|
||
initialData,
|
||
isEditMode = false,
|
||
}: OrderRegistrationProps) {
|
||
const [form, setForm] = useState<OrderFormData>({
|
||
...INITIAL_FORM,
|
||
...initialData,
|
||
});
|
||
const [isQuotationDialogOpen, setIsQuotationDialogOpen] = useState(false);
|
||
const [isItemDialogOpen, setIsItemDialogOpen] = useState(false);
|
||
const [isSaving, setIsSaving] = useState(false);
|
||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||
|
||
// Config 선택
|
||
const config = isEditMode ? orderEditConfig : orderCreateConfig;
|
||
|
||
// 거래처 목록 조회
|
||
const { clients, fetchClients, isLoading: isClientsLoading } = useClientList();
|
||
|
||
// 컴포넌트 마운트 시 거래처 목록 불러오기
|
||
useEffect(() => {
|
||
fetchClients({ onlyActive: true, size: 100 });
|
||
}, [fetchClients]);
|
||
|
||
// Daum 우편번호 서비스
|
||
const { openPostcode } = useDaumPostcode({
|
||
onComplete: (result) => {
|
||
setForm((prev) => ({
|
||
...prev,
|
||
zipCode: result.zonecode,
|
||
address: result.address,
|
||
}));
|
||
},
|
||
});
|
||
|
||
// 금액 계산
|
||
useEffect(() => {
|
||
const subtotal = form.items.reduce((sum, item) => sum + item.amount, 0);
|
||
const discountAmount = subtotal * (form.discountRate / 100);
|
||
const totalAmount = subtotal - discountAmount;
|
||
|
||
setForm((prev) => ({
|
||
...prev,
|
||
subtotal,
|
||
totalAmount,
|
||
}));
|
||
}, [form.items, form.discountRate]);
|
||
|
||
// DevToolbar 자동 채우기 (배송 정보만 - 기본정보는 견적에서 불러옴)
|
||
useDevFill(
|
||
'order',
|
||
useCallback(() => {
|
||
const sampleData = generateOrderData();
|
||
setForm(prev => ({ ...prev, ...sampleData }));
|
||
toast.success('[Dev] 수주 배송정보가 자동으로 채워졌습니다.');
|
||
}, [])
|
||
);
|
||
|
||
// 견적 선택 핸들러
|
||
const handleQuotationSelect = (quotation: QuotationForSelect) => {
|
||
// 견적 정보로 폼 자동 채우기 (견적에서 가져온 품목은 삭제 불가)
|
||
const items: OrderItem[] = (quotation.items || []).map((qi: QuotationItem) => ({
|
||
id: qi.id,
|
||
itemCode: qi.itemCode,
|
||
itemName: qi.itemName,
|
||
type: qi.type,
|
||
symbol: qi.symbol,
|
||
spec: qi.spec,
|
||
width: 0,
|
||
height: 0,
|
||
quantity: qi.quantity,
|
||
unit: qi.unit,
|
||
unitPrice: qi.unitPrice,
|
||
amount: qi.amount,
|
||
isFromQuotation: true, // 견적에서 가져온 품목 표시
|
||
}));
|
||
|
||
setForm((prev) => ({
|
||
...prev,
|
||
selectedQuotation: quotation,
|
||
clientId: quotation.clientId || "", // 견적의 발주처 ID 설정
|
||
clientName: quotation.client,
|
||
siteName: quotation.siteName,
|
||
manager: quotation.manager || "",
|
||
contact: quotation.contact || "",
|
||
items,
|
||
}));
|
||
|
||
// 발주처 에러 초기화
|
||
clearFieldError("clientName");
|
||
toast.success("견적 정보가 불러와졌습니다.");
|
||
};
|
||
|
||
// 견적 해제 핸들러
|
||
const handleClearQuotation = () => {
|
||
setForm((prev) => ({
|
||
...prev,
|
||
selectedQuotation: undefined,
|
||
// 기본 정보는 유지하고 품목만 초기화할지, 전체 초기화할지 선택 가능
|
||
items: [],
|
||
}));
|
||
};
|
||
|
||
// 품목 추가 핸들러
|
||
const handleAddItem = (item: OrderItem) => {
|
||
setForm((prev) => ({
|
||
...prev,
|
||
items: [...prev.items, item],
|
||
}));
|
||
// 품목 에러 초기화
|
||
setFieldErrors((prev) => {
|
||
if (prev.items) {
|
||
const { items: _, ...rest } = prev;
|
||
return rest;
|
||
}
|
||
return prev;
|
||
});
|
||
toast.success("품목이 추가되었습니다.");
|
||
};
|
||
|
||
// 품목 삭제 핸들러
|
||
const handleRemoveItem = (itemId: string) => {
|
||
setForm((prev) => ({
|
||
...prev,
|
||
items: prev.items.filter((item) => item.id !== itemId),
|
||
}));
|
||
};
|
||
|
||
// 품목 수량 변경 핸들러
|
||
const handleQuantityChange = (itemId: string, quantity: number) => {
|
||
setForm((prev) => ({
|
||
...prev,
|
||
items: prev.items.map((item) =>
|
||
item.id === itemId
|
||
? { ...item, quantity, amount: item.unitPrice * quantity }
|
||
: item
|
||
),
|
||
}));
|
||
};
|
||
|
||
// 유효성 검사 함수
|
||
const validateForm = useCallback((): FieldErrors => {
|
||
const errors: FieldErrors = {};
|
||
|
||
if (!form.clientName.trim()) {
|
||
errors.clientName = "발주처를 선택해주세요.";
|
||
}
|
||
if (!form.siteName.trim()) {
|
||
errors.siteName = "현장명을 입력해주세요.";
|
||
}
|
||
if (!form.deliveryRequestDate && !form.deliveryRequestDateUndecided) {
|
||
errors.deliveryRequestDate = "납품요청일을 입력하거나 '미정'을 선택해주세요.";
|
||
}
|
||
if (!form.receiver.trim()) {
|
||
errors.receiver = "수신자명을 입력해주세요.";
|
||
}
|
||
if (!form.receiverContact.trim()) {
|
||
errors.receiverContact = "연락처를 입력해주세요.";
|
||
}
|
||
if (form.items.length === 0) {
|
||
errors.items = "최소 1개 이상의 품목을 추가해주세요.";
|
||
}
|
||
|
||
return errors;
|
||
}, [form]);
|
||
|
||
// 필드 에러 초기화 (필드 값 변경 시)
|
||
const clearFieldError = useCallback((field: keyof FieldErrors) => {
|
||
setFieldErrors((prev) => {
|
||
if (prev[field]) {
|
||
const { [field]: _, ...rest } = prev;
|
||
return rest;
|
||
}
|
||
return prev;
|
||
});
|
||
}, []);
|
||
|
||
// 저장 핸들러
|
||
const handleSave = useCallback(async () => {
|
||
// 유효성 검사
|
||
const errors = validateForm();
|
||
setFieldErrors(errors);
|
||
|
||
const errorCount = Object.keys(errors).length;
|
||
if (errorCount > 0) {
|
||
toast.error(`입력 내용을 확인해주세요. (${errorCount}개 오류)`);
|
||
return;
|
||
}
|
||
|
||
setIsSaving(true);
|
||
try {
|
||
await onSave(form);
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
}, [form, validateForm, onSave]);
|
||
|
||
// 폼 콘텐츠 렌더링
|
||
const renderFormContent = useCallback(
|
||
() => (
|
||
<div className="space-y-6 max-w-4xl">
|
||
{/* Validation 에러 Alert */}
|
||
{Object.keys(fieldErrors).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(fieldErrors).length}개 오류)
|
||
</strong>
|
||
<ul className="space-y-1 text-sm">
|
||
{Object.entries(fieldErrors).map(([field, error]) => {
|
||
const fieldName = FIELD_NAME_MAP[field] || field;
|
||
return (
|
||
<li key={field} className="flex items-start gap-1">
|
||
<span>•</span>
|
||
<span>
|
||
<strong>{fieldName}</strong>: {error}
|
||
</span>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
|
||
{/* 견적 불러오기 섹션 */}
|
||
<FormSection
|
||
title="견적 불러오기"
|
||
description="확정된 견적을 선택하면 정보가 자동으로 채워집니다"
|
||
icon={Search}
|
||
>
|
||
<div className="space-y-4">
|
||
{form.selectedQuotation ? (
|
||
<div className="p-4 border rounded-lg bg-muted/30">
|
||
<div className="flex items-start justify-between">
|
||
<div className="space-y-2">
|
||
<div className="flex items-center gap-2">
|
||
<code className="text-sm font-mono bg-gray-100 px-2 py-0.5 rounded">
|
||
{form.selectedQuotation.quoteNumber}
|
||
</code>
|
||
<Badge variant="outline" className="bg-green-100 text-green-700 border-green-200">
|
||
{form.selectedQuotation.grade} (우량)
|
||
</Badge>
|
||
</div>
|
||
<div className="text-sm">
|
||
<span className="font-medium">{form.selectedQuotation.client}</span>
|
||
<span className="text-muted-foreground mx-2">/</span>
|
||
<span>{form.selectedQuotation.siteName}</span>
|
||
<span className="text-muted-foreground mx-2">/</span>
|
||
<span className="text-green-600 font-medium">
|
||
{formatAmount(form.selectedQuotation.amount)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={handleClearQuotation}
|
||
>
|
||
<X className="h-4 w-4 mr-1" />
|
||
해제
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => setIsQuotationDialogOpen(true)}
|
||
>
|
||
<Search className="h-4 w-4 mr-2" />
|
||
견적 선택
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</FormSection>
|
||
|
||
{/* 기본 정보 섹션 */}
|
||
<FormSection
|
||
title="기본 정보"
|
||
description="수주처 및 현장 정보를 입력하세요"
|
||
icon={FileText}
|
||
>
|
||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||
{/* 첫 번째 줄: 로트번호, 접수일, 수주처, 현장명 */}
|
||
<div className="space-y-2">
|
||
<Label>로트번호</Label>
|
||
<Input
|
||
value=""
|
||
disabled
|
||
className="bg-muted"
|
||
placeholder="자동 생성"
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label>접수일</Label>
|
||
<Input
|
||
value=""
|
||
disabled
|
||
className="bg-muted"
|
||
placeholder="자동 생성"
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label>
|
||
수주처 <span className="text-red-500">*</span>
|
||
</Label>
|
||
<Select
|
||
value={form.clientId}
|
||
onValueChange={(value) => {
|
||
const client = clients.find((c) => c.id === value);
|
||
setForm((prev) => ({
|
||
...prev,
|
||
clientId: value,
|
||
clientName: client?.name || "",
|
||
}));
|
||
clearFieldError("clientName");
|
||
}}
|
||
disabled={!!form.selectedQuotation || isClientsLoading}
|
||
>
|
||
<SelectTrigger className={cn(fieldErrors.clientName && "border-red-500")}>
|
||
<SelectValue placeholder={isClientsLoading ? "불러오는 중..." : "수주처 선택"}>
|
||
{form.clientName || (isClientsLoading ? "불러오는 중..." : "수주처 선택")}
|
||
</SelectValue>
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{clients.map((client) => (
|
||
<SelectItem key={client.id} value={client.id}>
|
||
{client.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
{fieldErrors.clientName && (
|
||
<p className="text-sm text-red-500">{fieldErrors.clientName}</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label>
|
||
현장명 <span className="text-red-500">*</span>
|
||
</Label>
|
||
<Input
|
||
placeholder="현장명 입력"
|
||
value={form.siteName}
|
||
onChange={(e) => {
|
||
setForm((prev) => ({ ...prev, siteName: e.target.value }));
|
||
clearFieldError("siteName");
|
||
}}
|
||
disabled={!!form.selectedQuotation}
|
||
className={cn(fieldErrors.siteName && "border-red-500")}
|
||
/>
|
||
{fieldErrors.siteName && (
|
||
<p className="text-sm text-red-500">{fieldErrors.siteName}</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* 두 번째 줄: 담당자, 연락처, 상태 */}
|
||
<div className="space-y-2">
|
||
<Label>담당자</Label>
|
||
<Input
|
||
placeholder="담당자명 입력"
|
||
value={form.manager}
|
||
onChange={(e) =>
|
||
setForm((prev) => ({ ...prev, manager: e.target.value }))
|
||
}
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label>연락처</Label>
|
||
<PhoneInput
|
||
placeholder="010-0000-0000"
|
||
value={form.contact}
|
||
onChange={(value) =>
|
||
setForm((prev) => ({ ...prev, contact: value }))
|
||
}
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label>상태</Label>
|
||
<Input
|
||
value=""
|
||
disabled
|
||
className="bg-muted"
|
||
placeholder="자동 생성"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</FormSection>
|
||
|
||
{/* 수주/배송 정보 섹션 */}
|
||
<FormSection
|
||
title="수주/배송 정보"
|
||
description="출고 및 배송 정보를 입력하세요"
|
||
icon={Truck}
|
||
>
|
||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||
{/* 첫 번째 줄: 수주일, 납품요청일, 출고예정일, 배송방식 */}
|
||
<div className="space-y-2">
|
||
<Label>수주일</Label>
|
||
<Input
|
||
value=""
|
||
disabled
|
||
className="bg-muted"
|
||
placeholder="자동 생성"
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label>
|
||
납품요청일 <span className="text-red-500">*</span>
|
||
</Label>
|
||
<Input
|
||
type="date"
|
||
value={form.deliveryRequestDate}
|
||
onChange={(e) => {
|
||
setForm((prev) => ({
|
||
...prev,
|
||
deliveryRequestDate: e.target.value,
|
||
}));
|
||
clearFieldError("deliveryRequestDate");
|
||
}}
|
||
disabled={form.deliveryRequestDateUndecided}
|
||
className={cn(fieldErrors.deliveryRequestDate && "border-red-500")}
|
||
/>
|
||
{fieldErrors.deliveryRequestDate && (
|
||
<p className="text-sm text-red-500">{fieldErrors.deliveryRequestDate}</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label>출고예정일</Label>
|
||
<Input
|
||
type="date"
|
||
value={form.expectedShipDate}
|
||
onChange={(e) =>
|
||
setForm((prev) => ({
|
||
...prev,
|
||
expectedShipDate: e.target.value,
|
||
}))
|
||
}
|
||
disabled={form.expectedShipDateUndecided}
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label>배송방식</Label>
|
||
<Select
|
||
value={form.deliveryMethod}
|
||
onValueChange={(value) =>
|
||
setForm((prev) => ({ ...prev, deliveryMethod: value }))
|
||
}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="선택" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{DELIVERY_METHODS.map((method) => (
|
||
<SelectItem key={method.value} value={method.value}>
|
||
{method.label}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{/* 두 번째 줄: 운임비용, 수신자, 수신처 */}
|
||
<div className="space-y-2">
|
||
<Label>운임비용</Label>
|
||
<Select
|
||
value={form.shippingCost}
|
||
onValueChange={(value) =>
|
||
setForm((prev) => ({ ...prev, shippingCost: value }))
|
||
}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="선택" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{SHIPPING_COSTS.map((cost) => (
|
||
<SelectItem key={cost.value} value={cost.value}>
|
||
{cost.label}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label>
|
||
수신자 <span className="text-red-500">*</span>
|
||
</Label>
|
||
<Input
|
||
placeholder="수신자명 입력"
|
||
value={form.receiver}
|
||
onChange={(e) => {
|
||
setForm((prev) => ({ ...prev, receiver: e.target.value }));
|
||
clearFieldError("receiver");
|
||
}}
|
||
className={cn(fieldErrors.receiver && "border-red-500")}
|
||
/>
|
||
{fieldErrors.receiver && (
|
||
<p className="text-sm text-red-500">{fieldErrors.receiver}</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label>
|
||
수신처 <span className="text-red-500">*</span>
|
||
</Label>
|
||
<Input
|
||
placeholder="수신처 입력"
|
||
value={form.receiverContact}
|
||
onChange={(e) => {
|
||
setForm((prev) => ({
|
||
...prev,
|
||
receiverContact: e.target.value,
|
||
}));
|
||
clearFieldError("receiverContact");
|
||
}}
|
||
className={cn(fieldErrors.receiverContact && "border-red-500")}
|
||
/>
|
||
{fieldErrors.receiverContact && (
|
||
<p className="text-sm text-red-500">{fieldErrors.receiverContact}</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* 주소 - 전체 너비 사용 */}
|
||
<div className="space-y-2 md:col-span-4">
|
||
<Label>주소</Label>
|
||
<div className="flex gap-2">
|
||
<Button variant="outline" type="button" onClick={openPostcode}>
|
||
우편번호 찾기
|
||
</Button>
|
||
<Input
|
||
placeholder="우편번호"
|
||
value={form.zipCode}
|
||
onChange={(e) =>
|
||
setForm((prev) => ({ ...prev, zipCode: e.target.value }))
|
||
}
|
||
className="w-32"
|
||
/>
|
||
<Input
|
||
placeholder="주소"
|
||
value={form.address}
|
||
onChange={(e) =>
|
||
setForm((prev) => ({ ...prev, address: e.target.value }))
|
||
}
|
||
className="flex-1"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</FormSection>
|
||
|
||
{/* 비고 섹션 */}
|
||
<FormSection
|
||
title="비고"
|
||
description="특이사항을 입력하세요"
|
||
icon={MessageSquare}
|
||
>
|
||
<Textarea
|
||
placeholder="특이사항을 입력하세요"
|
||
value={form.remarks}
|
||
onChange={(e) =>
|
||
setForm((prev) => ({ ...prev, remarks: e.target.value }))
|
||
}
|
||
rows={4}
|
||
/>
|
||
</FormSection>
|
||
|
||
{/* 품목 내역 섹션 */}
|
||
<FormSection
|
||
title="품목 내역"
|
||
description="수주 품목을 관리하세요"
|
||
icon={Package}
|
||
>
|
||
<div className="space-y-4">
|
||
{/* 품목 에러 메시지 */}
|
||
{fieldErrors.items && (
|
||
<p className="text-sm text-red-500">{fieldErrors.items}</p>
|
||
)}
|
||
{/* 품목 테이블 */}
|
||
<div className={cn("border rounded-lg overflow-hidden", fieldErrors.items && "border-red-500")}>
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead className="w-[60px] text-center">순번</TableHead>
|
||
<TableHead>품목코드</TableHead>
|
||
<TableHead>품명</TableHead>
|
||
<TableHead>규격</TableHead>
|
||
<TableHead className="w-[80px] text-center">수량</TableHead>
|
||
<TableHead className="w-[60px] text-center">단위</TableHead>
|
||
<TableHead className="text-right">단가</TableHead>
|
||
<TableHead className="text-right">금액</TableHead>
|
||
<TableHead className="w-[60px]"></TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{form.items.length === 0 ? (
|
||
<TableRow>
|
||
<TableCell colSpan={9} className="text-center py-8 text-muted-foreground">
|
||
품목이 없습니다. 견적을 선택하거나 품목을 추가해주세요.
|
||
</TableCell>
|
||
</TableRow>
|
||
) : (
|
||
form.items.map((item, index) => (
|
||
<TableRow key={item.id}>
|
||
<TableCell className="text-center">{index + 1}</TableCell>
|
||
<TableCell>
|
||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||
{item.itemCode}
|
||
</code>
|
||
</TableCell>
|
||
<TableCell>{item.itemName}</TableCell>
|
||
<TableCell>{item.spec}</TableCell>
|
||
<TableCell className="text-center">
|
||
<QuantityInput
|
||
min={1}
|
||
value={item.quantity}
|
||
onChange={(value) =>
|
||
handleQuantityChange(
|
||
item.id,
|
||
value ?? 1
|
||
)
|
||
}
|
||
className="w-16 text-center"
|
||
/>
|
||
</TableCell>
|
||
<TableCell className="text-center">{item.unit}</TableCell>
|
||
<TableCell className="text-right">
|
||
{formatAmount(item.unitPrice)}
|
||
</TableCell>
|
||
<TableCell className="text-right font-medium">
|
||
{formatAmount(item.amount)}
|
||
</TableCell>
|
||
<TableCell>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => handleRemoveItem(item.id)}
|
||
>
|
||
<Trash2 className="h-4 w-4 text-red-500" />
|
||
</Button>
|
||
</TableCell>
|
||
</TableRow>
|
||
))
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
|
||
{/* 품목 추가 버튼 */}
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => setIsItemDialogOpen(true)}
|
||
>
|
||
<Plus className="h-4 w-4 mr-2" />
|
||
품목 추가
|
||
</Button>
|
||
|
||
{/* 합계 */}
|
||
<div className="flex flex-col items-end gap-2 pt-4 border-t">
|
||
<div className="flex items-center gap-4 text-sm">
|
||
<span className="text-muted-foreground">소계:</span>
|
||
<span className="w-32 text-right">
|
||
{formatAmount(form.subtotal)}
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-4 text-sm">
|
||
<span className="text-muted-foreground">할인율(%):</span>
|
||
<NumberInput
|
||
min={0}
|
||
max={100}
|
||
value={form.discountRate}
|
||
onChange={(value) =>
|
||
setForm((prev) => ({
|
||
...prev,
|
||
discountRate: value ?? 0,
|
||
}))
|
||
}
|
||
allowDecimal
|
||
className="w-20 text-right"
|
||
/>
|
||
</div>
|
||
<div className="flex items-center gap-4 text-lg font-semibold">
|
||
<span>총금액:</span>
|
||
<span className="w-32 text-right text-green-600">
|
||
{formatAmount(form.totalAmount)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</FormSection>
|
||
</div>
|
||
),
|
||
[
|
||
form,
|
||
fieldErrors,
|
||
clients,
|
||
isClientsLoading,
|
||
openPostcode,
|
||
clearFieldError,
|
||
handleClearQuotation,
|
||
handleQuantityChange,
|
||
handleRemoveItem,
|
||
]
|
||
);
|
||
|
||
return (
|
||
<>
|
||
<IntegratedDetailTemplate
|
||
config={config}
|
||
mode={isEditMode ? "edit" : "create"}
|
||
isLoading={false}
|
||
isSubmitting={isSaving}
|
||
onBack={onBack}
|
||
onCancel={onBack}
|
||
onSubmit={handleSave}
|
||
renderForm={renderFormContent}
|
||
/>
|
||
|
||
{/* 견적 선택 팝업 */}
|
||
<QuotationSelectDialog
|
||
open={isQuotationDialogOpen}
|
||
onOpenChange={setIsQuotationDialogOpen}
|
||
onSelect={handleQuotationSelect}
|
||
selectedId={form.selectedQuotation?.id}
|
||
/>
|
||
|
||
{/* 품목 추가 팝업 */}
|
||
<ItemAddDialog
|
||
open={isItemDialogOpen}
|
||
onOpenChange={setIsItemDialogOpen}
|
||
onAdd={handleAddItem}
|
||
/>
|
||
</>
|
||
);
|
||
}
|