diff options
Diffstat (limited to 'lib/techsales-rfq/vendor-response')
5 files changed, 222 insertions, 1283 deletions
diff --git a/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx b/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx index 3449dcb6..20b2703c 100644 --- a/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx +++ b/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx @@ -1,7 +1,7 @@ "use client" import * as React from "react" -import { useState } from "react" +import { useState, useEffect } from "react" import { useRouter } from "next/navigation" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Button } from "@/components/ui/button" @@ -10,7 +10,7 @@ import { Label } from "@/components/ui/label" import { Textarea } from "@/components/ui/textarea" import { Badge } from "@/components/ui/badge" import { ScrollArea } from "@/components/ui/scroll-area" -import { CalendarIcon, Save, Send, AlertCircle } from "lucide-react" +import { CalendarIcon, Send, AlertCircle, Upload, X, FileText, Download } from "lucide-react" import { Calendar } from "@/components/ui/calendar" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" @@ -26,6 +26,13 @@ interface QuotationResponseTabProps { currency: string | null validUntil: Date | null remark: string | null + quotationAttachments?: Array<{ + id: number + fileName: string + fileSize: number + filePath: string + description?: string | null + }> rfq: { id: number rfqCode: string | null @@ -58,38 +65,93 @@ export function QuotationResponseTab({ quotation }: QuotationResponseTabProps) { ) const [remark, setRemark] = useState(quotation.remark || "") const [isLoading, setIsLoading] = useState(false) + const [attachments, setAttachments] = useState<Array<{ + id?: number + fileName: string + fileSize: number + filePath: string + isNew?: boolean + file?: File + }>>([]) + const [isUploadingFiles, setIsUploadingFiles] = useState(false) const router = useRouter() + // // 초기 첨부파일 데이터 로드 + // useEffect(() => { + // if (quotation.quotationAttachments) { + // setAttachments(quotation.quotationAttachments.map(att => ({ + // id: att.id, + // fileName: att.fileName, + // fileSize: att.fileSize, + // filePath: att.filePath, + // isNew: false + // }))) + // } + // }, [quotation.quotationAttachments]) + const rfq = quotation.rfq const isDueDatePassed = rfq?.dueDate ? new Date(rfq.dueDate) < new Date() : false - const canSubmit = quotation.status === "Draft" && !isDueDatePassed - const canEdit = ["Draft", "Revised"].includes(quotation.status) && !isDueDatePassed + const canSubmit = !["Accepted", "Rejected"].includes(quotation.status) && !isDueDatePassed + const canEdit = !["Accepted", "Rejected"].includes(quotation.status) && !isDueDatePassed + + // 파일 업로드 핸들러 + const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => { + const files = event.target.files + if (!files) return + + Array.from(files).forEach(file => { + setAttachments(prev => [ + ...prev, + { + fileName: file.name, + fileSize: file.size, + filePath: '', + isNew: true, + file + } + ]) + }) + } + + // 첨부파일 제거 + const removeAttachment = (index: number) => { + setAttachments(prev => prev.filter((_, i) => i !== index)) + } + + // 파일 업로드 함수 + const uploadFiles = async () => { + const newFiles = attachments.filter(att => att.isNew && att.file) + if (newFiles.length === 0) return [] + + setIsUploadingFiles(true) + const uploadedFiles = [] - const handleSaveDraft = async () => { - setIsLoading(true) try { - const { updateTechSalesVendorQuotation } = await import("@/lib/techsales-rfq/service") - - const result = await updateTechSalesVendorQuotation({ - id: quotation.id, - currency, - totalPrice, - validUntil: validUntil!, - remark, - updatedBy: 1 // TODO: 실제 사용자 ID로 변경 - }) + for (const attachment of newFiles) { + const formData = new FormData() + formData.append('file', attachment.file!) + + const response = await fetch('/api/upload', { + method: 'POST', + body: formData + }) - if (result.error) { - toast.error(result.error) - } else { - toast.success("임시 저장되었습니다.") - // 페이지 새로고침 대신 router.refresh() 사용 - router.refresh() + if (!response.ok) throw new Error('파일 업로드 실패') + + const result = await response.json() + uploadedFiles.push({ + fileName: result.fileName, + filePath: result.url, + fileSize: attachment.fileSize + }) } - } catch { - toast.error("저장 중 오류가 발생했습니다.") + return uploadedFiles + } catch (error) { + console.error('파일 업로드 오류:', error) + toast.error('파일 업로드 중 오류가 발생했습니다.') + return [] } finally { - setIsLoading(false) + setIsUploadingFiles(false) } } @@ -101,6 +163,9 @@ export function QuotationResponseTab({ quotation }: QuotationResponseTabProps) { setIsLoading(true) try { + // 파일 업로드 먼저 처리 + const uploadedFiles = await uploadFiles() + const { submitTechSalesVendorQuotation } = await import("@/lib/techsales-rfq/service") const result = await submitTechSalesVendorQuotation({ @@ -109,6 +174,7 @@ export function QuotationResponseTab({ quotation }: QuotationResponseTabProps) { totalPrice, validUntil: validUntil!, remark, + attachments: uploadedFiles, updatedBy: 1 // TODO: 실제 사용자 ID로 변경 }) @@ -116,8 +182,10 @@ export function QuotationResponseTab({ quotation }: QuotationResponseTabProps) { toast.error(result.error) } else { toast.success("견적서가 제출되었습니다.") - // 페이지 새로고침 대신 router.refresh() 사용 - router.refresh() + // // 페이지 새로고침 대신 router.refresh() 사용 + // router.refresh() + // 페이지 새로고침 + window.location.reload() } } catch { toast.error("제출 중 오류가 발생했습니다.") @@ -312,28 +380,98 @@ export function QuotationResponseTab({ quotation }: QuotationResponseTabProps) { /> </div> + {/* 첨부파일 */} + <div className="space-y-4"> + <Label>첨부파일</Label> + + {/* 파일 업로드 버튼 */} + {canEdit && ( + <div className="flex items-center gap-2"> + <Button + type="button" + variant="outline" + size="sm" + disabled={isUploadingFiles} + onClick={() => document.getElementById('file-input')?.click()} + > + <Upload className="h-4 w-4 mr-2" /> + 파일 선택 + </Button> + <input + id="file-input" + type="file" + multiple + onChange={handleFileSelect} + className="hidden" + accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.jpg,.jpeg,.png,.zip" + /> + <span className="text-sm text-muted-foreground"> + PDF, 문서파일, 이미지파일, 압축파일 등 + </span> + </div> + )} + + {/* 첨부파일 목록 */} + {attachments.length > 0 && ( + <div className="space-y-2"> + {attachments.map((attachment, index) => ( + <div + key={index} + className="flex items-center justify-between p-3 border rounded-lg bg-muted/50" + > + <div className="flex items-center gap-2"> + <FileText className="h-4 w-4 text-muted-foreground" /> + <div> + <div className="text-sm font-medium">{attachment.fileName}</div> + <div className="text-xs text-muted-foreground"> + {(attachment.fileSize / 1024 / 1024).toFixed(2)} MB + {attachment.isNew && ( + <Badge variant="secondary" className="ml-2"> + 새 파일 + </Badge> + )} + </div> + </div> + </div> + <div className="flex items-center gap-2"> + {!attachment.isNew && ( + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => window.open(attachment.filePath, '_blank')} + > + <Download className="h-4 w-4" /> + </Button> + )} + {canEdit && ( + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => removeAttachment(index)} + > + <X className="h-4 w-4" /> + </Button> + )} + </div> + </div> + ))} + </div> + )} + </div> + {/* 액션 버튼 */} - {canEdit && ( - <div className="flex gap-2 pt-4"> + {canEdit && canSubmit && ( + <div className="flex justify-center pt-4"> <Button - variant="outline" - onClick={handleSaveDraft} - disabled={isLoading} - className="flex-1" + onClick={handleSubmit} + disabled={isLoading || !totalPrice || !currency || !validUntil} + className="w-full " > - <Save className="mr-2 h-4 w-4" /> - 임시 저장 + <Send className="mr-2 h-4 w-4" /> + 견적서 제출 </Button> - {canSubmit && ( - <Button - onClick={handleSubmit} - disabled={isLoading || !totalPrice || !currency || !validUntil} - className="flex-1" - > - <Send className="mr-2 h-4 w-4" /> - 견적서 제출 - </Button> - )} </div> )} </CardContent> diff --git a/lib/techsales-rfq/vendor-response/quotation-editor.tsx b/lib/techsales-rfq/vendor-response/quotation-editor.tsx deleted file mode 100644 index 54058214..00000000 --- a/lib/techsales-rfq/vendor-response/quotation-editor.tsx +++ /dev/null @@ -1,559 +0,0 @@ -"use client" - -import * as React from "react" -import { useState, useEffect } from "react" -import { useForm } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import * as z from "zod" -import { toast } from "sonner" -import { useRouter } from "next/navigation" -import { CalendarIcon, Save, Send, ArrowLeft } from "lucide-react" - -import { Button } from "@/components/ui/button" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { Input } from "@/components/ui/input" -import { Textarea } from "@/components/ui/textarea" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -import { Badge } from "@/components/ui/badge" -import { Separator } from "@/components/ui/separator" -import { DatePicker } from "@/components/ui/date-picker" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" -import { Skeleton } from "@/components/ui/skeleton" - -import { formatCurrency, formatDate } from "@/lib/utils" -import { - updateTechSalesVendorQuotation, - submitTechSalesVendorQuotation, - fetchCurrencies -} from "../service" - -// 견적서 폼 스키마 (techsales용 단순화) -const quotationFormSchema = z.object({ - currency: z.string().min(1, "통화를 선택해주세요"), - totalPrice: z.string().min(1, "총액을 입력해주세요"), - validUntil: z.date({ - required_error: "견적 유효기간을 선택해주세요", - invalid_type_error: "유효한 날짜를 선택해주세요", - }), - remark: z.string().optional(), -}) - -type QuotationFormValues = z.infer<typeof quotationFormSchema> - -// 통화 타입 -interface Currency { - code: string - name: string -} - -// 이 컴포넌트에 전달되는 견적서 데이터 타입 (techsales용 단순화) -interface TechSalesVendorQuotation { - id: number - rfqId: number - vendorId: number - quotationCode: string | null - quotationVersion: number | null - totalPrice: string | null - currency: string | null - validUntil: Date | null - status: "Draft" | "Submitted" | "Revised" | "Rejected" | "Accepted" - remark: string | null - rejectionReason: string | null - submittedAt: Date | null - acceptedAt: Date | null - createdAt: Date - updatedAt: Date - rfq: { - id: number - rfqCode: string | null - dueDate: Date | null - status: string | null - materialCode: string | null - remark: string | null - projectSnapshot?: { - pspid?: string - projNm?: string - sector?: string - projMsrm?: number - kunnr?: string - kunnrNm?: string - ptypeNm?: string - } | null - seriesSnapshot?: Array<{ - pspid: string - sersNo: string - scDt?: string - klDt?: string - lcDt?: string - dlDt?: string - dockNo?: string - dockNm?: string - projNo?: string - post1?: string - }> | null - item?: { - id: number - itemCode: string | null - itemList: string | null - } | null - biddingProject?: { - id: number - pspid: string | null - projNm: string | null - } | null - createdByUser?: { - id: number - name: string | null - email: string | null - } | null - } - vendor: { - id: number - vendorName: string - vendorCode: string | null - } -} - -interface TechSalesQuotationEditorProps { - quotation: TechSalesVendorQuotation -} - -export default function TechSalesQuotationEditor({ quotation }: TechSalesQuotationEditorProps) { - const router = useRouter() - const [isSubmitting, setIsSubmitting] = useState(false) - const [isSaving, setIsSaving] = useState(false) - const [currencies, setCurrencies] = useState<Currency[]>([]) - const [loadingCurrencies, setLoadingCurrencies] = useState(true) - - // 폼 초기화 - const form = useForm<QuotationFormValues>({ - resolver: zodResolver(quotationFormSchema), - defaultValues: { - currency: quotation.currency || "USD", - totalPrice: quotation.totalPrice || "", - validUntil: quotation.validUntil || undefined, - remark: quotation.remark || "", - }, - }) - - // 통화 목록 로드 - useEffect(() => { - const loadCurrencies = async () => { - try { - const { data, error } = await fetchCurrencies() - if (error) { - toast.error("통화 목록을 불러오는데 실패했습니다") - return - } - setCurrencies(data || []) - } catch (error) { - console.error("Error loading currencies:", error) - toast.error("통화 목록을 불러오는데 실패했습니다") - } finally { - setLoadingCurrencies(false) - } - } - - loadCurrencies() - }, []) - - // 마감일 확인 - const isBeforeDueDate = () => { - if (!quotation.rfq.dueDate) return true - return new Date() <= new Date(quotation.rfq.dueDate) - } - - // 편집 가능 여부 확인 - const isEditable = () => { - return quotation.status === "Draft" || quotation.status === "Rejected" - } - - // 제출 가능 여부 확인 - const isSubmitReady = () => { - const values = form.getValues() - return values.currency && - values.totalPrice && - parseFloat(values.totalPrice) > 0 && - values.validUntil && - isBeforeDueDate() - } - - // 저장 핸들러 - const handleSave = async () => { - if (!isEditable()) { - toast.error("편집할 수 없는 상태입니다") - return - } - - setIsSaving(true) - try { - const values = form.getValues() - const { data, error } = await updateTechSalesVendorQuotation({ - id: quotation.id, - currency: values.currency, - totalPrice: values.totalPrice, - validUntil: values.validUntil, - remark: values.remark, - updatedBy: quotation.vendorId, // 임시로 vendorId 사용 - }) - - if (error) { - toast.error(error) - return - } - - toast.success("견적서가 저장되었습니다") - router.refresh() - } catch (error) { - console.error("Error saving quotation:", error) - toast.error("견적서 저장 중 오류가 발생했습니다") - } finally { - setIsSaving(false) - } - } - - // 제출 핸들러 - const handleSubmit = async () => { - if (!isEditable()) { - toast.error("제출할 수 없는 상태입니다") - return - } - - if (!isSubmitReady()) { - toast.error("필수 항목을 모두 입력해주세요") - return - } - - if (!isBeforeDueDate()) { - toast.error("마감일이 지났습니다") - return - } - - setIsSubmitting(true) - try { - const values = form.getValues() - const { data, error } = await submitTechSalesVendorQuotation({ - id: quotation.id, - currency: values.currency, - totalPrice: values.totalPrice, - validUntil: values.validUntil, - remark: values.remark, - updatedBy: quotation.vendorId, // 임시로 vendorId 사용 - }) - - if (error) { - toast.error(error) - return - } - - toast.success("견적서가 제출되었습니다") - router.push("/ko/partners/techsales/rfq-ship") - } catch (error) { - console.error("Error submitting quotation:", error) - toast.error("견적서 제출 중 오류가 발생했습니다") - } finally { - setIsSubmitting(false) - } - } - - // 상태 배지 - const getStatusBadge = (status: string) => { - const statusConfig = { - "Draft": { label: "초안", variant: "secondary" as const }, - "Submitted": { label: "제출됨", variant: "default" as const }, - "Revised": { label: "수정됨", variant: "outline" as const }, - "Rejected": { label: "반려됨", variant: "destructive" as const }, - "Accepted": { label: "승인됨", variant: "success" as const }, - } - - const config = statusConfig[status as keyof typeof statusConfig] || { - label: status, - variant: "secondary" as const - } - - return <Badge variant={config.variant}>{config.label}</Badge> - } - - return ( - <div className="container max-w-4xl mx-auto py-6 space-y-6"> - {/* 헤더 */} - <div className="flex items-center justify-between"> - <div className="flex items-center space-x-4"> - <Button - variant="ghost" - size="sm" - onClick={() => router.back()} - > - <ArrowLeft className="h-4 w-4 mr-2" /> - 뒤로가기 - </Button> - <div> - <h1 className="text-2xl font-bold">기술영업 견적서</h1> - <p className="text-muted-foreground"> - RFQ: {quotation.rfq.rfqCode} | {getStatusBadge(quotation.status)} - </p> - </div> - </div> - <div className="flex items-center space-x-2"> - {isEditable() && ( - <> - <Button - variant="outline" - onClick={handleSave} - disabled={isSaving} - > - <Save className="h-4 w-4 mr-2" /> - {isSaving ? "저장 중..." : "저장"} - </Button> - <Button - onClick={handleSubmit} - disabled={isSubmitting || !isSubmitReady()} - > - <Send className="h-4 w-4 mr-2" /> - {isSubmitting ? "제출 중..." : "제출"} - </Button> - </> - )} - </div> - </div> - - <div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> - {/* 왼쪽: RFQ 정보 */} - <div className="lg:col-span-1 space-y-6"> - {/* RFQ 기본 정보 */} - <Card> - <CardHeader> - <CardTitle>RFQ 정보</CardTitle> - </CardHeader> - <CardContent className="space-y-4"> - <div> - <label className="text-sm font-medium text-muted-foreground">RFQ 번호</label> - <p className="font-mono">{quotation.rfq.rfqCode}</p> - </div> - <div> - <label className="text-sm font-medium text-muted-foreground">자재 그룹</label> - <p>{quotation.rfq.materialCode || "N/A"}</p> - </div> - <div> - <label className="text-sm font-medium text-muted-foreground">자재명</label> - <p>{quotation.rfq.item?.itemList || "N/A"}</p> - </div> - <div> - <label className="text-sm font-medium text-muted-foreground">마감일</label> - <p className={!isBeforeDueDate() ? "text-red-600 font-medium" : ""}> - {quotation.rfq.dueDate ? formatDate(quotation.rfq.dueDate) : "N/A"} - </p> - </div> - {quotation.rfq.remark && ( - <div> - <label className="text-sm font-medium text-muted-foreground">비고</label> - <p className="text-sm">{quotation.rfq.remark}</p> - </div> - )} - </CardContent> - </Card> - - {/* 프로젝트 정보 */} - {quotation.rfq.projectSnapshot && ( - <Card> - <CardHeader> - <CardTitle>프로젝트 정보</CardTitle> - </CardHeader> - <CardContent className="space-y-3"> - <div> - <label className="text-sm font-medium text-muted-foreground">프로젝트 번호</label> - <p className="font-mono">{quotation.rfq.projectSnapshot.pspid}</p> - </div> - <div> - <label className="text-sm font-medium text-muted-foreground">프로젝트명</label> - <p>{quotation.rfq.projectSnapshot.projNm || "N/A"}</p> - </div> - <div> - <label className="text-sm font-medium text-muted-foreground">선종</label> - <p>{quotation.rfq.projectSnapshot.ptypeNm || "N/A"}</p> - </div> - <div> - <label className="text-sm font-medium text-muted-foreground">척수</label> - <p>{quotation.rfq.projectSnapshot.projMsrm || "N/A"}</p> - </div> - <div> - <label className="text-sm font-medium text-muted-foreground">선주</label> - <p>{quotation.rfq.projectSnapshot.kunnrNm || "N/A"}</p> - </div> - </CardContent> - </Card> - )} - - {/* 시리즈 정보 */} - {quotation.rfq.seriesSnapshot && quotation.rfq.seriesSnapshot.length > 0 && ( - <Card> - <CardHeader> - <CardTitle>시리즈 일정</CardTitle> - </CardHeader> - <CardContent> - <div className="space-y-3"> - {quotation.rfq.seriesSnapshot.map((series, index) => ( - <div key={index} className="border rounded p-3"> - <div className="font-medium mb-2">시리즈 {series.sersNo}</div> - <div className="grid grid-cols-2 gap-2 text-sm"> - {series.klDt && ( - <div> - <span className="text-muted-foreground">K/L:</span> {formatDate(series.klDt)} - </div> - )} - {series.dlDt && ( - <div> - <span className="text-muted-foreground">인도:</span> {formatDate(series.dlDt)} - </div> - )} - </div> - </div> - ))} - </div> - </CardContent> - </Card> - )} - </div> - - {/* 오른쪽: 견적서 입력 폼 */} - <div className="lg:col-span-2"> - <Card> - <CardHeader> - <CardTitle>견적서 작성</CardTitle> - <CardDescription> - 총액 기반으로 견적을 작성해주세요. - </CardDescription> - </CardHeader> - <CardContent> - <Form {...form}> - <form className="space-y-6"> - {/* 통화 선택 */} - <FormField - control={form.control} - name="currency" - render={({ field }) => ( - <FormItem> - <FormLabel>통화 *</FormLabel> - <Select - onValueChange={field.onChange} - defaultValue={field.value} - disabled={!isEditable()} - > - <FormControl> - <SelectTrigger> - <SelectValue placeholder="통화를 선택하세요" /> - </SelectTrigger> - </FormControl> - <SelectContent> - {loadingCurrencies ? ( - <div className="p-2"> - <Skeleton className="h-4 w-full" /> - </div> - ) : ( - currencies.map((currency) => ( - <SelectItem key={currency.code} value={currency.code}> - {currency.code} - {currency.name} - </SelectItem> - )) - )} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - - {/* 총액 입력 */} - <FormField - control={form.control} - name="totalPrice" - render={({ field }) => ( - <FormItem> - <FormLabel>총액 *</FormLabel> - <FormControl> - <Input - type="number" - step="0.01" - placeholder="총액을 입력하세요" - disabled={!isEditable()} - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 유효기간 */} - <FormField - control={form.control} - name="validUntil" - render={({ field }) => ( - <FormItem> - <FormLabel>견적 유효기간 *</FormLabel> - <FormControl> - <DatePicker - date={field.value} - onDateChange={field.onChange} - disabled={!isEditable()} - placeholder="유효기간을 선택하세요" - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 비고 */} - <FormField - control={form.control} - name="remark" - render={({ field }) => ( - <FormItem> - <FormLabel>비고</FormLabel> - <FormControl> - <Textarea - placeholder="추가 설명이나 특이사항을 입력하세요" - disabled={!isEditable()} - rows={4} - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 반려 사유 (반려된 경우에만 표시) */} - {quotation.status === "Rejected" && quotation.rejectionReason && ( - <div className="p-4 bg-red-50 border border-red-200 rounded-lg"> - <label className="text-sm font-medium text-red-800">반려 사유</label> - <p className="text-sm text-red-700 mt-1">{quotation.rejectionReason}</p> - </div> - )} - - {/* 제출 정보 */} - {quotation.submittedAt && ( - <div className="p-4 bg-blue-50 border border-blue-200 rounded-lg"> - <label className="text-sm font-medium text-blue-800">제출 정보</label> - <p className="text-sm text-blue-700 mt-1"> - 제출일: {formatDate(quotation.submittedAt)} - </p> - </div> - )} - </form> - </Form> - </CardContent> - </Card> - </div> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/lib/techsales-rfq/vendor-response/quotation-item-editor.tsx b/lib/techsales-rfq/vendor-response/quotation-item-editor.tsx deleted file mode 100644 index 92bec96a..00000000 --- a/lib/techsales-rfq/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 diff --git a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx index b89f8953..39de94ed 100644 --- a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx +++ b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx @@ -30,7 +30,6 @@ interface QuotationWithRfqCode extends TechSalesVendorQuotations { // 아이템 정보 itemName?: string; - itemCount?: number; // 프로젝트 정보 @@ -38,6 +37,9 @@ interface QuotationWithRfqCode extends TechSalesVendorQuotations { pspid?: string; sector?: string; + // RFQ 정보 + description?: string; + // 벤더 정보 vendorName?: string; vendorCode?: string; @@ -194,6 +196,33 @@ export function getColumns({ router, openAttachmentsSheet, openItemsDialog }: Ge // enableHiding: true, // }, { + accessorKey: "description", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="RFQ title" /> + ), + cell: ({ row }) => { + const description = row.getValue("description") as string; + return ( + <div className="min-w-48 max-w-64"> + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <span className="truncate block text-sm"> + {description || "N/A"} + </span> + </TooltipTrigger> + <TooltipContent> + <p className="max-w-xs">{description || "N/A"}</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + </div> + ); + }, + enableSorting: true, + enableHiding: true, + }, + { accessorKey: "projNm", header: ({ column }) => ( <DataTableColumnHeaderSimple column={column} title="프로젝트명" /> @@ -313,7 +342,6 @@ export function getColumns({ router, openAttachmentsSheet, openItemsDialog }: Ge cell: ({ row }) => { const quotation = row.original const attachmentCount = quotation.attachmentCount || 0 - const handleClick = () => { openAttachmentsSheet(quotation.rfqId) } diff --git a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx index 5e5d4f39..4c5cdf8e 100644 --- a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx +++ b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx @@ -38,12 +38,15 @@ interface QuotationWithRfqCode extends TechSalesVendorQuotations { rfqStatus?: string; itemName?: string | null; projNm?: string | null; - quotationCode?: string | null; - - rejectionReason?: string | null; - acceptedAt?: Date | null; + description?: string | null; attachmentCount?: number; itemCount?: number; + pspid?: string | null; + sector?: string | null; + vendorName?: string | null; + vendorCode?: string | null; + createdByName?: string | null; + updatedByName?: string | null; } interface VendorQuotationsTableProps { @@ -380,7 +383,7 @@ export function VendorQuotationsTable({ vendorId, rfqType }: VendorQuotationsTab // useDataTable 훅 사용 const { table } = useDataTable({ data: stableData, - columns, + columns: columns as any, pageCount, rowCount: total, filterFields, @@ -391,7 +394,7 @@ export function VendorQuotationsTable({ vendorId, rfqType }: VendorQuotationsTab enableRowSelection: true, // 행 선택 활성화 initialState: { sorting: initialSettings.sort, - columnPinning: { right: ["actions"] }, + columnPinning: { right: ["actions", "items", "attachments"] }, }, getRowId: (originalRow) => String(originalRow.id), shallow: false, @@ -417,13 +420,6 @@ export function VendorQuotationsTable({ vendorId, rfqType }: VendorQuotationsTab <div className="w-full"> <div className="overflow-x-auto"> <div className="relative"> - {/* 로딩 오버레이 (재로딩 시) */} - {/* {!isInitialLoad && isLoading && ( - <div className="absolute h-full w-full inset-0 bg-background/90 backdrop-blur-md z-10 flex items-center justify-center"> - <CenterLoadingIndicator /> - </div> - )} */} - <DataTable table={table} className="min-w-full" |
