summaryrefslogtreecommitdiff
path: root/lib/procurement-rfqs/vendor-response/quotation-item-editor.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/procurement-rfqs/vendor-response/quotation-item-editor.tsx')
-rw-r--r--lib/procurement-rfqs/vendor-response/quotation-item-editor.tsx664
1 files changed, 0 insertions, 664 deletions
diff --git a/lib/procurement-rfqs/vendor-response/quotation-item-editor.tsx b/lib/procurement-rfqs/vendor-response/quotation-item-editor.tsx
deleted file mode 100644
index e11864dc..00000000
--- a/lib/procurement-rfqs/vendor-response/quotation-item-editor.tsx
+++ /dev/null
@@ -1,664 +0,0 @@
-"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