Files
sam-react-prod/src/components/orders/OrderRegistration.tsx
유병철 1f640622e0 feat(WEB): 자재/영업/품질 모듈 기능 개선 및 문서 컴포넌트 추가
- 입고관리: 상세/목록 UI 개선, actions 로직 강화
- 재고현황: 상세/목록 개선, StockAuditModal 신규 추가
- 영업주문관리: 페이지 구조 개선, OrderSalesDetailEdit 기능 강화
- 주문: OrderRegistration 개선, SalesOrderDocument 신규 추가
- 견적: QuoteTransactionModal 기능 개선
- 품질: InspectionModalV2, ImportInspectionDocument 대폭 개선
- UniversalListPage: 템플릿 기능 확장

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 21:15:25 +09:00

921 lines
30 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.

"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}
/>
</>
);
}