diff options
Diffstat (limited to 'lib/tbe-last')
| -rw-r--r-- | lib/tbe-last/service.ts | 247 | ||||
| -rw-r--r-- | lib/tbe-last/table/tbe-last-table-columns.tsx | 376 | ||||
| -rw-r--r-- | lib/tbe-last/table/tbe-last-table.tsx | 419 | ||||
| -rw-r--r-- | lib/tbe-last/validations.ts | 37 |
4 files changed, 1079 insertions, 0 deletions
diff --git a/lib/tbe-last/service.ts b/lib/tbe-last/service.ts new file mode 100644 index 00000000..760f66ac --- /dev/null +++ b/lib/tbe-last/service.ts @@ -0,0 +1,247 @@ +// lib/tbe-last/service.ts +'use server' + +import { unstable_cache } from "next/cache"; +import db from "@/db/db"; +import { and, desc, asc, eq, sql, or, isNull, isNotNull, ne, inArray } from "drizzle-orm"; +import { tbeLastView, tbeDocumentsView } from "@/db/schema"; +import { rfqPrItems } from "@/db/schema/rfqLast"; +import { rfqLastTbeDocumentReviews, rfqLastTbePdftronComments, rfqLastTbeVendorDocuments } from "@/db/schema"; +import { filterColumns } from "@/lib/filter-columns"; +import { GetTBELastSchema } from "./validations"; + +// ========================================== +// 1. TBE 세션 목록 조회 +// ========================================== +export async function getAllTBELast(input: GetTBELastSchema) { + return unstable_cache( + async () => { + // 페이징 + const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10); + const limit = input.perPage ?? 10; + + // 고급 필터 + const advancedWhere = filterColumns({ + table: tbeLastView, + filters: input.filters ?? [], + joinOperator: input.joinOperator ?? "and", + }); + + // 글로벌 검색 + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + sql`${tbeLastView.sessionCode} ILIKE ${s}`, + sql`${tbeLastView.rfqCode} ILIKE ${s}`, + sql`${tbeLastView.vendorName} ILIKE ${s}`, + sql`${tbeLastView.vendorCode} ILIKE ${s}`, + sql`${tbeLastView.projectCode} ILIKE ${s}`, + sql`${tbeLastView.projectName} ILIKE ${s}`, + sql`${tbeLastView.packageNo} ILIKE ${s}`, + sql`${tbeLastView.packageName} ILIKE ${s}` + ); + } + + // 최종 WHERE + const finalWhere = and(advancedWhere, globalWhere); + + // 정렬 + const orderBy = input.sort?.length + ? input.sort.map((s) => { + const col = (tbeLastView as any)[s.id]; + return s.desc ? desc(col) : asc(col); + }) + : [desc(tbeLastView.createdAt)]; + + // 메인 SELECT + const [rows, total] = await db.transaction(async (tx) => { + const data = await tx + .select() + .from(tbeLastView) + .where(finalWhere) + .orderBy(...orderBy) + .offset(offset) + .limit(limit); + + const [{ count }] = await tx + .select({ count: sql<number>`count(*)`.as("count") }) + .from(tbeLastView) + .where(finalWhere); + + return [data, Number(count)]; + }); + + const pageCount = Math.ceil(total / limit); + return { data: rows, pageCount }; + }, + [JSON.stringify(input)], + { + revalidate: 60, + tags: ["tbe-last-sessions"], + } + )(); +} + +// ========================================== +// 2. TBE 세션 상세 조회 +// ========================================== +export async function getTBESessionDetail(sessionId: number) { + return unstable_cache( + async () => { + // 세션 기본 정보 + const [session] = await db + .select() + .from(tbeLastView) + .where(eq(tbeLastView.tbeSessionId, sessionId)) + .limit(1); + + if (!session) { + return null; + } + + // PR 아이템 목록 + const prItems = await db + .select() + .from(rfqPrItems) + .where(eq(rfqPrItems.rfqsLastId, session.rfqId)) + .orderBy(desc(rfqPrItems.majorYn), asc(rfqPrItems.prItem)); + + // 문서 목록 (구매자 + 벤더) + const documents = await db + .select() + .from(tbeDocumentsView) + .where(eq(tbeDocumentsView.tbeSessionId, sessionId)) + .orderBy( + sql`CASE document_source WHEN 'buyer' THEN 0 ELSE 1 END`, + asc(tbeDocumentsView.documentName) + ); + + // PDFTron 코멘트 통계 + const comments = await db + .select({ + documentReviewId: rfqLastTbePdftronComments.documentReviewId, + totalCount: sql<number>`count(*)`.as("total_count"), + openCount: sql<number>`sum(case when status = 'open' then 1 else 0 end)`.as("open_count"), + }) + .from(rfqLastTbePdftronComments) + .innerJoin( + rfqLastTbeDocumentReviews, + eq(rfqLastTbePdftronComments.documentReviewId, rfqLastTbeDocumentReviews.id) + ) + .where(eq(rfqLastTbeDocumentReviews.tbeSessionId, sessionId)) + .groupBy(rfqLastTbePdftronComments.documentReviewId); + + // 문서별 코멘트 수 매핑 + const commentsByDocumentId = new Map( + comments.map(c => [c.documentReviewId, { + totalCount: c.totalCount, + openCount: c.openCount + }]) + ); + + // 문서에 코멘트 정보 추가 + const documentsWithComments = documents.map(doc => ({ + ...doc, + comments: doc.documentReviewId + ? commentsByDocumentId.get(doc.documentReviewId) || { totalCount: 0, openCount: 0 } + : { totalCount: 0, openCount: 0 } + })); + + return { + session, + prItems, + documents: documentsWithComments, + }; + }, + [`tbe-session-${sessionId}`], + { + revalidate: 60, + tags: [`tbe-session-${sessionId}`], + } + )(); +} + +// ========================================== +// 3. 문서별 PDFTron 코멘트 조회 +// ========================================== +export async function getDocumentComments(documentReviewId: number) { + const comments = await db + .select({ + id: rfqLastTbePdftronComments.id, + pdftronAnnotationId: rfqLastTbePdftronComments.pdftronAnnotationId, + pageNumber: rfqLastTbePdftronComments.pageNumber, + commentText: rfqLastTbePdftronComments.commentText, + commentCategory: rfqLastTbePdftronComments.commentCategory, + severity: rfqLastTbePdftronComments.severity, + status: rfqLastTbePdftronComments.status, + createdBy: rfqLastTbePdftronComments.createdBy, + createdByType: rfqLastTbePdftronComments.createdByType, + createdAt: rfqLastTbePdftronComments.createdAt, + resolvedBy: rfqLastTbePdftronComments.resolvedBy, + resolvedAt: rfqLastTbePdftronComments.resolvedAt, + resolutionNote: rfqLastTbePdftronComments.resolutionNote, + replies: rfqLastTbePdftronComments.replies, + }) + .from(rfqLastTbePdftronComments) + .where(eq(rfqLastTbePdftronComments.documentReviewId, documentReviewId)) + .orderBy(asc(rfqLastTbePdftronComments.pageNumber), desc(rfqLastTbePdftronComments.createdAt)); + + return comments; +} + +// ========================================== +// 4. TBE 평가 결과 업데이트 +// ========================================== +export async function updateTBEEvaluation( + sessionId: number, + data: { + evaluationResult: "pass" | "conditional_pass" | "non_pass"; + conditionalRequirements?: string; + technicalSummary?: string; + commercialSummary?: string; + overallRemarks?: string; + } +) { + // 실제 업데이트 로직 + // await db.update(rfqLastTbeSessions)... + + // 캐시 무효화 + return { success: true }; +} + +// ========================================== +// 5. 벤더 문서 업로드 +// ========================================== +export async function uploadVendorDocument( + sessionId: number, + file: { + fileName: string; + originalFileName: string; + filePath: string; + fileSize: number; + fileType: string; + documentType: string; + description?: string; + } +) { + const [document] = await db + .insert(rfqLastTbeVendorDocuments) + .values({ + tbeSessionId: sessionId, + documentType: file.documentType as any, + fileName: file.fileName, + originalFileName: file.originalFileName, + filePath: file.filePath, + fileSize: file.fileSize, + fileType: file.fileType, + description: file.description, + reviewRequired: true, + reviewStatus: "pending", + submittedBy: 1, // TODO: 실제 사용자 ID + submittedAt: new Date(), + }) + .returning(); + + return document; +}
\ No newline at end of file diff --git a/lib/tbe-last/table/tbe-last-table-columns.tsx b/lib/tbe-last/table/tbe-last-table-columns.tsx new file mode 100644 index 00000000..71b3acde --- /dev/null +++ b/lib/tbe-last/table/tbe-last-table-columns.tsx @@ -0,0 +1,376 @@ +// lib/tbe-last/table/tbe-last-table-columns.tsx + +"use client" + +import * as React from "react" +import { type ColumnDef } from "@tanstack/react-table" +import { FileText, MessageSquare, Package, ListChecks } from "lucide-react" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { formatDate } from "@/lib/utils" +import { TbeLastView } from "@/db/schema/tbeLastView" + +interface GetColumnsProps { + onOpenSessionDetail: (sessionId: number) => void; + onOpenDocuments: (sessionId: number) => void; + onOpenPrItems: (rfqId: number) => void; + onOpenEvaluation: (session: TbeLastView) => void; +} + +export function getColumns({ + onOpenSessionDetail, + onOpenDocuments, + onOpenPrItems, + onOpenEvaluation, +}: GetColumnsProps): ColumnDef<TbeLastView>[] { + + const columns: ColumnDef<TbeLastView>[] = [ + // Select Column + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + }, + + // TBE Session Code + { + accessorKey: "sessionCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="TBE Code" /> + ), + cell: ({ row }) => { + const sessionId = row.original.tbeSessionId; + const sessionCode = row.original.sessionCode; + + return ( + <Button + variant="link" + className="p-0 h-auto font-medium" + onClick={() => onOpenSessionDetail(sessionId)} + > + {sessionCode} + </Button> + ); + }, + size: 120, + }, + + // RFQ Info Group + { + id: "rfqInfo", + header: "RFQ Information", + columns: [ + { + accessorKey: "rfqCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="RFQ Code" /> + ), + cell: ({ row }) => row.original.rfqCode, + size: 120, + }, + { + accessorKey: "rfqTitle", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="RFQ Title" /> + ), + cell: ({ row }) => row.original.rfqTitle || "-", + size: 200, + }, + { + accessorKey: "rfqDueDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Due Date" /> + ), + cell: ({ row }) => { + const date = row.original.rfqDueDate; + return date ? formatDate(date, "KR") : "-"; + }, + size: 100, + }, + ], + }, + + // Package Info + { + id: "packageInfo", + header: "Package", + columns: [ + { + accessorKey: "packageNo", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Package No" /> + ), + cell: ({ row }) => { + const packageNo = row.original.packageNo; + const packageName = row.original.packageName; + + if (!packageNo) return "-"; + + return ( + <div className="flex flex-col"> + <span className="font-medium">{packageNo}</span> + {packageName && ( + <span className="text-xs text-muted-foreground">{packageName}</span> + )} + </div> + ); + }, + size: 150, + }, + ], + }, + + // Project Info + { + accessorKey: "projectCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Project" /> + ), + cell: ({ row }) => { + const projectCode = row.original.projectCode; + const projectName = row.original.projectName; + + if (!projectCode) return "-"; + + return ( + <div className="flex flex-col"> + <span className="font-medium">{projectCode}</span> + {projectName && ( + <span className="text-xs text-muted-foreground">{projectName}</span> + )} + </div> + ); + }, + size: 150, + }, + + // Vendor Info + { + id: "vendorInfo", + header: "Vendor", + columns: [ + { + accessorKey: "vendorCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Vendor Code" /> + ), + cell: ({ row }) => row.original.vendorCode || "-", + size: 100, + }, + { + accessorKey: "vendorName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Vendor Name" /> + ), + cell: ({ row }) => row.original.vendorName, + size: 200, + }, + ], + }, + + // TBE Status + { + accessorKey: "sessionStatus", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Status" /> + ), + cell: ({ row }) => { + const status = row.original.sessionStatus; + + let variant: "default" | "secondary" | "outline" | "destructive" = "outline"; + + switch (status) { + case "준비중": + variant = "outline"; + break; + case "진행중": + variant = "default"; + break; + case "검토중": + variant = "secondary"; + break; + case "완료": + variant = "default"; + break; + case "보류": + variant = "destructive"; + break; + } + + return <Badge variant={variant}>{status}</Badge>; + }, + size: 100, + }, + + // Evaluation Result + { + accessorKey: "evaluationResult", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Result" /> + ), + cell: ({ row }) => { + const result = row.original.evaluationResult; + const session = row.original; + + if (!result) { + return ( + <Button + variant="outline" + size="sm" + onClick={() => onOpenEvaluation(session)} + > + 평가입력 + </Button> + ); + } + + let variant: "default" | "secondary" | "destructive" = "default"; + let displayText = result; + + switch (result) { + case "pass": + variant = "default"; + displayText = "Pass"; + break; + case "conditional_pass": + variant = "secondary"; + displayText = "Conditional"; + break; + case "non_pass": + variant = "destructive"; + displayText = "Non-Pass"; + break; + } + + return ( + <Button + variant="link" + className="p-0 h-auto" + onClick={() => onOpenEvaluation(session)} + > + <Badge variant={variant}>{displayText}</Badge> + </Button> + ); + }, + size: 120, + }, + + // PR Items + { + id: "prItems", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="PR Items" /> + ), + cell: ({ row }) => { + const rfqId = row.original.rfqId; + const totalCount = row.original.prItemsCount; + const majorCount = row.original.majorItemsCount; + + return ( + <Button + variant="ghost" + size="sm" + className="h-8 px-2" + onClick={() => onOpenPrItems(rfqId)} + > + <ListChecks className="h-4 w-4 mr-1" /> + <span className="text-xs"> + {totalCount} ({majorCount}) + </span> + </Button> + ); + }, + size: 100, + enableSorting: false, + }, + + // Documents + { + id: "documents", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Documents" /> + ), + cell: ({ row }) => { + const sessionId = row.original.tbeSessionId; + const buyerDocs = row.original.buyerDocumentsCount; + const vendorDocs = row.original.vendorDocumentsCount; + const reviewedDocs = row.original.reviewedDocumentsCount; + const totalDocs = buyerDocs + vendorDocs; + + return ( + <Button + variant="ghost" + size="sm" + className="h-8 px-2" + onClick={() => onOpenDocuments(sessionId)} + > + <FileText className="h-4 w-4 mr-1" /> + <span className="text-xs"> + {reviewedDocs}/{totalDocs} + </span> + </Button> + ); + }, + size: 100, + enableSorting: false, + }, + + // Comments + { + id: "comments", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Comments" /> + ), + cell: ({ row }) => { + const sessionId = row.original.tbeSessionId; + const totalComments = row.original.totalCommentsCount; + const unresolvedComments = row.original.unresolvedCommentsCount; + + return ( + <Button + variant="ghost" + size="sm" + className="h-8 px-2 relative" + onClick={() => onOpenDocuments(sessionId)} + > + <MessageSquare className="h-4 w-4" /> + {totalComments > 0 && ( + <Badge + variant={unresolvedComments > 0 ? "destructive" : "secondary"} + className="absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem]" + > + {unresolvedComments > 0 ? unresolvedComments : totalComments} + </Badge> + )} + </Button> + ); + }, + size: 80, + enableSorting: false, + }, + ]; + + return columns; +}
\ No newline at end of file diff --git a/lib/tbe-last/table/tbe-last-table.tsx b/lib/tbe-last/table/tbe-last-table.tsx new file mode 100644 index 00000000..64707e4e --- /dev/null +++ b/lib/tbe-last/table/tbe-last-table.tsx @@ -0,0 +1,419 @@ +// lib/tbe-last/table/tbe-last-table.tsx + +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import { type DataTableFilterField } 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 "./tbe-last-table-columns" +import { TbeLastView } from "@/db/schema" +import { getAllTBELast, getTBESessionDetail } from "@/lib/tbe-last/service" +import { Button } from "@/components/ui/button" +import { Download, RefreshCw } from "lucide-react" +import { exportTableToExcel } from "@/lib/export" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription +} from "@/components/ui/dialog" +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription +} from "@/components/ui/sheet" +import { Badge } from "@/components/ui/badge" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { ScrollArea } from "@/components/ui/scroll-area" +import { formatDate } from "@/lib/utils" + +interface TbeLastTableProps { + promises: Promise<[ + Awaited<ReturnType<typeof getAllTBELast>>, + ]> +} + +export function TbeLastTable({ promises }: TbeLastTableProps) { + const router = useRouter() + const [{ data, pageCount }] = React.use(promises) + + // Dialog states + const [sessionDetailOpen, setSessionDetailOpen] = React.useState(false) + const [documentsOpen, setDocumentsOpen] = React.useState(false) + const [prItemsOpen, setPrItemsOpen] = React.useState(false) + const [evaluationOpen, setEvaluationOpen] = React.useState(false) + + const [selectedSessionId, setSelectedSessionId] = React.useState<number | null>(null) + const [selectedRfqId, setSelectedRfqId] = React.useState<number | null>(null) + const [selectedSession, setSelectedSession] = React.useState<TbeLastView | null>(null) + const [sessionDetail, setSessionDetail] = React.useState<any>(null) + const [isLoadingDetail, setIsLoadingDetail] = React.useState(false) + + // Load session detail when needed + const loadSessionDetail = React.useCallback(async (sessionId: number) => { + setIsLoadingDetail(true) + try { + const detail = await getTBESessionDetail(sessionId) + setSessionDetail(detail) + } catch (error) { + console.error("Failed to load session detail:", error) + } finally { + setIsLoadingDetail(false) + } + }, []) + + // Handlers + const handleOpenSessionDetail = React.useCallback((sessionId: number) => { + setSelectedSessionId(sessionId) + setSessionDetailOpen(true) + loadSessionDetail(sessionId) + }, [loadSessionDetail]) + + const handleOpenDocuments = React.useCallback((sessionId: number) => { + setSelectedSessionId(sessionId) + setDocumentsOpen(true) + loadSessionDetail(sessionId) + }, [loadSessionDetail]) + + const handleOpenPrItems = React.useCallback((rfqId: number) => { + setSelectedRfqId(rfqId) + setPrItemsOpen(true) + loadSessionDetail(rfqId) + }, [loadSessionDetail]) + + const handleOpenEvaluation = React.useCallback((session: TbeLastView) => { + setSelectedSession(session) + setEvaluationOpen(true) + }, []) + + const handleRefresh = React.useCallback(() => { + router.refresh() + }, [router]) + + // Table columns + const columns = React.useMemo( + () => + getColumns({ + onOpenSessionDetail: handleOpenSessionDetail, + onOpenDocuments: handleOpenDocuments, + onOpenPrItems: handleOpenPrItems, + onOpenEvaluation: handleOpenEvaluation, + }), + [handleOpenSessionDetail, handleOpenDocuments, handleOpenPrItems, handleOpenEvaluation] + ) + + // Filter fields + const filterFields: DataTableFilterField<TbeLastView>[] = [ + { + id: "sessionStatus", + label: "Status", + type: "select", + options: [ + { label: "준비중", value: "준비중" }, + { label: "진행중", value: "진행중" }, + { label: "검토중", value: "검토중" }, + { label: "보류", value: "보류" }, + { label: "완료", value: "완료" }, + ], + }, + { + id: "evaluationResult", + label: "Result", + type: "select", + options: [ + { label: "Pass", value: "pass" }, + { label: "Conditional Pass", value: "conditional_pass" }, + { label: "Non-Pass", value: "non_pass" }, + ], + }, + ] + + // Data table + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["documents", "comments"] }, + }, + getRowId: (originalRow) => String(originalRow.tbeSessionId), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={filterFields} + shallow={false} + > + <div className="flex items-center gap-2"> + <Button + variant="outline" + size="sm" + onClick={handleRefresh} + className="gap-2" + > + <RefreshCw className="size-4" /> + <span>Refresh</span> + </Button> + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "tbe-sessions", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" /> + <span>Export</span> + </Button> + </div> + </DataTableAdvancedToolbar> + </DataTable> + + {/* Session Detail Dialog */} + <Dialog open={sessionDetailOpen} onOpenChange={setSessionDetailOpen}> + <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle>TBE Session Detail</DialogTitle> + <DialogDescription> + {sessionDetail?.session?.sessionCode} - {sessionDetail?.session?.vendorName} + </DialogDescription> + </DialogHeader> + {isLoadingDetail ? ( + <div className="p-8 text-center">Loading...</div> + ) : sessionDetail ? ( + <div className="space-y-4"> + {/* Session info */} + <div className="grid grid-cols-2 gap-4"> + <div> + <p className="text-sm font-medium">RFQ Code</p> + <p className="text-sm text-muted-foreground">{sessionDetail.session.rfqCode}</p> + </div> + <div> + <p className="text-sm font-medium">Status</p> + <Badge>{sessionDetail.session.sessionStatus}</Badge> + </div> + <div> + <p className="text-sm font-medium">Project</p> + <p className="text-sm text-muted-foreground"> + {sessionDetail.session.projectCode} - {sessionDetail.session.projectName} + </p> + </div> + <div> + <p className="text-sm font-medium">Package</p> + <p className="text-sm text-muted-foreground"> + {sessionDetail.session.packageNo} - {sessionDetail.session.packageName} + </p> + </div> + </div> + + {/* PR Items */} + {sessionDetail.prItems?.length > 0 && ( + <div> + <h3 className="font-medium mb-2">PR Items</h3> + <div className="border rounded-lg"> + <table className="w-full text-sm"> + <thead> + <tr className="border-b"> + <th className="text-left p-2">PR No</th> + <th className="text-left p-2">Material Code</th> + <th className="text-left p-2">Description</th> + <th className="text-left p-2">Qty</th> + <th className="text-left p-2">Delivery</th> + </tr> + </thead> + <tbody> + {sessionDetail.prItems.map((item: any) => ( + <tr key={item.id} className="border-b"> + <td className="p-2">{item.prNo}</td> + <td className="p-2">{item.materialCode}</td> + <td className="p-2">{item.materialDescription}</td> + <td className="p-2">{item.quantity} {item.uom}</td> + <td className="p-2"> + {item.deliveryDate ? formatDate(item.deliveryDate, "KR") : "-"} + </td> + </tr> + ))} + </tbody> + </table> + </div> + </div> + )} + </div> + ) : null} + </DialogContent> + </Dialog> + + {/* Documents Sheet */} + <Sheet open={documentsOpen} onOpenChange={setDocumentsOpen}> + <SheetContent className="w-[600px] sm:w-[800px]"> + <SheetHeader> + <SheetTitle>Documents & Comments</SheetTitle> + <SheetDescription> + Review documents and PDFTron comments + </SheetDescription> + </SheetHeader> + + {isLoadingDetail ? ( + <div className="p-8 text-center">Loading...</div> + ) : sessionDetail?.documents ? ( + <Tabs defaultValue="buyer" className="mt-4"> + <TabsList className="grid w-full grid-cols-2"> + <TabsTrigger value="buyer">Buyer Documents</TabsTrigger> + <TabsTrigger value="vendor">Vendor Documents</TabsTrigger> + </TabsList> + + <TabsContent value="buyer"> + <ScrollArea className="h-[calc(100vh-200px)]"> + <div className="space-y-2"> + {sessionDetail.documents + .filter((doc: any) => doc.documentSource === "buyer") + .map((doc: any) => ( + <div key={doc.documentId} className="border rounded-lg p-3"> + <div className="flex items-start justify-between"> + <div className="flex-1"> + <p className="font-medium text-sm">{doc.documentName}</p> + <p className="text-xs text-muted-foreground"> + Type: {doc.documentType} | Status: {doc.reviewStatus} + </p> + </div> + <div className="flex items-center gap-2"> + {doc.comments.totalCount > 0 && ( + <Badge variant={doc.comments.openCount > 0 ? "destructive" : "secondary"}> + {doc.comments.openCount}/{doc.comments.totalCount} comments + </Badge> + )} + <Button size="sm" variant="outline"> + View in PDFTron + </Button> + </div> + </div> + </div> + ))} + </div> + </ScrollArea> + </TabsContent> + + <TabsContent value="vendor"> + <ScrollArea className="h-[calc(100vh-200px)]"> + <div className="space-y-2"> + {sessionDetail.documents + .filter((doc: any) => doc.documentSource === "vendor") + .map((doc: any) => ( + <div key={doc.documentId} className="border rounded-lg p-3"> + <div className="flex items-start justify-between"> + <div className="flex-1"> + <p className="font-medium text-sm">{doc.documentName}</p> + <p className="text-xs text-muted-foreground"> + Type: {doc.documentType} | Status: {doc.reviewStatus} + </p> + {doc.submittedAt && ( + <p className="text-xs text-muted-foreground"> + Submitted: {formatDate(doc.submittedAt, "KR")} + </p> + )} + </div> + <div className="flex items-center gap-2"> + <Button size="sm" variant="outline"> + Download + </Button> + <Button size="sm" variant="outline"> + Review + </Button> + </div> + </div> + </div> + ))} + </div> + </ScrollArea> + </TabsContent> + </Tabs> + ) : null} + </SheetContent> + </Sheet> + + {/* PR Items Dialog */} + <Dialog open={prItemsOpen} onOpenChange={setPrItemsOpen}> + <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle>PR Items</DialogTitle> + <DialogDescription> + Purchase Request items for this RFQ + </DialogDescription> + </DialogHeader> + {sessionDetail?.prItems && ( + <div className="border rounded-lg"> + <table className="w-full text-sm"> + <thead> + <tr className="border-b bg-muted/50"> + <th className="text-left p-2">PR No</th> + <th className="text-left p-2">Material Code</th> + <th className="text-left p-2">Description</th> + <th className="text-left p-2">Size</th> + <th className="text-left p-2">Qty</th> + <th className="text-left p-2">Unit</th> + <th className="text-left p-2">Delivery</th> + <th className="text-left p-2">Major</th> + </tr> + </thead> + <tbody> + {sessionDetail.prItems.map((item: any) => ( + <tr key={item.id} className="border-b hover:bg-muted/20"> + <td className="p-2">{item.prNo}</td> + <td className="p-2">{item.materialCode}</td> + <td className="p-2">{item.materialDescription}</td> + <td className="p-2">{item.size || "-"}</td> + <td className="p-2 text-right">{item.quantity}</td> + <td className="p-2">{item.uom}</td> + <td className="p-2"> + {item.deliveryDate ? formatDate(item.deliveryDate, "KR") : "-"} + </td> + <td className="p-2 text-center"> + {item.majorYn && <Badge variant="default">Major</Badge>} + </td> + </tr> + ))} + </tbody> + </table> + </div> + )} + </DialogContent> + </Dialog> + + {/* Evaluation Dialog */} + <Dialog open={evaluationOpen} onOpenChange={setEvaluationOpen}> + <DialogContent> + <DialogHeader> + <DialogTitle>TBE Evaluation</DialogTitle> + <DialogDescription> + Enter evaluation result for {selectedSession?.sessionCode} + </DialogDescription> + </DialogHeader> + <div className="space-y-4 mt-4"> + {/* Evaluation form would go here */} + <p className="text-sm text-muted-foreground"> + Evaluation form to be implemented... + </p> + </div> + </DialogContent> + </Dialog> + </> + ) +}
\ No newline at end of file diff --git a/lib/tbe-last/validations.ts b/lib/tbe-last/validations.ts new file mode 100644 index 00000000..2f2cdd69 --- /dev/null +++ b/lib/tbe-last/validations.ts @@ -0,0 +1,37 @@ +import { createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum,parseAsBoolean +} from "nuqs/server" +import * as z from "zod" + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { TbeSession } from "@/db/schema"; + + + +export const searchParamsTBELastCache = createSearchParamsCache({ + // 1) 공통 플래그 + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + + // 2) 페이지네이션 + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // 3) 정렬 (Rfq 테이블) + // getSortingStateParser<Rfq>() → Rfq 테이블의 컬럼명에 맞춘 유효성 검사 + sort: getSortingStateParser<TbeSession>().withDefault([ + { id: "createdAt", desc: true }, + ]), + + + // 6) 고급 필터 (nuqs - filterColumns) + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 7) 글로벌 검색어 + search: parseAsString.withDefault(""), +}) +export type GetTBELastSchema = Awaited<ReturnType<typeof searchParamsTBELastCache.parse>>; + |
