diff options
Diffstat (limited to 'app')
| -rw-r--r-- | app/[lng]/evcp/(evcp)/po-rfq/page.tsx | 86 | ||||
| -rw-r--r-- | app/[lng]/partners/(partners)/rfq-all/[id]/page.tsx | 80 | ||||
| -rw-r--r-- | app/[lng]/partners/(partners)/rfq-all/page.tsx | 171 | ||||
| -rw-r--r-- | app/api/procurement-rfqs/[rfqId]/vendors/[vendorId]/comments/route.ts | 145 | ||||
| -rw-r--r-- | app/api/table-presets/[id]/route.ts | 57 | ||||
| -rw-r--r-- | app/api/table-presets/route.ts | 98 |
6 files changed, 637 insertions, 0 deletions
diff --git a/app/[lng]/evcp/(evcp)/po-rfq/page.tsx b/app/[lng]/evcp/(evcp)/po-rfq/page.tsx new file mode 100644 index 00000000..dfaa7708 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/po-rfq/page.tsx @@ -0,0 +1,86 @@ +import { Suspense } from "react" +import { getPORfqs } from "@/lib/procurement-rfqs/services" +import { searchParamsCache } from "@/lib/procurement-rfqs/validations" +import { Shell } from "@/components/shell" +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" +import RFQContainer from "@/components/po-rfq/po-rfq-container" + +interface RfqPageProps { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; + title?: string; + description?: string; +} + +export default async function RfqPage({ + searchParams, + title = "발주용 견적", + description = "SAP으로부터 전송된 발주용 견적을 관리할 수 있습니다.", +}: RfqPageProps) { + // searchParams를 await하여 resolve + const resolvedSearchParams = await searchParams; + + // 서버 액션: RFQ 데이터 가져오기 + async function fetchRfqData(params: any) { + "use server" + + try { + // URL 파라미터를 추출하고 필요한 형식으로 변환 + const parsedParams = searchParamsCache.parse(params); + + // RFQ 데이터 가져오기 + const data = await getPORfqs(parsedParams) + + return data + } catch (error) { + console.error("RFQ 데이터 조회 오류:", error) + // 에러 발생 시 빈 결과 반환 + return { data: [], pageCount: 0, total: 0 } + } + } + + // 현재 resolvedSearchParams를 파싱하여 초기 데이터 로드 + const initialParams = { + page: resolvedSearchParams.page?.toString() || "1", + perPage: resolvedSearchParams.perPage?.toString() || "10", + sort: resolvedSearchParams.sort?.toString() || JSON.stringify([{ id: "updatedAt", desc: true }]), + filters: resolvedSearchParams.filters?.toString() || null, + joinOperator: resolvedSearchParams.joinOperator?.toString() || "and", + basicFilters: resolvedSearchParams.basicFilters?.toString() || null, + basicJoinOperator: resolvedSearchParams.basicJoinOperator?.toString() || "and", + search: resolvedSearchParams.search?.toString() || "", + } + + // 초기 데이터 로드 + const initialData = await fetchRfqData(initialParams) + + return ( + <Shell className="gap-2"> + <div className="flex items-center justify-between space-y-2"> + <div className="flex items-center justify-between space-y-2"> + <div> + <h2 className="text-2xl font-bold tracking-tight"> + {title} + </h2> + </div> + </div> + </div> + + <Suspense + fallback={ + <DataTableSkeleton + columnCount={6} + searchableColumnCount={1} + filterableColumnCount={2} + cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} + shrinkZero + /> + } + > + <RFQContainer + initialData={initialData} + fetchData={fetchRfqData} + /> + </Suspense> + </Shell> + ) +}
\ No newline at end of file diff --git a/app/[lng]/partners/(partners)/rfq-all/[id]/page.tsx b/app/[lng]/partners/(partners)/rfq-all/[id]/page.tsx new file mode 100644 index 00000000..c8858704 --- /dev/null +++ b/app/[lng]/partners/(partners)/rfq-all/[id]/page.tsx @@ -0,0 +1,80 @@ +// app/vendor/quotations/[id]/page.tsx - 견적 응답 페이지 +import { Metadata } from "next" +import { notFound } from "next/navigation" +import db from "@/db/db"; +import { eq } from "drizzle-orm" +import { procurementVendorQuotations } from "@/db/schema" +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import VendorQuotationEditor from "@/lib/procurement-rfqs/vendor-response/quotation-editor"; + + +interface PageProps { + params: { + id: string + } +} + +export async function generateMetadata({ params }: PageProps): Promise<Metadata> { + return { + title: "견적서 응답", + description: "RFQ에 대한 견적서 작성 및 제출", + } +} + +export default async function VendorQuotationPage({ params }: PageProps) { + const quotationId = parseInt(params.id) + + if (isNaN(quotationId)) { + notFound() + } + + // 인증 확인 + const session = await getServerSession(authOptions); + + if (!session?.user) { + return ( + <div className="flex h-full items-center justify-center"> + <div className="text-center"> + <h2 className="text-xl font-bold">로그인이 필요합니다</h2> + <p className="mt-2 text-muted-foreground">견적서 응답을 위해 로그인해주세요.</p> + </div> + </div> + ) + } + + // 견적서 정보 가져오기 + const quotation = await db.query.procurementVendorQuotations.findFirst({ + where: eq(procurementVendorQuotations.id, quotationId), + with: { + rfq: true, // 관계 설정 필요 + vendor: true, // 관계 설정 필요 + items: true, // 관계 설정 필요 + } + }) + + if (!quotation) { + notFound() + } + + // 벤더 권한 확인 (필요한 경우) + const isAuthorized = session.user.domain === "partners" && + session.user.companyId === quotation.vendorId + + if (!isAuthorized) { + return ( + <div className="flex h-full items-center justify-center"> + <div className="text-center"> + <h2 className="text-xl font-bold">접근 권한이 없습니다</h2> + <p className="mt-2 text-muted-foreground">이 견적서에 대한 권한이 없습니다.</p> + </div> + </div> + ) + } + + return ( + <div className="container py-8"> + <VendorQuotationEditor quotation={quotation} /> + </div> + ) +}
\ No newline at end of file diff --git a/app/[lng]/partners/(partners)/rfq-all/page.tsx b/app/[lng]/partners/(partners)/rfq-all/page.tsx new file mode 100644 index 00000000..e7dccb02 --- /dev/null +++ b/app/[lng]/partners/(partners)/rfq-all/page.tsx @@ -0,0 +1,171 @@ +// app/vendor/quotations/page.tsx +import * as React from "react"; +import Link from "next/link"; +import { Metadata } from "next"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { LogIn } from "lucide-react"; +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; +import { Shell } from "@/components/shell"; +import { getValidFilters } from "@/lib/data-table"; +import { type SearchParams } from "@/types/table"; +import { searchParamsVendorRfqCache } from "@/lib/procurement-rfqs/validations"; +import { getQuotationStatusCounts, getVendorQuotations } from "@/lib/procurement-rfqs/services"; +import { VendorQuotationsTable } from "@/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table"; + +export const metadata: Metadata = { + title: "견적 목록", + description: "진행 중인 견적서 목록", +}; + +interface IndexPageProps { + searchParams: Promise<SearchParams> +} + + +export default async function IndexPage(props: IndexPageProps) { + const searchParams = await props.searchParams + const search = searchParamsVendorRfqCache.parse(searchParams) + const validFilters = getValidFilters(search.filters) + // 인증 확인 + const session = await getServerSession(authOptions); + + // 로그인 확인 + if (!session || !session.user) { + return ( + <Shell className="gap-6"> + <div className="flex items-center justify-between"> + <div> + <h2 className="text-2xl font-bold tracking-tight"> + 견적 목록 + </h2> + <p className="text-muted-foreground"> + 진행 중인 견적서 목록을 확인하고 관리합니다. + </p> + </div> + </div> + + <div className="flex flex-col items-center justify-center py-12 text-center"> + <div className="rounded-lg border border-dashed p-10 shadow-sm"> + <h3 className="mb-2 text-xl font-semibold">로그인이 필요합니다</h3> + <p className="mb-6 text-muted-foreground"> + 견적서를 확인하려면 먼저 로그인하세요. + </p> + <Button size="lg" asChild> + <Link href="/partners?callbackUrl=/vendor/quotations"> + <LogIn className="mr-2 h-4 w-4" /> + 로그인하기 + </Link> + </Button> + </div> + </div> + </Shell> + ); + } + + // 벤더 ID 확인 + const vendorId = session.user.companyId ? String(session.user.companyId) : "0"; + + // 벤더 권한 확인 + if (session.user.domain !== "partners") { + return ( + <Shell className="gap-6"> + <div className="flex items-center justify-between"> + <div> + <h2 className="text-2xl font-bold tracking-tight"> + 접근 권한 없음 + </h2> + </div> + </div> + <div className="flex flex-col items-center justify-center py-12 text-center"> + <div className="rounded-lg border border-dashed p-10 shadow-sm"> + <h3 className="mb-2 text-xl font-semibold">벤더 계정이 필요합니다</h3> + <p className="mb-6 text-muted-foreground"> + 벤더 계정으로 로그인해주세요. + </p> + </div> + </div> + </Shell> + ); + } + + // 데이터 가져오기 + const quotationsPromise = getVendorQuotations({ + ...search, + filters: validFilters + }, vendorId); + + // 상태별 개수 가져오기 + const statusCountsPromise = getQuotationStatusCounts(vendorId); + + // 모든 프로미스 병렬 실행 + const promises = Promise.all([quotationsPromise]); + const statusCounts = await statusCountsPromise; + + return ( + <Shell className="gap-6"> + <div className="flex justify-between items-center"> + <div> + <h2 className="text-2xl font-bold tracking-tight">견적 목록</h2> + <p className="text-muted-foreground"> + 진행 중인 견적서 목록을 확인하고 관리합니다. + </p> + </div> + </div> + + <div className="grid gap-4 md:grid-cols-4"> + <Card> + <CardHeader className="py-4"> + <CardTitle className="text-base">전체 견적</CardTitle> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold"> + {Object.values(statusCounts).reduce((sum, count) => sum + count, 0)}건 + </div> + </CardContent> + </Card> + <Card> + <CardHeader className="py-4"> + <CardTitle className="text-base">작성 중</CardTitle> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold">{statusCounts.Draft || 0}건</div> + </CardContent> + </Card> + <Card> + <CardHeader className="py-4"> + <CardTitle className="text-base">제출됨</CardTitle> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold"> + {(statusCounts.Submitted || 0) + (statusCounts.Revised || 0)}건 + </div> + </CardContent> + </Card> + <Card> + <CardHeader className="py-4"> + <CardTitle className="text-base">승인됨</CardTitle> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold">{statusCounts.Accepted || 0}건</div> + </CardContent> + </Card> + </div> + + <React.Suspense + fallback={ + <DataTableSkeleton + columnCount={7} + searchableColumnCount={2} + filterableColumnCount={3} + cellWidths={["10rem", "10rem", "8rem", "10rem", "10rem", "10rem", "8rem"]} + /> + } + > + <VendorQuotationsTable promises={promises} /> + </React.Suspense> + </Shell> + ); +}
\ No newline at end of file diff --git a/app/api/procurement-rfqs/[rfqId]/vendors/[vendorId]/comments/route.ts b/app/api/procurement-rfqs/[rfqId]/vendors/[vendorId]/comments/route.ts new file mode 100644 index 00000000..51430118 --- /dev/null +++ b/app/api/procurement-rfqs/[rfqId]/vendors/[vendorId]/comments/route.ts @@ -0,0 +1,145 @@ +// app/api/procurement-rfqs/[rfqId]/vendors/[vendorId]/comments/route.ts +import { NextRequest, NextResponse } from "next/server" + +import db from '@/db/db'; +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" + +import { procurementRfqComments, procurementRfqAttachments } from "@/db/schema" +import { revalidateTag } from "next/cache" + +// 파일 저장을 위한 유틸리티 +import { writeFile, mkdir } from 'fs/promises' +import { join } from 'path' +import crypto from 'crypto' + +/** + * 코멘트 생성 API 엔드포인트 + */ +export async function POST( + request: NextRequest, + { params }: { params: { rfqId: string; vendorId: string } } +) { + try { + // 인증 확인 + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json( + { success: false, message: "인증이 필요합니다" }, + { status: 401 } + ) + } + + const rfqId = parseInt(params.rfqId) + const vendorId = parseInt(params.vendorId) + + // 유효성 검사 + if (isNaN(rfqId) || isNaN(vendorId)) { + return NextResponse.json( + { success: false, message: "유효하지 않은 매개변수입니다" }, + { status: 400 } + ) + } + + // FormData 파싱 + const formData = await request.formData() + const content = formData.get("content") as string + const isVendorComment = formData.get("isVendorComment") === "true" + const files = formData.getAll("attachments") as File[] + + if (!content && files.length === 0) { + return NextResponse.json( + { success: false, message: "내용이나 첨부파일이 필요합니다" }, + { status: 400 } + ) + } + + // 코멘트 생성 + const [comment] = await db + .insert(procurementRfqComments) + .values({ + rfqId, + vendorId, + userId: parseInt(session.user.id), + content, + isVendorComment, + isRead: !isVendorComment, // 본인 메시지는 읽음 처리 + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning() + + // 첨부파일 처리 + const attachments = [] + if (files.length > 0) { + // 디렉토리 생성 + const uploadDir = join(process.cwd(), "public", `rfq-${rfqId}`, `vendor-${vendorId}`, `comment-${comment.id}`) + await mkdir(uploadDir, { recursive: true }) + + // 각 파일 저장 + for (const file of files) { + const buffer = Buffer.from(await file.arrayBuffer()) + const filename = `${Date.now()}-${crypto.randomBytes(8).toString("hex")}-${file.name.replace(/[^a-zA-Z0-9.-]/g, "_")}` + const filePath = join(uploadDir, filename) + + // 파일 쓰기 + await writeFile(filePath, buffer) + + // DB에 첨부파일 정보 저장 + const [attachment] = await db + .insert(procurementRfqAttachments) + .values({ + rfqId, + commentId: comment.id, + fileName: file.name, + fileSize: file.size, + fileType: file.type, + filePath: `/rfq-${rfqId}/vendor-${vendorId}/comment-${comment.id}/${filename}`, + isVendorUpload: isVendorComment, + uploadedBy: parseInt(session.user.id), + vendorId, + uploadedAt: new Date(), + }) + .returning() + + attachments.push({ + id: attachment.id, + fileName: attachment.fileName, + fileSize: attachment.fileSize, + fileType: attachment.fileType, + filePath: attachment.filePath, + uploadedAt: attachment.uploadedAt + }) + } + } + + // 캐시 무효화 + revalidateTag(`rfq-${rfqId}-comments`) + + // 응답 데이터 구성 + const responseData = { + id: comment.id, + rfqId: comment.rfqId, + vendorId: comment.vendorId, + userId: comment.userId, + content: comment.content, + isVendorComment: comment.isVendorComment, + createdAt: comment.createdAt, + updatedAt: comment.updatedAt, + userName: session.user.name, + attachments, + isRead: comment.isRead + } + + return NextResponse.json({ + success: true, + data: { comment: responseData } + }) + } catch (error) { + console.error("코멘트 생성 오류:", error) + return NextResponse.json( + { success: false, message: "코멘트 생성 중 오류가 발생했습니다" }, + { status: 500 } + ) + } +}
\ No newline at end of file diff --git a/app/api/table-presets/[id]/route.ts b/app/api/table-presets/[id]/route.ts new file mode 100644 index 00000000..8146889d --- /dev/null +++ b/app/api/table-presets/[id]/route.ts @@ -0,0 +1,57 @@ +// app/api/table-presets/[id]/route.ts +import { NextRequest, NextResponse } from "next/server" +import { getServerSession } from "next-auth" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import db from "@/db/db" +import { tablePresets } from "@/db/schema/setting" +import { eq } from "drizzle-orm" + +export async function PUT( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const presetId = params.id + const body = await request.json() + + const updatedPreset = await db + .update(tablePresets) + .set({ + ...body, + updatedAt: new Date(), + }) + .where(eq(tablePresets.id, presetId)) + .returning() + + return NextResponse.json(updatedPreset[0]) + } catch (error) { + console.error("Error updating preset:", error) + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }) + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const presetId = params.id + + await db.delete(tablePresets).where(eq(tablePresets.id, presetId)) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("Error deleting preset:", error) + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }) + } +}
\ No newline at end of file diff --git a/app/api/table-presets/route.ts b/app/api/table-presets/route.ts new file mode 100644 index 00000000..f276b469 --- /dev/null +++ b/app/api/table-presets/route.ts @@ -0,0 +1,98 @@ +// app/api/table-presets/route.ts +import { NextRequest, NextResponse } from "next/server" +import { getServerSession } from "next-auth" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import db from "@/db/db" +import { eq, and, or } from "drizzle-orm" +import { tablePresets } from "@/db/schema/setting" + +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const tableId = searchParams.get("tableId") + + if (!tableId) { + return NextResponse.json({ error: "tableId is required" }, { status: 400 }) + } + + // 사용자의 프리셋 + 공유된 프리셋 조회 + const presets = await db + .select() + .from(tablePresets) + .where( + and( + eq(tablePresets.tableId, tableId), + or( + eq(tablePresets.userId, session.user.id), + eq(tablePresets.isShared, true) + ) + ) + ) + .orderBy(tablePresets.createdAt) + + return NextResponse.json(presets) + } catch (error) { + console.error("Error fetching presets:", error) + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }) + } +} + +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const body = await request.json() + const { tableId, name, settings, isDefault } = body + + // 기본 프리셋으로 설정하는 경우 기존 기본 프리셋 해제 + if (isDefault) { + await db + .update(tablePresets) + .set({ isDefault: false }) + .where( + and( + eq(tablePresets.userId, session.user.id), + eq(tablePresets.tableId, tableId) + ) + ) + } + + const newPreset = await db + .insert(tablePresets) + .values({ + userId: session.user.id, + tableId, + name, + settings, + isDefault: Boolean(isDefault), + isActive: true, + createdBy: session.user.id, + }) + .returning() + + // 다른 프리셋들의 active 상태 해제 + await db + .update(tablePresets) + .set({ isActive: false }) + .where( + and( + eq(tablePresets.userId, session.user.id), + eq(tablePresets.tableId, tableId), + eq(tablePresets.id, newPreset[0].id) + ) + ) + + return NextResponse.json(newPreset[0]) + } catch (error) { + console.error("Error creating preset:", error) + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }) + } +}
\ No newline at end of file |
