diff options
Diffstat (limited to 'app')
| -rw-r--r-- | app/[lng]/evcp/(evcp)/evaluation/page.tsx | 194 | ||||
| -rw-r--r-- | app/[lng]/evcp/(evcp)/project-gtc/page.tsx | 63 | ||||
| -rw-r--r-- | app/api/project-gtc/route.ts | 154 |
3 files changed, 411 insertions, 0 deletions
diff --git a/app/[lng]/evcp/(evcp)/evaluation/page.tsx b/app/[lng]/evcp/(evcp)/evaluation/page.tsx new file mode 100644 index 00000000..3ae3272a --- /dev/null +++ b/app/[lng]/evcp/(evcp)/evaluation/page.tsx @@ -0,0 +1,194 @@ +// ================================================================ +// 4. PERIODIC EVALUATIONS PAGE +// ================================================================ + +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 { PeriodicEvaluationsTable } from "@/lib/evaluation/table/evaluation-table" + +export const metadata: Metadata = { + title: "협력업체 정기평가", + description: "협력업체 정기평가 진행 현황을 관리합니다.", +} + +interface PeriodicEvaluationsPageProps { + 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">평가 대상으로 확정된 업체들의 정기평가가 자동 생성됩니다.</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">각 업체는 평가에 필요한 자료를 제출 마감일까지 제출해야 합니다.</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> + ) +} + +// TODO: 이 함수들은 실제 서비스 파일에서 구현해야 함 +function getDefaultEvaluationYear() { + return new Date().getFullYear() +} + +function searchParamsPeriodicEvaluationsCache() { + // TODO: 실제 파서 구현 + return { + parse: (params: any) => params + } +} + +async function getPeriodicEvaluations(params: any) { + // TODO: 실제 API 호출 구현 + return { + data: [], + total: 0, + pageCount: 0 + } +} + +export default async function PeriodicEvaluationsPage(props: PeriodicEvaluationsPageProps) { + const searchParams = await props.searchParams + const search = searchParamsPeriodicEvaluationsCache().parse(searchParams) + const validFilters = getValidFilters(search.filters || []) + + // 기본 필터 처리 + let basicFilters = [] + if (search.basicFilters && search.basicFilters.length > 0) { + basicFilters = search.basicFilters + } + + // 모든 필터를 합쳐서 처리 + const allFilters = [...validFilters, ...basicFilters] + + // 조인 연산자 + const joinOperator = search.basicJoinOperator || search.joinOperator || 'and'; + + // 현재 평가년도 + const currentEvaluationYear = search.evaluationYear || getDefaultEvaluationYear() + + // Promise.all로 감싸서 전달 + const promises = Promise.all([ + getPeriodicEvaluations({ + ...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)} + fallback={ + <DataTableSkeleton + columnCount={15} + searchableColumnCount={2} + filterableColumnCount={8} + cellWidths={[ + "3rem", // checkbox + "5rem", // 평가년도 + "5rem", // 평가기간 + "4rem", // 구분 + "8rem", // 벤더코드 + "12rem", // 벤더명 + "4rem", // 내외자 + "6rem", // 자재구분 + "5rem", // 문서제출 + "4rem", // 제출일 + "4rem", // 마감일 + "4rem", // 총점 + "4rem", // 등급 + "5rem", // 진행상태 + "8rem" // actions + ]} + shrinkZero + /> + } + > + <PeriodicEvaluationsTable + promises={promises} + evaluationYear={currentEvaluationYear} + /> + </React.Suspense> + </Shell> + ) +}
\ No newline at end of file diff --git a/app/[lng]/evcp/(evcp)/project-gtc/page.tsx b/app/[lng]/evcp/(evcp)/project-gtc/page.tsx new file mode 100644 index 00000000..8e12a489 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/project-gtc/page.tsx @@ -0,0 +1,63 @@ +import * as React from "react" +import { type SearchParams } from "@/types/table" + +import { Skeleton } from "@/components/ui/skeleton" +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" +import { Shell } from "@/components/shell" +import { getProjectGtcList } from "@/lib/project-gtc/service" +import { projectGtcSearchParamsSchema } from "@/lib/project-gtc/validations" +import { ProjectGtcTable } from "@/lib/project-gtc/table/project-gtc-table" + +interface IndexPageProps { + searchParams: Promise<SearchParams> +} + +export default async function IndexPage(props: IndexPageProps) { + const searchParams = await props.searchParams + const search = projectGtcSearchParamsSchema.parse(searchParams) + + const promises = Promise.all([ + getProjectGtcList({ + page: search.page, + perPage: search.perPage, + search: search.search, + sort: search.sort, + }), + ]) + + 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"> + Project GTC + </h2> + <p className="text-muted-foreground"> + 프로젝트별 GTC(General Terms and Conditions) 파일을 관리할 수 있습니다. + 각 프로젝트마다 하나의 GTC 파일을 업로드할 수 있으며, 파일 업로드 시 기존 파일은 자동으로 교체됩니다. + </p> + </div> + </div> + </div> + + <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> + {/* 추가 기능이 필요하면 여기에 추가 */} + </React.Suspense> + + <React.Suspense + fallback={ + <DataTableSkeleton + columnCount={8} + searchableColumnCount={1} + filterableColumnCount={2} + cellWidths={["3rem", "3rem", "12rem", "20rem", "10rem", "20rem", "15rem", "12rem", "3rem"]} + shrinkZero + /> + } + > + <ProjectGtcTable promises={promises} /> + </React.Suspense> + </Shell> + ) +}
\ No newline at end of file diff --git a/app/api/project-gtc/route.ts b/app/api/project-gtc/route.ts new file mode 100644 index 00000000..8fe4ad2e --- /dev/null +++ b/app/api/project-gtc/route.ts @@ -0,0 +1,154 @@ +import { NextRequest, NextResponse } from "next/server" +import { uploadProjectGtcFile, deleteProjectGtcFile, getProjectGtcList, getProjectGtcFile } from "@/lib/project-gtc/service" +import { promises as fs } from "fs" +import path from "path" + +// 파일 다운로드 +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url) + const projectId = searchParams.get("projectId") + const action = searchParams.get("action") + + // 목록 조회 + if (action !== "download") { + const page = parseInt(searchParams.get("page") || "1") + const perPage = parseInt(searchParams.get("perPage") || "10") + const search = searchParams.get("search") || "" + const sortParam = searchParams.get("sort") + + let sort: Array<{ id: string; desc: boolean }> = [] + if (sortParam) { + try { + sort = JSON.parse(sortParam) + } catch { + sort = [{ id: "projectCreatedAt", desc: true }] + } + } else { + sort = [{ id: "projectCreatedAt", desc: true }] + } + + const result = await getProjectGtcList({ + page, + perPage, + search, + sort, + }) + + return NextResponse.json(result) + } + + // 파일 다운로드 + if (!projectId) { + return NextResponse.json( + { error: "프로젝트 ID가 필요합니다." }, + { status: 400 } + ) + } + + const fileInfo = await getProjectGtcFile(parseInt(projectId)) + + if (!fileInfo) { + return NextResponse.json( + { error: "파일을 찾을 수 없습니다." }, + { status: 404 } + ) + } + + // 파일 경로 구성 + const filePath = path.join(process.cwd(), "public", fileInfo.filePath) + + try { + // 파일 읽기 + const fileBuffer = await fs.readFile(filePath) + + // 응답 헤더 설정 + const headers = new Headers() + headers.set('Content-Type', fileInfo.mimeType || 'application/octet-stream') + headers.set('Content-Disposition', `attachment; filename="${encodeURIComponent(fileInfo.originalFileName)}"`) + headers.set('Content-Length', fileInfo.fileSize?.toString() || '0') + + return new NextResponse(new Uint8Array(fileBuffer), { + status: 200, + headers, + }) + } catch (fileError) { + console.error("파일 읽기 오류:", fileError) + return NextResponse.json( + { error: "파일을 읽을 수 없습니다." }, + { status: 500 } + ) + } + } catch (error) { + console.error("Project GTC API 에러:", error) + return NextResponse.json( + { error: "요청 처리 중 오류가 발생했습니다." }, + { status: 500 } + ) + } +} + +// 파일 업로드 +export async function POST(request: NextRequest) { + try { + const formData = await request.formData() + const projectId = formData.get("projectId") as string + const file = formData.get("file") as File + + if (!projectId || !file) { + return NextResponse.json( + { error: "프로젝트 ID와 파일이 필요합니다." }, + { status: 400 } + ) + } + + const result = await uploadProjectGtcFile(parseInt(projectId), file) + + if (result.success) { + return NextResponse.json({ success: true, data: result.data }) + } else { + return NextResponse.json( + { error: result.error }, + { status: 400 } + ) + } + } catch (error) { + console.error("Project GTC 파일 업로드 API 에러:", error) + return NextResponse.json( + { error: "파일 업로드 중 오류가 발생했습니다." }, + { status: 500 } + ) + } +} + +// 파일 삭제 +export async function DELETE(request: NextRequest) { + try { + const { searchParams } = new URL(request.url) + const projectId = searchParams.get("projectId") + + if (!projectId) { + return NextResponse.json( + { error: "프로젝트 ID가 필요합니다." }, + { status: 400 } + ) + } + + const result = await deleteProjectGtcFile(parseInt(projectId)) + + if (result.success) { + return NextResponse.json({ success: true }) + } else { + return NextResponse.json( + { error: result.error }, + { status: 400 } + ) + } + } catch (error) { + console.error("Project GTC 파일 삭제 API 에러:", error) + return NextResponse.json( + { error: "파일 삭제 중 오류가 발생했습니다." }, + { status: 500 } + ) + } +}
\ No newline at end of file |
