diff options
Diffstat (limited to 'lib/procurement-rfqs/vendor-response/quotation-item-editor.tsx')
| -rw-r--r-- | lib/procurement-rfqs/vendor-response/quotation-item-editor.tsx | 664 |
1 files changed, 664 insertions, 0 deletions
diff --git a/lib/procurement-rfqs/vendor-response/quotation-item-editor.tsx b/lib/procurement-rfqs/vendor-response/quotation-item-editor.tsx new file mode 100644 index 00000000..e11864dc --- /dev/null +++ b/lib/procurement-rfqs/vendor-response/quotation-item-editor.tsx @@ -0,0 +1,664 @@ +"use client" + +import * as React from "react" +import { useState, useEffect, useRef } from "react" +import { toast } from "sonner" +import { format } from "date-fns" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Checkbox } from "@/components/ui/checkbox" +import { DatePicker } from "@/components/ui/date-picker" +import { + Table, + TableBody, + TableCaption, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@/components/ui/table" +import { Badge } from "@/components/ui/badge" +import { ScrollArea } from "@/components/ui/scroll-area" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from "@/components/ui/tooltip" +import { + Info, + Clock, + CalendarIcon, + ClipboardCheck, + AlertTriangle, + CheckCircle2, + RefreshCw, + Save, + FileText, + Sparkles +} from "lucide-react" + +import { formatCurrency } from "@/lib/utils" +import { updateQuotationItem } from "../services" +import { Textarea } from "@/components/ui/textarea" + +// 견적 아이템 타입 +interface QuotationItem { + id: number + quotationId: number + prItemId: number + materialCode: string | null + materialDescription: string | null + quantity: number + uom: string | null + unitPrice: number + totalPrice: number + currency: string + vendorMaterialCode: string | null + vendorMaterialDescription: string | null + deliveryDate: Date | null + leadTimeInDays: number | null + taxRate: number | null + taxAmount: number | null + discountRate: number | null + discountAmount: number | null + remark: string | null + isAlternative: boolean + isRecommended: boolean // 남겨두지만 UI에서는 사용하지 않음 + createdAt: Date + updatedAt: Date + prItem?: { + id: number + materialCode: string | null + materialDescription: string | null + // 기타 필요한 정보 + } +} + +// debounce 함수 구현 +function debounce<T extends (...args: any[]) => any>( + func: T, + wait: number +): (...args: Parameters<T>) => void { + let timeout: NodeJS.Timeout | null = null; + + return function (...args: Parameters<T>) { + if (timeout) clearTimeout(timeout); + timeout = setTimeout(() => func(...args), wait); + }; +} + +interface QuotationItemEditorProps { + items: QuotationItem[] + onItemsChange: (items: QuotationItem[]) => void + disabled?: boolean + currency: string +} + +export function QuotationItemEditor({ + items, + onItemsChange, + disabled = false, + currency +}: QuotationItemEditorProps) { + const [editingItem, setEditingItem] = useState<number | null>(null) + const [isSaving, setIsSaving] = useState(false) + + // 저장이 필요한 항목들을 추적 + const [pendingChanges, setPendingChanges] = useState<Set<number>>(new Set()) + + // 로컬 상태 업데이트 함수 - 화면에 즉시 반영하지만 서버에는 즉시 저장하지 않음 + const updateLocalItem = <K extends keyof QuotationItem>( + index: number, + field: K, + value: QuotationItem[K] + ) => { + // 로컬 상태 업데이트 + const updatedItems = [...items] + const item = { ...updatedItems[index] } + + // 필드 업데이트 + item[field] = value + + // 대체품 체크 해제 시 관련 필드 초기화 + if (field === 'isAlternative' && value === false) { + item.vendorMaterialCode = null; + item.vendorMaterialDescription = null; + item.remark = null; + } + + // 단가나 수량이 변경되면 총액 계산 + if (field === 'unitPrice' || field === 'quantity') { + item.totalPrice = Number(item.unitPrice) * Number(item.quantity) + + // 세금이 있으면 세액 계산 + if (item.taxRate) { + item.taxAmount = item.totalPrice * (item.taxRate / 100) + } + + // 할인이 있으면 할인액 계산 + if (item.discountRate) { + item.discountAmount = item.totalPrice * (item.discountRate / 100) + } + } + + // 세율이 변경되면 세액 계산 + if (field === 'taxRate') { + item.taxAmount = item.totalPrice * (value as number / 100) + } + + // 할인율이 변경되면 할인액 계산 + if (field === 'discountRate') { + item.discountAmount = item.totalPrice * (value as number / 100) + } + + // 변경된 아이템으로 교체 + updatedItems[index] = item + + // 미저장 항목으로 표시 + setPendingChanges(prev => new Set(prev).add(item.id)) + + // 부모 컴포넌트에 변경 사항 알림 + onItemsChange(updatedItems) + + // 저장 필요함을 표시 + return item + } + + // 서버에 저장하는 함수 + const saveItemToServer = async (item: QuotationItem, field: keyof QuotationItem, value: any) => { + if (disabled) return + + try { + setIsSaving(true) + + const result = await updateQuotationItem({ + id: item.id, + [field]: value, + totalPrice: item.totalPrice, + taxAmount: item.taxAmount ?? 0, + discountAmount: item.discountAmount ?? 0 + }) + + // 저장 완료 후 pendingChanges에서 제거 + setPendingChanges(prev => { + const newSet = new Set(prev) + newSet.delete(item.id) + return newSet + }) + + if (!result.success) { + toast.error(result.message || "항목 저장 중 오류가 발생했습니다") + } + } catch (error) { + console.error("항목 저장 오류:", error) + toast.error("항목 저장 중 오류가 발생했습니다") + } finally { + setIsSaving(false) + } + } + + // debounce된 저장 함수 + const debouncedSave = useRef(debounce( + (item: QuotationItem, field: keyof QuotationItem, value: any) => { + saveItemToServer(item, field, value) + }, + 800 // 800ms 지연 + )).current + + // 견적 항목 업데이트 함수 + const handleItemUpdate = (index: number, field: keyof QuotationItem, value: any) => { + const updatedItem = updateLocalItem(index, field, value) + + // debounce를 통해 서버 저장 지연 + if (!disabled) { + debouncedSave(updatedItem, field, value) + } + } + + // 모든 변경 사항 저장 + const saveAllChanges = async () => { + if (disabled || pendingChanges.size === 0) return + + setIsSaving(true) + toast.info(`${pendingChanges.size}개 항목 저장 중...`) + + try { + // 변경된 모든 항목 저장 + for (const itemId of pendingChanges) { + const index = items.findIndex(item => item.id === itemId) + if (index !== -1) { + const item = items[index] + await updateQuotationItem({ + id: item.id, + unitPrice: item.unitPrice, + totalPrice: item.totalPrice, + taxRate: item.taxRate ?? 0, + taxAmount: item.taxAmount ?? 0, + discountRate: item.discountRate ?? 0, + discountAmount: item.discountAmount ?? 0, + deliveryDate: item.deliveryDate, + leadTimeInDays: item.leadTimeInDays ?? 0, + vendorMaterialCode: item.vendorMaterialCode ?? "", + vendorMaterialDescription: item.vendorMaterialDescription ?? "", + isAlternative: item.isAlternative, + isRecommended: false, // 항상 false로 설정 (사용하지 않음) + remark: item.remark ?? "" + }) + } + } + + // 모든 변경 사항 저장 완료 + setPendingChanges(new Set()) + toast.success("모든 변경 사항이 저장되었습니다") + } catch (error) { + console.error("변경 사항 저장 오류:", error) + toast.error("변경 사항 저장 중 오류가 발생했습니다") + } finally { + setIsSaving(false) + } + } + + // blur 이벤트로 저장 트리거 (사용자가 입력 완료 후) + const handleBlur = (index: number, field: keyof QuotationItem, value: any) => { + const itemId = items[index].id + + // 해당 항목이 pendingChanges에 있다면 즉시 저장 + if (pendingChanges.has(itemId)) { + const item = items[index] + saveItemToServer(item, field, value) + } + } + + // 전체 단가 업데이트 (일괄 반영) + const handleBulkUnitPriceUpdate = () => { + if (items.length === 0) return + + // 첫 번째 아이템의 단가 가져오기 + const firstUnitPrice = items[0].unitPrice + + if (!firstUnitPrice) { + toast.error("첫 번째 항목의 단가를 먼저 입력해주세요") + return + } + + // 모든 아이템에 동일한 단가 적용 + const updatedItems = items.map(item => ({ + ...item, + unitPrice: firstUnitPrice, + totalPrice: firstUnitPrice * item.quantity, + taxAmount: item.taxRate ? (firstUnitPrice * item.quantity) * (item.taxRate / 100) : item.taxAmount, + discountAmount: item.discountRate ? (firstUnitPrice * item.quantity) * (item.discountRate / 100) : item.discountAmount + })) + + // 모든 아이템을 변경 필요 항목으로 표시 + setPendingChanges(new Set(updatedItems.map(item => item.id))) + + // 부모 컴포넌트에 변경 사항 알림 + onItemsChange(updatedItems) + + toast.info("모든 항목의 단가가 업데이트되었습니다. 변경 사항을 저장하려면 '저장' 버튼을 클릭하세요.") + } + + // 입력 핸들러 + const handleNumberInputChange = ( + index: number, + field: keyof QuotationItem, + e: React.ChangeEvent<HTMLInputElement> + ) => { + const value = e.target.value === '' ? 0 : parseFloat(e.target.value) + handleItemUpdate(index, field, value) + } + + const handleTextInputChange = ( + index: number, + field: keyof QuotationItem, + e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement> + ) => { + handleItemUpdate(index, field, e.target.value) + } + + const handleDateChange = ( + index: number, + field: keyof QuotationItem, + date: Date | undefined + ) => { + handleItemUpdate(index, field, date || null) + } + + const handleCheckboxChange = ( + index: number, + field: keyof QuotationItem, + checked: boolean + ) => { + handleItemUpdate(index, field, checked) + } + + // 날짜 형식 지정 + const formatDeliveryDate = (date: Date | null) => { + if (!date) return "-" + return format(date, "yyyy-MM-dd") + } + + // 입력 폼 필드 렌더링 + const renderInputField = (item: QuotationItem, index: number, field: keyof QuotationItem) => { + if (field === 'unitPrice' || field === 'taxRate' || field === 'discountRate' || field === 'leadTimeInDays') { + return ( + <Input + type="number" + min={0} + step={field === 'unitPrice' ? 0.01 : field === 'taxRate' || field === 'discountRate' ? 0.1 : 1} + value={item[field] as number || 0} + onChange={(e) => handleNumberInputChange(index, field, e)} + onBlur={(e) => handleBlur(index, field, parseFloat(e.target.value) || 0)} + disabled={disabled || isSaving} + className="w-full" + /> + ) + } else if (field === 'vendorMaterialCode' || field === 'vendorMaterialDescription') { + return ( + <Input + type="text" + value={item[field] as string || ''} + onChange={(e) => handleTextInputChange(index, field, e)} + onBlur={(e) => handleBlur(index, field, e.target.value)} + disabled={disabled || isSaving || !item.isAlternative} + className="w-full" + placeholder={field === 'vendorMaterialCode' ? "벤더 자재코드" : "벤더 자재명"} + /> + ) + } else if (field === 'deliveryDate') { + return ( + <DatePicker + date={item.deliveryDate ? new Date(item.deliveryDate) : undefined} + onSelect={(date) => { + handleDateChange(index, field, date); + // DatePicker는 blur 이벤트가 없으므로 즉시 저장 트리거 + if (date) handleBlur(index, field, date); + }} + disabled={disabled || isSaving} + /> + ) + } else if (field === 'isAlternative') { + return ( + <div className="flex items-center gap-1"> + <Checkbox + checked={item.isAlternative} + onCheckedChange={(checked) => { + handleCheckboxChange(index, field, checked as boolean); + handleBlur(index, field, checked as boolean); + }} + disabled={disabled || isSaving} + /> + <span className="text-xs">대체품</span> + </div> + ) + } + + return null + } + + // 대체품 필드 렌더링 + const renderAlternativeFields = (item: QuotationItem, index: number) => { + if (!item.isAlternative) return null; + + return ( + <div className="mt-2 p-3 bg-blue-50 rounded-md space-y-2 text-sm"> + {/* <div className="flex flex-col gap-2"> + <label className="text-xs font-medium text-blue-700">벤더 자재코드</label> + <Input + value={item.vendorMaterialCode || ""} + onChange={(e) => handleTextInputChange(index, 'vendorMaterialCode', e)} + onBlur={(e) => handleBlur(index, 'vendorMaterialCode', e.target.value)} + disabled={disabled || isSaving} + className="h-8 text-sm" + placeholder="벤더 자재코드 입력" + /> + </div> */} + + <div className="flex flex-col gap-2"> + <label className="text-xs font-medium text-blue-700">벤더 자재명</label> + <Input + value={item.vendorMaterialDescription || ""} + onChange={(e) => handleTextInputChange(index, 'vendorMaterialDescription', e)} + onBlur={(e) => handleBlur(index, 'vendorMaterialDescription', e.target.value)} + disabled={disabled || isSaving} + className="h-8 text-sm" + placeholder="벤더 자재명 입력" + /> + </div> + + <div className="flex flex-col gap-2"> + <label className="text-xs font-medium text-blue-700">대체품 설명</label> + <Textarea + value={item.remark || ""} + onChange={(e) => handleTextInputChange(index, 'remark', e)} + onBlur={(e) => handleBlur(index, 'remark', e.target.value)} + disabled={disabled || isSaving} + className="min-h-[60px] text-sm" + placeholder="원본과의 차이점, 대체 사유, 장점 등을 설명해주세요" + /> + </div> + </div> + ); + }; + + // 항목의 저장 상태 아이콘 표시 + const renderSaveStatus = (itemId: number) => { + if (pendingChanges.has(itemId)) { + return ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger> + <RefreshCw className="h-4 w-4 text-yellow-500 animate-spin" /> + </TooltipTrigger> + <TooltipContent> + <p>저장되지 않은 변경 사항이 있습니다</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + ) + } + + return null + } + + return ( + <div className="space-y-4"> + <div className="flex justify-between items-center"> + <div className="flex items-center gap-2"> + <h3 className="text-lg font-medium">항목 목록 ({items.length}개)</h3> + {pendingChanges.size > 0 && ( + <Badge variant="outline" className="bg-yellow-50"> + 변경 {pendingChanges.size}개 + </Badge> + )} + </div> + + <div className="flex items-center gap-2"> + {pendingChanges.size > 0 && !disabled && ( + <Button + variant="default" + size="sm" + onClick={saveAllChanges} + disabled={isSaving} + > + {isSaving ? ( + <RefreshCw className="h-4 w-4 mr-2 animate-spin" /> + ) : ( + <Save className="h-4 w-4 mr-2" /> + )} + 변경사항 저장 ({pendingChanges.size}개) + </Button> + )} + + {!disabled && ( + <Button + variant="outline" + size="sm" + onClick={handleBulkUnitPriceUpdate} + disabled={items.length === 0 || isSaving} + > + 첫 항목 단가로 일괄 적용 + </Button> + )} + </div> + </div> + + <ScrollArea className="h-[500px] rounded-md border"> + <Table> + <TableHeader className="sticky top-0 bg-background"> + <TableRow> + <TableHead className="w-[50px]">번호</TableHead> + <TableHead>자재코드</TableHead> + <TableHead>자재명</TableHead> + <TableHead>수량</TableHead> + <TableHead>단위</TableHead> + <TableHead>단가</TableHead> + <TableHead>금액</TableHead> + <TableHead> + <div className="flex items-center gap-1"> + 세율(%) + <TooltipProvider> + <Tooltip> + <TooltipTrigger> + <Info className="h-4 w-4" /> + </TooltipTrigger> + <TooltipContent> + <p>세율을 입력하면 자동으로 세액이 계산됩니다.</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + </div> + </TableHead> + <TableHead> + <div className="flex items-center gap-1"> + 납품일 + <TooltipProvider> + <Tooltip> + <TooltipTrigger> + <Info className="h-4 w-4" /> + </TooltipTrigger> + <TooltipContent> + <p>납품 가능한 날짜를 선택해주세요.</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + </div> + </TableHead> + <TableHead>리드타임(일)</TableHead> + <TableHead> + <div className="flex items-center gap-1"> + 대체품 + <TooltipProvider> + <Tooltip> + <TooltipTrigger> + <Info className="h-4 w-4" /> + </TooltipTrigger> + <TooltipContent> + <p>요청된 제품의 대체품을 제안할 경우 선택하세요.</p> + <p>대체품을 선택하면 추가 정보를 입력할 수 있습니다.</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + </div> + </TableHead> + <TableHead className="w-[50px]">상태</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {items.length === 0 ? ( + <TableRow> + <TableCell colSpan={12} className="text-center py-10"> + 견적 항목이 없습니다 + </TableCell> + </TableRow> + ) : ( + items.map((item, index) => ( + <React.Fragment key={item.id}> + <TableRow className={pendingChanges.has(item.id) ? "bg-yellow-50/30" : ""}> + <TableCell> + {index + 1} + </TableCell> + <TableCell> + {item.materialCode || "-"} + </TableCell> + <TableCell> + <div className="font-medium max-w-xs truncate"> + {item.materialDescription || "-"} + </div> + </TableCell> + <TableCell> + {item.quantity} + </TableCell> + <TableCell> + {item.uom || "-"} + </TableCell> + <TableCell> + {renderInputField(item, index, 'unitPrice')} + </TableCell> + <TableCell> + {formatCurrency(item.totalPrice, currency)} + </TableCell> + <TableCell> + {renderInputField(item, index, 'taxRate')} + </TableCell> + <TableCell> + {renderInputField(item, index, 'deliveryDate')} + </TableCell> + <TableCell> + {renderInputField(item, index, 'leadTimeInDays')} + </TableCell> + <TableCell> + {renderInputField(item, index, 'isAlternative')} + </TableCell> + <TableCell> + {renderSaveStatus(item.id)} + </TableCell> + </TableRow> + + {/* 대체품으로 선택된 경우 추가 정보 행 표시 */} + {item.isAlternative && ( + <TableRow className={pendingChanges.has(item.id) ? "bg-blue-50/40" : "bg-blue-50/20"}> + <TableCell colSpan={1}></TableCell> + <TableCell colSpan={10}> + {renderAlternativeFields(item, index)} + </TableCell> + <TableCell colSpan={1}></TableCell> + </TableRow> + )} + </React.Fragment> + )) + )} + </TableBody> + </Table> + </ScrollArea> + + {isSaving && ( + <div className="flex items-center justify-center text-sm text-muted-foreground"> + <Clock className="h-4 w-4 animate-spin mr-2" /> + 변경 사항을 저장 중입니다... + </div> + )} + + <div className="bg-muted p-4 rounded-md"> + <h4 className="text-sm font-medium mb-2">안내 사항</h4> + <ul className="text-sm space-y-1 text-muted-foreground"> + <li className="flex items-start gap-2"> + <AlertTriangle className="h-4 w-4 mt-0.5 flex-shrink-0" /> + <span>단가와 납품일은 필수로 입력해야 합니다.</span> + </li> + <li className="flex items-start gap-2"> + <ClipboardCheck className="h-4 w-4 mt-0.5 flex-shrink-0" /> + <span>입력 후 다른 필드로 이동하면 자동으로 저장됩니다. 여러 항목을 변경한 후 '저장' 버튼을 사용할 수도 있습니다.</span> + </li> + <li className="flex items-start gap-2"> + <FileText className="h-4 w-4 mt-0.5 flex-shrink-0" /> + <span><strong>대체품</strong>으로 제안하는 경우 자재명, 대체품 설명을 입력해주세요.</span> + </li> + </ul> + </div> + </div> + ) +}
\ No newline at end of file |
