Files
sam-react-prod/src/components/orders/OrderRegistration.tsx
권혁성 f35df29264 fix(WEB): 수주 개소 그룹핑 개선 및 제품명 표시 수정
- floor+code 동일 시 인덱스 기반 균등 분배 로직 추가
- 제품명을 root_nodes[0].options.product_name에서 가져오도록 변경
2026-02-21 01:06:55 +09:00

1129 lines
38 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, useMemo } from "react";
import { useDaumPostcode } from "@/hooks/useDaumPostcode";
import { useClientList } from "@/hooks/useClientList";
import { Input } from "@/components/ui/input";
import { DatePicker } from "@/components/ui/date-picker";
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 { getPresetStyle } from "@/lib/utils/status-config";
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 "@/lib/utils/amount";
import { cn } from "@/lib/utils";
import { useDevFill } from "@/components/dev";
import { generateOrderData } from "@/components/dev/generators/orderData";
// 수주 폼 데이터 타입
// Index signature allows compatibility with Record<string, unknown> (required by actions API)
export interface OrderFormData {
[key: string]: unknown;
// 견적 정보
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), 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 itemGroups = useMemo(() => {
const calcItems = form.selectedQuotation?.calculationInputs?.items;
if (!calcItems || calcItems.length === 0) {
return null;
}
// floor+code 고유 키 수 확인 (모두 같은 값인지 판별)
const uniqueLocKeys = new Set(
calcItems.map(ci => `${ci.floor || '-'}|${ci.code || '-'}`)
);
// 개소가 여러 개인데 floor+code가 모두 동일 → 인덱스 기반 균등 분배
const useIndexGrouping = calcItems.length > 1 && uniqueLocKeys.size === 1;
if (useIndexGrouping) {
const itemsPerLocation = Math.ceil(form.items.length / calcItems.length);
const result: Array<{
key: string;
label: string;
productCode: string;
locationCount: number;
quantity: number;
amount: number;
items: OrderItem[];
}> = [];
calcItems.forEach((ci, idx) => {
const start = idx * itemsPerLocation;
const end = Math.min(start + itemsPerLocation, form.items.length);
const groupItems = form.items.slice(start, end);
const amount = groupItems.reduce((sum, item) => sum + (item.amount ?? 0), 0);
const floor = ci.floor || '-';
const code = ci.code || '-';
result.push({
key: `loc_${idx}`,
label: `${idx + 1}. ${floor} / ${code}`,
productCode: ci.productName || ci.productCode || '',
locationCount: 1,
quantity: ci.quantity ?? 1,
amount,
items: groupItems,
});
});
return result;
}
// floor+code → calculationInput 매핑 (개소 메타정보)
const locationMetaMap = new Map<string, {
productCode: string;
productName: string;
quantity: number;
floor: string;
code: string;
}>();
calcItems.forEach(ci => {
if (ci.floor && ci.code) {
const locKey = `${ci.floor}|${ci.code}`;
locationMetaMap.set(locKey, {
productCode: ci.productCode || '',
productName: ci.productName || '',
quantity: ci.quantity ?? 1,
floor: ci.floor,
code: ci.code,
});
}
});
// 개소별 그룹
const groups = new Map<string, {
items: OrderItem[];
meta: { productCode: string; productName: string; quantity: number; floor: string; code: string };
}>();
const ungrouped: OrderItem[] = [];
form.items.forEach(item => {
const locKey = `${item.type}|${item.symbol}`;
const meta = locationMetaMap.get(locKey);
if (meta) {
if (!groups.has(locKey)) {
groups.set(locKey, { items: [], meta });
}
groups.get(locKey)!.items.push(item);
} else {
ungrouped.push(item);
}
});
if (groups.size === 0) {
return null;
}
const result: Array<{
key: string;
label: string;
productCode: string;
locationCount: number;
quantity: number;
amount: number;
items: OrderItem[];
}> = [];
let orderNum = 1;
groups.forEach((value, key) => {
const amount = value.items.reduce((sum, item) => sum + (item.amount ?? 0), 0);
result.push({
key,
label: `${orderNum}. ${value.meta.floor} / ${value.meta.code}`,
productCode: value.meta.productName || value.meta.productCode,
locationCount: 1,
quantity: value.meta.quantity,
amount,
items: value.items,
});
orderNum++;
});
if (ungrouped.length > 0) {
const amount = ungrouped.reduce((sum, item) => sum + (item.amount ?? 0), 0);
result.push({
key: '_ungrouped',
label: '기타',
productCode: '',
locationCount: 0,
quantity: ungrouped.length,
amount,
items: ungrouped,
});
}
return result;
}, [form.items, form.selectedQuotation?.calculationInputs]);
// 견적 선택 핸들러
const handleQuotationSelect = (quotation: QuotationForSelect) => {
// 견적 정보로 폼 자동 채우기 (견적에서 가져온 품목은 삭제 불가)
const items: OrderItem[] = (quotation.items || []).map((qi: QuotationItem) => ({
id: qi.id,
itemId: qi.itemId ? Number(qi.itemId) : undefined, // Items Master 참조 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 (): Promise<{ success: boolean; error?: string }> => {
// 유효성 검사
const errors = validateForm();
setFieldErrors(errors);
const errorCount = Object.keys(errors).length;
if (errorCount > 0) {
toast.error(`입력 내용을 확인해주세요. (${errorCount}개 오류)`);
return { success: false, error: `입력 내용을 확인해주세요. (${errorCount}개 오류)` };
}
setIsSaving(true);
try {
await onSave(form);
return { success: true };
} catch (e) {
const errorMsg = e instanceof Error ? e.message : '저장 중 오류가 발생했습니다.';
return { success: false, error: errorMsg };
} finally {
setIsSaving(false);
}
}, [form, validateForm, onSave]);
// 폼 콘텐츠 렌더링
const renderFormContent = useCallback(
() => (
<div className="space-y-6">
{/* 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={getPresetStyle('success')}>
{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>
<DatePicker
value={form.deliveryRequestDate}
onChange={(date) => {
setForm((prev) => ({
...prev,
deliveryRequestDate: date,
}));
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>
<DatePicker
value={form.expectedShipDate}
onChange={(date) =>
setForm((prev) => ({
...prev,
expectedShipDate: date,
}))
}
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>
)}
{/* 품목 테이블 */}
{itemGroups ? (
// 그룹핑 표시
<div className="space-y-4">
{itemGroups.map((group) => (
<div key={group.key} className={cn("border rounded-lg overflow-hidden", fieldErrors.items && "border-red-500")}>
<div className="bg-blue-50 px-4 py-2 border-b flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge variant="outline" className={getPresetStyle('info')}>
{group.label}
</Badge>
{group.productCode && (
<span className="text-sm text-muted-foreground">
{group.productCode} ({group.quantity})
</span>
)}
</div>
<span className="text-sm font-medium">
: {formatAmount(group.amount)}
</span>
</div>
<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>
{group.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">
{item.quantity}
</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 ?? 0)}
</TableCell>
<TableCell />
</TableRow>
))}
</TableBody>
</Table>
</div>
))}
</div>
) : (
// 기본 플랫 리스트
<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 ?? 0)}
</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,
itemGroups,
]
);
return (
<>
<IntegratedDetailTemplate
config={config}
mode={isEditMode ? "edit" : "create"}
isLoading={false}
onCancel={onBack}
onSubmit={() => handleSave()}
renderForm={renderFormContent}
/>
{/* 견적 선택 팝업 */}
<QuotationSelectDialog
open={isQuotationDialogOpen}
onOpenChange={setIsQuotationDialogOpen}
onSelect={handleQuotationSelect}
selectedId={form.selectedQuotation?.id}
/>
{/* 품목 추가 팝업 */}
<ItemAddDialog
open={isItemDialogOpen}
onOpenChange={setIsItemDialogOpen}
onAdd={handleAddItem}
/>
</>
);
}