summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-10-01 06:26:44 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-10-01 06:26:44 +0000
commitd689608ca2a54cab2cd12a12f0b6007a1be39ab2 (patch)
treebb93e630c18b3028322f7f7aee87547e893f5df7
parent7021eca8f53e398f55f775c6dc431bca9670fabe (diff)
(대표님, 최겸) 구매 견적 첨부파일 type 오류 수정, 문서확정, short list 기능 수정
-rw-r--r--app/api/partners/rfq-last/[id]/response/route.ts104
-rw-r--r--db/schema/rfqVendor.ts2
-rw-r--r--lib/rfq-last/attachment/vendor-response-table.tsx296
-rw-r--r--lib/rfq-last/service.ts188
-rw-r--r--lib/rfq-last/vendor-response/editor/attachments-upload.tsx32
-rw-r--r--lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx38
-rw-r--r--lib/rfq-last/vendor/rfq-vendor-table.tsx46
-rw-r--r--lib/rfq-last/vendor/send-rfq-dialog.tsx8
-rw-r--r--lib/tbe-last/service.ts8
9 files changed, 528 insertions, 194 deletions
diff --git a/app/api/partners/rfq-last/[id]/response/route.ts b/app/api/partners/rfq-last/[id]/response/route.ts
index 21a4e7a4..bfbeb246 100644
--- a/app/api/partners/rfq-last/[id]/response/route.ts
+++ b/app/api/partners/rfq-last/[id]/response/route.ts
@@ -9,7 +9,7 @@ import {
rfqLastVendorAttachments,
rfqLastVendorResponseHistory
} from "@/db/schema"
-import { eq, and } from "drizzle-orm"
+import { eq, and, inArray } from "drizzle-orm"
import { writeFile, mkdir } from "fs/promises"
import { createWriteStream } from "fs"
import { pipeline } from "stream/promises"
@@ -48,15 +48,15 @@ export async function POST(
const data = JSON.parse(formData.get('data') as string)
const files = formData.getAll('attachments') as File[]
- // 업로드 디렉토리 생성
+ // 업로드 디렉토리 생성 (벤더 응답용)
const isDev = process.env.NODE_ENV === 'development'
const uploadDir = isDev
? path.join(process.cwd(), 'public', 'uploads', 'rfq', rfqId.toString())
: path.join(process.env.NAS_PATH || '/nas', 'uploads', 'rfq', rfqId.toString())
await mkdir(uploadDir, { recursive: true })
-
- // 트랜잭션 시작 (DB 작업만)
+
+ // 트랜잭션 시작
const result = await db.transaction(async (tx) => {
// 1. 벤더 응답 생성
const [vendorResponse] = await tx.insert(rfqLastVendorResponses).values({
@@ -135,31 +135,39 @@ export async function POST(
itemRemark: item.itemRemark,
deviationReason: item.deviationReason,
}))
-
+
await tx.insert(rfqLastVendorQuotationItems).values(quotationItemsData)
}
-
- // 3. 이력 기록
+
+ // 이력 기록
await tx.insert(rfqLastVendorResponseHistory).values({
- vendorResponseId: vendorResponse.id,
- action: "생성",
- previousStatus: null,
+ vendorResponseId: vendorResponseId,
+ action: isNewResponse ? "생성" : (data.status === "제출완료" ? "제출" : "수정"),
+ previousStatus: existingResponse?.status || null,
newStatus: data.status || "작성중",
changeDetails: data,
performedBy: session.user.id,
})
-
- return vendorResponse
+
+ return { id: vendorResponseId, isNew: isNewResponse }
})
- // 4. 파일 저장 (트랜잭션 밖에서 처리)
+ // 파일 저장 (트랜잭션 밖에서 처리)
const fileRecords = []
-
+
if (files.length > 0) {
+ console.log(`저장할 파일 수: ${files.length}`)
+ console.log('파일 메타데이터:', data.fileMetadata)
+
for (let i = 0; i < files.length; i++) {
const file = files[i]
- const metadata = data.fileMetadata?.[i] // 인덱스로 메타데이터 매칭
-
+ // 파일 메타데이터에서 attachmentType 정보 가져옴
+ const metadata = data.fileMetadata && data.fileMetadata[i]
+ const attachmentType = metadata?.attachmentType || "기타"
+ const description = metadata?.description || ""
+
+ console.log(`파일 ${i + 1} 처리: ${file.name}, 파일 객체 타입: ${attachmentType}`)
+
try {
const filename = `${uuidv4()}_${file.name.replace(/[^a-zA-Z0-9.-]/g, '_')}`
const filepath = path.join(uploadDir, filename)
@@ -171,24 +179,28 @@ export async function POST(
const buffer = Buffer.from(await file.arrayBuffer())
await writeFile(filepath, buffer)
}
-
+
fileRecords.push({
vendorResponseId: result.id,
- attachmentType: metadata?.attachmentType || "기타", // 메타데이터에서 가져옴
+ attachmentType: attachmentType, // 파일 객체에서 직접 가져옴
fileName: filename,
originalFileName: file.name,
filePath: `/uploads/rfq/${rfqId}/${filename}`,
fileSize: file.size,
- fileType: file.type,
- description: metadata?.description || "", // 메타데이터에서 가져옴
+ fileType: file.type || path.extname(file.name).slice(1),
+ description: description,
uploadedBy: session.user.id,
})
+
+ console.log(`파일 저장 완료: ${filename}, 타입: ${attachmentType}`)
} catch (fileError) {
- console.error(`Failed to save file ${file.name}:`, fileError)
+ console.error(`파일 저장 실패 ${file.name}:`, fileError)
}
}
+
// DB에 파일 정보 저장
if (fileRecords.length > 0) {
+ console.log('DB에 저장할 파일 레코드:', fileRecords)
await db.insert(rfqLastVendorAttachments).values(fileRecords)
}
}
@@ -224,14 +236,14 @@ export async function PUT(
const data = JSON.parse(formData.get('data') as string)
const files = formData.getAll('attachments') as File[]
- // 업로드 디렉토리 생성
+ // 업로드 디렉토리 생성 (벤더 응답용)
const isDev = process.env.NODE_ENV === 'development'
- const uploadDir = isDev
- ? path.join(process.cwd(), 'public', 'uploads', 'rfq', rfqId.toString())
- : path.join(process.env.NAS_PATH || '/nas', 'uploads', 'rfq', rfqId.toString())
-
+ const uploadDir = isDev
+ ? path.join(process.cwd(), 'public', 'uploads', 'rfq-last-vendor-responses')
+ : path.join(process.env.NAS_PATH || '/nas', 'uploads', 'rfq-last-vendor-responses')
+
await mkdir(uploadDir, { recursive: true })
-
+
// 트랜잭션 시작
const result = await db.transaction(async (tx) => {
// 1. 기존 응답 찾기
@@ -333,11 +345,22 @@ export async function PUT(
return { id: responseId }
})
- // 5. 새 첨부파일 추가 (트랜잭션 밖에서)
+ // 파일 저장 (트랜잭션 밖에서)
const fileRecords = []
-
+
if (files.length > 0) {
- for (const file of files) {
+ console.log(`업데이트 저장할 파일 수: ${files.length}`)
+ console.log('파일 메타데이터:', data.fileMetadata)
+
+ for (let i = 0; i < files.length; i++) {
+ const file = files[i]
+ // 파일 메타데이터에서 attachmentType 정보 가져옴
+ const metadata = data.fileMetadata && data.fileMetadata[i]
+ const attachmentType = metadata?.attachmentType || "기타"
+ const description = metadata?.description || ""
+
+ console.log(`업데이트 파일 ${i + 1} 처리: ${file.name}, 파일 객체 타입: ${attachmentType}`)
+
try {
const filename = `${uuidv4()}_${file.name.replace(/[^a-zA-Z0-9.-]/g, '_')}`
const filepath = path.join(uploadDir, filename)
@@ -349,35 +372,38 @@ export async function PUT(
const buffer = Buffer.from(await file.arrayBuffer())
await writeFile(filepath, buffer)
}
-
+
fileRecords.push({
vendorResponseId: result.id,
- attachmentType: (file as any).attachmentType || "기타",
+ attachmentType: attachmentType, // 파일 객체에서 직접 가져옴
fileName: filename,
originalFileName: file.name,
filePath: `/uploads/rfq/${rfqId}/${filename}`,
fileSize: file.size,
- fileType: file.type,
- description: (file as any).description,
+ fileType: file.type || path.extname(file.name).slice(1),
+ description: description,
uploadedBy: session.user.id,
})
+
+ console.log(`업데이트 파일 저장 완료: ${filename}, 타입: ${attachmentType}`)
} catch (fileError) {
- console.error(`Failed to save file ${file.name}:`, fileError)
+ console.error(`업데이트 파일 저장 실패 ${file.name}:`, fileError)
}
}
-
+
if (fileRecords.length > 0) {
+ console.log('업데이트 DB에 저장할 파일 레코드:', fileRecords)
await db.insert(rfqLastVendorAttachments).values(fileRecords)
}
}
- return NextResponse.json({
- success: true,
+ return NextResponse.json({
+ success: true,
data: result,
message: data.status === "제출완료" ? "견적서가 성공적으로 제출되었습니다." : "견적서가 수정되었습니다.",
filesUploaded: fileRecords.length
})
-
+
} catch (error) {
console.error("Error updating vendor response:", error)
return NextResponse.json(
diff --git a/db/schema/rfqVendor.ts b/db/schema/rfqVendor.ts
index 0ddf109b..dea196b1 100644
--- a/db/schema/rfqVendor.ts
+++ b/db/schema/rfqVendor.ts
@@ -29,6 +29,8 @@ export const rfqLastVendorResponses = pgTable(
responseVersion: integer("response_version").notNull().default(1),
isLatest: boolean("is_latest").notNull().default(true),
+ isDocumentConfirmed: boolean("is_document_confirmed").default(false),
+
// 참여 여부 관련 필드 (새로 추가)
participationStatus: varchar("participation_status", { length: 20 })
diff --git a/lib/rfq-last/attachment/vendor-response-table.tsx b/lib/rfq-last/attachment/vendor-response-table.tsx
index 076fb153..47a23d18 100644
--- a/lib/rfq-last/attachment/vendor-response-table.tsx
+++ b/lib/rfq-last/attachment/vendor-response-table.tsx
@@ -17,7 +17,9 @@ import {
FileCode,
Building2,
Calendar,
- AlertCircle, X
+ AlertCircle,
+ X,
+ CheckCircle2
} from "lucide-react";
import { format, formatDistanceToNow, isValid, isBefore, isAfter } from "date-fns";
import { ko } from "date-fns/locale";
@@ -43,7 +45,7 @@ import type {
DataTableRowAction,
} from "@/types/table";
import { cn } from "@/lib/utils";
-import { getRfqVendorAttachments, updateAttachmentTypes } from "@/lib/rfq-last/service";
+import { confirmVendorDocuments, getRfqVendorAttachments, updateAttachmentTypes } from "@/lib/rfq-last/service";
import { downloadFile } from "@/lib/file-download";
import { toast } from "sonner";
import {
@@ -61,7 +63,16 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
-
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
// 타입 정의
interface VendorAttachment {
@@ -153,53 +164,81 @@ export function VendorResponseTable({
const [data, setData] = React.useState<VendorAttachment[]>(initialData);
const [isRefreshing, setIsRefreshing] = React.useState(false);
const [selectedRows, setSelectedRows] = React.useState<VendorAttachment[]>([]);
-
-
-
const [isUpdating, setIsUpdating] = React.useState(false);
const [showTypeDialog, setShowTypeDialog] = React.useState(false);
const [selectedType, setSelectedType] = React.useState<"구매" | "설계" | "">("");
-
const [selectedVendor, setSelectedVendor] = React.useState<string | null>(null);
+ const [showConfirmDialog, setShowConfirmDialog] = React.useState(false);
+ const [isConfirming, setIsConfirming] = React.useState(false);
+ const [confirmedVendors, setConfirmedVendors] = React.useState<Set<number>>(new Set());
const filteredData = React.useMemo(() => {
if (!selectedVendor) return data;
return data.filter(item => item.vendorName === selectedVendor);
}, [data, selectedVendor]);
+ // 현재 선택된 벤더의 ID 가져오기
+ const selectedVendorId = React.useMemo(() => {
+ if (!selectedVendor) return null;
+ const vendorItem = data.find(item => item.vendorName === selectedVendor);
+ return vendorItem?.vendorId || null;
+ }, [selectedVendor, data]);
-
- // 데이터 새로고침
- const handleRefresh = React.useCallback(async () => {
- setIsRefreshing(true);
- try {
- const result = await getRfqVendorAttachments(rfqId);
- if (result.vendorSuccess && result.vendorData) {
- setData(result.vendorData);
- toast.success("데이터를 새로고침했습니다.");
- } else {
- toast.error("데이터를 불러오는데 실패했습니다.");
- }
- } catch (error) {
- console.error("Refresh error:", error);
- toast.error("새로고침 중 오류가 발생했습니다.");
- } finally {
- setIsRefreshing(false);
+ // 데이터 새로고침
+ const handleRefresh = React.useCallback(async () => {
+ setIsRefreshing(true);
+ try {
+ const result = await getRfqVendorAttachments(rfqId);
+ if (result.vendorSuccess && result.vendorData) {
+ setData(result.vendorData);
+ toast.success("데이터를 새로고침했습니다.");
+ } else {
+ toast.error("데이터를 불러오는데 실패했습니다.");
}
- }, [rfqId]);
+ } catch (error) {
+ console.error("Refresh error:", error);
+ toast.error("새로고침 중 오류가 발생했습니다.");
+ } finally {
+ setIsRefreshing(false);
+ }
+ }, [rfqId]);
+
+ const toggleVendorFilter = (vendor: string) => {
+ if (selectedVendor === vendor) {
+ setSelectedVendor(null); // 이미 선택된 벤더를 다시 클릭하면 필터 해제
+ } else {
+ setSelectedVendor(vendor);
+ // 필터 변경 시 선택 초기화 (옵션)
+ setSelectedRows([]);
+ }
+ };
+
+ // 문서 확정 처리
+ const handleConfirmDocuments = React.useCallback(async () => {
+ if (!selectedVendorId || !selectedVendor) return;
- const toggleVendorFilter = (vendor: string) => {
- if (selectedVendor === vendor) {
- setSelectedVendor(null); // 이미 선택된 벤더를 다시 클릭하면 필터 해제
+ setIsConfirming(true);
+ try {
+ const result = await confirmVendorDocuments(rfqId, selectedVendorId);
+
+ if (result.success) {
+ toast.success(result.message);
+ setConfirmedVendors(prev => new Set(prev).add(selectedVendorId));
+ setShowConfirmDialog(false);
+ // 데이터 새로고침
+ await handleRefresh();
} else {
- setSelectedVendor(vendor);
- // 필터 변경 시 선택 초기화 (옵션)
- setSelectedRows([]);
+ toast.error(result.message);
}
- };
+ } catch (error) {
+ toast.error("문서 확정 중 오류가 발생했습니다.");
+ } finally {
+ setIsConfirming(false);
+ }
+ }, [selectedVendorId, selectedVendor, rfqId, handleRefresh]);
- // 문서 유형 일괄 변경
- const handleBulkTypeChange = React.useCallback(async () => {
+ // 문서 유형 일괄 변경
+ const handleBulkTypeChange = React.useCallback(async () => {
if (!selectedType || selectedRows.length === 0) return;
setIsUpdating(true);
@@ -225,8 +264,6 @@ export function VendorResponseTable({
}
}, [selectedType, selectedRows, handleRefresh]);
-
-
// 액션 처리
const handleAction = React.useCallback(async (action: DataTableRowAction<VendorAttachment>) => {
const attachment = action.row.original;
@@ -352,56 +389,6 @@ export function VendorResponseTable({
},
size: 300,
},
- // {
- // accessorKey: "description",
- // header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="설명" />,
- // cell: ({ row }) => (
- // <div className="max-w-[200px] truncate" title={row.original.description || ""}>
- // {row.original.description || "-"}
- // </div>
- // ),
- // size: 200,
- // },
- // {
- // accessorKey: "validTo",
- // header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="유효기간" />,
- // cell: ({ row }) => {
- // const { validFrom, validTo } = row.original;
- // const validity = checkValidity(validTo);
-
- // if (!validTo) return <span className="text-muted-foreground">-</span>;
-
- // return (
- // <TooltipProvider>
- // <Tooltip>
- // <TooltipTrigger asChild>
- // <div className="flex items-center gap-2">
- // {validity === "expired" && (
- // <AlertCircle className="h-4 w-4 text-red-500" />
- // )}
- // {validity === "expiring-soon" && (
- // <AlertCircle className="h-4 w-4 text-yellow-500" />
- // )}
- // <span className={cn(
- // "text-sm",
- // validity === "expired" && "text-red-500",
- // validity === "expiring-soon" && "text-yellow-500"
- // )}>
- // {format(new Date(validTo), "yyyy-MM-dd")}
- // </span>
- // </div>
- // </TooltipTrigger>
- // <TooltipContent>
- // <p>유효기간: {validFrom ? format(new Date(validFrom), "yyyy-MM-dd") : "?"} ~ {format(new Date(validTo), "yyyy-MM-dd")}</p>
- // {validity === "expired" && <p className="text-red-500">만료됨</p>}
- // {validity === "expiring-soon" && <p className="text-yellow-500">곧 만료 예정</p>}
- // </TooltipContent>
- // </Tooltip>
- // </TooltipProvider>
- // );
- // },
- // size: 120,
- // },
{
accessorKey: "responseStatus",
header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="응답 상태" />,
@@ -496,11 +483,6 @@ export function VendorResponseTable({
options: [
{ label: "구매", value: "구매" },
{ label: "설계", value: "설계" },
- // { label: "인증서", value: "인증서" },
- // { label: "카탈로그", value: "카탈로그" },
- // { label: "도면", value: "도면" },
- // { label: "테스트성적서", value: "테스트성적서" },
- // { label: "기타", value: "기타" },
]
},
{ id: "documentNo", label: "문서번호", type: "text" },
@@ -518,8 +500,6 @@ export function VendorResponseTable({
{ label: "취소", value: "취소" },
]
},
- // { id: "validFrom", label: "유효시작일", type: "date" },
- // { id: "validTo", label: "유효종료일", type: "date" },
{ id: "uploadedAt", label: "업로드일", type: "date" },
];
@@ -579,17 +559,42 @@ export function VendorResponseTable({
<span className="text-sm font-medium text-muted-foreground">
벤더별 필터
</span>
- {selectedVendor && (
- <Button
- variant="ghost"
- size="sm"
- onClick={() => setSelectedVendor(null)}
- className="h-7 px-2 text-xs"
- >
- <X className="h-3 w-3 mr-1" />
- 필터 초기화
- </Button>
- )}
+ <div className="flex items-center gap-2">
+ {/* 선택된 벤더의 문서 확정 버튼 */}
+ {selectedVendor && selectedVendorId && (
+ <Button
+ variant="default"
+ size="sm"
+ onClick={() => setShowConfirmDialog(true)}
+ className="h-7"
+ disabled={confirmedVendors.has(selectedVendorId)}
+ >
+ {confirmedVendors.has(selectedVendorId) ? (
+ <>
+ <CheckCircle2 className="h-3 w-3 mr-1" />
+ 확정완료
+ </>
+ ) : (
+ <>
+ <CheckCircle2 className="h-3 w-3 mr-1" />
+ {selectedVendor} 문서 확정
+ </>
+ )}
+ </Button>
+ )}
+
+ {selectedVendor && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => setSelectedVendor(null)}
+ className="h-7 px-2 text-xs"
+ >
+ <X className="h-3 w-3 mr-1" />
+ 필터 초기화
+ </Button>
+ )}
+ </div>
</div>
{/* 벤더 버튼들 */}
@@ -607,20 +612,29 @@ export function VendorResponseTable({
</Button>
{/* 각 벤더별 버튼 */}
- {Array.from(vendorCounts.entries()).map(([vendor, count]) => (
- <Button
- key={vendor}
- variant={selectedVendor === vendor ? "default" : "outline"}
- size="sm"
- onClick={() => toggleVendorFilter(vendor)}
- className="h-7"
- >
- <Building2 className="h-3 w-3 mr-1" />
- <span className="text-xs">
- {vendor} ({count})
- </span>
- </Button>
- ))}
+ {Array.from(vendorCounts.entries()).map(([vendor, count]) => {
+ const vendorItem = data.find(item => item.vendorName === vendor);
+ const vendorId = vendorItem?.vendorId;
+ const isConfirmed = vendorId ? confirmedVendors.has(vendorId) : false;
+
+ return (
+ <Button
+ key={vendor}
+ variant={selectedVendor === vendor ? "default" : "outline"}
+ size="sm"
+ onClick={() => toggleVendorFilter(vendor)}
+ className="h-7 relative"
+ >
+ {isConfirmed && (
+ <CheckCircle2 className="h-3 w-3 mr-1 text-green-600" />
+ )}
+ <Building2 className="h-3 w-3 mr-1" />
+ <span className="text-xs">
+ {vendor} ({count})
+ </span>
+ </Button>
+ );
+ })}
</div>
{/* 현재 필터 상태 표시 */}
@@ -636,7 +650,7 @@ export function VendorResponseTable({
<ClientDataTable
columns={columns}
- data={filteredData} // 필터링된 데이터 사용
+ data={filteredData}
advancedFilterFields={advancedFilterFields}
autoSizeColumns={true}
compact={true}
@@ -650,8 +664,8 @@ export function VendorResponseTable({
{additionalActions}
</ClientDataTable>
- {/* 문서 유형 변경 다이얼로그 */}
- <Dialog open={showTypeDialog} onOpenChange={setShowTypeDialog}>
+ {/* 문서 유형 변경 다이얼로그 */}
+ <Dialog open={showTypeDialog} onOpenChange={setShowTypeDialog}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>문서 유형 변경</DialogTitle>
@@ -724,6 +738,46 @@ export function VendorResponseTable({
</DialogFooter>
</DialogContent>
</Dialog>
+
+ {/* 문서 확정 확인 다이얼로그 */}
+ <AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>문서 확정 확인</AlertDialogTitle>
+ <AlertDialogDescription className="space-y-2">
+ <p>
+ <span className="font-semibold">{selectedVendor}</span> 벤더의 모든 문서를 확정하시겠습니까?
+ </p>
+ <p className="text-sm text-muted-foreground">
+ 이 작업은 해당 벤더의 모든 응답 문서를 확정 처리합니다.
+ </p>
+ <p className="text-sm text-yellow-600">
+ ⚠️ 확정 후에는 되돌릴 수 없습니다.
+ </p>
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel disabled={isConfirming}>취소</AlertDialogCancel>
+ <AlertDialogAction
+ onClick={handleConfirmDocuments}
+ disabled={isConfirming}
+ className="bg-blue-600 hover:bg-blue-700"
+ >
+ {isConfirming ? (
+ <>
+ <RefreshCw className="mr-2 h-4 w-4 animate-spin" />
+ 확정 중...
+ </>
+ ) : (
+ <>
+ <CheckCircle2 className="mr-2 h-4 w-4" />
+ 확정
+ </>
+ )}
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
</div>
);
} \ No newline at end of file
diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts
index 09d707d7..78d2479a 100644
--- a/lib/rfq-last/service.ts
+++ b/lib/rfq-last/service.ts
@@ -3,7 +3,7 @@
import { revalidatePath, unstable_cache, unstable_noStore } from "next/cache";
import db from "@/db/db";
-import { avlVendorInfo, paymentTerms, incoterms, rfqLastVendorQuotationItems, rfqLastVendorAttachments, rfqLastVendorResponses, RfqsLastView, rfqLastAttachmentRevisions, rfqLastAttachments, rfqsLast, rfqsLastView, users, rfqPrItems, prItemsLastView, vendors, rfqLastDetails, rfqLastVendorResponseHistory, rfqLastDetailsView, vendorContacts, projects, basicContract, basicContractTemplates, rfqLastTbeSessions, rfqLastTbeDocumentReviews, templateDetailView } from "@/db/schema";
+import { avlVendorInfo, paymentTerms, incoterms, rfqLastVendorQuotationItems, rfqLastVendorAttachments, rfqLastVendorResponses, RfqsLastView, rfqLastAttachmentRevisions, rfqLastAttachments, rfqsLast, rfqsLastView, users, rfqPrItems, prItemsLastView, vendors, rfqLastDetails, rfqLastVendorResponseHistory, rfqLastDetailsView, vendorContacts, projects, basicContract, basicContractTemplates, rfqLastTbeSessions, rfqLastTbeDocumentReviews, templateDetailView, RfqStatus } from "@/db/schema";
import { sql, and, desc, asc, like, ilike, or, eq, SQL, count, gte, lte, isNotNull, ne, inArray } from "drizzle-orm";
import { filterColumns } from "@/lib/filter-columns";
import { GetRfqLastAttachmentsSchema, GetRfqsSchema } from "./validations";
@@ -1527,6 +1527,9 @@ export async function getRfqVendorResponses(rfqId: number) {
submittedBy: rfqLastVendorResponses.submittedBy,
submittedByName: users.name,
+ isDocumentConfirmed: rfqLastVendorResponses.isDocumentConfirmed,
+
+
// 금액 정보
totalAmount: rfqLastVendorResponses.totalAmount,
currency: rfqLastVendorResponses.currency,
@@ -1709,6 +1712,7 @@ export async function getRfqVendorResponses(rfqId: number) {
responseVersion: response.responseVersion,
isLatest: response.isLatest,
status: response.status,
+ isDocumentConfirmed: response.isDocumentConfirmed,
// 벤더 정보
vendor: {
@@ -2980,7 +2984,7 @@ export async function sendRfqToVendors({
// 6. RFQ 상태 업데이트
if (results.length > 0) {
- await updateRfqStatus(rfqId, currentUser.id);
+ await updateRfqStatus(rfqId, Number(currentUser.id));
}
return {
@@ -4812,13 +4816,24 @@ export async function updateShortList(
.update(rfqLastTbeSessions)
.set({
status: "준비중",
- updatedBy: session.user.id,
+ updatedBy: Number(session.user.id),
updatedAt: new Date()
})
.where(eq(rfqLastTbeSessions.id, existingSession[0].id));
}
})
);
+
+ // 2-3. RFQ 상태를 "Short List 확정"으로 업데이트
+ await tx
+ .update(rfqsLast)
+ .set({
+ status: "Short List 확정" as RfqStatus,
+ updatedBy: Number(session.user.id),
+ updatedAt: new Date()
+ })
+ .where(eq(rfqsLast.id, rfqId));
+
} else {
// shortList가 false인 경우, 해당 벤더들의 활성 TBE 세션을 취소 상태로 변경
await Promise.all(
@@ -4839,21 +4854,60 @@ export async function updateShortList(
)
)
);
+
+ // shortList를 해제하는 경우의 상태 처리
+ // 모든 벤더의 shortList가 false인지 확인
+ const remainingShortlisted = await tx
+ .select()
+ .from(rfqLastDetails)
+ .where(
+ and(
+ eq(rfqLastDetails.rfqsLastId, rfqId),
+ eq(rfqLastDetails.isLatest, true),
+ eq(rfqLastDetails.shortList, true)
+ )
+ )
+ .limit(1);
+
+ // 남은 shortList 벤더가 없으면 RFQ 상태를 이전 상태로 되돌림
+ // if (remainingShortlisted.length === 0) {
+ // await tx
+ // .update(rfqsLast)
+ // .set({
+ // status: "견적 접수" as RfqStatus, // 또는 적절한 이전 상태
+ // updatedBy: Number(session.user.id),
+ // updatedAt: new Date()
+ // })
+ // .where(eq(rfqsLast.id, rfqId));
+ // }
}
return {
success: true,
updatedCount: updatedDetails.length,
vendorIds,
- tbeSessionsUpdated: shortListStatus
+ tbeSessionsUpdated: shortListStatus,
+ rfqStatusUpdated: true
};
}
+ // 벤더가 없는 경우 (모든 shortList를 false로만 설정)
+ // RFQ 상태를 이전 상태로 되돌림
+ await tx
+ .update(rfqsLast)
+ .set({
+ status: "견적 접수" as RfqStatus, // 또는 적절한 이전 상태
+ updatedBy: Number(session.user.id),
+ updatedAt: new Date()
+ })
+ .where(eq(rfqsLast.id, rfqId));
+
return {
success: true,
updatedCount: 0,
vendorIds: [],
- tbeSessionsUpdated: false
+ tbeSessionsUpdated: false,
+ rfqStatusUpdated: true
};
});
@@ -5209,3 +5263,127 @@ export async function addAvlVendorsToRfq({
};
}
}
+
+
+
+interface ConfirmVendorDocumentsResult {
+ success: boolean;
+ message: string;
+ updatedCount?: number;
+}
+
+/**
+ * 특정 벤더의 모든 문서를 확정 처리
+ * @param rfqId RFQ ID
+ * @param vendorId 벤더 ID
+ * @returns 처리 결과
+ */
+export async function confirmVendorDocuments(
+ rfqId: number,
+ vendorId: number
+): Promise<ConfirmVendorDocumentsResult> {
+ try {
+ // 데이터 유효성 검증
+ if (!rfqId || !vendorId) {
+ return {
+ success: false,
+ message: "RFQ ID와 벤더 ID가 필요합니다.",
+ };
+ }
+
+ // 트랜잭션으로 두 테이블 동시 업데이트
+ const result = await db.transaction(async (tx) => {
+ // 1. rfqLastVendorResponses 테이블 업데이트
+ const vendorResponseResult = await tx
+ .update(rfqLastVendorResponses)
+ .set({
+ isDocumentConfirmed: true,
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(rfqLastVendorResponses.rfqsLastId, rfqId),
+ eq(rfqLastVendorResponses.vendorId, vendorId)
+ )
+ )
+ .returning({ id: rfqLastVendorResponses.id });
+
+ // 업데이트된 레코드 수 확인
+ const updatedCount = vendorResponseResult.length;
+
+ if (updatedCount === 0) {
+ throw new Error("해당 조건에 맞는 문서를 찾을 수 없습니다.");
+ }
+
+ // 2. rfqsLast 테이블 status 업데이트
+ const rfqUpdateResult = await tx
+ .update(rfqsLast)
+ .set({
+ status: "견적요청문서 확정" as RfqStatus,
+ updatedAt: new Date(),
+ })
+ .where(eq(rfqsLast.id, rfqId))
+ .returning({ id: rfqsLast.id });
+
+ if (rfqUpdateResult.length === 0) {
+ throw new Error("RFQ 상태 업데이트에 실패했습니다.");
+ }
+
+ return updatedCount;
+ });
+
+ // 캐시 무효화 (필요한 경우)
+ revalidatePath(`/rfq-last/${rfqId}`);
+
+ return {
+ success: true,
+ message: `문서가 확정되었습니다.`,
+ updatedCount: result,
+ };
+ } catch (error) {
+ console.log("문서 확정 중 오류 발생:", error);
+
+ return {
+ success: false,
+ message: error instanceof Error
+ ? `문서 확정 실패: ${error.message}`
+ : "문서 확정 중 알 수 없는 오류가 발생했습니다.",
+ };
+ }
+}
+
+/**
+ * 특정 벤더의 문서 확정 상태 조회
+ * @param rfqId RFQ ID
+ * @param vendorId 벤더 ID
+ * @returns 확정 상태
+ */
+export async function getVendorDocumentConfirmStatus(
+ rfqId: number,
+ vendorId: number
+): Promise<{ isConfirmed: boolean; count: number }> {
+ try {
+ const results = await db
+ .select({
+ isDocumentConfirmed: rfqLastVendorResponses.isDocumentConfirmed,
+ })
+ .from(rfqLastVendorResponses)
+ .where(
+ and(
+ eq(rfqLastVendorResponses.rfqsLastId, rfqId),
+ eq(rfqLastVendorResponses.vendorId, vendorId)
+ )
+ );
+
+ const confirmedCount = results.filter(r => r.isDocumentConfirmed).length;
+ const totalCount = results.length;
+
+ return {
+ isConfirmed: totalCount > 0 && confirmedCount === totalCount,
+ count: totalCount,
+ };
+ } catch (error) {
+ console.error("문서 확정 상태 조회 중 오류:", error);
+ return { isConfirmed: false, count: 0 };
+ }
+} \ No newline at end of file
diff --git a/lib/rfq-last/vendor-response/editor/attachments-upload.tsx b/lib/rfq-last/vendor-response/editor/attachments-upload.tsx
index ea7bb9c9..b85407ff 100644
--- a/lib/rfq-last/vendor-response/editor/attachments-upload.tsx
+++ b/lib/rfq-last/vendor-response/editor/attachments-upload.tsx
@@ -97,10 +97,10 @@ export default function AttachmentsUpload({
// 파일 추가
const handleFileAdd = (files: FileList | null, type: "구매" | "설계") => {
if (!files) return
-
+
const newFiles: FileWithType[] = []
const errors: string[] = []
-
+
Array.from(files).forEach(file => {
const error = validateFile(file)
if (error) {
@@ -110,17 +110,31 @@ export default function AttachmentsUpload({
attachmentType: type,
description: ""
})
+ // 디버그 로그 추가
+ console.log(`파일 추가됨: ${file.name}, 타입: ${type}`)
newFiles.push(fileWithType)
}
})
-
+
if (errors.length > 0) {
setUploadErrors(errors)
setTimeout(() => setUploadErrors([]), 5000)
}
-
+
if (newFiles.length > 0) {
- onAttachmentsChange([...attachments, ...newFiles])
+ const updatedFiles = [...attachments, ...newFiles]
+ onAttachmentsChange(updatedFiles)
+
+ // 추가된 파일들의 타입 확인 로그
+ console.log('업데이트된 파일 목록:', updatedFiles.map(f => ({
+ name: f.name,
+ attachmentType: f.attachmentType
+ })))
+
+ // 각 파일의 타입이 제대로 설정되었는지 검증
+ newFiles.forEach(file => {
+ console.log(`새 파일 타입 검증: ${file.name} = ${file.attachmentType}`)
+ })
}
}
@@ -175,7 +189,15 @@ export default function AttachmentsUpload({
// 파일 타입 변경
const handleTypeChange = (index: number, newType: "구매" | "설계") => {
const newFiles = [...attachments]
+ const oldType = newFiles[index].attachmentType
newFiles[index].attachmentType = newType
+
+ console.log(`파일 타입 변경: ${newFiles[index].name} (${oldType} -> ${newType})`)
+ console.log('변경 후 파일 목록:', newFiles.map(f => ({
+ name: f.name,
+ attachmentType: f.attachmentType
+ })))
+
onAttachmentsChange(newFiles)
}
diff --git a/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx b/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx
index fec9a2b9..795431f6 100644
--- a/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx
+++ b/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx
@@ -249,28 +249,34 @@ export default function VendorResponseEditor({
}
const onSubmit = async (data: VendorResponseFormData, isSubmit: boolean = false) => {
- console.log('onSubmit called with:', { data, isSubmit }) // 디버깅용
-
+ console.log('onSubmit called with:', { data, isSubmit, attachmentsCount: attachments.length }) // 디버깅용
+
setLoading(true)
setUploadProgress(0)
try {
const formData = new FormData()
- const fileMetadata = attachments.map((file: FileWithType) => ({
- attachmentType: file.attachmentType || "기타",
- description: file.description || ""
- }))
+ // 첨부파일 메타데이터 생성 시 타입 확인
+ const fileMetadata = attachments.map((file: FileWithType) => {
+ const metadata = {
+ attachmentType: file.attachmentType || "기타",
+ description: file.description || ""
+ };
+ console.log(`파일 메타데이터 생성: ${file.name} -> 타입: ${metadata.attachmentType}`);
+ return metadata;
+ });
+
- // 삭제된 첨부파일 ID 목록
- const deletedAttachmentIds = deletedAttachments.map(file => file.id)
// 디버그: 첨부파일 attachmentType 확인
- console.log('Attachments with types:', attachments.map(f => ({
+ console.log('최종 첨부파일 목록:', attachments.map(f => ({
name: f.name,
attachmentType: f.attachmentType,
size: f.size
})))
+
+ console.log('파일 메타데이터:', fileMetadata)
// 기본 데이터 추가
@@ -285,15 +291,17 @@ export default function VendorResponseEditor({
totalAmount: data.quotationItems.reduce((sum, item) => sum + item.totalPrice, 0),
updatedBy: userId,
fileMetadata,
- deletedAttachmentIds
}
console.log('Submitting data:', submitData) // 디버깅용
formData.append('data', JSON.stringify(submitData))
- // 첨부파일 추가
+ // 첨부파일 추가 (메타데이터를 통해 타입 정보 전달)
attachments.forEach((file, index) => {
+ const metadata = fileMetadata[index];
+ console.log(`첨부파일 추가: ${file.name}, 타입: ${metadata?.attachmentType}`);
+
formData.append(`attachments`, file)
})
@@ -341,6 +349,14 @@ export default function VendorResponseEditor({
await uploadPromise
+ // 임시저장 성공 시 첨부파일 목록 초기화 (중복 저장 방지)
+ if (!isSubmit) {
+ console.log('임시저장 완료 - 첨부파일 목록 초기화');
+ setAttachments([]);
+ setExistingAttachments([]);
+ setDeletedAttachments([]);
+ }
+
toast.success(isSubmit ? "견적서가 제출되었습니다." : "견적서가 저장되었습니다.")
if (isSubmit) {
router.push('/partners/rfq-last')
diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx
index 17433773..b8a5184f 100644
--- a/lib/rfq-last/vendor/rfq-vendor-table.tsx
+++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx
@@ -309,7 +309,17 @@ export function RfqVendorTable({
try {
setIsUpdatingShortList(true);
- const vendorIds = selectedRows
+ // response가 있는 벤더들만 필터링
+ const vendorsWithResponse = selectedRows.filter(vendor =>
+ vendor.response && vendor.response.vendor&& vendor.response.isDocumentConfirmed
+ );
+
+ if (vendorsWithResponse.length === 0) {
+ toast.warning("응답이 있는 벤더를 선택해주세요.");
+ return;
+ }
+
+ const vendorIds = vendorsWithResponse
.map(vendor => vendor.vendorId)
.filter(id => id != null);
@@ -474,19 +484,36 @@ export function RfqVendorTable({
});
// 기본계약 생성 결과 표시
+ let message = "";
if (result.contractResults && result.contractResults.length > 0) {
const totalContracts = result.contractResults.reduce((acc, r) => acc + r.totalCreated, 0);
- toast.success(`${data.vendors.length}개 업체에 RFQ를 발송하고 ${totalContracts}개의 기본계약을 생성했습니다.`);
+ message = `${data.vendors.length}개 업체에 RFQ를 발송하고 ${totalContracts}개의 기본계약을 생성했습니다.`;
} else {
- toast.success(`${data.vendors.length}개 업체에 RFQ를 발송했습니다.`);
+ message = `${data.vendors.length}개 업체에 RFQ를 발송했습니다.`;
}
- // 페이지 새로고침
- router.refresh();
+ // 성공 결과를 반환
+ return {
+ success: true,
+ message: message,
+ totalSent: result.totalSent || data.vendors.length,
+ totalFailed: result.totalFailed || 0,
+ totalContracts: result.totalContracts || 0,
+ totalTbeSessions: result.totalTbeSessions || 0
+ };
+
} catch (error) {
console.error("RFQ 발송 실패:", error);
- toast.error("RFQ 발송에 실패했습니다.");
- throw error;
+
+ // 실패 결과를 반환
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : "RFQ 발송에 실패했습니다.",
+ totalSent: 0,
+ totalFailed: data.vendors.length,
+ totalContracts: 0,
+ totalTbeSessions: 0
+ };
}
}, [rfqId, rfqCode, router]);
@@ -1513,6 +1540,7 @@ export function RfqVendorTable({
// 참여 의사가 있는 선택된 벤더 수 계산
const participatingCount = selectedRows.length;
const shortListCount = selectedRows.filter(v => v.shortList).length;
+ const vendorsWithResponseCount = selectedRows.filter(v => v.response && v.response.vendor && v.response.isDocumentConfirmed).length;
// 견적서가 있는 선택된 벤더 수 계산
const quotationCount = selectedRows.filter(row =>
@@ -1582,7 +1610,7 @@ export function RfqVendorTable({
variant="outline"
size="sm"
onClick={handleShortListConfirm}
- disabled={isUpdatingShortList}
+ disabled={isUpdatingShortList || vendorsWithResponseCount===0}
// className={ "border-green-500 text-green-600 hover:bg-green-50" }
>
{isUpdatingShortList ? (
@@ -1594,7 +1622,7 @@ export function RfqVendorTable({
<>
<CheckSquare className="h-4 w-4 mr-2" />
Short List 확정
- {participatingCount > 0 && ` (${participatingCount})`}
+ {vendorsWithResponseCount > 0 && ` (${vendorsWithResponseCount})`}
</>
)}
</Button>
diff --git a/lib/rfq-last/vendor/send-rfq-dialog.tsx b/lib/rfq-last/vendor/send-rfq-dialog.tsx
index e63086ad..42470ecc 100644
--- a/lib/rfq-last/vendor/send-rfq-dialog.tsx
+++ b/lib/rfq-last/vendor/send-rfq-dialog.tsx
@@ -812,8 +812,12 @@ export function SendRfqDialog({
hasToSendEmail: hasToSendEmail,
});
+ if (!sendResult) {
+ throw new Error("서버 응답이 없습니다.");
+ }
+
if (!sendResult.success) {
- throw new Error(sendResult.message);
+ throw new Error(sendResult.message || "RFQ 발송에 실패했습니다.");
}
toast.success(sendResult.message);
@@ -821,7 +825,7 @@ export function SendRfqDialog({
} catch (error) {
console.error("RFQ 발송 실패:", error);
- toast.error("RFQ 발송에 실패했습니다.");
+ toast.error(error instanceof Error ? error.message : "RFQ 발송에 실패했습니다.");
} finally {
setIsSending(false);
setIsGeneratingPdfs(false);
diff --git a/lib/tbe-last/service.ts b/lib/tbe-last/service.ts
index b69ab71c..da0a5a4c 100644
--- a/lib/tbe-last/service.ts
+++ b/lib/tbe-last/service.ts
@@ -6,7 +6,7 @@ import db from "@/db/db";
import { and, desc, asc, eq, sql, or, isNull, isNotNull, ne, inArray } from "drizzle-orm";
import { tbeLastView, tbeDocumentsView } from "@/db/schema";
import { rfqPrItems } from "@/db/schema/rfqLast";
-import { rfqLastTbeDocumentReviews, rfqLastTbePdftronComments, rfqLastTbeVendorDocuments,rfqLastTbeSessions } from "@/db/schema";
+import {rfqLastDetails, rfqLastTbeDocumentReviews, rfqLastTbePdftronComments, rfqLastTbeVendorDocuments,rfqLastTbeSessions } from "@/db/schema";
import { filterColumns } from "@/lib/filter-columns";
import { GetTBELastSchema } from "./validations";
import { getServerSession } from "next-auth"
@@ -49,7 +49,11 @@ export async function getAllTBELast(input: GetTBELastSchema) {
}
// 최종 WHERE
- const finalWhere = and(advancedWhere, globalWhere, ne(tbeLastView.status,"생성중"));
+ const whereConditions = [advancedWhere, ne(tbeLastView.sessionStatus,"생성중")];
+ if (globalWhere) {
+ whereConditions.push(globalWhere);
+ }
+ const finalWhere = and(...whereConditions);
// 정렬
const orderBy = input.sort?.length