From 7a1524ba54f43d0f2a19e4bca2c6a2e0b01c5ef1 Mon Sep 17 00:00:00 2001
From: dujinkim
Date: Tue, 17 Jun 2025 09:02:32 +0000
Subject: (대표님) 20250617 18시 작업사항
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
lib/b-rfq/attachment/request-revision-dialog.tsx | 205 +++
lib/b-rfq/attachment/vendor-responses-panel.tsx | 229 ++-
lib/b-rfq/initial/add-initial-rfq-dialog.tsx | 326 ++--
lib/b-rfq/initial/delete-initial-rfq-dialog.tsx | 149 ++
lib/b-rfq/initial/initial-rfq-detail-columns.tsx | 358 +++--
lib/b-rfq/initial/initial-rfq-detail-table.tsx | 74 +-
.../initial/initial-rfq-detail-toolbar-actions.tsx | 301 ++--
lib/b-rfq/initial/update-initial-rfq-sheet.tsx | 496 +++++++
lib/b-rfq/service.ts | 1558 ++++++++++++++++++--
lib/b-rfq/validations.ts | 139 ++
lib/b-rfq/vendor-response/comment-edit-dialog.tsx | 187 +++
.../vendor-response/response-detail-columns.tsx | 653 ++++++++
.../vendor-response/response-detail-sheet.tsx | 358 +++++
.../vendor-response/response-detail-table.tsx | 161 ++
.../vendor-response/upload-response-dialog.tsx | 325 ++++
.../vendor-responses-table-columns.tsx | 351 +++++
.../vendor-response/vendor-responses-table.tsx | 160 ++
.../vendor-response/waive-response-dialog.tsx | 210 +++
lib/mail/templates/initial-rfq-invitation.hbs | 165 +++
.../tech-sales-quotation-submitted-manager-ko.hbs | 18 +-
.../tech-sales-quotation-submitted-vendor-ko.hbs | 18 +-
lib/mail/templates/tech-sales-rfq-invite-ko.hbs | 35 +-
.../table/vendor-quotations-table-columns.tsx | 2 +-
lib/techsales-rfq/service.ts | 773 ++--------
.../table/detail-table/rfq-detail-column.tsx | 2 +-
lib/welding/table/ocr-table-toolbar-actions.tsx | 667 ++++++++-
26 files changed, 6654 insertions(+), 1266 deletions(-)
create mode 100644 lib/b-rfq/attachment/request-revision-dialog.tsx
create mode 100644 lib/b-rfq/initial/delete-initial-rfq-dialog.tsx
create mode 100644 lib/b-rfq/initial/update-initial-rfq-sheet.tsx
create mode 100644 lib/b-rfq/vendor-response/comment-edit-dialog.tsx
create mode 100644 lib/b-rfq/vendor-response/response-detail-columns.tsx
create mode 100644 lib/b-rfq/vendor-response/response-detail-sheet.tsx
create mode 100644 lib/b-rfq/vendor-response/response-detail-table.tsx
create mode 100644 lib/b-rfq/vendor-response/upload-response-dialog.tsx
create mode 100644 lib/b-rfq/vendor-response/vendor-responses-table-columns.tsx
create mode 100644 lib/b-rfq/vendor-response/vendor-responses-table.tsx
create mode 100644 lib/b-rfq/vendor-response/waive-response-dialog.tsx
create mode 100644 lib/mail/templates/initial-rfq-invitation.hbs
(limited to 'lib')
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;
+
+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({
+ 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 (
+
+ );
+}
\ 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 (
+
+ 업로드된 파일이 없습니다.
+
+ );
+ }
+
+ return (
+
+ {files.map((file, index) => (
+
+
+
+
+
+ {file.originalFileName}
+
+
+ {formatFileSize(file.fileSize)} • {formatDate(file.uploadedAt)}
+
+ {file.description && (
+
+ {file.description}
+
+ )}
+
+
+
+
+ ))}
+
+ );
+}
+
export function VendorResponsesPanel({
attachment,
responses,
isLoading,
onRefresh
}: VendorResponsesPanelProps) {
+
+ console.log(responses)
const getStatusIcon = (status: string) => {
switch (status) {
@@ -114,7 +212,8 @@ export function VendorResponsesPanel({
리비전
요청일
응답일
- 벤더 코멘트
+ 응답 파일
+ 코멘트
액션
@@ -161,37 +260,119 @@ export function VendorResponsesPanel({
{response.respondedAt ? formatDate(response.respondedAt) : '-'}
-
+
+ {/* 응답 파일 컬럼 */}
- {response.vendorComment ? (
-
- {response.vendorComment}
+ {response.totalFiles > 0 ? (
+
+
+ {response.totalFiles}개
+
+ {response.totalFiles === 1 ? (
+ // 파일이 1개면 바로 다운로드
+
+ ) : (
+ // 파일이 여러 개면 Popover로 목록 표시
+
+
+
+
+
+
+
+ 응답 파일 목록 ({response.totalFiles}개)
+
+
+
+
+
+ )}
) : (
- '-'
+
-
)}
+
+
+ {/* 벤더 응답 코멘트 */}
+ {response.responseComment && (
+
+
+
+ {response.responseComment}
+
+
+ )}
+
+ {/* 수정 요청 사유 */}
+ {response.revisionRequestComment && (
+
+
+
+ {response.revisionRequestComment}
+
+
+ )}
+
+ {!response.responseComment && !response.revisionRequestComment && (
+
-
+ )}
+
+
+
+ {/* 액션 컬럼 - 수정 요청 기능으로 변경 */}
{response.responseStatus === 'RESPONDED' && (
-
+
+
+ 수정요청
+
+ }
+ />
+ )}
+
+ {response.responseStatus === 'REVISION_REQUESTED' && (
+
+ 수정 요청됨
+
+ )}
+
+ {(response.responseStatus === 'NOT_RESPONDED' || response.responseStatus === 'WAIVED') && (
+ -
)}
-
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
+export type AddInitialRfqFormData = z.infer
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([])
@@ -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({
- resolver: zodResolver(addInitialRfqSchema),
- defaultValues: {
+ // 기본값 설정 (선택된 항목이 있으면 해당 값 사용, 없으면 일반 기본값)
+ const getDefaultFormValues = React.useCallback((): Partial => {
+ 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({
+ 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 (
- {vendor.vendorCode} • {vendor.country}
+ {vendor.vendorCode} • {vendor.country} • {vendor.taxId}
- (
-
- 견적 마감일
-
-
-
-
-
-
-
-
- date < new Date() || date < new Date("1900-01-01")
- }
- initialFocus
- />
-
-
-
-
- )}
- />
- (
-
- 견적 유효일
-
-
-
-
-
-
-
-
- date < new Date() || date < new Date("1900-01-01")
- }
- initialFocus
- />
-
-
-
-
- )}
- />
+ (
+
+ 견적 마감일 *
+
+
+
+
+
+
+
+
+ date < new Date() || date < new Date("1900-01-01")
+ }
+ initialFocus
+ />
+
+
+
+
+ )}
+ />
+
+ (
+
+ 견적 유효일
+
+
+
+
+
+
+
+
+ date < new Date() || date < new Date("1900-01-01")
+ }
+ initialFocus
+ />
+
+
+
+
+ )}
+ />
- {/* Incoterms 및 GTC */}
-
+ {/* Incoterms 선택 */}
(
- Incoterms *
+ Incoterms
@@ -391,9 +437,8 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro
>
{selectedIncoterm ? (
-
- {selectedIncoterm.code} ({selectedIncoterm.description})
+ {selectedIncoterm.code} - {selectedIncoterm.description}
) : (
@@ -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)
}}
>
+
- {incoterm.code} {incoterm.description}
+ {incoterm.code} - {incoterm.description}
+
@@ -445,34 +492,41 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro
)}
/>
-
- {/* GTC 정보 */}
+ {/* 옵션 체크박스 */}
-
-
- {{#if series}}
+
+ {{#if items}}
- * 시리즈별 K/L 일정 (Keel Laying Quarter)
- {{#each series}}
-
- {{sersNo}}호선: {{klQuarter}}
+ 3) 자재명
+ {{#each items}}
+
* {{itemList}} ({{itemCode}})
{{/each}}
+ {{#if rfq.materialCode}}
+
* 자재그룹 코드 : {{rfq.materialCode}}
+ {{/if}}
- {{/if}}
+ {{else}}
3) 자재명 : {{rfq.title}}
{{#if rfq.materialCode}}
* 자재그룹 코드 : {{rfq.materialCode}}
{{/if}}
+ {{/if}}
4) 제출 벤더
* 벤더명 : {{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}}
* 선급 : {{project.className}}
{{/if}}
- {{#if project.shipModelName}}
-
* 선형 : {{project.shipModelName}}
- {{/if}}
- {{#if series}}
+
+ {{#if items}}
- * 시리즈별 K/L 일정 (Keel Laying Quarter)
- {{#each series}}
-
- {{sersNo}}호선: {{klQuarter}}
+ 3) 자재명
+ {{#each items}}
+
* {{itemList}} ({{itemCode}})
{{/each}}
+ {{#if rfq.materialCode}}
+
* 자재그룹 코드 : {{rfq.materialCode}}
+ {{/if}}
- {{/if}}
+ {{else}}
3) 자재명 : {{rfq.title}}
{{#if rfq.materialCode}}
* 자재그룹 코드 : {{rfq.materialCode}}
{{/if}}
+ {{/if}}
4) 견적 금액 : {{quotation.currency}} {{quotation.totalPrice}}
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 @@
* 척수 : {{project.shipCount}}척
{{/if}}
{{#if project.ownerName}}
-
* 선주 : {{project.ownerName}} ({{project.ownerCode}})
+
* 선주 : {{project.ownerName}}
{{/if}}
{{#if project.className}}
-
* 선급 : {{project.className}} ({{project.classCode}})
- {{/if}}
- {{#if project.shipModelName}}
-
* 선형 : {{project.shipModelName}} ({{project.shipModelCode}})
- {{/if}}
- {{#if project.shipModelSize}}
-
* 선형크기 : {{project.shipModelSize}} {{project.shipModelUnit}}
- {{/if}}
- {{#if project.projectManager}}
-
* 견적대표PM : {{project.projectManager}}
- {{/if}}
- {{#if project.estimateStatus}}
-
* 견적상태 : {{project.estimateStatus}}
+
* 선급 : {{project.className}}
{{/if}}
- {{#if series}}
+
+
+ {{#if items}}
- * 시리즈별 K/L 일정 (Keel Laying Quarter)
- {{#each series}}
-
- {{sersNo}}호선: {{klQuarter}}
+ 2) 자재명
+ {{#each items}}
+
* {{itemList}} ({{itemCode}})
+ {{#if workType}}
- 작업유형: {{workType}}{{/if}}
+ {{#if shipType}}
- 선종: {{shipType}}{{/if}}
{{/each}}
+ {{#if rfq.materialCode}}
+
* 자재그룹 코드 : {{rfq.materialCode}}
+ {{/if}}
- {{/if}}
-
+ {{else}}
2) 자재명 : {{rfq.title}}
{{#if rfq.materialCode}}
@@ -69,6 +63,7 @@
* 선종 : {{project.shipType}}
{{/if}}
+ {{/if}}
3) Spec, & Scope of Supply : 첨부 사양서 참조
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({
router.push(`/partners/rfq-all/${id}`)}
+ onClick={() => router.push(`/partners/rfq-ship/${id}`)}
className="h-8 w-8"
>
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 {
@@ -3287,6 +2746,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 기반)
*/
@@ -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(null)
const [isExporting, setIsExporting] = React.useState(false)
- // 다이얼로그 닫기 핸들러 - 업로드 중에는 닫기 방지
+ // 멀티 파일 업로드 관련 상태
+ const [isBatchDialogOpen, setIsBatchDialogOpen] = React.useState(false)
+ const [fileQueue, setFileQueue] = React.useState([])
+ const [isBatchProcessing, setIsBatchProcessing] = React.useState(false)
+ const [batchProgress, setBatchProgress] = React.useState({ total: 0, completed: 0, failed: 0 })
+ const [isPaused, setIsPaused] = React.useState(false)
+ const batchControllerRef = React.useRef(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) => {
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
+ case 'processing': return
+ case 'completed': return
+ case 'failed': return
+ default: return
+ }
+ }
+
+ const getStatusText = (status: FileUploadItem['status']) => {
+ switch (status) {
+ case 'pending': return '대기 중'
+ case 'processing': return '처리 중'
+ case 'completed': return '완료'
+ case 'failed': return '실패'
+ default: return '대기 중'
+ }
+ }
+
return (
- {/* OCR 업로드 다이얼로그 */}
+ {/* 단일 파일 OCR 업로드 다이얼로그 */}
- {/* Export 버튼 */}
+ {/* 배치 파일 OCR 업로드 다이얼로그 */}
+
+
{/* Export 드롭다운 메뉴 */}
@@ -403,7 +934,7 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) {
)}
- {isExporting ? "Exporting..." : "Export"}
+ {isExporting ? "내보내는 중..." : "내보내기"}
--
cgit v1.2.3