diff options
Diffstat (limited to 'app')
| -rw-r--r-- | app/[lng]/evcp/(evcp)/esg-check-list/page.tsx | 74 | ||||
| -rw-r--r-- | app/[lng]/evcp/(evcp)/evaluation-target-list/page.tsx | 172 | ||||
| -rw-r--r-- | app/[lng]/evcp/(evcp)/incoterms/page.tsx | 53 | ||||
| -rw-r--r-- | app/[lng]/evcp/(evcp)/payment-conditions/page.tsx | 53 | ||||
| -rw-r--r-- | app/[lng]/evcp/(evcp)/vendor-check-list/page.tsx | 74 | ||||
| -rw-r--r-- | app/[lng]/partners/(partners)/evaluation/page.tsx | 135 | ||||
| -rw-r--r-- | app/[lng]/partners/(partners)/rfq-ship/[id]/page.tsx | 2 | ||||
| -rw-r--r-- | app/api/vendor-evaluation/delete-attachment/[id]/route.ts | 87 | ||||
| -rw-r--r-- | app/api/vendor-evaluation/upload-attachment/route.ts | 106 |
9 files changed, 755 insertions, 1 deletions
diff --git a/app/[lng]/evcp/(evcp)/esg-check-list/page.tsx b/app/[lng]/evcp/(evcp)/esg-check-list/page.tsx new file mode 100644 index 00000000..515751d5 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/esg-check-list/page.tsx @@ -0,0 +1,74 @@ +import * as React from "react" +import { type SearchParams } from "@/types/table" + +import { getValidFilters } from "@/lib/data-table" +import { Skeleton } from "@/components/ui/skeleton" +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" +import { Shell } from "@/components/shell" +import { getEsgEvaluations } from "@/lib/esg-check-list/service" +import { getEsgEvaluationsSchema } from "@/lib/esg-check-list/validation" +import { EsgEvaluationsTable } from "@/lib/esg-check-list/table/esg-table" + + +interface IndexPageProps { + searchParams: Promise<SearchParams> +} + +export default async function IndexPage(props: IndexPageProps) { + const searchParams = await props.searchParams + const search = getEsgEvaluationsSchema.parse(searchParams) + + const validFilters = getValidFilters(search.filters) + + const promises = Promise.all([ + getEsgEvaluations({ + ...search, + filters: validFilters, + }), + + ]) + + 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"> + ESG 자가진단표 + </h2> + <p className="text-muted-foreground"> + 협력업체 평가에 사용되는 ESG 자가진단표를 관리{" "} + {/* <span className="inline-flex items-center whitespace-nowrap"> + <Ellipsis className="size-3" /> + <span className="ml-1">버튼</span> + </span> + 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. */} + </p> + </div> + </div> + </div> + + <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> + {/* <DateRangePicker + triggerSize="sm" + triggerClassName="ml-auto w-56 sm:w-60" + align="end" + shallow={false} + /> */} + </React.Suspense> + <React.Suspense + fallback={ + <DataTableSkeleton + columnCount={6} + searchableColumnCount={1} + filterableColumnCount={2} + cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} + shrinkZero + /> + } + > + <EsgEvaluationsTable promises={promises} /> + </React.Suspense> + </Shell> + ) +} diff --git a/app/[lng]/evcp/(evcp)/evaluation-target-list/page.tsx b/app/[lng]/evcp/(evcp)/evaluation-target-list/page.tsx new file mode 100644 index 00000000..d60f695a --- /dev/null +++ b/app/[lng]/evcp/(evcp)/evaluation-target-list/page.tsx @@ -0,0 +1,172 @@ +import * as React from "react" +import { Metadata } from "next" +import { type SearchParams } from "@/types/table" +import { getValidFilters } from "@/lib/data-table" +import { Shell } from "@/components/shell" +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" +import { HelpCircle } from "lucide-react" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" + +import { getDefaultEvaluationYear, searchParamsEvaluationTargetsCache } from "@/lib/evaluation-target-list/validation" +import { getEvaluationTargets } from "@/lib/evaluation-target-list/service" +import { EvaluationTargetsTable } from "@/lib/evaluation-target-list/table/evaluation-target-table" + +export const metadata: Metadata = { + title: "협력업체 평가 대상 확정", + description: "협력업체 평가 대상을 확정하고 담당자를 지정합니다.", +} + +interface EvaluationTargetsPageProps { + searchParams: Promise<SearchParams> +} + +// 프로세스 안내 팝오버 컴포넌트 +function ProcessGuidePopover() { + return ( + <Popover> + <PopoverTrigger asChild> + <Button variant="ghost" size="icon" className="h-6 w-6"> + <HelpCircle className="h-4 w-4 text-muted-foreground" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-96" align="start"> + <div className="space-y-3"> + <div className="space-y-1"> + <h4 className="font-medium">평가 대상 확정 프로세스</h4> + <p className="text-sm text-muted-foreground"> + 발주실적을 기반으로 평가 대상을 확정하는 절차입니다. + </p> + </div> + <div className="space-y-3 text-sm"> + <div className="flex gap-3"> + <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600"> + 1 + </div> + <div> + <p className="font-medium">발주실적 기반 자동 추출</p> + <p className="text-muted-foreground">전년도 10월 ~ 해당년도 9월 발주실적에서 업체 목록을 자동으로 생성합니다.</p> + </div> + </div> + <div className="flex gap-3"> + <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600"> + 2 + </div> + <div> + <p className="font-medium">담당자 지정</p> + <p className="text-muted-foreground">각 평가 대상별로 5개 부서(발주/조달/품질/설계/CS)의 담당자를 지정합니다.</p> + </div> + </div> + <div className="flex gap-3"> + <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600"> + 3 + </div> + <div> + <p className="font-medium">검토 및 의견 수렴</p> + <p className="text-muted-foreground">모든 담당자가 평가 대상 적합성을 검토하고 의견을 제출합니다.</p> + </div> + </div> + <div className="flex gap-3"> + <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600"> + 4 + </div> + <div> + <p className="font-medium">최종 확정</p> + <p className="text-muted-foreground">모든 담당자 의견이 일치하면 평가 대상으로 최종 확정됩니다.</p> + </div> + </div> + </div> + </div> + </PopoverContent> + </Popover> + ) +} + +export default async function EvaluationTargetsPage(props: EvaluationTargetsPageProps) { + const searchParams = await props.searchParams + const search = searchParamsEvaluationTargetsCache.parse(searchParams) + const validFilters = getValidFilters(search.filters) + + // 기본 필터 처리 (통일된 이름 사용) + let basicFilters = [] + if (search.basicFilters && search.basicFilters.length > 0) { + basicFilters = search.basicFilters + console.log("Using search.basicFilters:", basicFilters); + } else { + console.log("No basic filters found"); + } + + // 모든 필터를 합쳐서 처리 + const allFilters = [...validFilters, ...basicFilters] + + // 조인 연산자도 통일된 이름 사용 + const joinOperator = search.basicJoinOperator || search.joinOperator || 'and'; + + // 현재 평가년도 (필터에서 가져오거나 기본값 사용) + const currentEvaluationYear = search.evaluationYear || getDefaultEvaluationYear() + + // Promise.all로 감싸서 전달 + const promises = Promise.all([ + getEvaluationTargets({ + ...search, + filters: allFilters, + joinOperator, + }) + ]) + + return ( + <Shell className="gap-4"> + {/* 간소화된 헤더 */} + <div className="flex items-center justify-between space-y-2"> + <div className="flex items-center justify-between space-y-2"> + <div className="flex items-center gap-2"> + <h2 className="text-2xl font-bold tracking-tight"> + 협력업체 평가 대상 확정 + </h2> + <Badge variant="outline" className="text-sm"> + {currentEvaluationYear}년도 + </Badge> + <ProcessGuidePopover /> + </div> + </div> + </div> + + {/* 메인 테이블 (통계는 테이블 내부로 이동) */} + <React.Suspense + key={JSON.stringify(searchParams)} // URL 파라미터가 변경될 때마다 강제 리렌더링 + fallback={ + <DataTableSkeleton + columnCount={12} + searchableColumnCount={2} + filterableColumnCount={6} + cellWidths={[ + "3rem", // checkbox + "5rem", // 평가년도 + "4rem", // 구분 + "8rem", // 벤더코드 + "12rem", // 벤더명 + "4rem", // 내외자 + "6rem", // 자재구분 + "5rem", // 상태 + "5rem", // 의견일치 + "8rem", // 담당자현황 + "10rem", // 관리자의견 + "8rem" // actions + ]} + shrinkZero + /> + } + > + <EvaluationTargetsTable + promises={promises} + evaluationYear={currentEvaluationYear} + /> + </React.Suspense> + </Shell> + ) +}
\ No newline at end of file diff --git a/app/[lng]/evcp/(evcp)/incoterms/page.tsx b/app/[lng]/evcp/(evcp)/incoterms/page.tsx new file mode 100644 index 00000000..57a19009 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/incoterms/page.tsx @@ -0,0 +1,53 @@ +import * as React from "react"; +import { type SearchParams } from "@/types/table"; +import { getValidFilters } from "@/lib/data-table"; +import { Shell } from "@/components/shell"; +import { Skeleton } from "@/components/ui/skeleton"; +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; +import { SearchParamsCache } from "@/lib/incoterms/validations"; +import { getIncoterms } from "@/lib/incoterms/service"; +import { IncotermsTable } from "@/lib/incoterms/table/incoterms-table"; + +interface IndexPageProps { + searchParams: Promise<SearchParams>; +} + +export default async function IndexPage(props: IndexPageProps) { + const searchParams = await props.searchParams; + const search = SearchParamsCache.parse(searchParams); + const validFilters = getValidFilters(search.filters); + + const promises = Promise.all([ + getIncoterms({ + ...search, + filters: validFilters, + }), + ]); + + return ( + <Shell className="gap-2"> + <div className="flex items-center justify-between space-y-2"> + <div> + <h2 className="text-2xl font-bold tracking-tight">인코텀즈 관리</h2> + <p className="text-muted-foreground"> + 인코텀즈(Incoterms)를 등록, 수정, 삭제할 수 있습니다. + </p> + </div> + </div> + <React.Suspense fallback={<Skeleton className="h-7 w-52" />}></React.Suspense> + <React.Suspense + fallback={ + <DataTableSkeleton + columnCount={4} + searchableColumnCount={1} + filterableColumnCount={2} + cellWidths={["10rem", "40rem", "12rem", "8rem"]} + shrinkZero + /> + } + > + <IncotermsTable promises={promises} /> + </React.Suspense> + </Shell> + ); +}
\ No newline at end of file diff --git a/app/[lng]/evcp/(evcp)/payment-conditions/page.tsx b/app/[lng]/evcp/(evcp)/payment-conditions/page.tsx new file mode 100644 index 00000000..b9aedfbb --- /dev/null +++ b/app/[lng]/evcp/(evcp)/payment-conditions/page.tsx @@ -0,0 +1,53 @@ +import * as React from "react"; +import { type SearchParams } from "@/types/table"; +import { getValidFilters } from "@/lib/data-table"; +import { Shell } from "@/components/shell"; +import { Skeleton } from "@/components/ui/skeleton"; +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; +import { SearchParamsCache } from "@/lib/payment-terms/validations"; +import { getPaymentTerms } from "@/lib/payment-terms/service"; +import { PaymentTermsTable } from "@/lib/payment-terms/table/payment-terms-table"; + +interface IndexPageProps { + searchParams: Promise<SearchParams>; +} + +export default async function IndexPage(props: IndexPageProps) { + const searchParams = await props.searchParams; + const search = SearchParamsCache.parse(searchParams); + const validFilters = getValidFilters(search.filters); + + const promises = Promise.all([ + getPaymentTerms({ + ...search, + filters: validFilters, + }), + ]); + + return ( + <Shell className="gap-2"> + <div className="flex items-center justify-between space-y-2"> + <div> + <h2 className="text-2xl font-bold tracking-tight">결제 조건 관리</h2> + <p className="text-muted-foreground"> + 결제 조건(Payment Terms)을 등록, 수정, 삭제할 수 있습니다. + </p> + </div> + </div> + <React.Suspense fallback={<Skeleton className="h-7 w-52" />}></React.Suspense> + <React.Suspense + fallback={ + <DataTableSkeleton + columnCount={4} + searchableColumnCount={1} + filterableColumnCount={2} + cellWidths={["10rem", "40rem", "12rem", "8rem"]} + shrinkZero + /> + } + > + <PaymentTermsTable promises={promises} /> + </React.Suspense> + </Shell> + ); +}
\ No newline at end of file diff --git a/app/[lng]/evcp/(evcp)/vendor-check-list/page.tsx b/app/[lng]/evcp/(evcp)/vendor-check-list/page.tsx new file mode 100644 index 00000000..3fd7e425 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/vendor-check-list/page.tsx @@ -0,0 +1,74 @@ +import * as React from "react" +import { type SearchParams } from "@/types/table" + +import { getValidFilters } from "@/lib/data-table" +import { Skeleton } from "@/components/ui/skeleton" +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" +import { Shell } from "@/components/shell" +import { getGenralEvaluationsSchema } from "@/lib/general-check-list/validation" +import { GeneralEvaluationsTable } from "@/lib/general-check-list/table/general-check-list-table" +import { getGeneralEvaluations } from "@/lib/general-check-list/service" + + +interface IndexPageProps { + searchParams: Promise<SearchParams> +} + +export default async function IndexPage(props: IndexPageProps) { + const searchParams = await props.searchParams + const search = getGenralEvaluationsSchema.parse(searchParams) + + const validFilters = getValidFilters(search.filters) + + const promises = Promise.all([ + getGeneralEvaluations({ + ...search, + filters: validFilters, + }), + + ]) + + 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"> + 협력업체 정기평가 체크리스트 + </h2> + <p className="text-muted-foreground"> + 협력업체 평가에 사용되는 정기평가 체크리스트를 관리{" "} + {/* <span className="inline-flex items-center whitespace-nowrap"> + <Ellipsis className="size-3" /> + <span className="ml-1">버튼</span> + </span> + 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. */} + </p> + </div> + </div> + </div> + + <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> + {/* <DateRangePicker + triggerSize="sm" + triggerClassName="ml-auto w-56 sm:w-60" + align="end" + shallow={false} + /> */} + </React.Suspense> + <React.Suspense + fallback={ + <DataTableSkeleton + columnCount={6} + searchableColumnCount={1} + filterableColumnCount={2} + cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} + shrinkZero + /> + } + > + <GeneralEvaluationsTable promises={promises} /> + </React.Suspense> + </Shell> + ) +} diff --git a/app/[lng]/partners/(partners)/evaluation/page.tsx b/app/[lng]/partners/(partners)/evaluation/page.tsx new file mode 100644 index 00000000..dc1d4137 --- /dev/null +++ b/app/[lng]/partners/(partners)/evaluation/page.tsx @@ -0,0 +1,135 @@ +import * as React from "react" +import { type SearchParams } from "@/types/table" +import { getValidFilters } from "@/lib/data-table" +import { Skeleton } from "@/components/ui/skeleton" +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" +import { Shell } from "@/components/shell" +import { getServerSession } from "next-auth" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import Link from "next/link" +import { Button } from "@/components/ui/button" +import { LogIn } from "lucide-react" +import { getEvaluationSubmissions } from "@/lib/vendor-evaluation-submit/service" +import { getEvaluationsSubmitSchema } from "@/lib/vendor-evaluation-submit/validation" +import { EvaluationSubmissionsTable } from "@/lib/vendor-evaluation-submit/table/submit-table" + +interface IndexPageProps { + searchParams: Promise<SearchParams> +} + +export default async function IndexPage(props: IndexPageProps) { + const searchParams = await props.searchParams + const search = getEvaluationsSubmitSchema.parse(searchParams) + const validFilters = getValidFilters(search.filters) + + // Get session + const session = await getServerSession(authOptions) + + // Check if user is logged in + if (!session || !session.user) { + // Return login required UI instead of redirecting + 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"> + <LogIn className="mr-2 h-4 w-4" /> + 로그인하기 + </Link> + </Button> + </div> + </div> + </Shell> + ) + } + + // User is logged in, proceed with vendor ID + const vendorId = session.user.companyId + + // Validate vendorId (should be a number) + const idAsNumber = Number(vendorId) + + console.log(idAsNumber) + + if (isNaN(idAsNumber)) { + // Handle invalid vendor ID (this shouldn't happen if authentication is working properly) + 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> + ) + } + + // If we got here, we have a valid vendor ID + const promises = Promise.all([ + getEvaluationSubmissions({ + ...search, + filters: validFilters, + }, idAsNumber) + ]) + + 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"> + 정기평가 + </h2> + <p className="text-muted-foreground"> + 요청된 정기평가를 입력하고 제출할 수 있습니다. + </p> + </div> + </div> + </div> + + <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> + {/* DateRangePicker can go here */} + </React.Suspense> + + <React.Suspense + fallback={ + <DataTableSkeleton + columnCount={6} + searchableColumnCount={1} + filterableColumnCount={2} + cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} + shrinkZero + /> + } + > + <EvaluationSubmissionsTable promises={promises} /> + </React.Suspense> + </Shell> + ) +}
\ No newline at end of file diff --git a/app/[lng]/partners/(partners)/rfq-ship/[id]/page.tsx b/app/[lng]/partners/(partners)/rfq-ship/[id]/page.tsx index 772a9840..7f5400f3 100644 --- a/app/[lng]/partners/(partners)/rfq-ship/[id]/page.tsx +++ b/app/[lng]/partners/(partners)/rfq-ship/[id]/page.tsx @@ -50,7 +50,7 @@ export default async function VendorQuotationPage({ params }: PageProps) { with: { rfq: true, // 관계 설정 필요 vendor: true, // 관계 설정 필요 - // items: true, // 관계 설정 필요 + items: true, // 관계 설정 필요 } }) diff --git a/app/api/vendor-evaluation/delete-attachment/[id]/route.ts b/app/api/vendor-evaluation/delete-attachment/[id]/route.ts new file mode 100644 index 00000000..f899883c --- /dev/null +++ b/app/api/vendor-evaluation/delete-attachment/[id]/route.ts @@ -0,0 +1,87 @@ +// app/api/delete-attachment/[id]/route.ts +import { NextRequest, NextResponse } from 'next/server' +import { unlink } from 'fs/promises' +import { join } from 'path' +import db from '@/db/db' +import { vendorEvaluationAttachments, generalEvaluationResponses } from '@/db/schema' +import { eq, and } from 'drizzle-orm' + +export async function DELETE( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const attachmentId = parseInt(params.id) + + if (isNaN(attachmentId)) { + return NextResponse.json({ error: '유효하지 않은 첨부파일 ID입니다.' }, { status: 400 }) + } + + // 1. 파일 정보 조회 + const [attachment] = await db + .select() + .from(vendorEvaluationAttachments) + .where( + and( + eq(vendorEvaluationAttachments.id, attachmentId), + eq(vendorEvaluationAttachments.isActive, true) + ) + ) + + if (!attachment) { + return NextResponse.json({ error: '첨부파일을 찾을 수 없습니다.' }, { status: 404 }) + } + + // 2. 실제 파일 삭제 + try { + const fullPath = join(process.cwd(), 'public', attachment.filePath) + await unlink(fullPath) + } catch (fileError) { + console.warn('파일 삭제 실패 (파일이 이미 없을 수 있음):', fileError) + // 파일 삭제 실패해도 DB 레코드는 삭제 진행 + } + + // 3. DB에서 소프트 삭제 + await db + .update(vendorEvaluationAttachments) + .set({ + isActive: false, + updatedAt: new Date() + }) + .where(eq(vendorEvaluationAttachments.id, attachmentId)) + + // 4. 해당 응답의 첨부파일 상태 업데이트 + if (attachment.generalEvaluationResponseId) { + // 남은 활성 첨부파일 개수 확인 + const remainingAttachments = await db + .select() + .from(vendorEvaluationAttachments) + .where( + and( + eq(vendorEvaluationAttachments.generalEvaluationResponseId, attachment.generalEvaluationResponseId), + eq(vendorEvaluationAttachments.isActive, true) + ) + ) + + const hasAttachments = remainingAttachments.length > 0 + + // generalEvaluationResponses 테이블 업데이트 + await db + .update(generalEvaluationResponses) + .set({ + hasAttachments, + updatedAt: new Date() + }) + .where(eq(generalEvaluationResponses.id, attachment.generalEvaluationResponseId)) + } + + return NextResponse.json({ + success: true, + message: '파일이 성공적으로 삭제되었습니다.' + }) + + } catch (error) { + console.error('파일 삭제 오류:', error) + return NextResponse.json({ error: '파일 삭제에 실패했습니다.' }, { status: 500 }) + } +}
\ No newline at end of file diff --git a/app/api/vendor-evaluation/upload-attachment/route.ts b/app/api/vendor-evaluation/upload-attachment/route.ts new file mode 100644 index 00000000..3fe8164c --- /dev/null +++ b/app/api/vendor-evaluation/upload-attachment/route.ts @@ -0,0 +1,106 @@ +// app/api/upload-attachment/route.ts +import { NextRequest, NextResponse } from 'next/server' +import { writeFile, mkdir } from 'fs/promises' +import { join } from 'path' +import { randomUUID } from 'crypto' +import db from '@/db/db' +import { vendorEvaluationAttachments } from '@/db/schema' + +export async function POST(request: NextRequest) { + try { + const formData = await request.formData() + const files = formData.getAll('files') as File[] + const submissionId = formData.get('submissionId') as string + const responseId = formData.get('responseId') as string + const uploadedBy = formData.get('uploadedBy') as string + + if (!files || files.length === 0) { + return NextResponse.json({ error: '파일이 선택되지 않았습니다.' }, { status: 400 }) + } + + if (!submissionId || !responseId || !uploadedBy) { + return NextResponse.json({ error: '필수 매개변수가 누락되었습니다.' }, { status: 400 }) + } + + const uploadedFiles = [] + + // uploads 디렉토리 생성 (없는 경우) + const uploadsDir = join(process.cwd(), 'public', 'uploads', 'attachments') + try { + await mkdir(uploadsDir, { recursive: true }) + } catch (error) { + // 디렉토리가 이미 존재하는 경우 무시 + } + + for (const file of files) { + // 파일 검증 + if (file.size > 10 * 1024 * 1024) { // 10MB 제한 + return NextResponse.json({ error: `파일 ${file.name}이 너무 큽니다. (최대 10MB)` }, { status: 400 }) + } + + // 파일 확장자 검증 (선택적) + const allowedTypes = [ + 'image/jpeg', 'image/png', 'image/gif', 'image/webp', + 'application/pdf', + 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'text/plain', 'text/csv' + ] + + if (!allowedTypes.includes(file.type)) { + return NextResponse.json({ error: `지원되지 않는 파일 형식입니다: ${file.name}` }, { status: 400 }) + } + + // 고유한 파일명 생성 + const fileExtension = file.name.split('.').pop() || '' + const storedFileName = `${randomUUID()}.${fileExtension}` + const filePath = join('uploads', 'attachments', storedFileName) + const fullPath = join(process.cwd(), 'public', filePath) + + try { + // 파일 저장 + const bytes = await file.arrayBuffer() + const buffer = Buffer.from(bytes) + await writeFile(fullPath, buffer) + + // 데이터베이스에 파일 정보 저장 + const [savedAttachment] = await db + .insert(vendorEvaluationAttachments) + .values({ + submissionId: parseInt(submissionId), + generalEvaluationResponseId: parseInt(responseId), + originalFileName: file.name, + storedFileName: storedFileName, + filePath: filePath, + fileSize: file.size, + mimeType: file.type, + uploadedBy: uploadedBy, + isActive: true, + }) + .returning() + + uploadedFiles.push({ + id: savedAttachment.id, + fileId: savedAttachment.fileId, + originalFileName: savedAttachment.originalFileName, + fileSize: savedAttachment.fileSize, + mimeType: savedAttachment.mimeType, + }) + + } catch (fileError) { + console.error(`파일 저장 실패: ${file.name}`, fileError) + return NextResponse.json({ error: `파일 저장에 실패했습니다: ${file.name}` }, { status: 500 }) + } + } + + return NextResponse.json({ + success: true, + files: uploadedFiles, + message: `${uploadedFiles.length}개 파일이 성공적으로 업로드되었습니다.` + }) + + } catch (error) { + console.error('파일 업로드 오류:', error) + return NextResponse.json({ error: '파일 업로드에 실패했습니다.' }, { status: 500 }) + } +}
\ No newline at end of file |
