summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/[lng]/evcp/(evcp)/esg-check-list/page.tsx74
-rw-r--r--app/[lng]/evcp/(evcp)/evaluation-target-list/page.tsx172
-rw-r--r--app/[lng]/evcp/(evcp)/incoterms/page.tsx53
-rw-r--r--app/[lng]/evcp/(evcp)/payment-conditions/page.tsx53
-rw-r--r--app/[lng]/evcp/(evcp)/vendor-check-list/page.tsx74
-rw-r--r--app/[lng]/partners/(partners)/evaluation/page.tsx135
-rw-r--r--app/[lng]/partners/(partners)/rfq-ship/[id]/page.tsx2
-rw-r--r--app/api/vendor-evaluation/delete-attachment/[id]/route.ts87
-rw-r--r--app/api/vendor-evaluation/upload-attachment/route.ts106
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