diff options
Diffstat (limited to 'lib/b-rfq/attachment')
| -rw-r--r-- | lib/b-rfq/attachment/request-revision-dialog.tsx | 205 | ||||
| -rw-r--r-- | lib/b-rfq/attachment/vendor-responses-panel.tsx | 229 |
2 files changed, 410 insertions, 24 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> |
