diff options
Diffstat (limited to 'lib/b-rfq')
18 files changed, 5705 insertions, 535 deletions
diff --git a/lib/b-rfq/attachment/request-revision-dialog.tsx b/lib/b-rfq/attachment/request-revision-dialog.tsx new file mode 100644 index 00000000..90d5b543 --- /dev/null +++ b/lib/b-rfq/attachment/request-revision-dialog.tsx @@ -0,0 +1,205 @@ +// components/rfq/request-revision-dialog.tsx +"use client"; + +import { useState, useTransition } from "react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Textarea } from "@/components/ui/textarea"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { AlertTriangle, Loader2 } from "lucide-react"; +import { useToast } from "@/hooks/use-toast"; +import { requestRevision } from "../service"; + +const revisionFormSchema = z.object({ + revisionReason: z + .string() + .min(10, "수정 요청 사유를 최소 10자 이상 입력해주세요") + .max(500, "수정 요청 사유는 500자를 초과할 수 없습니다"), +}); + +type RevisionFormData = z.infer<typeof revisionFormSchema>; + +interface RequestRevisionDialogProps { + responseId: number; + attachmentType: string; + serialNo: string; + vendorName?: string; + currentRevision: string; + trigger?: React.ReactNode; + onSuccess?: () => void; +} + +export function RequestRevisionDialog({ + responseId, + attachmentType, + serialNo, + vendorName, + currentRevision, + trigger, + onSuccess, +}: RequestRevisionDialogProps) { + const [open, setOpen] = useState(false); + const [isPending, startTransition] = useTransition(); + const { toast } = useToast(); + + const form = useForm<RevisionFormData>({ + resolver: zodResolver(revisionFormSchema), + defaultValues: { + revisionReason: "", + }, + }); + + const handleOpenChange = (newOpen: boolean) => { + setOpen(newOpen); + // 다이얼로그가 닫힐 때 form 리셋 + if (!newOpen) { + form.reset(); + } + }; + + const handleCancel = () => { + form.reset(); + setOpen(false); + }; + + const onSubmit = async (data: RevisionFormData) => { + startTransition(async () => { + try { + const result = await requestRevision(responseId, data.revisionReason); + + if (!result.success) { + throw new Error(result.message); + } + + toast({ + title: "수정 요청 완료", + description: result.message, + }); + + setOpen(false); + form.reset(); + onSuccess?.(); + + } catch (error) { + console.error("Request revision error:", error); + toast({ + title: "수정 요청 실패", + description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.", + variant: "destructive", + }); + } + }); + }; + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogTrigger asChild> + {trigger || ( + <Button size="sm" variant="outline"> + <AlertTriangle className="h-3 w-3 mr-1" /> + 수정요청 + </Button> + )} + </DialogTrigger> + <DialogContent className="max-w-lg"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <AlertTriangle className="h-5 w-5 text-orange-600" /> + 수정 요청 + </DialogTitle> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Badge variant="outline">{serialNo}</Badge> + <span>{attachmentType}</span> + <Badge variant="secondary">{currentRevision}</Badge> + {vendorName && ( + <> + <span>•</span> + <span>{vendorName}</span> + </> + )} + </div> + </DialogHeader> + + <div className="space-y-4"> + <div className="bg-orange-50 border border-orange-200 rounded-lg p-4"> + <div className="flex items-start gap-2"> + <AlertTriangle className="h-4 w-4 text-orange-600 mt-0.5 flex-shrink-0" /> + <div className="text-sm text-orange-800"> + <p className="font-medium mb-1">수정 요청 안내</p> + <p> + 벤더에게 현재 제출된 응답에 대한 수정을 요청합니다. + 수정 요청 후 벤더는 새로운 파일을 다시 제출할 수 있습니다. + </p> + </div> + </div> + </div> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + <FormField + control={form.control} + name="revisionReason" + render={({ field }) => ( + <FormItem> + <FormLabel className="text-base font-medium"> + 수정 요청 사유 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Textarea + placeholder="수정이 필요한 구체적인 사유를 입력해주세요... 예: 제출된 도면에서 치수 정보가 누락되었습니다." + className="resize-none" + rows={4} + disabled={isPending} + {...field} + /> + </FormControl> + <div className="flex justify-between text-xs text-muted-foreground"> + <FormMessage /> + <span>{field.value?.length || 0}/500</span> + </div> + </FormItem> + )} + /> + + <div className="flex justify-end gap-2 pt-2"> + <Button + type="button" + variant="outline" + onClick={handleCancel} + disabled={isPending} + > + 취소 + </Button> + <Button + type="submit" + disabled={isPending} + // className="bg-orange-600 hover:bg-orange-700" + > + {isPending && <Loader2 className="h-4 w-4 mr-2 animate-spin" />} + {isPending ? "요청 중..." : "수정 요청"} + </Button> + </div> + </form> + </Form> + </div> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/lib/b-rfq/attachment/vendor-responses-panel.tsx b/lib/b-rfq/attachment/vendor-responses-panel.tsx index 901af3bf..0cbe2a08 100644 --- a/lib/b-rfq/attachment/vendor-responses-panel.tsx +++ b/lib/b-rfq/attachment/vendor-responses-panel.tsx @@ -2,8 +2,25 @@ import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Skeleton } from "@/components/ui/skeleton" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" -import { RefreshCw, Download, MessageSquare, Clock, CheckCircle2, XCircle, AlertCircle } from "lucide-react" -import { formatDate } from "@/lib/utils" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { + RefreshCw, + Download, + MessageSquare, + Clock, + CheckCircle2, + XCircle, + AlertCircle, + FileText, + Files, + AlertTriangle +} from "lucide-react" +import { formatDate, formatFileSize } from "@/lib/utils" +import { RequestRevisionDialog } from "./request-revision-dialog" interface VendorResponsesPanelProps { attachment: any @@ -12,12 +29,93 @@ interface VendorResponsesPanelProps { onRefresh: () => void } +// 파일 다운로드 핸들러 +async function handleFileDownload(filePath: string, fileName: string, fileId: number) { + try { + const params = new URLSearchParams({ + path: filePath, + type: "vendor", + responseFileId: fileId.toString(), + }); + + const response = await fetch(`/api/rfq-attachments/download?${params.toString()}`); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || `Download failed: ${response.status}`); + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + + console.log("✅ 파일 다운로드 성공:", fileName); + } catch (error) { + console.error("❌ 파일 다운로드 실패:", error); + alert(`파일 다운로드에 실패했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`); + } +} + +// 파일 목록 컴포넌트 +function FilesList({ files }: { files: any[] }) { + if (files.length === 0) { + return ( + <div className="text-center py-4 text-muted-foreground text-sm"> + 업로드된 파일이 없습니다. + </div> + ); + } + + return ( + <div className="space-y-2 max-h-64 overflow-y-auto"> + {files.map((file, index) => ( + <div key={file.id} className="flex items-center justify-between p-3 border rounded-lg bg-green-50 border-green-200"> + <div className="flex items-center gap-2 flex-1 min-w-0"> + <FileText className="h-4 w-4 text-green-600 flex-shrink-0" /> + <div className="min-w-0 flex-1"> + <div className="font-medium text-sm truncate" title={file.originalFileName}> + {file.originalFileName} + </div> + <div className="text-xs text-muted-foreground"> + {formatFileSize(file.fileSize)} • {formatDate(file.uploadedAt)} + </div> + {file.description && ( + <div className="text-xs text-muted-foreground italic mt-1" title={file.description}> + {file.description} + </div> + )} + </div> + </div> + <Button + size="sm" + variant="ghost" + onClick={() => handleFileDownload(file.filePath, file.originalFileName, file.id)} + className="flex-shrink-0 ml-2" + title="파일 다운로드" + > + <Download className="h-4 w-4" /> + </Button> + </div> + ))} + </div> + ); +} + export function VendorResponsesPanel({ attachment, responses, isLoading, onRefresh }: VendorResponsesPanelProps) { + + console.log(responses) const getStatusIcon = (status: string) => { switch (status) { @@ -114,7 +212,8 @@ export function VendorResponsesPanel({ <TableHead>리비전</TableHead> <TableHead>요청일</TableHead> <TableHead>응답일</TableHead> - <TableHead>벤더 코멘트</TableHead> + <TableHead>응답 파일</TableHead> + <TableHead>코멘트</TableHead> <TableHead className="w-[100px]">액션</TableHead> </TableRow> </TableHeader> @@ -161,37 +260,119 @@ export function VendorResponsesPanel({ <TableCell> {response.respondedAt ? formatDate(response.respondedAt) : '-'} </TableCell> - + + {/* 응답 파일 컬럼 */} <TableCell> - {response.vendorComment ? ( - <div className="max-w-[200px] truncate" title={response.vendorComment}> - {response.vendorComment} + {response.totalFiles > 0 ? ( + <div className="flex items-center gap-2"> + <Badge variant="secondary" className="text-xs"> + {response.totalFiles}개 + </Badge> + {response.totalFiles === 1 ? ( + // 파일이 1개면 바로 다운로드 + <Button + variant="ghost" + size="sm" + className="h-8 w-8 p-0" + onClick={() => { + const file = response.files[0]; + handleFileDownload(file.filePath, file.originalFileName, file.id); + }} + title={response.latestFile?.originalFileName} + > + <Download className="h-4 w-4" /> + </Button> + ) : ( + // 파일이 여러 개면 Popover로 목록 표시 + <Popover> + <PopoverTrigger asChild> + <Button + variant="ghost" + size="sm" + className="h-8 w-8 p-0" + title="파일 목록 보기" + > + <Files className="h-4 w-4" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-96" align="start"> + <div className="space-y-2"> + <div className="font-medium text-sm"> + 응답 파일 목록 ({response.totalFiles}개) + </div> + <FilesList files={response.files} /> + </div> + </PopoverContent> + </Popover> + )} </div> ) : ( - '-' + <span className="text-muted-foreground text-sm">-</span> )} </TableCell> <TableCell> + <div className="space-y-1 max-w-[200px]"> + {/* 벤더 응답 코멘트 */} + {response.responseComment && ( + <div className="flex items-center gap-1"> + <div className="w-2 h-2 rounded-full bg-blue-500 flex-shrink-0" title="벤더 응답 코멘트"></div> + <div className="text-xs text-blue-600 truncate" title={response.responseComment}> + {response.responseComment} + </div> + </div> + )} + + {/* 수정 요청 사유 */} + {response.revisionRequestComment && ( + <div className="flex items-center gap-1"> + <div className="w-2 h-2 rounded-full bg-red-500 flex-shrink-0" title="수정 요청 사유"></div> + <div className="text-xs text-red-600 truncate" title={response.revisionRequestComment}> + {response.revisionRequestComment} + </div> + </div> + )} + + {!response.responseComment && !response.revisionRequestComment && ( + <span className="text-muted-foreground text-sm">-</span> + )} + </div> + </TableCell> + + {/* 액션 컬럼 - 수정 요청 기능으로 변경 */} + <TableCell> <div className="flex items-center gap-1"> {response.responseStatus === 'RESPONDED' && ( - <Button - variant="ghost" - size="sm" - className="h-8 w-8 p-0" - title="첨부파일 다운로드" - > - <Download className="h-4 w-4" /> - </Button> + <RequestRevisionDialog + responseId={response.id} + attachmentType={attachment.attachmentType} + serialNo={attachment.serialNo} + vendorName={response.vendorName} + currentRevision={response.currentRevision} + onSuccess={onRefresh} + trigger={ + <Button + variant="outline" + size="sm" + className="h-8 px-2" + title="수정 요청" + > + <AlertTriangle className="h-3 w-3 mr-1" /> + 수정요청 + </Button> + } + /> + )} + + {response.responseStatus === 'REVISION_REQUESTED' && ( + <Badge variant="secondary" className="text-xs"> + 수정 요청됨 + </Badge> + )} + + {(response.responseStatus === 'NOT_RESPONDED' || response.responseStatus === 'WAIVED') && ( + <span className="text-muted-foreground text-xs">-</span> )} - <Button - variant="ghost" - size="sm" - className="h-8 w-8 p-0" - title="상세 보기" - > - <MessageSquare className="h-4 w-4" /> - </Button> </div> </TableCell> </TableRow> diff --git a/lib/b-rfq/initial/add-initial-rfq-dialog.tsx b/lib/b-rfq/initial/add-initial-rfq-dialog.tsx index d0924be2..58a091ac 100644 --- a/lib/b-rfq/initial/add-initial-rfq-dialog.tsx +++ b/lib/b-rfq/initial/add-initial-rfq-dialog.tsx @@ -1,4 +1,3 @@ -// add-initial-rfq-dialog.tsx "use client" import * as React from "react" @@ -45,6 +44,7 @@ import { Checkbox } from "@/components/ui/checkbox" import { cn, formatDate } from "@/lib/utils" import { addInitialRfqRecord, getIncotermsForSelection, getVendorsForSelection } from "../service" import { Calendar } from "@/components/ui/calendar" +import { InitialRfqDetailView } from "@/db/schema" // Initial RFQ 추가 폼 스키마 const addInitialRfqSchema = z.object({ @@ -70,22 +70,30 @@ const addInitialRfqSchema = z.object({ returnRevision: z.number().default(0), }) -type AddInitialRfqFormData = z.infer<typeof addInitialRfqSchema> +export type AddInitialRfqFormData = z.infer<typeof addInitialRfqSchema> interface Vendor { id: number vendorName: string vendorCode: string country: string + taxId: string status: string } +interface Incoterm { + id: number + code: string + description: string +} + interface AddInitialRfqDialogProps { rfqId: number onSuccess?: () => void + defaultValues?: InitialRfqDetailView // 선택된 항목의 기본값 } -export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogProps) { +export function AddInitialRfqDialog({ rfqId, onSuccess, defaultValues }: AddInitialRfqDialogProps) { const [open, setOpen] = React.useState(false) const [isSubmitting, setIsSubmitting] = React.useState(false) const [vendors, setVendors] = React.useState<Vendor[]>([]) @@ -95,16 +103,38 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro const [incotermsLoading, setIncotermsLoading] = React.useState(false) const [incotermsSearchOpen, setIncotermsSearchOpen] = React.useState(false) - const form = useForm<AddInitialRfqFormData>({ - resolver: zodResolver(addInitialRfqSchema), - defaultValues: { + // 기본값 설정 (선택된 항목이 있으면 해당 값 사용, 없으면 일반 기본값) + const getDefaultFormValues = React.useCallback((): Partial<AddInitialRfqFormData> => { + if (defaultValues) { + return { + vendorId: defaultValues.vendorId, + initialRfqStatus: "DRAFT", // 새로 추가할 때는 항상 DRAFT로 시작 + dueDate: defaultValues.dueDate || new Date(), + validDate: defaultValues.validDate, + incotermsCode: defaultValues.incotermsCode || "", + classification: defaultValues.classification || "", + sparepart: defaultValues.sparepart || "", + shortList: false, // 새로 추가할 때는 기본적으로 false + returnYn: false, + cpRequestYn: defaultValues.cpRequestYn || false, + prjectGtcYn: defaultValues.prjectGtcYn || false, + returnRevision: 0, + } + } + + return { initialRfqStatus: "DRAFT", shortList: false, returnYn: false, cpRequestYn: false, prjectGtcYn: false, returnRevision: 0, - }, + } + }, [defaultValues]) + + const form = useForm<AddInitialRfqFormData>({ + resolver: zodResolver(addInitialRfqSchema), + defaultValues: getDefaultFormValues(), }) // 벤더 목록 로드 @@ -121,23 +151,27 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro } }, []) - // Incoterms 목록 로드 - const loadIncoterms = React.useCallback(async () => { - setIncotermsLoading(true) - try { - const incotermsList = await getIncotermsForSelection() - setIncoterms(incotermsList) - } catch (error) { - console.error("Failed to load incoterms:", error) - toast.error("Incoterms 목록을 불러오는데 실패했습니다.") - } finally { - setIncotermsLoading(false) - } - }, []) + // Incoterms 목록 로드 + const loadIncoterms = React.useCallback(async () => { + setIncotermsLoading(true) + try { + const incotermsList = await getIncotermsForSelection() + setIncoterms(incotermsList) + } catch (error) { + console.error("Failed to load incoterms:", error) + toast.error("Incoterms 목록을 불러오는데 실패했습니다.") + } finally { + setIncotermsLoading(false) + } + }, []) - // 다이얼로그 열릴 때 벤더 목록 로드 + // 다이얼로그 열릴 때 실행 React.useEffect(() => { if (open) { + // 폼을 기본값으로 리셋 + form.reset(getDefaultFormValues()) + + // 데이터 로드 if (vendors.length === 0) { loadVendors() } @@ -145,12 +179,12 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro loadIncoterms() } } - }, [open, vendors.length, incoterms.length, loadVendors, loadIncoterms]) + }, [open, vendors.length, incoterms.length, loadVendors, loadIncoterms, form, getDefaultFormValues]) // 다이얼로그 닫기 핸들러 const handleOpenChange = (newOpen: boolean) => { if (!newOpen && !isSubmitting) { - form.reset() + form.reset(getDefaultFormValues()) } setOpen(newOpen) } @@ -167,7 +201,7 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro if (result.success) { toast.success(result.message || "초기 RFQ가 성공적으로 추가되었습니다.") - form.reset() + form.reset(getDefaultFormValues()) handleOpenChange(false) onSuccess?.() } else { @@ -186,20 +220,32 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro const selectedVendor = vendors.find(vendor => vendor.id === form.watch("vendorId")) const selectedIncoterm = incoterms.find(incoterm => incoterm.code === form.watch("incotermsCode")) + // 기본값이 있을 때 버튼 텍스트 변경 + const buttonText = defaultValues ? "유사 항목 추가" : "초기 RFQ 추가" + const dialogTitle = defaultValues ? "유사 초기 RFQ 추가" : "초기 RFQ 추가" + const dialogDescription = defaultValues + ? "선택된 항목을 기본값으로 하여 새로운 초기 RFQ를 추가합니다." + : "새로운 벤더를 대상으로 하는 초기 RFQ를 추가합니다." + return ( <Dialog open={open} onOpenChange={handleOpenChange}> <DialogTrigger asChild> <Button variant="outline" size="sm" className="gap-2"> <Plus className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline">초기 RFQ 추가</span> + <span className="hidden sm:inline">{buttonText}</span> </Button> </DialogTrigger> <DialogContent className="sm:max-w-[600px] max-h-[80vh] overflow-y-auto"> <DialogHeader> - <DialogTitle>초기 RFQ 추가</DialogTitle> + <DialogTitle>{dialogTitle}</DialogTitle> <DialogDescription> - 새로운 벤더를 대상으로 하는 초기 RFQ를 추가합니다. + {dialogDescription} + {defaultValues && ( + <div className="mt-2 p-2 bg-muted rounded-md text-sm"> + <strong>기본값 출처:</strong> {defaultValues.vendorName} ({defaultValues.vendorCode}) + </div> + )} </DialogDescription> </DialogHeader> @@ -263,7 +309,7 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro {vendor.vendorName} </div> <div className="text-sm text-muted-foreground"> - {vendor.vendorCode} • {vendor.country} + {vendor.vendorCode} • {vendor.country} • {vendor.taxId} </div> </div> <Check @@ -287,98 +333,98 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro {/* 날짜 필드들 */} <div className="grid grid-cols-2 gap-4"> - <FormField - control={form.control} - name="dueDate" - render={({ field }) => ( - <FormItem className="flex flex-col"> - <FormLabel>견적 마감일</FormLabel> - <Popover> - <PopoverTrigger asChild> - <FormControl> - <Button - variant="outline" - className={cn( - "w-full pl-3 text-left font-normal", - !field.value && "text-muted-foreground" - )} - > - {field.value ? ( - formatDate(field.value,"KR") - ) : ( - <span>견적 유효일을 선택하세요</span> - )} - <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> - </Button> - </FormControl> - </PopoverTrigger> - <PopoverContent className="w-auto p-0" align="start"> - <Calendar - mode="single" - selected={field.value} - onSelect={field.onChange} - disabled={(date) => - date < new Date() || date < new Date("1900-01-01") - } - initialFocus - /> - </PopoverContent> - </Popover> - <FormMessage /> - </FormItem> - )} - /> - <FormField - control={form.control} - name="validDate" - render={({ field }) => ( - <FormItem className="flex flex-col"> - <FormLabel>견적 유효일</FormLabel> - <Popover> - <PopoverTrigger asChild> - <FormControl> - <Button - variant="outline" - className={cn( - "w-full pl-3 text-left font-normal", - !field.value && "text-muted-foreground" - )} - > - {field.value ? ( - formatDate(field.value,"KR") - ) : ( - <span>견적 유효일을 선택하세요</span> - )} - <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> - </Button> - </FormControl> - </PopoverTrigger> - <PopoverContent className="w-auto p-0" align="start"> - <Calendar - mode="single" - selected={field.value} - onSelect={field.onChange} - disabled={(date) => - date < new Date() || date < new Date("1900-01-01") - } - initialFocus - /> - </PopoverContent> - </Popover> - <FormMessage /> - </FormItem> - )} - /> + <FormField + control={form.control} + name="dueDate" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel>견적 마감일 *</FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + className={cn( + "w-full pl-3 text-left font-normal", + !field.value && "text-muted-foreground" + )} + > + {field.value ? ( + formatDate(field.value, "KR") + ) : ( + <span>견적 마감일을 선택하세요</span> + )} + <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <Calendar + mode="single" + selected={field.value} + onSelect={field.onChange} + disabled={(date) => + date < new Date() || date < new Date("1900-01-01") + } + initialFocus + /> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="validDate" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel>견적 유효일</FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + className={cn( + "w-full pl-3 text-left font-normal", + !field.value && "text-muted-foreground" + )} + > + {field.value ? ( + formatDate(field.value, "KR") + ) : ( + <span>견적 유효일을 선택하세요</span> + )} + <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <Calendar + mode="single" + selected={field.value} + onSelect={field.onChange} + disabled={(date) => + date < new Date() || date < new Date("1900-01-01") + } + initialFocus + /> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> </div> - {/* Incoterms 및 GTC */} - <div className="grid grid-cols-2 gap-4"> + {/* Incoterms 선택 */} <FormField control={form.control} - name="vendorId" + name="incotermsCode" render={({ field }) => ( <FormItem className="flex flex-col"> - <FormLabel>Incoterms *</FormLabel> + <FormLabel>Incoterms</FormLabel> <Popover open={incotermsSearchOpen} onOpenChange={setIncotermsSearchOpen}> <PopoverTrigger asChild> <FormControl> @@ -391,9 +437,8 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro > {selectedIncoterm ? ( <div className="flex items-center gap-2"> - <Building className="h-4 w-4" /> <span className="truncate"> - {selectedIncoterm.code} ({selectedIncoterm.description}) + {selectedIncoterm.code} - {selectedIncoterm.description} </span> </div> ) : ( @@ -419,18 +464,20 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro key={incoterm.id} value={`${incoterm.code} ${incoterm.description}`} onSelect={() => { - field.onChange(vendor.id) - setVendorSearchOpen(false) + field.onChange(incoterm.code) + setIncotermsSearchOpen(false) }} > <div className="flex items-center gap-2 w-full"> + <div className="flex-1 min-w-0"> <div className="font-medium truncate"> - {incoterm.code} {incoterm.description} + {incoterm.code} - {incoterm.description} </div> + </div> <Check className={cn( "ml-auto h-4 w-4", - incoterm.id === field.value ? "opacity-100" : "opacity-0" + incoterm.code === field.value ? "opacity-100" : "opacity-0" )} /> </div> @@ -445,34 +492,41 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro </FormItem> )} /> - </div> - {/* GTC 정보 */} + {/* 옵션 체크박스 */} <div className="grid grid-cols-2 gap-4"> <FormField control={form.control} - name="gtc" + name="cpRequestYn" render={({ field }) => ( - <FormItem> - <FormLabel>GTC</FormLabel> + <FormItem className="flex flex-row items-start space-x-3 space-y-0"> <FormControl> - <Input placeholder="GTC 정보" {...field} /> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> </FormControl> - <FormMessage /> + <div className="space-y-1 leading-none ml-2"> + <FormLabel>CP 요청</FormLabel> + </div> </FormItem> )} /> <FormField control={form.control} - name="gtcValidDate" + name="prjectGtcYn" render={({ field }) => ( - <FormItem> - <FormLabel>GTC 유효일</FormLabel> + <FormItem className="flex flex-row items-start space-x-3 space-y-0"> <FormControl> - <Input placeholder="GTC 유효일" {...field} /> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> </FormControl> - <FormMessage /> + <div className="space-y-1 leading-none ml-2"> + <FormLabel>Project용 GTC 사용</FormLabel> + </div> </FormItem> )} /> @@ -501,7 +555,7 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro <FormItem> <FormLabel>Spare part</FormLabel> <FormControl> - <Input placeholder="예비부품 정보" {...field} /> + <Input placeholder="O1, O2" {...field} /> </FormControl> <FormMessage /> </FormItem> @@ -509,8 +563,6 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro /> </div> - - <DialogFooter> <Button type="button" @@ -529,6 +581,4 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro </DialogContent> </Dialog> ) -} - - +}
\ No newline at end of file diff --git a/lib/b-rfq/initial/delete-initial-rfq-dialog.tsx b/lib/b-rfq/initial/delete-initial-rfq-dialog.tsx new file mode 100644 index 00000000..b5a231b7 --- /dev/null +++ b/lib/b-rfq/initial/delete-initial-rfq-dialog.tsx @@ -0,0 +1,149 @@ +"use client" + +import * as React from "react" +import { type Row } from "@tanstack/react-table" +import { Loader, Trash } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" + +import { InitialRfqDetailView } from "@/db/schema" +import { removeInitialRfqs } from "../service" + +interface DeleteInitialRfqDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + initialRfqs: Row<InitialRfqDetailView>["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeleteInitialRfqDialog({ + initialRfqs, + showTrigger = true, + onSuccess, + ...props +}: DeleteInitialRfqDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onDelete() { + startDeleteTransition(async () => { + const { error } = await removeInitialRfqs({ + ids: initialRfqs.map((rfq) => rfq.id), + }) + + if (error) { + toast.error(error) + return + } + + props.onOpenChange?.(false) + toast.success("초기 RFQ가 삭제되었습니다") + onSuccess?.() + }) + } + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + 삭제 ({initialRfqs.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle> + <DialogDescription> + 이 작업은 되돌릴 수 없습니다. 선택한{" "} + <span className="font-medium">{initialRfqs.length}개</span>의 + 초기 RFQ{initialRfqs.length === 1 ? "를" : "들을"} 영구적으로 삭제합니다. + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">취소</Button> + </DialogClose> + <Button + aria-label="Delete selected rows" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + 삭제 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + 삭제 ({initialRfqs.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle> + <DrawerDescription> + 이 작업은 되돌릴 수 없습니다. 선택한{" "} + <span className="font-medium">{initialRfqs.length}개</span>의 + 초기 RFQ{initialRfqs.length === 1 ? "를" : "들을"} 영구적으로 삭제합니다. + </DrawerDescription> + </DrawerHeader> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">취소</Button> + </DrawerClose> + <Button + aria-label="Delete selected rows" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + 삭제 + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +}
\ No newline at end of file diff --git a/lib/b-rfq/initial/initial-rfq-detail-columns.tsx b/lib/b-rfq/initial/initial-rfq-detail-columns.tsx index f7ac0960..02dfd765 100644 --- a/lib/b-rfq/initial/initial-rfq-detail-columns.tsx +++ b/lib/b-rfq/initial/initial-rfq-detail-columns.tsx @@ -3,8 +3,9 @@ import * as React from "react" import { type ColumnDef } from "@tanstack/react-table" +import { type Row } from "@tanstack/react-table" import { - Ellipsis, Building, Calendar, Eye, + Ellipsis, Building, Eye, Edit, Trash, MessageSquare, Settings, CheckCircle2, XCircle } from "lucide-react" @@ -14,17 +15,27 @@ import { Button } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuSeparator, DropdownMenuTrigger + DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuShortcut } from "@/components/ui/dropdown-menu" import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { InitialRfqDetailView } from "@/db/schema" + + +// RowAction 타입 정의 +export interface DataTableRowAction<TData> { + row: Row<TData> + type: "update" | "delete" +} interface GetInitialRfqDetailColumnsProps { onSelectDetail?: (detail: any) => void + setRowAction?: React.Dispatch<React.SetStateAction<DataTableRowAction<InitialRfqDetailView> | null>> } export function getInitialRfqDetailColumns({ - onSelectDetail -}: GetInitialRfqDetailColumnsProps = {}): ColumnDef<any>[] { + onSelectDetail, + setRowAction +}: GetInitialRfqDetailColumnsProps = {}): ColumnDef<InitialRfqDetailView>[] { return [ /** ───────────── 체크박스 ───────────── */ @@ -56,53 +67,6 @@ export function getInitialRfqDetailColumns({ /** ───────────── RFQ 정보 ───────────── */ { - accessorKey: "rfqCode", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="RFQ 코드" /> - ), - cell: ({ row }) => ( - <Button - variant="link" - className="p-0 h-auto font-medium text-blue-600 hover:text-blue-800" - onClick={() => onSelectDetail?.(row.original)} - > - {row.getValue("rfqCode") as string} - </Button> - ), - size: 120, - }, - { - accessorKey: "rfqStatus", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="RFQ 상태" /> - ), - cell: ({ row }) => { - const status = row.getValue("rfqStatus") as string - const getStatusColor = (status: string) => { - switch (status) { - case "DRAFT": return "secondary" - case "Doc. Received": return "outline" - case "PIC Assigned": return "default" - case "Doc. Confirmed": return "default" - case "Init. RFQ Sent": return "default" - case "Init. RFQ Answered": return "success" - case "TBE started": return "warning" - case "TBE finished": return "warning" - case "Final RFQ Sent": return "default" - case "Quotation Received": return "success" - case "Vendor Selected": return "success" - default: return "secondary" - } - } - return ( - <Badge variant={getStatusColor(status) as any}> - {status} - </Badge> - ) - }, - size: 140 - }, - { accessorKey: "initialRfqStatus", header: ({ column }) => ( <DataTableColumnHeaderSimple column={column} title="초기 RFQ 상태" /> @@ -111,11 +75,10 @@ export function getInitialRfqDetailColumns({ const status = row.getValue("initialRfqStatus") as string const getInitialStatusColor = (status: string) => { switch (status) { - case "PENDING": return "outline" - case "SENT": return "default" - case "RESPONDED": return "success" - case "EXPIRED": return "destructive" - case "CANCELLED": return "secondary" + case "DRAFT": return "outline" + case "Init. RFQ Sent": return "default" + case "Init. RFQ Answered": return "success" + case "S/L Decline": return "destructive" default: return "secondary" } } @@ -127,6 +90,30 @@ export function getInitialRfqDetailColumns({ }, size: 120 }, + { + accessorKey: "rfqCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="RFQ 코드" /> + ), + cell: ({ row }) => ( + <div className="text-sm"> + {row.getValue("rfqCode") as string} + </div> + ), + size: 120, + }, + { + accessorKey: "rfqRevision", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="RFQ 리비전" /> + ), + cell: ({ row }) => ( + <div className="text-sm"> + Rev. {row.getValue("rfqRevision") as number} + </div> + ), + size: 120, + }, /** ───────────── 벤더 정보 ───────────── */ { @@ -137,7 +124,8 @@ export function getInitialRfqDetailColumns({ cell: ({ row }) => { const vendorName = row.original.vendorName as string const vendorCode = row.original.vendorCode as string - const vendorCountry = row.original.vendorCountry as string + const vendorType = row.original.vendorCategory as string + const vendorCountry = row.original.vendorCountry === "KR" ? "D":"F" const businessSize = row.original.vendorBusinessSize as string return ( @@ -147,7 +135,7 @@ export function getInitialRfqDetailColumns({ <div className="font-medium">{vendorName}</div> </div> <div className="text-sm text-muted-foreground"> - {vendorCode} • {vendorCountry} + {vendorCode} • {vendorType} • {vendorCountry} </div> {businessSize && ( <Badge variant="outline" className="text-xs"> @@ -160,42 +148,67 @@ export function getInitialRfqDetailColumns({ size: 200, }, - /** ───────────── 날짜 정보 ───────────── */ { - accessorKey: "dueDate", + accessorKey: "cpRequestYn", header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="마감일" /> + <DataTableColumnHeaderSimple column={column} title="CP" /> ), cell: ({ row }) => { - const dueDate = row.getValue("dueDate") as Date - const isOverdue = dueDate && new Date(dueDate) < new Date() - - return dueDate ? ( - <div className={`flex items-center gap-2 ${isOverdue ? 'text-red-600' : ''}`}> - <Calendar className="h-4 w-4" /> - <div> - <div className="font-medium">{formatDate(dueDate)}</div> - {isOverdue && ( - <div className="text-xs text-red-600">지연</div> - )} - </div> - </div> + const cpRequest = row.getValue("cpRequestYn") as boolean + return cpRequest ? ( + <Badge variant="outline" className="text-xs"> + Yes + </Badge> ) : ( - <span className="text-muted-foreground">-</span> + <span className="text-muted-foreground text-xs">-</span> ) }, - size: 120, + size: 60, }, { - accessorKey: "validDate", + accessorKey: "prjectGtcYn", header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="유효일" /> + <DataTableColumnHeaderSimple column={column} title="Project GTC" /> ), cell: ({ row }) => { - const validDate = row.getValue("validDate") as Date - return validDate ? ( + const projectGtc = row.getValue("prjectGtcYn") as boolean + return projectGtc ? ( + <Badge variant="outline" className="text-xs"> + Yes + </Badge> + ) : ( + <span className="text-muted-foreground text-xs">-</span> + ) + }, + size: 100, + }, + { + accessorKey: "gtcYn", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="GTC" /> + ), + cell: ({ row }) => { + const gtc = row.getValue("gtcYn") as boolean + return gtc ? ( + <Badge variant="outline" className="text-xs"> + Yes + </Badge> + ) : ( + <span className="text-muted-foreground text-xs">-</span> + ) + }, + size: 60, + }, + { + accessorKey: "gtcValidDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="GTC 유효일" /> + ), + cell: ({ row }) => { + const gtcValidDate = row.getValue("gtcValidDate") as string + return gtcValidDate ? ( <div className="text-sm"> - {formatDate(validDate)} + {gtcValidDate} </div> ) : ( <span className="text-muted-foreground">-</span> @@ -204,7 +217,42 @@ export function getInitialRfqDetailColumns({ size: 100, }, - /** ───────────── Incoterms ───────────── */ + { + accessorKey: "classification", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="선급" /> + ), + cell: ({ row }) => { + const classification = row.getValue("classification") as string + return classification ? ( + <div className="text-sm font-medium max-w-[120px] truncate" title={classification}> + {classification} + </div> + ) : ( + <span className="text-muted-foreground">-</span> + ) + }, + size: 120, + }, + + { + accessorKey: "sparepart", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Spare Part" /> + ), + cell: ({ row }) => { + const sparepart = row.getValue("sparepart") as string + return sparepart ? ( + <Badge variant="outline" className="text-xs"> + {sparepart} + </Badge> + ) : ( + <span className="text-muted-foreground">-</span> + ) + }, + size: 100, + }, + { id: "incoterms", header: ({ column }) => ( @@ -230,84 +278,71 @@ export function getInitialRfqDetailColumns({ size: 120, }, - /** ───────────── 플래그 정보 ───────────── */ + /** ───────────── 날짜 정보 ───────────── */ { - id: "flags", + accessorKey: "validDate", header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="플래그" /> + <DataTableColumnHeaderSimple column={column} title="유효일" /> ), cell: ({ row }) => { - const shortList = row.original.shortList as boolean - const returnYn = row.original.returnYn as boolean - const cpRequestYn = row.original.cpRequestYn as boolean - const prjectGtcYn = row.original.prjectGtcYn as boolean - - return ( - <div className="flex flex-wrap gap-1"> - {shortList && ( - <Badge variant="secondary" className="text-xs"> - <CheckCircle2 className="h-3 w-3 mr-1" /> - Short List - </Badge> - )} - {returnYn && ( - <Badge variant="outline" className="text-xs"> - Return - </Badge> - )} - {cpRequestYn && ( - <Badge variant="outline" className="text-xs"> - CP Request - </Badge> - )} - {prjectGtcYn && ( - <Badge variant="outline" className="text-xs"> - GTC - </Badge> - )} + const validDate = row.getValue("validDate") as Date + return validDate ? ( + <div className="text-sm"> + {formatDate(validDate)} </div> + ) : ( + <span className="text-muted-foreground">-</span> ) }, - size: 150, + size: 100, }, - - /** ───────────── 분류 정보 ───────────── */ { - id: "classification", + accessorKey: "dueDate", header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="분류" /> + <DataTableColumnHeaderSimple column={column} title="마감일" /> ), cell: ({ row }) => { - const classification = row.original.classification as string - const sparepart = row.original.sparepart as string + const dueDate = row.getValue("dueDate") as Date + const isOverdue = dueDate && new Date(dueDate) < new Date() - return ( - <div className="space-y-1"> - {classification && ( - <div className="text-sm font-medium max-w-[120px] truncate" title={classification}> - {classification} - </div> - )} - {sparepart && ( - <Badge variant="outline" className="text-xs"> - {sparepart} - </Badge> + return dueDate ? ( + <div className={`${isOverdue ? 'text-red-600' : ''}`}> + <div className="font-medium">{formatDate(dueDate)}</div> + {isOverdue && ( + <div className="text-xs text-red-600">지연</div> )} </div> + ) : ( + <span className="text-muted-foreground">-</span> ) }, size: 120, }, - - /** ───────────── 리비전 정보 ───────────── */ + { + accessorKey: "returnYn", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="RFQ 회신여부" /> + ), + cell: ({ row }) => { + const returnFlag = row.getValue("returnYn") as boolean + return returnFlag ? ( + <Badge variant="outline" className="text-xs"> + Yes + </Badge> + ) : ( + <span className="text-muted-foreground text-xs">-</span> + ) + }, + size: 70, + }, { accessorKey: "returnRevision", header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="리비전" /> + <DataTableColumnHeaderSimple column={column} title="회신 리비전" /> ), cell: ({ row }) => { const revision = row.getValue("returnRevision") as number - return revision ? ( + return revision > 0 ? ( <Badge variant="outline"> Rev. {revision} </Badge> @@ -318,6 +353,25 @@ export function getInitialRfqDetailColumns({ size: 80, }, + { + accessorKey: "shortList", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Short List" /> + ), + cell: ({ row }) => { + const shortList = row.getValue("shortList") as boolean + return shortList ? ( + <Badge variant="secondary" className="text-xs"> + <CheckCircle2 className="h-3 w-3 mr-1" /> + Yes + </Badge> + ) : ( + <span className="text-muted-foreground text-xs">-</span> + ) + }, + size: 90, + }, + /** ───────────── 등록/수정 정보 ───────────── */ { accessorKey: "createdAt", @@ -333,7 +387,7 @@ export function getInitialRfqDetailColumns({ <div className="text-sm">{formatDate(created)}</div> {updated && new Date(updated) > new Date(created) && ( <div className="text-xs text-blue-600"> - 수정: {formatDate(updated)} + 수정: {formatDate(updated, "KR")} </div> )} </div> @@ -346,7 +400,7 @@ export function getInitialRfqDetailColumns({ { id: "actions", enableHiding: false, - cell: ({ row }) => { + cell: function Cell({ row }) { return ( <DropdownMenu> <DropdownMenuTrigger asChild> @@ -359,23 +413,29 @@ export function getInitialRfqDetailColumns({ </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-48"> - <DropdownMenuItem onClick={() => onSelectDetail?.(row.original)}> - <Eye className="mr-2 h-4 w-4" /> - 상세 보기 - </DropdownMenuItem> <DropdownMenuItem> <MessageSquare className="mr-2 h-4 w-4" /> 벤더 응답 보기 </DropdownMenuItem> <DropdownMenuSeparator /> - <DropdownMenuItem> - <Settings className="mr-2 h-4 w-4" /> - 설정 수정 - </DropdownMenuItem> - <DropdownMenuItem className="text-red-600"> - <XCircle className="mr-2 h-4 w-4" /> - 삭제 - </DropdownMenuItem> + {setRowAction && ( + <> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "update" })} + > + <Edit className="mr-2 h-4 w-4" /> + 수정 + </DropdownMenuItem> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "delete" })} + > + <Trash className="mr-2 h-4 w-4" /> + 삭제 + <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> + </DropdownMenuItem> + </> + )} + </DropdownMenuContent> </DropdownMenu> ) diff --git a/lib/b-rfq/initial/initial-rfq-detail-table.tsx b/lib/b-rfq/initial/initial-rfq-detail-table.tsx index fc8a5bc2..5ea6b0bf 100644 --- a/lib/b-rfq/initial/initial-rfq-detail-table.tsx +++ b/lib/b-rfq/initial/initial-rfq-detail-table.tsx @@ -6,8 +6,14 @@ import { useDataTable } from "@/hooks/use-data-table" import { DataTable } from "@/components/data-table/data-table" import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" import { getInitialRfqDetail } from "../service" // 앞서 만든 서버 액션 -import { getInitialRfqDetailColumns } from "./initial-rfq-detail-columns" +import { + getInitialRfqDetailColumns, + type DataTableRowAction +} from "./initial-rfq-detail-columns" import { InitialRfqDetailTableToolbarActions } from "./initial-rfq-detail-toolbar-actions" +import { DeleteInitialRfqDialog } from "./delete-initial-rfq-dialog" +import { UpdateInitialRfqSheet } from "./update-initial-rfq-sheet" +import { InitialRfqDetailView } from "@/db/schema" interface InitialRfqDetailTableProps { promises: Promise<Awaited<ReturnType<typeof getInitialRfqDetail>>> @@ -19,10 +25,14 @@ export function InitialRfqDetailTable({ promises, rfqId }: InitialRfqDetailTable // 선택된 상세 정보 const [selectedDetail, setSelectedDetail] = React.useState<any>(null) + + // Row action 상태 (update/delete) + const [rowAction, setRowAction] = React.useState<DataTableRowAction<InitialRfqDetailView> | null>(null) const columns = React.useMemo( () => getInitialRfqDetailColumns({ - onSelectDetail: setSelectedDetail + onSelectDetail: setSelectedDetail, + setRowAction: setRowAction }), [] ) @@ -62,11 +72,10 @@ export function InitialRfqDetailTable({ promises, rfqId }: InitialRfqDetailTable id: "initialRfqStatus", label: "초기 RFQ 상태", options: [ - { label: "대기", value: "PENDING", count: 0 }, - { label: "발송", value: "SENT", count: 0 }, - { label: "응답", value: "RESPONDED", count: 0 }, - { label: "만료", value: "EXPIRED", count: 0 }, - { label: "취소", value: "CANCELLED", count: 0 }, + { label: "초안", value: "DRAFT", count: 0 }, + { label: "발송", value: "Init. RFQ Sent", count: 0 }, + { label: "응답", value: "Init. RFQ Answered", count: 0 }, + { label: "거절", value: "S/L Decline", count: 0 }, ], }, { @@ -136,11 +145,10 @@ export function InitialRfqDetailTable({ promises, rfqId }: InitialRfqDetailTable label: "초기 RFQ 상태", type: "multi-select", options: [ - { label: "대기", value: "PENDING" }, - { label: "발송", value: "SENT" }, - { label: "응답", value: "RESPONDED" }, - { label: "만료", value: "EXPIRED" }, - { label: "취소", value: "CANCELLED" }, + { label: "초안", value: "DRAFT" }, + { label: "발송", value: "Init. RFQ Sent" }, + { label: "응답", value: "Init. RFQ Answered" }, + { label: "거절", value: "S/L Decline" }, ], }, { @@ -216,7 +224,7 @@ export function InitialRfqDetailTable({ promises, rfqId }: InitialRfqDetailTable sorting: [{ id: "createdAt", desc: true }], columnPinning: { right: ["actions"] }, }, - getRowId: (originalRow) => originalRow.initialRfqId.toString(), + getRowId: (originalRow) => originalRow.initialRfqId ? originalRow.initialRfqId.toString():"1", shallow: false, clearOnDefault: true, }) @@ -236,28 +244,24 @@ export function InitialRfqDetailTable({ promises, rfqId }: InitialRfqDetailTable </DataTable> </div> - {/* 선택된 상세 정보 패널 (필요시 추가) */} - {selectedDetail && ( - <div className="border rounded-lg p-4"> - <h3 className="text-lg font-semibold mb-2"> - 상세 정보: {selectedDetail.rfqCode} - </h3> - <div className="grid grid-cols-2 gap-4 text-sm"> - <div> - <strong>벤더:</strong> {selectedDetail.vendorName} - </div> - <div> - <strong>국가:</strong> {selectedDetail.vendorCountry} - </div> - <div> - <strong>마감일:</strong> {formatDate(selectedDetail.dueDate)} - </div> - <div> - <strong>유효일:</strong> {formatDate(selectedDetail.validDate)} - </div> - </div> - </div> - )} + {/* Update Sheet */} + <UpdateInitialRfqSheet + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + initialRfq={rowAction?.type === "update" ? rowAction.row.original : null} + /> + + {/* Delete Dialog */} + <DeleteInitialRfqDialog + open={rowAction?.type === "delete"} + onOpenChange={() => setRowAction(null)} + initialRfqs={rowAction?.type === "delete" ? [rowAction.row.original] : []} + showTrigger={false} + onSuccess={() => { + setRowAction(null) + // 테이블 리프레시는 revalidatePath로 자동 처리됨 + }} + /> </div> ) }
\ No newline at end of file diff --git a/lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx b/lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx index 981659d5..639d338d 100644 --- a/lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx +++ b/lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx @@ -1,109 +1,220 @@ -// initial-rfq-detail-toolbar-actions.tsx "use client" import * as React from "react" import { type Table } from "@tanstack/react-table" +import { useRouter } from "next/navigation" +import { toast } from "sonner" import { Button } from "@/components/ui/button" -import { - Download, - Mail, - RefreshCw, - Settings, - Trash2, - FileText +import { + Download, + Mail, + RefreshCw, + Settings, + Trash2, + FileText, + CheckCircle2, + Loader } from "lucide-react" +import { AddInitialRfqDialog } from "./add-initial-rfq-dialog" +import { DeleteInitialRfqDialog } from "./delete-initial-rfq-dialog" +import { InitialRfqDetailView } from "@/db/schema" +import { sendBulkInitialRfqEmails } from "../service" interface InitialRfqDetailTableToolbarActionsProps { - table: Table<any> - rfqId?: number + table: Table<InitialRfqDetailView> + rfqId?: number + onRefresh?: () => void // 데이터 새로고침 콜백 } export function InitialRfqDetailTableToolbarActions({ - table, - rfqId + table, + rfqId, + onRefresh }: InitialRfqDetailTableToolbarActionsProps) { - - // 선택된 행들 가져오기 - const selectedRows = table.getFilteredSelectedRowModel().rows - const selectedDetails = selectedRows.map((row) => row.original) - const selectedCount = selectedRows.length - - const handleBulkEmail = () => { - console.log("Bulk email to selected vendors:", selectedDetails) - // 벌크 이메일 로직 구현 - } - - const handleBulkDelete = () => { - console.log("Bulk delete selected items:", selectedDetails) - // 벌크 삭제 로직 구현 - table.toggleAllRowsSelected(false) - } - - const handleExport = () => { - console.log("Export data:", selectedCount > 0 ? selectedDetails : "all data") - // 데이터 엑스포트 로직 구현 - } - - const handleRefresh = () => { - window.location.reload() - } - - return ( - <div className="flex items-center gap-2"> - {/** 선택된 항목이 있을 때만 표시되는 액션들 */} - {selectedCount > 0 && ( + const router = useRouter() + + // 선택된 행들 가져오기 + const selectedRows = table.getFilteredSelectedRowModel().rows + const selectedDetails = selectedRows.map((row) => row.original) + const selectedCount = selectedRows.length + + // 상태 관리 + const [showDeleteDialog, setShowDeleteDialog] = React.useState(false) + const [isEmailSending, setIsEmailSending] = React.useState(false) + + const handleBulkEmail = async () => { + if (selectedCount === 0) return + + setIsEmailSending(true) + + try { + const initialRfqIds = selectedDetails.map(detail => detail.initialRfqId); + + const result = await sendBulkInitialRfqEmails({ + initialRfqIds, + language: "en" // 기본 영어, 필요시 사용자 설정으로 변경 + }) + + if (result.success) { + toast.success(result.message) + + // 에러가 있다면 별도 알림 + if (result.errors && result.errors.length > 0) { + setTimeout(() => { + toast.warning(`일부 오류 발생: ${result.errors?.join(', ')}`) + }, 1000) + } + + // 선택 해제 + table.toggleAllRowsSelected(false) + + // 데이터 새로고침 + if (onRefresh) { + onRefresh() + } + } else { + toast.error(result.message || "RFQ 발송에 실패했습니다.") + } + + } catch (error) { + console.error("Email sending error:", error) + toast.error("RFQ 발송 중 오류가 발생했습니다.") + } finally { + setIsEmailSending(false) + } + } + + const handleBulkDelete = () => { + // DRAFT가 아닌 상태의 RFQ 확인 + const nonDraftRfqs = selectedDetails.filter( + detail => detail.initialRfqStatus !== "DRAFT" + ) + + if (nonDraftRfqs.length > 0) { + const statusMessages = { + "Init. RFQ Sent": "이미 발송된", + "S/L Decline": "Short List 거절 처리된", + "Init. RFQ Answered": "답변 완료된" + } + + const nonDraftStatuses = [...new Set(nonDraftRfqs.map(rfq => rfq.initialRfqStatus))] + const statusText = nonDraftStatuses + .map(status => statusMessages[status as keyof typeof statusMessages] || status) + .join(", ") + + toast.error( + `${statusText} RFQ는 삭제할 수 없습니다. DRAFT 상태의 RFQ만 삭제 가능합니다.` + ) + return + } + + setShowDeleteDialog(true) + } + + // S/L 확정 버튼 클릭 + const handleSlConfirm = () => { + if (rfqId) { + router.push(`/evcp/b-rfq/${rfqId}`) + } + } + + // 초기 RFQ 추가 성공 시 처리 + const handleAddSuccess = () => { + // 선택 해제 + table.toggleAllRowsSelected(false) + + // 데이터 새로고침 + if (onRefresh) { + onRefresh() + } else { + // fallback으로 페이지 새로고침 + setTimeout(() => { + window.location.reload() + }, 1000) + } + } + + // 삭제 성공 시 처리 + const handleDeleteSuccess = () => { + // 선택 해제 + table.toggleAllRowsSelected(false) + setShowDeleteDialog(false) + + // 데이터 새로고침 + if (onRefresh) { + onRefresh() + } + } + + // 선택된 항목 중 첫 번째를 기본값으로 사용 + const defaultValues = selectedCount > 0 ? selectedDetails[0] : undefined + + const canDelete = selectedDetails.every(detail => detail.initialRfqStatus === "DRAFT") + const draftCount = selectedDetails.filter(detail => detail.initialRfqStatus === "DRAFT").length + + + return ( <> - <Button - variant="outline" - size="sm" - onClick={handleBulkEmail} - className="h-8" - > - <Mail className="mr-2 h-4 w-4" /> - 이메일 발송 ({selectedCount}) - </Button> - - <Button - variant="outline" - size="sm" - onClick={handleBulkDelete} - className="h-8 text-red-600 hover:text-red-700" - > - <Trash2 className="mr-2 h-4 w-4" /> - 삭제 ({selectedCount}) - </Button> + <div className="flex items-center gap-2"> + {/** 선택된 항목이 있을 때만 표시되는 액션들 */} + {selectedCount > 0 && ( + <> + <Button + variant="outline" + size="sm" + onClick={handleBulkEmail} + className="h-8" + disabled={isEmailSending} + > + {isEmailSending ? <Loader className="mr-2 h-4 w-4 animate-spin" /> : <Mail className="mr-2 h-4 w-4" />} + RFQ 발송 ({selectedCount}) + </Button> + + <Button + variant="outline" + size="sm" + onClick={handleBulkDelete} + className="h-8 text-red-600 hover:text-red-700" + disabled={!canDelete || selectedCount === 0} + title={!canDelete ? "DRAFT 상태의 RFQ만 삭제할 수 있습니다" : ""} + > + <Trash2 className="mr-2 h-4 w-4" /> + 삭제 ({draftCount}/{selectedCount}) + </Button> + </> + )} + + {/* S/L 확정 버튼 */} + {rfqId && ( + <Button + variant="default" + size="sm" + onClick={handleSlConfirm} + className="h-8" + > + <CheckCircle2 className="mr-2 h-4 w-4" /> + S/L 확정 + </Button> + )} + + {/* 초기 RFQ 추가 버튼 */} + {rfqId && ( + <AddInitialRfqDialog + rfqId={rfqId} + onSuccess={handleAddSuccess} + defaultValues={defaultValues} + /> + )} + </div> + + {/* 삭제 다이얼로그 */} + <DeleteInitialRfqDialog + open={showDeleteDialog} + onOpenChange={setShowDeleteDialog} + initialRfqs={selectedDetails} + showTrigger={false} + onSuccess={handleDeleteSuccess} + /> </> - )} - - {/** 항상 표시되는 액션들 */} - <Button - variant="outline" - size="sm" - onClick={handleExport} - className="h-8" - > - <Download className="mr-2 h-4 w-4" /> - 엑스포트 - </Button> - - <Button - variant="outline" - size="sm" - onClick={handleRefresh} - className="h-8" - > - <RefreshCw className="mr-2 h-4 w-4" /> - 새로고침 - </Button> - - <Button - variant="outline" - size="sm" - className="h-8" - > - <Settings className="mr-2 h-4 w-4" /> - 설정 - </Button> - </div> - ) -} + ) +}
\ No newline at end of file diff --git a/lib/b-rfq/initial/update-initial-rfq-sheet.tsx b/lib/b-rfq/initial/update-initial-rfq-sheet.tsx new file mode 100644 index 00000000..a19b5172 --- /dev/null +++ b/lib/b-rfq/initial/update-initial-rfq-sheet.tsx @@ -0,0 +1,496 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { CalendarIcon, Loader, ChevronsUpDown, Check } from "lucide-react" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import { format } from "date-fns" +import { ko } from "date-fns/locale" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Calendar } from "@/components/ui/calendar" +import { Checkbox } from "@/components/ui/checkbox" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + } from "@/components/ui/command" +import { Input } from "@/components/ui/input" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { UpdateInitialRfqSchema, updateInitialRfqSchema } from "../validations" +import { getIncotermsForSelection, modifyInitialRfq } from "../service" +import { InitialRfqDetailView } from "@/db/schema" + +interface UpdateInitialRfqSheetProps + extends React.ComponentPropsWithRef<typeof Sheet> { + initialRfq: InitialRfqDetailView | null +} + +interface Incoterm { + id: number + code: string + description: string +} + +export function UpdateInitialRfqSheet({ initialRfq, ...props }: UpdateInitialRfqSheetProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + const [incoterms, setIncoterms] = React.useState<Incoterm[]>([]) + const [incotermsLoading, setIncotermsLoading] = React.useState(false) + const [incotermsSearchOpen, setIncotermsSearchOpen] = React.useState(false) + + const loadIncoterms = React.useCallback(async () => { + setIncotermsLoading(true) + try { + const incotermsList = await getIncotermsForSelection() + setIncoterms(incotermsList) + } catch (error) { + console.error("Failed to load incoterms:", error) + toast.error("Incoterms 목록을 불러오는데 실패했습니다.") + } finally { + setIncotermsLoading(false) + } + }, []) + + React.useEffect(() => { + if (incoterms.length === 0) { + loadIncoterms() + } + }, [incoterms.length, loadIncoterms]) + + const form = useForm<UpdateInitialRfqSchema>({ + resolver: zodResolver(updateInitialRfqSchema), + defaultValues: { + initialRfqStatus: initialRfq?.initialRfqStatus ?? "DRAFT", + dueDate: initialRfq?.dueDate ?? new Date(), + validDate: initialRfq?.validDate ?? undefined, + incotermsCode: initialRfq?.incotermsCode ?? "", + classification: initialRfq?.classification ?? "", + sparepart: initialRfq?.sparepart ?? "", + rfqRevision: initialRfq?.rfqRevision ?? 0, + shortList: initialRfq?.shortList ?? false, + returnYn: initialRfq?.returnYn ?? false, + cpRequestYn: initialRfq?.cpRequestYn ?? false, + prjectGtcYn: initialRfq?.prjectGtcYn ?? false, + }, + }) + + // initialRfq가 변경될 때 폼 값을 업데이트 + React.useEffect(() => { + if (initialRfq) { + form.reset({ + initialRfqStatus: initialRfq.initialRfqStatus ?? "DRAFT", + dueDate: initialRfq.dueDate, + validDate: initialRfq.validDate, + incotermsCode: initialRfq.incotermsCode ?? "", + classification: initialRfq.classification ?? "", + sparepart: initialRfq.sparepart ?? "", + shortList: initialRfq.shortList ?? false, + returnYn: initialRfq.returnYn ?? false, + rfqRevision: initialRfq.rfqRevision ?? 0, + cpRequestYn: initialRfq.cpRequestYn ?? false, + prjectGtcYn: initialRfq.prjectGtcYn ?? false, + }) + } + }, [initialRfq, form]) + + function onSubmit(input: UpdateInitialRfqSchema) { + startUpdateTransition(async () => { + if (!initialRfq || !initialRfq.initialRfqId) { + toast.error("유효하지 않은 RFQ입니다.") + return + } + + const { error } = await modifyInitialRfq({ + id: initialRfq.initialRfqId, + ...input, + }) + + if (error) { + toast.error(error) + return + } + + form.reset() + props.onOpenChange?.(false) + toast.success("초기 RFQ가 수정되었습니다") + }) + } + + const selectedIncoterm = incoterms.find(incoterm => incoterm.code === form.watch("incotermsCode")) + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col h-full sm:max-w-md"> + {/* 고정 헤더 */} + <SheetHeader className="flex-shrink-0 text-left pb-6"> + <SheetTitle>초기 RFQ 수정</SheetTitle> + <SheetDescription> + 초기 RFQ 정보를 수정하고 변경사항을 저장하세요 + </SheetDescription> + </SheetHeader> + + {/* 스크롤 가능한 폼 영역 */} + <div className="flex-1 overflow-y-auto"> + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="flex flex-col gap-4 pr-2" + > + {/* RFQ 리비전 */} + <FormField + control={form.control} + name="rfqRevision" + render={({ field }) => ( + <FormItem> + <FormLabel>RFQ 리비전</FormLabel> + <FormControl> + <Input + type="number" + min="0" + placeholder="0" + {...field} + onChange={(e) => field.onChange(parseInt(e.target.value) || 0)} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 마감일 */} + <FormField + control={form.control} + name="dueDate" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel>마감일 *</FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant={"outline"} + className={cn( + "w-full pl-3 text-left font-normal", + !field.value && "text-muted-foreground" + )} + > + {field.value ? ( + format(field.value, "PPP", { locale: ko }) + ) : ( + <span>날짜를 선택하세요</span> + )} + <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <Calendar + mode="single" + selected={field.value} + onSelect={field.onChange} + disabled={(date) => + date < new Date("1900-01-01") + } + initialFocus + /> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + + {/* 유효일 */} + <FormField + control={form.control} + name="validDate" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel>유효일</FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant={"outline"} + className={cn( + "w-full pl-3 text-left font-normal", + !field.value && "text-muted-foreground" + )} + > + {field.value ? ( + format(field.value, "PPP", { locale: ko }) + ) : ( + <span>날짜를 선택하세요</span> + )} + <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <Calendar + mode="single" + selected={field.value} + onSelect={field.onChange} + disabled={(date) => + date < new Date("1900-01-01") + } + initialFocus + /> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + + {/* Incoterms 코드 */} + <FormField + control={form.control} + name="incotermsCode" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel>Incoterms</FormLabel> + <Popover open={incotermsSearchOpen} onOpenChange={setIncotermsSearchOpen}> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + role="combobox" + aria-expanded={incotermsSearchOpen} + className="justify-between" + disabled={incotermsLoading} + > + {selectedIncoterm ? ( + <div className="flex items-center gap-2"> + <span className="truncate"> + {selectedIncoterm.code} - {selectedIncoterm.description} + </span> + </div> + ) : ( + <span className="text-muted-foreground"> + {incotermsLoading ? "로딩 중..." : "인코텀즈를 선택하세요"} + </span> + )} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-full p-0" align="start"> + <Command> + <CommandInput + placeholder="코드 또는 내용으로 검색..." + className="h-9" + /> + <CommandList> + <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> + <CommandGroup> + {incoterms.map((incoterm) => ( + <CommandItem + key={incoterm.id} + value={`${incoterm.code} ${incoterm.description}`} + onSelect={() => { + field.onChange(incoterm.code) + setIncotermsSearchOpen(false) + }} + > + <div className="flex items-center gap-2 w-full"> + <div className="flex-1 min-w-0"> + <div className="font-medium truncate"> + {incoterm.code} - {incoterm.description} + </div> + </div> + <Check + className={cn( + "ml-auto h-4 w-4", + incoterm.code === field.value ? "opacity-100" : "opacity-0" + )} + /> + </div> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + {/* 체크박스 옵션들 */} + <div className="space-y-3"> + <FormField + control={form.control} + name="shortList" + render={({ field }) => ( + <FormItem className="flex flex-row items-start space-x-3 space-y-0"> + <FormControl> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + <div className="space-y-1 leading-none ml-2"> + <FormLabel>Short List</FormLabel> + </div> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="returnYn" + render={({ field }) => ( + <FormItem className="flex flex-row items-start space-x-3 space-y-0"> + <FormControl> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + <div className="space-y-1 leading-none ml-2"> + <FormLabel>회신 여부</FormLabel> + </div> + </FormItem> + )} + /> + + {/* 선급 */} + <FormField + control={form.control} + name="classification" + render={({ field }) => ( + <FormItem> + <FormLabel>선급</FormLabel> + <FormControl> + <Input + placeholder="선급" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 예비부품 */} + <FormField + control={form.control} + name="sparepart" + render={({ field }) => ( + <FormItem> + <FormLabel>예비부품</FormLabel> + <FormControl> + <Input + placeholder="O1, O2" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + + + + <FormField + control={form.control} + name="cpRequestYn" + render={({ field }) => ( + <FormItem className="flex flex-row items-start space-x-3 space-y-0"> + <FormControl> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + <div className="space-y-1 leading-none ml-2"> + <FormLabel>CP 요청</FormLabel> + </div> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="prjectGtcYn" + render={({ field }) => ( + <FormItem className="flex flex-row items-start space-x-3 space-y-0"> + <FormControl> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + <div className="space-y-1 leading-none ml-2"> + <FormLabel>프로젝트 GTC</FormLabel> + </div> + </FormItem> + )} + /> + </div> + + {/* 하단 여백 */} + <div className="h-4" /> + </form> + </Form> + </div> + + {/* 고정 푸터 */} + <SheetFooter className="flex-shrink-0 gap-2 pt-6 sm:space-x-0"> + <SheetClose asChild> + <Button type="button" variant="outline"> + 취소 + </Button> + </SheetClose> + <Button + onClick={form.handleSubmit(onSubmit)} + disabled={isUpdatePending} + > + {isUpdatePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + 저장 + </Button> + </SheetFooter> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/b-rfq/service.ts b/lib/b-rfq/service.ts index 0dc61832..c5398e6c 100644 --- a/lib/b-rfq/service.ts +++ b/lib/b-rfq/service.ts @@ -1,16 +1,24 @@ 'use server' -import { revalidateTag, unstable_cache } from "next/cache" -import { count, desc, asc, and, or, gte, lte, ilike, eq, inArray, sql } from "drizzle-orm" +import { revalidateTag, unstable_cache ,unstable_noStore} from "next/cache" +import {count, desc, asc, and, or, gte, lte, ilike, eq, inArray, sql } from "drizzle-orm" import { filterColumns } from "@/lib/filter-columns" import db from "@/db/db" -import { Incoterm, RfqDashboardView, Vendor, bRfqAttachmentRevisions, bRfqs, bRfqsAttachments, incoterms, initialRfq, initialRfqDetailView, projects, users, vendorAttachmentResponses, vendors } from "@/db/schema" // 실제 스키마 import 경로에 맞게 수정 +import { vendorResponseDetailView, + attachmentRevisionHistoryView, + rfqProgressSummaryView, + vendorResponseAttachmentsEnhanced ,Incoterm, RfqDashboardView, Vendor, VendorAttachmentResponse, bRfqAttachmentRevisions, bRfqs, bRfqsAttachments, incoterms, initialRfq, initialRfqDetailView, projects, users, vendorAttachmentResponses, vendors, + vendorResponseAttachmentsB} from "@/db/schema" // 실제 스키마 import 경로에 맞게 수정 import { rfqDashboardView } from "@/db/schema" // 뷰 import import type { SQL } from "drizzle-orm" -import { AttachmentRecord, CreateRfqInput, DeleteAttachmentsInput, GetInitialRfqDetailSchema, GetRFQDashboardSchema, GetRfqAttachmentsSchema, attachmentRecordSchema, createRfqServerSchema, deleteAttachmentsSchema } from "./validations" +import { AttachmentRecord, BulkEmailInput, CreateRfqInput, DeleteAttachmentsInput, GetInitialRfqDetailSchema, GetRFQDashboardSchema, GetRfqAttachmentsSchema, GetVendorResponsesSchema, RemoveInitialRfqsSchema, RequestRevisionResult, ResponseStatus, UpdateInitialRfqSchema, VendorRfqResponseSummary, attachmentRecordSchema, bulkEmailSchema, createRfqServerSchema, deleteAttachmentsSchema, removeInitialRfqsSchema, requestRevisionSchema, updateInitialRfqSchema } from "./validations" import { getServerSession } from "next-auth/next" import { authOptions } from "@/app/api/auth/[...nextauth]/route" import { unlink } from "fs/promises" +import { getErrorMessage } from "../handle-error" +import { AddInitialRfqFormData } from "./initial/add-initial-rfq-dialog" +import { sendEmail } from "../mail/sendEmail" +import { RfqType } from "../rfqs/validations" const tag = { initialRfqDetail:"initial-rfq", @@ -25,8 +33,7 @@ const tag = { } as const; export async function getRFQDashboard(input: GetRFQDashboardSchema) { - return unstable_cache( - async () => { + try { const offset = (input.page - 1) * input.perPage; @@ -140,11 +147,7 @@ export async function getRFQDashboard(input: GetRFQDashboardSchema) { console.error("Error in getRFQDashboard:", err); return { data: [], pageCount: 0, total: 0 }; } - }, - [JSON.stringify(input)], - { revalidate: 3600, tags: [tag.rfqDashboard] }, - - )(); + } // 헬퍼 함수들 @@ -192,6 +195,7 @@ async function generateNextSerial(picCode: string): Promise<string> { return "00001" // 기본값 } } + export async function createRfqAction(input: CreateRfqInput) { try { // 입력 데이터 검증 @@ -308,8 +312,6 @@ export async function getRfqAttachments( input: GetRfqAttachmentsSchema, rfqId: number ) { - return unstable_cache( - async () => { try { const offset = (input.page - 1) * input.perPage @@ -445,10 +447,7 @@ export async function getRfqAttachments( console.error("getRfqAttachments error:", err) return { data: [], pageCount: 0 } } - }, - [JSON.stringify(input), `${rfqId}`], - { revalidate: 300, tags: [tag.rfqAttachments(rfqId)] }, - )() + } // 첨부파일별 벤더 응답 통계 조회 @@ -495,51 +494,79 @@ export async function getVendorResponsesForAttachment( attachmentId: number, rfqType: 'INITIAL' | 'FINAL' = 'INITIAL' ) { - return unstable_cache( - async () => { - try { - const responses = await db - .select({ - id: vendorAttachmentResponses.id, - attachmentId: vendorAttachmentResponses.attachmentId, - vendorId: vendorAttachmentResponses.vendorId, - vendorCode: vendors.vendorCode, - vendorName: vendors.vendorName, - vendorCountry: vendors.country, - rfqType: vendorAttachmentResponses.rfqType, - rfqRecordId: vendorAttachmentResponses.rfqRecordId, - responseStatus: vendorAttachmentResponses.responseStatus, - currentRevision: vendorAttachmentResponses.currentRevision, - respondedRevision: vendorAttachmentResponses.respondedRevision, - responseComment: vendorAttachmentResponses.responseComment, - vendorComment: vendorAttachmentResponses.vendorComment, - requestedAt: vendorAttachmentResponses.requestedAt, - respondedAt: vendorAttachmentResponses.respondedAt, - updatedAt: vendorAttachmentResponses.updatedAt, - }) - .from(vendorAttachmentResponses) - .leftJoin(vendors, eq(vendorAttachmentResponses.vendorId, vendors.id)) - .where( - and( - eq(vendorAttachmentResponses.attachmentId, attachmentId), - eq(vendorAttachmentResponses.rfqType, rfqType) - ) - ) - .orderBy(vendors.vendorName) - - return responses - } catch (err) { - console.error("getVendorResponsesForAttachment error:", err) - return [] - } - }, - [`${attachmentId}`, rfqType], - { revalidate: 180, tags: [tag.vendorResponses(attachmentId, rfqType)] }, + try { + // 1. 기본 벤더 응답 정보 가져오기 + const responses = await db + .select({ + id: vendorAttachmentResponses.id, + attachmentId: vendorAttachmentResponses.attachmentId, + vendorId: vendorAttachmentResponses.vendorId, + vendorCode: vendors.vendorCode, + vendorName: vendors.vendorName, + vendorCountry: vendors.country, + rfqType: vendorAttachmentResponses.rfqType, + rfqRecordId: vendorAttachmentResponses.rfqRecordId, + responseStatus: vendorAttachmentResponses.responseStatus, + currentRevision: vendorAttachmentResponses.currentRevision, + respondedRevision: vendorAttachmentResponses.respondedRevision, + responseComment: vendorAttachmentResponses.responseComment, + vendorComment: vendorAttachmentResponses.vendorComment, + // 새로 추가된 필드들 + revisionRequestComment: vendorAttachmentResponses.revisionRequestComment, + revisionRequestedAt: vendorAttachmentResponses.revisionRequestedAt, + requestedAt: vendorAttachmentResponses.requestedAt, + respondedAt: vendorAttachmentResponses.respondedAt, + updatedAt: vendorAttachmentResponses.updatedAt, + }) + .from(vendorAttachmentResponses) + .leftJoin(vendors, eq(vendorAttachmentResponses.vendorId, vendors.id)) + .where( + and( + eq(vendorAttachmentResponses.attachmentId, attachmentId), + eq(vendorAttachmentResponses.rfqType, rfqType) + ) + ) + .orderBy(vendors.vendorName); + + // 2. 각 응답에 대한 파일 정보 가져오기 + const responseIds = responses.map(r => r.id); + + let responseFiles: any[] = []; + if (responseIds.length > 0) { + responseFiles = await db + .select({ + id: vendorResponseAttachmentsB.id, + vendorResponseId: vendorResponseAttachmentsB.vendorResponseId, + fileName: vendorResponseAttachmentsB.fileName, + originalFileName: vendorResponseAttachmentsB.originalFileName, + filePath: vendorResponseAttachmentsB.filePath, + fileSize: vendorResponseAttachmentsB.fileSize, + fileType: vendorResponseAttachmentsB.fileType, + description: vendorResponseAttachmentsB.description, + uploadedAt: vendorResponseAttachmentsB.uploadedAt, + }) + .from(vendorResponseAttachmentsB) + .where(inArray(vendorResponseAttachmentsB.vendorResponseId, responseIds)) + .orderBy(desc(vendorResponseAttachmentsB.uploadedAt)); + } - )() + // 3. 응답에 파일 정보 병합 + const enhancedResponses = responses.map(response => ({ + ...response, + files: responseFiles.filter(file => file.vendorResponseId === response.id), + totalFiles: responseFiles.filter(file => file.vendorResponseId === response.id).length, + latestFile: responseFiles + .filter(file => file.vendorResponseId === response.id) + .sort((a, b) => new Date(b.uploadedAt).getTime() - new Date(a.uploadedAt).getTime())[0] || null, + })); + + return enhancedResponses; + } catch (err) { + console.error("getVendorResponsesForAttachment error:", err); + return []; + } } - export async function confirmDocuments(rfqId: number) { try { const session = await getServerSession(authOptions) @@ -650,10 +677,7 @@ export async function requestTbe(rfqId: number, attachmentIds?: number[]) { } }) - // 캐시 무효화 - revalidateTag(`rfq-${rfqId}`) - revalidateTag(`rfq-attachments-${rfqId}`) - + const attachmentCount = targetAttachments.length const attachmentList = targetAttachments .map(a => `${a.serialNo} (${a.currentRevision})`) @@ -738,7 +762,7 @@ export async function addRfqAttachmentRecord(record: AttachmentRecord) { filePath: validatedRecord.filePath, fileSize: validatedRecord.fileSize, fileType: validatedRecord.fileType, - revisionComment: validatedRecord.revisionComment || "초기 업로드", + revisionComment: validatedRecord.revisionComment, isLatest: true, createdBy: userId, }) @@ -756,11 +780,7 @@ export async function addRfqAttachmentRecord(record: AttachmentRecord) { return { attachment, revision } }) - revalidateTag(tag.rfq(validatedRecord.rfqId)); - revalidateTag(tag.rfqDashboard); - revalidateTag(tag.rfqAttachments(validatedRecord.rfqId)); - - return { + return { success: true, message: `파일이 성공적으로 등록되었습니다. (시리얼: ${result.attachment.serialNo}, 리비전: Rev.0)`, attachment: result.attachment, @@ -868,13 +888,7 @@ export async function addRevisionToAttachment( return inserted; }); - // ──────────────────────────────────────────────────────────────────────────── - // 6. 캐시 무효화 (rfqId 기준으로 수정) - // ──────────────────────────────────────────────────────────────────────────── - revalidateTag(tag.rfq(rfqId)); - revalidateTag(tag.rfqDashboard); - revalidateTag(tag.rfqAttachments(rfqId)); - revalidateTag(tag.attachmentRevisions(attachmentId)); + return { success: true, @@ -892,8 +906,7 @@ export async function addRevisionToAttachment( // 특정 첨부파일의 모든 리비전 조회 export async function getAttachmentRevisions(attachmentId: number) { - return unstable_cache( - async () => { + try { const revisions = await db .select({ @@ -927,11 +940,6 @@ export async function getAttachmentRevisions(attachmentId: number) { revisions: [], } } - }, - [`${attachmentId}`], - { revalidate: 180, tags: [tag.attachmentRevisions(attachmentId)] }, - - )() } @@ -999,11 +1007,7 @@ export async function deleteRfqAttachments(input: DeleteAttachmentsInput) { } }) - // 캐시 무효화 - result.rfqIds.forEach(rfqId => { - revalidateTag(`rfq-attachments-${rfqId}`) - }) - + return { success: true, message: `${result.deletedCount}개의 첨부파일이 삭제되었습니다.`, @@ -1025,8 +1029,7 @@ export async function deleteRfqAttachments(input: DeleteAttachmentsInput) { //Initial RFQ export async function getInitialRfqDetail(input: GetInitialRfqDetailSchema, rfqId?: number) { - return unstable_cache( - async () => { + try { const offset = (input.page - 1) * input.perPage; @@ -1112,6 +1115,7 @@ export async function getInitialRfqDetail(input: GetInitialRfqDetailSchema, rfqI return { data: [], pageCount: 0, total: 0 }; } + console.log(totalResult); console.log(total); // 7) 정렬 및 페이징 처리된 데이터 조회 @@ -1139,10 +1143,6 @@ export async function getInitialRfqDetail(input: GetInitialRfqDetailSchema, rfqI console.error("Error in getInitialRfqDetail:", err); return { data: [], pageCount: 0, total: 0 }; } - }, - [JSON.stringify(input)], - { revalidate: 3600, tags: [tag.initialRfqDetail] }, - )(); } export async function getVendorsForSelection() { @@ -1152,6 +1152,7 @@ export async function getVendorsForSelection() { id: vendors.id, vendorName: vendors.vendorName, vendorCode: vendors.vendorCode, + taxId: vendors.taxId, country: vendors.country, status: vendors.status, }) @@ -1179,6 +1180,8 @@ export async function getVendorsForSelection() { export async function addInitialRfqRecord(data: AddInitialRfqFormData & { rfqId: number }) { try { + console.log('Incoming data:', data); + const [newRecord] = await db .insert(initialRfq) .values({ @@ -1231,4 +1234,1371 @@ export async function getIncotermsForSelection() { console.error("Error fetching incoterms:", error) throw new Error("Failed to fetch incoterms") } +} + +export async function removeInitialRfqs(input: RemoveInitialRfqsSchema) { + unstable_noStore () + try { + const { ids } = removeInitialRfqsSchema.parse(input) + + await db.transaction(async (tx) => { + await tx.delete(initialRfq).where(inArray(initialRfq.id, ids)) + }) + + revalidateTag(tag.initialRfqDetail) + + return { + data: null, + error: null, + } + } catch (err) { + return { + data: null, + error: getErrorMessage(err), + } + } +} + +interface ModifyInitialRfqInput extends UpdateInitialRfqSchema { + id: number +} + +export async function modifyInitialRfq(input: ModifyInitialRfqInput) { + unstable_noStore () + try { + const { id, ...updateData } = input + + // validation + updateInitialRfqSchema.parse(updateData) + + await db.transaction(async (tx) => { + const existingRfq = await tx + .select() + .from(initialRfq) + .where(eq(initialRfq.id, id)) + .limit(1) + + if (existingRfq.length === 0) { + throw new Error("초기 RFQ를 찾을 수 없습니다.") + } + + await tx + .update(initialRfq) + .set({ + ...updateData, + // Convert empty strings to null for optional fields + incotermsCode: updateData.incotermsCode || null, + gtc: updateData.gtc || null, + gtcValidDate: updateData.gtcValidDate || null, + classification: updateData.classification || null, + sparepart: updateData.sparepart || null, + validDate: updateData.validDate || null, + updatedAt: new Date(), + }) + .where(eq(initialRfq.id, id)) + }) + + revalidateTag(tag.initialRfqDetail) + + return { + data: null, + error: null, + } + } catch (err) { + return { + data: null, + error: getErrorMessage(err), + } + } +} + + + + +// 이메일 발송용 데이터 타입 +interface EmailData { + rfqCode: string + projectName: string + projectCompany: string + projectFlag: string + projectSite: string + classification: string + incotermsCode: string + incotermsDescription: string + dueDate: string + validDate: string + sparepart: string + vendorName: string + picName: string + picEmail: string + warrantyPeriod: string + packageName: string + rfqRevision: number + emailType: string +} + +export async function sendBulkInitialRfqEmails(input: BulkEmailInput) { + unstable_noStore() + try { + + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("인증이 필요합니다.") + } + + const { initialRfqIds, language } = bulkEmailSchema.parse(input) + + // 1. 선택된 초기 RFQ들의 상세 정보 조회 + const initialRfqDetails = await db + .select({ + // initialRfqDetailView 필드들을 명시적으로 선택 + rfqId: initialRfqDetailView.rfqId, + rfqCode: initialRfqDetailView.rfqCode, + rfqStatus: initialRfqDetailView.rfqStatus, + initialRfqId: initialRfqDetailView.initialRfqId, + initialRfqStatus: initialRfqDetailView.initialRfqStatus, + vendorId: initialRfqDetailView.vendorId, + vendorCode: initialRfqDetailView.vendorCode, + vendorName: initialRfqDetailView.vendorName, + vendorCategory: initialRfqDetailView.vendorCategory, + vendorCountry: initialRfqDetailView.vendorCountry, + vendorBusinessSize: initialRfqDetailView.vendorBusinessSize, + dueDate: initialRfqDetailView.dueDate, + validDate: initialRfqDetailView.validDate, + incotermsCode: initialRfqDetailView.incotermsCode, + incotermsDescription: initialRfqDetailView.incotermsDescription, + shortList: initialRfqDetailView.shortList, + returnYn: initialRfqDetailView.returnYn, + cpRequestYn: initialRfqDetailView.cpRequestYn, + prjectGtcYn: initialRfqDetailView.prjectGtcYn, + returnRevision: initialRfqDetailView.returnRevision, + rfqRevision: initialRfqDetailView.rfqRevision, + gtc: initialRfqDetailView.gtc, + gtcValidDate: initialRfqDetailView.gtcValidDate, + classification: initialRfqDetailView.classification, + sparepart: initialRfqDetailView.sparepart, + createdAt: initialRfqDetailView.createdAt, + updatedAt: initialRfqDetailView.updatedAt, + // bRfqs에서 추가로 필요한 필드들 + picName: bRfqs.picName, + picCode: bRfqs.picCode, + packageName: bRfqs.packageName, + packageNo: bRfqs.packageNo, + projectCompany: bRfqs.projectCompany, + projectFlag: bRfqs.projectFlag, + projectSite: bRfqs.projectSite, + }) + .from(initialRfqDetailView) + .leftJoin(bRfqs, eq(initialRfqDetailView.rfqId, bRfqs.id)) + .where(inArray(initialRfqDetailView.initialRfqId, initialRfqIds)) + + if (initialRfqDetails.length === 0) { + return { + success: false, + message: "선택된 초기 RFQ를 찾을 수 없습니다.", + } + } + + // 2. 각 RFQ에 대한 첨부파일 조회 + const rfqIds = [...new Set(initialRfqDetails.map(rfq => rfq.rfqId))].filter((id): id is number => id !== null) + const attachments = await db + .select() + .from(bRfqsAttachments) + .where(inArray(bRfqsAttachments.rfqId, rfqIds)) + + // 3. 벤더 이메일 정보 조회 (모든 이메일 주소 포함) + const vendorIds = [...new Set(initialRfqDetails.map(rfq => rfq.vendorId))].filter((id): id is number => id !== null) + const vendorsWithAllEmails = await db + .select({ + id: vendors.id, + vendorName: vendors.vendorName, + email: vendors.email, + representativeEmail: vendors.representativeEmail, + // 연락처 이메일들을 JSON 배열로 집계 + contactEmails: sql<string[]>` + COALESCE( + (SELECT json_agg(contact_email) + FROM vendor_contacts + WHERE vendor_id = ${vendors.id} + AND contact_email IS NOT NULL + AND contact_email != '' + ), + '[]'::json + ) + `.as("contact_emails") + }) + .from(vendors) + .where(inArray(vendors.id, vendorIds)) + + // 각 벤더의 모든 유효한 이메일 주소를 정리하는 함수 + function getAllVendorEmails(vendor: typeof vendorsWithAllEmails[0]): string[] { + const emails: string[] = [] + + // 벤더 기본 이메일 + if (vendor.email) { + emails.push(vendor.email) + } + + // 대표자 이메일 + if (vendor.representativeEmail && vendor.representativeEmail !== vendor.email) { + emails.push(vendor.representativeEmail) + } + + // 연락처 이메일들 + if (vendor.contactEmails && Array.isArray(vendor.contactEmails)) { + vendor.contactEmails.forEach(contactEmail => { + if (contactEmail && !emails.includes(contactEmail)) { + emails.push(contactEmail) + } + }) + } + + return emails.filter(email => email && email.trim() !== '') + } + + const results = [] + const errors = [] + + // 4. 각 초기 RFQ에 대해 처리 + for (const rfqDetail of initialRfqDetails) { + try { + // vendorId null 체크 + if (!rfqDetail.vendorId) { + errors.push(`벤더 ID가 없습니다: RFQ ID ${rfqDetail.initialRfqId}`) + continue + } + + // 해당 RFQ의 첨부파일들 + const rfqAttachments = attachments.filter(att => att.rfqId === rfqDetail.rfqId) + + // 벤더 정보 + const vendor = vendorsWithAllEmails.find(v => v.id === rfqDetail.vendorId) + if (!vendor) { + errors.push(`벤더 정보를 찾을 수 없습니다: RFQ ID ${rfqDetail.initialRfqId}`) + continue + } + + // 해당 벤더의 모든 이메일 주소 수집 + const vendorEmails = getAllVendorEmails(vendor) + + if (vendorEmails.length === 0) { + errors.push(`벤더 이메일 주소가 없습니다: ${vendor.vendorName}`) + continue + } + + // 5. 기존 vendorAttachmentResponses 조회하여 리비전 상태 확인 + const currentRfqRevision = rfqDetail.rfqRevision || 0 + let emailType: "NEW" | "RESEND" | "REVISION" = "NEW" + let revisionToUse = currentRfqRevision + + // 첫 번째 첨부파일을 기준으로 기존 응답 조회 (리비전 상태 확인용) + if (rfqAttachments.length > 0 && rfqDetail.initialRfqId) { + const existingResponses = await db + .select() + .from(vendorAttachmentResponses) + .where( + and( + eq(vendorAttachmentResponses.vendorId, rfqDetail.vendorId), + eq(vendorAttachmentResponses.rfqType, "INITIAL"), + eq(vendorAttachmentResponses.rfqRecordId, rfqDetail.initialRfqId) + ) + ) + + if (existingResponses.length > 0) { + // 기존 응답이 있음 + const existingRevision = parseInt(existingResponses[0].currentRevision?.replace("Rev.", "") || "0") + + if (currentRfqRevision > existingRevision) { + // RFQ 리비전이 올라감 → 리비전 업데이트 + emailType = "REVISION" + revisionToUse = currentRfqRevision + } else { + // 동일하거나 낮음 → 재전송 + emailType = "RESEND" + revisionToUse = existingRevision + } + } else { + // 기존 응답이 없음 → 신규 전송 + emailType = "NEW" + revisionToUse = currentRfqRevision + } + } + + // 6. vendorAttachmentResponses 레코드 생성/업데이트 + for (const attachment of rfqAttachments) { + const existingResponse = await db + .select() + .from(vendorAttachmentResponses) + .where( + and( + eq(vendorAttachmentResponses.attachmentId, attachment.id), + eq(vendorAttachmentResponses.vendorId, rfqDetail.vendorId), + eq(vendorAttachmentResponses.rfqType, "INITIAL") + ) + ) + .limit(1) + + if (existingResponse.length === 0) { + // 새 응답 레코드 생성 + await db.insert(vendorAttachmentResponses).values({ + attachmentId: attachment.id, + vendorId: rfqDetail.vendorId, + rfqType: "INITIAL", + rfqRecordId: rfqDetail.initialRfqId, + responseStatus: "NOT_RESPONDED", + currentRevision: `Rev.${revisionToUse}`, + requestedAt: new Date(), + }) + } else { + // 기존 레코드 업데이트 + await db + .update(vendorAttachmentResponses) + .set({ + currentRevision: `Rev.${revisionToUse}`, + requestedAt: new Date(), + // 리비전 업데이트인 경우 응답 상태 초기화 + responseStatus: emailType === "REVISION" ? "NOT_RESPONDED" : existingResponse[0].responseStatus, + }) + .where(eq(vendorAttachmentResponses.id, existingResponse[0].id)) + } + + } + + const formatDateSafely = (date: Date | string | null | undefined): string => { + if (!date) return "" + try { + // Date 객체로 변환하고 포맷팅 + const dateObj = new Date(date) + // 유효한 날짜인지 확인 + if (isNaN(dateObj.getTime())) return "" + + return dateObj.toLocaleDateString('en-US', { + year: 'numeric', + month: '2-digit', + day: '2-digit' + }) + } catch (error) { + console.error("Date formatting error:", error) + return "" + } + } + + // 7. 이메일 발송 + const emailData: EmailData = { + name:vendor.vendorName, + rfqCode: rfqDetail.rfqCode || "", + projectName: rfqDetail.rfqCode || "", // 실제 프로젝트명이 있다면 사용 + projectCompany: rfqDetail.projectCompany || "", + projectFlag: rfqDetail.projectFlag || "", + projectSite: rfqDetail.projectSite || "", + classification: rfqDetail.classification || "ABS", + incotermsCode: rfqDetail.incotermsCode || "FOB", + incotermsDescription: rfqDetail.incotermsDescription || "FOB Finland Port", + dueDate: rfqDetail.dueDate ? formatDateSafely(rfqDetail.dueDate) : "", + validDate: rfqDetail.validDate ?formatDateSafely(rfqDetail.validDate) : "", + sparepart: rfqDetail.sparepart || "One(1) year operational spare parts", + vendorName: vendor.vendorName, + picName: session.user.name || rfqDetail.picName || "Procurement Manager", + picEmail: session.user.email || "procurement@samsung.com", + warrantyPeriod: "Refer to commercial package attached", + packageName: rfqDetail.packageName || "", + rfqRevision: revisionToUse, // 리비전 정보 추가 + emailType: emailType, // 이메일 타입 추가 + } + + // 이메일 제목 생성 (타입에 따라 다르게) + let emailSubject = "" + const revisionText = revisionToUse > 0 ? ` Rev.${revisionToUse}` : "" + + switch (emailType) { + case "NEW": + emailSubject = `[SHI RFQ] ${rfqDetail.rfqCode}${revisionText} Invitation to Bidder for ${emailData.packageName} * ${vendor.vendorName} * RFQ No. ${rfqDetail.rfqCode}` + break + case "RESEND": + emailSubject = `[SHI RFQ - RESEND] ${rfqDetail.rfqCode}${revisionText} Invitation to Bidder for ${emailData.packageName} * ${vendor.vendorName} * RFQ No. ${rfqDetail.rfqCode}` + break + case "REVISION": + emailSubject = `[SHI RFQ - REVISED] ${rfqDetail.rfqCode}${revisionText} Invitation to Bidder for ${emailData.packageName} * ${vendor.vendorName} * RFQ No. ${rfqDetail.rfqCode}` + break + } + + // nodemailer로 모든 이메일 주소에 한번에 발송 + await sendEmail({ + // from: session.user.email || undefined, + to: vendorEmails.join(", "), // 콤마+공백으로 구분 + subject: emailSubject, + template: "initial-rfq-invitation", // hbs 템플릿 파일명 + context: { + ...emailData, + language, + } + }) + + // 8. 초기 RFQ 상태 업데이트 (리비전은 변경하지 않음 - 이미 DB에 저장된 값 사용) + if(rfqDetail.initialRfqId && rfqDetail.rfqId){ + // Promise.all로 두 테이블 동시 업데이트 + await Promise.all([ + // initialRfq 테이블 업데이트 + db + .update(initialRfq) + .set({ + initialRfqStatus: "Init. RFQ Sent", + updatedAt: new Date(), + }) + .where(eq(initialRfq.id, rfqDetail.initialRfqId)), + + // bRfqs 테이블 status도 함께 업데이트 + db + .update(bRfqs) + .set({ + status: "Init. RFQ Sent", + // updatedBy: session.user.id, + updatedAt: new Date(), + }) + .where(eq(bRfqs.id, rfqDetail.rfqId)) + ]); + } + + results.push({ + initialRfqId: rfqDetail.initialRfqId, + vendorName: vendor.vendorName, + vendorEmails: vendorEmails, // 발송된 모든 이메일 주소 기록 + emailCount: vendorEmails.length, + emailType: emailType, + rfqRevision: revisionToUse, + success: true, + }) + + } catch (error) { + console.error(`Error processing RFQ ${rfqDetail.initialRfqId}:`, error) + errors.push(`RFQ ${rfqDetail.initialRfqId} 처리 중 오류: ${getErrorMessage(error)}`) + } + } + + // 9. 페이지 새로고침 + revalidateTag(tag.initialRfqDetail) + revalidateTag(tag.rfqDashboard) // 📋 RFQ 대시보드도 새로고침 + + + return { + success: true, + message: `${results.length}개의 RFQ 이메일이 발송되었습니다.`, + results, + errors: errors.length > 0 ? errors : undefined, + } + + } catch (err) { + console.error("Bulk email error:", err) + return { + success: false, + message: getErrorMessage(err), + } + } +} + +// 개별 RFQ 이메일 재발송 +export async function resendInitialRfqEmail(initialRfqId: number) { + unstable_noStore() + try { + const result = await sendBulkInitialRfqEmails({ + initialRfqIds: [initialRfqId], + language: "en", + }) + + return result + } catch (err) { + return { + success: false, + message: getErrorMessage(err), + } + } +} + +export type VendorResponseDetail = VendorAttachmentResponse & { + attachment: { + id: number; + attachmentType: string; + serialNo: string; + description: string | null; + currentRevision: string; + }; + vendor: { + id: number; + vendorCode: string; + vendorName: string; + country: string | null; + businessSize: string | null; + }; + rfq: { + id: number; + rfqCode: string | null; + description: string | null; + status: string; + dueDate: Date; + }; +}; + +export async function getVendorRfqResponses(input: GetVendorResponsesSchema, vendorId?: string, rfqId?: string) { + try { + // 페이지네이션 설정 + const page = input.page || 1; + const perPage = input.perPage || 10; + const offset = (page - 1) * perPage; + + // 기본 조건 + let whereConditions = []; + + // 벤더 ID 조건 + if (vendorId) { + whereConditions.push(eq(vendorAttachmentResponses.vendorId, Number(vendorId))); + } + + // RFQ 타입 조건 + // if (input.rfqType !== "ALL") { + // whereConditions.push(eq(vendorAttachmentResponses.rfqType, input.rfqType as RfqType)); + // } + + // 날짜 범위 조건 + if (input.from && input.to) { + whereConditions.push( + and( + gte(vendorAttachmentResponses.requestedAt, new Date(input.from)), + lte(vendorAttachmentResponses.requestedAt, new Date(input.to)) + ) + ); + } + + const baseWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined; + + // 그룹핑된 응답 요약 데이터 조회 + const groupedResponses = await db + .select({ + vendorId: vendorAttachmentResponses.vendorId, + rfqRecordId: vendorAttachmentResponses.rfqRecordId, + rfqType: vendorAttachmentResponses.rfqType, + + // 통계 계산 (조건부 COUNT 수정) + totalAttachments: count(), + respondedCount: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'RESPONDED' THEN 1 ELSE 0 END)`, + pendingCount: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'NOT_RESPONDED' THEN 1 ELSE 0 END)`, + revisionRequestedCount: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'REVISION_REQUESTED' THEN 1 ELSE 0 END)`, + waivedCount: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'WAIVED' THEN 1 ELSE 0 END)`, + + // 날짜 정보 + requestedAt: sql<Date>`MIN(${vendorAttachmentResponses.requestedAt})`, + lastRespondedAt: sql<Date | null>`MAX(${vendorAttachmentResponses.respondedAt})`, + + // 코멘트 여부 + hasComments: sql<boolean>`BOOL_OR(${vendorAttachmentResponses.responseComment} IS NOT NULL OR ${vendorAttachmentResponses.vendorComment} IS NOT NULL)`, + }) + .from(vendorAttachmentResponses) + .where(baseWhere) + .groupBy( + vendorAttachmentResponses.vendorId, + vendorAttachmentResponses.rfqRecordId, + vendorAttachmentResponses.rfqType + ) + .orderBy(desc(sql`MIN(${vendorAttachmentResponses.requestedAt})`)) + .offset(offset) + .limit(perPage); + + // 벤더 정보와 RFQ 정보를 별도로 조회 + const vendorIds = [...new Set(groupedResponses.map(r => r.vendorId))]; + const rfqRecordIds = [...new Set(groupedResponses.map(r => r.rfqRecordId))]; + + // 벤더 정보 조회 + const vendorsData = await db.query.vendors.findMany({ + where: or(...vendorIds.map(id => eq(vendors.id, id))), + columns: { + id: true, + vendorCode: true, + vendorName: true, + country: true, + businessSize: true, + } + }); + + // RFQ 정보 조회 (초기 RFQ와 최종 RFQ 모두) + const [initialRfqs] = await Promise.all([ + db.query.initialRfq.findMany({ + where: or(...rfqRecordIds.map(id => eq(initialRfq.id, id))), + with: { + rfq: { + columns: { + id: true, + rfqCode: true, + description: true, + status: true, + dueDate: true, + } + } + } + }) + + ]); + + // 데이터 조합 및 변환 + const transformedResponses: VendorRfqResponseSummary[] = groupedResponses.map(response => { + const vendor = vendorsData.find(v => v.id === response.vendorId); + + let rfqInfo = null; + if (response.rfqType === "INITIAL") { + const initialRfq = initialRfqs.find(r => r.id === response.rfqRecordId); + rfqInfo = initialRfq?.rfq || null; + } + + // 응답률 계산 + const responseRate = Number(response.totalAttachments) > 0 + ? Math.round((Number(response.respondedCount) / Number(response.totalAttachments)) * 100) + : 0; + + // 완료율 계산 (응답완료 + 포기) + const completionRate = Number(response.totalAttachments) > 0 + ? Math.round(((Number(response.respondedCount) + Number(response.waivedCount)) / Number(response.totalAttachments)) * 100) + : 0; + + // 전체 상태 결정 + let overallStatus: ResponseStatus = "NOT_RESPONDED"; + if (Number(response.revisionRequestedCount) > 0) { + overallStatus = "REVISION_REQUESTED"; + } else if (completionRate === 100) { + overallStatus = Number(response.waivedCount) === Number(response.totalAttachments) ? "WAIVED" : "RESPONDED"; + } else if (Number(response.respondedCount) > 0) { + overallStatus = "RESPONDED"; // 부분 응답 + } + + return { + id: `${response.vendorId}-${response.rfqRecordId}-${response.rfqType}`, + vendorId: response.vendorId, + rfqRecordId: response.rfqRecordId, + rfqType: response.rfqType, + rfq: rfqInfo, + vendor: vendor || null, + totalAttachments: Number(response.totalAttachments), + respondedCount: Number(response.respondedCount), + pendingCount: Number(response.pendingCount), + revisionRequestedCount: Number(response.revisionRequestedCount), + waivedCount: Number(response.waivedCount), + responseRate, + completionRate, + overallStatus, + requestedAt: response.requestedAt, + lastRespondedAt: response.lastRespondedAt, + hasComments: response.hasComments, + }; + }); + + // 전체 개수 조회 (그룹핑 기준) - PostgreSQL 호환 방식 + const totalCountResult = await db + .select({ + totalCount: sql<number>`COUNT(DISTINCT (${vendorAttachmentResponses.vendorId}, ${vendorAttachmentResponses.rfqRecordId}, ${vendorAttachmentResponses.rfqType}))` + }) + .from(vendorAttachmentResponses) + .where(baseWhere); + + const totalCount = Number(totalCountResult[0].totalCount); + const pageCount = Math.ceil(totalCount / perPage); + + return { + data: transformedResponses, + pageCount, + totalCount + }; + + } catch (err) { + console.error("getVendorRfqResponses 에러:", err); + return { data: [], pageCount: 0, totalCount: 0 }; + } +} +/** + * 특정 RFQ의 첨부파일별 응답 상세 조회 (상세 페이지용) + */ +export async function getRfqAttachmentResponses(vendorId: string, rfqRecordId: string) { + try { + // 해당 RFQ의 모든 첨부파일 응답 조회 + const responses = await db.query.vendorAttachmentResponses.findMany({ + where: and( + eq(vendorAttachmentResponses.vendorId, Number(vendorId)), + eq(vendorAttachmentResponses.rfqRecordId, Number(rfqRecordId)), + ), + with: { + attachment: { + with: { + rfq: { + columns: { + id: true, + rfqCode: true, + description: true, + status: true, + dueDate: true, + // 추가 정보 + picCode: true, + picName: true, + EngPicName: true, + packageNo: true, + packageName: true, + projectId: true, + projectCompany: true, + projectFlag: true, + projectSite: true, + remark: true, + }, + with: { + project: { + columns: { + id: true, + code: true, + name: true, + type: true, + } + } + } + } + } + }, + vendor: { + columns: { + id: true, + vendorCode: true, + vendorName: true, + country: true, + businessSize: true, + } + }, + responseAttachments: true, + }, + orderBy: [asc(vendorAttachmentResponses.attachmentId)] + }); + + return { + data: responses, + rfqInfo: responses[0]?.attachment?.rfq || null, + vendorInfo: responses[0]?.vendor || null, + }; + + } catch (err) { + console.error("getRfqAttachmentResponses 에러:", err); + return { data: [], rfqInfo: null, vendorInfo: null }; + } +} + +export async function getVendorResponseStatusCounts(vendorId?: string, rfqId?: string, rfqType?: RfqType) { + try { + const initial: Record<ResponseStatus, number> = { + NOT_RESPONDED: 0, + RESPONDED: 0, + REVISION_REQUESTED: 0, + WAIVED: 0, + }; + + // 조건 설정 + let whereConditions = []; + + // 벤더 ID 조건 + if (vendorId) { + whereConditions.push(eq(vendorAttachmentResponses.vendorId, Number(vendorId))); + } + + // RFQ ID 조건 + if (rfqId) { + const attachmentIds = await db + .select({ id: bRfqsAttachments.id }) + .from(bRfqsAttachments) + .where(eq(bRfqsAttachments.rfqId, Number(rfqId))); + + if (attachmentIds.length > 0) { + whereConditions.push( + or(...attachmentIds.map(att => eq(vendorAttachmentResponses.attachmentId, att.id))) + ); + } + } + + // RFQ 타입 조건 + if (rfqType) { + whereConditions.push(eq(vendorAttachmentResponses.rfqType, rfqType)); + } + + const whereCondition = whereConditions.length > 0 ? and(...whereConditions) : undefined; + + // 상태별 그룹핑 쿼리 + const rows = await db + .select({ + status: vendorAttachmentResponses.responseStatus, + count: count(), + }) + .from(vendorAttachmentResponses) + .where(whereCondition) + .groupBy(vendorAttachmentResponses.responseStatus); + + // 결과 처리 + const result = rows.reduce<Record<ResponseStatus, number>>((acc, { status, count }) => { + if (status) { + acc[status as ResponseStatus] = Number(count); + } + return acc; + }, initial); + + return result; + } catch (err) { + console.error("getVendorResponseStatusCounts 에러:", err); + return {} as Record<ResponseStatus, number>; + } +} + +/** + * RFQ별 벤더 응답 요약 조회 + */ +export async function getRfqResponseSummary(rfqId: string, rfqType?: RfqType) { + + try { + // RFQ의 첨부파일 목록 조회 (relations 사용) + const attachments = await db.query.bRfqsAttachments.findMany({ + where: eq(bRfqsAttachments.rfqId, Number(rfqId)), + columns: { + id: true, + attachmentType: true, + serialNo: true, + description: true, + } + }); + + if (attachments.length === 0) { + return { + totalAttachments: 0, + totalVendors: 0, + responseRate: 0, + completionRate: 0, + statusCounts: {} as Record<ResponseStatus, number> + }; + } + + // 조건 설정 + let whereConditions = [ + or(...attachments.map(att => eq(vendorAttachmentResponses.attachmentId, att.id))) + ]; + + if (rfqType) { + whereConditions.push(eq(vendorAttachmentResponses.rfqType, rfqType)); + } + + const whereCondition = and(...whereConditions); + + // 벤더 수 및 응답 통계 조회 + const [vendorStats, statusCounts] = await Promise.all([ + // 전체 벤더 수 및 응답 벤더 수 (조건부 COUNT 수정) + db + .select({ + totalVendors: count(), + respondedVendors: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'RESPONDED' THEN 1 ELSE 0 END)`, + completedVendors: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'RESPONDED' OR ${vendorAttachmentResponses.responseStatus} = 'WAIVED' THEN 1 ELSE 0 END)`, + }) + .from(vendorAttachmentResponses) + .where(whereCondition), + + // 상태별 개수 + db + .select({ + status: vendorAttachmentResponses.responseStatus, + count: count(), + }) + .from(vendorAttachmentResponses) + .where(whereCondition) + .groupBy(vendorAttachmentResponses.responseStatus) + ]); + + const stats = vendorStats[0]; + const statusCountsMap = statusCounts.reduce<Record<ResponseStatus, number>>((acc, { status, count }) => { + if (status) { + acc[status as ResponseStatus] = Number(count); + } + return acc; + }, { + NOT_RESPONDED: 0, + RESPONDED: 0, + REVISION_REQUESTED: 0, + WAIVED: 0, + }); + + const responseRate = stats.totalVendors > 0 + ? Math.round((Number(stats.respondedVendors) / Number(stats.totalVendors)) * 100) + : 0; + + const completionRate = stats.totalVendors > 0 + ? Math.round((Number(stats.completedVendors) / Number(stats.totalVendors)) * 100) + : 0; + + return { + totalAttachments: attachments.length, + totalVendors: Number(stats.totalVendors), + responseRate, + completionRate, + statusCounts: statusCountsMap + }; + + } catch (err) { + console.error("getRfqResponseSummary 에러:", err); + return { + totalAttachments: 0, + totalVendors: 0, + responseRate: 0, + completionRate: 0, + statusCounts: {} as Record<ResponseStatus, number> + }; + } +} + +/** + * 벤더별 응답 진행률 조회 + */ +export async function getVendorResponseProgress(vendorId: string) { + + try { + let whereConditions = [eq(vendorAttachmentResponses.vendorId, Number(vendorId))]; + + const whereCondition = and(...whereConditions); + + const progress = await db + .select({ + totalRequests: count(), + responded: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'RESPONDED' THEN 1 ELSE 0 END)`, + pending: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'NOT_RESPONDED' THEN 1 ELSE 0 END)`, + revisionRequested: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'REVISION_REQUESTED' THEN 1 ELSE 0 END)`, + waived: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'WAIVED' THEN 1 ELSE 0 END)`, + }) + .from(vendorAttachmentResponses) + .where(whereCondition); + console.log(progress,"progress") + + const stats = progress[0]; + const responseRate = Number(stats.totalRequests) > 0 + ? Math.round((Number(stats.responded) / Number(stats.totalRequests)) * 100) + : 0; + + const completionRate = Number(stats.totalRequests) > 0 + ? Math.round(((Number(stats.responded) + Number(stats.waived)) / Number(stats.totalRequests)) * 100) + : 0; + + return { + totalRequests: Number(stats.totalRequests), + responded: Number(stats.responded), + pending: Number(stats.pending), + revisionRequested: Number(stats.revisionRequested), + waived: Number(stats.waived), + responseRate, + completionRate, + }; + + } catch (err) { + console.error("getVendorResponseProgress 에러:", err); + return { + totalRequests: 0, + responded: 0, + pending: 0, + revisionRequested: 0, + waived: 0, + responseRate: 0, + completionRate: 0, + }; + } +} + + +export async function getRfqAttachmentResponsesWithRevisions(vendorId: string, rfqRecordId: string) { + try { + // 1. 벤더 응답 상세 정보 조회 (뷰 사용) + const responses = await db + .select() + .from(vendorResponseDetailView) + .where( + and( + eq(vendorResponseDetailView.vendorId, Number(vendorId)), + eq(vendorResponseDetailView.rfqRecordId, Number(rfqRecordId)) + ) + ) + .orderBy(asc(vendorResponseDetailView.attachmentId)); + + // 2. RFQ 진행 현황 요약 조회 + const progressSummaryResult = await db + .select() + .from(rfqProgressSummaryView) + .where(eq(rfqProgressSummaryView.rfqId, responses[0]?.rfqId || 0)) + .limit(1); + + const progressSummary = progressSummaryResult[0] || null; + + // 3. 각 응답의 첨부파일 리비전 히스토리 조회 + const attachmentHistories = await Promise.all( + responses.map(async (response) => { + const history = await db + .select() + .from(attachmentRevisionHistoryView) + .where(eq(attachmentRevisionHistoryView.attachmentId, response.attachmentId)) + .orderBy(desc(attachmentRevisionHistoryView.clientRevisionCreatedAt)); + + return { + attachmentId: response.attachmentId, + revisions: history + }; + }) + ); + + // 4. 벤더 응답 파일들 조회 (향상된 정보 포함) + const responseFiles = await Promise.all( + responses.map(async (response) => { + const files = await db + .select() + .from(vendorResponseAttachmentsEnhanced) + .where(eq(vendorResponseAttachmentsEnhanced.vendorResponseId, response.responseId)) + .orderBy(desc(vendorResponseAttachmentsEnhanced.uploadedAt)); + + return { + responseId: response.responseId, + files: files + }; + }) + ); + + // 5. 데이터 변환 및 통합 + const enhancedResponses = responses.map(response => { + const attachmentHistory = attachmentHistories.find(h => h.attachmentId === response.attachmentId); + const responseFileData = responseFiles.find(f => f.responseId === response.responseId); + + return { + ...response, + // 첨부파일 정보에 리비전 히스토리 추가 + attachment: { + id: response.attachmentId, + attachmentType: response.attachmentType, + serialNo: response.serialNo, + description: response.attachmentDescription, + currentRevision: response.currentRevision, + // 모든 리비전 정보 + revisions: attachmentHistory?.revisions?.map(rev => ({ + id: rev.clientRevisionId, + revisionNo: rev.clientRevisionNo, + fileName: rev.clientFileName, + originalFileName: rev.clientFileName, + filePath: rev.clientFilePath, // 파일 경로 추가 + fileSize: rev.clientFileSize, + revisionComment: rev.clientRevisionComment, + createdAt: rev.clientRevisionCreatedAt?.toISOString() || new Date().toISOString(), + isLatest: rev.isLatestClientRevision + })) || [] + }, + // 벤더 응답 파일들 + responseAttachments: responseFileData?.files?.map(file => ({ + id: file.responseAttachmentId, + fileName: file.fileName, + originalFileName: file.originalFileName, + filePath: file.filePath, + fileSize: file.fileSize, + description: file.description, + uploadedAt: file.uploadedAt?.toISOString() || new Date().toISOString(), + isLatestResponseFile: file.isLatestResponseFile, + fileSequence: file.fileSequence + })) || [], + // 리비전 분석 정보 + isVersionMatched: response.isVersionMatched, + versionLag: response.versionLag, + needsUpdate: response.needsUpdate, + hasMultipleRevisions: response.hasMultipleRevisions, + + // 새로 추가된 필드들 + revisionRequestComment: response.revisionRequestComment, + revisionRequestedAt: response.revisionRequestedAt?.toISOString() || null, + }; + }); + + // RFQ 기본 정보 (첫 번째 응답에서 추출) + const rfqInfo = responses[0] ? { + id: responses[0].rfqId, + rfqCode: responses[0].rfqCode, + // 추가 정보는 기존 방식대로 별도 조회 필요 + description: "", + dueDate: progressSummary?.dueDate || new Date(), + status: progressSummary?.rfqStatus || "DRAFT", + // ... 기타 필요한 정보들 + } : null; + + // 벤더 정보 + const vendorInfo = responses[0] ? { + id: responses[0].vendorId, + vendorCode: responses[0].vendorCode, + vendorName: responses[0].vendorName, + country: responses[0].vendorCountry, + } : null; + + // 통계 정보 계산 + const calculateStats = (responses: typeof enhancedResponses) => { + const total = responses.length; + const responded = responses.filter(r => r.responseStatus === "RESPONDED").length; + const pending = responses.filter(r => r.responseStatus === "NOT_RESPONDED").length; + const revisionRequested = responses.filter(r => r.responseStatus === "REVISION_REQUESTED").length; + const waived = responses.filter(r => r.responseStatus === "WAIVED").length; + const versionMismatch = responses.filter(r => r.effectiveStatus === "VERSION_MISMATCH").length; + const upToDate = responses.filter(r => r.effectiveStatus === "UP_TO_DATE").length; + + return { + total, + responded, + pending, + revisionRequested, + waived, + versionMismatch, + upToDate, + responseRate: total > 0 ? Math.round((responded / total) * 100) : 0, + completionRate: total > 0 ? Math.round(((responded + waived) / total) * 100) : 0, + versionMatchRate: responded > 0 ? Math.round((upToDate / responded) * 100) : 100 + }; + }; + + const statistics = calculateStats(enhancedResponses); + + return { + data: enhancedResponses, + rfqInfo, + vendorInfo, + statistics, + progressSummary: progressSummary ? { + totalAttachments: progressSummary.totalAttachments, + attachmentsWithMultipleRevisions: progressSummary.attachmentsWithMultipleRevisions, + totalClientRevisions: progressSummary.totalClientRevisions, + totalResponseFiles: progressSummary.totalResponseFiles, + daysToDeadline: progressSummary.daysToDeadline + } : null + }; + + } catch (err) { + console.error("getRfqAttachmentResponsesWithRevisions 에러:", err); + return { + data: [], + rfqInfo: null, + vendorInfo: null, + statistics: { + total: 0, + responded: 0, + pending: 0, + revisionRequested: 0, + waived: 0, + versionMismatch: 0, + upToDate: 0, + responseRate: 0, + completionRate: 0, + versionMatchRate: 100 + }, + progressSummary: null + }; + } +} + +// 첨부파일 리비전 히스토리 조회 +export async function getAttachmentRevisionHistory(attachmentId: number) { + + try { + const history = await db + .select() + .from(attachmentRevisionHistoryView) + .where(eq(attachmentRevisionHistoryView.attachmentId, attachmentId)) + .orderBy(desc(attachmentRevisionHistoryView.clientRevisionCreatedAt)); + + return history; + } catch (err) { + console.error("getAttachmentRevisionHistory 에러:", err); + return []; + } + } + +// RFQ 전체 진행 현황 조회 +export async function getRfqProgressSummary(rfqId: number) { + try { + const summaryResult = await db + .select() + .from(rfqProgressSummaryView) + .where(eq(rfqProgressSummaryView.rfqId, rfqId)) + .limit(1); + + return summaryResult[0] || null; + } catch (err) { + console.error("getRfqProgressSummary 에러:", err); + return null; + } +} + +// 벤더 응답 파일 상세 조회 (향상된 정보 포함) +export async function getVendorResponseFiles(vendorResponseId: number) { + try { + const files = await db + .select() + .from(vendorResponseAttachmentsEnhanced) + .where(eq(vendorResponseAttachmentsEnhanced.vendorResponseId, vendorResponseId)) + .orderBy(desc(vendorResponseAttachmentsEnhanced.uploadedAt)); + + return files; + } catch (err) { + console.error("getVendorResponseFiles 에러:", err); + return []; + } + } + + +// 타입 정의 확장 +export type EnhancedVendorResponse = { + // 기본 응답 정보 + responseId: number; + rfqId: number; + rfqCode: string; + rfqType: "INITIAL" | "FINAL"; + rfqRecordId: number; + + // 첨부파일 정보 + attachmentId: number; + attachmentType: string; + serialNo: string; + attachmentDescription?: string; + + // 벤더 정보 + vendorId: number; + vendorCode: string; + vendorName: string; + vendorCountry: string; + + // 응답 상태 + responseStatus: "NOT_RESPONDED" | "RESPONDED" | "REVISION_REQUESTED" | "WAIVED"; + currentRevision: string; + respondedRevision?: string; + effectiveStatus: string; + + // 코멘트 관련 필드들 (새로 추가된 필드 포함) + responseComment?: string; // 벤더가 응답할 때 작성하는 코멘트 + vendorComment?: string; // 벤더 내부 메모 + revisionRequestComment?: string; // 발주처가 수정 요청할 때 작성하는 사유 (새로 추가) + + // 날짜 관련 필드들 (새로 추가된 필드 포함) + requestedAt: string; + respondedAt?: string; + revisionRequestedAt?: string; // 수정 요청 날짜 (새로 추가) + + // 발주처 최신 리비전 정보 + latestClientRevisionNo?: string; + latestClientFileName?: string; + latestClientFileSize?: number; + latestClientRevisionComment?: string; + + // 리비전 분석 + isVersionMatched: boolean; + versionLag?: number; + needsUpdate: boolean; + hasMultipleRevisions: boolean; + + // 응답 파일 통계 + totalResponseFiles: number; + latestResponseFileName?: string; + latestResponseFileSize?: number; + latestResponseUploadedAt?: string; + + // 첨부파일 정보 (리비전 히스토리 포함) + attachment: { + id: number; + attachmentType: string; + serialNo: string; + description?: string; + currentRevision: string; + revisions: Array<{ + id: number; + revisionNo: string; + fileName: string; + originalFileName: string; + filePath?: string; + fileSize?: number; + revisionComment?: string; + createdAt: string; + isLatest: boolean; + }>; + }; + + // 벤더 응답 파일들 + responseAttachments: Array<{ + id: number; + fileName: string; + originalFileName: string; + filePath: string; + fileSize?: number; + description?: string; + uploadedAt: string; + isLatestResponseFile: boolean; + fileSequence: number; + }>; +}; + + +export async function requestRevision( + responseId: number, + revisionReason: string +): Promise<RequestRevisionResult> { + try { + // 입력값 검증 + const validatedData = requestRevisionSchema.parse({ + responseId, + revisionReason, + }); + + // 현재 응답 정보 조회 + const existingResponse = await db + .select() + .from(vendorAttachmentResponses) + .where(eq(vendorAttachmentResponses.id, validatedData.responseId)) + .limit(1); + + if (existingResponse.length === 0) { + return { + success: false, + message: "해당 응답을 찾을 수 없습니다", + error: "NOT_FOUND", + }; + } + + const response = existingResponse[0]; + + // 응답 상태 확인 (이미 응답되었거나 포기된 상태에서만 수정 요청 가능) + if (response.responseStatus !== "RESPONDED") { + return { + success: false, + message: "응답된 상태의 항목에서만 수정을 요청할 수 있습니다", + error: "INVALID_STATUS", + }; + } + + // 응답 상태를 REVISION_REQUESTED로 업데이트 + const updateResult = await db + .update(vendorAttachmentResponses) + .set({ + responseStatus: "REVISION_REQUESTED", + revisionRequestComment: validatedData.revisionReason, // 새로운 필드에 저장 + revisionRequestedAt: new Date(), // 수정 요청 시간 저장 + updatedAt: new Date(), + }) + .where(eq(vendorAttachmentResponses.id, validatedData.responseId)) + .returning(); + + if (updateResult.length === 0) { + return { + success: false, + message: "수정 요청 업데이트에 실패했습니다", + error: "UPDATE_FAILED", + }; + } + + return { + success: true, + message: "수정 요청이 성공적으로 전송되었습니다", + }; + + } catch (error) { + console.error("Request revision server action error:", error); + return { + success: false, + message: "내부 서버 오류가 발생했습니다", + error: "INTERNAL_ERROR", + }; + } }
\ No newline at end of file diff --git a/lib/b-rfq/validations.ts b/lib/b-rfq/validations.ts index 15cc9425..f9473656 100644 --- a/lib/b-rfq/validations.ts +++ b/lib/b-rfq/validations.ts @@ -7,6 +7,7 @@ import { createSearchParamsCache, import * as z from "zod" import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { VendorAttachmentResponse } from "@/db/schema"; export const searchParamsRFQDashboardCache = createSearchParamsCache({ // 공통 플래그 @@ -265,3 +266,141 @@ export const searchParamsInitialRfqDetailCache = createSearchParamsCache({ export type GetInitialRfqDetailSchema = Awaited<ReturnType<typeof searchParamsInitialRfqDetailCache.parse>>; + +export const updateInitialRfqSchema = z.object({ + initialRfqStatus: z.enum(["DRAFT", "Init. RFQ Sent", "S/L Decline", "Init. RFQ Answered"]), + dueDate: z.date({ + required_error: "마감일을 선택해주세요.", + }), + validDate: z.date().optional(), + incotermsCode: z.string().max(20, "Incoterms 코드는 20자 이하여야 합니다.").optional(), + classification: z.string().max(255, "분류는 255자 이하여야 합니다.").optional(), + sparepart: z.string().max(255, "예비부품은 255자 이하여야 합니다.").optional(), + shortList: z.boolean().default(false), + returnYn: z.boolean().default(false), + cpRequestYn: z.boolean().default(false), + prjectGtcYn: z.boolean().default(false), + rfqRevision: z.number().int().min(0, "RFQ 리비전은 0 이상이어야 합니다.").default(0), +}) + +export const removeInitialRfqsSchema = z.object({ + ids: z.array(z.number()).min(1, "최소 하나의 항목을 선택해주세요."), +}) + +export type UpdateInitialRfqSchema = z.infer<typeof updateInitialRfqSchema> +export type RemoveInitialRfqsSchema = z.infer<typeof removeInitialRfqsSchema> + +// 벌크 이메일 발송 스키마 +export const bulkEmailSchema = z.object({ + initialRfqIds: z.array(z.number()).min(1, "최소 하나의 초기 RFQ를 선택해주세요."), + language: z.enum(["en", "ko"]).default("en"), +}) + +export type BulkEmailInput = z.infer<typeof bulkEmailSchema> + +// 검색 파라미터 캐시 설정 + +export type ResponseStatus = "NOT_RESPONDED" | "RESPONDED" | "REVISION_REQUESTED" | "WAIVED"; +export type RfqType = "INITIAL" | "FINAL"; + + +export type VendorRfqResponseColumns = { + id: string; + vendorId: number; + rfqRecordId: number; + rfqType: RfqType; + overallStatus: ResponseStatus; + totalAttachments: number; + respondedCount: number; + pendingCount: number; + responseRate: number; + completionRate: number; + requestedAt: Date; + lastRespondedAt: Date | null; +}; + +// 검색 파라미터 캐시 설정 +export const searchParamsVendorResponseCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<VendorRfqResponseColumns>().withDefault([ + { id: "requestedAt", desc: true }, + ]), + + // 고급 필터 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 기본 필터 + basicFilters: getFiltersStateParser().withDefault([]), + basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 검색 및 필터 + search: parseAsString.withDefault(""), + rfqType: parseAsStringEnum(["INITIAL", "FINAL", "ALL"]).withDefault("ALL"), + responseStatus: parseAsStringEnum(["NOT_RESPONDED", "RESPONDED", "REVISION_REQUESTED", "WAIVED", "ALL"]).withDefault("ALL"), + + // 날짜 범위 + from: parseAsString.withDefault(""), + to: parseAsString.withDefault(""), +}); + +export type GetVendorResponsesSchema = Awaited<ReturnType<typeof searchParamsVendorResponseCache.parse>>; + +// vendorId + rfqRecordId로 그룹핑된 응답 요약 타입 +export type VendorRfqResponseSummary = { + id: string; // vendorId + rfqRecordId + rfqType 조합으로 생성된 고유 ID + vendorId: number; + rfqRecordId: number; + rfqType: RfqType; + + // RFQ 정보 + rfq: { + id: number; + rfqCode: string | null; + description: string | null; + status: string; + dueDate: Date; + } | null; + + // 벤더 정보 + vendor: { + id: number; + vendorCode: string; + vendorName: string; + country: string | null; + businessSize: string | null; + } | null; + + // 응답 통계 + totalAttachments: number; + respondedCount: number; + pendingCount: number; + revisionRequestedCount: number; + waivedCount: number; + responseRate: number; + completionRate: number; + overallStatus: ResponseStatus; // 전체적인 상태 + + // 날짜 정보 + requestedAt: Date; + lastRespondedAt: Date | null; + + // 기타 + hasComments: boolean; +}; + + +// 수정 요청 스키마 +export const requestRevisionSchema = z.object({ + responseId: z.number().positive(), + revisionReason: z.string().min(10, "수정 요청 사유를 최소 10자 이상 입력해주세요").max(500), +}); + +// 수정 요청 결과 타입 +export type RequestRevisionResult = { + success: boolean; + message: string; + error?: string; +};
\ No newline at end of file diff --git a/lib/b-rfq/vendor-response/comment-edit-dialog.tsx b/lib/b-rfq/vendor-response/comment-edit-dialog.tsx new file mode 100644 index 00000000..0c2c0c62 --- /dev/null +++ b/lib/b-rfq/vendor-response/comment-edit-dialog.tsx @@ -0,0 +1,187 @@ +// components/rfq/comment-edit-dialog.tsx +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Textarea } from "@/components/ui/textarea"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { MessageSquare, Loader2 } from "lucide-react"; +import { useToast } from "@/hooks/use-toast"; +import { useRouter } from "next/navigation"; + +const commentFormSchema = z.object({ + responseComment: z.string().optional(), + vendorComment: z.string().optional(), +}); + +type CommentFormData = z.infer<typeof commentFormSchema>; + +interface CommentEditDialogProps { + responseId: number; + currentResponseComment?: string; + currentVendorComment?: string; + trigger?: React.ReactNode; + onSuccess?: () => void; +} + +export function CommentEditDialog({ + responseId, + currentResponseComment, + currentVendorComment, + trigger, + onSuccess, +}: CommentEditDialogProps) { + const [open, setOpen] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const { toast } = useToast(); + const router = useRouter(); + + const form = useForm<CommentFormData>({ + resolver: zodResolver(commentFormSchema), + defaultValues: { + responseComment: currentResponseComment || "", + vendorComment: currentVendorComment || "", + }, + }); + + const onSubmit = async (data: CommentFormData) => { + setIsSaving(true); + + try { + const response = await fetch("/api/vendor-responses/update-comment", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + responseId, + responseComment: data.responseComment, + vendorComment: data.vendorComment, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || "코멘트 업데이트 실패"); + } + + toast({ + title: "코멘트 업데이트 완료", + description: "코멘트가 성공적으로 업데이트되었습니다.", + }); + + setOpen(false); + + router.refresh(); + onSuccess?.(); + + } catch (error) { + console.error("Comment update error:", error); + toast({ + title: "업데이트 실패", + description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.", + variant: "destructive", + }); + } finally { + setIsSaving(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild> + {trigger || ( + <Button size="sm" variant="outline"> + <MessageSquare className="h-3 w-3 mr-1" /> + 코멘트 + </Button> + )} + </DialogTrigger> + <DialogContent className="max-w-lg"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <MessageSquare className="h-5 w-5" /> + 코멘트 수정 + </DialogTitle> + </DialogHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> + {/* 응답 코멘트 */} + <FormField + control={form.control} + name="responseComment" + render={({ field }) => ( + <FormItem> + <FormLabel>응답 코멘트</FormLabel> + <FormControl> + <Textarea + placeholder="응답에 대한 설명을 입력하세요..." + className="resize-none" + rows={3} + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 벤더 코멘트 */} + <FormField + control={form.control} + name="vendorComment" + render={({ field }) => ( + <FormItem> + <FormLabel>벤더 코멘트 (내부용)</FormLabel> + <FormControl> + <Textarea + placeholder="내부 참고용 코멘트를 입력하세요..." + className="resize-none" + rows={3} + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 버튼 */} + <div className="flex justify-end gap-2"> + <Button + type="button" + variant="outline" + onClick={() => setOpen(false)} + disabled={isSaving} + > + 취소 + </Button> + <Button type="submit" disabled={isSaving}> + {isSaving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />} + {isSaving ? "저장 중..." : "저장"} + </Button> + </div> + </form> + </Form> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/lib/b-rfq/vendor-response/response-detail-columns.tsx b/lib/b-rfq/vendor-response/response-detail-columns.tsx new file mode 100644 index 00000000..bc27d103 --- /dev/null +++ b/lib/b-rfq/vendor-response/response-detail-columns.tsx @@ -0,0 +1,653 @@ +"use client" + +import * as React from "react" +import { ColumnDef } from "@tanstack/react-table" +import type { Row } from "@tanstack/react-table" +import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header" +import { formatDateTime } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + FileText, + Upload, + CheckCircle, + Clock, + AlertTriangle, + FileX, + Download, + AlertCircle, + RefreshCw, + Calendar, + MessageSquare, + GitBranch, + Ellipsis +} from "lucide-react" +import { cn } from "@/lib/utils" +import type { EnhancedVendorResponse } from "@/lib/b-rfq/service" +import { UploadResponseDialog } from "./upload-response-dialog" +import { CommentEditDialog } from "./comment-edit-dialog" +import { WaiveResponseDialog } from "./waive-response-dialog" +import { ResponseDetailSheet } from "./response-detail-sheet" + +export interface DataTableRowAction<TData> { + row: Row<TData> + type: 'upload' | 'waive' | 'edit' | 'detail' +} + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<EnhancedVendorResponse> | null>> +} + +// 파일 다운로드 핸들러 +async function handleFileDownload( + filePath: string, + fileName: string, + type: "client" | "vendor" = "client", + id?: number +) { + try { + const params = new URLSearchParams({ + path: filePath, + type: type, + }); + + if (id) { + if (type === "client") { + params.append("revisionId", id.toString()); + } else { + params.append("responseFileId", id.toString()); + } + } + + const response = await fetch(`/api/rfq-attachments/download?${params.toString()}`); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || `Download failed: ${response.status}`); + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + + } catch (error) { + console.error("❌ 파일 다운로드 실패:", error); + alert(`파일 다운로드에 실패했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`); + } +} + +// 상태별 정보 반환 +function getEffectiveStatusInfo(effectiveStatus: string) { + switch (effectiveStatus) { + case "NOT_RESPONDED": + return { + icon: Clock, + label: "미응답", + variant: "outline" as const, + color: "text-orange-600" + }; + case "UP_TO_DATE": + return { + icon: CheckCircle, + label: "최신", + variant: "default" as const, + color: "text-green-600" + }; + case "VERSION_MISMATCH": + return { + icon: RefreshCw, + label: "업데이트 필요", + variant: "secondary" as const, + color: "text-blue-600" + }; + case "REVISION_REQUESTED": + return { + icon: AlertTriangle, + label: "수정요청", + variant: "secondary" as const, + color: "text-yellow-600" + }; + case "WAIVED": + return { + icon: FileX, + label: "포기", + variant: "outline" as const, + color: "text-gray-600" + }; + default: + return { + icon: FileText, + label: effectiveStatus, + variant: "outline" as const, + color: "text-gray-600" + }; + } +} + +// 파일명 컴포넌트 +function AttachmentFileNameCell({ revisions }: { + revisions: Array<{ + id: number; + originalFileName: string; + revisionNo: string; + isLatest: boolean; + filePath?: string; + fileSize: number; + createdAt: string; + revisionComment?: string; + }> +}) { + if (!revisions || revisions.length === 0) { + return <span className="text-muted-foreground">파일 없음</span>; + } + + const latestRevision = revisions.find(r => r.isLatest) || revisions[0]; + const hasMultipleRevisions = revisions.length > 1; + const canDownload = latestRevision.filePath; + + return ( + <div className="space-y-1"> + <div className="flex items-center gap-2"> + {canDownload ? ( + <button + onClick={() => handleFileDownload( + latestRevision.filePath!, + latestRevision.originalFileName, + "client", + latestRevision.id + )} + className="font-medium text-sm text-blue-600 hover:text-blue-800 hover:underline text-left max-w-64 truncate" + title={`${latestRevision.originalFileName} - 클릭하여 다운로드`} + > + {latestRevision.originalFileName} + </button> + ) : ( + <span className="font-medium text-sm text-muted-foreground max-w-64 truncate" title={latestRevision.originalFileName}> + {latestRevision.originalFileName} + </span> + )} + + {canDownload && ( + <Button + size="sm" + variant="ghost" + className="h-6 w-6 p-0" + onClick={() => handleFileDownload( + latestRevision.filePath!, + latestRevision.originalFileName, + "client", + latestRevision.id + )} + title="파일 다운로드" + > + <Download className="h-3 w-3" /> + </Button> + )} + + {hasMultipleRevisions && ( + <Badge variant="outline" className="text-xs"> + v{latestRevision.revisionNo} + </Badge> + )} + </div> + + {hasMultipleRevisions && ( + <div className="text-xs text-muted-foreground"> + 총 {revisions.length}개 리비전 + </div> + )} + </div> + ); +} + +// 리비전 비교 컴포넌트 +function RevisionComparisonCell({ response }: { response: EnhancedVendorResponse }) { + const isUpToDate = response.isVersionMatched; + const hasResponse = !!response.respondedRevision; + const versionLag = response.versionLag || 0; + + return ( + <div className="space-y-2"> + <div className="flex items-center gap-2"> + <span className="text-xs text-muted-foreground">발주처:</span> + <Badge variant="secondary" className="text-xs font-mono"> + {response.currentRevision} + </Badge> + </div> + <div className="flex items-center gap-2"> + <span className="text-xs text-muted-foreground">응답:</span> + {hasResponse ? ( + <Badge + variant={isUpToDate ? "default" : "outline"} + className={cn( + "text-xs font-mono", + !isUpToDate && "text-blue-600 border-blue-300" + )} + > + {response.respondedRevision} + </Badge> + ) : ( + <span className="text-xs text-muted-foreground">-</span> + )} + </div> + {hasResponse && !isUpToDate && versionLag > 0 && ( + <div className="flex items-center gap-1 text-xs text-blue-600"> + <AlertCircle className="h-3 w-3" /> + <span>{versionLag}버전 차이</span> + </div> + )} + {response.hasMultipleRevisions && ( + <div className="flex items-center gap-1 text-xs text-muted-foreground"> + <GitBranch className="h-3 w-3" /> + <span>다중 리비전</span> + </div> + )} + </div> + ); +} + +// 코멘트 표시 컴포넌트 +function CommentDisplayCell({ response }: { response: EnhancedVendorResponse }) { + const hasResponseComment = !!response.responseComment; + const hasVendorComment = !!response.vendorComment; + const hasRevisionRequestComment = !!response.revisionRequestComment; + const hasClientComment = !!response.attachment?.revisions?.find(r => r.revisionComment); + + const commentCount = [hasResponseComment, hasVendorComment, hasRevisionRequestComment, hasClientComment].filter(Boolean).length; + + if (commentCount === 0) { + return <span className="text-xs text-muted-foreground">-</span>; + } + + return ( + <div className="space-y-1"> + {hasResponseComment && ( + <div className="flex items-center gap-1"> + <div className="w-2 h-2 rounded-full bg-blue-500" title="벤더 응답 코멘트"></div> + <span className="text-xs text-blue-600 truncate max-w-32" title={response.responseComment}> + {response.responseComment} + </span> + </div> + )} + + {hasVendorComment && ( + <div className="flex items-center gap-1"> + <div className="w-2 h-2 rounded-full bg-green-500" title="벤더 내부 메모"></div> + <span className="text-xs text-green-600 truncate max-w-32" title={response.vendorComment}> + {response.vendorComment} + </span> + </div> + )} + + {hasRevisionRequestComment && ( + <div className="flex items-center gap-1"> + <div className="w-2 h-2 rounded-full bg-red-500" title="수정 요청 사유"></div> + <span className="text-xs text-red-600 truncate max-w-32" title={response.revisionRequestComment}> + {response.revisionRequestComment} + </span> + </div> + )} + + {hasClientComment && ( + <div className="flex items-center gap-1"> + <div className="w-2 h-2 rounded-full bg-orange-500" title="발주처 리비전 코멘트"></div> + <span className="text-xs text-orange-600 truncate max-w-32" + title={response.attachment?.revisions?.find(r => r.revisionComment)?.revisionComment}> + {response.attachment?.revisions?.find(r => r.revisionComment)?.revisionComment} + </span> + </div> + )} + + {/* <div className="text-xs text-muted-foreground text-center"> + {commentCount}개 + </div> */} + </div> + ); +} + +export function getColumns({ + setRowAction, +}: GetColumnsProps): ColumnDef<EnhancedVendorResponse>[] { + return [ + // 시리얼 번호 - 핀고정용 최소 너비 + { + accessorKey: "serialNo", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="시리얼" /> + ), + cell: ({ row }) => ( + <div className="font-mono text-sm">{row.getValue("serialNo")}</div> + ), + + meta: { + excelHeader: "시리얼", + paddingFactor: 0.8 + }, + }, + + // 분류 - 핀고정용 적절한 너비 + { + accessorKey: "attachmentType", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="분류" /> + ), + cell: ({ row }) => ( + <div className="space-y-1"> + <div className="font-medium text-sm">{row.getValue("attachmentType")}</div> + {row.original.attachmentDescription && ( + <div className="text-xs text-muted-foreground truncate max-w-32" + title={row.original.attachmentDescription}> + {row.original.attachmentDescription} + </div> + )} + </div> + ), + + meta: { + excelHeader: "분류", + paddingFactor: 1.0 + }, + }, + + // 파일명 - 가장 긴 텍스트를 위한 여유 공간 + { + id: "fileName", + header: "파일명", + cell: ({ row }) => ( + <AttachmentFileNameCell revisions={row.original.attachment?.revisions || []} /> + ), + + meta: { + paddingFactor: 1.5 + }, + }, + + // 상태 - 뱃지 크기 고려 + { + accessorKey: "effectiveStatus", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="상태" /> + ), + cell: ({ row }) => { + const statusInfo = getEffectiveStatusInfo(row.getValue("effectiveStatus")); + const StatusIcon = statusInfo.icon; + + return ( + <div className="space-y-1"> + <Badge variant={statusInfo.variant} className="flex items-center gap-1 w-fit"> + <StatusIcon className="h-3 w-3" /> + <span>{statusInfo.label}</span> + </Badge> + {row.original.needsUpdate && ( + <div className="text-xs text-blue-600 flex items-center gap-1"> + <RefreshCw className="h-3 w-3" /> + <span>업데이트 권장</span> + </div> + )} + </div> + ); + }, + + meta: { + excelHeader: "상태", + paddingFactor: 1.2 + }, + }, + + // 리비전 현황 - 복합 정보로 넓은 공간 필요 + { + id: "revisionStatus", + header: "리비전 현황", + cell: ({ row }) => <RevisionComparisonCell response={row.original} />, + + meta: { + paddingFactor: 1.3 + }, + }, + + // 요청일 - 날짜 형식 고정 + { + accessorKey: "requestedAt", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="요청일" /> + ), + cell: ({ row }) => ( + <div className="text-sm flex items-center gap-1"> + <Calendar className="h-3 w-3 text-muted-foreground" /> + <span className="whitespace-nowrap">{formatDateTime(new Date(row.getValue("requestedAt")))}</span> + </div> + ), + + meta: { + excelHeader: "요청일", + paddingFactor: 0.9 + }, + }, + + // 응답일 - 날짜 형식 고정 + { + accessorKey: "respondedAt", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="응답일" /> + ), + cell: ({ row }) => ( + <div className="text-sm"> + <span className="whitespace-nowrap"> + {row.getValue("respondedAt") + ? formatDateTime(new Date(row.getValue("respondedAt"))) + : "-" + } + </span> + </div> + ), + meta: { + excelHeader: "응답일", + paddingFactor: 0.9 + }, + }, + + // 응답파일 - 작은 공간 + { + accessorKey: "totalResponseFiles", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="응답파일" /> + ), + cell: ({ row }) => ( + <div className="text-center"> + <div className="text-sm font-medium"> + {row.getValue("totalResponseFiles")}개 + </div> + {row.original.latestResponseFileName && ( + <div className="text-xs text-muted-foreground truncate max-w-20" + title={row.original.latestResponseFileName}> + {row.original.latestResponseFileName} + </div> + )} + </div> + ), + meta: { + excelHeader: "응답파일", + paddingFactor: 0.8 + }, + }, + + // 코멘트 - 가변 텍스트 길이 + { + id: "comments", + header: "코멘트", + cell: ({ row }) => <CommentDisplayCell response={row.original} />, + // size: 180, + meta: { + paddingFactor: 1.4 + }, + }, + + // 진행도 - 중간 크기 + { + id: "progress", + header: "진행도", + cell: ({ row }) => ( + <div className="space-y-1 text-center"> + {row.original.hasMultipleRevisions && ( + <Badge variant="outline" className="text-xs"> + 다중 리비전 + </Badge> + )} + {row.original.versionLag !== undefined && row.original.versionLag > 0 && ( + <div className="text-xs text-blue-600 whitespace-nowrap"> + {row.original.versionLag}버전 차이 + </div> + )} + </div> + ), + // size: 100, + meta: { + paddingFactor: 1.1 + }, + }, + +{ + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const response = row.original; + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-56"> + {/* 상태별 주요 액션들 */} + {response.effectiveStatus === "NOT_RESPONDED" && ( + <> + <DropdownMenuItem asChild> + <UploadResponseDialog + responseId={response.responseId} + attachmentType={response.attachmentType} + serialNo={response.serialNo} + currentRevision={response.currentRevision} + trigger={ + <div className="flex items-center w-full cursor-pointer p-2"> + <Upload className="size-4 mr-2" /> + 업로드 + </div> + } + /> + </DropdownMenuItem> + <DropdownMenuItem asChild> + <WaiveResponseDialog + responseId={response.responseId} + attachmentType={response.attachmentType} + serialNo={response.serialNo} + trigger={ + <div className="flex items-center w-full cursor-pointer p-2"> + <FileX className="size-4 mr-2" /> + 포기 + </div> + } + /> + </DropdownMenuItem> + </> + )} + + {response.effectiveStatus === "REVISION_REQUESTED" && ( + <DropdownMenuItem asChild> + <UploadResponseDialog + responseId={response.responseId} + attachmentType={response.attachmentType} + serialNo={response.serialNo} + currentRevision={response.currentRevision} + trigger={ + <div className="flex items-center w-full cursor-pointer p-2"> + <FileText className="size-4 mr-2" /> + 수정 + </div> + } + /> + </DropdownMenuItem> + )} + + {response.effectiveStatus === "VERSION_MISMATCH" && ( + <DropdownMenuItem asChild> + <UploadResponseDialog + responseId={response.responseId} + attachmentType={response.attachmentType} + serialNo={response.serialNo} + currentRevision={response.currentRevision} + trigger={ + <div className="flex items-center w-full cursor-pointer p-2"> + <RefreshCw className="size-4 mr-2" /> + 업데이트 + </div> + } + /> + </DropdownMenuItem> + )} + + {/* 구분선 - 주요 액션과 보조 액션 분리 */} + {(response.effectiveStatus === "NOT_RESPONDED" || + response.effectiveStatus === "REVISION_REQUESTED" || + response.effectiveStatus === "VERSION_MISMATCH") && + response.effectiveStatus !== "WAIVED" && ( + <DropdownMenuSeparator /> + )} + + {/* 공통 액션들 */} + {response.effectiveStatus !== "WAIVED" && ( + <DropdownMenuItem asChild> + <CommentEditDialog + responseId={response.responseId} + currentResponseComment={response.responseComment || ""} + currentVendorComment={response.vendorComment || ""} + trigger={ + <div className="flex items-center w-full cursor-pointer p-2"> + <MessageSquare className="size-4 mr-2" /> + 코멘트 편집 + </div> + } + /> + </DropdownMenuItem> + )} + + <DropdownMenuItem asChild> + <ResponseDetailSheet + response={response} + trigger={ + <div className="flex items-center w-full cursor-pointer p-2"> + <FileText className="size-4 mr-2" /> + 상세보기 + </div> + } + /> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + + ) + }, + size: 40, +} + + ] +}
\ No newline at end of file diff --git a/lib/b-rfq/vendor-response/response-detail-sheet.tsx b/lib/b-rfq/vendor-response/response-detail-sheet.tsx new file mode 100644 index 00000000..da7f9b01 --- /dev/null +++ b/lib/b-rfq/vendor-response/response-detail-sheet.tsx @@ -0,0 +1,358 @@ +// components/rfq/response-detail-sheet.tsx +"use client"; + +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; +import { + FileText, + Upload, + Download, + AlertCircle, + MessageSquare, + FileCheck, + Eye +} from "lucide-react"; +import { formatDateTime, formatFileSize } from "@/lib/utils"; +import { cn } from "@/lib/utils"; +import type { EnhancedVendorResponse } from "@/lib/b-rfq/service"; + +// 파일 다운로드 핸들러 (API 사용) +async function handleFileDownload( + filePath: string, + fileName: string, + type: "client" | "vendor" = "client", + id?: number +) { + try { + const params = new URLSearchParams({ + path: filePath, + type: type, + }); + + // ID가 있으면 추가 + if (id) { + if (type === "client") { + params.append("revisionId", id.toString()); + } else { + params.append("responseFileId", id.toString()); + } + } + + const response = await fetch(`/api/rfq-attachments/download?${params.toString()}`); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || `Download failed: ${response.status}`); + } + + // Blob으로 파일 데이터 받기 + const blob = await response.blob(); + + // 임시 URL 생성하여 다운로드 + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + + // 정리 + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + + console.log("✅ 파일 다운로드 성공:", fileName); + + } catch (error) { + console.error("❌ 파일 다운로드 실패:", error); + + // 사용자에게 에러 알림 (토스트나 알럿으로 대체 가능) + alert(`파일 다운로드에 실패했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`); + } +} + +// 효과적인 상태별 아이콘 및 색상 +function getEffectiveStatusInfo(effectiveStatus: string) { + switch (effectiveStatus) { + case "NOT_RESPONDED": + return { + label: "미응답", + variant: "outline" as const + }; + case "UP_TO_DATE": + return { + label: "최신", + variant: "default" as const + }; + case "VERSION_MISMATCH": + return { + label: "업데이트 필요", + variant: "secondary" as const + }; + case "REVISION_REQUESTED": + return { + label: "수정요청", + variant: "secondary" as const + }; + case "WAIVED": + return { + label: "포기", + variant: "outline" as const + }; + default: + return { + label: effectiveStatus, + variant: "outline" as const + }; + } +} + +interface ResponseDetailSheetProps { + response: EnhancedVendorResponse; + trigger?: React.ReactNode; +} + +export function ResponseDetailSheet({ response, trigger }: ResponseDetailSheetProps) { + const hasMultipleRevisions = response.attachment?.revisions && response.attachment.revisions.length > 1; + const hasResponseFiles = response.responseAttachments && response.responseAttachments.length > 0; + + return ( + <Sheet> + <SheetTrigger asChild> + {trigger || ( + <Button size="sm" variant="ghost"> + <Eye className="h-3 w-3 mr-1" /> + 상세 + </Button> + )} + </SheetTrigger> + <SheetContent side="right" className="w-[600px] sm:w-[800px] overflow-y-auto"> + <SheetHeader> + <SheetTitle className="flex items-center gap-2"> + <FileText className="h-5 w-5" /> + 상세 정보 - {response.serialNo} + </SheetTitle> + <SheetDescription> + {response.attachmentType} • {response.attachment?.revisions?.[0]?.originalFileName} + </SheetDescription> + </SheetHeader> + + <div className="space-y-6 mt-6"> + {/* 기본 정보 */} + <div className="space-y-4"> + <h3 className="text-lg font-semibold flex items-center gap-2"> + <AlertCircle className="h-4 w-4" /> + 기본 정보 + </h3> + <div className="grid grid-cols-2 gap-4 p-4 bg-muted/30 rounded-lg"> + <div> + <div className="text-sm text-muted-foreground">상태</div> + <div className="font-medium">{getEffectiveStatusInfo(response.effectiveStatus).label}</div> + </div> + <div> + <div className="text-sm text-muted-foreground">현재 리비전</div> + <div className="font-medium">{response.currentRevision}</div> + </div> + <div> + <div className="text-sm text-muted-foreground">응답 리비전</div> + <div className="font-medium">{response.respondedRevision || "-"}</div> + </div> + <div> + <div className="text-sm text-muted-foreground">응답일</div> + <div className="font-medium"> + {response.respondedAt ? formatDateTime(new Date(response.respondedAt)) : "-"} + </div> + </div> + <div> + <div className="text-sm text-muted-foreground">요청일</div> + <div className="font-medium"> + {formatDateTime(new Date(response.requestedAt))} + </div> + </div> + <div> + <div className="text-sm text-muted-foreground">응답 파일 수</div> + <div className="font-medium">{response.totalResponseFiles}개</div> + </div> + </div> + </div> + + {/* 코멘트 정보 */} + <div className="space-y-4"> + <h3 className="text-lg font-semibold flex items-center gap-2"> + <MessageSquare className="h-4 w-4" /> + 코멘트 + </h3> + <div className="space-y-3"> + {response.responseComment && ( + <div className="p-3 border-l-4 border-blue-500 bg-blue-50"> + <div className="text-sm font-medium text-blue-700 mb-1">발주처 응답 코멘트</div> + <div className="text-sm">{response.responseComment}</div> + </div> + )} + {response.vendorComment && ( + <div className="p-3 border-l-4 border-green-500 bg-green-50"> + <div className="text-sm font-medium text-green-700 mb-1">내부 메모</div> + <div className="text-sm">{response.vendorComment}</div> + </div> + )} + {response.attachment?.revisions?.find(r => r.revisionComment) && ( + <div className="p-3 border-l-4 border-orange-500 bg-orange-50"> + <div className="text-sm font-medium text-orange-700 mb-1">발주처 요청 사항</div> + <div className="text-sm"> + {response.attachment.revisions.find(r => r.revisionComment)?.revisionComment} + </div> + </div> + )} + {!response.responseComment && !response.vendorComment && !response.attachment?.revisions?.find(r => r.revisionComment) && ( + <div className="text-center text-muted-foreground py-4 bg-muted/20 rounded-lg"> + 코멘트가 없습니다. + </div> + )} + </div> + </div> + + {/* 발주처 리비전 히스토리 */} + {hasMultipleRevisions && ( + <div className="space-y-4"> + <h3 className="text-lg font-semibold flex items-center gap-2"> + <FileCheck className="h-4 w-4" /> + 발주처 리비전 히스토리 ({response.attachment!.revisions.length}개) + </h3> + <div className="space-y-3"> + {response.attachment!.revisions + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + .map((revision) => ( + <div + key={revision.id} + className={cn( + "flex items-center justify-between p-4 rounded-lg border", + revision.isLatest ? "bg-blue-50 border-blue-200" : "bg-white" + )} + > + <div className="flex items-center gap-3 flex-1"> + <Badge variant={revision.isLatest ? "default" : "outline"}> + {revision.revisionNo} + </Badge> + <div className="flex-1"> + <div className="font-medium text-sm">{revision.originalFileName}</div> + <div className="text-xs text-muted-foreground"> + {formatFileSize(revision.fileSize)} • {formatDateTime(new Date(revision.createdAt))} + </div> + {revision.revisionComment && ( + <div className="text-xs text-muted-foreground mt-1 italic"> + "{revision.revisionComment}" + </div> + )} + </div> + </div> + + <div className="flex items-center gap-2"> + {revision.isLatest && ( + <Badge variant="secondary" className="text-xs">최신</Badge> + )} + {revision.revisionNo === response.respondedRevision && ( + <Badge variant="outline" className="text-xs text-green-600 border-green-300"> + 응답됨 + </Badge> + )} + <Button + size="sm" + variant="ghost" + onClick={() => { + if (revision.filePath) { + handleFileDownload( + revision.filePath, + revision.originalFileName, + "client", + revision.id + ); + } + }} + disabled={!revision.filePath} + title="파일 다운로드" + > + <Download className="h-4 w-4" /> + </Button> + </div> + </div> + ))} + </div> + </div> + )} + + {/* 벤더 응답 파일들 */} + {hasResponseFiles && ( + <div className="space-y-4"> + <h3 className="text-lg font-semibold flex items-center gap-2"> + <Upload className="h-4 w-4" /> + 벤더 응답 파일들 ({response.totalResponseFiles}개) + </h3> + <div className="space-y-3"> + {response.responseAttachments! + .sort((a, b) => new Date(b.uploadedAt).getTime() - new Date(a.uploadedAt).getTime()) + .map((file) => ( + <div key={file.id} className="flex items-center justify-between p-4 rounded-lg border bg-green-50 border-green-200"> + <div className="flex items-center gap-3 flex-1"> + <Badge variant="outline" className="bg-green-100"> + 파일 #{file.fileSequence} + </Badge> + <div className="flex-1"> + <div className="font-medium text-sm">{file.originalFileName}</div> + <div className="text-xs text-muted-foreground"> + {formatFileSize(file.fileSize)} • {formatDateTime(new Date(file.uploadedAt))} + </div> + {file.description && ( + <div className="text-xs text-muted-foreground mt-1 italic"> + "{file.description}" + </div> + )} + </div> + </div> + + <div className="flex items-center gap-2"> + {file.isLatestResponseFile && ( + <Badge variant="secondary" className="text-xs">최신</Badge> + )} + <Button + size="sm" + variant="ghost" + onClick={() => { + if (file.filePath) { + handleFileDownload( + file.filePath, + file.originalFileName, + "vendor", + file.id + ); + } + }} + disabled={!file.filePath} + title="파일 다운로드" + > + <Download className="h-4 w-4" /> + </Button> + </div> + </div> + ))} + </div> + </div> + )} + + {!hasMultipleRevisions && !hasResponseFiles && ( + <div className="text-center text-muted-foreground py-8 bg-muted/20 rounded-lg"> + <FileText className="h-8 w-8 mx-auto mb-2 opacity-50" /> + <p>추가 파일이나 리비전 정보가 없습니다.</p> + </div> + )} + </div> + </SheetContent> + </Sheet> + ); +}
\ No newline at end of file diff --git a/lib/b-rfq/vendor-response/response-detail-table.tsx b/lib/b-rfq/vendor-response/response-detail-table.tsx new file mode 100644 index 00000000..124d5241 --- /dev/null +++ b/lib/b-rfq/vendor-response/response-detail-table.tsx @@ -0,0 +1,161 @@ +"use client" + +import * as React from "react" +import { ClientDataTable } from "@/components/client-data-table/data-table" +import type { EnhancedVendorResponse } from "@/lib/b-rfq/service" +import { DataTableAdvancedFilterField } from "@/types/table" +import { DataTableRowAction, getColumns } from "./response-detail-columns" + +interface FinalRfqResponseTableProps { + data: EnhancedVendorResponse[] + // ✅ 헤더 정보를 props로 받기 + statistics?: { + total: number + upToDate: number + versionMismatch: number + pending: number + revisionRequested: number + waived: number + } + showHeader?: boolean + title?: string +} + +/** + * FinalRfqResponseTable: RFQ 응답 데이터를 표시하는 표 + */ +export function FinalRfqResponseTable({ + data, + statistics, + showHeader = true, + title = "첨부파일별 응답 현황" +}: FinalRfqResponseTableProps) { + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<EnhancedVendorResponse> | null>(null) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + // 고급 필터 필드 정의 + const advancedFilterFields: DataTableAdvancedFilterField<EnhancedVendorResponse>[] = [ + { + id: "effectiveStatus", + label: "상태", + type: "select", + options: [ + { label: "미응답", value: "NOT_RESPONDED" }, + { label: "최신", value: "UP_TO_DATE" }, + { label: "업데이트 필요", value: "VERSION_MISMATCH" }, + { label: "수정요청", value: "REVISION_REQUESTED" }, + { label: "포기", value: "WAIVED" }, + ], + }, + { + id: "attachmentType", + label: "첨부파일 분류", + type: "text", + }, + { + id: "serialNo", + label: "시리얼 번호", + type: "text", + }, + { + id: "isVersionMatched", + label: "버전 일치", + type: "select", + options: [ + { label: "일치", value: "true" }, + { label: "불일치", value: "false" }, + ], + }, + { + id: "hasMultipleRevisions", + label: "다중 리비전", + type: "select", + options: [ + { label: "있음", value: "true" }, + { label: "없음", value: "false" }, + ], + }, + ] + + if (data.length === 0) { + return ( + <div className="border rounded-lg p-12 text-center"> + <div className="mx-auto mb-4 h-12 w-12 text-muted-foreground"> + 📄 + </div> + <p className="text-muted-foreground">응답할 첨부파일이 없습니다.</p> + </div> + ) + } + + return ( + // ✅ 상위 컨테이너 구조 단순화 및 너비 제한 해제 +<> + {/* 코멘트 범례 */} + <div className="flex items-center gap-6 text-xs text-muted-foreground bg-muted/30 p-3 rounded-lg"> + <span className="font-medium">코멘트 범례:</span> + <div className="flex items-center gap-1"> + <div className="w-2 h-2 rounded-full bg-blue-500"></div> + <span>벤더 응답</span> + </div> + <div className="flex items-center gap-1"> + <div className="w-2 h-2 rounded-full bg-green-500"></div> + <span>내부 메모</span> + </div> + <div className="flex items-center gap-1"> + <div className="w-2 h-2 rounded-full bg-red-500"></div> + <span>수정 요청</span> + </div> + <div className="flex items-center gap-1"> + <div className="w-2 h-2 rounded-full bg-orange-500"></div> + <span>발주처 리비전</span> + </div> + </div> + <div style={{ + width: '100%', + maxWidth: '100%', + overflow: 'hidden', + contain: 'layout' + }}> + {/* 데이터 테이블 - 컨테이너 제약 최소화 */} + <ClientDataTable + data={data} + columns={columns} + advancedFilterFields={advancedFilterFields} + autoSizeColumns={true} + compact={true} + // ✅ RFQ 테이블에 맞는 컬럼 핀고정 + initialColumnPinning={{ + left: ["serialNo", "attachmentType"], + right: ["actions"] + }} + > + {showHeader && ( + <div className="flex items-center justify-between"> + + {statistics && ( + <div className="flex items-center gap-4 text-sm text-muted-foreground"> + <span>전체 {statistics.total}개</span> + <span className="text-green-600">최신 {statistics.upToDate}개</span> + <span className="text-blue-600">업데이트필요 {statistics.versionMismatch}개</span> + <span className="text-orange-600">미응답 {statistics.pending}개</span> + {statistics.revisionRequested > 0 && ( + <span className="text-yellow-600">수정요청 {statistics.revisionRequested}개</span> + )} + {statistics.waived > 0 && ( + <span className="text-gray-600">포기 {statistics.waived}개</span> + )} + </div> + )} + </div> + )} + </ClientDataTable> + </div> + </> + ) +} diff --git a/lib/b-rfq/vendor-response/upload-response-dialog.tsx b/lib/b-rfq/vendor-response/upload-response-dialog.tsx new file mode 100644 index 00000000..b4b306d6 --- /dev/null +++ b/lib/b-rfq/vendor-response/upload-response-dialog.tsx @@ -0,0 +1,325 @@ +// components/rfq/upload-response-dialog.tsx +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { Upload, FileText, X, Loader2 } from "lucide-react"; +import { useToast } from "@/hooks/use-toast" +import { useRouter } from "next/navigation"; + +const uploadFormSchema = z.object({ + files: z.array(z.instanceof(File)).min(1, "최소 1개의 파일을 선택해주세요"), + responseComment: z.string().optional(), + vendorComment: z.string().optional(), +}); + +type UploadFormData = z.infer<typeof uploadFormSchema>; + +interface UploadResponseDialogProps { + responseId: number; + attachmentType: string; + serialNo: string; + currentRevision: string; + trigger?: React.ReactNode; + onSuccess?: () => void; +} + +export function UploadResponseDialog({ + responseId, + attachmentType, + serialNo, + currentRevision, + trigger, + onSuccess, +}: UploadResponseDialogProps) { + const [open, setOpen] = useState(false); + const [isUploading, setIsUploading] = useState(false); + const { toast } = useToast(); + const router = useRouter(); + + const form = useForm<UploadFormData>({ + resolver: zodResolver(uploadFormSchema), + defaultValues: { + files: [], + responseComment: "", + vendorComment: "", + }, + }); + + const selectedFiles = form.watch("files"); + + const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { + const files = Array.from(e.target.files || []); + if (files.length > 0) { + form.setValue("files", files); + } + }; + + const removeFile = (index: number) => { + const currentFiles = form.getValues("files"); + const newFiles = currentFiles.filter((_, i) => i !== index); + form.setValue("files", newFiles); + }; + + const formatFileSize = (bytes: number): string => { + if (bytes === 0) return "0 Bytes"; + const k = 1024; + const sizes = ["Bytes", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; + }; + + const handleOpenChange = (newOpen: boolean) => { + setOpen(newOpen); + // 다이얼로그가 닫힐 때 form 리셋 + if (!newOpen) { + form.reset(); + } + }; + + const handleCancel = () => { + form.reset(); + setOpen(false); + }; + + const onSubmit = async (data: UploadFormData) => { + setIsUploading(true); + + try { + // 1. 각 파일을 업로드 API로 전송 + const uploadedFiles = []; + + for (const file of data.files) { + const formData = new FormData(); + formData.append("file", file); + formData.append("responseId", responseId.toString()); + formData.append("description", ""); // 필요시 파일별 설명 추가 가능 + + const uploadResponse = await fetch("/api/vendor-responses/upload", { + method: "POST", + body: formData, + }); + + if (!uploadResponse.ok) { + const error = await uploadResponse.json(); + throw new Error(error.message || "파일 업로드 실패"); + } + + const uploadResult = await uploadResponse.json(); + uploadedFiles.push(uploadResult); + } + + // 2. vendor response 상태 업데이트 (서버에서 자동으로 리비전 증가) + const updateResponse = await fetch("/api/vendor-responses/update", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + responseId, + responseStatus: "RESPONDED", + // respondedRevision 제거 - 서버에서 자동 처리 + responseComment: data.responseComment, + vendorComment: data.vendorComment, + respondedAt: new Date().toISOString(), + }), + }); + + if (!updateResponse.ok) { + const error = await updateResponse.json(); + throw new Error(error.message || "응답 상태 업데이트 실패"); + } + + const updateResult = await updateResponse.json(); + + toast({ + title: "업로드 완료", + description: `${data.files.length}개 파일이 성공적으로 업로드되었습니다. (${updateResult.newRevision})`, + }); + + setOpen(false); + form.reset(); + + router.refresh(); + onSuccess?.(); + + } catch (error) { + console.error("Upload error:", error); + toast({ + title: "업로드 실패", + description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.", + variant: "destructive", + }); + } finally { + setIsUploading(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogTrigger asChild> + {trigger || ( + <Button size="sm"> + <Upload className="h-3 w-3 mr-1" /> + 업로드 + </Button> + )} + </DialogTrigger> + <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <Upload className="h-5 w-5" /> + 응답 파일 업로드 + </DialogTitle> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Badge variant="outline">{serialNo}</Badge> + <span>{attachmentType}</span> + <Badge variant="secondary">{currentRevision}</Badge> + <span className="text-xs text-blue-600">→ 벤더 응답 리비전 자동 증가</span> + </div> + </DialogHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> + {/* 파일 선택 */} + <FormField + control={form.control} + name="files" + render={({ field }) => ( + <FormItem> + <FormLabel>파일 선택</FormLabel> + <FormControl> + <div className="space-y-4"> + <Input + type="file" + multiple + onChange={handleFileSelect} + accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.zip,.rar" + className="cursor-pointer" + /> + <div className="text-xs text-muted-foreground"> + 지원 파일: PDF, DOC, DOCX, XLS, XLSX, PNG, JPG, ZIP, RAR (최대 10MB) + </div> + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 선택된 파일 목록 */} + {selectedFiles.length > 0 && ( + <div className="space-y-2"> + <div className="text-sm font-medium">선택된 파일 ({selectedFiles.length}개)</div> + <div className="space-y-2 max-h-40 overflow-y-auto"> + {selectedFiles.map((file, index) => ( + <div + key={index} + className="flex items-center justify-between p-3 bg-muted/50 rounded-lg" + > + <div className="flex items-center gap-2 flex-1 min-w-0"> + <FileText className="h-4 w-4 text-muted-foreground flex-shrink-0" /> + <div className="min-w-0 flex-1"> + <div className="text-sm font-medium truncate">{file.name}</div> + <div className="text-xs text-muted-foreground"> + {formatFileSize(file.size)} + </div> + </div> + </div> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => removeFile(index)} + className="flex-shrink-0 ml-2" + > + <X className="h-4 w-4" /> + </Button> + </div> + ))} + </div> + </div> + )} + + {/* 응답 코멘트 */} + <FormField + control={form.control} + name="responseComment" + render={({ field }) => ( + <FormItem> + <FormLabel>응답 코멘트</FormLabel> + <FormControl> + <Textarea + placeholder="응답에 대한 설명을 입력하세요..." + className="resize-none" + rows={3} + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 벤더 코멘트 */} + <FormField + control={form.control} + name="vendorComment" + render={({ field }) => ( + <FormItem> + <FormLabel>벤더 코멘트 (내부용)</FormLabel> + <FormControl> + <Textarea + placeholder="내부 참고용 코멘트를 입력하세요..." + className="resize-none" + rows={2} + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 버튼 */} + <div className="flex justify-end gap-2"> + <Button + type="button" + variant="outline" + onClick={handleCancel} + disabled={isUploading} + > + 취소 + </Button> + <Button type="submit" disabled={isUploading || selectedFiles.length === 0}> + {isUploading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />} + {isUploading ? "업로드 중..." : "업로드"} + </Button> + </div> + </form> + </Form> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/lib/b-rfq/vendor-response/vendor-responses-table-columns.tsx b/lib/b-rfq/vendor-response/vendor-responses-table-columns.tsx new file mode 100644 index 00000000..47b7570b --- /dev/null +++ b/lib/b-rfq/vendor-response/vendor-responses-table-columns.tsx @@ -0,0 +1,351 @@ +// lib/vendor-responses/table/vendor-responses-table-columns.tsx +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { + Ellipsis, FileText, Pencil, Edit, Trash2, + Eye, MessageSquare, Clock, CheckCircle, AlertTriangle, FileX +} from "lucide-react" +import { formatDate, formatDateTime } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import Link from "next/link" +import { useRouter } from "next/navigation" +import { VendorResponseDetail } from "../service" +import { VendorRfqResponseSummary } from "../validations" + +// 응답 상태에 따른 배지 컴포넌트 +function ResponseStatusBadge({ status }: { status: string }) { + switch (status) { + case "NOT_RESPONDED": + return ( + <Badge variant="outline" className="text-orange-600 border-orange-600"> + <Clock className="mr-1 h-3 w-3" /> + 미응답 + </Badge> + ) + case "RESPONDED": + return ( + <Badge variant="default" className="bg-green-600 text-white"> + <CheckCircle className="mr-1 h-3 w-3" /> + 응답완료 + </Badge> + ) + case "REVISION_REQUESTED": + return ( + <Badge variant="secondary" className="text-yellow-600 border-yellow-600"> + <AlertTriangle className="mr-1 h-3 w-3" /> + 수정요청 + </Badge> + ) + case "WAIVED": + return ( + <Badge variant="outline" className="text-gray-600 border-gray-600"> + <FileX className="mr-1 h-3 w-3" /> + 포기 + </Badge> + ) + default: + return <Badge>{status}</Badge> + } +} + + +type NextRouter = ReturnType<typeof useRouter>; + +interface GetColumnsProps { + router: NextRouter +} + +/** + * tanstack table 컬럼 정의 + */ +export function getColumns({ + router, +}: GetColumnsProps): ColumnDef<VendorResponseDetail>[] { + + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<VendorRfqResponseSummary> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) actions 컬럼 (작성하기 버튼만) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef<VendorRfqResponseSummary> = { + id: "actions", + enableHiding: false, + cell: ({ row }) => { + const vendorId = row.original.vendorId + const rfqRecordId = row.original.rfqRecordId + const rfqType = row.original.rfqType + const rfqCode = row.original.rfq?.rfqCode || "RFQ" + + return ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="sm" + onClick={() => router.push(`/partners/rfq-answer/${vendorId}/${rfqRecordId}`)} + className="h-8 px-3" + > + <Edit className="h-4 w-4 mr-1" /> + 작성하기 + </Button> + </TooltipTrigger> + <TooltipContent> + <p>{rfqCode} 응답 작성하기</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + ) + }, + size: 100, + minSize: 100, + maxSize: 150, + } + + // ---------------------------------------------------------------- + // 3) 컬럼 정의 배열 + // ---------------------------------------------------------------- + const columnDefinitions = [ + { + id: "rfqCode", + label: "RFQ 번호", + group: "RFQ 정보", + size: 120, + minSize: 100, + maxSize: 150, + }, + + { + id: "rfqDueDate", + label: "RFQ 마감일", + group: "RFQ 정보", + size: 120, + minSize: 100, + maxSize: 150, + }, + + { + id: "overallStatus", + label: "전체 상태", + group: null, + size: 80, + minSize: 60, + maxSize: 100, + }, + { + id: "totalAttachments", + label: "총 첨부파일", + group: "응답 통계", + size: 100, + minSize: 80, + maxSize: 120, + }, + { + id: "respondedCount", + label: "응답완료", + group: "응답 통계", + size: 100, + minSize: 80, + maxSize: 120, + }, + { + id: "pendingCount", + label: "미응답", + group: "응답 통계", + size: 100, + minSize: 80, + maxSize: 120, + }, + { + id: "responseRate", + label: "응답률", + group: "진행률", + size: 100, + minSize: 80, + maxSize: 120, + }, + { + id: "completionRate", + label: "완료율", + group: "진행률", + size: 100, + minSize: 80, + maxSize: 120, + }, + { + id: "requestedAt", + label: "요청일", + group: "날짜 정보", + size: 120, + minSize: 100, + maxSize: 150, + }, + { + id: "lastRespondedAt", + label: "최종 응답일", + group: "날짜 정보", + size: 120, + minSize: 100, + maxSize: 150, + }, + ]; + + // ---------------------------------------------------------------- + // 4) 그룹별로 컬럼 정리 (중첩 헤더 생성) + // ---------------------------------------------------------------- + const groupMap: Record<string, ColumnDef<VendorRfqResponseSummary>[]> = {} + + columnDefinitions.forEach((cfg) => { + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // 개별 컬럼 정의 + const columnDef: ColumnDef<VendorRfqResponseSummary> = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> + ), + cell: ({ row, cell }) => { + // 각 컬럼별 특별한 렌더링 처리 + switch (cfg.id) { + case "rfqCode": + return row.original.rfq?.rfqCode || "-" + + + case "rfqDueDate": + const dueDate = row.original.rfq?.dueDate; + return dueDate ? formatDate(new Date(dueDate)) : "-"; + + case "overallStatus": + return <ResponseStatusBadge status={row.original.overallStatus} /> + + case "totalAttachments": + return ( + <div className="text-center font-medium"> + {row.original.totalAttachments} + </div> + ) + + case "respondedCount": + return ( + <div className="text-center text-green-600 font-medium"> + {row.original.respondedCount} + </div> + ) + + case "pendingCount": + return ( + <div className="text-center text-orange-600 font-medium"> + {row.original.pendingCount} + </div> + ) + + case "responseRate": + const responseRate = row.original.responseRate; + return ( + <div className="text-center"> + <span className={`font-medium ${responseRate >= 80 ? 'text-green-600' : responseRate >= 50 ? 'text-yellow-600' : 'text-red-600'}`}> + {responseRate}% + </span> + </div> + ) + + case "completionRate": + const completionRate = row.original.completionRate; + return ( + <div className="text-center"> + <span className={`font-medium ${completionRate >= 80 ? 'text-green-600' : completionRate >= 50 ? 'text-yellow-600' : 'text-red-600'}`}> + {completionRate}% + </span> + </div> + ) + + case "requestedAt": + return formatDateTime(new Date(row.original.requestedAt)) + + case "lastRespondedAt": + const lastRespondedAt = row.original.lastRespondedAt; + return lastRespondedAt ? formatDateTime(new Date(lastRespondedAt)) : "-"; + + default: + return row.getValue(cfg.id) ?? "" + } + }, + size: cfg.size, + minSize: cfg.minSize, + maxSize: cfg.maxSize, + } + + groupMap[groupName].push(columnDef) + }) + + // ---------------------------------------------------------------- + // 5) 그룹별 중첩 컬럼 생성 + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef<VendorRfqResponseSummary>[] = [] + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + // 그룹이 없는 컬럼들은 직접 추가 + nestedColumns.push(...colDefs) + } else { + // 그룹이 있는 컬럼들은 중첩 구조로 추가 + nestedColumns.push({ + id: groupName, + header: groupName, + columns: colDefs, + }) + } + }) + + // ---------------------------------------------------------------- + // 6) 최종 컬럼 배열 + // ---------------------------------------------------------------- + return [ + selectColumn, + ...nestedColumns, + actionsColumn, + ] +}
\ No newline at end of file diff --git a/lib/b-rfq/vendor-response/vendor-responses-table.tsx b/lib/b-rfq/vendor-response/vendor-responses-table.tsx new file mode 100644 index 00000000..251b1ad0 --- /dev/null +++ b/lib/b-rfq/vendor-response/vendor-responses-table.tsx @@ -0,0 +1,160 @@ +// lib/vendor-responses/table/vendor-responses-table.tsx +"use client" + +import * as React from "react" +import { type DataTableAdvancedFilterField, type DataTableFilterField, type DataTableRowAction } from "@/types/table" +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { Button } from "@/components/ui/button" +import { useRouter } from "next/navigation" +import { getColumns } from "./vendor-responses-table-columns" +import { VendorRfqResponseSummary } from "../validations" + +interface VendorResponsesTableProps { + promises: Promise<[{ data: VendorRfqResponseSummary[], pageCount: number, totalCount: number }]>; +} + +export function VendorResponsesTable({ promises }: VendorResponsesTableProps) { + const [{ data, pageCount, totalCount }] = React.use(promises); + const router = useRouter(); + + console.log(data, "vendor responses data") + + // 선택된 행 액션 상태 + + // 테이블 컬럼 정의 + const columns = React.useMemo(() => getColumns({ + router, + }), [router]); + + // 상태별 응답 수 계산 (전체 상태 기준) + const statusCounts = React.useMemo(() => { + return { + NOT_RESPONDED: data.filter(r => r.overallStatus === "NOT_RESPONDED").length, + RESPONDED: data.filter(r => r.overallStatus === "RESPONDED").length, + REVISION_REQUESTED: data.filter(r => r.overallStatus === "REVISION_REQUESTED").length, + WAIVED: data.filter(r => r.overallStatus === "WAIVED").length, + }; + }, [data]); + + + // 필터 필드 + const filterFields: DataTableFilterField<VendorRfqResponseSummary>[] = [ + { + id: "overallStatus", + label: "전체 상태", + options: [ + { label: "미응답", value: "NOT_RESPONDED", count: statusCounts.NOT_RESPONDED }, + { label: "응답완료", value: "RESPONDED", count: statusCounts.RESPONDED }, + { label: "수정요청", value: "REVISION_REQUESTED", count: statusCounts.REVISION_REQUESTED }, + { label: "포기", value: "WAIVED", count: statusCounts.WAIVED }, + ] + }, + + { + id: "rfqCode", + label: "RFQ 번호", + placeholder: "RFQ 번호 검색...", + } + ]; + + // 고급 필터 필드 + const advancedFilterFields: DataTableAdvancedFilterField<VendorRfqResponseSummary>[] = [ + { + id: "rfqCode", + label: "RFQ 번호", + type: "text", + }, + { + id: "overallStatus", + label: "전체 상태", + type: "multi-select", + options: [ + { label: "미응답", value: "NOT_RESPONDED" }, + { label: "응답완료", value: "RESPONDED" }, + { label: "수정요청", value: "REVISION_REQUESTED" }, + { label: "포기", value: "WAIVED" }, + ], + }, + { + id: "rfqType", + label: "RFQ 타입", + type: "multi-select", + options: [ + { label: "초기 RFQ", value: "INITIAL" }, + { label: "최종 RFQ", value: "FINAL" }, + ], + }, + { + id: "responseRate", + label: "응답률", + type: "number", + }, + { + id: "completionRate", + label: "완료율", + type: "number", + }, + { + id: "requestedAt", + label: "요청일", + type: "date", + }, + { + id: "lastRespondedAt", + label: "최종 응답일", + type: "date", + }, + ]; + + // useDataTable 훅 사용 + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + enableColumnResizing: true, + columnResizeMode: 'onChange', + initialState: { + sorting: [{ id: "updatedAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + defaultColumn: { + minSize: 50, + maxSize: 500, + }, + }); + + return ( + <div className="w-full"> + <div className="flex items-center justify-between py-4"> + <div className="flex items-center space-x-2"> + <span className="text-sm text-muted-foreground"> + 총 {totalCount}개의 응답 요청 + </span> + </div> + </div> + + <div className="overflow-x-auto"> + <DataTable + table={table} + className="min-w-full" + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + {/* 추가적인 액션 버튼들을 여기에 추가할 수 있습니다 */} + </DataTableAdvancedToolbar> + </DataTable> + </div> + </div> + ); +}
\ No newline at end of file diff --git a/lib/b-rfq/vendor-response/waive-response-dialog.tsx b/lib/b-rfq/vendor-response/waive-response-dialog.tsx new file mode 100644 index 00000000..5ded4da3 --- /dev/null +++ b/lib/b-rfq/vendor-response/waive-response-dialog.tsx @@ -0,0 +1,210 @@ +// components/rfq/waive-response-dialog.tsx +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Textarea } from "@/components/ui/textarea"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { FileX, Loader2, AlertTriangle } from "lucide-react"; +import { useToast } from "@/hooks/use-toast"; +import { useRouter } from "next/navigation"; + +const waiveFormSchema = z.object({ + responseComment: z.string().min(1, "포기 사유를 입력해주세요"), + vendorComment: z.string().optional(), +}); + +type WaiveFormData = z.infer<typeof waiveFormSchema>; + +interface WaiveResponseDialogProps { + responseId: number; + attachmentType: string; + serialNo: string; + trigger?: React.ReactNode; + onSuccess?: () => void; +} + +export function WaiveResponseDialog({ + responseId, + attachmentType, + serialNo, + trigger, + onSuccess, +}: WaiveResponseDialogProps) { + const [open, setOpen] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const { toast } = useToast(); + const router = useRouter(); + + const form = useForm<WaiveFormData>({ + resolver: zodResolver(waiveFormSchema), + defaultValues: { + responseComment: "", + vendorComment: "", + }, + }); + + const onSubmit = async (data: WaiveFormData) => { + setIsSubmitting(true); + + try { + const response = await fetch("/api/vendor-responses/waive", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + responseId, + responseComment: data.responseComment, + vendorComment: data.vendorComment, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || "응답 포기 처리 실패"); + } + + toast({ + title: "응답 포기 완료", + description: "해당 항목에 대한 응답이 포기 처리되었습니다.", + }); + + setOpen(false); + form.reset(); + + router.refresh(); + onSuccess?.(); + + } catch (error) { + console.error("Waive error:", error); + toast({ + title: "처리 실패", + description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.", + variant: "destructive", + }); + } finally { + setIsSubmitting(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild> + {trigger || ( + <Button size="sm" variant="outline"> + <FileX className="h-3 w-3 mr-1" /> + 포기 + </Button> + )} + </DialogTrigger> + <DialogContent className="max-w-lg"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2 text-orange-600"> + <FileX className="h-5 w-5" /> + 응답 포기 + </DialogTitle> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Badge variant="outline">{serialNo}</Badge> + <span>{attachmentType}</span> + </div> + </DialogHeader> + + <div className="bg-orange-50 border border-orange-200 rounded-lg p-4 mb-4"> + <div className="flex items-center gap-2 text-orange-800 text-sm font-medium mb-2"> + <AlertTriangle className="h-4 w-4" /> + 주의사항 + </div> + <p className="text-orange-700 text-sm"> + 응답을 포기하면 해당 항목에 대한 입찰 참여가 불가능합니다. + 포기 사유를 명확히 기입해 주세요. + </p> + </div> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> + {/* 포기 사유 (필수) */} + <FormField + control={form.control} + name="responseComment" + render={({ field }) => ( + <FormItem> + <FormLabel className="text-red-600"> + 포기 사유 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Textarea + placeholder="응답을 포기하는 사유를 구체적으로 입력하세요..." + className="resize-none" + rows={4} + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 내부 코멘트 (선택) */} + <FormField + control={form.control} + name="vendorComment" + render={({ field }) => ( + <FormItem> + <FormLabel>내부 코멘트 (선택)</FormLabel> + <FormControl> + <Textarea + placeholder="내부 참고용 코멘트를 입력하세요..." + className="resize-none" + rows={2} + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 버튼 */} + <div className="flex justify-end gap-2"> + <Button + type="button" + variant="outline" + onClick={() => setOpen(false)} + disabled={isSubmitting} + > + 취소 + </Button> + <Button + type="submit" + variant="destructive" + disabled={isSubmitting} + > + {isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />} + {isSubmitting ? "처리 중..." : "포기하기"} + </Button> + </div> + </form> + </Form> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file |
