summaryrefslogtreecommitdiff
path: root/lib/techsales-rfq/vendor-response/quotation-item-editor.tsx
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-05-28 19:03:21 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-05-28 19:03:21 +0000
commit5036cf2908792cef45f06256e71f10920f647f49 (patch)
tree3116e7419e872d45025d1d48e6ddaffe2ba2dd38 /lib/techsales-rfq/vendor-response/quotation-item-editor.tsx
parent7ae037e9c2fc0be1fe68cecb461c5e1e837cb0da (diff)
(김준회) 기술영업 조선 RFQ (SHI/벤더)
Diffstat (limited to 'lib/techsales-rfq/vendor-response/quotation-item-editor.tsx')
-rw-r--r--lib/techsales-rfq/vendor-response/quotation-item-editor.tsx664
1 files changed, 664 insertions, 0 deletions
diff --git a/lib/techsales-rfq/vendor-response/quotation-item-editor.tsx b/lib/techsales-rfq/vendor-response/quotation-item-editor.tsx
new file mode 100644
index 00000000..e11864dc
--- /dev/null
+++ b/lib/techsales-rfq/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