summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/b-rfq/attachment/request-revision-dialog.tsx205
-rw-r--r--lib/b-rfq/attachment/vendor-responses-panel.tsx229
-rw-r--r--lib/b-rfq/initial/add-initial-rfq-dialog.tsx326
-rw-r--r--lib/b-rfq/initial/delete-initial-rfq-dialog.tsx149
-rw-r--r--lib/b-rfq/initial/initial-rfq-detail-columns.tsx358
-rw-r--r--lib/b-rfq/initial/initial-rfq-detail-table.tsx74
-rw-r--r--lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx301
-rw-r--r--lib/b-rfq/initial/update-initial-rfq-sheet.tsx496
-rw-r--r--lib/b-rfq/service.ts1558
-rw-r--r--lib/b-rfq/validations.ts139
-rw-r--r--lib/b-rfq/vendor-response/comment-edit-dialog.tsx187
-rw-r--r--lib/b-rfq/vendor-response/response-detail-columns.tsx653
-rw-r--r--lib/b-rfq/vendor-response/response-detail-sheet.tsx358
-rw-r--r--lib/b-rfq/vendor-response/response-detail-table.tsx161
-rw-r--r--lib/b-rfq/vendor-response/upload-response-dialog.tsx325
-rw-r--r--lib/b-rfq/vendor-response/vendor-responses-table-columns.tsx351
-rw-r--r--lib/b-rfq/vendor-response/vendor-responses-table.tsx160
-rw-r--r--lib/b-rfq/vendor-response/waive-response-dialog.tsx210
-rw-r--r--lib/mail/templates/initial-rfq-invitation.hbs165
-rw-r--r--lib/mail/templates/tech-sales-quotation-submitted-manager-ko.hbs18
-rw-r--r--lib/mail/templates/tech-sales-quotation-submitted-vendor-ko.hbs18
-rw-r--r--lib/mail/templates/tech-sales-rfq-invite-ko.hbs35
-rw-r--r--lib/procurement-rfqs/vendor-response/table/vendor-quotations-table-columns.tsx2
-rw-r--r--lib/techsales-rfq/service.ts773
-rw-r--r--lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx2
-rw-r--r--lib/welding/table/ocr-table-toolbar-actions.tsx667
26 files changed, 6654 insertions, 1266 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>
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
diff --git a/lib/mail/templates/initial-rfq-invitation.hbs b/lib/mail/templates/initial-rfq-invitation.hbs
new file mode 100644
index 00000000..c732e584
--- /dev/null
+++ b/lib/mail/templates/initial-rfq-invitation.hbs
@@ -0,0 +1,165 @@
+<!DOCTYPE html>
+<html lang="{{language}}">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>{{t "email.rfq_invitation.title"}}</title>
+ <style>
+ body {
+ font-family: Arial, sans-serif;
+ line-height: 1.6;
+ color: #333;
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 20px;
+ }
+ .header {
+ text-align: center;
+ border-bottom: 2px solid #0066cc;
+ padding-bottom: 20px;
+ margin-bottom: 30px;
+ }
+ .logo {
+ font-size: 24px;
+ font-weight: bold;
+ color: #0066cc;
+ margin-bottom: 10px;
+ }
+ .content {
+ margin-bottom: 30px;
+ }
+ .terms-section {
+ background-color: #f8f9fa;
+ padding: 20px;
+ border-left: 4px solid #0066cc;
+ margin: 20px 0;
+ }
+ .terms-title {
+ font-weight: bold;
+ font-size: 16px;
+ margin-bottom: 15px;
+ color: #0066cc;
+ }
+ .terms-list {
+ list-style: none;
+ padding: 0;
+ }
+ .terms-list li {
+ margin-bottom: 8px;
+ padding-left: 0;
+ }
+ .terms-label {
+ font-weight: bold;
+ display: inline-block;
+ width: 180px;
+ }
+ .highlight {
+ background-color: #fff3cd;
+ padding: 2px 4px;
+ border-radius: 3px;
+ }
+ .important {
+ background-color: #d1ecf1;
+ padding: 15px;
+ border-radius: 5px;
+ margin: 20px 0;
+ border-left: 4px solid #0086b3;
+ }
+ .footer {
+ border-top: 2px solid #0066cc;
+ padding-top: 20px;
+ margin-top: 30px;
+ }
+ .contact-info {
+ background-color: #f8f9fa;
+ padding: 15px;
+ border-radius: 5px;
+ margin-top: 15px;
+ }
+ .date {
+ font-weight: bold;
+ color: #d63384;
+ }
+ </style>
+</head>
+<body>
+ <div class="header">
+ <div class="logo">SAMSUNG HEAVY INDUSTRIES CO., LTD.</div>
+ <div>{{t "email.rfq_invitation.subtitle"}}</div>
+ </div>
+
+ <div class="content">
+ <p><strong>{{t "email.greeting"}}</strong></p>
+
+ <p>{{t "email.rfq_invitation.opening_message"}}</p>
+
+ <p>{{t "email.rfq_invitation.invitation_message"}}
+ <span class="date">{{dueDate}}</span>
+ {{t "email.rfq_invitation.korean_time"}}</p>
+
+ <p><em>{{t "email.rfq_invitation.evcp_note"}}</em></p>
+ </div>
+
+ <div class="terms-section">
+ <div class="terms-title">A. {{t "email.rfq_invitation.commercial_terms"}}</div>
+ <div style="text-align: center; margin-bottom: 15px;">- {{t "email.rfq_invitation.details_below"}} -</div>
+
+ <ul class="terms-list">
+ <li>
+ <span class="terms-label">A-1 {{t "email.rfq_invitation.project_name"}} :</span>
+ {{projectName}} ({{rfqCode}})
+ </li>
+ <li>
+ <span class="terms-label">A-2 {{t "email.rfq_invitation.company_flag"}} :</span>
+ {{projectCompany}} / {{projectFlag}}
+ </li>
+ <li>
+ <span class="terms-label">A-3 {{t "email.rfq_invitation.site"}} :</span>
+ {{projectSite}}
+ </li>
+ <li>
+ <span class="terms-label">A-4 {{t "email.rfq_invitation.classification"}} :</span>
+ {{classification}}
+ </li>
+ <li>
+ <span class="terms-label">A-5 {{t "email.rfq_invitation.delivery_condition"}} :</span>
+ {{incotermsDescription}}
+ </li>
+ <li>
+ <span class="terms-label">A-6 {{t "email.rfq_invitation.warranty_period"}} :</span>
+ {{warrantyPeriod}}
+ </li>
+ <li>
+ <span class="terms-label">A-7 {{t "email.rfq_invitation.quotation_validity"}} :</span>
+ {{validDate}}
+ </li>
+ <li>
+ <span class="terms-label">A-8 {{t "email.rfq_invitation.spare_part"}} :</span>
+ {{sparepart}} {{t "email.rfq_invitation.spare_part_detail"}}
+ </li>
+ <li>
+ <span class="terms-label">A-9 {{t "email.rfq_invitation.bid_closing_date"}} :</span>
+ <span class="date">{{dueDate}}</span>
+ </li>
+ </ul>
+ </div>
+
+ <div class="terms-section">
+ <div class="terms-title">B. {{t "email.rfq_invitation.evcp_address"}} : www.evcp.com/partners/Irfq-answer</div>
+ </div>
+
+ <div class="important">
+ <p><strong>{{t "email.rfq_invitation.acknowledgement_request"}}</strong></p>
+ </div>
+
+ <div class="footer">
+ <p>{{t "email.closing"}}</p>
+
+ <div class="contact-info">
+ <p><strong>{{picName}} / {{t "email.rfq_invitation.procurement_manager"}} / {{picEmail}}</strong></p>
+ <p><strong>SAMSUNG HEAVY INDUSTRIES CO., LTD.</strong></p>
+ <p>80, Jangpyeong 3-ro, Geoje-si, Gyeongsangnam-do, Republic of Korea, 53261</p>
+ </div>
+ </div>
+</body>
+</html> \ No newline at end of file
diff --git a/lib/mail/templates/tech-sales-quotation-submitted-manager-ko.hbs b/lib/mail/templates/tech-sales-quotation-submitted-manager-ko.hbs
index 4cd078c1..6700a29b 100644
--- a/lib/mail/templates/tech-sales-quotation-submitted-manager-ko.hbs
+++ b/lib/mail/templates/tech-sales-quotation-submitted-manager-ko.hbs
@@ -37,25 +37,27 @@
{{#if project.className}}
<br>* 선급 : {{project.className}}
{{/if}}
- {{#if project.shipModelName}}
- <br>* 선형 : {{project.shipModelName}}
- {{/if}}
</p>
- {{#if series}}
+
+ {{#if items}}
<p style="font-size:16px; line-height:24px; margin-bottom:8px;">
- <strong>* 시리즈별 K/L 일정 (Keel Laying Quarter)</strong>
- {{#each series}}
- <br> - {{sersNo}}호선: {{klQuarter}}
+ <strong>3) 자재명</strong>
+ {{#each items}}
+ <br>* {{itemList}} ({{itemCode}})
{{/each}}
+ {{#if rfq.materialCode}}
+ <br>* 자재그룹 코드 : {{rfq.materialCode}}
+ {{/if}}
</p>
- {{/if}}
+ {{else}}
<p style="font-size:16px; line-height:24px; margin-bottom:8px;">
<strong>3) 자재명 : {{rfq.title}}</strong>
{{#if rfq.materialCode}}
<br>* 자재그룹 코드 : {{rfq.materialCode}}
{{/if}}
</p>
+ {{/if}}
<p style="font-size:16px; line-height:24px; margin-bottom:8px;">
<strong>4) 제출 벤더</strong>
<br>* 벤더명 : {{vendor.name}}
diff --git a/lib/mail/templates/tech-sales-quotation-submitted-vendor-ko.hbs b/lib/mail/templates/tech-sales-quotation-submitted-vendor-ko.hbs
index 0bc234c7..0bc39964 100644
--- a/lib/mail/templates/tech-sales-quotation-submitted-vendor-ko.hbs
+++ b/lib/mail/templates/tech-sales-quotation-submitted-vendor-ko.hbs
@@ -37,25 +37,27 @@
{{#if project.className}}
<br>* 선급 : {{project.className}}
{{/if}}
- {{#if project.shipModelName}}
- <br>* 선형 : {{project.shipModelName}}
- {{/if}}
</p>
- {{#if series}}
+
+ {{#if items}}
<p style="font-size:16px; line-height:24px; margin-bottom:8px;">
- <strong>* 시리즈별 K/L 일정 (Keel Laying Quarter)</strong>
- {{#each series}}
- <br> - {{sersNo}}호선: {{klQuarter}}
+ <strong>3) 자재명</strong>
+ {{#each items}}
+ <br>* {{itemList}} ({{itemCode}})
{{/each}}
+ {{#if rfq.materialCode}}
+ <br>* 자재그룹 코드 : {{rfq.materialCode}}
+ {{/if}}
</p>
- {{/if}}
+ {{else}}
<p style="font-size:16px; line-height:24px; margin-bottom:8px;">
<strong>3) 자재명 : {{rfq.title}}</strong>
{{#if rfq.materialCode}}
<br>* 자재그룹 코드 : {{rfq.materialCode}}
{{/if}}
</p>
+ {{/if}}
<p style="font-size:16px; line-height:24px; margin-bottom:8px;">
<strong>4) 견적 금액 : {{quotation.currency}} {{quotation.totalPrice}}</strong>
</p>
diff --git a/lib/mail/templates/tech-sales-rfq-invite-ko.hbs b/lib/mail/templates/tech-sales-rfq-invite-ko.hbs
index 0fe029e9..d3ee0d8f 100644
--- a/lib/mail/templates/tech-sales-rfq-invite-ko.hbs
+++ b/lib/mail/templates/tech-sales-rfq-invite-ko.hbs
@@ -32,34 +32,28 @@
<br>* 척수 : {{project.shipCount}}척
{{/if}}
{{#if project.ownerName}}
- <br>* 선주 : {{project.ownerName}} ({{project.ownerCode}})
+ <br>* 선주 : {{project.ownerName}}
{{/if}}
{{#if project.className}}
- <br>* 선급 : {{project.className}} ({{project.classCode}})
- {{/if}}
- {{#if project.shipModelName}}
- <br>* 선형 : {{project.shipModelName}} ({{project.shipModelCode}})
- {{/if}}
- {{#if project.shipModelSize}}
- <br>* 선형크기 : {{project.shipModelSize}} {{project.shipModelUnit}}
- {{/if}}
- {{#if project.projectManager}}
- <br>* 견적대표PM : {{project.projectManager}}
- {{/if}}
- {{#if project.estimateStatus}}
- <br>* 견적상태 : {{project.estimateStatus}}
+ <br>* 선급 : {{project.className}}
{{/if}}
</p>
- {{#if series}}
+
+
+ {{#if items}}
<p style="font-size:16px; line-height:24px; margin-bottom:8px;">
- <strong>* 시리즈별 K/L 일정 (Keel Laying Quarter)</strong>
- {{#each series}}
- <br> - {{sersNo}}호선: {{klQuarter}}
+ <strong>2) 자재명</strong>
+ {{#each items}}
+ <br>* {{itemList}} ({{itemCode}})
+ {{#if workType}}<br> - 작업유형: {{workType}}{{/if}}
+ {{#if shipType}}<br> - 선종: {{shipType}}{{/if}}
{{/each}}
+ {{#if rfq.materialCode}}
+ <br>* 자재그룹 코드 : {{rfq.materialCode}}
+ {{/if}}
</p>
- {{/if}}
-
+ {{else}}
<p style="font-size:16px; line-height:24px; margin-bottom:8px;">
<strong>2) 자재명 : {{rfq.title}}</strong>
{{#if rfq.materialCode}}
@@ -69,6 +63,7 @@
<br>* 선종 : {{project.shipType}}
{{/if}}
</p>
+ {{/if}}
<p style="font-size:16px; line-height:24px; margin-bottom:8px;">
<strong>3) Spec, & Scope of Supply : 첨부 사양서 참조</strong>
</p>
diff --git a/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table-columns.tsx b/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table-columns.tsx
index bad793c3..1fb225d8 100644
--- a/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table-columns.tsx
+++ b/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table-columns.tsx
@@ -129,7 +129,7 @@ export function getColumns({
<Button
variant="ghost"
size="icon"
- onClick={() => router.push(`/partners/rfq-all/${id}`)}
+ onClick={() => router.push(`/partners/rfq-ship/${id}`)}
className="h-8 w-8"
>
<Edit className="h-4 w-4" />
diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts
index d74c54b4..c3c14aff 100644
--- a/lib/techsales-rfq/service.ts
+++ b/lib/techsales-rfq/service.ts
@@ -9,7 +9,6 @@ import {
users,
techSalesRfqComments,
techSalesRfqItems,
- projectSeries,
biddingProjects
} from "@/db/schema";
import { and, desc, eq, ilike, or, sql, inArray } from "drizzle-orm";
@@ -29,7 +28,7 @@ import { GetTechSalesRfqsSchema } from "./validations";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { sendEmail } from "../mail/sendEmail";
-import { formatDate, formatDateToQuarter } from "../utils";
+import { formatDate } from "../utils";
import { techVendors, techVendorPossibleItems } from "@/db/schema/techVendors";
// 정렬 타입 정의
@@ -90,136 +89,6 @@ async function generateRfqCodes(tx: any, count: number, year?: number): Promise<
*
* 나머지 벤더, 첨부파일 등은 생성 이후 처리
*/
-// export async function createTechSalesRfq(input: {
-// // 프로젝트 관련
-// biddingProjectId: number;
-// // 조선 아이템 관련
-// itemShipbuildingId: number;
-// // 자재 관련 (자재그룹 코드들을 CSV로)
-// materialGroupCodes: string[];
-// // 기본 정보
-// dueDate?: Date;
-// remark?: string;
-// createdBy: number;
-// }) {
-// unstable_noStore();
-// console.log('🔍 createTechSalesRfq 호출됨:', {
-// biddingProjectId: input.biddingProjectId,
-// itemShipbuildingId: input.itemShipbuildingId,
-// materialGroupCodes: input.materialGroupCodes,
-// dueDate: input.dueDate,
-// remark: input.remark,
-// createdBy: input.createdBy
-// });
-
-// try {
-// let result: typeof techSalesRfqs.$inferSelect | undefined;
-
-// // 트랜잭션으로 처리
-// await db.transaction(async (tx) => {
-// // 실제 프로젝트 정보 조회
-// const biddingProject = await tx.query.biddingProjects.findFirst({
-// where: (biddingProjects, { eq }) => eq(biddingProjects.id, input.biddingProjectId)
-// });
-
-// if (!biddingProject) {
-// throw new Error(`프로젝트 ID ${input.biddingProjectId}를 찾을 수 없습니다.`);
-// }
-
-// // 프로젝트 시리즈 정보 조회
-// const seriesInfo = await tx.query.projectSeries.findMany({
-// where: (projectSeries, { eq }) => eq(projectSeries.pspid, biddingProject.pspid)
-// });
-
-// // 프로젝트 스냅샷 생성
-// const projectSnapshot = {
-// pspid: biddingProject.pspid,
-// projNm: biddingProject.projNm || undefined,
-// sector: biddingProject.sector || undefined,
-// projMsrm: biddingProject.projMsrm ? Number(biddingProject.projMsrm) : undefined,
-// kunnr: biddingProject.kunnr || undefined,
-// kunnrNm: biddingProject.kunnrNm || undefined,
-// cls1: biddingProject.cls1 || undefined,
-// cls1Nm: biddingProject.cls1Nm || undefined,
-// ptype: biddingProject.ptype || undefined,
-// ptypeNm: biddingProject.ptypeNm || undefined,
-// pmodelCd: biddingProject.pmodelCd || undefined,
-// pmodelNm: biddingProject.pmodelNm || undefined,
-// pmodelSz: biddingProject.pmodelSz || undefined,
-// pmodelUom: biddingProject.pmodelUom || undefined,
-// txt04: biddingProject.txt04 || undefined,
-// txt30: biddingProject.txt30 || undefined,
-// estmPm: biddingProject.estmPm || undefined,
-// pspCreatedAt: biddingProject.createdAt,
-// pspUpdatedAt: biddingProject.updatedAt,
-// };
-
-// // 시리즈 스냅샷 생성
-// const seriesSnapshot = seriesInfo.map(series => ({
-// pspid: series.pspid,
-// sersNo: series.sersNo.toString(),
-// scDt: series.scDt || undefined,
-// klDt: series.klDt || undefined,
-// lcDt: series.lcDt || undefined,
-// dlDt: series.dlDt || undefined,
-// dockNo: series.dockNo || undefined,
-// dockNm: series.dockNm || undefined,
-// projNo: series.projNo || undefined,
-// post1: series.post1 || undefined,
-// }));
-
-// // RFQ 코드 생성
-// const rfqCode = await generateRfqCodes(tx, 1);
-
-// // 기본 due date 설정 (7일 후)
-// const dueDate = input.dueDate || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
-
-// // itemShipbuildingId 유효성 검증
-// console.log('🔍 itemShipbuildingId 검증:', input.itemShipbuildingId);
-// const existingItemShipbuilding = await tx.query.itemShipbuilding.findFirst({
-// where: (itemShipbuilding, { eq }) => eq(itemShipbuilding.id, input.itemShipbuildingId),
-// columns: { id: true, itemCode: true, itemList: true }
-// });
-
-// if (!existingItemShipbuilding) {
-// throw new Error(`itemShipbuildingId ${input.itemShipbuildingId}에 해당하는 itemShipbuilding 레코드를 찾을 수 없습니다.`);
-// }
-
-// console.log('✅ itemShipbuilding 찾음:', existingItemShipbuilding);
-
-// // 새 기술영업 RFQ 작성 (스냅샷 포함)
-// const [newRfq] = await tx.insert(techSalesRfqs).values({
-// rfqCode: rfqCode[0],
-// rfqType: "SHIP",
-// itemShipbuildingId: input.itemShipbuildingId,
-// biddingProjectId: input.biddingProjectId,
-// materialCode: input.materialGroupCodes.join(','), // 모든 materialCode를 CSV로 저장
-// dueDate,
-// remark: input.remark,
-// createdBy: input.createdBy,
-// updatedBy: input.createdBy,
-// // 스냅샷 데이터 추가
-// projectSnapshot,
-// seriesSnapshot,
-// }).returning();
-
-// result = newRfq;
-// });
-
-// // 캐시 무효화
-// revalidateTag("techSalesRfqs");
-// revalidatePath("/evcp/budgetary-tech-sales-ship");
-
-// if (!result) {
-// throw new Error(`RFQ 생성에 실패했습니다. 입력값: ${JSON.stringify(input)}`);
-// }
-
-// return { data: [result], error: null };
-// } catch (err) {
-// console.error("Error creating RFQ:", err);
-// return { data: null, error: getErrorMessage(err) };
-// }
-// }
/**
* 직접 조인을 사용하여 RFQ 데이터 조회하는 함수
@@ -545,166 +414,7 @@ export async function getTechSalesDashboardWithJoin(input: {
}
}
-/**
- * 기술영업 RFQ에 벤더 추가 (단일)
- */
-export async function addVendorToTechSalesRfq(input: {
- rfqId: number;
- vendorId: number;
- createdBy: number;
-}) {
- unstable_noStore();
- try {
- // 이미 해당 RFQ에 벤더가 추가되어 있는지 확인
- const existingQuotation = await db
- .select()
- .from(techSalesVendorQuotations)
- .where(
- and(
- eq(techSalesVendorQuotations.rfqId, input.rfqId),
- eq(techSalesVendorQuotations.vendorId, input.vendorId)
- )
- )
- .limit(1);
-
- if (existingQuotation.length > 0) {
- return {
- data: null,
- error: "이미 해당 벤더가 이 RFQ에 추가되어 있습니다."
- };
- }
-
- // 새 벤더 견적서 레코드 생성
- const [newQuotation] = await db
- .insert(techSalesVendorQuotations)
- .values({
- rfqId: input.rfqId,
- vendorId: input.vendorId,
- status: "Draft",
- totalPrice: "0",
- currency: null,
- createdBy: input.createdBy,
- updatedBy: input.createdBy,
- })
- .returning();
-
- // 캐시 무효화
- revalidateTag("techSalesRfqs");
- revalidateTag("techSalesVendorQuotations");
- revalidateTag(`techSalesRfq-${input.rfqId}`);
- revalidateTag(`vendor-${input.vendorId}-quotations`);
- revalidatePath("/evcp/budgetary-tech-sales-ship");
- return { data: newQuotation, error: null };
- } catch (err) {
- console.error("Error adding vendor to RFQ:", err);
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-/**
- * 기술영업 RFQ에 여러 벤더 추가 (다중)
- */
-export async function addVendorsToTechSalesRfq(input: {
- rfqId: number;
- vendorIds: number[];
- createdBy: number;
-}) {
- unstable_noStore();
- try {
- const results: typeof techSalesVendorQuotations.$inferSelect[] = [];
- const errors: string[] = [];
-
- // 트랜잭션으로 처리
- await db.transaction(async (tx) => {
- // 1. RFQ 상태 확인
- const rfq = await tx.query.techSalesRfqs.findFirst({
- where: eq(techSalesRfqs.id, input.rfqId),
- columns: {
- id: true,
- status: true
- }
- });
-
- if (!rfq) {
- throw new Error("RFQ를 찾을 수 없습니다");
- }
-
- // 2. 각 벤더에 대해 처리
- for (const vendorId of input.vendorIds) {
- try {
- // 이미 해당 RFQ에 벤더가 추가되어 있는지 확인
- const existingQuotation = await tx
- .select()
- .from(techSalesVendorQuotations)
- .where(
- and(
- eq(techSalesVendorQuotations.rfqId, input.rfqId),
- eq(techSalesVendorQuotations.vendorId, vendorId)
- )
- )
- .limit(1);
-
- if (existingQuotation.length > 0) {
- errors.push(`벤더 ID ${vendorId}는 이미 추가되어 있습니다.`);
- continue;
- }
-
- // 새 벤더 견적서 레코드 생성
- const [newQuotation] = await tx
- .insert(techSalesVendorQuotations)
- .values({
- rfqId: input.rfqId,
- vendorId: vendorId,
- status: "Draft",
- totalPrice: "0",
- currency: "USD",
- createdBy: input.createdBy,
- updatedBy: input.createdBy,
- })
- .returning();
-
- results.push(newQuotation);
- } catch (vendorError) {
- console.error(`Error adding vendor ${vendorId}:`, vendorError);
- errors.push(`벤더 ID ${vendorId} 추가 중 오류가 발생했습니다.`);
- }
- }
-
- // 3. RFQ 상태가 "RFQ Created"이고 성공적으로 추가된 벤더가 있는 경우 상태 업데이트
- if (rfq.status === "RFQ Created" && results.length > 0) {
- await tx.update(techSalesRfqs)
- .set({
- status: "RFQ Vendor Assignned",
- updatedBy: input.createdBy,
- updatedAt: new Date()
- })
- .where(eq(techSalesRfqs.id, input.rfqId));
- }
- });
-
- // 캐시 무효화 추가
- revalidateTag("techSalesRfqs");
- revalidateTag("techSalesVendorQuotations");
- revalidateTag(`techSalesRfq-${input.rfqId}`);
- revalidatePath("/evcp/budgetary-tech-sales-ship");
-
- // 벤더별 캐시도 무효화
- for (const vendorId of input.vendorIds) {
- revalidateTag(`vendor-${vendorId}-quotations`);
- }
-
- return {
- data: results,
- error: errors.length > 0 ? errors.join(", ") : null,
- successCount: results.length,
- errorCount: errors.length
- };
- } catch (err) {
- console.error("Error adding vendors to RFQ:", err);
- return { data: null, error: getErrorMessage(err) };
- }
-}
/**
* 기술영업 RFQ에서 벤더 제거 (Draft 상태 체크 포함)
@@ -753,11 +463,16 @@ export async function removeVendorFromTechSalesRfq(input: {
)
.returning();
- // 캐시 무효화 추가
+ // RFQ 타입 조회 및 캐시 무효화
+ const rfqForCache = await db.query.techSalesRfqs.findFirst({
+ where: eq(techSalesRfqs.id, input.rfqId),
+ columns: { rfqType: true }
+ });
+
revalidateTag("techSalesVendorQuotations");
revalidateTag(`techSalesRfq-${input.rfqId}`);
revalidateTag(`vendor-${input.vendorId}-quotations`);
- revalidatePath("/evcp/budgetary-tech-sales-ship");
+ revalidatePath(getTechSalesRevalidationPath(rfqForCache?.rfqType || "SHIP"));
return { data: deletedQuotations[0], error: null };
} catch (err) {
@@ -826,10 +541,15 @@ export async function removeVendorsFromTechSalesRfq(input: {
}
});
- // 캐시 무효화 추가
+ // RFQ 타입 조회 및 캐시 무효화
+ const rfqForCache2 = await db.query.techSalesRfqs.findFirst({
+ where: eq(techSalesRfqs.id, input.rfqId),
+ columns: { rfqType: true }
+ });
+
revalidateTag("techSalesVendorQuotations");
revalidateTag(`techSalesRfq-${input.rfqId}`);
- revalidatePath("/evcp/budgetary-tech-sales-ship");
+ revalidatePath(getTechSalesRevalidationPath(rfqForCache2?.rfqType || "SHIP"));
// 벤더별 캐시도 무효화
for (const vendorId of input.vendorIds) {
@@ -987,10 +707,10 @@ export async function sendTechSalesRfqToVendors(input: {
updatedAt: new Date(),
};
- // rfqSendDate가 null인 경우에만 최초 전송일 설정
- if (!rfq.rfqSendDate) {
- updateData.rfqSendDate = new Date();
- }
+ // rfqSendDate가 null인 경우에만 최초 전송일 설정
+ if (!rfq.rfqSendDate) {
+ updateData.rfqSendDate = new Date();
+ }
await tx.update(techSalesRfqs)
.set(updateData)
@@ -1021,26 +741,11 @@ export async function sendTechSalesRfqToVendors(input: {
// 대표 언어 결정 (첫 번째 사용자의 언어 또는 기본값)
const language = vendorUsers[0]?.language || "ko";
- // 시리즈 정보 처리 - 직접 조회
- const seriesInfo = rfq.biddingProject?.pspid ? await db.query.projectSeries.findMany({
- where: eq(projectSeries.pspid, rfq.biddingProject.pspid)
- }).then(series => series.map(s => ({
- sersNo: s.sersNo.toString(),
- klQuarter: s.klDt ? formatDateToQuarter(s.klDt) : '',
- scDt: s.scDt,
- lcDt: s.lcDt,
- dlDt: s.dlDt,
- dockNo: s.dockNo,
- dockNm: s.dockNm,
- projNo: s.projNo,
- post1: s.post1,
- }))) : [];
-
// RFQ 아이템 목록 조회
const rfqItemsResult = await getTechSalesRfqItems(rfq.id);
const rfqItems = rfqItemsResult.data || [];
- // 이메일 컨텍스트 구성
+ // 이메일 컨텍스트 구성 (시리즈 정보 제거, 프로젝트 정보 간소화)
const emailContext = {
language: language,
rfq: {
@@ -1072,27 +777,15 @@ export async function sendTechSalesRfqToVendors(input: {
email: sender.email,
},
project: {
- // 기본 정보
+ // 기본 정보만 유지
id: rfq.biddingProject?.pspid || '',
name: rfq.biddingProject?.projNm || '',
sector: rfq.biddingProject?.sector || '',
shipType: rfq.biddingProject?.ptypeNm || '',
-
- // 추가 프로젝트 정보
shipCount: rfq.biddingProject?.projMsrm || 0,
- ownerCode: rfq.biddingProject?.kunnr || '',
ownerName: rfq.biddingProject?.kunnrNm || '',
- classCode: rfq.biddingProject?.cls1 || '',
className: rfq.biddingProject?.cls1Nm || '',
- shipTypeCode: rfq.biddingProject?.ptype || '',
- shipModelCode: rfq.biddingProject?.pmodelCd || '',
- shipModelName: rfq.biddingProject?.pmodelNm || '',
- shipModelSize: rfq.biddingProject?.pmodelSz || '',
- shipModelUnit: rfq.biddingProject?.pmodelUom || '',
- estimateStatus: rfq.biddingProject?.txt30 || '',
- projectManager: rfq.biddingProject?.estmPm || '',
},
- series: seriesInfo,
details: {
currency: quotation.currency || 'USD',
},
@@ -1106,8 +799,8 @@ export async function sendTechSalesRfqToVendors(input: {
await sendEmail({
to: vendorEmailsString,
subject: isResend
- ? `[기술영업 RFQ 재전송] ${rfq.rfqCode} - ${rfqItems.length > 0 ? rfqItems.map(item => item.itemList).join(', ') : '견적 요청'} ${emailContext.versionInfo}`
- : `[기술영업 RFQ] ${rfq.rfqCode} - ${rfqItems.length > 0 ? rfqItems.map(item => item.itemList).join(', ') : '견적 요청'}`,
+ ? `[기술영업 RFQ 재전송] ${rfq.rfqCode} - ${rfqItems.length > 0 ? rfqItems.map(item => item.itemList).join(', ') : '견적 요청'} ${emailContext.versionInfo}`
+ : `[기술영업 RFQ] ${rfq.rfqCode} - ${rfqItems.length > 0 ? rfqItems.map(item => item.itemList).join(', ') : '견적 요청'}`,
template: 'tech-sales-rfq-invite-ko', // 기술영업용 템플릿
context: emailContext,
cc: sender.email, // 발신자를 CC에 추가
@@ -1120,7 +813,7 @@ export async function sendTechSalesRfqToVendors(input: {
revalidateTag("techSalesRfqs");
revalidateTag("techSalesVendorQuotations");
revalidateTag(`techSalesRfq-${input.rfqId}`);
- revalidatePath("/evcp/budgetary-tech-sales-ship");
+ revalidatePath(getTechSalesRevalidationPath(rfq?.rfqType || "SHIP"));
return {
success: true,
@@ -1633,22 +1326,6 @@ export async function acceptTechSalesVendorQuotation(quotationId: number) {
})
.where(eq(techSalesVendorQuotations.id, quotationId))
- // // 3. 같은 RFQ의 다른 견적들을 Rejected로 변경
- // await tx
- // .update(techSalesVendorQuotations)
- // .set({
- // status: "Rejected",
- // rejectionReason: "다른 벤더가 선택됨",
- // updatedAt: new Date(),
- // })
- // .where(
- // and(
- // eq(techSalesVendorQuotations.rfqId, quotation.rfqId),
- // ne(techSalesVendorQuotations.id, quotationId),
- // eq(techSalesVendorQuotations.status, "Submitted")
- // )
- // )
-
// 4. RFQ 상태를 Closed로 변경
await tx
.update(techSalesRfqs)
@@ -1667,28 +1344,6 @@ export async function acceptTechSalesVendorQuotation(quotationId: number) {
console.error("벤더 견적 선택 알림 메일 발송 실패:", error);
});
- // // 거절된 견적들에 대한 알림 메일 발송 - 트랜잭션 완료 후 별도로 처리
- // setTimeout(async () => {
- // try {
- // const rejectedQuotations = await db.query.techSalesVendorQuotations.findMany({
- // where: and(
- // eq(techSalesVendorQuotations.rfqId, result.rfqId),
- // ne(techSalesVendorQuotations.id, quotationId),
- // eq(techSalesVendorQuotations.status, "Rejected")
- // ),
- // columns: { id: true }
- // });
-
- // for (const rejectedQuotation of rejectedQuotations) {
- // sendQuotationRejectedNotification(rejectedQuotation.id).catch(error => {
- // console.error("벤더 견적 거절 알림 메일 발송 실패:", error);
- // });
- // }
- // } catch (error) {
- // console.error("거절된 견적 알림 메일 발송 중 오류:", error);
- // }
- // }, 1000); // 1초 후 실행
-
// 캐시 무효화
revalidateTag("techSalesVendorQuotations")
revalidateTag(`techSalesRfq-${result.rfqId}`)
@@ -1714,45 +1369,6 @@ export async function acceptTechSalesVendorQuotation(quotationId: number) {
}
}
-// /**
-// * 기술영업 벤더 견적 거절
-// */
-// export async function rejectTechSalesVendorQuotation(quotationId: number, rejectionReason?: string) {
-// // try {
-// // const result = await db
-// // .update(techSalesVendorQuotations)
-// // .set({
-// // status: "Rejected" as any,
-// // rejectionReason: rejectionReason || "기술영업 담당자에 의해 거절됨",
-// // updatedAt: new Date(),
-// // })
-// // .where(eq(techSalesVendorQuotations.id, quotationId))
-// // .returning()
-
-// // if (result.length === 0) {
-// // throw new Error("견적을 찾을 수 없습니다")
-// // }
-
-// // // 메일 발송 (백그라운드에서 실행)
-// // sendQuotationRejectedNotification(quotationId).catch(error => {
-// // console.error("벤더 견적 거절 알림 메일 발송 실패:", error);
-// // });
-
-// // // 캐시 무효화
-// // revalidateTag("techSalesVendorQuotations")
-// // revalidateTag(`techSalesRfq-${result[0].rfqId}`)
-// // revalidateTag(`vendor-${result[0].vendorId}-quotations`)
-
-// // return { success: true, data: result[0] }
-// // } catch (error) {
-// // console.error("벤더 견적 거절 오류:", error)
-// // return {
-// // success: false,
-// // error: error instanceof Error ? error.message : "벤더 견적 거절에 실패했습니다"
-// // }
-// // }
-// }
-
/**
* 기술영업 RFQ 첨부파일 생성 (파일 업로드)
*/
@@ -1827,10 +1443,15 @@ export async function createTechSalesRfqAttachments(params: {
}
});
- // 캐시 무효화
+ // RFQ 타입 조회하여 캐시 무효화
+ const rfqType = await db.query.techSalesRfqs.findFirst({
+ where: eq(techSalesRfqs.id, techSalesRfqId),
+ columns: { rfqType: true }
+ });
+
revalidateTag("techSalesRfqs");
revalidateTag(`techSalesRfq-${techSalesRfqId}`);
- revalidatePath("/evcp/budgetary-tech-sales-ship");
+ revalidatePath(getTechSalesRevalidationPath(rfqType?.rfqType || "SHIP"));
return { data: results, error: null };
} catch (err) {
@@ -1918,10 +1539,15 @@ export async function deleteTechSalesRfqAttachment(attachmentId: number) {
return deletedAttachment[0];
});
- // 캐시 무효화
+ // RFQ 타입 조회하여 캐시 무효화
+ const attachmentRfq = await db.query.techSalesRfqs.findFirst({
+ where: eq(techSalesRfqs.id, attachment.techSalesRfqId!),
+ columns: { rfqType: true }
+ });
+
revalidateTag("techSalesRfqs");
revalidateTag(`techSalesRfq-${attachment.techSalesRfqId}`);
- revalidatePath("/evcp/budgetary-tech-sales-ship");
+ revalidatePath(getTechSalesRevalidationPath(attachmentRfq?.rfqType || "SHIP"));
return { data: result, error: null };
} catch (err) {
@@ -2103,27 +1729,11 @@ export async function sendQuotationSubmittedNotificationToVendor(quotationId: nu
return { success: false, error: "벤더 이메일 주소가 없습니다" };
}
- // 프로젝트 시리즈 정보 조회
- const seriesData = quotation.rfq.biddingProject?.pspid
- ? await db.query.projectSeries.findMany({
- where: eq(projectSeries.pspid, quotation.rfq.biddingProject.pspid)
- })
- : [];
+ // RFQ 아이템 정보 조회
+ const rfqItemsResult = await getTechSalesRfqItems(quotation.rfq.id);
+ const rfqItems = rfqItemsResult.data || [];
- // 시리즈 정보 처리
- const seriesInfo = seriesData.map(series => ({
- sersNo: series.sersNo?.toString() || '',
- klQuarter: series.klDt ? formatDateToQuarter(series.klDt) : '',
- scDt: series.scDt,
- lcDt: series.lcDt,
- dlDt: series.dlDt,
- dockNo: series.dockNo,
- dockNm: series.dockNm,
- projNo: series.projNo,
- post1: series.post1,
- }));
-
- // 이메일 컨텍스트 구성
+ // 이메일 컨텍스트 구성 (시리즈 정보 제거, 프로젝트 정보 간소화)
const emailContext = {
language: vendorUsers[0]?.language || "ko",
quotation: {
@@ -2144,6 +1754,14 @@ export async function sendQuotationSubmittedNotificationToVendor(quotationId: nu
materialCode: quotation.rfq.materialCode,
description: quotation.rfq.remark,
},
+ items: rfqItems.map(item => ({
+ itemCode: item.itemCode,
+ itemList: item.itemList,
+ workType: item.workType,
+ shipType: item.shipType,
+ subItemName: item.subItemName,
+ itemType: item.itemType,
+ })),
vendor: {
id: quotation.vendor.id,
code: quotation.vendor.vendorCode,
@@ -2155,9 +1773,7 @@ export async function sendQuotationSubmittedNotificationToVendor(quotationId: nu
shipCount: quotation.rfq.biddingProject?.projMsrm ? Number(quotation.rfq.biddingProject.projMsrm) : 0,
ownerName: quotation.rfq.biddingProject?.kunnrNm || '',
className: quotation.rfq.biddingProject?.cls1Nm || '',
- shipModelName: quotation.rfq.biddingProject?.pmodelNm || '',
},
- series: seriesInfo,
manager: {
name: quotation.rfq.createdByUser?.name || '',
email: quotation.rfq.createdByUser?.email || '',
@@ -2225,27 +1841,11 @@ export async function sendQuotationSubmittedNotificationToManager(quotationId: n
return { success: false, error: "담당자 이메일 주소가 없습니다" };
}
- // 프로젝트 시리즈 정보 조회
- const seriesData = quotation.rfq.biddingProject?.pspid
- ? await db.query.projectSeries.findMany({
- where: eq(projectSeries.pspid, quotation.rfq.biddingProject.pspid)
- })
- : [];
-
- // 시리즈 정보 처리
- const seriesInfo = seriesData.map(series => ({
- sersNo: series.sersNo?.toString() || '',
- klQuarter: series.klDt ? formatDateToQuarter(series.klDt) : '',
- scDt: series.scDt,
- lcDt: series.lcDt,
- dlDt: series.dlDt,
- dockNo: series.dockNo,
- dockNm: series.dockNm,
- projNo: series.projNo,
- post1: series.post1,
- }));
+ // RFQ 아이템 정보 조회
+ const rfqItemsResult = await getTechSalesRfqItems(quotation.rfq.id);
+ const rfqItems = rfqItemsResult.data || [];
- // 이메일 컨텍스트 구성
+ // 이메일 컨텍스트 구성 (시리즈 정보 제거, 프로젝트 정보 간소화)
const emailContext = {
language: "ko",
quotation: {
@@ -2266,6 +1866,14 @@ export async function sendQuotationSubmittedNotificationToManager(quotationId: n
materialCode: quotation.rfq.materialCode,
description: quotation.rfq.remark,
},
+ items: rfqItems.map(item => ({
+ itemCode: item.itemCode,
+ itemList: item.itemList,
+ workType: item.workType,
+ shipType: item.shipType,
+ subItemName: item.subItemName,
+ itemType: item.itemType,
+ })),
vendor: {
id: quotation.vendor.id,
code: quotation.vendor.vendorCode,
@@ -2277,9 +1885,7 @@ export async function sendQuotationSubmittedNotificationToManager(quotationId: n
shipCount: quotation.rfq.biddingProject?.projMsrm ? Number(quotation.rfq.biddingProject.projMsrm) : 0,
ownerName: quotation.rfq.biddingProject?.kunnrNm || '',
className: quotation.rfq.biddingProject?.cls1Nm || '',
- shipModelName: quotation.rfq.biddingProject?.pmodelNm || '',
},
- series: seriesInfo,
manager: {
name: manager.name || '',
email: manager.email,
@@ -2362,27 +1968,11 @@ export async function sendQuotationAcceptedNotification(quotationId: number) {
return { success: false, error: "벤더 이메일 주소가 없습니다" };
}
- // 프로젝트 시리즈 정보 조회
- const seriesData = quotation.rfq.biddingProject?.pspid
- ? await db.query.projectSeries.findMany({
- where: eq(projectSeries.pspid, quotation.rfq.biddingProject.pspid)
- })
- : [];
-
- // 시리즈 정보 처리
- const seriesInfo = seriesData.map(series => ({
- sersNo: series.sersNo?.toString() || '',
- klQuarter: series.klDt ? formatDateToQuarter(series.klDt) : '',
- scDt: series.scDt,
- lcDt: series.lcDt,
- dlDt: series.dlDt,
- dockNo: series.dockNo,
- dockNm: series.dockNm,
- projNo: series.projNo,
- post1: series.post1,
- }));
+ // RFQ 아이템 정보 조회
+ const rfqItemsResult = await getTechSalesRfqItems(quotation.rfq.id);
+ const rfqItems = rfqItemsResult.data || [];
- // 이메일 컨텍스트 구성
+ // 이메일 컨텍스트 구성 (시리즈 정보 제거, 프로젝트 정보 간소화)
const emailContext = {
language: vendorUsers[0]?.language || "ko",
quotation: {
@@ -2403,6 +1993,14 @@ export async function sendQuotationAcceptedNotification(quotationId: number) {
materialCode: quotation.rfq.materialCode,
description: quotation.rfq.remark,
},
+ items: rfqItems.map(item => ({
+ itemCode: item.itemCode,
+ itemList: item.itemList,
+ workType: item.workType,
+ shipType: item.shipType,
+ subItemName: item.subItemName,
+ itemType: item.itemType,
+ })),
vendor: {
id: quotation.vendor.id,
code: quotation.vendor.vendorCode,
@@ -2414,9 +2012,7 @@ export async function sendQuotationAcceptedNotification(quotationId: number) {
shipCount: quotation.rfq.biddingProject?.projMsrm ? Number(quotation.rfq.biddingProject.projMsrm) : 0,
ownerName: quotation.rfq.biddingProject?.kunnrNm || '',
className: quotation.rfq.biddingProject?.cls1Nm || '',
- shipModelName: quotation.rfq.biddingProject?.pmodelNm || '',
},
- series: seriesInfo,
manager: {
name: quotation.rfq.createdByUser?.name || '',
email: quotation.rfq.createdByUser?.email || '',
@@ -2442,143 +2038,6 @@ export async function sendQuotationAcceptedNotification(quotationId: number) {
}
}
-/**
- * 벤더 견적 거절 알림 메일 발송
- */
-export async function sendQuotationRejectedNotification(quotationId: number) {
- try {
- // 견적서 정보 조회
- const quotation = await db.query.techSalesVendorQuotations.findFirst({
- where: eq(techSalesVendorQuotations.id, quotationId),
- with: {
- rfq: {
- with: {
- biddingProject: true,
- createdByUser: {
- columns: {
- id: true,
- name: true,
- email: true,
- }
- }
- }
- },
- vendor: {
- columns: {
- id: true,
- vendorName: true,
- vendorCode: true,
- }
- }
- }
- });
-
- if (!quotation || !quotation.rfq || !quotation.vendor) {
- console.error("견적서 또는 관련 정보를 찾을 수 없습니다");
- return { success: false, error: "견적서 정보를 찾을 수 없습니다" };
- }
-
- // 벤더 사용자들 조회
- const vendorUsers = await db.query.users.findMany({
- where: eq(users.companyId, quotation.vendor.id),
- columns: {
- id: true,
- email: true,
- name: true,
- language: true
- }
- });
-
- const vendorEmails = vendorUsers
- .filter(user => user.email)
- .map(user => user.email)
- .join(", ");
-
- if (!vendorEmails) {
- console.warn(`벤더 ID ${quotation.vendor.id}에 등록된 이메일 주소가 없습니다`);
- return { success: false, error: "벤더 이메일 주소가 없습니다" };
- }
-
- // 프로젝트 시리즈 정보 조회
- const seriesData = quotation.rfq.biddingProject?.pspid
- ? await db.query.projectSeries.findMany({
- where: eq(projectSeries.pspid, quotation.rfq.biddingProject.pspid)
- })
- : [];
-
- // 시리즈 정보 처리
- const seriesInfo = seriesData.map(series => ({
- sersNo: series.sersNo?.toString() || '',
- klQuarter: series.klDt ? formatDateToQuarter(series.klDt) : '',
- scDt: series.scDt,
- lcDt: series.lcDt,
- dlDt: series.dlDt,
- dockNo: series.dockNo,
- dockNm: series.dockNm,
- projNo: series.projNo,
- post1: series.post1,
- }));
-
- // 이메일 컨텍스트 구성
- const emailContext = {
- language: vendorUsers[0]?.language || "ko",
- quotation: {
- id: quotation.id,
- currency: quotation.currency,
- totalPrice: quotation.totalPrice,
- validUntil: quotation.validUntil,
- rejectionReason: quotation.rejectionReason,
- remark: quotation.remark,
- },
- rfq: {
- id: quotation.rfq.id,
- code: quotation.rfq.rfqCode,
- title: quotation.rfq.description || '',
- projectCode: quotation.rfq.biddingProject?.pspid || '',
- projectName: quotation.rfq.biddingProject?.projNm || '',
- dueDate: quotation.rfq.dueDate,
- materialCode: quotation.rfq.materialCode,
- description: quotation.rfq.remark,
- },
- vendor: {
- id: quotation.vendor.id,
- code: quotation.vendor.vendorCode,
- name: quotation.vendor.vendorName,
- },
- project: {
- name: quotation.rfq.biddingProject?.projNm || '',
- sector: quotation.rfq.biddingProject?.sector || '',
- shipCount: quotation.rfq.biddingProject?.projMsrm ? Number(quotation.rfq.biddingProject.projMsrm) : 0,
- ownerName: quotation.rfq.biddingProject?.kunnrNm || '',
- className: quotation.rfq.biddingProject?.cls1Nm || '',
- shipModelName: quotation.rfq.biddingProject?.pmodelNm || '',
- },
- series: seriesInfo,
- manager: {
- name: quotation.rfq.createdByUser?.name || '',
- email: quotation.rfq.createdByUser?.email || '',
- },
- systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://60.101.108.100/ko/partners',
- companyName: 'Samsung Heavy Industries',
- year: new Date().getFullYear(),
- };
-
- // 이메일 발송
- await sendEmail({
- to: vendorEmails,
- subject: `[견적 거절 알림] ${quotation.rfq.rfqCode} - 견적 결과 안내`,
- template: 'tech-sales-quotation-rejected-ko',
- context: emailContext,
- });
-
- console.log(`벤더 견적 거절 알림 메일 발송 완료: ${vendorEmails}`);
- return { success: true };
- } catch (error) {
- console.error("벤더 견적 거절 알림 메일 발송 오류:", error);
- return { success: false, error: "메일 발송 중 오류가 발생했습니다" };
- }
-}
-
// ==================== Vendor Communication 관련 ====================
export interface TechSalesAttachment {
@@ -3288,6 +2747,22 @@ export async function addTechVendorToTechSalesRfq(input: {
}
/**
+ * RFQ 타입에 따른 캐시 무효화 경로 반환
+ */
+function getTechSalesRevalidationPath(rfqType: "SHIP" | "TOP" | "HULL"): string {
+ switch (rfqType) {
+ case "SHIP":
+ return "/evcp/budgetary-tech-sales-ship";
+ case "TOP":
+ return "/evcp/budgetary-tech-sales-top";
+ case "HULL":
+ return "/evcp/budgetary-tech-sales-hull";
+ default:
+ return "/evcp/budgetary-tech-sales-ship";
+ }
+}
+
+/**
* 기술영업 RFQ에 여러 벤더 추가 (techVendors 기반)
*/
export async function addTechVendorsToTechSalesRfq(input: {
@@ -3300,17 +2775,38 @@ export async function addTechVendorsToTechSalesRfq(input: {
try {
return await db.transaction(async (tx) => {
const results = [];
+ const errors: string[] = [];
+
+ // 1. RFQ 상태 및 타입 확인
+ const rfq = await tx.query.techSalesRfqs.findFirst({
+ where: eq(techSalesRfqs.id, input.rfqId),
+ columns: {
+ id: true,
+ status: true,
+ rfqType: true
+ }
+ });
+
+ if (!rfq) {
+ throw new Error("RFQ를 찾을 수 없습니다");
+ }
+ // 2. 각 벤더에 대해 처리
for (const vendorId of input.vendorIds) {
- // 벤더가 이미 추가되어 있는지 확인
- const existingQuotation = await tx.query.techSalesVendorQuotations.findFirst({
- where: and(
- eq(techSalesVendorQuotations.rfqId, input.rfqId),
- eq(techSalesVendorQuotations.vendorId, vendorId)
- )
- });
+ try {
+ // 벤더가 이미 추가되어 있는지 확인
+ const existingQuotation = await tx.query.techSalesVendorQuotations.findFirst({
+ where: and(
+ eq(techSalesVendorQuotations.rfqId, input.rfqId),
+ eq(techSalesVendorQuotations.vendorId, vendorId)
+ )
+ });
+
+ if (existingQuotation) {
+ errors.push(`벤더 ID ${vendorId}는 이미 추가되어 있습니다.`);
+ continue;
+ }
- if (!existingQuotation) {
// 새로운 견적서 레코드 생성
const [quotation] = await tx
.insert(techSalesVendorQuotations)
@@ -3324,13 +2820,40 @@ export async function addTechVendorsToTechSalesRfq(input: {
.returning({ id: techSalesVendorQuotations.id });
results.push(quotation);
+ } catch (vendorError) {
+ console.error(`Error adding vendor ${vendorId}:`, vendorError);
+ errors.push(`벤더 ID ${vendorId} 추가 중 오류가 발생했습니다.`);
}
}
- // 캐시 무효화
+ // 3. RFQ 상태가 "RFQ Created"이고 성공적으로 추가된 벤더가 있는 경우 상태 업데이트
+ if (rfq.status === "RFQ Created" && results.length > 0) {
+ await tx.update(techSalesRfqs)
+ .set({
+ status: "RFQ Vendor Assignned",
+ updatedBy: input.createdBy,
+ updatedAt: new Date()
+ })
+ .where(eq(techSalesRfqs.id, input.rfqId));
+ }
+
+ // 캐시 무효화 (RFQ 타입에 따른 동적 경로)
revalidateTag("techSalesRfqs");
+ revalidateTag("techSalesVendorQuotations");
+ revalidateTag(`techSalesRfq-${input.rfqId}`);
+ revalidatePath(getTechSalesRevalidationPath(rfq.rfqType || "SHIP"));
- return { data: results, error: null };
+ // 벤더별 캐시도 무효화
+ for (const vendorId of input.vendorIds) {
+ revalidateTag(`vendor-${vendorId}-quotations`);
+ }
+
+ return {
+ data: results,
+ error: errors.length > 0 ? errors.join(", ") : null,
+ successCount: results.length,
+ errorCount: errors.length
+ };
});
} catch (err) {
console.error("Error adding tech vendors to RFQ:", err);
diff --git a/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx
index 7d5c359e..3e50a516 100644
--- a/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx
+++ b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx
@@ -139,7 +139,7 @@ export function getRfqDetailColumns({
variant="link"
className="p-0 h-auto font-normal text-left justify-start hover:underline"
onClick={() => {
- window.open(`/ko/evcp/vendors/${vendorId}/info`, '_blank');
+ window.open(`/ko/evcp/tech-vendors/${vendorId}/info`, '_blank');
}}
>
{vendorName}
diff --git a/lib/welding/table/ocr-table-toolbar-actions.tsx b/lib/welding/table/ocr-table-toolbar-actions.tsx
index 120ff54f..03d8cab0 100644
--- a/lib/welding/table/ocr-table-toolbar-actions.tsx
+++ b/lib/welding/table/ocr-table-toolbar-actions.tsx
@@ -2,7 +2,7 @@
import * as React from "react"
import { type Table } from "@tanstack/react-table"
-import { Download, RefreshCcw, Upload, FileText, Loader2, ChevronDown } from "lucide-react"
+import { Download, RefreshCcw, Upload, FileText, Loader2, ChevronDown, X, Play, Pause, RotateCcw } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -27,6 +27,25 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
+import {
+ Dropzone,
+ DropzoneDescription,
+ DropzoneInput,
+ DropzoneTitle,
+ DropzoneUploadIcon,
+ DropzoneZone,
+} from "@/components/ui/dropzone"
+import {
+ FileList,
+ FileListAction,
+ FileListDescription,
+ FileListHeader,
+ FileListIcon,
+ FileListInfo,
+ FileListItem,
+ FileListName,
+ FileListSize,
+} from "@/components/ui/file-list"
import { getOcrAllRows } from "../service"
import { exportOcrDataToExcel } from "./exporft-ocr-data"
@@ -40,6 +59,26 @@ interface UploadProgress {
message: string
}
+interface FileUploadItem {
+ id: string
+ file: File
+ status: 'pending' | 'processing' | 'completed' | 'failed'
+ progress?: UploadProgress
+ error?: string
+ result?: {
+ totalTables: number
+ totalRows: number
+ sessionId: string
+ }
+}
+
+interface BatchProgress {
+ total: number
+ completed: number
+ failed: number
+ current?: string // 현재 처리 중인 파일명
+}
+
export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) {
const [isLoading, setIsLoading] = React.useState(false)
const [isUploading, setIsUploading] = React.useState(false)
@@ -49,25 +88,42 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) {
const fileInputRef = React.useRef<HTMLInputElement>(null)
const [isExporting, setIsExporting] = React.useState(false)
- // 다이얼로그 닫기 핸들러 - 업로드 중에는 닫기 방지
+ // 멀티 파일 업로드 관련 상태
+ const [isBatchDialogOpen, setIsBatchDialogOpen] = React.useState(false)
+ const [fileQueue, setFileQueue] = React.useState<FileUploadItem[]>([])
+ const [isBatchProcessing, setIsBatchProcessing] = React.useState(false)
+ const [batchProgress, setBatchProgress] = React.useState<BatchProgress>({ total: 0, completed: 0, failed: 0 })
+ const [isPaused, setIsPaused] = React.useState(false)
+ const batchControllerRef = React.useRef<AbortController | null>(null)
+
+ // 단일 파일 업로드 다이얼로그 닫기 핸들러
const handleDialogOpenChange = (open: boolean) => {
- // 다이얼로그를 닫으려고 할 때
if (!open) {
- // 업로드가 진행 중이면 닫기를 방지
if (isUploading && uploadProgress?.stage !== "complete") {
- toast.warning("Cannot close while processing. Please wait for completion.", {
- description: "OCR processing is in progress..."
+ toast.warning("처리 중에는 창을 닫을 수 없습니다. 완료될 때까지 기다려주세요.", {
+ description: "OCR 처리가 진행 중입니다..."
})
- return // 다이얼로그를 닫지 않음
+ return
}
-
- // 업로드가 진행 중이 아니거나 완료되었으면 초기화 후 닫기
resetUpload()
}
-
setIsUploadDialogOpen(open)
}
+ // 배치 업로드 다이얼로그 닫기 핸들러
+ const handleBatchDialogOpenChange = (open: boolean) => {
+ if (!open) {
+ if (isBatchProcessing && !isPaused) {
+ toast.warning("일괄 처리 중에는 창을 닫을 수 없습니다. 먼저 일시정지하세요.", {
+ description: "일괄 OCR 처리가 진행 중입니다..."
+ })
+ return
+ }
+ resetBatchUpload()
+ }
+ setIsBatchDialogOpen(open)
+ }
+
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (file) {
@@ -75,10 +131,36 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) {
}
}
+ // 멀티 파일 선택/드롭 핸들러
+ const handleFilesSelect = (files: FileList | File[]) => {
+ const newFiles = Array.from(files).map(file => ({
+ id: `${file.name}-${Date.now()}-${Math.random()}`,
+ file,
+ status: 'pending' as const
+ }))
+
+ // 파일 검증
+ const validFiles = newFiles.filter(item => {
+ const error = validateFile(item.file)
+ if (error) {
+ toast.error(`${item.file.name}: ${error}`)
+ return false
+ }
+ return true
+ })
+
+ setFileQueue(prev => [...prev, ...validFiles])
+ setBatchProgress(prev => ({ ...prev, total: prev.total + validFiles.length }))
+
+ if (validFiles.length > 0) {
+ toast.success(`${validFiles.length}개 파일이 대기열에 추가되었습니다`)
+ }
+ }
+
const validateFile = (file: File): string | null => {
// 파일 크기 체크 (10MB)
if (file.size > 10 * 1024 * 1024) {
- return "File size must be less than 10MB"
+ return "파일 크기는 10MB 미만이어야 합니다"
}
// 파일 타입 체크
@@ -92,15 +174,16 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) {
]
if (!allowedTypes.includes(file.type)) {
- return "Only PDF and image files (JPG, PNG, TIFF, BMP) are supported"
+ return "PDF 및 이미지 파일(JPG, PNG, TIFF, BMP)만 지원됩니다"
}
return null
}
+ // 단일 파일 업로드
const uploadFile = async () => {
if (!selectedFile) {
- toast.error("Please select a file first")
+ toast.error("먼저 파일을 선택하세요")
return
}
@@ -115,7 +198,7 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) {
setUploadProgress({
stage: "preparing",
progress: 10,
- message: "Preparing file upload..."
+ message: "파일 업로드 준비 중..."
})
const formData = new FormData()
@@ -124,7 +207,7 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) {
setUploadProgress({
stage: "uploading",
progress: 30,
- message: "Uploading file and processing..."
+ message: "파일 업로드 및 처리 중..."
})
const response = await fetch('/api/ocr/enhanced', {
@@ -135,12 +218,12 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) {
setUploadProgress({
stage: "processing",
progress: 70,
- message: "Analyzing document with OCR..."
+ message: "OCR을 사용하여 문서 분석 중..."
})
if (!response.ok) {
const errorData = await response.json()
- throw new Error(errorData.error || 'OCR processing failed')
+ throw new Error(errorData.error || 'OCR 처리가 실패했습니다')
}
const result = await response.json()
@@ -148,44 +231,41 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) {
setUploadProgress({
stage: "saving",
progress: 90,
- message: "Saving results to database..."
+ message: "결과를 데이터베이스에 저장 중..."
})
if (result.success) {
setUploadProgress({
stage: "complete",
progress: 100,
- message: "OCR processing completed successfully!"
+ message: "OCR 처리가 성공적으로 완료되었습니다!"
})
toast.success(
- `OCR completed! Extracted ${result.metadata.totalRows} rows from ${result.metadata.totalTables} tables`,
+ `OCR 완료! ${result.metadata.totalTables}개 테이블에서 ${result.metadata.totalRows}개 행을 추출했습니다`,
{
description: result.warnings?.length
- ? `Warnings: ${result.warnings.join(', ')}`
+ ? `경고: ${result.warnings.join(', ')}`
: undefined
}
)
- // 성공 후 다이얼로그 닫기 및 상태 초기화
setTimeout(() => {
setIsUploadDialogOpen(false)
resetUpload()
-
- // 테이블 새로고침
window.location.reload()
}, 2000)
} else {
- throw new Error(result.error || 'Unknown error occurred')
+ throw new Error(result.error || '알 수 없는 오류가 발생했습니다')
}
} catch (error) {
- console.error('Error uploading file:', error)
+ console.error('파일 업로드 오류:', error)
toast.error(
error instanceof Error
? error.message
- : 'An error occurred while processing the file'
+ : '파일 처리 중 오류가 발생했습니다'
)
setUploadProgress(null)
} finally {
@@ -193,6 +273,190 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) {
}
}
+ // 배치 처리 시작
+ const startBatchProcessing = async () => {
+ const pendingFiles = fileQueue.filter(item => item.status === 'pending')
+ if (pendingFiles.length === 0) {
+ toast.warning("처리할 파일이 없습니다")
+ return
+ }
+
+ setIsBatchProcessing(true)
+ setIsPaused(false)
+ batchControllerRef.current = new AbortController()
+
+ let processed = 0
+
+ for (const fileItem of pendingFiles) {
+ // 일시정지 체크
+ if (isPaused) {
+ break
+ }
+
+ // 중단 체크
+ if (batchControllerRef.current?.signal.aborted) {
+ break
+ }
+
+ try {
+ // 파일 상태를 processing으로 변경
+ setFileQueue(prev => prev.map(item =>
+ item.id === fileItem.id
+ ? { ...item, status: 'processing' as const }
+ : item
+ ))
+
+ setBatchProgress(prev => ({
+ ...prev,
+ current: fileItem.file.name
+ }))
+
+ // 개별 파일 처리
+ const result = await processSingleFileInBatch(fileItem)
+
+ // 결과에 따라 상태 업데이트
+ if (result.success) {
+ setFileQueue(prev => prev.map(item =>
+ item.id === fileItem.id
+ ? {
+ ...item,
+ status: 'completed' as const,
+ result: {
+ totalTables: result.metadata.totalTables,
+ totalRows: result.metadata.totalRows,
+ sessionId: result.sessionId
+ }
+ }
+ : item
+ ))
+ processed++
+ } else {
+ throw new Error(result.error || '처리가 실패했습니다')
+ }
+
+ } catch (error) {
+ // 실패 상태로 변경
+ setFileQueue(prev => prev.map(item =>
+ item.id === fileItem.id
+ ? {
+ ...item,
+ status: 'failed' as const,
+ error: error instanceof Error ? error.message : '알 수 없는 오류'
+ }
+ : item
+ ))
+
+ setBatchProgress(prev => ({
+ ...prev,
+ failed: prev.failed + 1
+ }))
+ }
+
+ setBatchProgress(prev => ({
+ ...prev,
+ completed: prev.completed + 1
+ }))
+
+ // 다음 파일 처리 전 잠시 대기 (API 부하 방지)
+ await new Promise(resolve => setTimeout(resolve, 1000))
+ }
+
+ setIsBatchProcessing(false)
+ setBatchProgress(prev => ({ ...prev, current: undefined }))
+
+ const completedCount = fileQueue.filter(item => item.status === 'completed').length
+ const failedCount = fileQueue.filter(item => item.status === 'failed').length
+
+ toast.success(
+ `일괄 처리 완료! ${completedCount}개 성공, ${failedCount}개 실패`,
+ { description: "이제 테이블을 새로고침하여 새 데이터를 확인할 수 있습니다" }
+ )
+ }
+
+ // 개별 파일 처리 (배치 내에서)
+ const processSingleFileInBatch = async (fileItem: FileUploadItem) => {
+ const formData = new FormData()
+ formData.append('file', fileItem.file)
+
+ const response = await fetch('/api/ocr/enhanced', {
+ method: 'POST',
+ body: formData,
+ signal: batchControllerRef.current?.signal
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json()
+ throw new Error(errorData.error || 'OCR 처리가 실패했습니다')
+ }
+
+ return await response.json()
+ }
+
+ // 배치 처리 일시정지/재개
+ const toggleBatchPause = () => {
+ setIsPaused(prev => !prev)
+ if (isPaused) {
+ toast.info("일괄 처리가 재개되었습니다")
+ } else {
+ toast.info("현재 파일 처리 후 일시정지됩니다")
+ }
+ }
+
+ // 배치 처리 중단
+ const stopBatchProcessing = () => {
+ batchControllerRef.current?.abort()
+ setIsBatchProcessing(false)
+ setIsPaused(false)
+ setBatchProgress(prev => ({ ...prev, current: undefined }))
+ toast.info("일괄 처리가 중단되었습니다")
+ }
+
+ // 파일 큐에서 제거
+ const removeFileFromQueue = (fileId: string) => {
+ setFileQueue(prev => {
+ const newQueue = prev.filter(item => item.id !== fileId)
+ const removedItem = prev.find(item => item.id === fileId)
+
+ if (removedItem?.status === 'pending') {
+ setBatchProgress(prevProgress => ({
+ ...prevProgress,
+ total: prevProgress.total - 1
+ }))
+ }
+
+ return newQueue
+ })
+ }
+
+ // 실패한 파일들 재시도
+ const retryFailedFiles = () => {
+ setFileQueue(prev => prev.map(item =>
+ item.status === 'failed'
+ ? { ...item, status: 'pending' as const, error: undefined }
+ : item
+ ))
+
+ const failedCount = fileQueue.filter(item => item.status === 'failed').length
+ setBatchProgress(prev => ({
+ ...prev,
+ failed: 0,
+ total: prev.total + failedCount
+ }))
+
+ toast.success(`${failedCount}개의 실패한 파일이 대기열에 다시 추가되었습니다`)
+ }
+
+ // 완료된 파일들 제거
+ const clearCompletedFiles = () => {
+ const completedCount = fileQueue.filter(item => item.status === 'completed').length
+ setFileQueue(prev => prev.filter(item => item.status !== 'completed'))
+ setBatchProgress(prev => ({
+ ...prev,
+ completed: Math.max(0, prev.completed - completedCount)
+ }))
+ toast.success(`${completedCount}개의 완료된 파일이 대기열에서 제거되었습니다`)
+ }
+
const resetUpload = () => {
setSelectedFile(null)
setUploadProgress(null)
@@ -201,15 +465,20 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) {
}
}
- // Cancel 버튼 핸들러
+ const resetBatchUpload = () => {
+ if (isBatchProcessing) {
+ stopBatchProcessing()
+ }
+ setFileQueue([])
+ setBatchProgress({ total: 0, completed: 0, failed: 0 })
+ }
+
const handleCancelClick = () => {
if (isUploading && uploadProgress?.stage !== "complete") {
- // 업로드 진행 중이면 취소 불가능 메시지
- toast.warning("Cannot cancel while processing. Please wait for completion.", {
- description: "OCR processing cannot be interrupted safely."
+ toast.warning("처리 중에는 취소할 수 없습니다. 완료될 때까지 기다려주세요.", {
+ description: "OCR 처리를 안전하게 중단할 수 없습니다."
})
} else {
- // 업로드 중이 아니거나 완료되었으면 다이얼로그 닫기
setIsUploadDialogOpen(false)
resetUpload()
}
@@ -218,7 +487,7 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) {
// 현재 페이지 데이터만 내보내기
const exportCurrentPage = () => {
exportTableToExcel(table, {
- filename: "OCR Result (Current Page)",
+ filename: "OCR 결과 (현재 페이지)",
excludeColumns: ["select", "actions"],
})
}
@@ -234,9 +503,7 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) {
description: "잠시만 기다려주세요."
})
- // 모든 데이터 가져오기
const allData = await getOcrAllRows()
-
toast.dismiss()
if (allData.length === 0) {
@@ -244,68 +511,90 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) {
return
}
- console.log(allData)
-
- // 새로운 단순한 export 함수 사용
- await exportOcrDataToExcel(allData, `OCR Result (All Data - ${allData.length} rows)`)
-
+ await exportOcrDataToExcel(allData, `OCR 결과 (전체 데이터 - ${allData.length}개 행)`)
toast.success(`전체 데이터 ${allData.length}개 행이 성공적으로 내보내졌습니다.`)
} catch (error) {
- console.error('Error exporting all data:', error)
+ console.error('전체 데이터 내보내기 오류:', error)
toast.error('전체 데이터 내보내기 중 오류가 발생했습니다.')
} finally {
setIsExporting(false)
}
}
+ const getStatusBadgeVariant = (status: FileUploadItem['status']) => {
+ switch (status) {
+ case 'pending': return 'secondary'
+ case 'processing': return 'default'
+ case 'completed': return 'default'
+ case 'failed': return 'destructive'
+ default: return 'secondary'
+ }
+ }
+
+ const getStatusIcon = (status: FileUploadItem['status']) => {
+ switch (status) {
+ case 'pending': return <FileText className="size-4" />
+ case 'processing': return <Loader2 className="size-4 animate-spin" />
+ case 'completed': return <FileText className="size-4 text-green-600" />
+ case 'failed': return <FileText className="size-4 text-red-600" />
+ default: return <FileText className="size-4" />
+ }
+ }
+
+ const getStatusText = (status: FileUploadItem['status']) => {
+ switch (status) {
+ case 'pending': return '대기 중'
+ case 'processing': return '처리 중'
+ case 'completed': return '완료'
+ case 'failed': return '실패'
+ default: return '대기 중'
+ }
+ }
+
return (
<div className="flex items-center gap-2">
- {/* OCR 업로드 다이얼로그 */}
+ {/* 단일 파일 OCR 업로드 다이얼로그 */}
<Dialog open={isUploadDialogOpen} onOpenChange={handleDialogOpenChange}>
<DialogTrigger asChild>
<Button variant="samsung" size="sm" className="gap-2">
<Upload className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">Upload OCR</span>
+ <span className="hidden sm:inline">OCR 업로드</span>
</Button>
</DialogTrigger>
<DialogContent
className="sm:max-w-md"
- // 업로드 중에는 ESC 키로도 닫기 방지
onEscapeKeyDown={(e) => {
if (isUploading && uploadProgress?.stage !== "complete") {
e.preventDefault()
- toast.warning("Cannot close while processing. Please wait for completion.")
+ toast.warning("처리 중에는 창을 닫을 수 없습니다. 완료될 때까지 기다려주세요.")
}
}}
- // 업로드 중에는 외부 클릭으로도 닫기 방지
onInteractOutside={(e) => {
if (isUploading && uploadProgress?.stage !== "complete") {
e.preventDefault()
- toast.warning("Cannot close while processing. Please wait for completion.")
+ toast.warning("처리 중에는 창을 닫을 수 없습니다. 완료될 때까지 기다려주세요.")
}
}}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
- Upload Document for OCR
- {/* 업로드 중일 때 로딩 인디케이터 표시 */}
+ OCR용 문서 업로드
{isUploading && uploadProgress?.stage !== "complete" && (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
)}
</DialogTitle>
<DialogDescription>
{isUploading && uploadProgress?.stage !== "complete"
- ? "Processing in progress. Please do not close this dialog."
- : "Upload a PDF or image file to extract table data using OCR technology."
+ ? "처리가 진행 중입니다. 이 창을 닫지 마세요."
+ : "OCR 기술을 사용하여 테이블 데이터를 추출할 PDF 또는 이미지 파일을 업로드하세요."
}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
- {/* 파일 선택 */}
<div className="space-y-2">
- <Label htmlFor="file-upload">Select File</Label>
+ <Label htmlFor="file-upload">파일 선택</Label>
<Input
ref={fileInputRef}
id="file-upload"
@@ -315,11 +604,10 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) {
disabled={isUploading}
/>
<p className="text-xs text-muted-foreground">
- Supported formats: PDF, JPG, PNG, TIFF, BMP (Max 10MB)
+ 지원 형식: PDF, JPG, PNG, TIFF, BMP (최대 10MB)
</p>
</div>
- {/* 선택된 파일 정보 */}
{selectedFile && (
<div className="rounded-lg border p-3 space-y-2">
<div className="flex items-center gap-2">
@@ -327,19 +615,22 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) {
<span className="text-sm font-medium">{selectedFile.name}</span>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
- <span>Size: {(selectedFile.size / 1024 / 1024).toFixed(2)} MB</span>
- <span>Type: {selectedFile.type}</span>
+ <span>크기: {(selectedFile.size / 1024 / 1024).toFixed(2)} MB</span>
+ <span>형식: {selectedFile.type}</span>
</div>
</div>
)}
- {/* 업로드 진행상황 */}
{uploadProgress && (
<div className="space-y-3">
<div className="flex items-center justify-between">
- <span className="text-sm font-medium">Processing...</span>
+ <span className="text-sm font-medium">처리 중...</span>
<Badge variant={uploadProgress.stage === "complete" ? "default" : "secondary"}>
- {uploadProgress.stage}
+ {uploadProgress.stage === "preparing" && "준비 중"}
+ {uploadProgress.stage === "uploading" && "업로드 중"}
+ {uploadProgress.stage === "processing" && "처리 중"}
+ {uploadProgress.stage === "saving" && "저장 중"}
+ {uploadProgress.stage === "complete" && "완료"}
</Badge>
</div>
<Progress value={uploadProgress.progress} className="h-2" />
@@ -347,27 +638,25 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) {
{uploadProgress.message}
</p>
- {/* 진행 중일 때 안내 메시지 */}
{isUploading && uploadProgress.stage !== "complete" && (
<div className="flex items-center gap-2 p-2 bg-blue-50 dark:bg-blue-950/20 rounded-md">
<Loader2 className="size-3 animate-spin text-blue-600" />
<p className="text-xs text-blue-700 dark:text-blue-300">
- Please wait... This dialog will close automatically when complete.
+ 잠시만 기다려주세요... 완료되면 이 창이 자동으로 닫힙니다.
</p>
</div>
)}
</div>
)}
- {/* 액션 버튼들 */}
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={handleCancelClick}
- disabled={false} // 항상 클릭 가능하지만 핸들러에서 처리
+ disabled={false}
>
- {isUploading && uploadProgress?.stage !== "complete" ? "Close" : "Cancel"}
+ {isUploading && uploadProgress?.stage !== "complete" ? "닫기" : "취소"}
</Button>
<Button
size="sm"
@@ -380,14 +669,256 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) {
) : (
<Upload className="size-4" aria-hidden="true" />
)}
- {isUploading ? "Processing..." : "Start OCR"}
+ {isUploading ? "처리 중..." : "OCR 시작"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
- {/* Export 버튼 */}
+ {/* 배치 파일 OCR 업로드 다이얼로그 */}
+ <Dialog open={isBatchDialogOpen} onOpenChange={handleBatchDialogOpenChange}>
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <Upload className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">일괄 업로드</span>
+ </Button>
+ </DialogTrigger>
+ <DialogContent
+ className="sm:max-w-2xl max-h-[80vh] overflow-hidden flex flex-col"
+ onEscapeKeyDown={(e) => {
+ if (isBatchProcessing && !isPaused) {
+ e.preventDefault()
+ toast.warning("일괄 처리 중에는 창을 닫을 수 없습니다. 먼저 일시정지하세요.")
+ }
+ }}
+ onInteractOutside={(e) => {
+ if (isBatchProcessing && !isPaused) {
+ e.preventDefault()
+ toast.warning("일괄 처리 중에는 창을 닫을 수 없습니다. 먼저 일시정지하세요.")
+ }
+ }}
+ >
+ <DialogHeader>
+ <DialogTitle className="flex items-center justify-between">
+ <span className="flex items-center gap-2">
+ 일괄 OCR 업로드
+ {isBatchProcessing && (
+ <Loader2 className="size-4 animate-spin text-muted-foreground" />
+ )}
+ </span>
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <span>전체: {batchProgress.total}</span>
+ <span>완료: {batchProgress.completed}</span>
+ {batchProgress.failed > 0 && (
+ <span className="text-red-600">실패: {batchProgress.failed}</span>
+ )}
+ </div>
+ </DialogTitle>
+ <DialogDescription>
+ {isBatchProcessing && !isPaused
+ ? `파일 처리 중... 현재: ${batchProgress.current || '시작 중...'}`
+ : "여러 파일을 드래그 앤 드롭하거나 선택하여 일괄 처리하세요."
+ }
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="flex-1 overflow-hidden flex flex-col space-y-4">
+ {/* 파일 드롭존 */}
+ {fileQueue.length === 0 && (
+ <Dropzone onDrop={handleFilesSelect} className="border-2 border-dashed border-gray-300 rounded-lg">
+ <DropzoneZone>
+ <DropzoneUploadIcon />
+ <DropzoneTitle>파일을 여기로 드래그하거나 클릭하여 선택</DropzoneTitle>
+ <DropzoneDescription>
+ PDF, JPG, PNG, TIFF, BMP 파일 지원 (각각 최대 10MB)
+ </DropzoneDescription>
+ <DropzoneInput
+ multiple
+ accept=".pdf,.jpg,.jpeg,.png,.tiff,.bmp"
+ onChange={(e) => e.target.files && handleFilesSelect(e.target.files)}
+ />
+ </DropzoneZone>
+ </Dropzone>
+ )}
+
+ {/* 배치 진행 상황 */}
+ {batchProgress.total > 0 && (
+ <div className="space-y-2">
+ <div className="flex items-center justify-between text-sm">
+ <span>진행률: {batchProgress.completed + batchProgress.failed} / {batchProgress.total}</span>
+ <span>{Math.round(((batchProgress.completed + batchProgress.failed) / batchProgress.total) * 100)}%</span>
+ </div>
+ <Progress
+ value={((batchProgress.completed + batchProgress.failed) / batchProgress.total) * 100}
+ className="h-2"
+ />
+ {batchProgress.current && (
+ <p className="text-xs text-muted-foreground">
+ 현재 처리 중: {batchProgress.current}
+ </p>
+ )}
+ </div>
+ )}
+
+ {/* 파일 목록 */}
+ {fileQueue.length > 0 && (
+ <div className="flex-1 overflow-y-auto">
+ <FileList className="h-full overflow-y-auto">
+ <FileListHeader>
+ <div className="flex items-center justify-between">
+ <span>파일 ({fileQueue.length}개)</span>
+ <div className="flex items-center gap-1">
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => {
+ const input = document.createElement('input')
+ input.type = 'file'
+ input.multiple = true
+ input.accept = '.pdf,.jpg,.jpeg,.png,.tiff,.bmp'
+ input.onchange = (e) => {
+ const files = (e.target as HTMLInputElement).files
+ if (files) handleFilesSelect(files)
+ }
+ input.click()
+ }}
+ className="gap-1"
+ >
+ <Upload className="size-3" />
+ 추가
+ </Button>
+ {fileQueue.some(item => item.status === 'failed') && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={retryFailedFiles}
+ className="gap-1"
+ >
+ <RotateCcw className="size-3" />
+ 실패 재시도
+ </Button>
+ )}
+ {fileQueue.some(item => item.status === 'completed') && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={clearCompletedFiles}
+ className="gap-1"
+ >
+ <X className="size-3" />
+ 완료 제거
+ </Button>
+ )}
+ </div>
+ </div>
+ </FileListHeader>
+
+ {fileQueue.map((fileItem) => (
+ <FileListItem key={fileItem.id} className="flex items-center justify-between gap-3">
+ <FileListIcon>
+ {getStatusIcon(fileItem.status)}
+ </FileListIcon>
+ <FileListInfo>
+ <FileListName>{fileItem.file.name}</FileListName>
+ <FileListDescription>
+ <FileListSize>
+ {fileItem.file.size}
+ </FileListSize>
+ {fileItem.result && (
+ <span className="ml-2 text-green-600">
+ {fileItem.result.totalTables}개 테이블, {fileItem.result.totalRows}개 행
+ </span>
+ )}
+ {fileItem.error && (
+ <span className="ml-2 text-red-600 text-xs">
+ 오류: {fileItem.error}
+ </span>
+ )}
+ </FileListDescription>
+ </FileListInfo>
+ <div className="flex items-center gap-2">
+ <Badge variant={getStatusBadgeVariant(fileItem.status)}>
+ {getStatusText(fileItem.status)}
+ </Badge>
+ {fileItem.status !== 'processing' && (
+ <FileListAction
+ onClick={() => removeFileFromQueue(fileItem.id)}
+ disabled={isBatchProcessing && fileItem.status === 'processing'}
+ >
+ <X className="size-4" />
+ </FileListAction>
+ )}
+ </div>
+ </FileListItem>
+ ))}
+ </FileList>
+ </div>
+ )}
+
+ {/* 액션 버튼들 */}
+ <div className="flex justify-between">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ if (isBatchProcessing && !isPaused) {
+ toast.warning("먼저 처리를 일시정지하세요")
+ } else {
+ setIsBatchDialogOpen(false)
+ resetBatchUpload()
+ }
+ }}
+ >
+ {isBatchProcessing && !isPaused ? "닫기" : "취소"}
+ </Button>
+
+ <div className="flex items-center gap-2">
+ {isBatchProcessing && (
+ <>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={toggleBatchPause}
+ className="gap-2"
+ >
+ {isPaused ? (
+ <Play className="size-4" />
+ ) : (
+ <Pause className="size-4" />
+ )}
+ {isPaused ? "재개" : "일시정지"}
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={stopBatchProcessing}
+ className="gap-2"
+ >
+ <X className="size-4" />
+ 중단
+ </Button>
+ </>
+ )}
+ <Button
+ size="sm"
+ onClick={startBatchProcessing}
+ disabled={fileQueue.filter(item => item.status === 'pending').length === 0 || isBatchProcessing}
+ className="gap-2"
+ >
+ {isBatchProcessing ? (
+ <Loader2 className="size-4 animate-spin" />
+ ) : (
+ <Play className="size-4" />
+ )}
+ {isBatchProcessing ? "처리 중..." : "일괄 시작"}
+ </Button>
+ </div>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+
{/* Export 드롭다운 메뉴 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -403,7 +934,7 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) {
<Download className="size-4" aria-hidden="true" />
)}
<span className="hidden sm:inline">
- {isExporting ? "Exporting..." : "Export"}
+ {isExporting ? "내보내는 중..." : "내보내기"}
</span>
<ChevronDown className="size-3" aria-hidden="true" />
</Button>