diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-05 11:44:32 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-05 11:44:32 +0000 |
| commit | 50adedf48ee4674ebe00f1ee72d93485183cdc51 (patch) | |
| tree | 18053ab04d94c750028eee5d5d2f16ba4f38f50e /lib | |
| parent | 66d64b482f2b6b52b0dd396ef998f27d491c70dd (diff) | |
(대표님, 최겸, 임수민) EDP 입력 진행률, 견적목록관리, EDP excel import 오류수정, GTC-Contract
Diffstat (limited to 'lib')
20 files changed, 4974 insertions, 1574 deletions
diff --git a/lib/basic-contract/gtc-vendor/clause-table.tsx b/lib/basic-contract/gtc-vendor/clause-table.tsx index a9230cd4..88b1f45c 100644 --- a/lib/basic-contract/gtc-vendor/clause-table.tsx +++ b/lib/basic-contract/gtc-vendor/clause-table.tsx @@ -30,6 +30,8 @@ interface GtcClausesTableProps { Awaited<ReturnType<typeof getGtcClauses>>, Awaited<ReturnType<typeof getUsersForFilter>>, Awaited<ReturnType<typeof getVendorClausesForDocument>>, + Vendor | null, // vendor 데이터 추가 + ] > , @@ -46,7 +48,7 @@ export function GtcClausesVendorTable({ vendorId, vendorName }: GtcClausesTableProps) { - const [{ data, pageCount }, users, vendorData] = React.use(promises) + const [{ data, pageCount }, users, vendorData, vendor] = React.use(promises) const [rowAction, setRowAction] = @@ -195,6 +197,7 @@ export function GtcClausesVendorTable({ table={table} documentId={documentId} document={document} + vendor={vendor} /> </DataTableAdvancedToolbar> </DataTable> diff --git a/lib/basic-contract/gtc-vendor/gtc-clauses-table-toolbar-actions.tsx b/lib/basic-contract/gtc-vendor/gtc-clauses-table-toolbar-actions.tsx index 3a0fbdb6..f0cebe5f 100644 --- a/lib/basic-contract/gtc-vendor/gtc-clauses-table-toolbar-actions.tsx +++ b/lib/basic-contract/gtc-vendor/gtc-clauses-table-toolbar-actions.tsx @@ -37,12 +37,14 @@ import { exportFullDataToExcel, type ExcelColumnDef } from "@/lib/export" import { getAllGtcClausesForExport, importGtcClausesFromExcel } from "@/lib/gtc-contract/service" import { ImportExcelDialog } from "./import-excel-dialog" import { toast } from "@/hooks/use-toast" +import { Vendor } from "@/db/schema" interface GtcClausesTableToolbarActionsProps { table: Table<GtcClauseTreeView> documentId: number document: any currentUserId?: number // 현재 사용자 ID 추가 + vendor:Vendor } // GTC 조항을 위한 Excel 컬럼 정의 (실용적으로 간소화) @@ -101,7 +103,7 @@ export function GtcClausesTableToolbarActions({ table, documentId, document, - currentUserId = 1, // 기본값 설정 (실제로는 auth에서 가져와야 함) + vendor }: GtcClausesTableToolbarActionsProps) { const [showCreateDialog, setShowCreateDialog] = React.useState(false) const [showReorderDialog, setShowReorderDialog] = React.useState(false) @@ -188,7 +190,7 @@ export function GtcClausesTableToolbarActions({ // Excel 데이터 가져오기 처리 const handleImportExcelData = async (data: Partial<GtcClauseTreeView>[]) => { try { - const result = await importGtcClausesFromExcel(documentId, data, currentUserId) + const result = await importGtcClausesFromExcel(documentId, data) if (result.success) { toast({ @@ -340,7 +342,8 @@ export function GtcClausesTableToolbarActions({ open={showPreviewDialog} onOpenChange={setShowPreviewDialog} clauses={previewClauses} - document={document} + contractDocument={document} + vendor={vendor} onExport={() => { console.log("Export from preview dialog") }} diff --git a/lib/basic-contract/gtc-vendor/preview-document-dialog.tsx b/lib/basic-contract/gtc-vendor/preview-document-dialog.tsx index 78ddc7f7..c017b8be 100644 --- a/lib/basic-contract/gtc-vendor/preview-document-dialog.tsx +++ b/lib/basic-contract/gtc-vendor/preview-document-dialog.tsx @@ -5,38 +5,62 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f import { Button } from "@/components/ui/button" import { Badge } from "@/components/ui/badge" import { Separator } from "@/components/ui/separator" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" import { Eye, Download, + Save, + Upload, Loader2, FileText, RefreshCw, Settings, - AlertCircle + AlertCircle, + CheckCircle } from "lucide-react" import { toast } from "sonner" import { type GtcClauseTreeView } from "@/db/schema/gtc" import { ClausePreviewViewer } from "./clause-preview-viewer" +import { saveGtcDocumentAction } from "../service" + +interface Vendor { + vendorName: string + address: string + representativeName: string + taxId: string + phone: string +} interface PreviewDocumentDialogProps extends React.ComponentPropsWithRef<typeof Dialog> { clauses: GtcClauseTreeView[] - document: any + contractDocument: any + vendor: Vendor onExport?: () => void } export function PreviewDocumentDialog({ clauses, - document, + contractDocument, + vendor, onExport, ...props }: PreviewDocumentDialogProps) { + const [isGenerating, setIsGenerating] = React.useState(false) + const [isSaving, setIsSaving] = React.useState(false) + const [isConverting, setIsConverting] = React.useState(false) const [documentGenerated, setDocumentGenerated] = React.useState(false) const [viewerInstance, setViewerInstance] = React.useState<any>(null) const [hasError, setHasError] = React.useState(false) + + // 파일 업로드 관련 상태 + const [selectedFile, setSelectedFile] = React.useState<File | null>(null) + const [convertedPdf, setConvertedPdf] = React.useState<Uint8Array | null>(null) + const fileInputRef = React.useRef<HTMLInputElement>(null) // 조항 통계 계산 const stats = React.useMemo(() => { @@ -58,8 +82,6 @@ export function PreviewDocumentDialog({ setDocumentGenerated(false) try { - // 실제로는 ClausePreviewViewer에서 문서 생성을 처리하므로 - // 여기서는 상태만 관리 console.log("🚀 문서 미리보기 생성 시작") // ClausePreviewViewer가 완전히 로드될 때까지 기다림 @@ -78,12 +100,182 @@ export function PreviewDocumentDialog({ } } + // 파일 선택 핸들러 + const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => { + const file = event.target.files?.[0] + if (!file) return + + // Word 파일만 허용 + const allowedTypes = [ + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx + 'application/msword' // .doc + ] + + if (!allowedTypes.includes(file.type)) { + toast.error("Word 파일(.doc, .docx)만 업로드할 수 있습니다.") + return + } + + if (file.size > 50 * 1024 * 1024) { // 50MB 제한 + toast.error("파일 크기는 50MB 이하여야 합니다.") + return + } + + setSelectedFile(file) + setConvertedPdf(null) // 이전 변환 결과 초기화 + toast.success(`파일이 선택되었습니다: ${file.name}`) + } + + // PDF 변환 함수 + const handleConvertToPdf = async () => { + if (!selectedFile) { + toast.error("먼저 Word 파일을 선택해주세요.") + return + } + + // 브라우저 환경 체크 + if (typeof window === 'undefined' || typeof document === 'undefined') { + toast.error("브라우저 환경에서만 PDF 변환이 가능합니다.") + return + } + + setIsConverting(true) + + try { + console.log("🔄 PDF 변환 시작:", selectedFile.name) + + // PDFTron WebViewer 동적 import + const { default: WebViewer } = await import("@pdftron/webviewer") + + // 임시 WebViewer 인스턴스 생성 (화면에 표시하지 않음) + const tempDiv = document.createElement('div') + tempDiv.style.display = 'none' + tempDiv.style.position = 'absolute' + tempDiv.style.top = '-9999px' + tempDiv.style.left = '-9999px' + tempDiv.style.width = '1px' + tempDiv.style.height = '1px' + document.body.appendChild(tempDiv) + + const instance = await WebViewer( + { + path: "/pdftronWeb", + licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, + fullAPI: true, + enableOfficeEditing: true, + }, + tempDiv + ) + + try { + // WebViewer 초기화 대기 + await new Promise(resolve => setTimeout(resolve, 1000)) + + const { Core } = instance + const { createDocument } = Core + + const templateData = { + company_name: vendor.vendorName || '협력업체명', + company_address: vendor.address || '주소', + representative_name: vendor.representativeName || '대표자명', + signature_date: new Date().toLocaleDateString('ko-KR'), + tax_id: vendor.taxId || '사업자번호', + phone_number: vendor.phone || '전화번호', + } + + const templateDoc = await createDocument(selectedFile, { + filename: selectedFile.name|| 'template.docx', + extension: 'docx', + }) + + await templateDoc.applyTemplateValues(templateData) + + + // 문서 로드 완료 대기 + await new Promise(resolve => setTimeout(resolve, 3000)) + + // PDF로 변환 - 더 안전한 방식 + const fileData = await templateDoc.getFileData() + const pdfBuffer = await Core.officeToPDFBuffer(fileData, { extension: 'docx' }) + + console.log("✅ PDF 변환 완료:", pdfBuffer.byteLength, "bytes") + setConvertedPdf(new Uint8Array(pdfBuffer)) + toast.success("PDF 변환이 완료되었습니다.") + + } finally { + // 임시 WebViewer 정리 + try { + instance.UI.dispose() + } catch (disposeError) { + console.warn("WebViewer dispose 오류:", disposeError) + } + + try { + if (tempDiv && tempDiv.parentNode) { + document.body.removeChild(tempDiv) + } + } catch (removeError) { + console.warn("임시 div 제거 오류:", removeError) + } + } + + } catch (error) { + console.error("❌ PDF 변환 실패:", error) + toast.error(`PDF 변환 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`) + } finally { + setIsConverting(false) + } + } + + // 문서 저장 함수 + const handleSaveDocument = async () => { + if (!convertedPdf || !selectedFile) { + toast.error("먼저 파일을 업로드하고 PDF로 변환해주세요.") + return + } + + setIsSaving(true) + + try { + console.log("💾 문서 저장 시작") + + const result = await saveGtcDocumentAction({ + documentId: contractDocument.id, + pdfBuffer: convertedPdf, + originalFileName: selectedFile.name, + vendor + }) + + if (result.success) { + toast.success(`문서가 성공적으로 저장되었습니다.`) + console.log("✅ 문서 저장 완료:", { + fileName: result.fileName, + filePath: result.filePath, + fileSize: result.fileSize + }) + + // 저장 완료 후 상태 초기화 + setSelectedFile(null) + setConvertedPdf(null) + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + } else { + throw new Error(result.error || "문서 저장에 실패했습니다.") + } + } catch (error) { + console.error("❌ 문서 저장 실패:", error) + toast.error(`문서 저장 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`) + } finally { + setIsSaving(false) + } + } + const handleExportDocument = () => { if (viewerInstance) { try { - // PDFTron의 다운로드 기능 실행 viewerInstance.UI.downloadPdf({ - filename: `${document?.title || 'GTC계약서'}_미리보기.pdf` + filename: `${contractDocument?.title || 'GTC계약서'}_미리보기.pdf` }) toast.success("PDF 다운로드가 시작됩니다.") } catch (error) { @@ -119,7 +311,7 @@ export function PreviewDocumentDialog({ if (props.open && !documentGenerated && !isGenerating && !hasError) { const timer = setTimeout(() => { handleGeneratePreview() - }, 300) // 다이얼로그 애니메이션 후 시작 + }, 300) return () => clearTimeout(timer) } @@ -130,8 +322,15 @@ export function PreviewDocumentDialog({ if (!props.open) { setDocumentGenerated(false) setIsGenerating(false) + setIsSaving(false) + setIsConverting(false) setHasError(false) setViewerInstance(null) + setSelectedFile(null) + setConvertedPdf(null) + if (fileInputRef.current) { + fileInputRef.current.value = '' + } } }, [props.open]) @@ -141,20 +340,23 @@ export function PreviewDocumentDialog({ <DialogHeader className="flex-shrink-0"> <DialogTitle className="flex items-center gap-2"> <Eye className="h-5 w-5" /> - 문서 미리보기 + 문서 미리보기 및 저장 </DialogTitle> <DialogDescription> - 현재 조항들을 기반으로 생성된 문서를 미리보기합니다. + 조항 기반 미리보기를 확인하고, Word 파일을 업로드하여 최종 문서를 저장하세요. </DialogDescription> </DialogHeader> {/* 문서 정보 및 통계 */} - <div className="flex-shrink-0 p-4 bg-muted/30 rounded-lg"> - <div className="flex items-center justify-between mb-3"> + <div className="flex-shrink-0 p-4 bg-muted/30 rounded-lg space-y-4"> + <div className="flex items-center justify-between"> <div className="flex items-center gap-2"> <FileText className="h-4 w-4" /> - <span className="font-medium">{document?.title || 'GTC 계약서'}</span> + <span className="font-medium">{contractDocument?.title || 'GTC 계약서'}</span> <Badge variant="outline">{stats.total}개 조항</Badge> + {vendor && ( + <Badge variant="secondary">{vendor.vendorName}</Badge> + )} {hasError && ( <Badge variant="destructive" className="gap-1"> <AlertCircle className="h-3 w-3" /> @@ -164,33 +366,22 @@ export function PreviewDocumentDialog({ </div> <div className="flex items-center gap-2"> {documentGenerated && !hasError && ( - <> - <Button - variant="outline" - size="sm" - onClick={handleRegenerateDocument} - disabled={isGenerating} - > - <RefreshCw className={`mr-2 h-3 w-3 ${isGenerating ? 'animate-spin' : ''}`} /> - 재생성 - </Button> - {/* <Button - variant="outline" - size="sm" - onClick={handleExportDocument} - disabled={!viewerInstance} - > - <Download className="mr-2 h-3 w-3" /> - PDF 다운로드 - </Button> */} - </> + <Button + variant="outline" + size="sm" + onClick={handleRegenerateDocument} + disabled={isGenerating || isSaving || isConverting} + > + <RefreshCw className={`mr-2 h-3 w-3 ${isGenerating ? 'animate-spin' : ''}`} /> + 재생성 + </Button> )} {hasError && ( <Button variant="default" size="sm" onClick={handleRegenerateDocument} - disabled={isGenerating} + disabled={isGenerating || isSaving || isConverting} > <RefreshCw className={`mr-2 h-3 w-3 ${isGenerating ? 'animate-spin' : ''}`} /> 다시 시도 @@ -199,7 +390,71 @@ export function PreviewDocumentDialog({ </div> </div> - <div className="grid grid-cols-4 gap-4 text-sm"> + {/* 파일 업로드 섹션 */} + <div className="border-t pt-4"> + <div className="grid grid-cols-1 lg:grid-cols-3 gap-4"> + <div className="space-y-2"> + <Label htmlFor="file-upload">1. Word 파일 업로드</Label> + <div className="flex gap-2"> + <Input + ref={fileInputRef} + id="file-upload" + type="file" + accept=".doc,.docx,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document" + onChange={handleFileSelect} + disabled={isConverting || isSaving} + className="flex-1" + /> + {selectedFile && ( + <CheckCircle className="h-8 w-8 text-green-500 flex-shrink-0" /> + )} + </div> + {selectedFile && ( + <p className="text-sm text-muted-foreground"> + 선택됨: {selectedFile.name} ({(selectedFile.size / (1024 * 1024)).toFixed(2)}MB) + </p> + )} + </div> + + <div className="space-y-2"> + <Label>2. PDF 변환</Label> + <Button + onClick={handleConvertToPdf} + disabled={!selectedFile || isConverting || isSaving} + className="w-full" + variant={convertedPdf ? "outline" : "default"} + > + {isConverting ? ( + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + ) : convertedPdf ? ( + <CheckCircle className="mr-2 h-4 w-4" /> + ) : ( + <RefreshCw className="mr-2 h-4 w-4" /> + )} + {isConverting ? "변환 중..." : convertedPdf ? "변환 완료" : "PDF 변환"} + </Button> + </div> + + <div className="space-y-2"> + <Label>3. 문서 저장</Label> + <Button + onClick={handleSaveDocument} + disabled={!convertedPdf || isSaving || isConverting} + className="w-full" + > + {isSaving ? ( + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + ) : ( + <Save className="mr-2 h-4 w-4" /> + )} + {isSaving ? "저장 중..." : "문서 저장"} + </Button> + </div> + </div> + </div> + + {/* 통계 정보 */} + <div className="grid grid-cols-4 gap-4 text-sm border-t pt-4"> <div className="text-center p-2 bg-background rounded"> <div className="font-medium text-lg">{stats.total}</div> <div className="text-muted-foreground">총 조항</div> @@ -241,7 +496,7 @@ export function PreviewDocumentDialog({ <p className="text-sm text-muted-foreground mb-4 text-center max-w-md"> 문서 생성 중 오류가 발생했습니다. 네트워크 연결이나 파일 권한을 확인해주세요. </p> - <Button onClick={handleRegenerateDocument} disabled={isGenerating}> + <Button onClick={handleRegenerateDocument} disabled={isGenerating || isSaving || isConverting}> <RefreshCw className="mr-2 h-4 w-4" /> 다시 시도 </Button> @@ -249,7 +504,7 @@ export function PreviewDocumentDialog({ ) : documentGenerated ? ( <ClausePreviewViewer clauses={clauses} - document={document} + document={contractDocument} instance={viewerInstance} setInstance={setViewerInstance} onSuccess={handleViewerSuccess} @@ -259,7 +514,7 @@ export function PreviewDocumentDialog({ <div className="absolute inset-0 flex flex-col items-center justify-center bg-muted/10"> <FileText className="h-12 w-12 text-muted-foreground mb-4" /> <p className="text-lg font-medium mb-2">문서 미리보기 준비 중</p> - <Button onClick={handleGeneratePreview} disabled={isGenerating}> + <Button onClick={handleGeneratePreview} disabled={isGenerating || isSaving || isConverting}> <Eye className="mr-2 h-4 w-4" /> 미리보기 생성 </Button> diff --git a/lib/basic-contract/service.ts b/lib/basic-contract/service.ts index 8189381b..057526cf 100644 --- a/lib/basic-contract/service.ts +++ b/lib/basic-contract/service.ts @@ -65,7 +65,7 @@ import { sendEmail } from "../mail/sendEmail"; import { headers } from 'next/headers';
import { filterColumns } from "@/lib/filter-columns";
import { differenceInDays, addYears, isBefore } from "date-fns";
-import { deleteFile, saveFile } from "@/lib/file-stroage";
+import { deleteFile, saveBuffer, saveFile } from "@/lib/file-stroage";
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/app/api/auth/[...nextauth]/route"
@@ -3407,3 +3407,89 @@ export async function updateVendorDocumentStatus( return { success: false, error: "상태 업데이트 중 오류가 발생했습니다." }
}
}
+
+
+interface SaveDocumentParams {
+ documentId: number
+ pdfBuffer: Uint8Array
+ originalFileName: string
+ vendor: {
+ vendorName: string
+ }
+ userId: number
+}
+
+export async function saveGtcDocumentAction({
+ documentId,
+ pdfBuffer,
+ originalFileName,
+ vendor
+}: SaveDocumentParams) {
+ try {
+ console.log("📄 GTC 문서 저장 시작:", {
+ documentId,
+ vendorName: vendor.vendorName,
+ originalFileName,
+ bufferSize: pdfBuffer.length
+ })
+
+ const session = await getServerSession(authOptions)
+
+ if (!session?.user) {
+ return { success: false, error: "인증되지 않은 사용자입니다." }
+ }
+ const userId = Number(session.user.id);
+
+ // 1. PDF 파일명 생성
+ const baseName = originalFileName.replace(/\.[^/.]+$/, "") // 확장자 제거
+ const fileName = `GTC_${vendor.vendorName}_${baseName}_${new Date().toISOString().split('T')[0]}.pdf`
+
+ // 2. 파일 저장 (공용 파일 저장 함수 사용)
+ const saveResult = await saveBuffer({
+ buffer: Buffer.from(pdfBuffer),
+ fileName,
+ directory: 'basic-contracts',
+ originalName: fileName,
+ userId: userId.toString()
+ })
+
+ if (!saveResult.success) {
+ throw new Error(saveResult.error || '파일 저장 실패')
+ }
+
+ // 3. 데이터베이스 업데이트
+ await db.update(basicContract)
+ .set({
+ fileName: saveResult.fileName!,
+ filePath: saveResult.publicPath!,
+ status: 'PENDING',
+ // 기존 서명 관련 timestamp들 리셋
+ vendorSignedAt: null,
+ buyerSignedAt: null,
+ legalReviewRequestedAt: null,
+ legalReviewCompletedAt: null,
+ updatedAt: new Date()
+ })
+ .where(eq(basicContract.id, documentId))
+
+ console.log("✅ GTC 문서 저장 완료:", {
+ fileName: saveResult.fileName,
+ filePath: saveResult.publicPath,
+ fileSize: saveResult.fileSize
+ })
+
+ return {
+ success: true,
+ fileName: saveResult.fileName,
+ filePath: saveResult.publicPath,
+ fileSize: saveResult.fileSize
+ }
+
+ } catch (error) {
+ console.error("❌ GTC 문서 저장 실패:", error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '문서 저장 중 오류가 발생했습니다'
+ }
+ }
+}
\ No newline at end of file diff --git a/lib/forms/stat.ts b/lib/forms/stat.ts index fbcc6f46..45bf2710 100644 --- a/lib/forms/stat.ts +++ b/lib/forms/stat.ts @@ -20,11 +20,12 @@ export async function getVendorFormStatus(): Promise<VendorFormStatus[]> { try { // 1. 모든 벤더 조회 const vendorList = await db - .select({ - id: vendors.id, - vendorName: vendors.vendorName - }) - .from(vendors) + .selectDistinct({ + vendorId: vendors.id, + vendorName: vendors.vendorName, + }) + .from(vendors) + .innerJoin(contracts, eq(contracts.vendorId, vendors.id)) const vendorStatusList: VendorFormStatus[] = [] @@ -37,12 +38,12 @@ export async function getVendorFormStatus(): Promise<VendorFormStatus[]> { // 2. 벤더별 계약 조회 const vendorContracts = await db - .selectDistinct({ - vendorId: vendors.id, - vendorName: vendors.vendorName, - }) - .from(vendors) - .innerJoin(contracts, eq(contracts.vendorId, vendors.id)) + .select({ + id: contracts.id, + projectId: contracts.projectId + }) + .from(contracts) + .where(eq(contracts.vendorId, vendor.vendorId)) for (const contract of vendorContracts) { // 3. 계약별 contractItems 조회 @@ -119,8 +120,7 @@ export async function getVendorFormStatus(): Promise<VendorFormStatus[]> { // 최종 입력 필요 필드 = shi 기반 필드 + TAG 기반 편집 가능 필드 const allRequiredFields = inputRequiredFields.filter(field => tagEditableFields.includes(field) - ) - + ) // 각 필드별 입력 상태 체크 for (const fieldKey of allRequiredFields) { vendorTotalFields++ @@ -143,7 +143,7 @@ export async function getVendorFormStatus(): Promise<VendorFormStatus[]> { : 0 vendorStatusList.push({ - vendorId: vendor.id, + vendorId: vendor.vendorId, vendorName: vendor.vendorName || '이름 없음', formCount: vendorFormCount, tagCount: uniqueTags.size, diff --git a/lib/forms/vendor-completion-examples.ts b/lib/forms/vendor-completion-examples.ts deleted file mode 100644 index b2cac730..00000000 --- a/lib/forms/vendor-completion-examples.ts +++ /dev/null @@ -1,205 +0,0 @@ -/** - * 벤더 입력 완성도 서버 액션 사용 예제 - * - * 이 파일은 실제 사용 방법을 보여주는 예제입니다. - */ - -import { - calculateVendorFormCompletion, - getProjectVendorCompletionSummary, - calculateVendorContractCompletion, - getVendorAllContractsCompletionSummary, - getAllVendorsContractsCompletionSummary, - getAllProjectsVendorCompletionSummary -} from './vendor-completion-stats'; - -/** - * 예제 1: 특정 벤더의 특정 Form 완성도 확인 - */ -export async function exampleVendorFormCompletion() { - const contractItemId = 123; - const formCode = "SPR_LST"; - - const stats = await calculateVendorFormCompletion(contractItemId, formCode); - - if (stats) { - console.log(`벤더: ${stats.vendorName}`); - console.log(`폼: ${stats.formName} (${stats.formCode})`); - console.log(`완성도: ${stats.completionPercentage}%`); - console.log(`입력 현황: ${stats.totalFilledFields}/${stats.totalRequiredFields} 필드 완료`); - console.log(`미입력 필드: ${stats.totalEmptyFields}개`); - console.log(`총 태그 수: ${stats.tagCount}개`); - - // 태그별 세부 현황 - stats.detailsByTag.forEach(tag => { - console.log(` 태그 ${tag.tagNo}: ${tag.completionPercentage}% (${tag.filledFields}/${tag.requiredFields})`); - }); - } - - return stats; -} - -/** - * 예제 2: 프로젝트의 모든 벤더들의 특정 Form 완성도 요약 - */ -export async function exampleProjectFormCompletion() { - const projectId = 456; - const formCode = "SPR_LST"; - - const summary = await getProjectVendorCompletionSummary(projectId, formCode); - - if (summary) { - console.log(`프로젝트: ${summary.projectName} (${summary.projectCode})`); - console.log(`평균 완성도: ${summary.averageCompletionPercentage}%`); - console.log(`참여 벤더 수: ${summary.totalVendors}개`); - - // 벤더별 완성도 - summary.vendors.forEach(vendor => { - console.log(` ${vendor.vendorName}: ${vendor.completionPercentage}%`); - }); - } - - return summary; -} - -/** - * 예제 3: 특정 벤더의 특정 계약에 대한 모든 Form 완성도 - */ -export async function exampleVendorContractCompletion() { - const vendorId = 789; - const contractItemId = 123; - - const stats = await calculateVendorContractCompletion(vendorId, contractItemId); - - if (stats) { - console.log(`벤더: ${stats.contracts?.[0]?.forms?.[0]?.vendorName || 'Unknown'}`); - console.log(`프로젝트: ${stats.projectName} (${stats.projectCode})`); - console.log(`아이템: ${stats.itemName} (${stats.itemCode})`); - console.log(`전체 완성도: ${stats.averageCompletionPercentage}%`); - console.log(`총 폼 수: ${stats.totalForms}개`); - console.log(`총 필드 수: ${stats.totalRequiredFields}개`); - console.log(`입력된 필드: ${stats.totalFilledFields}개`); - - // 폼별 완성도 - stats.forms.forEach(form => { - console.log(` ${form.formName}: ${form.completionPercentage}%`); - }); - } - - return stats; -} - -/** - * 예제 4: 특정 벤더의 모든 계약에 대한 입력 완성도 요약 - */ -export async function exampleVendorAllContractsCompletion() { - const vendorId = 789; - - const summary = await getVendorAllContractsCompletionSummary(vendorId); - - if (summary) { - console.log(`벤더: ${summary.vendorName}`); - console.log(`전체 완성도: ${summary.overallCompletionPercentage}%`); - console.log(`총 계약 수: ${summary.totalContracts}개`); - console.log(`총 폼 수: ${summary.totalForms}개`); - console.log(`총 필드 수: ${summary.totalRequiredFields}개`); - console.log(`입력된 필드: ${summary.totalFilledFields}개`); - - // 프로젝트별 요약 - console.log('\n프로젝트별 완성도:'); - summary.projectBreakdown.forEach(project => { - console.log(` ${project.projectName}: ${project.completionPercentage}% (계약 ${project.contractsCount}개, 폼 ${project.formsCount}개)`); - }); - - // 계약별 세부 현황 - console.log('\n계약별 세부 현황:'); - summary.contracts.forEach(contract => { - console.log(` ${contract.itemName}: ${contract.averageCompletionPercentage}% (폼 ${contract.totalForms}개)`); - }); - } - - return summary; -} - -/** - * 예제 5: 모든 벤더들의 계약 완성도 요약 (관리자용) - */ -export async function exampleAllVendorsCompletion() { - const summary = await getAllVendorsContractsCompletionSummary(); - - console.log(`전체 벤더 수: ${summary.totalVendors}개`); - console.log(`전체 평균 완성도: ${summary.overallAverageCompletion}%`); - - // 상위 성과 벤더들 - console.log('\n상위 성과 벤더들:'); - summary.topPerformingVendors.forEach((vendor, index) => { - console.log(` ${index + 1}. ${vendor.vendorName}: ${vendor.completionPercentage}%`); - }); - - // 하위 성과 벤더들 - console.log('\n개선이 필요한 벤더들:'); - summary.lowPerformingVendors.forEach((vendor, index) => { - console.log(` ${index + 1}. ${vendor.vendorName}: ${vendor.completionPercentage}%`); - }); - - return summary; -} - -/** - * 예제 6: 모든 프로젝트의 벤더 완성도 요약 (관리자용) - */ -export async function exampleAllProjectsCompletion() { - const summary = await getAllProjectsVendorCompletionSummary(); - - console.log(`전체 프로젝트 수: ${summary.totalProjects}개`); - console.log(`전체 평균 완성도: ${summary.overallAverageCompletion}%`); - - // 프로젝트별 완성도 - console.log('\n프로젝트별 완성도:'); - summary.projects.forEach(project => { - console.log(` ${project.projectName}: ${project.averageCompletionPercentage}% (벤더 ${project.totalVendors}개)`); - }); - - return summary; -} - -/** - * 예제 7: 대시보드용 완성도 통계 생성 - */ -export async function generateCompletionDashboard() { - console.log('=== 벤더 입력 완성도 대시보드 ===\n'); - - // 전체 벤더 요약 - const allVendors = await getAllVendorsContractsCompletionSummary(); - console.log(`📊 전체 통계`); - console.log(`- 총 벤더 수: ${allVendors.totalVendors}개`); - console.log(`- 평균 완성도: ${allVendors.overallAverageCompletion}%`); - - // 전체 프로젝트 요약 - const allProjects = await getAllProjectsVendorCompletionSummary(); - console.log(`- 총 프로젝트 수: ${allProjects.totalProjects}개`); - console.log(`- 프로젝트 평균 완성도: ${allProjects.overallAverageCompletion}%\n`); - - // 우수 벤더 - console.log('🏆 우수 벤더 (완성도 90% 이상):'); - const excellentVendors = allVendors.vendors.filter(v => v.overallCompletionPercentage >= 90); - excellentVendors.forEach(vendor => { - console.log(` ✅ ${vendor.vendorName}: ${vendor.overallCompletionPercentage}%`); - }); - - // 주의 필요 벤더 - console.log('\n⚠️ 주의 필요 벤더 (완성도 50% 미만):'); - const warningVendors = allVendors.vendors.filter(v => v.overallCompletionPercentage < 50); - warningVendors.forEach(vendor => { - console.log(` 🔴 ${vendor.vendorName}: ${vendor.overallCompletionPercentage}%`); - }); - - return { - totalVendors: allVendors.totalVendors, - averageCompletion: allVendors.overallAverageCompletion, - excellentVendors: excellentVendors.length, - warningVendors: warningVendors.length, - topVendors: allVendors.topPerformingVendors, - lowVendors: allVendors.lowPerformingVendors - }; -} diff --git a/lib/forms/vendor-completion-stats.ts b/lib/forms/vendor-completion-stats.ts deleted file mode 100644 index 97efec30..00000000 --- a/lib/forms/vendor-completion-stats.ts +++ /dev/null @@ -1,1007 +0,0 @@ -"use server"; - -import db from "@/db/db"; -import { - formMetas, - formEntries, - tags, - tagClasses, - tagClassAttributes -} from "@/db/schema/vendorData"; -import { contractItems } from "@/db/schema/contract"; -import { contracts } from "@/db/schema/contract"; -import { projects } from "@/db/schema/projects"; -import { vendors } from "@/db/schema/vendors"; -import { eq, and, desc } from "drizzle-orm"; -import type { DataTableColumnJSON } from "@/components/form-data/form-data-table-columns"; - -export interface VendorFormCompletionStats { - vendorId: number; - vendorName: string; - contractItemId: number; - formCode: string; - formName: string; - totalRequiredFields: number; - totalFilledFields: number; - totalEmptyFields: number; - completionPercentage: number; - tagCount: number; - detailsByTag: Array<{ - tagNo: string; - requiredFields: number; - filledFields: number; - emptyFields: number; - completionPercentage: number; - }>; -} - -export interface ProjectVendorCompletionSummary { - projectId: number; - projectCode: string; - projectName: string; - vendors: VendorFormCompletionStats[]; - totalVendors: number; - averageCompletionPercentage: number; -} - -export interface VendorContractCompletionStats { - contractId: number; - contractItemId: number; - projectId: number; - projectCode: string; - projectName: string; - itemCode: string; - itemName: string; - forms: VendorFormCompletionStats[]; - totalForms: number; - totalRequiredFields: number; - totalFilledFields: number; - totalEmptyFields: number; - averageCompletionPercentage: number; -} - -export interface VendorAllContractsCompletionSummary { - vendorId: number; - vendorName: string; - contracts: VendorContractCompletionStats[]; - totalContracts: number; - totalForms: number; - totalTags: number; - totalRequiredFields: number; - totalFilledFields: number; - totalEmptyFields: number; - overallCompletionPercentage: number; - projectBreakdown: Array<{ - projectId: number; - projectCode: string; - projectName: string; - contractsCount: number; - formsCount: number; - completionPercentage: number; - }>; -} - -/** - * 필드가 벤더에 의해 편집 가능한지 확인 - * SHI 값이 "BOTH" 또는 "IN"인 경우만 벤더가 편집 가능 (대소문자 무관) - */ -function isFieldEditableByVendor(column: DataTableColumnJSON): boolean { - const shi = column.shi?.toString().toUpperCase(); - const isEditable = shi === "BOTH" || shi === "IN"; - console.log(`isFieldEditableByVendor - Key: ${column.key}, shi: ${column.shi}, upperShi: ${shi}, isEditable: ${isEditable}`); - return isEditable; -} - -/** - * 특정 태그에 대해 편집 가능한 필드 목록을 가져옴 - */ -async function getEditableFieldsForTag( - tagNo: string, - contractItemId: number, - projectId: number -): Promise<string[]> { - try { - // 1. 해당 태그 정보 조회 - const tagResult = await db - .select({ - tagClass: tags.class - }) - .from(tags) - .where( - and( - eq(tags.tagNo, tagNo), - eq(tags.contractItemId, contractItemId) - ) - ) - .limit(1); - - if (tagResult.length === 0) { - console.log(`getEditableFieldsForTag - No tag found for tagNo: ${tagNo}, contractItemId: ${contractItemId}`); - return []; - } - - console.log(`getEditableFieldsForTag - Found tag for tagNo: ${tagNo}, class: ${tagResult[0].tagClass}`); - - // 2. tagClasses에서 해당 class와 projectId로 tagClass 찾기 - const tagClassResult = await db - .select({ id: tagClasses.id }) - .from(tagClasses) - .where( - and( - eq(tagClasses.label, tagResult[0].tagClass), - eq(tagClasses.projectId, projectId) - ) - ) - .limit(1); - - if (tagClassResult.length === 0) { - console.log(`getEditableFieldsForTag - No tag class found for class: ${tagResult[0].tagClass}, projectId: ${projectId}`); - return []; - } - - console.log(`getEditableFieldsForTag - Found tag class: ${tagClassResult[0].id} for class: ${tagResult[0].tagClass}`); - - // 3. tagClassAttributes에서 편집 가능한 필드 목록 조회 - const editableAttributes = await db - .select({ attId: tagClassAttributes.attId }) - .from(tagClassAttributes) - .where(eq(tagClassAttributes.tagClassId, tagClassResult[0].id)) - .orderBy(tagClassAttributes.seq); - - console.log(`getEditableFieldsForTag - Found ${editableAttributes.length} editable attributes for tag class ${tagClassResult[0].id}:`, editableAttributes.map(attr => attr.attId)); - - return editableAttributes.map(attr => attr.attId); - } catch (error) { - console.error(`Error getting editable fields for tag ${tagNo}:`, error); - return []; - } -} - -/** - * 값이 "빈" 값인지 확인 - */ -function isEmptyValue(value: unknown): boolean { - if (value === null || value === undefined) return true; - if (typeof value === 'string') return value.trim() === ''; - if (typeof value === 'number') return isNaN(value); - return false; -} - -/** - * 특정 contract item의 form에 대한 벤더 입력 완성도 계산 - */ -export async function calculateVendorFormCompletion( - contractItemId: number, - formCode: string -): Promise<VendorFormCompletionStats | null> { - try { - // 1. Contract Item 정보 및 Vendor 정보 조회 - const contractInfo = await db - .select({ - projectId: projects.id, - projectCode: projects.code, - projectName: projects.name, - vendorId: vendors.id, - vendorName: vendors.vendorName, - contractId: contracts.id - }) - .from(contractItems) - .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) - .innerJoin(projects, eq(contracts.projectId, projects.id)) - .innerJoin(vendors, eq(contracts.vendorId, vendors.id)) - .where(eq(contractItems.id, contractItemId)) - .limit(1); - - if (contractInfo.length === 0) { - console.warn(`No contract item found with ID: ${contractItemId}`); - return null; - } - - const { projectId, vendorId, vendorName } = contractInfo[0]; - - // 2. Form 메타데이터 조회 (컬럼 정의) - const metaRows = await db - .select() - .from(formMetas) - .where(eq(formMetas.formCode, formCode)) - .orderBy(desc(formMetas.updatedAt)) - .limit(1); - - const meta = metaRows[0]; - if (!meta) { - console.warn(`No form meta found for formCode: ${formCode} and projectId: ${projectId}`); - return null; - } - - console.log(`calculateVendorFormCompletion - Found form meta for formCode: ${formCode}, projectId: ${projectId}, columns type: ${typeof meta.columns}, isArray: ${Array.isArray(meta.columns)}`); - - // 3. Form 실제 데이터 조회 - const entryRows = await db - .select() - .from(formEntries) - .where( - and( - eq(formEntries.formCode, formCode), - eq(formEntries.contractItemId, contractItemId) - ) - ) - .orderBy(desc(formEntries.updatedAt)) - .limit(1); - - const entry = entryRows[0]; - if (!entry || !Array.isArray(entry.data)) { - console.warn(`No form data found for formCode: ${formCode} and contractItemId: ${contractItemId}`); - return null; - } - - // 4. 컬럼 정의에서 벤더가 편집 가능한 필드 필터링 - const columns = meta.columns as DataTableColumnJSON[]; - const excludeKeys = ['BF_TAG_NO', 'TAG_TYPE_ID', 'PIC_NO', 'status']; - const editableColumns = columns.filter(col => - !excludeKeys.includes(col.key) && isFieldEditableByVendor(col) - ); - - console.log(`calculateVendorFormCompletion - Total columns: ${columns.length}, Editable columns: ${editableColumns.length}`); - console.log(`calculateVendorFormCompletion - Editable column keys:`, editableColumns.map(col => col.key)); - console.log(`calculateVendorFormCompletion - All column keys:`, columns.map(col => col.key)); - console.log(`calculateVendorFormCompletion - All column shi values:`, columns.map(col => col.shi)); - - // 5. 각 태그별로 완성도 계산 - const detailsByTag: VendorFormCompletionStats['detailsByTag'] = []; - let totalRequiredFields = 0; - let totalFilledFields = 0; - - const formData = entry.data as Array<Record<string, unknown>>; - - for (const rowData of formData) { - const tagNo = rowData.TAG_NO as string; - if (!tagNo) continue; - - // Debug 페이지와 동일하게 직접 editableColumns 사용 (getEditableFieldsForTag 대신) - const actualEditableFields = editableColumns; - - const requiredFieldsCount = actualEditableFields.length; - let filledFieldsCount = 0; - - // 각 편집 가능한 필드의 값 확인 - for (const column of actualEditableFields) { - const value = rowData[column.key]; - if (!isEmptyValue(value)) { - filledFieldsCount++; - } - } - - const emptyFieldsCount = requiredFieldsCount - filledFieldsCount; - const completionPercentage = requiredFieldsCount > 0 - ? Math.round((filledFieldsCount / requiredFieldsCount) * 100) - : 100; - - detailsByTag.push({ - tagNo: tagNo as string, - requiredFields: requiredFieldsCount, - filledFields: filledFieldsCount, - emptyFields: emptyFieldsCount, - completionPercentage - }); - - totalRequiredFields += requiredFieldsCount; - totalFilledFields += filledFieldsCount; - } - - const totalEmptyFields = totalRequiredFields - totalFilledFields; - const overallCompletionPercentage = totalRequiredFields > 0 - ? Math.round((totalFilledFields / totalRequiredFields) * 100) - : 100; - - return { - vendorId, - vendorName, - contractItemId, - formCode, - formName: meta.formName, - totalRequiredFields, - totalFilledFields, - totalEmptyFields, - completionPercentage: overallCompletionPercentage, - tagCount: formData.length, - detailsByTag - }; - - } catch (error) { - console.error(`Error calculating vendor form completion:`, error); - return null; - } -} - -/** - * 프로젝트의 모든 벤더들에 대한 특정 form의 입력 완성도 요약 - */ -export async function getProjectVendorCompletionSummary( - projectId: number, - formCode: string -): Promise<ProjectVendorCompletionSummary | null> { - try { - // 1. 프로젝트 정보 조회 - const projectInfo = await db - .select({ - id: projects.id, - code: projects.code, - name: projects.name - }) - .from(projects) - .where(eq(projects.id, projectId)) - .limit(1); - - if (projectInfo.length === 0) { - console.warn(`No project found with ID: ${projectId}`); - return null; - } - - const project = projectInfo[0]; - - // 2. 해당 프로젝트의 모든 contract items 조회 (formCode와 연관된) - const contractItemsInfo = await db - .select({ - contractItemId: contractItems.id, - vendorId: vendors.id, - vendorName: vendors.vendorName - }) - .from(contractItems) - .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) - .innerJoin(vendors, eq(contracts.vendorId, vendors.id)) - .innerJoin(formEntries, and( - eq(formEntries.contractItemId, contractItems.id), - eq(formEntries.formCode, formCode) - )) - .where(eq(contracts.projectId, projectId)); - - // 3. 각 contract item별로 완성도 계산 - const vendorStats: VendorFormCompletionStats[] = []; - - for (const item of contractItemsInfo) { - const stats = await calculateVendorFormCompletion( - item.contractItemId, - formCode - ); - - if (stats) { - vendorStats.push(stats); - } - } - - // 4. 전체 평균 완성도 계산 - const averageCompletionPercentage = vendorStats.length > 0 - ? Math.round( - vendorStats.reduce((sum, stat) => sum + stat.completionPercentage, 0) / vendorStats.length - ) - : 0; - - return { - projectId: project.id, - projectCode: project.code, - projectName: project.name, - vendors: vendorStats, - totalVendors: vendorStats.length, - averageCompletionPercentage - }; - - } catch (error) { - console.error(`Error getting project vendor completion summary:`, error); - return null; - } -} - -/** - * 특정 벤더의 특정 계약(contract item)에 대한 모든 form 완성도 계산 - */ -export async function calculateVendorContractCompletion( - vendorId: number, - contractItemId: number -): Promise<VendorContractCompletionStats | null> { - try { - // 1. Contract Item 정보 조회 - const contractItemInfo = await db - .select({ - contractId: contracts.id, - contractItemId: contractItems.id, - projectId: projects.id, - projectCode: projects.code, - projectName: projects.name, - itemId: contractItems.itemId, - description: contractItems.description, - vendorId: vendors.id, - vendorName: vendors.vendorName - }) - .from(contractItems) - .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) - .innerJoin(projects, eq(contracts.projectId, projects.id)) - .innerJoin(vendors, eq(contracts.vendorId, vendors.id)) - .where( - and( - eq(contractItems.id, contractItemId), - eq(vendors.id, vendorId) - ) - ) - .limit(1); - - if (contractItemInfo.length === 0) { - console.warn(`No contract item found for vendorId: ${vendorId}, contractItemId: ${contractItemId}`); - return null; - } - - const contractInfo = contractItemInfo[0]; - - // 2. 해당 contract item과 연관된 모든 form codes 조회 - const formCodes = await db - .selectDistinct({ - formCode: formEntries.formCode - }) - .from(formEntries) - .where(eq(formEntries.contractItemId, contractItemId)); - - // 3. 각 form에 대한 완성도 계산 - const formStats: VendorFormCompletionStats[] = []; - let totalRequiredFields = 0; - let totalFilledFields = 0; - - for (const { formCode } of formCodes) { - const formCompletion = await calculateVendorFormCompletion(contractItemId, formCode); - if (formCompletion) { - formStats.push(formCompletion); - totalRequiredFields += formCompletion.totalRequiredFields; - totalFilledFields += formCompletion.totalFilledFields; - } - } - - const totalEmptyFields = totalRequiredFields - totalFilledFields; - const averageCompletionPercentage = formStats.length > 0 - ? Math.round( - formStats.reduce((sum, stat) => sum + stat.completionPercentage, 0) / formStats.length - ) - : 0; - - return { - contractId: contractInfo.contractId, - contractItemId: contractInfo.contractItemId, - projectId: contractInfo.projectId, - projectCode: contractInfo.projectCode, - projectName: contractInfo.projectName, - itemCode: contractInfo.itemId?.toString() || '', - itemName: contractInfo.description || '', - forms: formStats, - totalForms: formStats.length, - totalRequiredFields, - totalFilledFields, - totalEmptyFields, - averageCompletionPercentage - }; - - } catch (error) { - console.error(`Error calculating vendor contract completion:`, error); - return null; - } -} - -/** - * 특정 벤더가 보유한 모든 계약에 대한 입력 완성도 요약 - */ -export async function getVendorAllContractsCompletionSummary( - vendorId: number -): Promise<VendorAllContractsCompletionSummary | null> { - try { - // 1. 벤더 정보 조회 - const vendorInfo = await db - .select({ - id: vendors.id, - vendorName: vendors.vendorName - }) - .from(vendors) - .where(eq(vendors.id, vendorId)) - .limit(1); - - if (vendorInfo.length === 0) { - console.warn(`No vendor found with ID: ${vendorId}`); - return null; - } - - const vendor = vendorInfo[0]; - - // 2. 해당 벤더의 모든 contract items 조회 - const contractItemsInfo = await db - .select({ - contractId: contracts.id, - contractItemId: contractItems.id, - projectId: projects.id, - projectCode: projects.code, - projectName: projects.name, - itemId: contractItems.itemId, - description: contractItems.description - }) - .from(contractItems) - .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) - .innerJoin(projects, eq(contracts.projectId, projects.id)) - .where(eq(contracts.vendorId, vendorId)); - - // 3. 각 contract item별로 완성도 계산 - const contractStats: VendorContractCompletionStats[] = []; - - for (const item of contractItemsInfo) { - console.log(`getVendorAllContractsCompletionSummary - Processing contract item: ${item.contractItemId} for vendor: ${vendorId}`); - const contractCompletion = await calculateVendorContractCompletion( - vendorId, - item.contractItemId - ); - - if (contractCompletion) { - console.log(`getVendorAllContractsCompletionSummary - Contract completion for item ${item.contractItemId}:`, { - totalRequiredFields: contractCompletion.totalRequiredFields, - totalFilledFields: contractCompletion.totalFilledFields, - totalForms: contractCompletion.totalForms - }); - contractStats.push(contractCompletion); - } else { - console.log(`getVendorAllContractsCompletionSummary - No contract completion for item: ${item.contractItemId}`); - } - } - - // 4. 전체 통계 계산 - const totalRequiredFields = contractStats.reduce((sum, stat) => sum + stat.totalRequiredFields, 0); - const totalFilledFields = contractStats.reduce((sum, stat) => sum + stat.totalFilledFields, 0); - const totalEmptyFields = totalRequiredFields - totalFilledFields; - const totalForms = contractStats.reduce((sum, stat) => sum + stat.totalForms, 0); - const totalTags = contractStats.reduce((sum, stat) => - sum + stat.forms.reduce((formSum, form) => formSum + form.tagCount, 0), 0 - ); - - const overallCompletionPercentage = totalRequiredFields > 0 - ? Math.round((totalFilledFields / totalRequiredFields) * 100) - : 100; - - // 5. 프로젝트별 요약 계산 - const projectMap = new Map<number, { - projectId: number; - projectCode: string; - projectName: string; - contracts: VendorContractCompletionStats[]; - }>(); - - contractStats.forEach(contract => { - const key = contract.projectId; - if (!projectMap.has(key)) { - projectMap.set(key, { - projectId: contract.projectId, - projectCode: contract.projectCode, - projectName: contract.projectName, - contracts: [] - }); - } - projectMap.get(key)!.contracts.push(contract); - }); - - const projectBreakdown = Array.from(projectMap.values()).map(project => { - const projectTotalRequired = project.contracts.reduce((sum, c) => sum + c.totalRequiredFields, 0); - const projectTotalFilled = project.contracts.reduce((sum, c) => sum + c.totalFilledFields, 0); - const projectCompletionPercentage = projectTotalRequired > 0 - ? Math.round((projectTotalFilled / projectTotalRequired) * 100) - : 100; - - return { - projectId: project.projectId, - projectCode: project.projectCode, - projectName: project.projectName, - contractsCount: project.contracts.length, - formsCount: project.contracts.reduce((sum, c) => sum + c.totalForms, 0), - completionPercentage: projectCompletionPercentage - }; - }); - - return { - vendorId: vendor.id, - vendorName: vendor.vendorName, - contracts: contractStats, - totalContracts: contractStats.length, - totalForms, - totalTags, - totalRequiredFields, - totalFilledFields, - totalEmptyFields, - overallCompletionPercentage, - projectBreakdown - }; - - } catch (error) { - console.error(`Error getting vendor all contracts completion summary:`, error); - return null; - } -} - -/** - * 모든 프로젝트의 모든 form에 대한 벤더 완성도 요약 (관리자용) - */ -export async function getAllProjectsVendorCompletionSummary(): Promise<{ - projects: ProjectVendorCompletionSummary[]; - totalProjects: number; - overallAverageCompletion: number; -}> { - try { - // 1. 모든 프로젝트 조회 - const allProjects = await db - .select({ - id: projects.id, - code: projects.code, - name: projects.name - }) - .from(projects); - - // 2. 각 프로젝트별로 form들의 완성도 조회 - const projectSummaries: ProjectVendorCompletionSummary[] = []; - - for (const project of allProjects) { - // 해당 프로젝트의 모든 form codes 조회 - const formCodes = await db - .selectDistinct({ - formCode: formMetas.formCode - }) - .from(formMetas) - .where(eq(formMetas.projectId, project.id)); - - // 각 form에 대한 완성도 조회 후 통합 - const allVendorStats: VendorFormCompletionStats[] = []; - - for (const { formCode } of formCodes) { - const summary = await getProjectVendorCompletionSummary(project.id, formCode); - if (summary) { - allVendorStats.push(...summary.vendors); - } - } - - if (allVendorStats.length > 0) { - const averageCompletion = Math.round( - allVendorStats.reduce((sum, stat) => sum + stat.completionPercentage, 0) / allVendorStats.length - ); - - projectSummaries.push({ - projectId: project.id, - projectCode: project.code, - projectName: project.name, - vendors: allVendorStats, - totalVendors: allVendorStats.length, - averageCompletionPercentage: averageCompletion - }); - } - } - - // 3. 전체 평균 계산 - const overallAverageCompletion = projectSummaries.length > 0 - ? Math.round( - projectSummaries.reduce((sum, proj) => sum + proj.averageCompletionPercentage, 0) / projectSummaries.length - ) - : 0; - - return { - projects: projectSummaries, - totalProjects: projectSummaries.length, - overallAverageCompletion - }; - - } catch (error) { - console.error(`Error getting all projects vendor completion summary:`, error); - return { - projects: [], - totalProjects: 0, - overallAverageCompletion: 0 - }; - } -} - -/** - * 특정 벤더의 필드 계산 상세 정보를 디버깅용으로 반환 - */ -export async function debugVendorFieldCalculation(vendorId: number): Promise<{ - vendorId: number; - vendorName: string; - debugInfo: { - contracts: Array<{ - contractId: number; - contractItemId: number; - projectName: string; - forms: Array<{ - formCode: string; - formName: string; - tags: Array<{ - tagNo: string; - editableFields: string[]; - requiredFieldsCount: number; - filledFieldsCount: number; - fieldDetails: Array<{ - fieldKey: string; - fieldValue: unknown; - isEmpty: boolean; - }>; - }>; - totalRequiredFields: number; - totalFilledFields: number; - }>; - totalRequiredFields: number; - totalFilledFields: number; - }>; - grandTotal: { - totalRequiredFields: number; - totalFilledFields: number; - totalEmptyFields: number; - completionPercentage: number; - }; - }; -} | null> { - try { - // 1. 벤더 정보 조회 - const vendorInfo = await db - .select({ - id: vendors.id, - vendorName: vendors.vendorName - }) - .from(vendors) - .where(eq(vendors.id, vendorId)) - .limit(1); - - if (vendorInfo.length === 0) { - console.warn(`No vendor found with ID: ${vendorId}`); - return null; - } - - const vendor = vendorInfo[0]; - - // 2. 해당 벤더의 모든 contract items 조회 - const contractItemsInfo = await db - .select({ - contractId: contracts.id, - contractItemId: contractItems.id, - projectId: projects.id, - projectCode: projects.code, - projectName: projects.name, - itemId: contractItems.itemId, - description: contractItems.description - }) - .from(contractItems) - .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) - .innerJoin(projects, eq(contracts.projectId, projects.id)) - .where(eq(contracts.vendorId, vendorId)); - - const debugContracts = []; - - for (const item of contractItemsInfo) { - // 3. 해당 contract item과 연관된 모든 form codes 조회 - const formCodes = await db - .selectDistinct({ - formCode: formEntries.formCode - }) - .from(formEntries) - .where(eq(formEntries.contractItemId, item.contractItemId)); - - const debugForms = []; - let contractTotalRequired = 0; - let contractTotalFilled = 0; - - for (const { formCode } of formCodes) { - // 4. Form 메타데이터 조회 - const metaRows = await db - .select() - .from(formMetas) - .where(eq(formMetas.formCode, formCode)) - .orderBy(desc(formMetas.updatedAt)) - .limit(1); - - const meta = metaRows[0]; - if (!meta) { - console.log(`No form meta found for formCode: ${formCode}, projectId: ${item.projectId}`); - continue; - } - - console.log(`Found form meta for formCode: ${formCode}, projectId: ${item.projectId}, columns type: ${typeof meta.columns}, isArray: ${Array.isArray(meta.columns)}`); - - // 5. Form 실제 데이터 조회 - const entryRows = await db - .select() - .from(formEntries) - .where( - and( - eq(formEntries.formCode, formCode), - eq(formEntries.contractItemId, item.contractItemId) - ) - ) - .orderBy(desc(formEntries.updatedAt)) - .limit(1); - - const entry = entryRows[0]; - if (!entry || !Array.isArray(entry.data)) continue; - - // 6. 컬럼 정의에서 벤더가 편집 가능한 필드 필터링 - const columns = meta.columns as DataTableColumnJSON[]; - const excludeKeys = ['BF_TAG_NO', 'TAG_TYPE_ID', 'PIC_NO', 'status']; - const editableColumns = columns.filter(col => - !excludeKeys.includes(col.key) && isFieldEditableByVendor(col) - ); - - const debugTags = []; - let formTotalRequired = 0; - let formTotalFilled = 0; - - const formData = entry.data as Array<Record<string, unknown>>; - - for (const rowData of formData) { - const tagNo = rowData.TAG_NO as string; - if (!tagNo) continue; - - // 직접 editableColumns 사용 (getEditableFieldsForTag 대신) - const actualEditableFields = editableColumns; - - const requiredFieldsCount = actualEditableFields.length; - let filledFieldsCount = 0; - - const fieldDetails = []; - // 각 편집 가능한 필드의 값 확인 - for (const column of actualEditableFields) { - const value = rowData[column.key]; - const isEmpty = isEmptyValue(value); - if (!isEmpty) { - filledFieldsCount++; - } - fieldDetails.push({ - fieldKey: column.key, - fieldValue: value, - isEmpty - }); - } - - debugTags.push({ - tagNo, - editableFields: actualEditableFields.map(col => col.key), - requiredFieldsCount, - filledFieldsCount, - fieldDetails - }); - - formTotalRequired += requiredFieldsCount; - formTotalFilled += filledFieldsCount; - } - - debugForms.push({ - formCode, - formName: meta.formName, - tags: debugTags, - totalRequiredFields: formTotalRequired, - totalFilledFields: formTotalFilled - }); - - contractTotalRequired += formTotalRequired; - contractTotalFilled += formTotalFilled; - } - - debugContracts.push({ - contractId: item.contractId, - contractItemId: item.contractItemId, - projectName: item.projectName, - forms: debugForms, - totalRequiredFields: contractTotalRequired, - totalFilledFields: contractTotalFilled - }); - } - - // 전체 합계 계산 - const grandTotalRequired = debugContracts.reduce((sum, contract) => sum + contract.totalRequiredFields, 0); - const grandTotalFilled = debugContracts.reduce((sum, contract) => sum + contract.totalFilledFields, 0); - const grandTotalEmpty = grandTotalRequired - grandTotalFilled; - const grandCompletionPercentage = grandTotalRequired > 0 - ? Math.round((grandTotalFilled / grandTotalRequired) * 100) - : 100; - - return { - vendorId: vendor.id, - vendorName: vendor.vendorName, - debugInfo: { - contracts: debugContracts, - grandTotal: { - totalRequiredFields: grandTotalRequired, - totalFilledFields: grandTotalFilled, - totalEmptyFields: grandTotalEmpty, - completionPercentage: grandCompletionPercentage - } - } - }; - - } catch (error) { - console.error(`Error debugging vendor field calculation:`, error); - return null; - } -} - -/** - * 모든 벤더들의 전체 계약 완성도 요약 (관리자용) - */ -export async function getAllVendorsContractsCompletionSummary(): Promise<{ - vendors: VendorAllContractsCompletionSummary[]; - totalVendors: number; - overallAverageCompletion: number; - topPerformingVendors: Array<{ - vendorId: number; - vendorName: string; - completionPercentage: number; - }>; - lowPerformingVendors: Array<{ - vendorId: number; - vendorName: string; - completionPercentage: number; - }>; -}> { - try { - // 1. 계약이 있는 모든 벤더 조회 - const vendorsWithContracts = await db - .selectDistinct({ - vendorId: vendors.id, - vendorName: vendors.vendorName - }) - .from(vendors) - .innerJoin(contracts, eq(contracts.vendorId, vendors.id)) - .innerJoin(contractItems, eq(contractItems.contractId, contracts.id)); - - // 2. 각 벤더별로 완성도 계산 - const vendorSummaries: VendorAllContractsCompletionSummary[] = []; - - for (const vendor of vendorsWithContracts) { - console.log(`getAllVendorsContractsCompletionSummary - Processing vendor: ${vendor.vendorId} (${vendor.vendorName})`); - const summary = await getVendorAllContractsCompletionSummary(vendor.vendorId); - if (summary) { - console.log(`getAllVendorsContractsCompletionSummary - Vendor ${vendor.vendorId} summary:`, { - totalRequiredFields: summary.totalRequiredFields, - totalFilledFields: summary.totalFilledFields, - totalTags: summary.totalTags, - totalForms: summary.totalForms - }); - vendorSummaries.push(summary); - } else { - console.log(`getAllVendorsContractsCompletionSummary - No summary for vendor: ${vendor.vendorId}`); - } - } - - // 3. 전체 평균 계산 - const overallAverageCompletion = vendorSummaries.length > 0 - ? Math.round( - vendorSummaries.reduce((sum, vendor) => sum + vendor.overallCompletionPercentage, 0) / vendorSummaries.length - ) - : 0; - - // 4. 상위/하위 성과 벤더 추출 (상위 5개, 하위 5개) - const sortedVendors = [...vendorSummaries].sort((a, b) => b.overallCompletionPercentage - a.overallCompletionPercentage); - - const topPerformingVendors = sortedVendors.slice(0, 5).map(vendor => ({ - vendorId: vendor.vendorId, - vendorName: vendor.vendorName, - completionPercentage: vendor.overallCompletionPercentage - })); - - const lowPerformingVendors = sortedVendors.slice(-5).reverse().map(vendor => ({ - vendorId: vendor.vendorId, - vendorName: vendor.vendorName, - completionPercentage: vendor.overallCompletionPercentage - })); - - return { - vendors: vendorSummaries, - totalVendors: vendorSummaries.length, - overallAverageCompletion, - topPerformingVendors, - lowPerformingVendors - }; - - } catch (error) { - console.error(`Error getting all vendors contracts completion summary:`, error); - return { - vendors: [], - totalVendors: 0, - overallAverageCompletion: 0, - topPerformingVendors: [], - lowPerformingVendors: [] - }; - } -} - diff --git a/lib/gtc-contract/gtc-clauses/table/gtc-clauses-table-toolbar-actions.tsx b/lib/gtc-contract/gtc-clauses/table/gtc-clauses-table-toolbar-actions.tsx index ea516f49..70cec7fa 100644 --- a/lib/gtc-contract/gtc-clauses/table/gtc-clauses-table-toolbar-actions.tsx +++ b/lib/gtc-contract/gtc-clauses/table/gtc-clauses-table-toolbar-actions.tsx @@ -101,7 +101,6 @@ export function GtcClausesTableToolbarActions({ table, documentId, document, - currentUserId = 1, // 기본값 설정 (실제로는 auth에서 가져와야 함) }: GtcClausesTableToolbarActionsProps) { const [showCreateDialog, setShowCreateDialog] = React.useState(false) const [showReorderDialog, setShowReorderDialog] = React.useState(false) @@ -161,7 +160,7 @@ export function GtcClausesTableToolbarActions({ // Excel 데이터 가져오기 처리 const handleImportExcelData = async (data: Partial<GtcClauseTreeView>[]) => { try { - const result = await importGtcClausesFromExcel(documentId, data, currentUserId) + const result = await importGtcClausesFromExcel(documentId, data) if (result.success) { toast({ diff --git a/lib/gtc-contract/service.ts b/lib/gtc-contract/service.ts index f9725f80..0d21f7aa 100644 --- a/lib/gtc-contract/service.ts +++ b/lib/gtc-contract/service.ts @@ -9,7 +9,8 @@ import { users } from "@/db/schema/users" import { vendors } from "@/db/schema/vendors" import { filterColumns } from "@/lib/filter-columns" import type { GetGtcDocumentsSchema, CreateGtcDocumentSchema, UpdateGtcDocumentSchema, CreateNewRevisionSchema, CloneGtcDocumentSchema } from "./validations" - +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" /** * 프로젝트 존재 여부 확인 */ @@ -568,7 +569,6 @@ interface ImportResult { export async function importGtcClausesFromExcel( documentId: number, data: Partial<GtcClauseTreeView>[], - userId: number = 1 // TODO: 실제 사용자 ID로 교체 ): Promise<ImportResult> { const result: ImportResult = { success: false, @@ -577,6 +577,13 @@ export async function importGtcClausesFromExcel( duplicates: [] } + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("인증이 필요합니다.") + } + + const userId = Number(session.user.id) + try { // 데이터 검증 및 변환 const validData: ImportGtcClauseData[] = [] diff --git a/lib/procurement-select/service.ts b/lib/procurement-select/service.ts new file mode 100644 index 00000000..14fe54d7 --- /dev/null +++ b/lib/procurement-select/service.ts @@ -0,0 +1,85 @@ +'use server' + +import db from "@/db/db" +import { + incoterms, placeOfShipping, paymentTerms +} from "@/db/schema" +import { eq } from "drizzle-orm" + +export async function getIncotermsForSelection() { + try { + const data = await db + .select({ + code: incoterms.code, + description: incoterms.description, + }) + .from(incoterms) + .where(eq(incoterms.isActive, true)) + .orderBy(incoterms.code) + + return data + + } catch (error) { + console.error("Error fetching incoterms:", error) + throw new Error("Failed to fetch incoterms") + } +} + + +export async function getPaymentTermsForSelection() { + try { + const data = await db + .select({ + code: paymentTerms.code, + description: paymentTerms.description, + }) + .from(paymentTerms) + .where(eq(paymentTerms.isActive, true)) + .orderBy(paymentTerms.code) + + return data + + } catch (error) { + console.error("Error fetching paymentTerms:", error) + throw new Error("Failed to fetch paymentTerms") + } +} + + +export async function getPlaceOfShippingForSelection() { + try { + const data = await db + .select({ + code: placeOfShipping.code, + description: placeOfShipping.description, + }) + .from(placeOfShipping) + .where(eq(placeOfShipping.isActive, true)) + .orderBy(placeOfShipping.code) + + return data + + } catch (error) { + console.error("Error fetching placeOfShipping:", error) + throw new Error("Failed to fetch placeOfShipping") + } +} + +export async function getPlaceOfDestinationForSelection() { + try { + const data = await db + .select({ + code: placeOfShipping.code, + description: placeOfShipping.description, + }) + .from(placeOfShipping) + .where(eq(placeOfShipping.isActive, true)) + .orderBy(placeOfShipping.code) + + return data + + } catch (error) { + console.error("Error fetching placeOfShipping:", error) + throw new Error("Failed to fetch placeOfShipping") + } +}
\ No newline at end of file diff --git a/lib/rfq-last/attachment/revision-historty-dialog.tsx b/lib/rfq-last/attachment/revision-historty-dialog.tsx new file mode 100644 index 00000000..6e4772cb --- /dev/null +++ b/lib/rfq-last/attachment/revision-historty-dialog.tsx @@ -0,0 +1,305 @@ +// @/lib/rfq-last/attachment/revision-history-dialog.tsx + +"use client"; + +import * as React from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + Download, + Eye, + FileText, + Clock, + User, + MessageSquare, + AlertCircle, + CheckCircle, +} from "lucide-react"; +import { format, formatDistanceToNow } from "date-fns"; +import { ko } from "date-fns/locale"; +import { toast } from "sonner"; +import { downloadFile } from "@/lib/file-download"; +import { + getRevisionHistory, + type AttachmentWithHistory, + type RevisionHistory, +} from "../service"; +import { formatFileSize } from "@/lib/utils"; + +interface RevisionHistoryDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + attachmentId: number; + attachmentName?: string; +} + +export function RevisionHistoryDialog({ + open, + onOpenChange, + attachmentId, + attachmentName, +}: RevisionHistoryDialogProps) { + const [loading, setLoading] = React.useState(false); + const [historyData, setHistoryData] = React.useState<AttachmentWithHistory | null>(null); + const [error, setError] = React.useState<string | null>(null); + + // 다이얼로그가 열릴 때 데이터 로드 + React.useEffect(() => { + if (open && attachmentId) { + loadRevisionHistory(); + } + }, [open, attachmentId]); + + const loadRevisionHistory = async () => { + setLoading(true); + setError(null); + try { + const result = await getRevisionHistory(attachmentId); + if (result.success && result.data) { + setHistoryData(result.data); + } else { + setError(result.error || "리비전 히스토리를 불러올 수 없습니다."); + } + } catch (err) { + console.error("Load revision history error:", err); + setError("리비전 히스토리 조회 중 오류가 발생했습니다."); + } finally { + setLoading(false); + } + }; + + // 리비전 다운로드 + const handleDownloadRevision = async (revision: RevisionHistory) => { + try { + await downloadFile(revision.filePath, revision.originalFileName, { + action: 'download', + showToast: true, + }); + } catch (err) { + console.error("Download revision error:", err); + toast.error("파일 다운로드 중 오류가 발생했습니다."); + } + }; + + // 리비전 미리보기 + const handlePreviewRevision = async (revision: RevisionHistory) => { + try { + await downloadFile(revision.filePath, revision.originalFileName, { + action: 'preview', + showToast: true, + }); + } catch (err) { + console.error("Preview revision error:", err); + toast.error("파일 미리보기 중 오류가 발생했습니다."); + } + }; + + // 리비전 번호에 따른 색상 결정 + const getRevisionBadgeVariant = (isLatest: boolean) => { + return isLatest ? "default" : "secondary"; + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-5xl max-h-[80vh]"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <FileText className="h-5 w-5" /> + 리비전 히스토리 + </DialogTitle> + <DialogDescription> + {historyData?.originalFileName || attachmentName || "파일"}의 모든 버전 히스토리를 확인할 수 있습니다. + </DialogDescription> + </DialogHeader> + + <div className="mt-4"> + {loading ? ( + <div className="space-y-3"> + <Skeleton className="h-10 w-full" /> + <Skeleton className="h-10 w-full" /> + <Skeleton className="h-10 w-full" /> + </div> + ) : error ? ( + <Alert variant="destructive"> + <AlertCircle className="h-4 w-4" /> + <AlertDescription>{error}</AlertDescription> + </Alert> + ) : historyData ? ( + <> + {/* 파일 정보 헤더 */} + <div className="mb-4 p-3 bg-muted rounded-lg"> + <div className="grid grid-cols-2 gap-2 text-sm"> + <div> + <span className="text-muted-foreground">일련번호:</span>{" "} + <span className="font-medium font-mono">{historyData.serialNo || "-"}</span> + </div> + <div> + <span className="text-muted-foreground">현재 리비전:</span>{" "} + <Badge variant="default" className="ml-1"> + Rev. {historyData.currentRevision || "A"} + </Badge> + </div> + {historyData.description && ( + <div className="col-span-2"> + <span className="text-muted-foreground">설명:</span>{" "} + <span className="font-medium">{historyData.description}</span> + </div> + )} + </div> + </div> + + {/* 리비전 테이블 */} + <ScrollArea className="h-[400px] rounded-md border"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[80px]">리비전</TableHead> + <TableHead>파일명</TableHead> + <TableHead className="w-[80px]">크기</TableHead> + <TableHead className="w-[100px]">업로드자</TableHead> + <TableHead className="w-[150px]">업로드일시</TableHead> + <TableHead>코멘트</TableHead> + <TableHead className="w-[100px] text-center">작업</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {historyData.revisions.length > 0 ? ( + historyData.revisions.map((revision) => ( + <TableRow key={revision.id}> + <TableCell> + <Badge + variant={getRevisionBadgeVariant(revision.isLatest)} + className="font-mono" + > + Rev. {revision.revisionNo} + {revision.isLatest && ( + <CheckCircle className="ml-1 h-3 w-3" /> + )} + </Badge> + </TableCell> + <TableCell> + <div className="flex flex-col"> + <span className="text-sm font-medium truncate max-w-[200px]" title={revision.originalFileName}> + {revision.originalFileName} + </span> + {revision.fileName !== revision.originalFileName && ( + <span className="text-xs text-muted-foreground truncate max-w-[200px]"> + ({revision.fileName}) + </span> + )} + </div> + </TableCell> + <TableCell> + <span className="text-sm text-muted-foreground"> + {formatFileSize(revision.fileSize)} + </span> + </TableCell> + <TableCell> + <div className="flex items-center gap-1"> + <User className="h-3 w-3 text-muted-foreground" /> + <span className="text-sm"> + {revision.createdByName || "Unknown"} + </span> + </div> + </TableCell> + <TableCell> + <div className="flex items-center gap-1"> + <Clock className="h-3 w-3 text-muted-foreground" /> + <span className="text-sm"> + {format(new Date(revision.createdAt), "yyyy-MM-dd HH:mm")} + </span> + </div> + <span className="text-xs text-muted-foreground"> + {formatDistanceToNow(new Date(revision.createdAt), { + addSuffix: true, + locale: ko, + })} + </span> + </TableCell> + <TableCell> + {revision.revisionComment ? ( + <div className="flex items-start gap-1"> + <MessageSquare className="h-3 w-3 text-muted-foreground mt-0.5" /> + <span className="text-sm text-muted-foreground"> + {revision.revisionComment} + </span> + </div> + ) : ( + <span className="text-sm text-muted-foreground">-</span> + )} + </TableCell> + <TableCell> + <div className="flex items-center justify-center gap-1"> + <Button + variant="ghost" + size="icon" + className="h-8 w-8" + onClick={() => handleDownloadRevision(revision)} + title="다운로드" + > + <Download className="h-4 w-4" /> + </Button> + <Button + variant="ghost" + size="icon" + className="h-8 w-8" + onClick={() => handlePreviewRevision(revision)} + title="미리보기" + > + <Eye className="h-4 w-4" /> + </Button> + </div> + </TableCell> + </TableRow> + )) + ) : ( + <TableRow> + <TableCell colSpan={7} className="text-center text-muted-foreground py-8"> + 리비전 히스토리가 없습니다. + </TableCell> + </TableRow> + )} + </TableBody> + </Table> + </ScrollArea> + + {/* 요약 정보 */} + <div className="mt-4 flex items-center justify-between text-sm text-muted-foreground"> + <span>총 {historyData.revisions.length}개의 리비전</span> + <span> + 최초 업로드:{" "} + {historyData.revisions.length > 0 + ? format( + new Date( + historyData.revisions[historyData.revisions.length - 1].createdAt + ), + "yyyy년 MM월 dd일" + ) + : "-"} + </span> + </div> + </> + ) : null} + </div> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/lib/rfq-last/attachment/rfq-attachments-table.tsx b/lib/rfq-last/attachment/rfq-attachments-table.tsx index a66e12a2..155fd412 100644 --- a/lib/rfq-last/attachment/rfq-attachments-table.tsx +++ b/lib/rfq-last/attachment/rfq-attachments-table.tsx @@ -1,7 +1,6 @@ "use client"; import * as React from "react"; -import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Card, CardContent } from "@/components/ui/card"; @@ -24,10 +23,8 @@ import { format, formatDistanceToNow } from "date-fns"; import { ko } from "date-fns/locale"; import { type ColumnDef } from "@tanstack/react-table"; import { Checkbox } from "@/components/ui/checkbox"; -import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"; -import { useDataTable } from "@/hooks/use-data-table"; -import { DataTable } from "@/components/data-table/data-table"; -import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"; +import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header"; +import { ClientDataTable } from "@/components/client-data-table/data-table"; import { DropdownMenu, DropdownMenuContent, @@ -43,16 +40,16 @@ import { } from "@/components/ui/tooltip"; import type { DataTableAdvancedFilterField, - DataTableFilterField, DataTableRowAction, } from "@/types/table"; import { cn } from "@/lib/utils"; -import { getRfqLastAttachments } from "@/lib/rfq-last/service"; +import { getRfqAllAttachments } from "@/lib/rfq-last/service"; import { downloadFile } from "@/lib/file-download"; import { DeleteAttachmentsDialog } from "./delete-attachments-dialog"; import { AddAttachmentDialog } from "./add-attachment-dialog"; import { UpdateRevisionDialog } from "./update-revision-dialog"; -import { useQueryState ,parseAsStringEnum} from "nuqs"; +import { toast } from "sonner"; +import { RevisionHistoryDialog } from "./revision-historty-dialog"; // 타입 정의 interface RfqAttachment { @@ -77,9 +74,7 @@ interface RfqAttachment { interface RfqAttachmentsTableProps { rfqId: number; - initialDesignData: Awaited<ReturnType<typeof getRfqLastAttachments>>; - initialPurchaseData: Awaited<ReturnType<typeof getRfqLastAttachments>>; - className?: string; + initialData: RfqAttachment[]; } // 파일 타입별 아이콘 반환 @@ -112,31 +107,41 @@ const formatFileSize = (bytes: number | null) => { export function RfqAttachmentsTable({ rfqId, - initialDesignData, - initialPurchaseData, - className + initialData, }: RfqAttachmentsTableProps) { - const router = useRouter(); - const [activeTab, setActiveTab] = useQueryState( - 'tab', - parseAsStringEnum(['설계', '구매']) - .withDefault('설계') - .withOptions({ shallow: false }) - ); - - const [designData] = React.useState(initialDesignData); - const [purchaseData] = React.useState(initialPurchaseData); + const [activeTab, setActiveTab] = React.useState<'설계' | '구매'>('설계'); + const [data, setData] = React.useState<RfqAttachment[]>(initialData); const [selectedAttachment, setSelectedAttachment] = React.useState<RfqAttachment | null>(null); const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false); const [updateRevisionDialogOpen, setUpdateRevisionDialogOpen] = React.useState(false); + const [revisionHistoryDialogOpen, setRevisionHistoryDialogOpen] = React.useState(false); + const [addDialogOpen, setAddDialogOpen] = React.useState(false); const [isRefreshing, setIsRefreshing] = React.useState(false); + const [selectedRows, setSelectedRows] = React.useState<RfqAttachment[]>([]); + + // 탭에 따른 데이터 필터링 + const filteredData = React.useMemo(() => { + return data.filter(item => item.attachmentType === activeTab); + }, [data, activeTab]); - // 새로고침 (router.refresh 사용) - const handleRefresh = React.useCallback(() => { + // 데이터 새로고침 + const handleRefresh = React.useCallback(async () => { setIsRefreshing(true); - router.refresh(); - setTimeout(() => setIsRefreshing(false), 1000); - }, [router]); + try { + const result = await getRfqAllAttachments(rfqId); + if (result.success && result.data) { + setData(result.data); + toast.success("데이터를 새로고침했습니다."); + } else { + toast.error("데이터를 불러오는데 실패했습니다."); + } + } catch (error) { + console.error("Refresh error:", error); + toast.error("새로고침 중 오류가 발생했습니다."); + } finally { + setIsRefreshing(false); + } + }, [rfqId]); // 액션 처리 const handleAction = React.useCallback(async (action: DataTableRowAction<RfqAttachment>) => { @@ -162,8 +167,8 @@ export function RfqAttachmentsTable({ break; case "history": - // 리비전 이력 보기 - 별도 구현 필요 - console.log("History:", attachment); + setSelectedAttachment(attachment); + setRevisionHistoryDialogOpen(true); break; case "update": @@ -178,10 +183,35 @@ export function RfqAttachmentsTable({ } }, []); + // 선택된 항목 일괄 삭제 + const handleBulkDelete = React.useCallback(() => { + if (selectedRows.length === 0) { + toast.warning("삭제할 항목을 선택해주세요."); + return; + } + setDeleteDialogOpen(true); + }, [selectedRows]); + + // 선택된 항목 일괄 다운로드 + const handleBulkDownload = React.useCallback(async () => { + if (selectedRows.length === 0) { + toast.warning("다운로드할 항목을 선택해주세요."); + return; + } + + for (const attachment of selectedRows) { + if (attachment.filePath && attachment.originalFileName) { + await downloadFile(attachment.filePath, attachment.originalFileName, { + action: 'download', + showToast: false + }); + } + } + toast.success(`${selectedRows.length}개 파일을 다운로드했습니다.`); + }, [selectedRows]); + // 컬럼 정의 - const getAttachmentColumns = React.useCallback(( - onAction: (action: DataTableRowAction<RfqAttachment>) => void - ): ColumnDef<RfqAttachment>[] => [ + const columns: ColumnDef<RfqAttachment>[] = React.useMemo(() => [ { id: "select", header: ({ table }) => ( @@ -203,18 +233,21 @@ export function RfqAttachmentsTable({ size: 40, enableSorting: false, enableHiding: false, + enablePinning: true, }, { accessorKey: "serialNo", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="일련번호" />, + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="일련번호" />, cell: ({ row }) => ( <span className="font-mono text-sm">{row.original.serialNo || "-"}</span> ), size: 100, + meta: { excelHeader: "일련번호" }, + enablePinning: true, }, { accessorKey: "originalFileName", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="파일명" />, + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="파일명" />, cell: ({ row }) => { const file = row.original; return ( @@ -224,11 +257,6 @@ export function RfqAttachmentsTable({ <span className="text-sm font-medium truncate max-w-[250px]" title={file.originalFileName || ""}> {file.originalFileName || file.fileName || "-"} </span> - {file.fileName && file.fileName !== file.originalFileName && ( - <span className="text-xs text-muted-foreground truncate max-w-[250px]"> - ({file.fileName}) - </span> - )} </div> </div> ); @@ -237,7 +265,7 @@ export function RfqAttachmentsTable({ }, { accessorKey: "description", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="설명" />, + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="설명" />, cell: ({ row }) => ( <div className="max-w-[200px] truncate" title={row.original.description || ""}> {row.original.description || "-"} @@ -247,7 +275,7 @@ export function RfqAttachmentsTable({ }, { accessorKey: "currentRevision", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="리비전" />, + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="리비전" />, cell: ({ row }) => { const revision = row.original.currentRevision; return revision ? ( @@ -262,7 +290,7 @@ export function RfqAttachmentsTable({ }, { accessorKey: "fileSize", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="크기" />, + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="크기" />, cell: ({ row }) => ( <span className="text-sm text-muted-foreground"> {formatFileSize(row.original.fileSize)} @@ -271,29 +299,14 @@ export function RfqAttachmentsTable({ size: 80, }, { - accessorKey: "fileType", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="타입" />, - cell: ({ row }) => { - const type = row.original.fileType; - return type ? ( - <Badge variant="secondary" className="text-xs"> - {type.toUpperCase()} - </Badge> - ) : ( - <span className="text-muted-foreground">-</span> - ); - }, - size: 80, - }, - { accessorKey: "createdByName", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="업로드자" />, + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="업로드자" />, cell: ({ row }) => row.original.createdByName || "-", size: 100, }, { accessorKey: "createdAt", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="업로드일" />, + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="업로드일" />, cell: ({ row }) => { const date = row.original.createdAt; return date ? ( @@ -320,7 +333,7 @@ export function RfqAttachmentsTable({ }, { accessorKey: "updatedAt", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="수정일" />, + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="수정일" />, cell: ({ row }) => { const date = row.original.updatedAt; return date ? format(new Date(date), "MM-dd HH:mm") : "-"; @@ -342,26 +355,26 @@ export function RfqAttachmentsTable({ </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> - <DropdownMenuItem onClick={() => onAction({ row, type: "download" })}> + <DropdownMenuItem onClick={() => handleAction({ row, type: "download" })}> <Download className="mr-2 h-4 w-4" /> 다운로드 </DropdownMenuItem> - <DropdownMenuItem onClick={() => onAction({ row, type: "preview" })}> + <DropdownMenuItem onClick={() => handleAction({ row, type: "preview" })}> <Eye className="mr-2 h-4 w-4" /> 미리보기 </DropdownMenuItem> <DropdownMenuSeparator /> - <DropdownMenuItem onClick={() => onAction({ row, type: "history" })}> + <DropdownMenuItem onClick={() => handleAction({ row, type: "history" })}> <History className="mr-2 h-4 w-4" /> 리비전 이력 </DropdownMenuItem> - <DropdownMenuItem onClick={() => onAction({ row, type: "update" })}> + <DropdownMenuItem onClick={() => handleAction({ row, type: "update" })}> <Upload className="mr-2 h-4 w-4" /> 새 버전 업로드 </DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem - onClick={() => onAction({ row, type: "delete" })} + onClick={() => handleAction({ row, type: "delete" })} className="text-red-600" > <Trash2 className="mr-2 h-4 w-4" /> @@ -372,17 +385,9 @@ export function RfqAttachmentsTable({ ); }, size: 60, + enablePinning: true, }, - ], []); - - const columns = React.useMemo(() => getAttachmentColumns(handleAction), [getAttachmentColumns, handleAction]); - - const filterFields: DataTableFilterField<RfqAttachment>[] = [ - { id: "serialNo", label: "일련번호" }, - { id: "originalFileName", label: "파일명" }, - { id: "description", label: "설명" }, - { id: "createdByName", label: "업로드자" }, - ]; + ], [handleAction]); const advancedFilterFields: DataTableAdvancedFilterField<RfqAttachment>[] = [ { id: "serialNo", label: "일련번호", type: "text" }, @@ -406,121 +411,136 @@ export function RfqAttachmentsTable({ { id: "updatedAt", label: "수정일", type: "date" }, ]; - const { table: designTable } = useDataTable({ - data: designData.data, - columns, - pageCount: designData.pageCount, - rowCount: designData.data.length, - filterFields, - enableAdvancedFilter: true, - // 설계 탭용 파라미터 prefix - paramPrefix: 'design_', - initialState: { - sorting: [{ id: "createdAt", desc: true }], - }, - getRowId: (row) => String(row.id), - shallow: false, - clearOnDefault: true, - }); - - const { table: purchaseTable } = useDataTable({ - data: purchaseData.data, - columns, - pageCount: purchaseData.pageCount, - rowCount: purchaseData.data.length, - filterFields, - enableAdvancedFilter: true, - // 구매 탭용 파라미터 prefix - paramPrefix: 'purchase_', - initialState: { - sorting: [{ id: "createdAt", desc: true }], - }, - getRowId: (row) => String(row.id), - shallow: false, - clearOnDefault: true, - }); - + // 탭별 데이터 카운트 + const designCount = React.useMemo(() => + data.filter(item => item.attachmentType === "설계").length, [data] + ); + const purchaseCount = React.useMemo(() => + data.filter(item => item.attachmentType === "구매").length, [data] + ); - React.useEffect(() => { - router.refresh(); - }, [activeTab]); + // 추가 액션 버튼들 + const additionalActions = React.useMemo(() => ( + <div className="flex items-center gap-2"> + {selectedRows.length > 0 && ( + <> + <Button + variant="outline" + size="sm" + onClick={handleBulkDownload} + > + <Download className="h-4 w-4 mr-2" /> + 다운로드 ({selectedRows.length}) + </Button> + <Button + variant="outline" + size="sm" + onClick={handleBulkDelete} + className="text-red-600" + > + <Trash2 className="h-4 w-4 mr-2" /> + 삭제 ({selectedRows.length}) + </Button> + </> + )} + <Button + variant="outline" + size="sm" + onClick={handleRefresh} + disabled={isRefreshing} + > + <RefreshCw className={cn("h-4 w-4 mr-2", isRefreshing && "animate-spin")} /> + 새로고침 + </Button> + + {/* 구매 탭에서만 파일 업로드 버튼 표시 */} + {activeTab === "구매" && ( + <AddAttachmentDialog + rfqId={rfqId} + attachmentType="구매" + onSuccess={handleRefresh} + open={addDialogOpen} + onOpenChange={setAddDialogOpen} + /> + )} + </div> + ), [selectedRows, activeTab, isRefreshing, addDialogOpen, handleBulkDownload, handleBulkDelete, handleRefresh, rfqId]); return ( - <div className={cn("w-full space-y-4", className)}> - <Tabs value={activeTab} onValueChange={setActiveTab}> + <div className={cn("w-full space-y-4")}> + <Tabs + value={activeTab} + onValueChange={(value) => setActiveTab(value as '설계' | '구매')} + > <div className="flex items-center justify-between mb-4"> <TabsList> <TabsTrigger value="설계"> 설계 첨부파일 <Badge variant="secondary" className="ml-2"> - {designData.data.length} + {designCount} </Badge> </TabsTrigger> <TabsTrigger value="구매"> 구매 첨부파일 <Badge variant="secondary" className="ml-2"> - {purchaseData.data.length} + {purchaseCount} </Badge> </TabsTrigger> </TabsList> - - <div className="flex items-center gap-2"> - <Button - variant="outline" - size="sm" - onClick={handleRefresh} - disabled={isRefreshing} - > - <RefreshCw className={cn("h-4 w-4 mr-2", isRefreshing && "animate-spin")} /> - 새로고침 - </Button> - - {/* 구매 탭에서만 파일 업로드 버튼 표시 */} - {activeTab === "구매" && ( - <AddAttachmentDialog - rfqId={rfqId} - attachmentType="구매" - onSuccess={handleRefresh} - /> - )} - </div> </div> <TabsContent value="설계" className="mt-0"> - <Card> - <CardContent className="p-0"> - <DataTable table={designTable}> - <DataTableAdvancedToolbar - table={designTable} - filterFields={advancedFilterFields} - shallow={false} - /> - </DataTable> - </CardContent> - </Card> + + <ClientDataTable + columns={columns} + data={filteredData} + advancedFilterFields={advancedFilterFields} + autoSizeColumns={true} + compact={true} + maxHeight="34rem" + onSelectedRowsChange={setSelectedRows} + initialColumnPinning={{ + left: ["select", "serialNo"], + right: ["actions"], + }} + > + {additionalActions} + </ClientDataTable> + </TabsContent> <TabsContent value="구매" className="mt-0"> - <Card> - <CardContent className="p-0"> - <DataTable table={purchaseTable}> - <DataTableAdvancedToolbar - table={purchaseTable} - filterFields={advancedFilterFields} - shallow={false} - /> - </DataTable> - </CardContent> - </Card> + + <ClientDataTable + columns={columns} + data={filteredData} + advancedFilterFields={advancedFilterFields} + autoSizeColumns={true} + compact={true} + maxHeight="34rem" + onSelectedRowsChange={setSelectedRows} + initialColumnPinning={{ + left: ["select", "serialNo"], + right: ["actions"], + }} + > + {additionalActions} + </ClientDataTable> + </TabsContent> </Tabs> {/* 삭제 다이얼로그 */} - {selectedAttachment && ( + {(selectedAttachment || selectedRows.length > 0) && ( <DeleteAttachmentsDialog open={deleteDialogOpen} - onOpenChange={setDeleteDialogOpen} - attachments={[selectedAttachment]} + onOpenChange={(open) => { + setDeleteDialogOpen(open); + if (!open) { + setSelectedAttachment(null); + } + }} + attachments={selectedAttachment ? [selectedAttachment] : selectedRows} onSuccess={handleRefresh} /> )} @@ -529,11 +549,31 @@ export function RfqAttachmentsTable({ {selectedAttachment && ( <UpdateRevisionDialog open={updateRevisionDialogOpen} - onOpenChange={setUpdateRevisionDialogOpen} + onOpenChange={(open) => { + setUpdateRevisionDialogOpen(open); + if (!open) { + setSelectedAttachment(null); + } + }} attachment={selectedAttachment} onSuccess={handleRefresh} /> )} + + {/* 리비전 히스토리 다이얼로그 */} + {selectedAttachment && ( + <RevisionHistoryDialog + open={revisionHistoryDialogOpen} + onOpenChange={(open) => { + setRevisionHistoryDialogOpen(open); + if (!open) { + setSelectedAttachment(null); + } + }} + attachmentId={selectedAttachment.id} + attachmentName={selectedAttachment.originalFileName || selectedAttachment.fileName || undefined} + /> + )} </div> ); }
\ No newline at end of file diff --git a/lib/rfq-last/attachment/vendor-response-table.tsx b/lib/rfq-last/attachment/vendor-response-table.tsx new file mode 100644 index 00000000..6e1a02c8 --- /dev/null +++ b/lib/rfq-last/attachment/vendor-response-table.tsx @@ -0,0 +1,519 @@ +// @/lib/rfq-last/vendor/vendor-response-table.tsx + +"use client"; + +import * as React from "react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Download, + FileText, + RefreshCw, + Eye, + Trash2, + File, + FileImage, + FileSpreadsheet, + FileCode, + Building2, + Calendar, + AlertCircle +} from "lucide-react"; +import { format, formatDistanceToNow, isValid, isBefore, isAfter } from "date-fns"; +import { ko } from "date-fns/locale"; +import { type ColumnDef } from "@tanstack/react-table"; +import { Checkbox } from "@/components/ui/checkbox"; +import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header"; +import { ClientDataTable } from "@/components/client-data-table/data-table"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import type { + DataTableAdvancedFilterField, + DataTableRowAction, +} from "@/types/table"; +import { cn } from "@/lib/utils"; +import { getRfqVendorAttachments } from "@/lib/rfq-last/service"; +import { downloadFile } from "@/lib/file-download"; +import { toast } from "sonner"; + +// 타입 정의 +interface VendorAttachment { + id: number; + vendorResponseId: number; + attachmentType: string; + documentNo: string | null; + fileName: string; + originalFileName: string; + filePath: string; + fileSize: number | null; + fileType: string | null; + description: string | null; + validFrom: Date | null; + validTo: Date | null; + uploadedBy: number; + uploadedAt: Date; + uploadedByName: string | null; + vendorId: number | null; + vendorName: string | null; + vendorCode: string | null; + responseStatus: "작성중" | "제출완료" | "수정요청" | "최종확정" | "취소" | null; + responseVersion: number | null; +} + +interface VendorResponseTableProps { + rfqId: number; + initialData: VendorAttachment[]; +} + +// 파일 타입별 아이콘 반환 +const getFileIcon = (fileType: string | null) => { + if (!fileType) return <File className="h-4 w-4" />; + + const type = fileType.toLowerCase(); + if (type.includes('image') || ['jpg', 'jpeg', 'png', 'gif'].includes(type)) { + return <FileImage className="h-4 w-4 text-blue-500" />; + } + if (type.includes('excel') || type.includes('spreadsheet') || ['xls', 'xlsx'].includes(type)) { + return <FileSpreadsheet className="h-4 w-4 text-green-500" />; + } + if (type.includes('pdf')) { + return <FileText className="h-4 w-4 text-red-500" />; + } + if (type.includes('code') || ['js', 'ts', 'tsx', 'jsx', 'html', 'css'].includes(type)) { + return <FileCode className="h-4 w-4 text-purple-500" />; + } + return <File className="h-4 w-4 text-gray-500" />; +}; + +// 파일 크기 포맷팅 +const formatFileSize = (bytes: number | null) => { + if (!bytes) return "-"; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`; +}; + +// 응답 상태별 색상 +const getStatusVariant = (status: string | null) => { + switch (status) { + case "작성중": return "outline"; + case "제출완료": return "default"; + case "수정요청": return "secondary"; + case "최종확정": return "success"; + case "취소": return "destructive"; + default: return "outline"; + } +}; + +// 유효기간 체크 +const checkValidity = (validTo: Date | null) => { + if (!validTo) return null; + const today = new Date(); + const expiry = new Date(validTo); + + if (isBefore(expiry, today)) { + return "expired"; + } else if (isBefore(expiry, new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000))) { + return "expiring-soon"; // 30일 이내 만료 + } + return "valid"; +}; + +export function VendorResponseTable({ + rfqId, + initialData, +}: VendorResponseTableProps) { + const [data, setData] = React.useState<VendorAttachment[]>(initialData); + const [isRefreshing, setIsRefreshing] = React.useState(false); + const [selectedRows, setSelectedRows] = React.useState<VendorAttachment[]>([]); + + // 데이터 새로고침 + const handleRefresh = React.useCallback(async () => { + setIsRefreshing(true); + try { + const result = await getRfqVendorAttachments(rfqId); + if (result.success && result.data) { + setData(result.data); + toast.success("데이터를 새로고침했습니다."); + } else { + toast.error("데이터를 불러오는데 실패했습니다."); + } + } catch (error) { + console.error("Refresh error:", error); + toast.error("새로고침 중 오류가 발생했습니다."); + } finally { + setIsRefreshing(false); + } + }, [rfqId]); + + // 액션 처리 + const handleAction = React.useCallback(async (action: DataTableRowAction<VendorAttachment>) => { + const attachment = action.row.original; + + switch (action.type) { + case "download": + if (attachment.filePath && attachment.originalFileName) { + await downloadFile(attachment.filePath, attachment.originalFileName, { + action: 'download', + showToast: true + }); + } + break; + + case "preview": + if (attachment.filePath && attachment.originalFileName) { + await downloadFile(attachment.filePath, attachment.originalFileName, { + action: 'preview', + showToast: true + }); + } + break; + } + }, []); + + // 선택된 항목 일괄 다운로드 + const handleBulkDownload = React.useCallback(async () => { + if (selectedRows.length === 0) { + toast.warning("다운로드할 항목을 선택해주세요."); + return; + } + + for (const attachment of selectedRows) { + if (attachment.filePath && attachment.originalFileName) { + await downloadFile(attachment.filePath, attachment.originalFileName, { + action: 'download', + showToast: false + }); + } + } + toast.success(`${selectedRows.length}개 파일을 다운로드했습니다.`); + }, [selectedRows]); + + // 컬럼 정의 + const columns: ColumnDef<VendorAttachment>[] = React.useMemo(() => [ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")} + onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)} + aria-label="select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(v) => row.toggleSelected(!!v)} + aria-label="select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + enablePinning: true, + }, + { + accessorKey: "vendorName", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="벤더" />, + cell: ({ row }) => { + const vendor = row.original; + return ( + <div className="flex items-center gap-2"> + <Building2 className="h-4 w-4 text-muted-foreground" /> + <div className="flex flex-col"> + <span className="text-sm font-medium">{vendor.vendorName || "-"}</span> + <span className="text-xs text-muted-foreground">{vendor.vendorCode}</span> + </div> + </div> + ); + }, + size: 150, + enablePinning: true, + }, + { + accessorKey: "attachmentType", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="문서 유형" />, + cell: ({ row }) => { + const type = row.original.attachmentType; + return ( + <Badge variant="outline" className="font-mono"> + {type} + </Badge> + ); + }, + size: 100, + }, + { + accessorKey: "documentNo", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="문서번호" />, + cell: ({ row }) => ( + <span className="font-mono text-sm">{row.original.documentNo || "-"}</span> + ), + size: 120, + }, + { + accessorKey: "originalFileName", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="파일명" />, + cell: ({ row }) => { + const file = row.original; + return ( + <div className="flex items-center gap-2"> + {getFileIcon(file.fileType)} + <div className="flex flex-col"> + <span className="text-sm font-medium truncate max-w-[250px]" title={file.originalFileName}> + {file.originalFileName || file.fileName || "-"} + </span> + </div> + </div> + ); + }, + 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="응답 상태" />, + cell: ({ row }) => { + const status = row.original.responseStatus; + return status ? ( + <Badge variant={getStatusVariant(status)}> + {status} + </Badge> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 100, + }, + { + accessorKey: "fileSize", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="크기" />, + cell: ({ row }) => ( + <span className="text-sm text-muted-foreground"> + {formatFileSize(row.original.fileSize)} + </span> + ), + size: 80, + }, + { + accessorKey: "uploadedAt", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="업로드일" />, + cell: ({ row }) => { + const date = row.original.uploadedAt; + return date ? ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <span className="text-sm cursor-help"> + {format(new Date(date), "MM-dd HH:mm")} + </span> + </TooltipTrigger> + <TooltipContent> + <p>{format(new Date(date), "yyyy년 MM월 dd일 HH시 mm분")}</p> + <p className="text-xs text-muted-foreground"> + ({formatDistanceToNow(new Date(date), { addSuffix: true, locale: ko })}) + </p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + ) : ( + "-" + ); + }, + size: 100, + }, + { + id: "actions", + header: "작업", + cell: ({ row }) => { + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" className="h-8 w-8 p-0"> + <span className="sr-only">메뉴 열기</span> + <svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M3.625 7.5C3.625 8.12132 3.12132 8.625 2.5 8.625C1.87868 8.625 1.375 8.12132 1.375 7.5C1.375 6.87868 1.87868 6.375 2.5 6.375C3.12132 6.375 3.625 6.87868 3.625 7.5ZM8.625 7.5C8.625 8.12132 8.12132 8.625 7.5 8.625C6.87868 8.625 6.375 8.12132 6.375 7.5C6.375 6.87868 6.87868 6.375 7.5 6.375C8.12132 6.375 8.625 6.87868 8.625 7.5ZM12.5 8.625C13.1213 8.625 13.625 8.12132 13.625 7.5C13.625 6.87868 13.1213 6.375 12.5 6.375C11.8787 6.375 11.375 6.87868 11.375 7.5C11.375 8.12132 11.8787 8.625 12.5 8.625Z" fill="currentColor" fillRule="evenodd" clipRule="evenodd"></path> + </svg> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem onClick={() => handleAction({ row, type: "download" })}> + <Download className="mr-2 h-4 w-4" /> + 다운로드 + </DropdownMenuItem> + <DropdownMenuItem onClick={() => handleAction({ row, type: "preview" })}> + <Eye className="mr-2 h-4 w-4" /> + 미리보기 + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ); + }, + size: 60, + enablePinning: true, + }, + ], [handleAction]); + + const advancedFilterFields: DataTableAdvancedFilterField<VendorAttachment>[] = [ + { id: "vendorName", label: "벤더명", type: "text" }, + { id: "vendorCode", label: "벤더코드", type: "text" }, + { + id: "attachmentType", + label: "문서 유형", + type: "select", + options: [ + { label: "견적서", value: "견적서" }, + { label: "기술제안서", value: "기술제안서" }, + { label: "인증서", value: "인증서" }, + { label: "카탈로그", value: "카탈로그" }, + { label: "도면", value: "도면" }, + { label: "테스트성적서", value: "테스트성적서" }, + { label: "기타", value: "기타" }, + ] + }, + { id: "documentNo", label: "문서번호", type: "text" }, + { id: "originalFileName", label: "파일명", type: "text" }, + { id: "description", label: "설명", type: "text" }, + { + id: "responseStatus", + label: "응답 상태", + type: "select", + options: [ + { label: "작성중", value: "작성중" }, + { label: "제출완료", value: "제출완료" }, + { label: "수정요청", value: "수정요청" }, + { label: "최종확정", value: "최종확정" }, + { label: "취소", value: "취소" }, + ] + }, + { id: "validFrom", label: "유효시작일", type: "date" }, + { id: "validTo", label: "유효종료일", type: "date" }, + { id: "uploadedAt", label: "업로드일", type: "date" }, + ]; + + // 추가 액션 버튼들 + const additionalActions = React.useMemo(() => ( + <div className="flex items-center gap-2"> + {selectedRows.length > 0 && ( + <Button + variant="outline" + size="sm" + onClick={handleBulkDownload} + > + <Download className="h-4 w-4 mr-2" /> + 다운로드 ({selectedRows.length}) + </Button> + )} + <Button + variant="outline" + size="sm" + onClick={handleRefresh} + disabled={isRefreshing} + > + <RefreshCw className={cn("h-4 w-4 mr-2", isRefreshing && "animate-spin")} /> + 새로고침 + </Button> + </div> + ), [selectedRows, isRefreshing, handleBulkDownload, handleRefresh]); + + // 벤더별 그룹 카운트 + const vendorCounts = React.useMemo(() => { + const counts = new Map<string, number>(); + data.forEach(item => { + const vendor = item.vendorName || "Unknown"; + counts.set(vendor, (counts.get(vendor) || 0) + 1); + }); + return counts; + }, [data]); + + return ( + <div className={cn("w-full space-y-4")}> + {/* 벤더별 요약 정보 */} + <div className="flex gap-2 flex-wrap"> + {Array.from(vendorCounts.entries()).map(([vendor, count]) => ( + <Badge key={vendor} variant="secondary"> + {vendor}: {count} + </Badge> + ))} + </div> + + <ClientDataTable + columns={columns} + data={data} + advancedFilterFields={advancedFilterFields} + autoSizeColumns={true} + compact={true} + maxHeight="34rem" + onSelectedRowsChange={setSelectedRows} + initialColumnPinning={{ + left: ["select", "vendorName"], + right: ["actions"], + }} + > + {additionalActions} + </ClientDataTable> + </div> + ); +}
\ No newline at end of file diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts index ffeed1b1..67cb901f 100644 --- a/lib/rfq-last/service.ts +++ b/lib/rfq-last/service.ts @@ -1,12 +1,14 @@ // lib/rfq/service.ts 'use server' -import { unstable_cache, unstable_noStore } from "next/cache"; +import { revalidatePath, unstable_cache, unstable_noStore } from "next/cache"; import db from "@/db/db"; -import { RfqsLastView, rfqLastAttachmentRevisions, rfqLastAttachments, rfqsLast, rfqsLastView, users, rfqPrItems, prItemsLastView } from "@/db/schema"; -import {sql, and, desc, asc, like, ilike, or, eq, SQL, count, gte, lte, isNotNull, ne, inArray } from "drizzle-orm"; +import {paymentTerms,incoterms, rfqLastVendorQuotationItems,rfqLastVendorAttachments,rfqLastVendorResponses, RfqsLastView, rfqLastAttachmentRevisions, rfqLastAttachments, rfqsLast, rfqsLastView, users, rfqPrItems, prItemsLastView ,vendors, rfqLastDetails, rfqLastVendorResponseHistory, rfqLastDetailsView} 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"; +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" export async function getRfqs(input: GetRfqsSchema) { unstable_noStore(); @@ -172,68 +174,57 @@ export const findRfqLastById = async (id: number): Promise<RfqsLastView | null> }; -export async function getRfqLastAttachments( - input: GetRfqLastAttachmentsSchema, - rfqId: number, - attachmentType: "설계" | "구매" -) { +// 모든 첨부파일을 가져오는 새로운 서버 액션 +export async function getRfqAllAttachments(rfqId: number) { try { - const offset = (input.page - 1) * input.perPage - - // Advanced Filter 처리 (메인 테이블 기준) - const advancedWhere = filterColumns({ - table: rfqLastAttachments, - filters: input.filters, - joinOperator: input.joinOperator, - }) - - // 전역 검색 - let globalWhere - if (input.search) { - const s = `%${input.search}%` - globalWhere = or( - ilike(rfqLastAttachments.serialNo, s), - ilike(rfqLastAttachments.description, s), - ilike(rfqLastAttachments.currentRevision, s), - ilike(rfqLastAttachmentRevisions.fileName, s), - ilike(rfqLastAttachmentRevisions.originalFileName, s) + // 데이터 조회 + const data = await db + .select({ + // 첨부파일 메인 정보 + id: rfqLastAttachments.id, + attachmentType: rfqLastAttachments.attachmentType, + serialNo: rfqLastAttachments.serialNo, + rfqId: rfqLastAttachments.rfqId, + currentRevision: rfqLastAttachments.currentRevision, + latestRevisionId: rfqLastAttachments.latestRevisionId, + description: rfqLastAttachments.description, + createdBy: rfqLastAttachments.createdBy, + createdAt: rfqLastAttachments.createdAt, + updatedAt: rfqLastAttachments.updatedAt, + + // 최신 리비전 파일 정보 + fileName: rfqLastAttachmentRevisions.fileName, + originalFileName: rfqLastAttachmentRevisions.originalFileName, + filePath: rfqLastAttachmentRevisions.filePath, + fileSize: rfqLastAttachmentRevisions.fileSize, + fileType: rfqLastAttachmentRevisions.fileType, + revisionComment: rfqLastAttachmentRevisions.revisionComment, + + // 생성자 정보 + createdByName: users.name, + }) + .from(rfqLastAttachments) + .leftJoin( + rfqLastAttachmentRevisions, + and( + eq(rfqLastAttachments.latestRevisionId, rfqLastAttachmentRevisions.id), + eq(rfqLastAttachmentRevisions.isLatest, true) + ) ) - } + .leftJoin(users, eq(rfqLastAttachments.createdBy, users.id)) + .where(eq(rfqLastAttachments.rfqId, rfqId)) + .orderBy(desc(rfqLastAttachments.createdAt)) - // 파일 타입 필터 - let fileTypeWhere - if (input.fileType && input.fileType.length > 0) { - fileTypeWhere = inArray(rfqLastAttachmentRevisions.fileType, input.fileType) + return { + data, + success: true } - - // 최종 WHERE 절 - const finalWhere = and( - eq(rfqLastAttachments.rfqId, rfqId), - eq(rfqLastAttachments.attachmentType, attachmentType), - advancedWhere, - globalWhere, - fileTypeWhere - ) - - // 정렬 - const orderBy = input.sort.length > 0 - ? input.sort.map((item) => - item.desc - ? desc(rfqLastAttachments[item.id as keyof typeof rfqLastAttachments]) - : asc(rfqLastAttachments[item.id as keyof typeof rfqLastAttachments]) - ) - : [desc(rfqLastAttachments.createdAt)] - - // 데이터 조회 (기존 코드와 동일) - const { data, total } = await db.transaction(async (tx) => { - // ... 기존 조회 로직 - }) - - const pageCount = Math.ceil(total / input.perPage) - return { data, pageCount } } catch (err) { - console.error("getRfqAttachments error:", err) - return { data: [], pageCount: 0 } + console.error("getRfqAllAttachments error:", err) + return { + data: [], + success: false + } } } // 사용자 목록 조회 (필터용) @@ -689,3 +680,1159 @@ export async function getRfqBasicInfoAction(rfqId: number) { } } +export interface RevisionHistory { + id: number; + attachmentId: number; + revisionNo: string; + fileName: string; + originalFileName: string; + filePath: string; + fileSize: number; + fileType: string; + isLatest: boolean; + revisionComment: string | null; + createdBy: number; + createdAt: Date; + createdByName: string | null; +} + +export interface AttachmentWithHistory { + id: number; + serialNo: string | null; + description: string | null; + currentRevision: string | null; + originalFileName: string | null; + revisions: RevisionHistory[]; +} + +// 리비전 히스토리 조회 +export async function getRevisionHistory(attachmentId: number): Promise<{ + success: boolean; + data?: AttachmentWithHistory; + error?: string; +}> { + try { + // 첨부파일 기본 정보 조회 + const [attachment] = await db + .select({ + id: rfqLastAttachments.id, + serialNo: rfqLastAttachments.serialNo, + description: rfqLastAttachments.description, + currentRevision: rfqLastAttachments.currentRevision, + latestRevisionId: rfqLastAttachments.latestRevisionId, + }) + .from(rfqLastAttachments) + .where(eq(rfqLastAttachments.id, attachmentId)); + + if (!attachment) { + return { + success: false, + error: "첨부파일을 찾을 수 없습니다.", + }; + } + + // 최신 리비전 정보 조회 (파일명 가져오기 위해) + let originalFileName: string | null = null; + if (attachment.latestRevisionId) { + const [latestRevision] = await db + .select({ + originalFileName: rfqLastAttachmentRevisions.originalFileName, + }) + .from(rfqLastAttachmentRevisions) + .where(eq(rfqLastAttachmentRevisions.id, attachment.latestRevisionId)); + + originalFileName = latestRevision?.originalFileName || null; + } + + // 모든 리비전 히스토리 조회 + const revisions = await db + .select({ + id: rfqLastAttachmentRevisions.id, + attachmentId: rfqLastAttachmentRevisions.attachmentId, + revisionNo: rfqLastAttachmentRevisions.revisionNo, + fileName: rfqLastAttachmentRevisions.fileName, + originalFileName: rfqLastAttachmentRevisions.originalFileName, + filePath: rfqLastAttachmentRevisions.filePath, + fileSize: rfqLastAttachmentRevisions.fileSize, + fileType: rfqLastAttachmentRevisions.fileType, + isLatest: rfqLastAttachmentRevisions.isLatest, + revisionComment: rfqLastAttachmentRevisions.revisionComment, + createdBy: rfqLastAttachmentRevisions.createdBy, + createdAt: rfqLastAttachmentRevisions.createdAt, + createdByName: users.name, + }) + .from(rfqLastAttachmentRevisions) + .leftJoin(users, eq(rfqLastAttachmentRevisions.createdBy, users.id)) + .where(eq(rfqLastAttachmentRevisions.attachmentId, attachmentId)) + .orderBy(desc(rfqLastAttachmentRevisions.createdAt)); + + return { + success: true, + data: { + ...attachment, + originalFileName, + revisions, + }, + }; + } catch (error) { + console.error("Get revision history error:", error); + return { + success: false, + error: "리비전 히스토리 조회 중 오류가 발생했습니다.", + }; + } +} + +// 특정 리비전 다운로드 URL 생성 +export async function getRevisionDownloadUrl(revisionId: number): Promise<{ + success: boolean; + data?: { + url: string; + fileName: string; + }; + error?: string; +}> { + try { + const [revision] = await db + .select({ + filePath: rfqLastAttachmentRevisions.filePath, + originalFileName: rfqLastAttachmentRevisions.originalFileName, + }) + .from(rfqLastAttachmentRevisions) + .where(eq(rfqLastAttachmentRevisions.id, revisionId)); + + if (!revision) { + return { + success: false, + error: "리비전을 찾을 수 없습니다.", + }; + } + + return { + success: true, + data: { + url: revision.filePath, + fileName: revision.originalFileName, + }, + }; + } catch (error) { + console.error("Get revision download URL error:", error); + return { + success: false, + error: "다운로드 URL 생성 중 오류가 발생했습니다.", + }; + } +} + +export async function getRfqVendorAttachments(rfqId: number) { + try { + // 데이터 조회 + const data = await db + .select({ + // 첨부파일 메인 정보 + id: rfqLastVendorAttachments.id, + vendorResponseId: rfqLastVendorAttachments.vendorResponseId, + attachmentType: rfqLastVendorAttachments.attachmentType, + documentNo: rfqLastVendorAttachments.documentNo, + + // 파일 정보 + fileName: rfqLastVendorAttachments.fileName, + originalFileName: rfqLastVendorAttachments.originalFileName, + filePath: rfqLastVendorAttachments.filePath, + fileSize: rfqLastVendorAttachments.fileSize, + fileType: rfqLastVendorAttachments.fileType, + + // 파일 설명 + description: rfqLastVendorAttachments.description, + + // 유효기간 + validFrom: rfqLastVendorAttachments.validFrom, + validTo: rfqLastVendorAttachments.validTo, + + // 업로드 정보 + uploadedBy: rfqLastVendorAttachments.uploadedBy, + uploadedAt: rfqLastVendorAttachments.uploadedAt, + + // 업로더 정보 + uploadedByName: users.name, + + // 벤더 정보 + vendorId: rfqLastVendorResponses.vendorId, + vendorName: vendors.vendorName, + vendorCode: vendors.vendorCode, + + // 응답 상태 + responseStatus: rfqLastVendorResponses.status, + responseVersion: rfqLastVendorResponses.responseVersion, + }) + .from(rfqLastVendorAttachments) + .leftJoin( + rfqLastVendorResponses, + eq(rfqLastVendorAttachments.vendorResponseId, rfqLastVendorResponses.id) + ) + .leftJoin(users, eq(rfqLastVendorAttachments.uploadedBy, users.id)) + .leftJoin(vendors, eq(rfqLastVendorResponses.vendorId, vendors.id)) + .where(eq(rfqLastVendorResponses.rfqsLastId, rfqId)) + .orderBy(desc(rfqLastVendorAttachments.uploadedAt)) + + return { + vendorData, + vendorSuccess: true + } + } catch (err) { + console.error("getRfqVendorAttachments error:", err) + return { + vendorData: [], + vendorSuccess: false + } + } +} + + + +// 벤더 추가 액션 +export async function addVendorToRfq({ + rfqId, + vendorId, + conditions, +}: { + rfqId: number; + vendorId: number; + conditions: { + currency: string; + paymentTermsCode: string; + incotermsCode: string; + incotermsDetail?: string; + deliveryDate: Date; + contractDuration?: string; + taxCode?: string; + placeOfShipping?: string; + placeOfDestination?: string; + materialPriceRelatedYn?: boolean; + sparepartYn?: boolean; + firstYn?: boolean; + firstDescription?: string; + sparepartDescription?: string; + }; +}) { + try { + const session = await getServerSession(authOptions) + + if (!session?.user) { + throw new Error("인증이 필요합니다.") + } + + const userId = Number(session.user.id) + // 중복 체크 + const existing = await db + .select() + .from(rfqLastDetails) + .where( + and( + eq(rfqLastDetails.rfqsLastId, rfqId), + eq(rfqLastDetails.vendorsId, vendorId) + ) + ) + .limit(1); + + if (existing.length > 0) { + return { success: false, error: "이미 추가된 벤더입니다." }; + } + + // 트랜잭션으로 처리 + await db.transaction(async (tx) => { + // 1. rfqLastDetails에 벤더 추가 + const [detail] = await tx + .insert(rfqLastDetails) + .values({ + rfqsLastId: rfqId, + vendorsId: vendorId, + ...conditions, + updatedBy: userId, + }) + .returning(); + + // 2. rfqLastVendorResponses에 초기 응답 레코드 생성 + const [response] = await tx + .insert(rfqLastVendorResponses) + .values({ + rfqsLastId: rfqId, + rfqLastDetailsId: detail.id, + vendorId: vendorId, + status: "초대됨", + responseVersion: 1, + isLatest: true, + currency: conditions.currency, + // 구매자 제시 조건 복사 (초기값) + vendorCurrency: conditions.currency, + vendorPaymentTermsCode: conditions.paymentTermsCode, + vendorIncotermsCode: conditions.incotermsCode, + vendorIncotermsDetail: conditions.incotermsDetail, + vendorDeliveryDate: conditions.deliveryDate, + vendorContractDuration: conditions.contractDuration, + vendorTaxCode: conditions.taxCode, + vendorPlaceOfShipping: conditions.placeOfShipping, + vendorPlaceOfDestination: conditions.placeOfDestination, + vendorMaterialPriceRelatedYn: conditions.materialPriceRelatedYn, + vendorSparepartYn: conditions.sparepartYn, + vendorFirstYn: conditions.firstYn, + vendorFirstDescription: conditions.firstDescription, + vendorSparepartDescription: conditions.sparepartDescription, + createdBy: user.id, + updatedBy: user.id, + }) + .returning(); + + // 3. 이력 기록 + await tx.insert(rfqLastVendorResponseHistory).values({ + vendorResponseId: response.id, + action: "생성", + newStatus: "초대됨", + changeDetails: { action: "벤더 초대", conditions }, + performedBy: userId, + }); + }); + + revalidatePath(`/rfq-last/${rfqId}/vendor`); + + return { success: true }; + } catch (error) { + console.error("Add vendor error:", error); + return { success: false, error: "벤더 추가 중 오류가 발생했습니다." }; + } +} + +export async function addVendorsToRfq({ + rfqId, + vendorIds, + conditions, +}: { + rfqId: number; + vendorIds: number[]; + conditions?: { + currency: string; + paymentTermsCode: string; + incotermsCode: string; + incotermsDetail?: string; + deliveryDate: Date; + contractDuration?: string; + taxCode?: string; + placeOfShipping?: string; + placeOfDestination?: string; + materialPriceRelatedYn?: boolean; + sparepartYn?: boolean; + firstYn?: boolean; + firstDescription?: string; + sparepartDescription?: string; + } | null; +}) { + try { + const session = await getServerSession(authOptions) + + if (!session?.user) { + throw new Error("인증이 필요합니다.") + } + + const userId = Number(session.user.id) + + // 빈 배열 체크 + if (!vendorIds || vendorIds.length === 0) { + return { success: false, error: "벤더를 선택해주세요." }; + } + + // 중복 체크 - 이미 추가된 벤더들 확인 + const existingVendors = await db + .select({ + vendorId: rfqLastDetails.vendorsId, + }) + .from(rfqLastDetails) + .where( + and( + eq(rfqLastDetails.rfqsLastId, rfqId), + inArray(rfqLastDetails.vendorsId, vendorIds) + ) + ); + + const existingVendorIds = existingVendors.map(v => v.vendorId); + const newVendorIds = vendorIds.filter(id => !existingVendorIds.includes(id)); + + if (newVendorIds.length === 0) { + return { + success: false, + error: "모든 벤더가 이미 추가되어 있습니다." + }; + } + + // 일부만 중복인 경우 경고 메시지 준비 + const skippedCount = vendorIds.length - newVendorIds.length; + + // 트랜잭션으로 처리 + const results = await db.transaction(async (tx) => { + const addedVendors = []; + + for (const vendorId of newVendorIds) { + // conditions가 없는 경우 기본값 설정 + const vendorConditions = conditions || { + currency: "USD", + paymentTermsCode: "NET30", + incotermsCode: "FOB", + deliveryDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30일 후 + taxCode: "VV", + }; + + // 1. rfqLastDetails에 벤더 추가 + const [detail] = await tx + .insert(rfqLastDetails) + .values({ + rfqsLastId: rfqId, + vendorsId: vendorId, + ...vendorConditions, + updatedBy: userId, + }) + .returning(); + + // 2. rfqLastVendorResponses에 초기 응답 레코드 생성 + const [response] = await tx + .insert(rfqLastVendorResponses) + .values({ + rfqsLastId: rfqId, + rfqLastDetailsId: detail.id, + vendorId: vendorId, + status: "초대됨", + responseVersion: 1, + isLatest: true, + currency: vendorConditions.currency, + // 구매자 제시 조건 복사 (초기값) + vendorCurrency: vendorConditions.currency, + vendorPaymentTermsCode: vendorConditions.paymentTermsCode, + vendorIncotermsCode: vendorConditions.incotermsCode, + vendorIncotermsDetail: vendorConditions.incotermsDetail, + vendorDeliveryDate: vendorConditions.deliveryDate, + vendorContractDuration: vendorConditions.contractDuration, + vendorTaxCode: vendorConditions.taxCode, + vendorPlaceOfShipping: vendorConditions.placeOfShipping, + vendorPlaceOfDestination: vendorConditions.placeOfDestination, + vendorMaterialPriceRelatedYn: vendorConditions.materialPriceRelatedYn, + vendorSparepartYn: vendorConditions.sparepartYn, + vendorFirstYn: vendorConditions.firstYn, + vendorFirstDescription: vendorConditions.firstDescription, + vendorSparepartDescription: vendorConditions.sparepartDescription, + createdBy: userId, + updatedBy: userId, + }) + .returning(); + + // 3. 이력 기록 + await tx.insert(rfqLastVendorResponseHistory).values({ + vendorResponseId: response.id, + action: "생성", + newStatus: "초대됨", + changeDetails: { + action: "벤더 초대", + conditions: vendorConditions, + batchAdd: true, + totalVendors: newVendorIds.length + }, + performedBy: userId, + }); + + addedVendors.push({ + vendorId, + detailId: detail.id, + responseId: response.id, + }); + } + + return addedVendors; + }); + + revalidatePath(`/rfq-last/${rfqId}/vendor`); + + // 성공 메시지 구성 + let message = `${results.length}개 벤더가 추가되었습니다.`; + if (skippedCount > 0) { + message += ` (${skippedCount}개는 이미 추가된 벤더로 제외)`; + } + + return { + success: true, + data: { + added: results.length, + skipped: skippedCount, + message, + } + }; + } catch (error) { + console.error("Add vendors error:", error); + return { + success: false, + error: "벤더 추가 중 오류가 발생했습니다." + }; + } +} + +// 벤더 조건 일괄 업데이트 함수 (추가) +export async function updateVendorConditionsBatch({ + rfqId, + vendorIds, + conditions, +}: { + rfqId: number; + vendorIds: number[]; + conditions: { + currency?: string; + paymentTermsCode?: string; + incotermsCode?: string; + incotermsDetail?: string; + deliveryDate?: Date; + contractDuration?: string; + taxCode?: string; + placeOfShipping?: string; + placeOfDestination?: string; + materialPriceRelatedYn?: boolean; + sparepartYn?: boolean; + firstYn?: boolean; + firstDescription?: string; + sparepartDescription?: string; + }; +}) { + try { + const session = await getServerSession(authOptions) + + if (!session?.user) { + throw new Error("인증이 필요합니다.") + } + + const userId = Number(session.user.id) + + if (!vendorIds || vendorIds.length === 0) { + return { success: false, error: "벤더를 선택해주세요." }; + } + + // 트랜잭션으로 처리 + await db.transaction(async (tx) => { + // 1. rfqLastDetails 업데이트 + await tx + .update(rfqLastDetails) + .set({ + ...conditions, + updatedBy: userId, + updatedAt: new Date(), + }) + .where( + and( + eq(rfqLastDetails.rfqsLastId, rfqId), + inArray(rfqLastDetails.vendorsId, vendorIds) + ) + ); + + // 2. rfqLastVendorResponses의 구매자 제시 조건도 업데이트 + const vendorConditions = Object.keys(conditions).reduce((acc, key) => { + if (conditions[key] !== undefined) { + acc[`vendor${key.charAt(0).toUpperCase() + key.slice(1)}`] = conditions[key]; + } + return acc; + }, {}); + + await tx + .update(rfqLastVendorResponses) + .set({ + ...vendorConditions, + updatedBy: userId, + updatedAt: new Date(), + }) + .where( + and( + eq(rfqLastVendorResponses.rfqsLastId, rfqId), + inArray(rfqLastVendorResponses.vendorId, vendorIds), + eq(rfqLastVendorResponses.isLatest, true) + ) + ); + + // 3. 이력 기록 (각 벤더별로) + const responses = await tx + .select({ id: rfqLastVendorResponses.id }) + .from(rfqLastVendorResponses) + .where( + and( + eq(rfqLastVendorResponses.rfqsLastId, rfqId), + inArray(rfqLastVendorResponses.vendorId, vendorIds), + eq(rfqLastVendorResponses.isLatest, true) + ) + ); + + for (const response of responses) { + await tx.insert(rfqLastVendorResponseHistory).values({ + vendorResponseId: response.id, + action: "조건변경", + changeDetails: { + action: "조건 일괄 업데이트", + conditions, + batchUpdate: true, + totalVendors: vendorIds.length + }, + performedBy: userId, + }); + } + }); + + revalidatePath(`/rfq-last/${rfqId}/vendor`); + + return { + success: true, + data: { + message: `${vendorIds.length}개 벤더의 조건이 업데이트되었습니다.` + } + }; + } catch (error) { + console.error("Update vendor conditions error:", error); + return { + success: false, + error: "조건 업데이트 중 오류가 발생했습니다." + }; + } +} + +// RFQ 발송 액션 +export async function sendRfqToVendors({ + rfqId, + vendorIds, +}: { + rfqId: number; + vendorIds: number[]; +}) { + try { + + const session = await getServerSession(authOptions) + + if (!session?.user) { + throw new Error("인증이 필요합니다.") + } + const userId = Number(session.user.id) + + // 벤더별 응답 상태 업데이트 + for (const vendorId of vendorIds) { + const [response] = await db + .select() + .from(rfqLastVendorResponses) + .where( + and( + eq(rfqLastVendorResponses.rfqsLastId, rfqId), + eq(rfqLastVendorResponses.vendorId, vendorId), + eq(rfqLastVendorResponses.isLatest, true) + ) + ) + .limit(1); + + if (response) { + // 상태 업데이트 + await db + .update(rfqLastVendorResponses) + .set({ + status: "작성중", + updatedBy: userId, + updatedAt: new Date(), + }) + .where(eq(rfqLastVendorResponses.id, response.id)); + + // 이력 기록 + await db.insert(rfqLastVendorResponseHistory).values({ + vendorResponseId: response.id, + action: "발송", + previousStatus: response.status, + newStatus: "작성중", + changeDetails: { action: "RFQ 발송" }, + performedBy: userId, + }); + } + } + + // TODO: 실제 이메일 발송 로직 + + revalidatePath(`/rfq-last/${rfqId}/vendor`); + + return { success: true, count: vendorIds.length }; + } catch (error) { + console.error("Send RFQ error:", error); + return { success: false, error: "RFQ 발송 중 오류가 발생했습니다." }; + } +} + +// 벤더 삭제 액션 +export async function removeVendorFromRfq({ + rfqId, + vendorId, +}: { + rfqId: number; + vendorId: number; +}) { + try { + const session = await getServerSession(authOptions) + + if (!session?.user) { + throw new Error("인증이 필요합니다.") + } + + // 응답 체크 + const [response] = await db + .select() + .from(rfqLastVendorResponses) + .where( + and( + eq(rfqLastVendorResponses.rfqsLastId, rfqId), + eq(rfqLastVendorResponses.vendorId, vendorId), + eq(rfqLastVendorResponses.isLatest, true) + ) + ) + .limit(1); + + if (response && response.status !== "초대됨") { + return { + success: false, + error: "이미 진행 중인 벤더는 삭제할 수 없습니다." + }; + } + + // 삭제 + await db + .delete(rfqLastDetails) + .where( + and( + eq(rfqLastDetails.rfqsLastId, rfqId), + eq(rfqLastDetails.vendorsId, vendorId) + ) + ); + + revalidatePath(`/rfq-last/${rfqId}/vendor`); + + return { success: true }; + } catch (error) { + console.error("Remove vendor error:", error); + return { success: false, error: "벤더 삭제 중 오류가 발생했습니다." }; + } +} + +// 벤더 응답 상태 업데이트 +export async function updateVendorResponseStatus({ + responseId, + status, + reason, +}: { + responseId: number; + status: "작성중" | "제출완료" | "수정요청" | "최종확정" | "취소"; + reason?: string; +}) { + try { + const session = await getServerSession(authOptions) + + if (!session?.user) { + throw new Error("인증이 필요합니다.") + } + + + const [current] = await db + .select() + .from(rfqLastVendorResponses) + .where(eq(rfqLastVendorResponses.id, responseId)) + .limit(1); + + if (!current) { + return { success: false, error: "응답을 찾을 수 없습니다." }; + } + + // 상태 업데이트 + await db + .update(rfqLastVendorResponses) + .set({ + status, + submittedAt: status === "제출완료" ? new Date() : current.submittedAt, + updatedBy: Number(session.user.id), + updatedAt: new Date(), + }) + .where(eq(rfqLastVendorResponses.id, responseId)); + + // 이력 기록 + await db.insert(rfqLastVendorResponseHistory).values({ + vendorResponseId: responseId, + action: getActionFromStatus(status), + previousStatus: current.status, + newStatus: status, + changeReason: reason, + performedBy: Number(session.user.id), + }); + + revalidatePath(`/evcp/rfq-last/${current.rfqsLastId}/vendor`); + + return { success: true }; + } catch (error) { + console.error("Update status error:", error); + return { success: false, error: "상태 업데이트 중 오류가 발생했습니다." }; + } +} + +// 상태에 따른 액션 텍스트 +function getActionFromStatus(status: string): string { + switch (status) { + case "제출완료": return "제출"; + case "수정요청": return "반려"; + case "최종확정": return "승인"; + case "취소": return "취소"; + default: return "수정"; + } +} + +export async function getRfqVendorResponses(rfqId: number) { + try { + // 1. RFQ 기본 정보 조회 + const rfqData = await db + .select({ + id: rfqsLast.id, + rfqCode: rfqsLast.rfqCode, + title: rfqsLast.title, + status: rfqsLast.status, + startDate: rfqsLast.startDate, + endDate: rfqsLast.endDate, + }) + .from(rfqsLast) + .where(eq(rfqsLast.id, rfqId)) + .limit(1); + + if (!rfqData || rfqData.length === 0) { + return { + success: false, + error: "RFQ를 찾을 수 없습니다.", + data: null + }; + } + + // 2. RFQ 세부 정보 조회 (복수 버전이 있을 수 있음) + const details = await db + .select() + .from(rfqLastDetails) + .where(eq(rfqLastDetails.rfqsLastId, rfqId)) + .orderBy(desc(rfqLastDetails.version)); + + // 3. 벤더 응답 정보 조회 (벤더 정보, 제출자 정보 포함) + const vendorResponsesData = await db + .select({ + // 응답 기본 정보 + id: rfqLastVendorResponses.id, + rfqsLastId: rfqLastVendorResponses.rfqsLastId, + rfqLastDetailsId: rfqLastVendorResponses.rfqLastDetailsId, + responseVersion: rfqLastVendorResponses.responseVersion, + isLatest: rfqLastVendorResponses.isLatest, + status: rfqLastVendorResponses.status, + + // 벤더 정보 + vendorId: rfqLastVendorResponses.vendorId, + vendorCode: vendors.vendorCode, + vendorName: vendors.vendorName, + vendorEmail: vendors.email, + + // 제출 정보 + submittedAt: rfqLastVendorResponses.submittedAt, + submittedBy: rfqLastVendorResponses.submittedBy, + submittedByName: users.name, + + // 금액 정보 + totalAmount: rfqLastVendorResponses.totalAmount, + currency: rfqLastVendorResponses.currency, + + // 벤더 제안 조건 + vendorCurrency: rfqLastVendorResponses.vendorCurrency, + vendorPaymentTermsCode: rfqLastVendorResponses.vendorPaymentTermsCode, + vendorIncotermsCode: rfqLastVendorResponses.vendorIncotermsCode, + vendorDeliveryDate: rfqLastVendorResponses.vendorDeliveryDate, + vendorContractDuration: rfqLastVendorResponses.vendorContractDuration, + + // 초도품/Spare part 응답 + vendorFirstYn: rfqLastVendorResponses.vendorFirstYn, + vendorFirstAcceptance: rfqLastVendorResponses.vendorFirstAcceptance, + vendorSparepartYn: rfqLastVendorResponses.vendorSparepartYn, + vendorSparepartAcceptance: rfqLastVendorResponses.vendorSparepartAcceptance, + + // 비고 + generalRemark: rfqLastVendorResponses.generalRemark, + technicalProposal: rfqLastVendorResponses.technicalProposal, + + // 타임스탬프 + createdAt: rfqLastVendorResponses.createdAt, + updatedAt: rfqLastVendorResponses.updatedAt, + }) + .from(rfqLastVendorResponses) + .leftJoin(vendors, eq(rfqLastVendorResponses.vendorId, vendors.id)) + .leftJoin(users, eq(rfqLastVendorResponses.submittedBy, users.id)) + .where( + and( + eq(rfqLastVendorResponses.rfqsLastId, rfqId), + eq(rfqLastVendorResponses.isLatest, true) // 최신 버전만 조회 + ) + ) + .orderBy(desc(rfqLastVendorResponses.createdAt)); + + // 4. 각 벤더 응답별 견적 아이템 수와 첨부파일 수 계산 + const vendorResponsesWithCounts = await Promise.all( + vendorResponsesData.map(async (response) => { + // 견적 아이템 수 조회 + const itemCount = await db + .select({ count: sql`COUNT(*)::int` }) + .from(rfqLastVendorQuotationItems) + .where(eq(rfqLastVendorQuotationItems.vendorResponseId, response.id)); + + // 첨부파일 수 조회 + const attachmentCount = await db + .select({ count: sql`COUNT(*)::int` }) + .from(rfqLastVendorAttachments) + .where(eq(rfqLastVendorAttachments.vendorResponseId, response.id)); + + return { + ...response, + quotedItemCount: itemCount[0]?.count || 0, + attachmentCount: attachmentCount[0]?.count || 0, + }; + }) + ); + + // 5. 응답 데이터 정리 + const formattedResponses = vendorResponsesWithCounts.map(response => ({ + id: response.id, + rfqsLastId: response.rfqsLastId, + rfqLastDetailsId: response.rfqLastDetailsId, + responseVersion: response.responseVersion, + isLatest: response.isLatest, + status: response.status || "초대됨", // 기본값 설정 + + // 벤더 정보 + vendor: { + id: response.vendorId, + code: response.vendorCode, + name: response.vendorName, + email: response.vendorEmail, + }, + + // 제출 정보 + submission: { + submittedAt: response.submittedAt, + submittedBy: response.submittedBy, + submittedByName: response.submittedByName, + }, + + // 금액 정보 + pricing: { + totalAmount: response.totalAmount, + currency: response.currency || "USD", + vendorCurrency: response.vendorCurrency, + }, + + // 벤더 제안 조건 + vendorTerms: { + paymentTermsCode: response.vendorPaymentTermsCode, + incotermsCode: response.vendorIncotermsCode, + deliveryDate: response.vendorDeliveryDate, + contractDuration: response.vendorContractDuration, + }, + + // 초도품/Spare part + additionalRequirements: { + firstArticle: { + required: response.vendorFirstYn, + acceptance: response.vendorFirstAcceptance, + }, + sparePart: { + required: response.vendorSparepartYn, + acceptance: response.vendorSparepartAcceptance, + }, + }, + + // 카운트 정보 + counts: { + quotedItems: response.quotedItemCount, + attachments: response.attachmentCount, + }, + + // 비고 + remarks: { + general: response.generalRemark, + technical: response.technicalProposal, + }, + + // 타임스탬프 + timestamps: { + createdAt: response.createdAt, + updatedAt: response.updatedAt, + }, + })); + + return { + success: true, + data: formattedResponses, + rfq: rfqData[0], + details: details, + }; + + } catch (error) { + console.error("Failed to get vendor responses:", error); + return { + success: false, + error: error instanceof Error ? error.message : "벤더 응답 정보를 가져오는데 실패했습니다.", + data: null, + }; + } +} + +export async function getRfqWithDetails(rfqId: number) { + try { + // 1. RFQ 기본 정보 조회 (rfqsLastView 활용) + const [rfqData] = await db + .select() + .from(rfqsLastView) + .where(eq(rfqsLastView.id, rfqId)); + + if (!rfqData) { + return { success: false, error: "RFQ를 찾을 수 없습니다." }; + } + + // 2. 벤더별 상세 조건 조회 (rfqLastDetailsView 활용) + const details = await db + .select() + .from(rfqLastDetailsView) + .where(eq(rfqLastDetailsView.rfqId, rfqId)) + .orderBy(desc(rfqLastDetailsView.detailId)); + + return { + success: true, + data: { + // RFQ 기본 정보 (rfqsLastView에서 제공) + id: rfqData.id, + rfqCode: rfqData.rfqCode, + rfqType: rfqData.rfqType, + rfqTitle: rfqData.rfqTitle, + series: rfqData.series, + rfqSealedYn: rfqData.rfqSealedYn, + + // ITB 관련 + projectCompany: rfqData.projectCompany, + projectFlag: rfqData.projectFlag, + projectSite: rfqData.projectSite, + smCode: rfqData.smCode, + + // PR 정보 + prNumber: rfqData.prNumber, + prIssueDate: rfqData.prIssueDate, + + // 프로젝트 정보 + projectId: rfqData.projectId, + projectCode: rfqData.projectCode, + projectName: rfqData.projectName, + + // 아이템 정보 + itemCode: rfqData.itemCode, + itemName: rfqData.itemName, + + // 패키지 정보 + packageNo: rfqData.packageNo, + packageName: rfqData.packageName, + + // 날짜 및 상태 + dueDate: rfqData.dueDate, + rfqSendDate: rfqData.rfqSendDate, + status: rfqData.status, + + // PIC 정보 + picId: rfqData.picId, + picCode: rfqData.picCode, + picName: rfqData.picName, + picUserName: rfqData.picUserName, + engPicName: rfqData.engPicName, + + // 집계 정보 (View에서 이미 계산됨) + vendorCount: rfqData.vendorCount, + shortListedVendorCount: rfqData.shortListedVendorCount, + quotationReceivedCount: rfqData.quotationReceivedCount, + prItemsCount: rfqData.prItemsCount, + majorItemsCount: rfqData.majorItemsCount, + + // 견적 제출 정보 + earliestQuotationSubmittedAt: rfqData.earliestQuotationSubmittedAt, + + // Major Item 정보 + majorItemMaterialCode: rfqData.majorItemMaterialCode, + majorItemMaterialDescription: rfqData.majorItemMaterialDescription, + majorItemMaterialCategory: rfqData.majorItemMaterialCategory, + majorItemPrNo: rfqData.majorItemPrNo, + + // 감사 정보 + createdBy: rfqData.createdBy, + createdByUserName: rfqData.createdByUserName, + createdAt: rfqData.createdAt, + sentBy: rfqData.sentBy, + sentByUserName: rfqData.sentByUserName, + updatedBy: rfqData.updatedBy, + updatedByUserName: rfqData.updatedByUserName, + updatedAt: rfqData.updatedAt, + + // 비고 + remark: rfqData.remark, + + // 벤더별 상세 조건 (rfqLastDetailsView에서 제공) + details: details.map(d => ({ + detailId: d.detailId, + + // 벤더 정보 + vendorId: d.vendorId, + vendorName: d.vendorName, + vendorCode: d.vendorCode, + vendorCountry: d.vendorCountry, + + // 조건 정보 + currency: d.currency, + paymentTermsCode: d.paymentTermsCode, + paymentTermsDescription: d.paymentTermsDescription, + incotermsCode: d.incotermsCode, + incotermsDescription: d.incotermsDescription, + incotermsDetail: d.incotermsDetail, + deliveryDate: d.deliveryDate, + contractDuration: d.contractDuration, + taxCode: d.taxCode, + placeOfShipping: d.placeOfShipping, + placeOfDestination: d.placeOfDestination, + + // Boolean 필드들 + shortList: d.shortList, + returnYn: d.returnYn, + returnedAt: d.returnedAt, + prjectGtcYn: d.prjectGtcYn, + generalGtcYn: d.generalGtcYn, + ndaYn: d.ndaYn, + agreementYn: d.agreementYn, + materialPriceRelatedYn: d.materialPriceRelatedYn, + sparepartYn: d.sparepartYn, + firstYn: d.firstYn, + + // 설명 필드 + firstDescription: d.firstDescription, + sparepartDescription: d.sparepartDescription, + remark: d.remark, + cancelReason: d.cancelReason, + + // 견적 관련 정보 (View에서 이미 계산됨) + hasQuotation: d.hasQuotation, + quotationStatus: d.quotationStatus, + quotationTotalPrice: d.quotationTotalPrice, + quotationVersion: d.quotationVersion, + quotationVersionCount: d.quotationVersionCount, + lastQuotationDate: d.lastQuotationDate, + quotationSubmittedAt: d.quotationSubmittedAt, + + // 감사 정보 + updatedBy: d.updatedBy, + updatedByUserName: d.updatedByUserName, + updatedAt: d.updatedAt, + })), + } + }; + } catch (error) { + console.error("Get RFQ with details error:", error); + return { success: false, error: "데이터 조회 중 오류가 발생했습니다." }; + } +}
\ No newline at end of file diff --git a/lib/rfq-last/validations.ts b/lib/rfq-last/validations.ts index 34110141..5615db7a 100644 --- a/lib/rfq-last/validations.ts +++ b/lib/rfq-last/validations.ts @@ -71,87 +71,25 @@ export const searchParamsRfqTabCache = createSearchParamsCache({ tab: parseAsStringEnum(['design', 'purchase']).withDefault('design'), }) -// 설계 탭 전용 파라미터 -export const searchParamsRfqDesignCache = createSearchParamsCache({ - design_page: parseAsInteger.withDefault(1), - design_perPage: parseAsInteger.withDefault(10), - design_sort: getSortingStateParser<RfqLastAttachments>().withDefault([ - { id: "createdAt", desc: true }, - ]), - design_search: parseAsString.withDefault(""), - design_fileType: parseAsArrayOf(z.string()).withDefault([]), - design_filters: getFiltersStateParser().withDefault([]), - design_joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), -}) -// 구매 탭 전용 파라미터 -export const searchParamsRfqPurchaseCache = createSearchParamsCache({ - purchase_page: parseAsInteger.withDefault(1), - purchase_perPage: parseAsInteger.withDefault(10), - purchase_sort: getSortingStateParser<RfqLastAttachments>().withDefault([ - { id: "createdAt", desc: true }, - ]), - purchase_search: parseAsString.withDefault(""), - purchase_fileType: parseAsArrayOf(z.string()).withDefault([]), - purchase_filters: getFiltersStateParser().withDefault([]), - purchase_joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), -}) // 통합 파라미터 캐시 (모든 파라미터를 한 번에 파싱) -export const searchParamsRfqAttachmentsCache = createSearchParamsCache({ - // 공통 - tab: parseAsStringEnum(['design', 'purchase']).withDefault('design'), +export const searchParamsRfqAttachmentsCache =createSearchParamsCache({ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), - - // 설계 탭 파라미터 - design_page: parseAsInteger.withDefault(1), - design_perPage: parseAsInteger.withDefault(10), - design_sort: getSortingStateParser<RfqLastAttachments>().withDefault([ + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<RfqLastAttachments>().withDefault([ { id: "createdAt", desc: true }, ]), - design_search: parseAsString.withDefault(""), - design_fileType: parseAsArrayOf(z.string()).withDefault([]), - design_filters: getFiltersStateParser().withDefault([]), - design_joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), - - // 구매 탭 파라미터 - purchase_page: parseAsInteger.withDefault(1), - purchase_perPage: parseAsInteger.withDefault(10), - purchase_sort: getSortingStateParser<RfqLastAttachments>().withDefault([ - { id: "createdAt", desc: true }, - ]), - purchase_search: parseAsString.withDefault(""), - purchase_fileType: parseAsArrayOf(z.string()).withDefault([]), - purchase_filters: getFiltersStateParser().withDefault([]), - purchase_joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), -}) + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), -// 타입 정의 -export type GetRfqLastAttachmentsSchema = { - page: number - perPage: number - sort: Array<{ id: string; desc: boolean }> - search: string - fileType: string[] - filters: any[] - joinOperator: "and" | "or" - attachmentType?: string[] -} -// 헬퍼 함수: prefix가 붙은 파라미터를 일반 파라미터로 변환 -export function extractTabParams( - allParams: Awaited<ReturnType<typeof searchParamsRfqAttachmentsCache.parse>>, - tabPrefix: 'design' | 'purchase' -): GetRfqLastAttachmentsSchema { - const prefix = `${tabPrefix}_` - - return { - page: allParams[`${prefix}page` as keyof typeof allParams] as number, - perPage: allParams[`${prefix}perPage` as keyof typeof allParams] as number, - sort: allParams[`${prefix}sort` as keyof typeof allParams] as any, - search: allParams[`${prefix}search` as keyof typeof allParams] as string, - fileType: allParams[`${prefix}fileType` as keyof typeof allParams] as string[], - filters: allParams[`${prefix}filters` as keyof typeof allParams] as any[], - joinOperator: allParams[`${prefix}joinOperator` as keyof typeof allParams] as "and" | "or", - } -}
\ No newline at end of file +}); + + +// 타입 정의 +export type GetRfqLastAttachmentsSchema =Awaited< +ReturnType<typeof searchParamsRfqAttachmentsCache.parse> +>; diff --git a/lib/rfq-last/vendor/add-vendor-dialog.tsx b/lib/rfq-last/vendor/add-vendor-dialog.tsx new file mode 100644 index 00000000..d8745298 --- /dev/null +++ b/lib/rfq-last/vendor/add-vendor-dialog.tsx @@ -0,0 +1,307 @@ +"use client"; + +import * as React from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@/components/ui/popover"; +import { Check, ChevronsUpDown, Loader2, X, Plus } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; +import { addVendorsToRfq } from "../service"; +import { getVendorsForSelection } from "@/lib/b-rfq/service"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Info } from "lucide-react"; + +interface AddVendorDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + rfqId: number; + onSuccess: () => void; +} + +export function AddVendorDialog({ + open, + onOpenChange, + rfqId, + onSuccess, +}: AddVendorDialogProps) { + const [isLoading, setIsLoading] = React.useState(false); + const [vendorOpen, setVendorOpen] = React.useState(false); + const [vendorList, setVendorList] = React.useState<any[]>([]); + const [selectedVendors, setSelectedVendors] = React.useState<any[]>([]); + + // 벤더 로드 + const loadVendors = React.useCallback(async () => { + try { + const result = await getVendorsForSelection(); + if (result) { + setVendorList(result); + } + } catch (error) { + console.error("Failed to load vendors:", error); + toast.error("벤더 목록을 불러오는데 실패했습니다."); + } + }, []); + + React.useEffect(() => { + if (open) { + loadVendors(); + } + }, [open, loadVendors]); + + // 초기화 + React.useEffect(() => { + if (!open) { + setSelectedVendors([]); + } + }, [open]); + + // 벤더 추가 + const handleAddVendor = (vendor: any) => { + if (!selectedVendors.find(v => v.id === vendor.id)) { + setSelectedVendors([...selectedVendors, vendor]); + } + setVendorOpen(false); + }; + + // 벤더 제거 + const handleRemoveVendor = (vendorId: number) => { + setSelectedVendors(selectedVendors.filter(v => v.id !== vendorId)); + }; + + // 제출 처리 - 벤더만 추가 + const handleSubmit = async () => { + if (selectedVendors.length === 0) { + toast.error("최소 1개 이상의 벤더를 선택해주세요."); + return; + } + + setIsLoading(true); + + try { + const vendorIds = selectedVendors.map(v => v.id); + const result = await addVendorsToRfq({ + rfqId, + vendorIds, + // 기본값으로 벤더만 추가 (상세 조건은 나중에 일괄 입력) + conditions: null, + }); + + if (result.success) { + toast.success( + <div> + <p>{selectedVendors.length}개 벤더가 추가되었습니다.</p> + <p className="text-sm text-muted-foreground mt-1"> + 벤더 목록에서 '정보 일괄 입력' 버튼으로 조건을 설정하세요. + </p> + </div> + ); + onSuccess(); + onOpenChange(false); + } else { + toast.error(result.error || "벤더 추가에 실패했습니다."); + } + } catch (error) { + console.error("Submit error:", error); + toast.error("오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + // 이미 선택된 벤더인지 확인 + const isVendorSelected = (vendorId: number) => { + return selectedVendors.some(v => v.id === vendorId); + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-2xl max-h-[80vh] p-0 flex flex-col"> + {/* 헤더 */} + <DialogHeader className="p-6 pb-0"> + <DialogTitle>벤더 추가</DialogTitle> + <DialogDescription> + 견적 요청을 보낼 벤더를 선택하세요. 조건 설정은 추가 후 일괄로 진행할 수 있습니다. + </DialogDescription> + </DialogHeader> + + {/* 컨텐츠 영역 */} + <div className="flex-1 px-6 py-4 overflow-y-auto"> + <div className="space-y-4"> + {/* 안내 메시지 */} + <Alert> + <Info className="h-4 w-4" /> + <AlertDescription> + 여기서는 벤더만 선택합니다. 납기일, 결제조건 등의 상세 정보는 벤더 추가 후 + '정보 일괄 입력' 기능으로 한 번에 설정할 수 있습니다. + </AlertDescription> + </Alert> + + {/* 벤더 선택 카드 */} + <Card> + <CardHeader> + <div className="flex items-center justify-between"> + <CardTitle className="text-lg">벤더 선택</CardTitle> + <Badge variant="outline" className="ml-2"> + {selectedVendors.length}개 선택됨 + </Badge> + </div> + <CardDescription> + RFQ를 발송할 벤더를 선택하세요. 여러 개 선택 가능합니다. + </CardDescription> + </CardHeader> + <CardContent> + <div className="space-y-4"> + {/* 벤더 추가 버튼 */} + <Popover open={vendorOpen} onOpenChange={setVendorOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={vendorOpen} + className="w-full justify-between" + disabled={vendorList.length === 0} + > + <span className="flex items-center gap-2"> + <Plus className="h-4 w-4" /> + 벤더 선택하기 + </span> + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-[500px] p-0" align="start"> + <Command> + <CommandInput placeholder="벤더명 또는 코드로 검색..." /> + <CommandList + onWheel={(e) => { + e.stopPropagation(); // 이벤트 전파 차단 + const target = e.currentTarget; + target.scrollTop += e.deltaY; // 직접 스크롤 처리 + }} + > + <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> + <CommandGroup> + {vendorList + .filter(vendor => !isVendorSelected(vendor.id)) + .map((vendor) => ( + <CommandItem + key={vendor.id} + value={`${vendor.vendorCode} ${vendor.vendorName}`} + onSelect={() => handleAddVendor(vendor)} + > + <div className="flex items-center gap-2 w-full"> + <Badge variant="outline" className="shrink-0"> + {vendor.vendorCode} + </Badge> + <span className="truncate">{vendor.vendorName}</span> + {vendor.country && ( + <span className="text-xs text-muted-foreground ml-auto"> + {vendor.country} + </span> + )} + </div> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + + {/* 선택된 벤더 목록 */} + {selectedVendors.length > 0 && ( + <div className="space-y-2"> + <Label className="text-sm text-muted-foreground">선택된 벤더 목록</Label> + <ScrollArea className="h-[200px] w-full rounded-md border p-4"> + <div className="space-y-2"> + {selectedVendors.map((vendor, index) => ( + <div + key={vendor.id} + className="flex items-center justify-between p-2 rounded-lg bg-secondary/50" + > + <div className="flex items-center gap-2"> + <span className="text-sm text-muted-foreground"> + {index + 1}. + </span> + <Badge variant="outline"> + {vendor.vendorCode} + </Badge> + <span className="text-sm font-medium"> + {vendor.vendorName} + </span> + </div> + <Button + variant="ghost" + size="sm" + onClick={() => handleRemoveVendor(vendor.id)} + className="h-8 w-8 p-0" + > + <X className="h-4 w-4" /> + </Button> + </div> + ))} + </div> + </ScrollArea> + </div> + )} + + {/* 벤더가 없는 경우 메시지 */} + {selectedVendors.length === 0 && ( + <div className="text-center py-8 text-muted-foreground"> + <p className="text-sm">아직 선택된 벤더가 없습니다.</p> + <p className="text-xs mt-1">위 버튼을 클릭하여 벤더를 추가하세요.</p> + </div> + )} + </div> + </CardContent> + </Card> + </div> + </div> + + {/* 푸터 */} + <DialogFooter className="p-6 pt-0 border-t"> + <Button + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isLoading} + > + 취소 + </Button> + <Button + onClick={handleSubmit} + disabled={isLoading || selectedVendors.length === 0} + > + {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + {selectedVendors.length > 0 + ? `${selectedVendors.length}개 벤더 추가` + : '벤더 추가' + } + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx b/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx new file mode 100644 index 00000000..1b8fa528 --- /dev/null +++ b/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx @@ -0,0 +1,1121 @@ +"use client"; + +import * as React from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@/components/ui/popover"; +import { Textarea } from "@/components/ui/textarea"; +import { Switch } from "@/components/ui/switch"; +import { Calendar } from "@/components/ui/calendar"; +import { CalendarIcon, Loader2, Info, Package, Check, ChevronsUpDown } from "lucide-react"; +import { format } from "date-fns"; +import { ko } from "date-fns/locale"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; +import { updateVendorConditionsBatch } from "../service"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + getIncotermsForSelection, + getPaymentTermsForSelection, + getPlaceOfShippingForSelection, + getPlaceOfDestinationForSelection +} from "@/lib/procurement-select/service"; + +interface BatchUpdateConditionsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + rfqId: number; + rfqCode: string; + selectedVendors: Array<{ + id: number; + vendorName: string; + vendorCode: string; + }>; + onSuccess: () => void; +} + +// 타입 정의 +interface SelectOption { + id: number; + code: string; + description: string; +} + +// 폼 스키마 +const formSchema = z.object({ + currency: z.string().optional(), + paymentTermsCode: z.string().optional(), + incotermsCode: z.string().optional(), + incotermsDetail: z.string().optional(), + contractDuration: z.string().optional(), + taxCode: z.string().optional(), + placeOfShipping: z.string().optional(), + placeOfDestination: z.string().optional(), + deliveryDate: z.date().optional(), + materialPriceRelatedYn: z.boolean().default(false), + sparepartYn: z.boolean().default(false), + firstYn: z.boolean().default(false), + firstDescription: z.string().optional(), + sparepartDescription: z.string().optional(), +}); + +type FormValues = z.infer<typeof formSchema>; + +const currencies = ["USD", "EUR", "KRW", "JPY", "CNY"]; + +export function BatchUpdateConditionsDialog({ + open, + onOpenChange, + rfqId, + rfqCode, + selectedVendors, + onSuccess, +}: BatchUpdateConditionsDialogProps) { + const [isLoading, setIsLoading] = React.useState(false); + + // Select 옵션들 상태 + const [incoterms, setIncoterms] = React.useState<SelectOption[]>([]); + const [paymentTerms, setPaymentTerms] = React.useState<SelectOption[]>([]); + const [shippingPlaces, setShippingPlaces] = React.useState<SelectOption[]>([]); + const [destinationPlaces, setDestinationPlaces] = React.useState<SelectOption[]>([]); + + // 로딩 상태 + const [incotermsLoading, setIncotermsLoading] = React.useState(false); + const [paymentTermsLoading, setPaymentTermsLoading] = React.useState(false); + const [shippingLoading, setShippingLoading] = React.useState(false); + const [destinationLoading, setDestinationLoading] = React.useState(false); + + // Popover 열림 상태 + const [incotermsOpen, setIncotermsOpen] = React.useState(false); + const [paymentTermsOpen, setPaymentTermsOpen] = React.useState(false); + const [shippingOpen, setShippingOpen] = React.useState(false); + const [destinationOpen, setDestinationOpen] = React.useState(false); + const [calendarOpen, setCalendarOpen] = React.useState(false); + + // 체크박스로 각 필드 업데이트 여부 관리 + const [fieldsToUpdate, setFieldsToUpdate] = React.useState({ + currency: false, + paymentTermsCode: false, + incoterms: false, + deliveryDate: false, + contractDuration: false, + taxCode: false, + shipping: false, + materialPrice: false, + sparepart: false, + first: false, + }); + + // 폼 초기화 + const form = useForm<FormValues>({ + resolver: zodResolver(formSchema), + defaultValues: { + currency: "", + paymentTermsCode: "", + incotermsCode: "", + incotermsDetail: "", + contractDuration: "", + taxCode: "", + placeOfShipping: "", + placeOfDestination: "", + materialPriceRelatedYn: false, + sparepartYn: false, + firstYn: false, + firstDescription: "", + sparepartDescription: "", + }, + }); + + // 데이터 로드 함수들 + const loadIncoterms = React.useCallback(async () => { + setIncotermsLoading(true); + try { + const data = await getIncotermsForSelection(); + setIncoterms(data); + } catch (error) { + console.error("Failed to load incoterms:", error); + toast.error("Incoterms 목록을 불러오는데 실패했습니다."); + } finally { + setIncotermsLoading(false); + } + }, []); + + const loadPaymentTerms = React.useCallback(async () => { + setPaymentTermsLoading(true); + try { + const data = await getPaymentTermsForSelection(); + setPaymentTerms(data); + } catch (error) { + console.error("Failed to load payment terms:", error); + toast.error("결제조건 목록을 불러오는데 실패했습니다."); + } finally { + setPaymentTermsLoading(false); + } + }, []); + + const loadShippingPlaces = React.useCallback(async () => { + setShippingLoading(true); + try { + const data = await getPlaceOfShippingForSelection(); + setShippingPlaces(data); + } catch (error) { + console.error("Failed to load shipping places:", error); + toast.error("선적지 목록을 불러오는데 실패했습니다."); + } finally { + setShippingLoading(false); + } + }, []); + + const loadDestinationPlaces = React.useCallback(async () => { + setDestinationLoading(true); + try { + const data = await getPlaceOfDestinationForSelection(); + setDestinationPlaces(data); + } catch (error) { + console.error("Failed to load destination places:", error); + toast.error("도착지 목록을 불러오는데 실패했습니다."); + } finally { + setDestinationLoading(false); + } + }, []); + + // 초기 데이터 로드 + React.useEffect(() => { + if (open) { + loadIncoterms(); + loadPaymentTerms(); + loadShippingPlaces(); + loadDestinationPlaces(); + } + }, [open, loadIncoterms, loadPaymentTerms, loadShippingPlaces, loadDestinationPlaces]); + + // 다이얼로그 닫힐 때 초기화 + React.useEffect(() => { + if (!open) { + form.reset(); + setFieldsToUpdate({ + currency: false, + paymentTermsCode: false, + incoterms: false, + deliveryDate: false, + contractDuration: false, + taxCode: false, + shipping: false, + materialPrice: false, + sparepart: false, + first: false, + }); + } + }, [open, form]); + + // 제출 처리 + const onSubmit = async (data: FormValues) => { + const hasFieldsToUpdate = Object.values(fieldsToUpdate).some(v => v); + if (!hasFieldsToUpdate) { + toast.error("최소 1개 이상의 변경할 항목을 선택해주세요."); + return; + } + + // 선택된 필드만 포함하여 conditions 객체 생성 + const conditions: any = {}; + + if (fieldsToUpdate.currency && data.currency) { + conditions.currency = data.currency; + } + if (fieldsToUpdate.paymentTermsCode && data.paymentTermsCode) { + conditions.paymentTermsCode = data.paymentTermsCode; + } + if (fieldsToUpdate.incoterms) { + if (data.incotermsCode) conditions.incotermsCode = data.incotermsCode; + if (data.incotermsDetail) conditions.incotermsDetail = data.incotermsDetail; + } + if (fieldsToUpdate.deliveryDate && data.deliveryDate) { + conditions.deliveryDate = data.deliveryDate; + } + if (fieldsToUpdate.contractDuration) { + conditions.contractDuration = data.contractDuration; + } + if (fieldsToUpdate.taxCode) { + conditions.taxCode = data.taxCode; + } + if (fieldsToUpdate.shipping) { + conditions.placeOfShipping = data.placeOfShipping; + conditions.placeOfDestination = data.placeOfDestination; + } + if (fieldsToUpdate.materialPrice) { + conditions.materialPriceRelatedYn = data.materialPriceRelatedYn; + } + if (fieldsToUpdate.sparepart) { + conditions.sparepartYn = data.sparepartYn; + if (data.sparepartYn) { + conditions.sparepartDescription = data.sparepartDescription; + } + } + if (fieldsToUpdate.first) { + conditions.firstYn = data.firstYn; + if (data.firstYn) { + conditions.firstDescription = data.firstDescription; + } + } + + setIsLoading(true); + + try { + const vendorIds = selectedVendors.map(v => v.id); + const result = await updateVendorConditionsBatch({ + rfqId, + vendorIds, + conditions, + }); + + if (result.success) { + toast.success(result.data?.message || "조건이 성공적으로 업데이트되었습니다."); + onSuccess(); + onOpenChange(false); + } else { + toast.error(result.error || "조건 업데이트에 실패했습니다."); + } + } catch (error) { + console.error("Submit error:", error); + toast.error("오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + const getUpdateCount = () => { + return Object.values(fieldsToUpdate).filter(v => v).length; + }; + + // 선택된 옵션 찾기 헬퍼 함수들 + const selectedIncoterm = incoterms.find(i => i.code === form.watch("incotermsCode")); + const selectedPaymentTerm = paymentTerms.find(p => p.code === form.watch("paymentTermsCode")); + const selectedShipping = shippingPlaces.find(s => s.code === form.watch("placeOfShipping")); + const selectedDestination = destinationPlaces.find(d => d.code === form.watch("placeOfDestination")); + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-3xl h-[90vh] p-0 flex flex-col"> + {/* 헤더 */} + <DialogHeader className="p-6 pb-0"> + <DialogTitle>조건 일괄 설정</DialogTitle> + <DialogDescription> + 선택한 {selectedVendors.length}개 벤더에 동일한 조건을 적용합니다. + 변경하려는 항목만 체크하고 값을 입력하세요. + </DialogDescription> + </DialogHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0"> + {/* 스크롤 가능한 컨텐츠 영역 */} + <ScrollArea className="flex-1 px-6"> + <div className="grid gap-4 py-4"> + {/* 선택된 벤더 정보 */} + <Card> + <CardHeader className="pb-3"> + <div className="flex items-center justify-between"> + <CardTitle className="text-lg flex items-center gap-2"> + <Package className="h-5 w-5" /> + 대상 벤더 + </CardTitle> + <Badge>{selectedVendors.length}개</Badge> + </div> + </CardHeader> + <CardContent> + <div className="flex flex-wrap gap-2"> + {selectedVendors.map((vendor) => ( + <Badge key={vendor.id} variant="secondary"> + {vendor.vendorCode} - {vendor.vendorName} + </Badge> + ))} + </div> + </CardContent> + </Card> + + {/* 안내 메시지 */} + <Alert> + <Info className="h-4 w-4" /> + <AlertDescription> + 체크박스를 선택한 항목만 업데이트됩니다. + 선택하지 않은 항목은 기존 값이 유지됩니다. + </AlertDescription> + </Alert> + + {/* 기본 조건 설정 */} + <Card> + <CardHeader> + <CardTitle className="text-lg">기본 조건</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + {/* 통화 */} + <div className="flex items-center gap-4"> + <Checkbox + checked={fieldsToUpdate.currency} + onCheckedChange={(checked) => + setFieldsToUpdate({ ...fieldsToUpdate, currency: !!checked }) + } + /> + <FormField + control={form.control} + name="currency" + render={({ field }) => ( + <FormItem className="flex-1 grid grid-cols-3 items-center gap-4"> + <FormLabel className={cn( + "text-right", + !fieldsToUpdate.currency && "text-muted-foreground" + )}> + 통화 + </FormLabel> + <div className="col-span-2"> + <FormControl> + <Popover> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + className="w-full justify-between" + disabled={!fieldsToUpdate.currency} + > + {field.value || "통화 선택"} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-full p-0" align="start"> + <Command> + <CommandInput placeholder="통화 검색..." /> + <CommandList> + <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> + <CommandGroup> + {currencies.map((currency) => ( + <CommandItem + key={currency} + value={currency} + onSelect={() => field.onChange(currency)} + > + {currency} + <Check + className={cn( + "ml-auto h-4 w-4", + currency === field.value ? "opacity-100" : "opacity-0" + )} + /> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + </FormControl> + <FormMessage /> + </div> + </FormItem> + )} + /> + </div> + + {/* 결제 조건 */} + <div className="flex items-center gap-4"> + <Checkbox + checked={fieldsToUpdate.paymentTermsCode} + onCheckedChange={(checked) => + setFieldsToUpdate({ ...fieldsToUpdate, paymentTermsCode: !!checked }) + } + /> + <FormField + control={form.control} + name="paymentTermsCode" + render={({ field }) => ( + <FormItem className="flex-1 grid grid-cols-3 items-center gap-4"> + <FormLabel className={cn( + "text-right", + !fieldsToUpdate.paymentTermsCode && "text-muted-foreground" + )}> + 결제 조건 + </FormLabel> + <div className="col-span-2"> + <Popover open={paymentTermsOpen} onOpenChange={setPaymentTermsOpen}> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + role="combobox" + aria-expanded={paymentTermsOpen} + className="w-full justify-between" + disabled={!fieldsToUpdate.paymentTermsCode || paymentTermsLoading} + > + {selectedPaymentTerm ? ( + <span className="truncate"> + {selectedPaymentTerm.code} - {selectedPaymentTerm.description} + </span> + ) : ( + <span className="text-muted-foreground"> + {paymentTermsLoading ? "로딩 중..." : "결제조건 선택"} + </span> + )} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-full p-0" align="start"> + <Command> + <CommandInput placeholder="코드 또는 설명으로 검색..." /> + <CommandList> + <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> + <CommandGroup> + {paymentTerms.map((term) => ( + <CommandItem + key={term.id} + value={`${term.code} ${term.description}`} + onSelect={() => { + field.onChange(term.code); + setPaymentTermsOpen(false); + }} + > + <div className="flex items-center gap-2 w-full"> + <span className="font-medium">{term.code}</span> + <span className="text-muted-foreground">-</span> + <span className="truncate">{term.description}</span> + <Check + className={cn( + "ml-auto h-4 w-4", + term.code === field.value ? "opacity-100" : "opacity-0" + )} + /> + </div> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + <FormMessage /> + </div> + </FormItem> + )} + /> + </div> + + {/* 인코텀즈 */} + <div className="flex items-start gap-4"> + <Checkbox + className="mt-3" + checked={fieldsToUpdate.incoterms} + onCheckedChange={(checked) => + setFieldsToUpdate({ ...fieldsToUpdate, incoterms: !!checked }) + } + /> + <div className="flex-1 grid grid-cols-3 gap-4"> + <Label className={cn( + "text-right pt-2", + !fieldsToUpdate.incoterms && "text-muted-foreground" + )}> + 인코텀즈 + </Label> + <div className="col-span-2 space-y-2"> + <FormField + control={form.control} + name="incotermsCode" + render={({ field }) => ( + <FormItem> + <Popover open={incotermsOpen} onOpenChange={setIncotermsOpen}> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + role="combobox" + aria-expanded={incotermsOpen} + className="w-full justify-between" + disabled={!fieldsToUpdate.incoterms || incotermsLoading} + > + {selectedIncoterm ? ( + <span className="truncate"> + {selectedIncoterm.code} - {selectedIncoterm.description} + </span> + ) : ( + <span className="text-muted-foreground"> + {incotermsLoading ? "로딩 중..." : "인코텀즈 선택"} + </span> + )} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-full p-0" align="start"> + <Command> + <CommandInput placeholder="코드 또는 설명으로 검색..." /> + <CommandList> + <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> + <CommandGroup> + {incoterms.map((incoterm) => ( + <CommandItem + key={incoterm.id} + value={`${incoterm.code} ${incoterm.description}`} + onSelect={() => { + field.onChange(incoterm.code); + setIncotermsOpen(false); + }} + > + <div className="flex items-center gap-2 w-full"> + <span className="font-medium">{incoterm.code}</span> + <span className="text-muted-foreground">-</span> + <span className="truncate">{incoterm.description}</span> + <Check + className={cn( + "ml-auto h-4 w-4", + incoterm.code === field.value ? "opacity-100" : "opacity-0" + )} + /> + </div> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + {/* <FormField + control={form.control} + name="incotermsDetail" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + placeholder="인코텀즈 상세 (예: 부산항)" + {...field} + disabled={!fieldsToUpdate.incoterms} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> */} + </div> + </div> + </div> + + {/* 납기일 */} + {!rfqCode.startsWith("F") && ( + <div className="flex items-center gap-4"> + <Checkbox + checked={fieldsToUpdate.deliveryDate} + onCheckedChange={(checked) => + setFieldsToUpdate({ ...fieldsToUpdate, deliveryDate: !!checked }) + } + /> + <FormField + control={form.control} + name="deliveryDate" + render={({ field }) => ( + <FormItem className="flex-1 grid grid-cols-3 items-center gap-4"> + <FormLabel className={cn( + "text-right", + !fieldsToUpdate.deliveryDate && "text-muted-foreground" + )}> + 납기일 + </FormLabel> + <div className="col-span-2"> + <Popover open={calendarOpen} onOpenChange={setCalendarOpen}> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + className={cn( + "w-full justify-start text-left font-normal", + !field.value && "text-muted-foreground" + )} + disabled={!fieldsToUpdate.deliveryDate} + > + <CalendarIcon className="mr-2 h-4 w-4" /> + {field.value ? ( + format(field.value, "yyyy-MM-dd", { locale: ko }) + ) : ( + <span>날짜를 선택하세요</span> + )} + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-auto p-0"> + <Calendar + mode="single" + selected={field.value} + onSelect={(date) => { + field.onChange(date); + setCalendarOpen(false); + }} + initialFocus + /> + </PopoverContent> + </Popover> + <FormMessage /> + </div> + </FormItem> + )} + /> + </div> + )} + + {/* 계약 기간 */} + {rfqCode.startsWith("F") && ( + <div className="flex items-center gap-4"> + <Checkbox + checked={fieldsToUpdate.contractDuration} + onCheckedChange={(checked) => + setFieldsToUpdate({ ...fieldsToUpdate, contractDuration: !!checked }) + } + /> + <FormField + control={form.control} + name="contractDuration" + render={({ field }) => ( + <FormItem className="flex-1 grid grid-cols-3 items-center gap-4"> + <FormLabel className={cn( + "text-right", + !fieldsToUpdate.contractDuration && "text-muted-foreground" + )}> + 계약 기간 + </FormLabel> + <div className="col-span-2"> + <FormControl> + <Input + placeholder="예: 12개월" + {...field} + disabled={!fieldsToUpdate.contractDuration} + /> + </FormControl> + <FormMessage /> + </div> + </FormItem> + )} + /> + </div> + )} + + {/* 세금 코드 */} + <div className="flex items-center gap-4"> + <Checkbox + checked={fieldsToUpdate.taxCode} + onCheckedChange={(checked) => + setFieldsToUpdate({ ...fieldsToUpdate, taxCode: !!checked }) + } + /> + <FormField + control={form.control} + name="taxCode" + render={({ field }) => ( + <FormItem className="flex-1 grid grid-cols-3 items-center gap-4"> + <FormLabel className={cn( + "text-right", + !fieldsToUpdate.taxCode && "text-muted-foreground" + )}> + 세금 코드 + </FormLabel> + <div className="col-span-2"> + <FormControl> + <Input + {...field} + disabled={!fieldsToUpdate.taxCode} + /> + </FormControl> + <FormMessage /> + </div> + </FormItem> + )} + /> + </div> + + {/* 선적지/도착지 */} + <div className="flex items-start gap-4"> + <Checkbox + className="mt-3" + checked={fieldsToUpdate.shipping} + onCheckedChange={(checked) => + setFieldsToUpdate({ ...fieldsToUpdate, shipping: !!checked }) + } + /> + <div className="flex-1 space-y-2"> + <FormField + control={form.control} + name="placeOfShipping" + render={({ field }) => ( + <FormItem className="grid grid-cols-3 items-center gap-4"> + <FormLabel className={cn( + "text-right", + !fieldsToUpdate.shipping && "text-muted-foreground" + )}> + 선적지 + </FormLabel> + <div className="col-span-2"> + <Popover open={shippingOpen} onOpenChange={setShippingOpen}> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + role="combobox" + aria-expanded={shippingOpen} + className="w-full justify-between" + disabled={!fieldsToUpdate.shipping || shippingLoading} + > + {selectedShipping ? ( + <span className="truncate"> + {selectedShipping.code} - {selectedShipping.description} + </span> + ) : ( + <span className="text-muted-foreground"> + {shippingLoading ? "로딩 중..." : "선적지 선택"} + </span> + )} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-full p-0" align="start"> + <Command> + <CommandInput placeholder="선적지 검색..." /> + <CommandList> + <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> + <CommandGroup> + {shippingPlaces.map((place) => ( + <CommandItem + key={place.id} + value={`${place.code} ${place.description}`} + onSelect={() => { + field.onChange(place.code); + setShippingOpen(false); + }} + > + <div className="flex items-center gap-2 w-full"> + <span className="font-medium">{place.code}</span> + <span className="text-muted-foreground">-</span> + <span className="truncate">{place.description}</span> + <Check + className={cn( + "ml-auto h-4 w-4", + place.code === field.value ? "opacity-100" : "opacity-0" + )} + /> + </div> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + <FormMessage /> + </div> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="placeOfDestination" + render={({ field }) => ( + <FormItem className="grid grid-cols-3 items-center gap-4"> + <FormLabel className={cn( + "text-right", + !fieldsToUpdate.shipping && "text-muted-foreground" + )}> + 도착지 + </FormLabel> + <div className="col-span-2"> + <Popover open={destinationOpen} onOpenChange={setDestinationOpen}> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + role="combobox" + aria-expanded={destinationOpen} + className="w-full justify-between" + disabled={!fieldsToUpdate.shipping || destinationLoading} + > + {selectedDestination ? ( + <span className="truncate"> + {selectedDestination.code} - {selectedDestination.description} + </span> + ) : ( + <span className="text-muted-foreground"> + {destinationLoading ? "로딩 중..." : "도착지 선택"} + </span> + )} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-full p-0" align="start"> + <Command> + <CommandInput placeholder="도착지 검색..." /> + <CommandList> + <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> + <CommandGroup> + {destinationPlaces.map((place) => ( + <CommandItem + key={place.id} + value={`${place.code} ${place.description}`} + onSelect={() => { + field.onChange(place.code); + setDestinationOpen(false); + }} + > + <div className="flex items-center gap-2 w-full"> + <span className="font-medium">{place.code}</span> + <span className="text-muted-foreground">-</span> + <span className="truncate">{place.description}</span> + <Check + className={cn( + "ml-auto h-4 w-4", + place.code === field.value ? "opacity-100" : "opacity-0" + )} + /> + </div> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + <FormMessage /> + </div> + </FormItem> + )} + /> + </div> + </div> + </CardContent> + </Card> + + {/* 추가 옵션 */} + <Card> + <CardHeader> + <CardTitle className="text-lg">추가 옵션</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + {/* 연동제 적용 */} + <div className="flex items-center gap-4"> + <Checkbox + checked={fieldsToUpdate.materialPrice} + onCheckedChange={(checked) => + setFieldsToUpdate({ ...fieldsToUpdate, materialPrice: !!checked }) + } + /> + <FormField + control={form.control} + name="materialPriceRelatedYn" + render={({ field }) => ( + <FormItem className="flex-1 flex items-center justify-between"> + <div className="space-y-0.5"> + <FormLabel className={cn( + !fieldsToUpdate.materialPrice && "text-muted-foreground" + )}> + 연동제 적용 + </FormLabel> + <div className="text-sm text-muted-foreground"> + 원자재 가격 연동 여부 + </div> + </div> + <FormControl> + <Switch + checked={field.value} + onCheckedChange={field.onChange} + disabled={!fieldsToUpdate.materialPrice} + /> + </FormControl> + </FormItem> + )} + /> + </div> + + {/* Spare Part */} + <div className="space-y-2"> + <div className="flex items-center gap-4"> + <Checkbox + checked={fieldsToUpdate.sparepart} + onCheckedChange={(checked) => + setFieldsToUpdate({ ...fieldsToUpdate, sparepart: !!checked }) + } + /> + <FormField + control={form.control} + name="sparepartYn" + render={({ field }) => ( + <FormItem className="flex-1 flex items-center justify-between"> + <div className="space-y-0.5"> + <FormLabel className={cn( + !fieldsToUpdate.sparepart && "text-muted-foreground" + )}> + Spare Part + </FormLabel> + <div className="text-sm text-muted-foreground"> + 예비 부품 요구사항 + </div> + </div> + <FormControl> + <Switch + checked={field.value} + onCheckedChange={field.onChange} + disabled={!fieldsToUpdate.sparepart} + /> + </FormControl> + </FormItem> + )} + /> + </div> + {form.watch("sparepartYn") && fieldsToUpdate.sparepart && ( + <FormField + control={form.control} + name="sparepartDescription" + render={({ field }) => ( + <FormItem className="ml-7"> + <FormControl> + <Textarea + placeholder="Spare Part 요구사항을 입력하세요..." + {...field} + disabled={!fieldsToUpdate.sparepart} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + )} + </div> + + {/* 초도품 관리 */} + <div className="space-y-2"> + <div className="flex items-center gap-4"> + <Checkbox + checked={fieldsToUpdate.first} + onCheckedChange={(checked) => + setFieldsToUpdate({ ...fieldsToUpdate, first: !!checked }) + } + /> + <FormField + control={form.control} + name="firstYn" + render={({ field }) => ( + <FormItem className="flex-1 flex items-center justify-between"> + <div className="space-y-0.5"> + <FormLabel className={cn( + !fieldsToUpdate.first && "text-muted-foreground" + )}> + 초도품 관리 + </FormLabel> + <div className="text-sm text-muted-foreground"> + 초도품 관리 요구사항 + </div> + </div> + <FormControl> + <Switch + checked={field.value} + onCheckedChange={field.onChange} + disabled={!fieldsToUpdate.first} + /> + </FormControl> + </FormItem> + )} + /> + </div> + {form.watch("firstYn") && fieldsToUpdate.first && ( + <FormField + control={form.control} + name="firstDescription" + render={({ field }) => ( + <FormItem className="ml-7"> + <FormControl> + <Textarea + placeholder="초도품 관리 요구사항을 입력하세요..." + {...field} + disabled={!fieldsToUpdate.first} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + )} + </div> + </CardContent> + </Card> + </div> + </ScrollArea> + + {/* 푸터 */} + <DialogFooter className="p-6 pt-4 border-t"> + <div className="flex items-center justify-between w-full"> + <div className="text-sm text-muted-foreground"> + {getUpdateCount() > 0 + ? `${getUpdateCount()}개 항목 선택됨` + : '변경할 항목을 선택하세요' + } + </div> + <div className="flex gap-2"> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isLoading} + > + 취소 + </Button> + <Button + type="submit" + disabled={isLoading || getUpdateCount() === 0} + > + {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + {getUpdateCount() > 0 + ? `${getUpdateCount()}개 항목 업데이트` + : '조건 업데이트' + } + </Button> + </div> + </div> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx new file mode 100644 index 00000000..b6d42804 --- /dev/null +++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx @@ -0,0 +1,746 @@ +"use client"; + +import * as React from "react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Plus, + Send, + Eye, + Edit, + Trash2, + Building2, + Calendar, + DollarSign, + FileText, + RefreshCw, + Mail, + CheckCircle, + Clock, + XCircle, + AlertCircle, + Settings2, + ClipboardList, + Globe, + Package, + MapPin, + Info +} from "lucide-react"; +import { format } from "date-fns"; +import { ko } from "date-fns/locale"; +import { type ColumnDef } from "@tanstack/react-table"; +import { Checkbox } from "@/components/ui/checkbox"; +import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header"; +import { ClientDataTable } from "@/components/client-data-table/data-table"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import type { DataTableAdvancedFilterField } from "@/types/table"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; +import { AddVendorDialog } from "./add-vendor-dialog"; +import { BatchUpdateConditionsDialog } from "./batch-update-conditions-dialog"; +// import { VendorDetailDialog } from "./vendor-detail-dialog"; + +// 타입 정의 +interface RfqDetail { + detailId: number; + vendorId: number | null; + vendorName: string | null; + vendorCode: string | null; + vendorCountry: string | null; + vendorCategory?: string | null; // 업체분류 + vendorGrade?: string | null; // AVL 등급 + basicContract?: string | null; // 기본계약 + shortList: boolean; + currency: string | null; + paymentTermsCode: string | null; + paymentTermsDescription: string | null; + incotermsCode: string | null; + incotermsDescription: string | null; + incotermsDetail?: string | null; + deliveryDate: Date | null; + contractDuration: string | null; + taxCode: string | null; + placeOfShipping?: string | null; + placeOfDestination?: string | null; + materialPriceRelatedYn?: boolean | null; + sparepartYn?: boolean | null; + firstYn?: boolean | null; + firstDescription?: string | null; + sparepartDescription?: string | null; + updatedAt?: Date | null; + updatedByUserName?: string | null; +} + +interface VendorResponse { + id: number; + vendorId: number; + status: "초대됨" | "작성중" | "제출완료" | "수정요청" | "최종확정" | "취소"; + responseVersion: number; + isLatest: boolean; + submittedAt: Date | null; + totalAmount: number | null; + currency: string | null; + vendorDeliveryDate: Date | null; + quotedItemCount?: number; + attachmentCount?: number; +} + +interface RfqVendorTableProps { + rfqId: number; + rfqCode?: string; + rfqDetails: RfqDetail[]; + vendorResponses: VendorResponse[]; +} + +// 상태별 아이콘 반환 +const getStatusIcon = (status: string) => { + switch (status) { + case "초대됨": return <Mail className="h-4 w-4" />; + case "작성중": return <Clock className="h-4 w-4" />; + case "제출완료": return <CheckCircle className="h-4 w-4" />; + case "수정요청": return <AlertCircle className="h-4 w-4" />; + case "최종확정": return <FileText className="h-4 w-4" />; + case "취소": return <XCircle className="h-4 w-4" />; + default: return <Clock className="h-4 w-4" />; + } +}; + +// 상태별 색상 +const getStatusVariant = (status: string) => { + switch (status) { + case "초대됨": return "secondary"; + case "작성중": return "outline"; + case "제출완료": return "default"; + case "수정요청": return "warning"; + case "최종확정": return "success"; + case "취소": return "destructive"; + default: return "outline"; + } +}; + +// 데이터 병합 (rfqDetails + vendorResponses) +const mergeVendorData = ( + rfqDetails: RfqDetail[], + vendorResponses: VendorResponse[], + rfqCode?: string +): (RfqDetail & { response?: VendorResponse; rfqCode?: string })[] => { + return rfqDetails.map(detail => { + const response = vendorResponses.find( + r => r.vendorId === detail.vendorId && r.isLatest + ); + return { ...detail, response, rfqCode }; + }); +}; + +// 추가 조건 포맷팅 +const formatAdditionalConditions = (data: any) => { + const conditions = []; + if (data.firstYn) conditions.push("초도품"); + if (data.materialPriceRelatedYn) conditions.push("연동제"); + if (data.sparepartYn) conditions.push("스페어"); + return conditions.length > 0 ? conditions.join(", ") : "-"; +}; + +export function RfqVendorTable({ + rfqId, + rfqCode, + rfqDetails, + vendorResponses, +}: RfqVendorTableProps) { + const [isRefreshing, setIsRefreshing] = React.useState(false); + const [selectedRows, setSelectedRows] = React.useState<any[]>([]); + const [isAddDialogOpen, setIsAddDialogOpen] = React.useState(false); + const [isBatchUpdateOpen, setIsBatchUpdateOpen] = React.useState(false); + const [selectedVendor, setSelectedVendor] = React.useState<any | null>(null); + + // 데이터 병합 + const mergedData = React.useMemo( + () => mergeVendorData(rfqDetails, vendorResponses, rfqCode), + [rfqDetails, vendorResponses, rfqCode] + ); + + // 액션 처리 + const handleAction = React.useCallback(async (action: string, vendor: any) => { + switch (action) { + case "view": + setSelectedVendor(vendor); + break; + + case "send": + // RFQ 발송 로직 + toast.info(`${vendor.vendorName}에게 RFQ를 발송합니다.`); + break; + + case "edit": + // 수정 로직 + toast.info("수정 기능은 준비중입니다."); + break; + + case "delete": + // 삭제 로직 + if (confirm(`${vendor.vendorName}을(를) 삭제하시겠습니까?`)) { + toast.success(`${vendor.vendorName}이(가) 삭제되었습니다.`); + } + break; + + case "response-detail": + // 회신 상세 보기 + toast.info(`${vendor.vendorName}의 회신 상세를 확인합니다.`); + break; + } + }, []); + + // 선택된 벤더들에게 일괄 발송 + const handleBulkSend = React.useCallback(async () => { + if (selectedRows.length === 0) { + toast.warning("발송할 벤더를 선택해주세요."); + return; + } + + const vendorNames = selectedRows.map(r => r.vendorName).join(", "); + if (confirm(`선택한 ${selectedRows.length}개 벤더에게 RFQ를 발송하시겠습니까?\n\n${vendorNames}`)) { + toast.success(`${selectedRows.length}개 벤더에게 RFQ를 발송했습니다.`); + setSelectedRows([]); + } + }, [selectedRows]); + + + // 컬럼 정의 (확장된 버전) + const columns: ColumnDef<any>[] = React.useMemo(() => [ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")} + onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)} + aria-label="select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(v) => row.toggleSelected(!!v)} + aria-label="select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "rfqCode", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="ITB/RFQ/견적 No." />, + cell: ({ row }) => { + return ( + <span className="font-mono text-xs">{row.original.rfqCode || "-"}</span> + ); + }, + size: 120, + }, + // { + // accessorKey: "response.responseVersion", + // header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="Rev" />, + // cell: ({ row }) => { + // const version = row.original.response?.responseVersion; + // return version ? ( + // <Badge variant="outline" className="font-mono">v{version}</Badge> + // ) : ( + // <span className="text-muted-foreground">-</span> + // ); + // }, + // size: 60, + // }, + { + accessorKey: "vendorName", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="협력업체정보" />, + cell: ({ row }) => { + const vendor = row.original; + return ( + <div className="flex items-center gap-2"> + <Building2 className="h-4 w-4 text-muted-foreground" /> + <div className="flex flex-col"> + <span className="text-sm font-medium">{vendor.vendorName || "-"}</span> + <span className="text-xs text-muted-foreground">{vendor.vendorCode}</span> + </div> + </div> + ); + }, + size: 180, + }, + { + accessorKey: "vendorCategory", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="업체분류" />, + cell: ({ row }) => row.original.vendorCategory || "-", + size: 100, + }, + { + accessorKey: "vendorCountry", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="내외자 (위치)" />, + cell: ({ row }) => { + const country = row.original.vendorCountry; + const isLocal = country === "KR" || country === "한국"; + return ( + <Badge variant={isLocal ? "default" : "secondary"}> + {country || "-"} + </Badge> + ); + }, + size: 100, + }, + { + accessorKey: "vendorGrade", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="AVL 정보 (등급)" />, + cell: ({ row }) => { + const grade = row.original.vendorGrade; + if (!grade) return <span className="text-muted-foreground">-</span>; + + const gradeColor = { + "A": "text-green-600", + "B": "text-blue-600", + "C": "text-yellow-600", + "D": "text-red-600", + }[grade] || "text-gray-600"; + + return <span className={cn("font-semibold", gradeColor)}>{grade}</span>; + }, + size: 100, + }, + { + accessorKey: "basicContract", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="기본계약" />, + cell: ({ row }) => row.original.basicContract || "-", + size: 100, + }, + { + accessorKey: "currency", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="요청 통화" />, + cell: ({ row }) => { + const currency = row.original.currency; + return currency ? ( + <Badge variant="outline">{currency}</Badge> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 80, + }, + { + accessorKey: "paymentTermsCode", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="지급조건" />, + cell: ({ row }) => { + const code = row.original.paymentTermsCode; + const desc = row.original.paymentTermsDescription; + return ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <span className="text-sm">{code || "-"}</span> + </TooltipTrigger> + {desc && ( + <TooltipContent> + <p>{desc}</p> + </TooltipContent> + )} + </Tooltip> + </TooltipProvider> + ); + }, + size: 100, + }, + { + accessorKey: "taxCode", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="Tax" />, + cell: ({ row }) => row.original.taxCode || "-", + size: 60, + }, + { + accessorKey: "deliveryDate", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="계약납기일/기간" />, + cell: ({ row }) => { + const deliveryDate = row.original.deliveryDate; + const contractDuration = row.original.contractDuration; + + return ( + <div className="flex flex-col gap-0.5"> + {deliveryDate && ( + <span className="text-xs"> + {format(new Date(deliveryDate), "yyyy-MM-dd")} + </span> + )} + {contractDuration && ( + <span className="text-xs text-muted-foreground">{contractDuration}</span> + )} + {!deliveryDate && !contractDuration && ( + <span className="text-muted-foreground">-</span> + )} + </div> + ); + }, + size: 120, + }, + { + accessorKey: "incotermsCode", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="Incoterms" />, + cell: ({ row }) => { + const code = row.original.incotermsCode; + const detail = row.original.incotermsDetail; + + return ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <div className="flex items-center gap-1"> + <Globe className="h-3 w-3 text-muted-foreground" /> + <span className="text-sm">{code || "-"}</span> + </div> + </TooltipTrigger> + {detail && ( + <TooltipContent> + <p>{detail}</p> + </TooltipContent> + )} + </Tooltip> + </TooltipProvider> + ); + }, + size: 100, + }, + { + accessorKey: "placeOfShipping", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="선적지" />, + cell: ({ row }) => { + const place = row.original.placeOfShipping; + return place ? ( + <div className="flex items-center gap-1"> + <MapPin className="h-3 w-3 text-muted-foreground" /> + <span className="text-xs">{place}</span> + </div> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 100, + }, + { + accessorKey: "placeOfDestination", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="도착지" />, + cell: ({ row }) => { + const place = row.original.placeOfDestination; + return place ? ( + <div className="flex items-center gap-1"> + <Package className="h-3 w-3 text-muted-foreground" /> + <span className="text-xs">{place}</span> + </div> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 100, + }, + { + id: "additionalConditions", + header: "추가조건", + cell: ({ row }) => { + const conditions = formatAdditionalConditions(row.original); + if (conditions === "-") { + return <span className="text-muted-foreground">-</span>; + } + + const items = conditions.split(", "); + return ( + <div className="flex flex-wrap gap-1"> + {items.map((item, idx) => ( + <Badge key={idx} variant="outline" className="text-xs"> + {item} + </Badge> + ))} + </div> + ); + }, + size: 120, + }, + { + accessorKey: "response.submittedAt", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="참여여부 (회신일)" />, + cell: ({ row }) => { + const submittedAt = row.original.response?.submittedAt; + const status = row.original.response?.status; + + if (!submittedAt) { + return <Badge variant="outline">미참여</Badge>; + } + + return ( + <div className="flex flex-col gap-0.5"> + <Badge variant="default" className="text-xs">참여</Badge> + <span className="text-xs text-muted-foreground"> + {format(new Date(submittedAt), "MM-dd")} + </span> + </div> + ); + }, + size: 100, + }, + { + id: "responseDetail", + header: "회신상세", + cell: ({ row }) => { + const hasResponse = !!row.original.response?.submittedAt; + + if (!hasResponse) { + return <span className="text-muted-foreground text-xs">-</span>; + } + + return ( + <Button + variant="ghost" + size="sm" + onClick={() => handleAction("response-detail", row.original)} + className="h-7 px-2" + > + <Eye className="h-3 w-3 mr-1" /> + 상세 + </Button> + ); + }, + size: 80, + }, + { + accessorKey: "shortList", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="Short List" />, + cell: ({ row }) => ( + row.original.shortList ? ( + <Badge variant="default">선정</Badge> + ) : ( + <Badge variant="outline">대기</Badge> + ) + ), + size: 80, + }, + { + accessorKey: "updatedAt", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="최신수정일" />, + cell: ({ row }) => { + const date = row.original.updatedAt; + return date ? ( + <span className="text-xs text-muted-foreground"> + {format(new Date(date), "MM-dd HH:mm")} + </span> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 100, + }, + { + accessorKey: "updatedByUserName", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="최신수정자" />, + cell: ({ row }) => { + const name = row.original.updatedByUserName; + return name ? ( + <span className="text-xs">{name}</span> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 100, + }, + { + id: "actions", + header: "작업", + cell: ({ row }) => { + const vendor = row.original; + const hasResponse = !!vendor.response; + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" className="h-8 w-8 p-0"> + <span className="sr-only">메뉴 열기</span> + <svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M3.625 7.5C3.625 8.12132 3.12132 8.625 2.5 8.625C1.87868 8.625 1.375 8.12132 1.375 7.5C1.375 6.87868 1.87868 6.375 2.5 6.375C3.12132 6.375 3.625 6.87868 3.625 7.5ZM8.625 7.5C8.625 8.12132 8.12132 8.625 7.5 8.625C6.87868 8.625 6.375 8.12132 6.375 7.5C6.375 6.87868 6.87868 6.375 7.5 6.375C8.12132 6.375 8.625 6.87868 8.625 7.5ZM12.5 8.625C13.1213 8.625 13.625 8.12132 13.625 7.5C13.625 6.87868 13.1213 6.375 12.5 6.375C11.8787 6.375 11.375 6.87868 11.375 7.5C11.375 8.12132 11.8787 8.625 12.5 8.625Z" fill="currentColor" fillRule="evenodd" clipRule="evenodd"></path> + </svg> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem onClick={() => handleAction("view", vendor)}> + <Eye className="mr-2 h-4 w-4" /> + 상세보기 + </DropdownMenuItem> + {!hasResponse && ( + <DropdownMenuItem onClick={() => handleAction("send", vendor)}> + <Send className="mr-2 h-4 w-4" /> + RFQ 발송 + </DropdownMenuItem> + )} + <DropdownMenuItem onClick={() => handleAction("edit", vendor)}> + <Edit className="mr-2 h-4 w-4" /> + 조건 수정 + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem + onClick={() => handleAction("delete", vendor)} + className="text-red-600" + > + <Trash2 className="mr-2 h-4 w-4" /> + 삭제 + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ); + }, + size: 60, + }, + ], [handleAction]); + + const advancedFilterFields: DataTableAdvancedFilterField<any>[] = [ + { id: "vendorName", label: "벤더명", type: "text" }, + { id: "vendorCode", label: "벤더코드", type: "text" }, + { id: "vendorCountry", label: "국가", type: "text" }, + { + id: "response.status", + label: "응답 상태", + type: "select", + options: [ + { label: "초대됨", value: "초대됨" }, + { label: "작성중", value: "작성중" }, + { label: "제출완료", value: "제출완료" }, + { label: "수정요청", value: "수정요청" }, + { label: "최종확정", value: "최종확정" }, + { label: "취소", value: "취소" }, + ] + }, + { + id: "shortList", + label: "Short List", + type: "select", + options: [ + { label: "선정", value: "true" }, + { label: "대기", value: "false" }, + ] + }, + ]; + + // 선택된 벤더 정보 (BatchUpdate용) + const selectedVendorsForBatch = React.useMemo(() => { + return selectedRows.map(row => ({ + id: row.vendorId, + vendorName: row.vendorName, + vendorCode: row.vendorCode, + })); + }, [selectedRows]); + + // 추가 액션 버튼들 + const additionalActions = React.useMemo(() => ( + <div className="flex items-center gap-2"> + <Button + variant="outline" + size="sm" + onClick={() => setIsAddDialogOpen(true)} + > + <Plus className="h-4 w-4 mr-2" /> + 벤더 추가 + </Button> + {selectedRows.length > 0 && ( + <> + <Button + variant="outline" + size="sm" + onClick={() => setIsBatchUpdateOpen(true)} + > + <Settings2 className="h-4 w-4 mr-2" /> + 정보 일괄 입력 ({selectedRows.length}) + </Button> + <Button + variant="outline" + size="sm" + onClick={handleBulkSend} + > + <Send className="h-4 w-4 mr-2" /> + 선택 발송 ({selectedRows.length}) + </Button> + </> + )} + <Button + variant="outline" + size="sm" + onClick={() => { + setIsRefreshing(true); + setTimeout(() => { + setIsRefreshing(false); + toast.success("데이터를 새로고침했습니다."); + }, 1000); + }} + disabled={isRefreshing} + > + <RefreshCw className={cn("h-4 w-4 mr-2", isRefreshing && "animate-spin")} /> + 새로고침 + </Button> + </div> + ), [selectedRows, isRefreshing, handleBulkSend]); + + return ( + <> + <ClientDataTable + columns={columns} + data={mergedData} + advancedFilterFields={advancedFilterFields} + autoSizeColumns={false} + compact={true} + maxHeight="34rem" + onSelectedRowsChange={setSelectedRows} + > + {additionalActions} + </ClientDataTable> + + {/* 벤더 추가 다이얼로그 */} + <AddVendorDialog + open={isAddDialogOpen} + onOpenChange={setIsAddDialogOpen} + rfqId={rfqId} + onSuccess={() => { + toast.success("벤더가 추가되었습니다."); + setIsAddDialogOpen(false); + }} + /> + + {/* 조건 일괄 설정 다이얼로그 */} + <BatchUpdateConditionsDialog + open={isBatchUpdateOpen} + onOpenChange={setIsBatchUpdateOpen} + rfqId={rfqId} + rfqCode={rfqCode} + selectedVendors={selectedVendorsForBatch} + onSuccess={() => { + toast.success("조건이 업데이트되었습니다."); + setIsBatchUpdateOpen(false); + setSelectedRows([]); + }} + /> + + {/* 벤더 상세 다이얼로그 */} + {/* {selectedVendor && ( + <VendorDetailDialog + open={!!selectedVendor} + onOpenChange={(open) => !open && setSelectedVendor(null)} + vendor={selectedVendor} + rfqId={rfqId} + /> + )} */} + </> + ); +}
\ No newline at end of file diff --git a/lib/forms/vendor-tag-actions.ts b/lib/rfq-last/vendor/vendor-detail-dialog.tsx index e69de29b..e69de29b 100644 --- a/lib/forms/vendor-tag-actions.ts +++ b/lib/rfq-last/vendor/vendor-detail-dialog.tsx diff --git a/lib/rfq-last/vendor/vendor-response-status-card.tsx b/lib/rfq-last/vendor/vendor-response-status-card.tsx new file mode 100644 index 00000000..d4ef8dd3 --- /dev/null +++ b/lib/rfq-last/vendor/vendor-response-status-card.tsx @@ -0,0 +1,51 @@ +import { Card, CardContent } from "@/components/ui/card"; +import { LucideIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface VendorResponseStatusCardProps { + title: string; + count: number; + icon: LucideIcon; + variant?: "default" | "primary" | "secondary" | "success" | "warning" | "destructive"; +} + +const variantStyles = { + default: "border-gray-200 bg-gray-50/50", + primary: "border-blue-200 bg-blue-50/50", + secondary: "border-purple-200 bg-purple-50/50", + success: "border-green-200 bg-green-50/50", + warning: "border-yellow-200 bg-yellow-50/50", + destructive: "border-red-200 bg-red-50/50", +}; + +const iconStyles = { + default: "text-gray-600", + primary: "text-blue-600", + secondary: "text-purple-600", + success: "text-green-600", + warning: "text-yellow-600", + destructive: "text-red-600", +}; + +export function VendorResponseStatusCard({ + title, + count, + icon: Icon, + variant = "default", +}: VendorResponseStatusCardProps) { + return ( + <Card className={cn("border", variantStyles[variant])}> + <CardContent className="p-4"> + <div className="flex items-center justify-between"> + <div className="space-y-1"> + <p className="text-sm font-medium text-muted-foreground">{title}</p> + <p className="text-2xl font-bold">{count}</p> + </div> + <div className={cn("p-2 rounded-full bg-white/80", iconStyles[variant])}> + <Icon className="h-5 w-5" /> + </div> + </div> + </CardContent> + </Card> + ); +}
\ No newline at end of file |
