diff options
Diffstat (limited to 'lib/vendor-document-list/plant')
19 files changed, 3972 insertions, 52 deletions
diff --git a/lib/vendor-document-list/plant/document-stage-dialogs.tsx b/lib/vendor-document-list/plant/document-stage-dialogs.tsx index 26f6b638..f49d7d47 100644 --- a/lib/vendor-document-list/plant/document-stage-dialogs.tsx +++ b/lib/vendor-document-list/plant/document-stage-dialogs.tsx @@ -31,7 +31,7 @@ import { SelectValue, } from "@/components/ui/select" import { Badge } from "@/components/ui/badge" -import { DocumentStagesOnlyView } from "@/db/schema" +import { StageDocumentsView } from "@/db/schema" import { Upload, FileSpreadsheet, Calendar, User, Target, Loader2, AlertTriangle, Loader ,Trash, CheckCircle, Download, AlertCircle} from "lucide-react" import { toast } from "sonner" import { @@ -109,11 +109,10 @@ export function AddDocumentDialog({ const [selectedTypeConfigs, setSelectedTypeConfigs] = React.useState<any[]>([]) const [comboBoxOptions, setComboBoxOptions] = React.useState<Record<number, any[]>>({}) const [documentClassOptions, setDocumentClassOptions] = React.useState<any[]>([]) + const [isLoadingInitialData, setIsLoadingInitialData] = React.useState(false) + const [isSubmitting, setIsSubmitting] = React.useState(false) - console.log(documentNumberTypes, "documentNumberTypes") - console.log(documentClassOptions, "documentClassOptions") - const [formData, setFormData] = React.useState({ documentNumberTypeId: "", documentClassId: "", @@ -126,12 +125,13 @@ export function AddDocumentDialog({ // Load initial data React.useEffect(() => { if (open) { + resetForm() // 폼 리셋 추가 loadInitialData() } }, [open]) const loadInitialData = async () => { - setIsLoading(true) + setIsLoadingInitialData(true) // isLoading 대신 try { const [typesResult, classesResult] = await Promise.all([ getDocumentNumberTypes(contractId), @@ -147,7 +147,7 @@ export function AddDocumentDialog({ } catch (error) { toast.error("Error loading data.") } finally { - setIsLoading(false) + setIsLoadingInitialData(false) } } @@ -284,7 +284,7 @@ export function AddDocumentDialog({ return } - setIsLoading(true) + setIsSubmitting(true) // isLoading 대신 try { const result = await createDocument({ contractId, @@ -307,7 +307,7 @@ export function AddDocumentDialog({ } catch (error) { toast.error("Error adding document.") } finally { - setIsLoading(false) + setIsSubmitting(false) // isLoading 대신 } } @@ -513,11 +513,11 @@ export function AddDocumentDialog({ )} <DialogFooter className="flex-shrink-0"> - <Button variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}> + <Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}> Cancel </Button> - <Button onClick={handleSubmit} disabled={isLoading || !isFormValid()}> - {isLoading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null} + <Button onClick={handleSubmit} disabled={isSubmitting || !isFormValid()}> + {isSubmitting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null} Add Document </Button> </DialogFooter> @@ -532,7 +532,7 @@ export function AddDocumentDialog({ interface EditDocumentDialogProps { open: boolean onOpenChange: (open: boolean) => void - document: DocumentStagesOnlyView | null + document: StageDocumentsView | null contractId: number projectType: "ship" | "plant" } @@ -753,7 +753,7 @@ export function EditDocumentDialog({ interface EditStageDialogProps { open: boolean onOpenChange: (open: boolean) => void - document: DocumentStagesOnlyView | null + document: StageDocumentsView | null stageId: number | null } @@ -1290,7 +1290,7 @@ export function ExcelImportDialog({ interface DeleteDocumentsDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> { - documents: Row<DocumentStagesOnlyView>["original"][] + documents: Row<StageDocumentsView>["original"][] showTrigger?: boolean onSuccess?: () => void } diff --git a/lib/vendor-document-list/plant/document-stage-toolbar.tsx b/lib/vendor-document-list/plant/document-stage-toolbar.tsx index 87b221b7..601a9152 100644 --- a/lib/vendor-document-list/plant/document-stage-toolbar.tsx +++ b/lib/vendor-document-list/plant/document-stage-toolbar.tsx @@ -1,11 +1,10 @@ "use client" import * as React from "react" -import { type DocumentStagesOnlyView } from "@/db/schema" +import { type StageDocumentsView } from "@/db/schema" import { type Table } from "@tanstack/react-table" -import { Download, Upload, Plus, FileSpreadsheet } from "lucide-react" +import { Download, RefreshCw, Send, CheckCircle, AlertCircle, Plus, FileSpreadsheet, Loader2 } from "lucide-react" import { toast } from "sonner" - import { exportTableToExcel } from "@/lib/export" import { Button } from "@/components/ui/button" @@ -15,12 +14,17 @@ import { AddDocumentDialog, ExcelImportDialog } from "./document-stage-dialogs" +import { sendDocumentsToSHI } from "./document-stages-service" +import { useDocumentPolling } from "@/hooks/use-document-polling" +import { cn } from "@/lib/utils" +import { MultiUploadDialog } from "./upload/components/multi-upload-dialog" +// import { useRouter } from "next/navigation" // 서버 액션 import (필요한 경우) // import { importDocumentsExcel } from "./document-stages-service" interface DocumentsTableToolbarActionsProps { - table: Table<DocumentStagesOnlyView> + table: Table<StageDocumentsView> contractId: number projectType: "ship" | "plant" } @@ -33,6 +37,43 @@ export function DocumentsTableToolbarActions({ // 다이얼로그 상태 관리 const [showAddDialog, setShowAddDialog] = React.useState(false) const [showExcelImportDialog, setShowExcelImportDialog] = React.useState(false) + const [isSending, setIsSending] = React.useState(false) + const router = useRouter() + + // 자동 폴링 훅 사용 + const { + isPolling, + lastPolledAt, + pollingStatus, + pollDocuments + } = useDocumentPolling({ + contractId, + autoStart: true, + onUpdate: () => { + // 테이블 새로고침 + router.refresh() + } + }) + + async function handleSendToSHI() { + setIsSending(true) + try { + const result = await sendDocumentsToSHI(contractId) + + if (result.success) { + toast.success(result.message) + router.refresh() + // 테이블 새로고침 + } else { + toast.error(result.message) + } + } catch (error) { + toast.error("전송 중 오류가 발생했습니다.") + } finally { + setIsSending(false) + } + } + const handleExcelImport = () => { setShowExcelImportDialog(true) @@ -50,17 +91,28 @@ export function DocumentsTableToolbarActions({ }) } + + + return ( <div className="flex items-center gap-2"> + + + {/* 1) 선택된 문서가 있으면 삭제 다이얼로그 */} - {table.getFilteredSelectedRowModel().rows.length > 0 ? ( - <DeleteDocumentsDialog - documents={table - .getFilteredSelectedRowModel() - .rows.map((row) => row.original)} - onSuccess={() => table.toggleAllRowsSelected(false)} - /> - ) : null} + {(() => { + const selectedRows = table.getFilteredSelectedRowModel().rows; + const deletableDocuments = selectedRows + .map((row) => row.original)s + .filter((doc) => !doc.buyerSystemStatus); // buyerSystemStatus가 null인 것만 필터링 + + return deletableDocuments.length > 0 ? ( + <DeleteDocumentsDialog + documents={deletableDocuments} + onSuccess={() => table.toggleAllRowsSelected(false)} + /> + ) : null; + })()} {/* 2) 새 문서 추가 다이얼로그 */} @@ -76,9 +128,45 @@ export function DocumentsTableToolbarActions({ projectType={projectType} /> + {/* SHI 전송 버튼 */} + <Button + variant="samsung" + size="sm" + onClick={handleSendToSHI} + disabled={isSending} + className="gap-2" + > + {isSending ? ( + <> + <Loader2 className="h-4 w-4 animate-spin" /> + Sending.. + </> + ) : ( + <> + <Send className="h-4 w-4" /> + Send to SHI + </> + )} + </Button> + + <Button + variant="outline" + size="sm" + onClick={() => pollDocuments(true)} + disabled={isPolling} + className="gap-2" + > + <RefreshCw className={cn( + "h-4 w-4", + isPolling && "animate-spin" + )} /> + Sync from SHI + </Button> + + <Button onClick={handleExcelImport} variant="outline" size="sm"> <FileSpreadsheet className="mr-2 h-4 w-4" /> - Excel Import + Excel Import </Button> <ExcelImportDialog diff --git a/lib/vendor-document-list/plant/document-stages-columns.tsx b/lib/vendor-document-list/plant/document-stages-columns.tsx index aee47029..2f8fd482 100644 --- a/lib/vendor-document-list/plant/document-stages-columns.tsx +++ b/lib/vendor-document-list/plant/document-stages-columns.tsx @@ -6,7 +6,7 @@ import { formatDate, formatDateTime } from "@/lib/utils" import { Checkbox } from "@/components/ui/checkbox" import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" import { DataTableRowAction } from "@/types/table" -import { DocumentStagesOnlyView } from "@/db/schema" +import { StageDocumentsView } from "@/db/schema" import { DropdownMenu, DropdownMenuContent, @@ -28,12 +28,17 @@ import { Eye, Edit, Plus, - Trash2 + Trash2,MessageSquare } from "lucide-react" import { cn } from "@/lib/utils" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" interface GetColumnsProps { - setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<DocumentStagesOnlyView> | null>> + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<StageDocumentsView> | null>> projectType: string domain?: "evcp" | "partners" // 선택적 파라미터로 유지 } @@ -139,11 +144,11 @@ export function getDocumentStagesColumns({ setRowAction, projectType, domain = "partners", // 기본값 설정 -}: GetColumnsProps): ColumnDef<DocumentStagesOnlyView>[] { +}: GetColumnsProps): ColumnDef<StageDocumentsView>[] { const isPlantProject = projectType === "plant" const isEvcpDomain = domain === "evcp" - const columns: ColumnDef<DocumentStagesOnlyView>[] = [ + const columns: ColumnDef<StageDocumentsView>[] = [ // 체크박스 선택 { id: "select", @@ -315,6 +320,75 @@ export function getDocumentStagesColumns({ // 나머지 공통 컬럼들 columns.push( // 현재 스테이지 (상태, 담당자 한 줄) + + { + accessorKey: "status", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Document Status" /> + ), + cell: ({ row }) => { + const doc = row.original + + return ( + <div className="flex items-center gap-2"> + <Badge + variant={getStatusColor(doc.status || false)} + className="text-xs px-1.5 py-0" + > + {getStatusText(doc.status || '')} + </Badge> + </div> + ) + }, + size: 180, + enableResizing: true, + meta: { + excelHeader: "Document Status" + }, + }, + + { + accessorKey: "buyerSystemStatus", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="SHI Status" /> + ), + cell: ({ row }) => { + const doc = row.original + const getBuyerStatusBadge = () => { + if (!doc.buyerSystemStatus) { + return <Badge variant="outline">Not Recieved</Badge> + } + + switch (doc.buyerSystemStatus) { + case '승인(DC)': + return <Badge variant="success">Approved</Badge> + case '검토중': + return <Badge variant="default">검토중</Badge> + case '반려': + return <Badge variant="destructive">반려</Badge> + default: + return <Badge variant="secondary">{doc.buyerSystemStatus}</Badge> + } + } + + return ( + <div className="flex flex-col gap-1"> + {getBuyerStatusBadge()} + {doc.buyerSystemComment && ( + <Tooltip> + <TooltipTrigger> + <MessageSquare className="h-3 w-3 text-muted-foreground" /> + </TooltipTrigger> + <TooltipContent> + <p className="max-w-xs">{doc.buyerSystemComment}</p> + </TooltipContent> + </Tooltip> + )} + </div> + ) + }, + size: 120, + }, { accessorKey: "currentStageName", header: ({ column }) => ( @@ -486,7 +560,7 @@ export function getDocumentStagesColumns({ label: "Delete Document", icon: Trash2, action: () => setRowAction({ row, type: "delete" }), - show: true, + show: !doc.buyerSystemStatus, // null일 때만 true className: "text-red-600 dark:text-red-400", shortcut: "⌘⌫" } diff --git a/lib/vendor-document-list/plant/document-stages-expanded-content.tsx b/lib/vendor-document-list/plant/document-stages-expanded-content.tsx index ca5e9c5b..72a804a8 100644 --- a/lib/vendor-document-list/plant/document-stages-expanded-content.tsx +++ b/lib/vendor-document-list/plant/document-stages-expanded-content.tsx @@ -2,7 +2,7 @@ "use client" import React from "react" -import { DocumentStagesOnlyView } from "@/db/schema" +import { StageDocumentsView } from "@/db/schema" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" @@ -40,7 +40,7 @@ import { toast } from "sonner" import { updateStage } from "./document-stages-service" interface DocumentStagesExpandedContentProps { - document: DocumentStagesOnlyView + document: StageDocumentsView onEditStage: (stageId: number) => void projectType: "ship" | "plant" } diff --git a/lib/vendor-document-list/plant/document-stages-service.ts b/lib/vendor-document-list/plant/document-stages-service.ts index 57f17bae..30a235c3 100644 --- a/lib/vendor-document-list/plant/document-stages-service.ts +++ b/lib/vendor-document-list/plant/document-stages-service.ts @@ -4,7 +4,7 @@ import { revalidatePath, revalidateTag } from "next/cache" import { redirect } from "next/navigation" import db from "@/db/db" -import { codeGroups, comboBoxSettings, contracts, documentClassOptions, documentClasses, documentNumberTypeConfigs, documentNumberTypes, documentStagesOnlyView, documents, issueStages, stageDocuments, stageDocumentsView, stageIssueStages } from "@/db/schema" +import {stageSubmissionView, codeGroups, comboBoxSettings, contracts, documentClassOptions, documentClasses, documentNumberTypeConfigs, documentNumberTypes, documentStagesOnlyView, documents, issueStages, stageDocuments, stageDocumentsView, stageIssueStages } from "@/db/schema" import { and, eq, asc, desc, sql, inArray, max, ne, or, ilike } from "drizzle-orm" import { createDocumentSchema, @@ -32,6 +32,7 @@ import { GetEnhancedDocumentsSchema, GetDocumentsSchema } from "../enhanced-docu import { countDocumentStagesOnly, selectDocumentStagesOnly } from "../repository" import { getServerSession } from "next-auth" import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import { ShiBuyerSystemAPI } from "./shi-buyer-system-api" interface UpdateDocumentData { documentId: number @@ -810,7 +811,7 @@ export async function getDocumentClassOptions(documentClassId: number) { eq(documentClassOptions.isActive, true) ) ) - // .orderBy(asc(documentClassOptions.sortOrder)) + .orderBy(asc(documentClassOptions.sdq)) return { success: true, data: options } } catch (error) { @@ -920,6 +921,8 @@ export async function createDocument(data: CreateDocumentData) { }, }) + console.log(contract,"contract") + if (!contract) { return { success: false, error: "유효하지 않은 계약(ID)입니다." } } @@ -1053,7 +1056,7 @@ export async function getDocumentStagesOnly( finalWhere = and( advancedWhere, globalWhere, - eq(documentStagesOnlyView.contractId, contractId) + eq(stageDocumentsView.contractId, contractId) ) } @@ -1066,7 +1069,7 @@ export async function getDocumentStagesOnly( ? desc(stageDocumentsView[item.id]) : asc(stageDocumentsView[item.id]) ) - : [desc(documentStagesOnlyView.createdAt)] + : [desc(stageDocumentsView.createdAt)] // 트랜잭션 실행 @@ -1183,3 +1186,115 @@ export async function getDocumentsByStageStats(contractId: number) { return [] } } + + +export async function sendDocumentsToSHI(contractId: number) { + try { + const api = new ShiBuyerSystemAPI() + const result = await api.sendToSHI(contractId) + + // 캐시 무효화 + revalidatePath(`/partners/document-list-only/${contractId}`) + + return result + } catch (error) { + console.error("SHI 전송 실패:", error) + return { + success: false, + message: error instanceof Error ? error.message : "전송 중 오류가 발생했습니다." + } + } +} + +export async function pullDocumentStatusFromSHI( + contractId: number, +) { + try { + const api = new ShiBuyerSystemAPI() + const result = await api.pullDocumentStatus(contractId) + + // 캐시 무효화 + revalidatePath(`/partners/document-list-only/${contractId}`) + + return result + } catch (error) { + console.error("문서 상태 풀링 실패:", error) + return { + success: false, + message: error instanceof Error ? error.message : "상태 가져오기 중 오류가 발생했습니다." + } + } +} + + +interface FileValidation { + projectId: number + docNumber: string + stageName: string + revision: string +} + +interface ValidationResult { + projectId: number + docNumber: string + stageName: string + matched?: { + documentId: number + stageId: number + documentTitle: string + currentRevision?: number + } +} + +export async function validateFiles(files: FileValidation[]): Promise<ValidationResult[]> { + const session = await getServerSession(authOptions) + if (!session?.user?.companyId) { + throw new Error("Unauthorized") + } + + const vendorId = session.user.companyId + const results: ValidationResult[] = [] + + for (const file of files) { + // stageSubmissionView에서 매칭되는 레코드 찾기 + const match = await db + .select({ + documentId: stageSubmissionView.documentId, + stageId: stageSubmissionView.stageId, + documentTitle: stageSubmissionView.documentTitle, + latestRevisionNumber: stageSubmissionView.latestRevisionNumber, + }) + .from(stageSubmissionView) + .where( + and( + eq(stageSubmissionView.vendorId, vendorId), + eq(stageSubmissionView.projectId, file.projectId), + eq(stageSubmissionView.docNumber, file.docNumber), + eq(stageSubmissionView.stageName, file.stageName) + ) + ) + .limit(1) + + if (match.length > 0) { + results.push({ + projectId: file.projectId, + docNumber: file.docNumber, + stageName: file.stageName, + matched: { + documentId: match[0].documentId, + stageId: match[0].stageId!, + documentTitle: match[0].documentTitle, + currentRevision: match[0].latestRevisionNumber || 0, + } + }) + } else { + results.push({ + projectId: file.projectId, + docNumber: file.docNumber, + stageName: file.stageName, + }) + } + } + + return results +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/document-stages-table.tsx b/lib/vendor-document-list/plant/document-stages-table.tsx index 3d2ddafd..50d54a92 100644 --- a/lib/vendor-document-list/plant/document-stages-table.tsx +++ b/lib/vendor-document-list/plant/document-stages-table.tsx @@ -9,7 +9,7 @@ import type { import { useDataTable } from "@/hooks/use-data-table" import { getDocumentStagesOnly } from "./document-stages-service" -import type { DocumentStagesOnlyView } from "@/db/schema" +import type { StageDocumentsView } from "@/db/schema" import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" import { Badge } from "@/components/ui/badge" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" @@ -51,17 +51,17 @@ export function DocumentStagesTable({ const { data: session } = useSession() - + // URL에서 언어 파라미터 가져오기 const params = useParams() const lng = (params?.lng as string) || 'ko' const { t } = useTranslation(lng, 'document') - // 세션에서 도메인을 가져오기 - const currentDomain = session?.user?.domain as "evcp" | "partners" + // 세션에서 도메인을 가져오기 + const currentDomain = session?.user?.domain as "evcp" | "partners" // 상태 관리 - const [rowAction, setRowAction] = React.useState<DataTableRowAction<DocumentStagesOnlyView> | null>(null) + const [rowAction, setRowAction] = React.useState<DataTableRowAction<StageDocumentsView> | null>(null) const [expandedRows, setExpandedRows] = React.useState<Set<string>>(new Set()) const [quickFilter, setQuickFilter] = React.useState<'all' | 'overdue' | 'due_soon' | 'in_progress' | 'high_priority'>('all') @@ -72,7 +72,7 @@ export function DocumentStagesTable({ const [excelImportOpen, setExcelImportOpen] = React.useState(false) // 선택된 항목들 - const [selectedDocument, setSelectedDocument] = React.useState<DocumentStagesOnlyView | null>(null) + const [selectedDocument, setSelectedDocument] = React.useState<StageDocumentsView | null>(null) const [selectedStageId, setSelectedStageId] = React.useState<number | null>(null) // 컬럼 정의 @@ -116,7 +116,7 @@ export function DocumentStagesTable({ const stats = React.useMemo(() => { console.log('DocumentStagesTable - data:', data) console.log('DocumentStagesTable - data length:', data?.length) - + const totalDocs = data?.length || 0 const overdue = data?.filter(doc => doc.isOverdue)?.length || 0 const dueSoon = data?.filter(doc => @@ -138,7 +138,7 @@ export function DocumentStagesTable({ highPriority, avgProgress } - + console.log('DocumentStagesTable - stats:', result) return result }, [data]) @@ -201,10 +201,10 @@ export function DocumentStagesTable({ } // 필터 필드 정의 - const filterFields: DataTableFilterField<DocumentStagesOnlyView>[] = [ + const filterFields: DataTableFilterField<StageDocumentsView>[] = [ ] - const advancedFilterFields: DataTableAdvancedFilterField<DocumentStagesOnlyView>[] = [ + const advancedFilterFields: DataTableAdvancedFilterField<StageDocumentsView>[] = [ { id: "docNumber", label: "문서번호", diff --git a/lib/vendor-document-list/plant/excel-import-export.ts b/lib/vendor-document-list/plant/excel-import-export.ts index 3ddb7195..c1409205 100644 --- a/lib/vendor-document-list/plant/excel-import-export.ts +++ b/lib/vendor-document-list/plant/excel-import-export.ts @@ -10,7 +10,7 @@ import { type ExcelImportResult, type CreateDocumentInput } from './document-stage-validations' -import { DocumentStagesOnlyView } from '@/db/schema' +import { StageDocumentsView } from '@/db/schema' // ============================================================================= // 1. 엑셀 템플릿 생성 및 다운로드 @@ -510,7 +510,7 @@ function formatExcelDate(value: any): string | undefined { // 문서 데이터를 엑셀로 익스포트 export function exportDocumentsToExcel( - documents: DocumentStagesOnlyView[], + documents: StageDocumentsView[], projectType: "ship" | "plant" ) { const headers = [ @@ -609,7 +609,7 @@ export function exportDocumentsToExcel( } // 스테이지 상세 데이터를 엑셀로 익스포트 -export function exportStageDetailsToExcel(documents: DocumentStagesOnlyView[]) { +export function exportStageDetailsToExcel(documents: StageDocumentsView[]) { const headers = [ "문서번호", "문서명", diff --git a/lib/vendor-document-list/plant/shi-buyer-system-api.ts b/lib/vendor-document-list/plant/shi-buyer-system-api.ts new file mode 100644 index 00000000..1f15efa6 --- /dev/null +++ b/lib/vendor-document-list/plant/shi-buyer-system-api.ts @@ -0,0 +1,874 @@ +// app/lib/shi-buyer-system-api.ts +import db from "@/db/db" +import { stageDocuments, stageIssueStages, contracts, vendors, projects, stageSubmissions, stageSubmissionAttachments } from "@/db/schema" +import { eq, and, sql, ne } from "drizzle-orm" +import fs from 'fs/promises' +import path from 'path' + +interface ShiDocumentInfo { + PROJ_NO: string + SHI_DOC_NO: string + CATEGORY: string + RESPONSIBLE_CD: string + RESPONSIBLE: string + VNDR_CD: string + VNDR_NM: string + DSN_SKL: string + MIFP_CD: string + MIFP_NM: string + CG_EMPNO1: string + CG_EMPNM1: string + OWN_DOC_NO: string + DSC: string + DOC_CLASS: string + COMMENT: string + STATUS: string + CRTER: string + CRTE_DTM: string + CHGR: string + CHG_DTM: string +} + +interface ShiScheduleInfo { + PROJ_NO: string + SHI_DOC_NO: string + DDPKIND: string + SCHEDULE_TYPE: string + BASELINE1: string | null + REVISED1: string | null + FORECAST1: string | null + ACTUAL1: string | null + BASELINE2: string | null + REVISED2: string | null + FORECAST2: string | null + ACTUAL2: string | null + CRTER: string + CRTE_DTM: string + CHGR: string + CHG_DTM: string +} + +// SHI API 응답 타입 +interface ShiDocumentResponse { + PROJ_NO: string + SHI_DOC_NO: string + STATUS: string + COMMENT: string | null + CATEGORY?: string + RESPONSIBLE_CD?: string + RESPONSIBLE?: string + VNDR_CD?: string + VNDR_NM?: string + DSN_SKL?: string + MIFP_CD?: string + MIFP_NM?: string + CG_EMPNO1?: string + CG_EMPNM1?: string + OWN_DOC_NO?: string + DSC?: string + DOC_CLASS?: string + CRTER?: string + CRTE_DTM?: string + CHGR?: string + CHG_DTM?: string +} + +interface ShiApiResponse { + GetDwgInfoResult: ShiDocumentResponse[] +} + +// InBox 파일 정보 인터페이스 추가 +interface InBoxFileInfo { + PROJ_NO: string + SHI_DOC_NO: string + STAGE_NAME: string + REVISION_NO: string + VNDR_CD: string + VNDR_NM: string + FILE_NAME: string + FILE_SIZE: number + CONTENT_TYPE: string + UPLOAD_DATE: string + UPLOADED_BY: string + STATUS: string + COMMENT: string +} + +// SaveInBoxList API 응답 인터페이스 +interface SaveInBoxListResponse { + SaveInBoxListResult: { + success: boolean + message: string + processedCount?: number + files?: Array<{ + fileName: string + networkPath: string + status: string + }> + } +} + +export class ShiBuyerSystemAPI { + private baseUrl = process.env.SWP_BASE_URL || 'http://60.100.99.217/DDP/Services/VNDRService.svc' + private ddcUrl = process.env.DDC_BASE_URL || 'http://60.100.99.217/DDC/Services/WebService.svc' + private localStoragePath = process.env.NAS_PATH || './uploads' + + async sendToSHI(contractId: number) { + try { + // 1. 전송할 문서 조회 + const documents = await this.getDocumentsToSend(contractId) + + if (documents.length === 0) { + return { success: false, message: "전송할 문서가 없습니다." } + } + + // 2. 도서 정보 전송 + await this.sendDocumentInfo(documents) + + // 3. 스케줄 정보 전송 + await this.sendScheduleInfo(documents) + + // 4. 동기화 상태 업데이트 + await this.updateSyncStatus(documents.map(d => d.documentId)) + + return { + success: true, + message: `${documents.length}개 문서가 성공적으로 전송되었습니다.`, + count: documents.length + } + } catch (error) { + console.error("SHI 전송 오류:", error) + + // 에러 시 동기화 상태 업데이트 + await this.updateSyncError( + contractId, + error instanceof Error ? error.message : "알 수 없는 오류" + ) + + throw error + } + } + + private async getDocumentsToSend(contractId: number) { + const result = await db + .select({ + documentId: stageDocuments.id, + docNumber: stageDocuments.docNumber, + vendorDocNumber: stageDocuments.vendorDocNumber, + title: stageDocuments.title, + status: stageDocuments.status, + projectCode: sql<string>`(SELECT code FROM projects WHERE id = ${stageDocuments.projectId})`, + vendorCode: sql<string>`(SELECT vendor_code FROM vendors WHERE id = ${stageDocuments.vendorId})`, + vendorName: sql<string>`(SELECT vendor_name FROM vendors WHERE id = ${stageDocuments.vendorId})`, + stages: sql<any[]>` + COALESCE( + (SELECT json_agg(row_to_json(s.*)) + FROM stage_issue_stages s + WHERE s.document_id = ${stageDocuments.id} + ORDER BY s.stage_order), + '[]'::json + ) + ` + }) + .from(stageDocuments) + .where( + and( + eq(stageDocuments.contractId, contractId), + eq(stageDocuments.status, 'ACTIVE'), + ne(stageDocuments.buyerSystemStatus, "승인(DC)") + ) + ) + + return result + } + + private async sendDocumentInfo(documents: any[]) { + const shiDocuments: ShiDocumentInfo[] = documents.map(doc => ({ + PROJ_NO: doc.projectCode, + SHI_DOC_NO: doc.docNumber, + CATEGORY: "SHIP", + RESPONSIBLE_CD: "EVCP", + RESPONSIBLE: "eVCP System", + VNDR_CD: doc.vendorCode || "", + VNDR_NM: doc.vendorName || "", + DSN_SKL: "B3", + MIFP_CD: "", + MIFP_NM: "", + CG_EMPNO1: "", + CG_EMPNM1: "", + OWN_DOC_NO: doc.vendorDocNumber || doc.docNumber, + DSC: doc.title, + DOC_CLASS: "B3", + COMMENT: "", + STATUS: "ACTIVE", + CRTER: "EVCP_SYSTEM", + CRTE_DTM: new Date().toISOString(), + CHGR: "EVCP_SYSTEM", + CHG_DTM: new Date().toISOString() + })) + + const response = await fetch(`${this.baseUrl}/SetDwgInfo`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(shiDocuments) + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`도서 정보 전송 실패: ${response.statusText} - ${errorText}`) + } + + return response.json() + } + + private async sendScheduleInfo(documents: any[]) { + const schedules: ShiScheduleInfo[] = [] + + for (const doc of documents) { + for (const stage of doc.stages) { + if (stage.plan_date) { + schedules.push({ + PROJ_NO: doc.projectCode, + SHI_DOC_NO: doc.docNumber, + DDPKIND: "V", + SCHEDULE_TYPE: stage.stage_name, + BASELINE1: stage.plan_date ? new Date(stage.plan_date).toISOString() : null, + REVISED1: null, + FORECAST1: null, + ACTUAL1: stage.actual_date ? new Date(stage.actual_date).toISOString() : null, + BASELINE2: null, + REVISED2: null, + FORECAST2: null, + ACTUAL2: null, + CRTER: "EVCP_SYSTEM", + CRTE_DTM: new Date().toISOString(), + CHGR: "EVCP_SYSTEM", + CHG_DTM: new Date().toISOString() + }) + } + } + } + + if (schedules.length === 0) { + console.log("전송할 스케줄 정보가 없습니다.") + return + } + + const response = await fetch(`${this.baseUrl}/SetScheduleInfo`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(schedules) + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`스케줄 정보 전송 실패: ${response.statusText} - ${errorText}`) + } + + return response.json() + } + + private async updateSyncStatus(documentIds: number[]) { + if (documentIds.length === 0) return + + await db + .update(stageDocuments) + .set({ + syncStatus: 'synced', + lastSyncedAt: new Date(), + syncError: null, + syncVersion: sql`sync_version + 1`, + lastModifiedBy: 'EVCP' + }) + .where(sql`id = ANY(${documentIds})`) + } + + private async updateSyncError(contractId: number, errorMessage: string) { + await db + .update(stageDocuments) + .set({ + syncStatus: 'error', + syncError: errorMessage, + lastModifiedBy: 'EVCP' + }) + .where( + and( + eq(stageDocuments.contractId, contractId), + eq(stageDocuments.status, 'ACTIVE') + ) + ) + } + + async pullDocumentStatus(contractId: number) { + try { + const contract = await db.query.contracts.findFirst({ + where: eq(contracts.id, contractId), + }); + + if (!contract) { + throw new Error(`계약을 찾을 수 없습니다: ${contractId}`) + } + + const project = await db.query.projects.findFirst({ + where: eq(projects.id, contract.projectId), + }); + + if (!project) { + throw new Error(`프로젝트를 찾을 수 없습니다: ${contract.projectId}`) + } + + const vendor = await db.query.vendors.findFirst({ + where: eq(vendors.id, contract.vendorId), + }); + + if (!vendor) { + throw new Error(`벤더를 찾을 수 없습니다: ${contract.vendorId}`) + } + + const shiDocuments = await this.fetchDocumentsFromSHI(project.code, { + VNDR_CD: vendor.vendorCode + }) + + if (!shiDocuments || shiDocuments.length === 0) { + return { + success: true, + message: "동기화할 문서가 없습니다.", + updatedCount: 0, + documents: [] + } + } + + const updateResults = await this.updateLocalDocuments(project.code, shiDocuments) + + return { + success: true, + message: `${updateResults.updatedCount}개 문서의 상태가 업데이트되었습니다.`, + updatedCount: updateResults.updatedCount, + newCount: updateResults.newCount, + documents: updateResults.documents + } + } catch (error) { + console.error("문서 상태 풀링 오류:", error) + throw error + } + } + + private async fetchDocumentsFromSHI( + projectCode: string, + filters?: { + SHI_DOC_NO?: string + CATEGORY?: string + VNDR_CD?: string + RESPONSIBLE_CD?: string + STATUS?: string + DOC_CLASS?: string + CRTE_DTM_FROM?: string + CRTE_DTM_TO?: string + CHG_DTM_FROM?: string + CHG_DTM_TO?: string + } + ): Promise<ShiDocumentResponse[]> { + const params = new URLSearchParams({ PROJ_NO: projectCode }) + + if (filters) { + Object.entries(filters).forEach(([key, value]) => { + if (value) params.append(key, value) + }) + } + + const url = `${this.baseUrl}/GetDwgInfo?${params.toString()}` + + const response = await fetch(url, { + method: 'GET', + headers: { + 'Accept': 'application/json' + } + }) + + if (!response.ok) { + throw new Error(`문서 조회 실패: ${response.statusText}`) + } + + const data: ShiApiResponse = await response.json() + + return data.GetDwgInfoResult || [] + } + + private async updateLocalDocuments( + projectCode: string, + shiDocuments: ShiDocumentResponse[] + ) { + let updatedCount = 0 + let newCount = 0 + const updatedDocuments: any[] = [] + + const project = await db.query.projects.findFirst({ + where: eq(projects.code, projectCode) + }) + + if (!project) { + throw new Error(`프로젝트를 찾을 수 없습니다: ${projectCode}`) + } + + for (const shiDoc of shiDocuments) { + const localDoc = await db.query.stageDocuments.findFirst({ + where: and( + eq(stageDocuments.projectId, project.id), + eq(stageDocuments.docNumber, shiDoc.SHI_DOC_NO) + ) + }) + + if (localDoc) { + if ( + localDoc.buyerSystemStatus !== shiDoc.STATUS || + localDoc.buyerSystemComment !== shiDoc.COMMENT + ) { + await db + .update(stageDocuments) + .set({ + buyerSystemStatus: shiDoc.STATUS, + buyerSystemComment: shiDoc.COMMENT, + lastSyncedAt: new Date(), + syncStatus: 'synced', + syncError: null, + lastModifiedBy: 'BUYER_SYSTEM', + syncVersion: sql`sync_version + 1` + }) + .where(eq(stageDocuments.id, localDoc.id)) + + updatedCount++ + updatedDocuments.push({ + docNumber: shiDoc.SHI_DOC_NO, + title: shiDoc.DSC || localDoc.title, + status: shiDoc.STATUS, + comment: shiDoc.COMMENT, + action: 'updated' + }) + } + } else { + console.log(`SHI에만 존재하는 문서: ${shiDoc.SHI_DOC_NO}`) + newCount++ + updatedDocuments.push({ + docNumber: shiDoc.SHI_DOC_NO, + title: shiDoc.DSC || 'N/A', + status: shiDoc.STATUS, + comment: shiDoc.COMMENT, + action: 'new_in_shi' + }) + } + } + + return { + updatedCount, + newCount, + documents: updatedDocuments + } + } + + async getSyncStatus(contractId: number) { + const documents = await db + .select({ + docNumber: stageDocuments.docNumber, + title: stageDocuments.title, + syncStatus: stageDocuments.syncStatus, + lastSyncedAt: stageDocuments.lastSyncedAt, + syncError: stageDocuments.syncError, + buyerSystemStatus: stageDocuments.buyerSystemStatus, + buyerSystemComment: stageDocuments.buyerSystemComment + }) + .from(stageDocuments) + .where(eq(stageDocuments.contractId, contractId)) + + return documents + } + + /** + * 스테이지 제출 건들의 파일을 SHI 구매자 시스템으로 동기화 + * @param submissionIds 제출 ID 배열 + */ + async syncSubmissionsToSHI(submissionIds: number[]) { + const results = { + totalCount: submissionIds.length, + successCount: 0, + failedCount: 0, + details: [] as any[] + } + + for (const submissionId of submissionIds) { + try { + const result = await this.syncSingleSubmission(submissionId) + if (result.success) { + results.successCount++ + } else { + results.failedCount++ + } + results.details.push(result) + } catch (error) { + results.failedCount++ + results.details.push({ + submissionId, + success: false, + error: error instanceof Error ? error.message : "Unknown error" + }) + } + } + + return results + } + + /** + * 단일 제출 건 동기화 + */ + private async syncSingleSubmission(submissionId: number) { + try { + // 1. 제출 정보 조회 (프로젝트, 문서, 스테이지, 파일 정보 포함) + const submissionInfo = await this.getSubmissionFullInfo(submissionId) + + if (!submissionInfo) { + throw new Error(`제출 정보를 찾을 수 없습니다: ${submissionId}`) + } + + // 2. 동기화 시작 상태 업데이트 + await this.updateSubmissionSyncStatus(submissionId, 'syncing') + + // 3. 첨부파일들과 실제 파일 내용을 준비 + const filesWithContent = await this.prepareFilesWithContent(submissionInfo) + + if (filesWithContent.length === 0) { + await this.updateSubmissionSyncStatus(submissionId, 'synced', '전송할 파일이 없습니다') + return { + submissionId, + success: true, + message: "전송할 파일이 없습니다" + } + } + + // 4. SaveInBoxList API 호출하여 네트워크 경로 받기 + const response = await this.sendToInBox(filesWithContent) + + // 5. 응답받은 네트워크 경로에 파일 저장 + if (response.SaveInBoxListResult.success && response.SaveInBoxListResult.files) { + await this.saveFilesToNetworkPaths(filesWithContent, response.SaveInBoxListResult.files) + + // 6. 동기화 결과 업데이트 + await this.updateSubmissionSyncStatus(submissionId, 'synced', null, { + syncedFilesCount: filesWithContent.length, + buyerSystemStatus: 'SYNCED' + }) + + // 개별 파일 상태 업데이트 + await this.updateAttachmentsSyncStatus( + submissionInfo.attachments.map(a => a.id), + 'synced' + ) + + return { + submissionId, + success: true, + message: response.SaveInBoxListResult.message, + syncedFiles: filesWithContent.length + } + } else { + throw new Error(response.SaveInBoxListResult.message) + } + } catch (error) { + await this.updateSubmissionSyncStatus( + submissionId, + 'failed', + error instanceof Error ? error.message : '알 수 없는 오류' + ) + + throw error + } + } + + /** + * 제출 정보 조회 (관련 정보 포함) + */ + private async getSubmissionFullInfo(submissionId: number) { + const result = await db + .select({ + submission: stageSubmissions, + stage: stageIssueStages, + document: stageDocuments, + project: projects, + vendor: vendors + }) + .from(stageSubmissions) + .innerJoin(stageIssueStages, eq(stageSubmissions.stageId, stageIssueStages.id)) + .innerJoin(stageDocuments, eq(stageSubmissions.documentId, stageDocuments.id)) + .innerJoin(projects, eq(stageDocuments.projectId, projects.id)) + .leftJoin(vendors, eq(stageDocuments.vendorId, vendors.id)) + .where(eq(stageSubmissions.id, submissionId)) + .limit(1) + + if (result.length === 0) return null + + // 첨부파일 조회 - 파일 경로 포함 + const attachments = await db + .select() + .from(stageSubmissionAttachments) + .where( + and( + eq(stageSubmissionAttachments.submissionId, submissionId), + eq(stageSubmissionAttachments.status, 'ACTIVE') + ) + ) + + return { + ...result[0], + attachments + } + } + + /** + * 파일 내용과 함께 InBox 파일 정보 준비 + */ + private async prepareFilesWithContent(submissionInfo: any): Promise<Array<InBoxFileInfo & { fileBuffer: Buffer, attachment: any }>> { + const filesWithContent: Array<InBoxFileInfo & { fileBuffer: Buffer, attachment: any }> = [] + + for (const attachment of submissionInfo.attachments) { + try { + // 파일 경로 결정 (storagePath 또는 storageUrl 사용) + const filePath = attachment.storagePath || attachment.storageUrl + + if (!filePath) { + console.warn(`첨부파일 ${attachment.id}의 경로를 찾을 수 없습니다.`) + continue + } + + // 전체 경로 생성 + const fullPath = path.isAbsolute(filePath) + ? filePath + : path.join(this.localStoragePath, filePath) + + // 파일 읽기 + const fileBuffer = await fs.readFile(fullPath) + + // 파일 정보 생성 + const fileInfo: InBoxFileInfo & { fileBuffer: Buffer, attachment: any } = { + PROJ_NO: submissionInfo.project.code, + SHI_DOC_NO: submissionInfo.document.docNumber, + STAGE_NAME: submissionInfo.stage.stageName, + REVISION_NO: String(submissionInfo.submission.revisionNumber), + VNDR_CD: submissionInfo.vendor?.vendorCode || '', + VNDR_NM: submissionInfo.vendor?.vendorName || '', + FILE_NAME: attachment.fileName, + FILE_SIZE: fileBuffer.length, // 실제 파일 크기 사용 + CONTENT_TYPE: attachment.mimeType || 'application/octet-stream', + UPLOAD_DATE: new Date().toISOString(), + UPLOADED_BY: submissionInfo.submission.submittedBy, + STATUS: 'PENDING', + COMMENT: `Revision ${submissionInfo.submission.revisionNumber} - ${submissionInfo.stage.stageName}`, + fileBuffer: fileBuffer, + attachment: attachment + } + + filesWithContent.push(fileInfo) + } catch (error) { + console.error(`파일 읽기 실패: ${attachment.fileName}`, error) + // 파일 읽기 실패 시 계속 진행 + continue + } + } + + return filesWithContent + } + + /** + * SaveInBoxList API 호출 (파일 메타데이터만 전송) + */ + private async sendToInBox(files: Array<InBoxFileInfo & { fileBuffer: Buffer }>): Promise<SaveInBoxListResponse> { + // fileBuffer를 제외한 메타데이터만 전송 + const fileMetadata = files.map(({ fileBuffer, attachment, ...metadata }) => metadata) + + const request = { files: fileMetadata } + + const response = await fetch(`${this.ddcUrl}/SaveInBoxList`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify(request) + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`InBox 전송 실패: ${response.statusText} - ${errorText}`) + } + + const data = await response.json() + + // 응답 구조 확인 및 처리 + if (!data.SaveInBoxListResult) { + return { + SaveInBoxListResult: { + success: true, + message: "전송 완료", + processedCount: files.length, + files: files.map(f => ({ + fileName: f.FILE_NAME, + networkPath: `\\\\network\\share\\${f.PROJ_NO}\\${f.SHI_DOC_NO}\\${f.FILE_NAME}`, + status: 'READY' + })) + } + } + } + + return data + } + + /** + * 네트워크 경로에 파일 저장 + */ + private async saveFilesToNetworkPaths( + filesWithContent: Array<InBoxFileInfo & { fileBuffer: Buffer, attachment: any }>, + networkPathInfo: Array<{ fileName: string, networkPath: string, status: string }> + ) { + for (const fileInfo of filesWithContent) { + const pathInfo = networkPathInfo.find(p => p.fileName === fileInfo.FILE_NAME) + + if (!pathInfo || !pathInfo.networkPath) { + console.error(`네트워크 경로를 찾을 수 없습니다: ${fileInfo.FILE_NAME}`) + continue + } + + try { + // 네트워크 경로에 파일 저장 + // Windows 네트워크 경로인 경우 처리 + let targetPath = pathInfo.networkPath + + // Windows 네트워크 경로를 Node.js가 이해할 수 있는 형식으로 변환 + if (process.platform === 'win32' && targetPath.startsWith('\\\\')) { + // 그대로 사용 + } else if (process.platform !== 'win32' && targetPath.startsWith('\\\\')) { + // Linux/Mac에서는 SMB 마운트 경로로 변환 필요 + // 예: \\\\server\\share -> /mnt/server/share + targetPath = targetPath.replace(/\\\\/g, '/mnt/').replace(/\\/g, '/') + } + + // 디렉토리 생성 (없는 경우) + const targetDir = path.dirname(targetPath) + await fs.mkdir(targetDir, { recursive: true }) + + // 파일 저장 + await fs.writeFile(targetPath, fileInfo.fileBuffer) + + console.log(`파일 저장 완료: ${fileInfo.FILE_NAME} -> ${targetPath}`) + + // DB에 네트워크 경로 업데이트 + await db + .update(stageSubmissionAttachments) + .set({ + buyerSystemUrl: pathInfo.networkPath, + buyerSystemStatus: 'UPLOADED', + lastModifiedBy: 'EVCP' + }) + .where(eq(stageSubmissionAttachments.id, fileInfo.attachment.id)) + + } catch (error) { + console.error(`파일 저장 실패: ${fileInfo.FILE_NAME}`, error) + // 개별 파일 실패는 전체 프로세스를 중단하지 않음 + } + } + } + + /** + * 제출 동기화 상태 업데이트 + */ + private async updateSubmissionSyncStatus( + submissionId: number, + status: string, + error?: string | null, + additionalData?: any + ) { + const updateData: any = { + syncStatus: status, + lastSyncedAt: new Date(), + syncError: error, + lastModifiedBy: 'EVCP', + ...additionalData + } + + if (status === 'failed') { + updateData.syncRetryCount = sql`sync_retry_count + 1` + updateData.nextRetryAt = new Date(Date.now() + 30 * 60 * 1000) // 30분 후 재시도 + } + + await db + .update(stageSubmissions) + .set(updateData) + .where(eq(stageSubmissions.id, submissionId)) + } + + /** + * 첨부파일 동기화 상태 업데이트 + */ + private async updateAttachmentsSyncStatus( + attachmentIds: number[], + status: string + ) { + if (attachmentIds.length === 0) return + + await db + .update(stageSubmissionAttachments) + .set({ + syncStatus: status, + syncCompletedAt: status === 'synced' ? new Date() : null, + buyerSystemStatus: status === 'synced' ? 'UPLOADED' : 'PENDING', + lastModifiedBy: 'EVCP' + }) + .where(sql`id = ANY(${attachmentIds})`) + } + + /** + * 동기화 재시도 (실패한 건들) + */ + async retrySyncFailedSubmissions(contractId?: number) { + const conditions = [ + eq(stageSubmissions.syncStatus, 'failed'), + sql`next_retry_at <= NOW()` + ] + + if (contractId) { + const documentIds = await db + .select({ id: stageDocuments.id }) + .from(stageDocuments) + .where(eq(stageDocuments.contractId, contractId)) + + if (documentIds.length > 0) { + conditions.push( + sql`document_id = ANY(${documentIds.map(d => d.id)})` + ) + } + } + + const failedSubmissions = await db + .select({ id: stageSubmissions.id }) + .from(stageSubmissions) + .where(and(...conditions)) + .limit(10) // 한 번에 최대 10개씩 재시도 + + if (failedSubmissions.length === 0) { + return { + success: true, + message: "재시도할 제출 건이 없습니다.", + retryCount: 0 + } + } + + const submissionIds = failedSubmissions.map(s => s.id) + const results = await this.syncSubmissionsToSHI(submissionIds) + + return { + success: true, + message: `${results.successCount}/${results.totalCount}개 제출 건 재시도 완료`, + ...results + } + } +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/upload/columns.tsx b/lib/vendor-document-list/plant/upload/columns.tsx new file mode 100644 index 00000000..c0f17afc --- /dev/null +++ b/lib/vendor-document-list/plant/upload/columns.tsx @@ -0,0 +1,379 @@ +"use client" + +import * as React from "react" +import { ColumnDef } from "@tanstack/react-table" +import { formatDate, formatDateTime } from "@/lib/utils" +import { Checkbox } from "@/components/ui/checkbox" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { DataTableRowAction } from "@/types/table" +import { StageSubmissionView } from "@/db/schema" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Progress } from "@/components/ui/progress" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + Ellipsis, + Upload, + Eye, + RefreshCw, + CheckCircle2, + XCircle, + AlertCircle, + Clock +} from "lucide-react" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<StageSubmissionView> | null>> +} + +export function getColumns({ + setRowAction, +}: GetColumnsProps): ColumnDef<StageSubmissionView>[] { + return [ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "docNumber", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Doc Number" /> + ), + cell: ({ row }) => { + const vendorDocNumber = row.original.vendorDocNumber + return ( + <div className="space-y-1"> + <div className="font-medium">{row.getValue("docNumber")}</div> + {vendorDocNumber && ( + <div className="text-xs text-muted-foreground">{vendorDocNumber}</div> + )} + </div> + ) + }, + size: 150, + }, + { + accessorKey: "documentTitle", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Document Title" /> + ), + cell: ({ row }) => ( + <div className="max-w-[300px] truncate" title={row.getValue("documentTitle")}> + {row.getValue("documentTitle")} + </div> + ), + size: 250, + }, + { + accessorKey: "projectCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Project" /> + ), + cell: ({ row }) => ( + <Badge variant="outline">{row.getValue("projectCode")}</Badge> + ), + size: 100, + }, + { + accessorKey: "stageName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Stage" /> + ), + cell: ({ row }) => { + const stageName = row.getValue("stageName") as string + const stageStatus = row.original.stageStatus + const stageOrder = row.original.stageOrder + + return ( + <div className="space-y-1"> + <div className="flex items-center gap-2"> + <Badge variant="secondary" className="text-xs"> + {stageOrder ? `#${stageOrder}` : ""} + </Badge> + <span className="text-sm">{stageName}</span> + </div> + {stageStatus && ( + <Badge + variant={ + stageStatus === "COMPLETED" ? "success" : + stageStatus === "IN_PROGRESS" ? "default" : + stageStatus === "REJECTED" ? "destructive" : + "secondary" + } + className="text-xs" + > + {stageStatus} + </Badge> + )} + </div> + ) + }, + size: 200, + }, + { + accessorKey: "stagePlanDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Due Date" /> + ), + cell: ({ row }) => { + const planDate = row.getValue("stagePlanDate") as Date | null + const isOverdue = row.original.isOverdue + const daysUntilDue = row.original.daysUntilDue + + if (!planDate) return <span className="text-muted-foreground">-</span> + + return ( + <div className="space-y-1"> + <div className={isOverdue ? "text-destructive font-medium" : ""}> + {formatDate(planDate)} + </div> + {daysUntilDue !== null && ( + <div className="text-xs"> + {isOverdue ? ( + <Badge variant="destructive" className="gap-1"> + <AlertCircle className="h-3 w-3" /> + {Math.abs(daysUntilDue)} days overdue + </Badge> + ) : daysUntilDue === 0 ? ( + <Badge variant="warning" className="gap-1"> + <Clock className="h-3 w-3" /> + Due today + </Badge> + ) : ( + <span className="text-muted-foreground"> + {daysUntilDue} days remaining + </span> + )} + </div> + )} + </div> + ) + }, + size: 150, + }, + { + accessorKey: "latestSubmissionStatus", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Submission Status" /> + ), + cell: ({ row }) => { + const status = row.getValue("latestSubmissionStatus") as string | null + const reviewStatus = row.original.latestReviewStatus + const revisionNumber = row.original.latestRevisionNumber + const revisionCode = row.original.latestRevisionCode + + if (!status) { + return ( + <Badge variant="outline" className="gap-1"> + <AlertCircle className="h-3 w-3" /> + Not submitted + </Badge> + ) + } + + return ( + <div className="space-y-1"> + <Badge + variant={ + reviewStatus === "APPROVED" ? "success" : + reviewStatus === "REJECTED" ? "destructive" : + status === "SUBMITTED" ? "default" : + "secondary" + } + > + {reviewStatus || status} + </Badge> + {revisionCode !== null &&( + <div className="text-xs text-muted-foreground"> + {revisionCode} + </div> + )} + </div> + ) + }, + size: 150, + }, + { + id: "syncStatus", + accessorKey: "latestSyncStatus", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Sync Status" /> + ), + cell: ({ row }) => { + const syncStatus = row.getValue("latestSyncStatus") as string | null + const syncProgress = row.original.syncProgress + const requiresSync = row.original.requiresSync + + if (!syncStatus || syncStatus === "pending") { + if (requiresSync) { + return ( + <Badge variant="outline" className="gap-1"> + <Clock className="h-3 w-3" /> + Pending + </Badge> + ) + } + return <span className="text-muted-foreground">-</span> + } + + return ( + <div className="space-y-2"> + <Badge + variant={ + syncStatus === "synced" ? "success" : + syncStatus === "failed" ? "destructive" : + syncStatus === "syncing" ? "default" : + "secondary" + } + className="gap-1" + > + {syncStatus === "syncing" && <RefreshCw className="h-3 w-3 animate-spin" />} + {syncStatus === "synced" && <CheckCircle2 className="h-3 w-3" />} + {syncStatus === "failed" && <XCircle className="h-3 w-3" />} + {syncStatus} + </Badge> + {syncProgress !== null && syncProgress !== undefined && syncStatus === "syncing" && ( + <Progress value={syncProgress} className="h-1.5 w-20" /> + )} + </div> + ) + }, + size: 120, + }, + { + accessorKey: "totalFiles", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Files" /> + ), + cell: ({ row }) => { + const totalFiles = row.getValue("totalFiles") as number + const syncedFiles = row.original.syncedFilesCount + + if (!totalFiles) return <span className="text-muted-foreground">0</span> + + return ( + <div className="text-sm"> + {syncedFiles !== null && syncedFiles !== undefined ? ( + <span>{syncedFiles}/{totalFiles}</span> + ) : ( + <span>{totalFiles}</span> + )} + </div> + ) + }, + size: 80, + }, + // { + // accessorKey: "vendorName", + // header: ({ column }) => ( + // <DataTableColumnHeaderSimple column={column} title="Vendor" /> + // ), + // cell: ({ row }) => { + // const vendorName = row.getValue("vendorName") as string + // const vendorCode = row.original.vendorCode + + // return ( + // <div className="space-y-1"> + // <div className="text-sm">{vendorName}</div> + // {vendorCode && ( + // <div className="text-xs text-muted-foreground">{vendorCode}</div> + // )} + // </div> + // ) + // }, + // size: 150, + // }, + { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const requiresSubmission = row.original.requiresSubmission + const requiresSync = row.original.requiresSync + const latestSubmissionId = row.original.latestSubmissionId + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-7 p-0" + > + <Ellipsis className="size-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-48"> + {requiresSubmission && ( + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "upload" })} + className="gap-2" + > + <Upload className="h-4 w-4" /> + Upload Documents + </DropdownMenuItem> + )} + + {latestSubmissionId && ( + <> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "view" })} + className="gap-2" + > + <Eye className="h-4 w-4" /> + View Submission + </DropdownMenuItem> + + {requiresSync && ( + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "sync" })} + className="gap-2" + > + <RefreshCw className="h-4 w-4" /> + Retry Sync + </DropdownMenuItem> + )} + </> + )} + + <DropdownMenuSeparator /> + + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "history" })} + className="gap-2" + > + <Clock className="h-4 w-4" /> + View History + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, + } + ] +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/upload/components/history-dialog.tsx b/lib/vendor-document-list/plant/upload/components/history-dialog.tsx new file mode 100644 index 00000000..9c4f160b --- /dev/null +++ b/lib/vendor-document-list/plant/upload/components/history-dialog.tsx @@ -0,0 +1,144 @@ +// lib/vendor-document-list/plant/upload/components/history-dialog.tsx +"use client" + +import * as React from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Badge } from "@/components/ui/badge" +import { ScrollArea } from "@/components/ui/scroll-area" +import { + CheckCircle2, + XCircle, + Clock, + FileText, + User, + Calendar, + AlertCircle +} from "lucide-react" +import { StageSubmissionView } from "@/db/schema" +import { formatDateTime } from "@/lib/utils" + +interface HistoryDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + submission: StageSubmissionView +} + +export function HistoryDialog({ + open, + onOpenChange, + submission +}: HistoryDialogProps) { + const history = submission.submissionHistory || [] + + const getStatusIcon = (status: string, reviewStatus?: string) => { + if (reviewStatus === "APPROVED") { + return <CheckCircle2 className="h-4 w-4 text-success" /> + } + if (reviewStatus === "REJECTED") { + return <XCircle className="h-4 w-4 text-destructive" /> + } + if (status === "SUBMITTED") { + return <Clock className="h-4 w-4 text-primary" /> + } + return <AlertCircle className="h-4 w-4 text-muted-foreground" /> + } + + const getStatusBadge = (status: string, reviewStatus?: string) => { + const variant = reviewStatus === "APPROVED" ? "success" : + reviewStatus === "REJECTED" ? "destructive" : + status === "SUBMITTED" ? "default" : "secondary" + + return ( + <Badge variant={variant}> + {reviewStatus || status} + </Badge> + ) + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-3xl max-h-[80vh]"> + <DialogHeader> + <DialogTitle>Submission History</DialogTitle> + <DialogDescription> + View all submission history for this stage + </DialogDescription> + </DialogHeader> + + {/* Document Info */} + <div className="grid gap-2 p-4 bg-muted rounded-lg"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <FileText className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm font-medium">{submission.docNumber}</span> + <span className="text-sm text-muted-foreground"> + - {submission.documentTitle} + </span> + </div> + <Badge variant="outline">{submission.stageName}</Badge> + </div> + </div> + + {/* History Timeline */} + <ScrollArea className="h-[400px] pr-4"> + {history.length === 0 ? ( + <div className="text-center py-8 text-muted-foreground"> + No submission history available + </div> + ) : ( + <div className="space-y-4"> + {history.map((item, index) => ( + <div key={item.submissionId} className="relative"> + {/* Timeline line */} + {index < history.length - 1 && ( + <div className="absolute left-5 top-10 bottom-0 w-0.5 bg-border" /> + )} + + {/* Timeline item */} + <div className="flex gap-4"> + <div className="flex-shrink-0 w-10 h-10 rounded-full bg-background border-2 border-border flex items-center justify-center"> + {getStatusIcon(item.status, item.reviewStatus)} + </div> + + <div className="flex-1 pb-4"> + <div className="flex items-center gap-2 mb-2"> + <span className="font-medium">Revision {item.revisionNumber}</span> + {getStatusBadge(item.status, item.reviewStatus)} + {item.syncStatus && ( + <Badge variant="outline" className="text-xs"> + Sync: {item.syncStatus} + </Badge> + )} + </div> + + <div className="grid gap-1 text-sm text-muted-foreground"> + <div className="flex items-center gap-2"> + <User className="h-3 w-3" /> + <span>{item.submittedBy}</span> + </div> + <div className="flex items-center gap-2"> + <Calendar className="h-3 w-3" /> + <span>{formatDateTime(new Date(item.submittedAt))}</span> + </div> + <div className="flex items-center gap-2"> + <FileText className="h-3 w-3" /> + <span>{item.fileCount} file(s)</span> + </div> + </div> + </div> + </div> + </div> + ))} + </div> + )} + </ScrollArea> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/upload/components/multi-upload-dialog.tsx b/lib/vendor-document-list/plant/upload/components/multi-upload-dialog.tsx new file mode 100644 index 00000000..81a1d486 --- /dev/null +++ b/lib/vendor-document-list/plant/upload/components/multi-upload-dialog.tsx @@ -0,0 +1,492 @@ +// lib/vendor-document-list/plant/upload/components/multi-upload-dialog.tsx +"use client" + +import * as React from "react" +import { useState, useCallback } from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { ScrollArea } from "@/components/ui/scroll-area" +import { + Dropzone, + DropzoneDescription, + DropzoneInput, + DropzoneTitle, + DropzoneUploadIcon, + DropzoneZone, +} from "@/components/ui/dropzone" +import { + FileList, + FileListAction, + FileListDescription, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, + FileListSize, +} from "@/components/ui/file-list" +import { + Upload, + X, + CheckCircle2, + AlertCircle, + Loader2, + CloudUpload, + FileWarning +} from "lucide-react" +import { toast } from "sonner" +import { validateFiles } from "../../document-stages-service" +import { parseFileName, ParsedFileName } from "../util/filie-parser" + +interface FileWithMetadata { + file: File + parsed: ParsedFileName + matched?: { + documentId: number + stageId: number + documentTitle: string + currentRevision?: string // number에서 string으로 변경 + } + status: 'pending' | 'validating' | 'uploading' | 'success' | 'error' + error?: string + progress?: number +} + +interface MultiUploadDialogProps { + projectId: number + // projectCode: string + onUploadComplete?: () => void +} + + +export function MultiUploadDialog({ + projectId, + // projectCode, + onUploadComplete +}: MultiUploadDialogProps) { + const [open, setOpen] = useState(false) + const [files, setFiles] = useState<FileWithMetadata[]>([]) + const [isValidating, setIsValidating] = useState(false) + const [isUploading, setIsUploading] = useState(false) + + // 디버깅용 로그 + console.log("Current files:", files) + + // 파일 추가 핸들러 - onChange 이벤트용 + const handleFilesChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { + const fileList = e.target.files + console.log("Files selected via input:", fileList) + + if (fileList && fileList.length > 0) { + handleFilesAdded(Array.from(fileList)) + } + }, []) + + // 파일 추가 핸들러 - 공통 + const handleFilesAdded = useCallback(async (newFiles: File[]) => { + console.log("handleFilesAdded called with:", newFiles) + + if (!newFiles || newFiles.length === 0) { + console.log("No files provided") + return + } + + const processedFiles: FileWithMetadata[] = newFiles.map(file => { + const parsed = parseFileName(file.name) + console.log(`Parsed ${file.name}:`, parsed) + + return { + file, + parsed, + status: 'pending' as const + } + }) + + setFiles(prev => { + const updated = [...prev, ...processedFiles] + console.log("Updated files state:", updated) + return updated + }) + + // 유효한 파일들만 검증 + const validFiles = processedFiles.filter(f => f.parsed.isValid) + console.log("Valid files for validation:", validFiles) + + if (validFiles.length > 0) { + await validateFilesWithServer(validFiles) + } + }, []) + + // 서버 검증 + const validateFilesWithServer = async (filesToValidate: FileWithMetadata[]) => { + console.log("Starting validation for:", filesToValidate) + setIsValidating(true) + + setFiles(prev => prev.map(file => + filesToValidate.some(f => f.file === file.file) + ? { ...file, status: 'validating' as const } + : file + )) + + try { + const validationData = filesToValidate.map(f => ({ + projectId, // projectCode 대신 projectId 사용 + docNumber: f.parsed.docNumber, + stageName: f.parsed.stageName, + revision: f.parsed.revision + }))s + + console.log("Sending validation data:", validationData) + const results = await validateFiles(validationData) + console.log("Validation results:", results) + + // 매칭 결과 업데이트 - projectCode 체크 제거 + setFiles(prev => prev.map(file => { + const result = results.find(r => + r.docNumber === file.parsed.docNumber && + r.stageName === file.parsed.stageName + ) + + if (result && result.matched) { + console.log(`File ${file.file.name} matched:`, result.matched) + return { + ...file, + matched: result.matched, + status: 'pending' as const + } + } + return { ...file, status: 'pending' as const } + })) + } catch (error) { + console.error("Validation error:", error) + toast.error("Failed to validate files") + setFiles(prev => prev.map(file => + filesToValidate.some(f => f.file === file.file) + ? { ...file, status: 'error' as const, error: 'Validation failed' } + : file + )) + } finally { + setIsValidating(false) + } + } + // Drag and Drop 핸들러 + const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => { + e.preventDefault() + e.stopPropagation() + + const droppedFiles = Array.from(e.dataTransfer.files) + console.log("Files dropped:", droppedFiles) + + if (droppedFiles.length > 0) { + handleFilesAdded(droppedFiles) + } + }, [handleFilesAdded]) + + const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => { + e.preventDefault() + e.stopPropagation() + }, []) + + // 파일 제거 + const removeFile = (index: number) => { + console.log("Removing file at index:", index) + setFiles(prev => prev.filter((_, i) => i !== index)) + } + + // 업로드 실행 + const handleUpload = async () => { + const uploadableFiles = files.filter(f => f.parsed.isValid && f.matched) + console.log("Files to upload:", uploadableFiles) + + if (uploadableFiles.length === 0) { + toast.error("No valid files to upload") + return + } + + setIsUploading(true) + + // 업로드 중 상태로 변경 + setFiles(prev => prev.map(file => + uploadableFiles.includes(file) + ? { ...file, status: 'uploading' as const } + : file + )) + + try { + const formData = new FormData() + + uploadableFiles.forEach((fileData, index) => { + formData.append(`files`, fileData.file) + formData.append(`metadata[${index}]`, JSON.stringify({ + documentId: fileData.matched!.documentId, + stageId: fileData.matched!.stageId, + revision: fileData.parsed.revision, + originalName: fileData.file.name + })) + }) + + console.log("Sending upload request") + const response = await fetch('/api/stage-submissions/bulk-upload', { + method: 'POST', + body: formData + }) + + if (!response.ok) { + const error = await response.text() + console.error("Upload failed:", error) + throw new Error('Upload failed') + } + + const result = await response.json() + console.log("Upload result:", result) + + // 성공 상태 업데이트 + setFiles(prev => prev.map(file => + uploadableFiles.includes(file) + ? { ...file, status: 'success' as const } + : file + )) + + toast.success(`Successfully uploaded ${result.uploaded} files`) + + setTimeout(() => { + setOpen(false) + setFiles([]) + onUploadComplete?.() + }, 2000) + + } catch (error) { + console.error("Upload error:", error) + toast.error("Upload failed") + + setFiles(prev => prev.map(file => + uploadableFiles.includes(file) + ? { ...file, status: 'error' as const, error: 'Upload failed' } + : file + )) + } finally { + setIsUploading(false) + } + } + + // 통계 계산 + const stats = { + total: files.length, + valid: files.filter(f => f.parsed.isValid).length, + matched: files.filter(f => f.matched).length, + ready: files.filter(f => f.parsed.isValid && f.matched).length, + totalSize: files.reduce((acc, f) => acc + f.file.size, 0) + } + + const formatFileSize = (bytes: number) => { + if (bytes === 0) return '0 Bytes' + const k = 1024 + const sizes = ['Bytes', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] + } + + // 파일별 상태 아이콘 + const getStatusIcon = (fileData: FileWithMetadata) => { + if (!fileData.parsed.isValid) { + return <FileWarning className="h-4 w-4 text-destructive" /> + } + + switch (fileData.status) { + case 'validating': + return <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> + case 'uploading': + return <Loader2 className="h-4 w-4 animate-spin text-primary" /> + case 'success': + return <CheckCircle2 className="h-4 w-4 text-success" /> + case 'error': + return <AlertCircle className="h-4 w-4 text-destructive" /> + default: + if (fileData.matched) { + return <CheckCircle2 className="h-4 w-4 text-success" /> + } else { + return <AlertCircle className="h-4 w-4 text-warning" /> + } + } + } + + // 파일별 상태 설명 + const getStatusDescription = (fileData: FileWithMetadata) => { + if (!fileData.parsed.isValid) { + return fileData.parsed.error || "Invalid format" + } + + switch (fileData.status) { + case 'validating': + return "Checking..." + case 'uploading': + return "Uploading..." + case 'success': + return "Uploaded" + case 'error': + return fileData.error || "Failed" + default: + if (fileData.matched) { + // projectCode 제거 + return `${fileData.parsed.docNumber}_${fileData.parsed.stageName}` + } else { + return "Document not found in system" + } + } + } + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild> + <Button variant="outline" className="gap-2"> + <CloudUpload className="h-4 w-4" /> + Multi-Upload + </Button> + </DialogTrigger> + <DialogContent className="max-w-5xl max-h-[90vh] flex flex-col"> + <DialogHeader> + <DialogTitle>Bulk Document Upload</DialogTitle> + <DialogDescription> + Upload multiple files at once. Files should be named as: DocNumber_StageName_Revision.ext + </DialogDescription> + </DialogHeader> + + {/* Custom Dropzone with input */} + <div + className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-gray-400 transition-colors cursor-pointer" + onDrop={handleDrop} + onDragOver={handleDragOver} + onClick={() => document.getElementById('file-upload')?.click()} + > + <input + id="file-upload" + type="file" + multiple + className="hidden" + onChange={handleFilesChange} + accept="*/*" + /> + <Upload className="mx-auto h-12 w-12 text-gray-400 mb-4" /> + <p className="text-lg font-medium">Drop files here or click to browse</p> + <p className="text-sm text-gray-500 mt-1"> + Maximum 10GB total • Format: DocNumber_StageName_Revision.ext + </p> + </div> + + {/* Stats */} + {files.length > 0 && ( + <div className="flex gap-2 flex-wrap"> + <Badge variant="outline">Total: {stats.total}</Badge> + <Badge variant={stats.valid === stats.total ? "success" : "secondary"}> + Valid Format: {stats.valid} + </Badge> + <Badge variant={stats.matched > 0 ? "success" : "secondary"}> + Matched: {stats.matched} + </Badge> + <Badge variant={stats.ready > 0 ? "default" : "outline"}> + Ready: {stats.ready} + </Badge> + <Badge variant="outline"> + Size: {formatFileSize(stats.totalSize)} + </Badge> + </div> + )} + + {/* File List */} + {files.length > 0 && ( + <div className="flex-1 rounded-md border overflow-y-auto" style={{ minHeight: 200, maxHeight: 400 }}> + <FileList className="p-4"> + <FileListHeader> + <div className="text-sm font-medium">Files ({files.length})</div> + </FileListHeader> + + {files.map((fileData, index) => ( + <FileListItem key={index}> + <FileListIcon> + {getStatusIcon(fileData)} + </FileListIcon> + + <FileListInfo> + <FileListName>{fileData.file.name}</FileListName> + <FileListDescription> + {getStatusDescription(fileData)} + </FileListDescription> + </FileListInfo> + + <FileListSize> + {fileData.file.size} + </FileListSize> + + <FileListAction> + <Button + variant="ghost" + size="icon" + className="h-8 w-8" + onClick={(e) => { + e.stopPropagation() + removeFile(index) + }} + disabled={isUploading || fileData.status === 'uploading'} + > + <X className="h-4 w-4" /> + </Button> + </FileListAction> + </FileListItem> + ))} + </FileList> + </div> + )} + {/* Error Alert */} + {files.filter(f => !f.parsed.isValid).length > 0 && ( + <Alert variant="destructive"> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + {files.filter(f => !f.parsed.isValid).length} file(s) have invalid naming format. + Expected: ProjectCode_DocNumber_StageName_Rev0.ext + </AlertDescription> + </Alert> + )} + + <DialogFooter> + <Button + variant="outline" + onClick={() => { + setOpen(false) + setFiles([]) + }} + disabled={isUploading} + > + Cancel + </Button> + <Button + onClick={handleUpload} + disabled={stats.ready === 0 || isUploading || isValidating} + className="gap-2" + > + {isUploading ? ( + <> + <Loader2 className="h-4 w-4 animate-spin" /> + Uploading {stats.ready} files... + </> + ) : ( + <> + <Upload className="h-4 w-4" /> + Upload {stats.ready} file{stats.ready !== 1 ? 's' : ''} + </> + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/upload/components/project-filter.tsx b/lib/vendor-document-list/plant/upload/components/project-filter.tsx new file mode 100644 index 00000000..33c2819b --- /dev/null +++ b/lib/vendor-document-list/plant/upload/components/project-filter.tsx @@ -0,0 +1,109 @@ +// lib/vendor-document-list/plant/upload/components/project-filter.tsx +"use client" + +import * as React from "react" +import { Check, ChevronsUpDown, Building2 } from "lucide-react" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { Badge } from "@/components/ui/badge" + +interface Project { + id: number + code: string +} + +interface ProjectFilterProps { + projects: Project[] + value?: number | null + onValueChange: (value: number | null) => void +} + +export function ProjectFilter({ projects, value, onValueChange }: ProjectFilterProps) { + const [open, setOpen] = React.useState(false) + + const selectedProject = projects.find(p => p.id === value) + + return ( + <Popover open={open} onOpenChange={setOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={open} + className="w-[250px] justify-between" + > + <div className="flex items-center gap-2 truncate"> + <Building2 className="h-4 w-4 shrink-0 text-muted-foreground" /> + {selectedProject ? ( + <> + <span className="truncate">{selectedProject.code}</span> + <Badge variant="secondary" className="ml-1"> + Selected + </Badge> + </> + ) : ( + <span className="text-muted-foreground">All Projects</span> + )} + </div> + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-[250px] p-0"> + <Command> + <CommandInput placeholder="Search project..." /> + <CommandList> + <CommandEmpty>No project found.</CommandEmpty> + <CommandGroup> + <CommandItem + value="" + onSelect={() => { + onValueChange(null) + setOpen(false) + }} + > + <Check + className={cn( + "mr-2 h-4 w-4", + value === null ? "opacity-100" : "opacity-0" + )} + /> + <span className="text-muted-foreground">All Projects</span> + </CommandItem> + {projects.map((project) => ( + <CommandItem + key={project.id} + value={project.code} + onSelect={() => { + onValueChange(project.id) + setOpen(false) + }} + > + <Check + className={cn( + "mr-2 h-4 w-4", + value === project.id ? "opacity-100" : "opacity-0" + )} + /> + {project.code} + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/upload/components/single-upload-dialog.tsx b/lib/vendor-document-list/plant/upload/components/single-upload-dialog.tsx new file mode 100644 index 00000000..a33a7160 --- /dev/null +++ b/lib/vendor-document-list/plant/upload/components/single-upload-dialog.tsx @@ -0,0 +1,265 @@ +// lib/vendor-document-list/plant/upload/components/single-upload-dialog.tsx +"use client" + +import * as React from "react" +import { useState } from "react" +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 { Textarea } from "@/components/ui/textarea" +import { Badge } from "@/components/ui/badge" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { + FileList, + FileListAction, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, + FileListSize, +} from "@/components/ui/file-list" +import { + Upload, + X, + FileIcon, + Loader2, + AlertCircle +} from "lucide-react" +import { toast } from "sonner" +import { StageSubmissionView } from "@/db/schema" + +interface SingleUploadDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + submission: StageSubmissionView + onUploadComplete?: () => void +} + +export function SingleUploadDialog({ + open, + onOpenChange, + submission, + onUploadComplete +}: SingleUploadDialogProps) { + const [files, setFiles] = useState<File[]>([]) + const [description, setDescription] = useState("") + const [isUploading, setIsUploading] = useState(false) + const fileInputRef = React.useRef<HTMLInputElement>(null) + + // 파일 선택 + const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const fileList = e.target.files + if (fileList) { + setFiles(Array.from(fileList)) + } + } + + // 파일 제거 + const removeFile = (index: number) => { + setFiles(prev => prev.filter((_, i) => i !== index)) + } + + // 업로드 처리 + const handleUpload = async () => { + if (files.length === 0) { + toast.error("Please select files to upload") + return + } + + setIsUploading(true) + + try { + const formData = new FormData() + + files.forEach((file) => { + formData.append("files", file) + }) + + formData.append("documentId", submission.documentId.toString()) + formData.append("stageId", submission.stageId!.toString()) + formData.append("description", description) + + // 현재 리비전 + 1 + const nextRevision = (submission.latestRevisionNumber || 0) + 1 + formData.append("revision", nextRevision.toString()) + + const response = await fetch("/api/stage-submissions/upload", { + method: "POST", + body: formData, + }) + + if (!response.ok) { + throw new Error("Upload failed") + } + + const result = await response.json() + toast.success(`Successfully uploaded ${files.length} file(s)`) + + // 초기화 및 닫기 + setFiles([]) + setDescription("") + onOpenChange(false) + onUploadComplete?.() + + } catch (error) { + console.error("Upload error:", error) + toast.error("Failed to upload files") + } finally { + setIsUploading(false) + } + } + + const formatFileSize = (bytes: number) => { + if (bytes === 0) return '0 Bytes' + const k = 1024 + const sizes = ['Bytes', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] + } + + const totalSize = files.reduce((acc, file) => acc + file.size, 0) + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-2xl"> + <DialogHeader> + <DialogTitle>Upload Documents</DialogTitle> + <DialogDescription> + Upload documents for this stage submission + </DialogDescription> + </DialogHeader> + + {/* Document Info */} + <div className="grid gap-2 p-4 bg-muted rounded-lg"> + <div className="flex items-center gap-2"> + <span className="text-sm font-medium">Document:</span> + <span className="text-sm">{submission.docNumber}</span> + {submission.vendorDocNumber && ( + <span className="text-sm text-muted-foreground"> + ({submission.vendorDocNumber}) + </span> + )} + </div> + <div className="flex items-center gap-2"> + <span className="text-sm font-medium">Stage:</span> + <Badge variant="secondary">{submission.stageName}</Badge> + </div> + <div className="flex items-center gap-2"> + <span className="text-sm font-medium">Current Revision:</span> + <span className="text-sm">Rev. {submission.latestRevisionNumber || 0}</span> + <Badge variant="outline" className="ml-2"> + Next: Rev. {(submission.latestRevisionNumber || 0) + 1} + </Badge> + </div> + </div> + + {/* File Upload Area */} + <div + className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-gray-400 transition-colors cursor-pointer" + onClick={() => fileInputRef.current?.click()} + > + <input + ref={fileInputRef} + type="file" + multiple + className="hidden" + onChange={handleFileChange} + accept="*/*" + /> + <Upload className="mx-auto h-10 w-10 text-gray-400 mb-3" /> + <p className="text-sm font-medium">Click to browse files</p> + <p className="text-xs text-gray-500 mt-1"> + You can select multiple files + </p> + </div> + + {/* File List */} + {files.length > 0 && ( + <> + <FileList> + {files.map((file, index) => ( + <FileListItem key={index}> + <FileListIcon> + <FileIcon className="h-4 w-4 text-muted-foreground" /> + </FileListIcon> + <FileListInfo> + <FileListName>{file.name}</FileListName> + </FileListInfo> + <FileListSize> + {file.size} + </FileListSize> + <FileListAction> + <Button + variant="ghost" + size="icon" + className="h-8 w-8" + onClick={(e) => { + e.stopPropagation() + removeFile(index) + }} + disabled={isUploading} + > + <X className="h-4 w-4" /> + </Button> + </FileListAction> + </FileListItem> + ))} + </FileList> + + <div className="flex justify-between text-sm text-muted-foreground"> + <span>{files.length} file(s) selected</span> + <span>Total: {formatFileSize(totalSize)}</span> + </div> + </> + )} + + {/* Description */} + <div className="space-y-2"> + <Label htmlFor="description">Description (Optional)</Label> + <Textarea + id="description" + placeholder="Add a description for this submission..." + value={description} + onChange={(e) => setDescription(e.target.value)} + rows={3} + /> + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isUploading} + > + Cancel + </Button> + <Button + onClick={handleUpload} + disabled={files.length === 0 || isUploading} + className="gap-2" + > + {isUploading ? ( + <> + <Loader2 className="h-4 w-4 animate-spin" /> + Uploading... + </> + ) : ( + <> + <Upload className="h-4 w-4" /> + Upload + </> + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/upload/components/view-submission-dialog.tsx b/lib/vendor-document-list/plant/upload/components/view-submission-dialog.tsx new file mode 100644 index 00000000..9a55a7fa --- /dev/null +++ b/lib/vendor-document-list/plant/upload/components/view-submission-dialog.tsx @@ -0,0 +1,520 @@ +// lib/vendor-document-list/plant/upload/components/view-submission-dialog.tsx +"use client" + +import * as React from "react" +import { useState, useEffect, useRef } from "react" +import { WebViewerInstance } from "@pdftron/webviewer" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { + Download, + Eye, + FileText, + Calendar, + User, + CheckCircle2, + XCircle, + Clock, + RefreshCw, + Loader2 +} from "lucide-react" +import { StageSubmissionView } from "@/db/schema" +import { formatDateTime, formatDate } from "@/lib/utils" +import { toast } from "sonner" +import { downloadFile, formatFileSize } from "@/lib/file-download" + +interface ViewSubmissionDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + submission: StageSubmissionView +} + +interface SubmissionDetail { + id: number + revisionNumber: number + submissionStatus: string + reviewStatus?: string + reviewComments?: string + submittedBy: string + submittedAt: Date + files: Array<{ + id: number + originalFileName: string + fileSize: number + uploadedAt: Date + syncStatus: string + storageUrl: string + }> +} + +// PDFTron 문서 뷰어 컴포넌트 +const DocumentViewer: React.FC<{ + open: boolean + onClose: () => void + files: Array<{ + id: number + originalFileName: string + storageUrl: string + }> +}> = ({ open, onClose, files }) => { + const [instance, setInstance] = useState<null | WebViewerInstance>(null) + const [viewerLoading, setViewerLoading] = useState<boolean>(true) + const [fileSetLoading, setFileSetLoading] = useState<boolean>(true) + const viewer = useRef<HTMLDivElement>(null) + const initialized = useRef(false) + const isCancelled = useRef(false) + + const cleanupHtmlStyle = () => { + const htmlElement = document.documentElement + const originalStyle = htmlElement.getAttribute("style") || "" + const colorSchemeStyle = originalStyle + .split(";") + .map((s) => s.trim()) + .find((s) => s.startsWith("color-scheme:")) + + if (colorSchemeStyle) { + htmlElement.setAttribute("style", colorSchemeStyle + ";") + } else { + htmlElement.removeAttribute("style") + } + } + + useEffect(() => { + if (open && !initialized.current) { + initialized.current = true + isCancelled.current = false + + requestAnimationFrame(() => { + if (viewer.current) { + import("@pdftron/webviewer").then(({ default: WebViewer }) => { + if (isCancelled.current) { + console.log("WebViewer 초기화 취소됨") + return + } + + WebViewer( + { + path: "/pdftronWeb", + licenseKey: + "demo:1739264618684:616161d7030000000091db1c97c6f386d41d3506ab5b507381ef2ee2bd", + fullAPI: true, + css: "/globals.css", + }, + viewer.current as HTMLDivElement + ).then(async (instance: WebViewerInstance) => { + setInstance(instance) + instance.UI.enableFeatures([instance.UI.Feature.MultiTab]) + instance.UI.disableElements([ + "addTabButton", + "multiTabsEmptyPage", + ]) + setViewerLoading(false) + }) + }) + } + }) + } + + return () => { + if (instance) { + instance.UI.dispose() + } + setTimeout(() => cleanupHtmlStyle(), 500) + } + }, [open]) + + useEffect(() => { + const loadDocuments = async () => { + if (instance && files.length > 0) { + const { UI } = instance + const tabIds = [] + + for (const file of files) { + const fileExtension = file.originalFileName.split('.').pop()?.toLowerCase() + + const options = { + filename: file.originalFileName, + ...(fileExtension === 'xlsx' || fileExtension === 'xls' ? { + officeOptions: { + formatOptions: { + applyPageBreaksToSheet: true, + }, + }, + } : {}), + } + + try { + const response = await fetch(file.storageUrl) + const blob = await response.blob() + const tab = await UI.TabManager.addTab(blob, options) + tabIds.push(tab) + } catch (error) { + console.error(`Failed to load ${file.originalFileName}:`, error) + toast.error(`Failed to load ${file.originalFileName}`) + } + } + + if (tabIds.length > 0) { + await UI.TabManager.setActiveTab(tabIds[0]) + } + + setFileSetLoading(false) + } + } + + loadDocuments() + }, [instance, files]) + + const handleClose = async () => { + if (!fileSetLoading) { + if (instance) { + try { + await instance.UI.dispose() + setInstance(null) + } catch (e) { + console.warn("dispose error", e) + } + } + + setTimeout(() => cleanupHtmlStyle(), 1000) + onClose() + } + } + + return ( + <Dialog open={open} onOpenChange={(val) => !val && handleClose()}> + <DialogContent className="w-[90vw] h-[90vh]" style={{ maxWidth: "none" }}> + <DialogHeader className="h-[38px]"> + <DialogTitle>Preview</DialogTitle> + {/* <DialogDescription>첨부파일 미리보기</DialogDescription> */} + </DialogHeader> + <div + ref={viewer} + style={{ height: "calc(90vh - 20px - 38px - 1rem - 48px)" }} + > + {viewerLoading && ( + <div className="flex flex-col items-center justify-center py-12"> + <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" /> + <p className="text-sm text-muted-foreground"> + 문서 뷰어 로딩 중... + </p> + </div> + )} + </div> + </DialogContent> + </Dialog> + ) +} + +export function ViewSubmissionDialog({ + open, + onOpenChange, + submission +}: ViewSubmissionDialogProps) { + const [loading, setLoading] = useState(false) + const [submissionDetail, setSubmissionDetail] = useState<SubmissionDetail | null>(null) + const [downloadingFiles, setDownloadingFiles] = useState<Set<number>>(new Set()) + const [viewerOpen, setViewerOpen] = useState(false) + const [selectedFiles, setSelectedFiles] = useState<Array<{ + id: number + originalFileName: string + storageUrl: string + }>>([]) + + useEffect(() => { + if (open && submission.latestSubmissionId) { + fetchSubmissionDetail() + } + }, [open, submission.latestSubmissionId]) + + const fetchSubmissionDetail = async () => { + if (!submission.latestSubmissionId) return + + setLoading(true) + try { + const response = await fetch(`/api/stage-submissions/${submission.latestSubmissionId}`) + if (response.ok) { + const data = await response.json() + setSubmissionDetail(data) + } + } catch (error) { + console.error("Failed to fetch submission details:", error) + toast.error("Failed to load submission details") + } finally { + setLoading(false) + } + } + + const handleDownload = async (file: any) => { + setDownloadingFiles(prev => new Set(prev).add(file.id)) + + try { + const result = await downloadFile( + file.storageUrl, + file.originalFileName, + { + action: 'download', + showToast: true, + showSuccessToast: true, + onError: (error) => { + console.error("Download failed:", error) + toast.error(`Failed to download ${file.originalFileName}`) + }, + onSuccess: (fileName, fileSize) => { + console.log(`Successfully downloaded ${fileName}`) + } + } + ) + + if (!result.success) { + console.error("Download failed:", result.error) + } + } finally { + setDownloadingFiles(prev => { + const newSet = new Set(prev) + newSet.delete(file.id) + return newSet + }) + } + } + + // PDFTron으로 미리보기 처리 + const handlePreview = (file: any) => { + setSelectedFiles([{ + id: file.id, + originalFileName: file.originalFileName, + storageUrl: file.storageUrl + }]) + setViewerOpen(true) + } + + // 모든 파일 미리보기 + const handlePreviewAll = () => { + if (submissionDetail) { + const files = submissionDetail.files.map(file => ({ + id: file.id, + originalFileName: file.originalFileName, + storageUrl: file.storageUrl + })) + setSelectedFiles(files) + setViewerOpen(true) + } + } + + const getStatusBadge = (status?: string) => { + if (!status) return null + + const variant = status === "APPROVED" ? "success" : + status === "REJECTED" ? "destructive" : + status === "SUBMITTED" ? "default" : "secondary" + + return <Badge variant={variant}>{status}</Badge> + } + + return ( + <> + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-4xl max-h-[80vh]"> + <DialogHeader> + <DialogTitle>View Submission</DialogTitle> + <DialogDescription> + Submission details and attached files + </DialogDescription> + </DialogHeader> + + {loading ? ( + <div className="flex items-center justify-center py-8"> + <RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" /> + </div> + ) : submissionDetail ? ( + <Tabs defaultValue="details" className="w-full"> + <TabsList className="grid w-full grid-cols-2"> + <TabsTrigger value="details">Details</TabsTrigger> + <TabsTrigger value="files"> + Files ({submissionDetail.files.length}) + </TabsTrigger> + </TabsList> + + <TabsContent value="details" className="space-y-4"> + <div className="grid gap-4"> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-1"> + <p className="text-sm font-medium text-muted-foreground"> + Revision + </p> + <p className="text-lg font-medium"> + Rev. {submissionDetail.revisionNumber} + </p> + </div> + <div className="space-y-1"> + <p className="text-sm font-medium text-muted-foreground"> + Status + </p> + <div className="flex items-center gap-2"> + {getStatusBadge(submissionDetail.submissionStatus)} + {submissionDetail.reviewStatus && + getStatusBadge(submissionDetail.reviewStatus)} + </div> + </div> + </div> + + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-1"> + <p className="text-sm font-medium text-muted-foreground"> + Submitted By + </p> + <div className="flex items-center gap-2"> + <User className="h-4 w-4 text-muted-foreground" /> + <span>{submissionDetail.submittedBy}</span> + </div> + </div> + <div className="space-y-1"> + <p className="text-sm font-medium text-muted-foreground"> + Submitted At + </p> + <div className="flex items-center gap-2"> + <Calendar className="h-4 w-4 text-muted-foreground" /> + <span>{formatDateTime(submissionDetail.submittedAt)}</span> + </div> + </div> + </div> + + {submissionDetail.reviewComments && ( + <div className="space-y-1"> + <p className="text-sm font-medium text-muted-foreground"> + Review Comments + </p> + <div className="p-3 bg-muted rounded-lg"> + <p className="text-sm">{submissionDetail.reviewComments}</p> + </div> + </div> + )} + </div> + </TabsContent> + + <TabsContent value="files"> + <div className="flex justify-end mb-4"> + <Button + variant="outline" + size="sm" + onClick={handlePreviewAll} + disabled={submissionDetail.files.length === 0} + > + <Eye className="h-4 w-4 mr-2" /> + 모든 파일 미리보기 + </Button> + </div> + <ScrollArea className="h-[400px]"> + <Table> + <TableHeader> + <TableRow> + <TableHead>File Name</TableHead> + <TableHead>Size</TableHead> + <TableHead>Upload Date</TableHead> + <TableHead>Sync Status</TableHead> + <TableHead className="text-right">Actions</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {submissionDetail.files.map((file) => { + const isDownloading = downloadingFiles.has(file.id) + + return ( + <TableRow key={file.id}> + <TableCell className="font-medium"> + <div className="flex items-center gap-2"> + <FileText className="h-4 w-4 text-muted-foreground" /> + {file.originalFileName} + </div> + </TableCell> + <TableCell>{formatFileSize(file.fileSize)}</TableCell> + <TableCell>{formatDate(file.uploadedAt)}</TableCell> + <TableCell> + <Badge + variant={ + file.syncStatus === "synced" ? "success" : + file.syncStatus === "failed" ? "destructive" : + "secondary" + } + className="text-xs" + > + {file.syncStatus} + </Badge> + </TableCell> + <TableCell className="text-right"> + <div className="flex justify-end gap-2"> + <Button + variant="ghost" + size="icon" + onClick={() => handleDownload(file)} + disabled={isDownloading} + title="Download" + > + {isDownloading ? ( + <RefreshCw className="h-4 w-4 animate-spin" /> + ) : ( + <Download className="h-4 w-4" /> + )} + </Button> + <Button + variant="ghost" + size="icon" + onClick={() => handlePreview(file)} + disabled={isDownloading} + title="Preview" + > + {isDownloading ? ( + <RefreshCw className="h-4 w-4 animate-spin" /> + ) : ( + <Eye className="h-4 w-4" /> + )} + </Button> + </div> + </TableCell> + </TableRow> + ) + })} + </TableBody> + </Table> + </ScrollArea> + </TabsContent> + </Tabs> + ) : ( + <div className="text-center py-8 text-muted-foreground"> + No submission found + </div> + )} + </DialogContent> + </Dialog> + + {/* PDFTron 문서 뷰어 다이얼로그 */} + {viewerOpen && ( + <DocumentViewer + open={viewerOpen} + onClose={() => { + setViewerOpen(false) + setSelectedFiles([]) + }} + files={selectedFiles} + /> + )} + </> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/upload/service.ts b/lib/vendor-document-list/plant/upload/service.ts new file mode 100644 index 00000000..18e6c132 --- /dev/null +++ b/lib/vendor-document-list/plant/upload/service.ts @@ -0,0 +1,228 @@ +import db from "@/db/db" +import { stageSubmissionView, StageSubmissionView } from "@/db/schema" +import { and, asc, desc, eq, or, ilike, isTrue, sql, isNotNull, count } from "drizzle-orm" +import { filterColumns } from "@/lib/filter-columns" +import { GetStageSubmissionsSchema } from "./validation" +import { getServerSession } from 'next-auth/next' +import { authOptions } from '@/app/api/auth/[...nextauth]/route' +import { redirect } from "next/navigation" + +// Repository functions (동일) +async function selectStageSubmissions( + tx: typeof db, + params: { + where?: any + orderBy?: any + offset?: number + limit?: number + } +) { + const { where, orderBy = [desc(stageSubmissionView.isOverdue)], offset = 0, limit = 10 } = params + + const query = tx + .select() + .from(stageSubmissionView) + .$dynamic() + + if (where) query.where(where) + if (orderBy) query.orderBy(...(Array.isArray(orderBy) ? orderBy : [orderBy])) + query.limit(limit).offset(offset) + + return await query +} + +async function countStageSubmissions(tx: typeof db, where?: any) { + const query = tx + .select({ count: count() }) + .from(stageSubmissionView) + .$dynamic() + + if (where) query.where(where) + + const result = await query + return result[0]?.count ?? 0 +} + +// Service function with session check +export async function getStageSubmissions(input: GetStageSubmissionsSchema) { + // Session 체크 + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return { + success: false, + error: '로그인이 필요합니다.' + } + } + const vendorId = session.user.companyId // companyId가 vendorId + + try { + const offset = (input.page - 1) * input.perPage + + // Advanced filters + const advancedWhere = filterColumns({ + table: stageSubmissionView, + filters: input.filters, + joinOperator: input.joinOperator, + }) + + // Global search + let globalWhere + if (input.search) { + const s = `%${input.search}%` + globalWhere = or( + ilike(stageSubmissionView.documentTitle, s), + ilike(stageSubmissionView.docNumber, s), + ilike(stageSubmissionView.vendorDocNumber, s), + ilike(stageSubmissionView.stageName, s) + // vendorName 검색 제거 (자기 회사만 보므로) + ) + } + + // Status filters + let statusWhere + if (input.submissionStatus && input.submissionStatus !== "all") { + switch (input.submissionStatus) { + case "required": + statusWhere = eq(stageSubmissionView.requiresSubmission, true) + break + case "submitted": + statusWhere = eq(stageSubmissionView.latestSubmissionStatus, "SUBMITTED") + break + case "approved": + statusWhere = eq(stageSubmissionView.latestReviewStatus, "APPROVED") + break + case "rejected": + statusWhere = eq(stageSubmissionView.latestReviewStatus, "REJECTED") + break + } + } + + // Sync status filter + let syncWhere + if (input.syncStatus && input.syncStatus !== "all") { + if (input.syncStatus === "pending") { + syncWhere = or( + eq(stageSubmissionView.latestSyncStatus, "pending"), + eq(stageSubmissionView.requiresSync, true) + ) + } else { + syncWhere = eq(stageSubmissionView.latestSyncStatus, input.syncStatus) + } + } + + // Project filter + let projectWhere = input.projectId ? eq(stageSubmissionView.projectId, input.projectId) : undefined + + // ✅ 벤더 필터 - session의 companyId 사용 + const vendorWhere = eq(stageSubmissionView.vendorId, vendorId) + + const finalWhere = and( + vendorWhere, // 항상 벤더 필터 적용 + advancedWhere, + globalWhere, + statusWhere, + syncWhere, + projectWhere + ) + + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc + ? desc(stageSubmissionView[item.id]) + : asc(stageSubmissionView[item.id]) + ) + : [desc(stageSubmissionView.isOverdue), asc(stageSubmissionView.daysUntilDue)] + + // Transaction + const { data, total } = await db.transaction(async (tx) => { + const data = await selectStageSubmissions(tx, { + where: finalWhere, + orderBy, + offset, + limit: input.perPage, + }) + const total = await countStageSubmissions(tx, finalWhere) + return { data, total } + }) + + const pageCount = Math.ceil(total / input.perPage) + + return { data, pageCount } + } catch (err) { + console.error("Error fetching stage submissions:", err) + return { data: [], pageCount: 0 } + } +} + +// 프로젝트 목록 조회 - 벤더 필터 적용 +export async function getProjects() { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return { + success: false, + error: '로그인이 필요합니다.' + } + } + if (!session?.user?.companyId) { + return [] + } + + const vendorId = session.user.companyId + + const projects = await db + .selectDistinct({ + id: stageSubmissionView.projectId, + code: stageSubmissionView.projectCode, + }) + .from(stageSubmissionView) + .where( + and( + eq(stageSubmissionView.vendorId, vendorId), + isNotNull(stageSubmissionView.projectId) + ) + ) + .orderBy(asc(stageSubmissionView.projectCode)) + + return projects +} + +// 통계 조회 - 벤더별 +export async function getSubmissionStats() { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return { + success: false, + error: '로그인이 필요합니다.' + } + } + + + if (!session?.user?.companyId) { + return { + pending: 0, + overdue: 0, + awaitingSync: 0, + completed: 0, + } + } + + const vendorId = session.user.companyId + + const stats = await db + .select({ + pending: sql<number>`count(*) filter (where ${stageSubmissionView.requiresSubmission} = true)::int`, + overdue: sql<number>`count(*) filter (where ${stageSubmissionView.isOverdue} = true)::int`, + awaitingSync: sql<number>`count(*) filter (where ${stageSubmissionView.requiresSync} = true)::int`, + completed: sql<number>`count(*) filter (where ${stageSubmissionView.latestReviewStatus} = 'APPROVED')::int`, + }) + .from(stageSubmissionView) + .where(eq(stageSubmissionView.vendorId, vendorId)) + + return stats[0] || { + pending: 0, + overdue: 0, + awaitingSync: 0, + completed: 0, + } +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/upload/table.tsx b/lib/vendor-document-list/plant/upload/table.tsx new file mode 100644 index 00000000..92507900 --- /dev/null +++ b/lib/vendor-document-list/plant/upload/table.tsx @@ -0,0 +1,223 @@ +// lib/vendor-document-list/plant/upload/table.tsx +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { getColumns } from "./columns" +import { getStageSubmissions } from "./service" +import { StageSubmissionView } from "@/db/schema" +import { StageSubmissionToolbarActions } from "./toolbar-actions" +import { useRouter, useSearchParams, usePathname } from "next/navigation" +import { ProjectFilter } from "./components/project-filter" +import { SingleUploadDialog } from "./components/single-upload-dialog" +import { HistoryDialog } from "./components/history-dialog" +import { ViewSubmissionDialog } from "./components/view-submission-dialog" + +interface StageSubmissionsTableProps { + promises: Promise<[ + Awaited<ReturnType<typeof getStageSubmissions>>, + { projects: Array<{ id: number; code: string }> } + ]> + selectedProjectId?: number | null +} + +export function StageSubmissionsTable({ promises, selectedProjectId }: StageSubmissionsTableProps) { + const [{ data, pageCount }, { projects }] = React.use(promises) + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + + const [rowAction, setRowAction] = React.useState<DataTableRowAction<StageSubmissionView> | null>(null) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + // 프로젝트 필터 핸들러 + const handleProjectChange = (projectId: number | null) => { + const current = new URLSearchParams(Array.from(searchParams.entries())) + + if (projectId) { + current.set("projectId", projectId.toString()) + } else { + current.delete("projectId") + } + + // 페이지를 1로 리셋 + current.set("page", "1") + + const search = current.toString() + const query = search ? `?${search}` : "" + + router.push(`${pathname}${query}`) + } + + // Filter fields - 프로젝트 필터 제거 + const filterFields: DataTableFilterField<StageSubmissionView>[] = [ + { + id: "stageStatus", + label: "Stage Status", + options: [ + { label: "Planned", value: "PLANNED" }, + { label: "In Progress", value: "IN_PROGRESS" }, + { label: "Submitted", value: "SUBMITTED" }, + { label: "Approved", value: "APPROVED" }, + { label: "Rejected", value: "REJECTED" }, + { label: "Completed", value: "COMPLETED" }, + ] + }, + { + id: "latestSubmissionStatus", + label: "Submission Status", + options: [ + { label: "Submitted", value: "SUBMITTED" }, + { label: "Under Review", value: "UNDER_REVIEW" }, + { label: "Draft", value: "DRAFT" }, + { label: "Withdrawn", value: "WITHDRAWN" }, + ] + }, + { + id: "requiresSubmission", + label: "Requires Submission", + options: [ + { label: "Yes", value: "true" }, + { label: "No", value: "false" }, + ] + }, + { + id: "requiresSync", + label: "Requires Sync", + options: [ + { label: "Yes", value: "true" }, + { label: "No", value: "false" }, + ] + }, + { + id: "isOverdue", + label: "Overdue", + options: [ + { label: "Yes", value: "true" }, + { label: "No", value: "false" }, + ] + } + ] + + const advancedFilterFields: DataTableAdvancedFilterField<StageSubmissionView>[] = [ + { + id: "docNumber", + label: "Doc Number", + type: "text", + }, + { + id: "documentTitle", + label: "Document Title", + type: "text", + }, + { + id: "stageName", + label: "Stage Name", + type: "text", + }, + { + id: "stagePlanDate", + label: "Due Date", + type: "date", + }, + { + id: "daysUntilDue", + label: "Days Until Due", + type: "number", + }, + ] + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [ + { id: "isOverdue", desc: true }, + { id: "daysUntilDue", desc: false } + ], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => `${originalRow.documentId}-${originalRow.stageId}`, + shallow: false, + clearOnDefault: true, + columnResizeMode: "onEnd", + }) + + return ( + <> + <DataTable table={table}> + {/* 프로젝트 필터를 툴바 위에 배치 */} + <div className="flex items-center justify-between pb-3"> + <ProjectFilter + projects={projects} + value={selectedProjectId} + onValueChange={handleProjectChange} + /> + <div className="text-sm text-muted-foreground"> + {data.length} record(s) found + </div> + </div> + + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <StageSubmissionToolbarActions + table={table} + rowAction={rowAction} + setRowAction={setRowAction} + /> + </DataTableAdvancedToolbar> + </DataTable> + + {/* Upload Dialog */} + {rowAction?.type === "upload" && ( + <SingleUploadDialog + open={true} + onOpenChange={(open) => !open && setRowAction(null)} + submission={rowAction.row.original} + onUploadComplete={() => { + setRowAction(null) + // 테이블 새로고침 + window.location.reload() + }} + /> + )} + + {/* View Submission Dialog */} + {rowAction?.type === "view" && ( + <ViewSubmissionDialog + open={true} + onOpenChange={(open) => !open && setRowAction(null)} + submission={rowAction.row.original} + /> + )} + + {/* History Dialog */} + {rowAction?.type === "history" && ( + <HistoryDialog + open={true} + onOpenChange={(open) => !open && setRowAction(null)} + submission={rowAction.row.original} + /> + )} + </> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/upload/toolbar-actions.tsx b/lib/vendor-document-list/plant/upload/toolbar-actions.tsx new file mode 100644 index 00000000..072fd72d --- /dev/null +++ b/lib/vendor-document-list/plant/upload/toolbar-actions.tsx @@ -0,0 +1,242 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, RefreshCw, Upload, Send, AlertCircle } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { StageSubmissionView } from "@/db/schema" +import { DataTableRowAction } from "@/types/table" +import { MultiUploadDialog } from "./components/multi-upload-dialog" +import { useRouter, useSearchParams } from "next/navigation" + +interface StageSubmissionToolbarActionsProps { + table: Table<StageSubmissionView> + rowAction: DataTableRowAction<StageSubmissionView> | null + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<StageSubmissionView> | null>> +} + +export function StageSubmissionToolbarActions({ + table, + rowAction, + setRowAction +}: StageSubmissionToolbarActionsProps) { + const selectedRows = table.getFilteredSelectedRowModel().rows + const router = useRouter() + const searchParams = useSearchParams() + + const projectId = searchParams.get('projectId') + + + const [isSyncing, setIsSyncing] = React.useState(false) + const [showSyncDialog, setShowSyncDialog] = React.useState(false) + const [syncTargets, setSyncTargets] = React.useState<typeof selectedRows>([]) + + const handleUploadComplete = () => { + // Refresh table + router.refresh() + } + + const handleSyncClick = () => { + const rowsRequiringSync = selectedRows.filter( + row => row.original.requiresSync && row.original.latestSubmissionId + ) + setSyncTargets(rowsRequiringSync) + setShowSyncDialog(true) + } + + const handleSyncConfirm = async () => { + setShowSyncDialog(false) + setIsSyncing(true) + + try { + // Extract submission IDs + const submissionIds = syncTargets + .map(row => row.original.latestSubmissionId) + .filter((id): id is number => id !== null) + + if (submissionIds.length === 0) { + toast.error("No submissions to sync.") + return + } + + // API call + const response = await fetch('/api/stage-submissions/sync', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ submissionIds }), + }) + + const result = await response.json() + + if (result.success) { + toast.success(result.message) + + // Display detailed information for successful items + if (result.results?.details) { + const successCount = result.results.details.filter((d: any) => d.success).length + const failedCount = result.results.details.filter((d: any) => !d.success).length + + if (failedCount > 0) { + toast.warning(`${successCount} succeeded, ${failedCount} failed`) + } + } + + // Refresh table + router.refresh() + table.toggleAllPageRowsSelected(false) // Deselect all + } else { + toast.error(result.error || "Sync failed") + } + } catch (error) { + console.error("Sync error:", error) + toast.error("An error occurred during synchronization.") + } finally { + setIsSyncing(false) + } + } + + return ( + <> + <div className="flex items-center gap-2"> + {projectId && ( + <MultiUploadDialog + projectId={parseInt(projectId)} + // projectCode={projectCode} + onUploadComplete={handleUploadComplete} + /> + )} + {selectedRows.length > 0 && ( + <> + {/* Bulk Upload for selected rows that require submission */} + {selectedRows.some(row => row.original.requiresSubmission) && ( + <Button + variant="outline" + size="sm" + onClick={() => { + // Filter selected rows that require submission + const rowsRequiringSubmission = selectedRows.filter( + row => row.original.requiresSubmission + ) + // Open bulk upload dialog + console.log("Bulk upload for:", rowsRequiringSubmission) + }} + className="gap-2" + > + <Upload className="size-4" /> + <span>Upload ({selectedRows.filter(r => r.original.requiresSubmission).length})</span> + </Button> + )} + + {/* Bulk Sync for selected rows that need syncing */} + {selectedRows.some(row => row.original.requiresSync && row.original.latestSubmissionId) && ( + <Button + variant="outline" + size="sm" + onClick={handleSyncClick} + disabled={isSyncing} + className="gap-2" + > + {isSyncing ? ( + <> + <RefreshCw className="size-4 animate-spin" /> + <span>Syncing...</span> + </> + ) : ( + <> + <RefreshCw className="size-4" /> + <span>Sync ({selectedRows.filter(r => r.original.requiresSync && r.original.latestSubmissionId).length})</span> + </> + )} + </Button> + )} + </> + )} + + {/* Export Button */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: `stage-submissions-${new Date().toISOString().split('T')[0]}`, + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" /> + <span className="hidden sm:inline">Export</span> + </Button> + </div> + + {/* Sync Confirmation Dialog */} + <AlertDialog open={showSyncDialog} onOpenChange={setShowSyncDialog}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle className="flex items-center gap-2"> + <RefreshCw className="size-5" /> + Sync to Buyer System + </AlertDialogTitle> + <AlertDialogDescription className="space-y-3"> + <div> + Are you sure you want to sync {syncTargets.length} selected submission(s) to the buyer system? + </div> + <div className="space-y-2 rounded-lg bg-muted p-3"> + <div className="text-sm font-medium">Items to sync:</div> + <ul className="text-sm space-y-1"> + {syncTargets.slice(0, 3).map((row, idx) => ( + <li key={idx} className="flex items-center gap-2"> + <span className="text-muted-foreground">•</span> + <span>{row.original.docNumber}</span> + <span className="text-muted-foreground">-</span> + <span>{row.original.stageName}</span> + <span className="text-muted-foreground"> + (Rev.{row.original.latestRevisionNumber}) + </span> + </li> + ))} + {syncTargets.length > 3 && ( + <li className="text-muted-foreground"> + ... and {syncTargets.length - 3} more + </li> + )} + </ul> + </div> + <div className="flex items-start gap-2 text-sm text-amber-600"> + <AlertCircle className="size-4 mt-0.5 shrink-0" /> + <div> + Synchronized files will be sent to the SHI Buyer System and + cannot be recalled after transmission. + </div> + </div> + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>Cancel</AlertDialogCancel> + <AlertDialogAction + onClick={handleSyncConfirm} + // className="bg-samsung hover:bg-samsung/90" + > + Start Sync + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + </> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/upload/util/filie-parser.ts b/lib/vendor-document-list/plant/upload/util/filie-parser.ts new file mode 100644 index 00000000..42dac9b4 --- /dev/null +++ b/lib/vendor-document-list/plant/upload/util/filie-parser.ts @@ -0,0 +1,132 @@ +// lib/vendor-document-list/plant/upload/utils/file-parser.ts + +export interface ParsedFileName { + docNumber: string + stageName: string + revision: string + extension: string + originalName: string + isValid: boolean + error?: string +} + +export function parseFileName(fileName: string): ParsedFileName { + try { + // 확장자 분리 + const lastDotIndex = fileName.lastIndexOf('.') + if (lastDotIndex === -1) { + return { + docNumber: '', + stageName: '', + revision: '', + extension: '', + originalName: fileName, + isValid: false, + error: 'No file extension found' + } + } + + const extension = fileName.substring(lastDotIndex + 1) + const nameWithoutExt = fileName.substring(0, lastDotIndex) + + // 언더스코어로 분리 (최소 3개 부분 필요) + const parts = nameWithoutExt.split('_') + + if (parts.length < 3) { + return { + docNumber: '', + stageName: '', + revision: '', + extension, + originalName: fileName, + isValid: false, + error: `Invalid format. Expected: DocNumber_StageName_Revision.${extension}` + } + } + + // 파싱 결과 + const docNumber = parts[0] + const stageName = parts.slice(1, -1).join('_') // 중간 부분이 여러 개일 수 있음 + const revision = parts[parts.length - 1] // 마지막 부분이 리비전 + + // 기본 검증 + if (!docNumber || !stageName || !revision) { + return { + docNumber: '', + stageName: '', + revision: '', + extension, + originalName: fileName, + isValid: false, + error: 'Missing required parts' + } + } + + return { + docNumber, + stageName, + revision, + extension, + originalName: fileName, + isValid: true + } + } catch (error) { + return { + docNumber: '', + stageName: '', + revision: '', + extension: '', + originalName: fileName, + isValid: false, + error: 'Failed to parse filename' + } + } +} + +// 리비전 번호 추출 (숫자 우선, 없으면 문자를 숫자로 변환) +export function extractRevisionNumber(revision: string): number { + const cleanRevision = revision.toLowerCase().replace(/[^a-z0-9]/g, '') + + // Rev0, Rev1 형식 + const revMatch = cleanRevision.match(/rev(\d+)/) + if (revMatch) return parseInt(revMatch[1]) + + // R0, R1 형식 + const rMatch = cleanRevision.match(/r(\d+)/) + if (rMatch) return parseInt(rMatch[1]) + + // v1, v2 형식 + const vMatch = cleanRevision.match(/v(\d+)/) + if (vMatch) return parseInt(vMatch[1]) + + // 단순 숫자 + const numMatch = cleanRevision.match(/^(\d+)$/) + if (numMatch) return parseInt(numMatch[1]) + + // RevA, RevB 또는 A, B 형식 -> 숫자로 변환 (A=1, B=2, etc.) + const alphaMatch = cleanRevision.match(/^(?:rev)?([a-z])$/i) + if (alphaMatch) { + return alphaMatch[1].toUpperCase().charCodeAt(0) - 64 // A=1, B=2, C=3... + } + + // 기본값 + return 0 +} + +// 리비전 코드 정규화 (DB 저장용) +export function normalizeRevisionCode(revision: string): string { + // Rev0 -> 0, RevA -> A, v1 -> 1 등으로 정규화 + const cleanRevision = revision.toLowerCase() + + // Rev 제거 + if (cleanRevision.startsWith('rev')) { + return revision.substring(3) + } + + // R, v 제거 + if (cleanRevision.startsWith('r') || cleanRevision.startsWith('v')) { + return revision.substring(1) + } + + return revision +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/upload/validation.ts b/lib/vendor-document-list/plant/upload/validation.ts new file mode 100644 index 00000000..80a7d390 --- /dev/null +++ b/lib/vendor-document-list/plant/upload/validation.ts @@ -0,0 +1,35 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, + } from "nuqs/server" + import * as z from "zod" + + import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" + import { StageSubmissionView } from "@/db/schema" + + export const searchParamsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(20), + sort: getSortingStateParser<StageSubmissionView>().withDefault([ + { id: "isOverdue", desc: true }, + { id: "daysUntilDue", desc: false }, + ]), + + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), + + // 프로젝트 필터만 유지 + projectId: parseAsInteger, + syncStatus: parseAsStringEnum(["all", "pending", "syncing", "synced", "failed", "partial"]).withDefault("all"), + submissionStatus: parseAsStringEnum(["all", "required", "submitted", "approved", "rejected"]).withDefault("all"), + }) + + export type GetStageSubmissionsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>
\ No newline at end of file |
