summaryrefslogtreecommitdiff
path: root/lib/b-rfq/attachment
diff options
context:
space:
mode:
Diffstat (limited to 'lib/b-rfq/attachment')
-rw-r--r--lib/b-rfq/attachment/request-revision-dialog.tsx205
-rw-r--r--lib/b-rfq/attachment/vendor-responses-panel.tsx229
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="수정이 필요한 구체적인 사유를 입력해주세요...&#10;예: 제출된 도면에서 치수 정보가 누락되었습니다."
+ 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>