Files
sam-react-prod/src/components/orders/OrderRegistration.tsx
권혁성 e6ef80f17f Merge remote-tracking branch 'origin/master'
# Conflicts:
#	src/components/quotes/QuoteRegistration.tsx
2026-01-20 20:49:14 +09:00

947 lines
31 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 { 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,
Info,
MapPin,
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 { generateOrderDataFull } 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: "택배" },
];
// 운임비용 옵션
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 = generateOrderDataFull();
// 거래처 목록에서 실제 데이터 사용
if (clients.length > 0) {
const randomClient = clients[Math.floor(Math.random() * clients.length)];
sampleData.clientId = randomClient.id;
sampleData.clientName = randomClient.name;
}
setForm(sampleData);
toast.success('[Dev] 수주 폼이 자동으로 채워졌습니다.');
}, [clients])
);
// 견적 선택 핸들러
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-2 gap-4">
<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>
<Input
placeholder="010-0000-0000"
value={form.contact}
onChange={(e) =>
setForm((prev) => ({ ...prev, contact: e.target.value }))
}
/>
</div>
</div>
</FormSection>
{/* 수주/배송 정보 섹션 */}
<FormSection
title="수주/배송 정보"
description="출고 및 배송 정보를 입력하세요"
icon={Truck}
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 출고예정일 */}
<div className="space-y-2">
<Label></Label>
<div className="flex items-center gap-4">
<Input
type="date"
value={form.expectedShipDate}
onChange={(e) =>
setForm((prev) => ({
...prev,
expectedShipDate: e.target.value,
}))
}
disabled={form.expectedShipDateUndecided}
className="flex-1"
/>
<div className="flex items-center gap-2">
<Checkbox
id="expectedShipDateUndecided"
checked={form.expectedShipDateUndecided}
onCheckedChange={(checked) =>
setForm((prev) => ({
...prev,
expectedShipDateUndecided: checked as boolean,
expectedShipDate: checked ? "" : prev.expectedShipDate,
}))
}
/>
<Label
htmlFor="expectedShipDateUndecided"
className="text-sm font-normal"
>
</Label>
</div>
</div>
</div>
{/* 납품요청일 */}
<div className="space-y-2">
<Label>
<span className="text-red-500">*</span>
</Label>
<div className="flex items-center gap-4">
<Input
type="date"
value={form.deliveryRequestDate}
onChange={(e) => {
setForm((prev) => ({
...prev,
deliveryRequestDate: e.target.value,
}));
clearFieldError("deliveryRequestDate");
}}
disabled={form.deliveryRequestDateUndecided}
className={cn("flex-1", fieldErrors.deliveryRequestDate && "border-red-500")}
/>
<div className="flex items-center gap-2">
<Checkbox
id="deliveryRequestDateUndecided"
checked={form.deliveryRequestDateUndecided}
onCheckedChange={(checked) => {
setForm((prev) => ({
...prev,
deliveryRequestDateUndecided: checked as boolean,
deliveryRequestDate: checked
? ""
: prev.deliveryRequestDate,
}));
if (checked) clearFieldError("deliveryRequestDate");
}}
/>
<Label
htmlFor="deliveryRequestDateUndecided"
className="text-sm font-normal"
>
</Label>
</div>
</div>
{fieldErrors.deliveryRequestDate && (
<p className="text-sm text-red-500">{fieldErrors.deliveryRequestDate}</p>
)}
</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="010-0000-0000"
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>
</FormSection>
{/* 수신처 주소 섹션 */}
<FormSection
title="수신처 주소"
description="배송지 주소를 입력하세요"
icon={MapPin}
>
<div className="space-y-4">
<div className="flex gap-2">
<Input
placeholder="우편번호"
value={form.zipCode}
onChange={(e) =>
setForm((prev) => ({ ...prev, zipCode: e.target.value }))
}
className="w-32"
/>
<Button variant="outline" type="button" onClick={openPostcode}>
</Button>
</div>
<Input
placeholder="기본 주소"
value={form.address}
onChange={(e) =>
setForm((prev) => ({ ...prev, address: e.target.value }))
}
/>
<Input
placeholder="상세 주소 입력"
value={form.addressDetail}
onChange={(e) =>
setForm((prev) => ({ ...prev, addressDetail: e.target.value }))
}
/>
</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">
<Input
type="number"
min={1}
value={item.quantity}
onChange={(e) =>
handleQuantityChange(
item.id,
Number(e.target.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>
<Input
type="number"
min={0}
max={100}
value={form.discountRate}
onChange={(e) =>
setForm((prev) => ({
...prev,
discountRate: Number(e.target.value) || 0,
}))
}
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}
/>
</>
);
}