diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-05-28 00:17:25 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-05-28 00:17:25 +0000 |
| commit | 44356f1b203da5169db42950a42a8146f612c674 (patch) | |
| tree | c127d21e1342330c82acb5392e961e5cc4ff3c22 /app | |
| parent | 1c1c1019b6af72771358d387a2ae70ca965cd9f9 (diff) | |
(대표님) app router 파트 커밋
Diffstat (limited to 'app')
| -rw-r--r-- | app/[lng]/evcp/(evcp)/admin/if/items/page.tsx | 371 | ||||
| -rw-r--r-- | app/[lng]/evcp/(evcp)/items-tech/layout.tsx | 38 | ||||
| -rw-r--r-- | app/[lng]/evcp/(evcp)/items-tech/page.tsx | 62 | ||||
| -rw-r--r-- | app/[lng]/evcp/(evcp)/po-rfq/page.tsx | 107 | ||||
| -rw-r--r-- | app/[lng]/evcp/(evcp)/pq_new/[vendorId]/[submissionId]/page.tsx | 215 | ||||
| -rw-r--r-- | app/[lng]/evcp/(evcp)/pq_new/page.tsx | 96 | ||||
| -rw-r--r-- | app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/items/page.tsx | 56 | ||||
| -rw-r--r-- | app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/layout.tsx | 73 | ||||
| -rw-r--r-- | app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/page.tsx | 56 | ||||
| -rw-r--r-- | app/[lng]/evcp/(evcp)/tech-vendors/page.tsx | 71 | ||||
| -rw-r--r-- | app/[lng]/partners/(partners)/document-list/[contractId]/page.tsx | 6 | ||||
| -rw-r--r-- | app/[lng]/partners/(partners)/vendor-data/form/[packageId]/[formId]/[projectId]/[contractId]/page.tsx | 2 | ||||
| -rw-r--r-- | app/[lng]/partners/pq_new/[id]/page.tsx | 205 | ||||
| -rw-r--r-- | app/[lng]/partners/pq_new/page.tsx | 267 |
14 files changed, 829 insertions, 796 deletions
diff --git a/app/[lng]/evcp/(evcp)/admin/if/items/page.tsx b/app/[lng]/evcp/(evcp)/admin/if/items/page.tsx deleted file mode 100644 index 5fa788bd..00000000 --- a/app/[lng]/evcp/(evcp)/admin/if/items/page.tsx +++ /dev/null @@ -1,371 +0,0 @@ -import { getOracleConnection } from "@/lib/oracle-db/db"; -import db from "@/db/db"; -import { items } from "@/db/schema/items"; -import { cache } from "react"; -import { Button } from "@/components/ui/button"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; - -// Oracle 메타데이터와 행 타입 정의 -type OracleColumn = { - name: string; -}; - -type OracleRow = (string | number | null)[]; - -// PLM(?)의 Oracle DB 에 직접 연결해 데이터를 가져오는 페이지 -// 모든 데이터를 전부 가져오기는 힘들 수 있으므로 샘플 수준으로 가져오는 것을 우선 node로 처리한다. - -// Oracle에서 데이터를 가져오는 함수를 캐싱 -const fetchOracleData = cache(async (limit = 100, offset = 0) => { - const connection = await getOracleConnection(); - - try { - // 자재마스터 클래스 정보 테이블 조회 (CMCTB_MAT_CLAS) - const result = await connection.execute( - `SELECT - CLAS_CD, - CLAS_NM, - CLAS_DTL, - PRNT_CLAS_CD, - CLAS_LVL, - DEL_ORDR, - UOM, - STYPE, - GRD_MATL, - CHG_DT, - BSE_UOM - FROM SHI1.CMCTB_MAT_CLAS - WHERE ROWNUM <= :limit + :offset - OFFSET :offset ROWS`, - { limit, offset } - ); - - // 총 레코드 수 조회 - const countResult = await connection.execute( - `SELECT COUNT(*) AS TOTAL FROM SHI1.CMCTB_MAT_CLAS` - ); - - const totalCount = countResult.rows?.[0]?.[0] || 0; - - return { - rows: result.rows as OracleRow[] || [], - metadata: result.metaData as OracleColumn[] || [], - totalCount - }; - } catch (error) { - console.error("Oracle 데이터 조회 오류:", error); - return { rows: [], metadata: [], totalCount: 0 }; - } finally { - // 연결 종료 - if (connection) { - try { - await connection.close(); - } catch (err) { - console.error("Oracle 연결 종료 오류:", err); - } - } - } -}); - -// 전체 데이터를 가져와 Postgres에 삽입하는 함수 -const syncAllDataToPostgres = cache(async () => { - const BATCH_SIZE = 1000; // 한 번에 처리할 레코드 수 - const MAX_RECORDS = 50000; // 최대 처리할 레코드 수 - - try { - // 총 레코드 수 확인 - const { totalCount } = await fetchOracleData(1, 0); - const recordsToProcess = Math.min(totalCount, MAX_RECORDS); - - let processedCount = 0; - let currentOffset = 0; - - // 배치 단위로 처리 - while (processedCount < recordsToProcess) { - // 오라클에서 데이터 가져오기 - const { rows, metadata } = await fetchOracleData(BATCH_SIZE, currentOffset); - - if (!rows.length) break; - - // Postgres DB 트랜잭션 시작 - await db.transaction(async (tx) => { - // Oracle 데이터를 Postgres 스키마에 맞게 변환하여 삽입 (UPSERT) - for (const row of rows) { - // 배열 형태의 데이터를 객체로 변환 - const rowObj: Record<string, string | number | null> = {}; - metadata.forEach((col: OracleColumn, index: number) => { - rowObj[col.name] = row[index]; - }); - - await tx - .insert(items) - .values({ - itemCode: String(rowObj.CLAS_CD || ''), - itemName: String(rowObj.CLAS_NM || ''), - description: rowObj.CLAS_DTL ? String(rowObj.CLAS_DTL) : null, - parentItemCode: rowObj.PRNT_CLAS_CD ? String(rowObj.PRNT_CLAS_CD) : null, - itemLevel: typeof rowObj.CLAS_LVL === 'number' ? rowObj.CLAS_LVL : null, - deleteFlag: rowObj.DEL_ORDR ? String(rowObj.DEL_ORDR) : null, - unitOfMeasure: rowObj.UOM ? String(rowObj.UOM) : null, - steelType: rowObj.STYPE ? String(rowObj.STYPE) : null, - gradeMaterial: rowObj.GRD_MATL ? String(rowObj.GRD_MATL) : null, - changeDate: rowObj.CHG_DT ? String(rowObj.CHG_DT) : null, - baseUnitOfMeasure: rowObj.BSE_UOM ? String(rowObj.BSE_UOM) : null - }) - .onConflictDoUpdate({ - target: items.itemCode, - set: { - itemName: String(rowObj.CLAS_NM || ''), - description: rowObj.CLAS_DTL ? String(rowObj.CLAS_DTL) : null, - parentItemCode: rowObj.PRNT_CLAS_CD ? String(rowObj.PRNT_CLAS_CD) : null, - itemLevel: typeof rowObj.CLAS_LVL === 'number' ? rowObj.CLAS_LVL : null, - deleteFlag: rowObj.DEL_ORDR ? String(rowObj.DEL_ORDR) : null, - unitOfMeasure: rowObj.UOM ? String(rowObj.UOM) : null, - steelType: rowObj.STYPE ? String(rowObj.STYPE) : null, - gradeMaterial: rowObj.GRD_MATL ? String(rowObj.GRD_MATL) : null, - changeDate: rowObj.CHG_DT ? String(rowObj.CHG_DT) : null, - baseUnitOfMeasure: rowObj.BSE_UOM ? String(rowObj.BSE_UOM) : null, - updatedAt: new Date() - } - }); - } - }); - - processedCount += rows.length; - currentOffset += BATCH_SIZE; - - // 진행 상황 업데이트 - const progress = Math.min(100, Math.round((processedCount / recordsToProcess) * 100)); - - // 임시 상태 저장 (실제 구현에서는 저장소나 상태 관리 도구를 사용) - console.log(`진행 상황: ${progress}% (${processedCount}/${recordsToProcess})`); - } - - return { - success: true, - message: `${processedCount}개의 자재마스터 클래스 정보가 items 테이블로 성공적으로 이관되었습니다.`, - count: processedCount - }; - } catch (error) { - console.error("Postgres 데이터 이관 오류:", error); - return { - success: false, - message: `데이터 이관 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`, - count: 0 - }; - } -}); - -// 샘플 데이터만 Postgres에 삽입하는 함수 -const syncSampleDataToPostgres = cache(async () => { - const { rows, metadata } = await fetchOracleData(100, 0); - - if (!rows.length) { - return { - success: false, - message: "Oracle에서 가져올 데이터가 없습니다.", - count: 0 - }; - } - - try { - // Postgres DB 트랜잭션 시작 - await db.transaction(async (tx) => { - // Oracle 데이터를 Postgres 스키마에 맞게 변환하여 삽입 (UPSERT) - for (const row of rows) { - // 배열 형태의 데이터를 객체로 변환 - const rowObj: Record<string, string | number | null> = {}; - metadata.forEach((col: OracleColumn, index: number) => { - rowObj[col.name] = row[index]; - }); - - await tx - .insert(items) - .values({ - itemCode: String(rowObj.CLAS_CD || ''), - itemName: String(rowObj.CLAS_NM || ''), - description: rowObj.CLAS_DTL ? String(rowObj.CLAS_DTL) : null, - parentItemCode: rowObj.PRNT_CLAS_CD ? String(rowObj.PRNT_CLAS_CD) : null, - itemLevel: typeof rowObj.CLAS_LVL === 'number' ? rowObj.CLAS_LVL : null, - deleteFlag: rowObj.DEL_ORDR ? String(rowObj.DEL_ORDR) : null, - unitOfMeasure: rowObj.UOM ? String(rowObj.UOM) : null, - steelType: rowObj.STYPE ? String(rowObj.STYPE) : null, - gradeMaterial: rowObj.GRD_MATL ? String(rowObj.GRD_MATL) : null, - changeDate: rowObj.CHG_DT ? String(rowObj.CHG_DT) : null, - baseUnitOfMeasure: rowObj.BSE_UOM ? String(rowObj.BSE_UOM) : null - }) - .onConflictDoUpdate({ - target: items.itemCode, - set: { - itemName: String(rowObj.CLAS_NM || ''), - description: rowObj.CLAS_DTL ? String(rowObj.CLAS_DTL) : null, - parentItemCode: rowObj.PRNT_CLAS_CD ? String(rowObj.PRNT_CLAS_CD) : null, - itemLevel: typeof rowObj.CLAS_LVL === 'number' ? rowObj.CLAS_LVL : null, - deleteFlag: rowObj.DEL_ORDR ? String(rowObj.DEL_ORDR) : null, - unitOfMeasure: rowObj.UOM ? String(rowObj.UOM) : null, - steelType: rowObj.STYPE ? String(rowObj.STYPE) : null, - gradeMaterial: rowObj.GRD_MATL ? String(rowObj.GRD_MATL) : null, - changeDate: rowObj.CHG_DT ? String(rowObj.CHG_DT) : null, - baseUnitOfMeasure: rowObj.BSE_UOM ? String(rowObj.BSE_UOM) : null, - updatedAt: new Date() - } - }); - } - }); - - return { - success: true, - message: `${rows.length}개의 자재마스터 클래스 정보가 items 테이블로 성공적으로 이관되었습니다.`, - count: rows.length - }; - } catch (error) { - console.error("Postgres 데이터 삽입 오류:", error); - return { - success: false, - message: `데이터 이관 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`, - count: 0 - }; - } -}); - -// 현재 PostgreSQL DB에 저장된 데이터를 조회하는 함수 -const fetchCurrentPgData = cache(async () => { - try { - return await db.select().from(items).limit(100); - } catch (error) { - console.error("Postgres 데이터 조회 오류:", error); - return []; - } -}); - -export default async function ItemsAdminPage() { - // 데이터 초기 로드 - const { rows: oracleData, metadata, totalCount } = await fetchOracleData(100, 0); - const pgData = await fetchCurrentPgData(); - - // 서버 액션으로 샘플 데이터 동기화 수행 - async function handleSyncSample() { - "use server"; - await syncSampleDataToPostgres(); - // 반환 없이 void로 처리 - } - - // 서버 액션으로 전체 데이터 동기화 수행 - async function handleSyncAll() { - "use server"; - await syncAllDataToPostgres(); - // 반환 없이 void로 처리 - } - - return ( - <div className="p-8 space-y-6"> - <h1 className="text-2xl font-bold">Items 테이블 데이터 관리</h1> - <p className="text-muted-foreground">PLM의 Oracle DB에서 자재마스터 클래스 정보를 Items 테이블로 이관하는 페이지입니다.</p> - - <div className="grid grid-cols-1 md:grid-cols-2 gap-8"> - <div className="border rounded-lg"> - <div className="p-4 border-b bg-muted/50"> - <h2 className="text-xl font-semibold">Oracle DB 데이터</h2> - <p className="text-sm text-muted-foreground">총 {totalCount}개 중 {oracleData.length}개의 레코드</p> - </div> - - <div className="overflow-auto max-h-80 p-4"> - <Table> - <TableHeader> - <TableRow> - <TableHead>클래스코드</TableHead> - <TableHead>클래스명</TableHead> - <TableHead>클래스내역</TableHead> - <TableHead>레벨</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {oracleData.map((row: OracleRow, idx: number) => { - // 컬럼 인덱스 찾기 - const getColIndex = (name: string) => metadata.findIndex((col: OracleColumn) => col.name === name); - const classCdIdx = getColIndex('CLAS_CD'); - const clasNmIdx = getColIndex('CLAS_NM'); - const clasDtlIdx = getColIndex('CLAS_DTL'); - const clasLvlIdx = getColIndex('CLAS_LVL'); - - return ( - <TableRow key={idx}> - <TableCell>{row[classCdIdx]}</TableCell> - <TableCell>{row[clasNmIdx]}</TableCell> - <TableCell>{row[clasDtlIdx]}</TableCell> - <TableCell>{row[clasLvlIdx]}</TableCell> - </TableRow> - ); - })} - </TableBody> - </Table> - </div> - </div> - - <div className="border rounded-lg"> - <div className="p-4 border-b bg-muted/50"> - <h2 className="text-xl font-semibold">Items 테이블 데이터</h2> - <p className="text-sm text-muted-foreground">총 {pgData.length}개의 레코드</p> - </div> - - <div className="overflow-auto max-h-80 p-4"> - <Table> - <TableHeader> - <TableRow> - <TableHead>ID</TableHead> - <TableHead>아이템코드</TableHead> - <TableHead>아이템명</TableHead> - <TableHead>레벨</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {pgData.map((row) => ( - <TableRow key={row.id}> - <TableCell>{row.id}</TableCell> - <TableCell>{row.itemCode}</TableCell> - <TableCell>{row.itemName}</TableCell> - <TableCell>{row.itemLevel}</TableCell> - </TableRow> - ))} - </TableBody> - </Table> - </div> - </div> - </div> - - <div className="flex flex-col gap-6 mt-8"> - <div className="flex flex-wrap gap-4"> - <form action={handleSyncSample}> - <Button type="submit" variant="default"> - 샘플 데이터 이관 (100건) - </Button> - </form> - - <form action={handleSyncAll}> - <Button type="submit" variant="secondary"> - 전체 데이터 이관 (최대 50,000건) - </Button> - </form> - </div> - - <div className="border rounded-lg p-4"> - <h3 className="text-lg font-semibold mb-2">데이터 이관 시 참고사항</h3> - <ul className="list-disc list-inside space-y-1 text-sm text-muted-foreground"> - <li>샘플 데이터 이관은 100건의 데이터만 Items 테이블에 저장합니다.</li> - <li>전체 데이터 이관은 1,000건씩 나누어 최대 50,000건까지 처리합니다.</li> - <li>데이터 양이 많을 경우 이관 작업에 시간이 소요될 수 있습니다.</li> - <li>기존 데이터는 유지하고 아이템코드가 같은 경우 업데이트합니다 (UPSERT).</li> - <li>Oracle의 CMCTB_MAT_CLAS 테이블 데이터를 Items 테이블로 매핑합니다.</li> - </ul> - </div> - </div> - </div> - ); -}
\ No newline at end of file diff --git a/app/[lng]/evcp/(evcp)/items-tech/layout.tsx b/app/[lng]/evcp/(evcp)/items-tech/layout.tsx deleted file mode 100644 index d375059b..00000000 --- a/app/[lng]/evcp/(evcp)/items-tech/layout.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import * as React from "react"
-import { ItemTechContainer } from "@/components/items-tech/item-tech-container"
-import { Shell } from "@/components/shell"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-
-// Layout 컴포넌트는 서버 컴포넌트입니다
-export default function ItemsShipLayout({
- children,
-}: {
- children: React.ReactNode
-}) {
- // 아이템 타입 정의
- const itemTypes = [
- { id: "ship", name: "조선 아이템" },
- { id: "top", name: "해양 TOP" },
- { id: "hull", name: "해양 HULL" },
- ]
-
- return (
- <Shell className="gap-4">
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <ItemTechContainer itemTypes={itemTypes}>
- {children}
- </ItemTechContainer>
- </React.Suspense>
- </Shell>
- )
-}
diff --git a/app/[lng]/evcp/(evcp)/items-tech/page.tsx b/app/[lng]/evcp/(evcp)/items-tech/page.tsx deleted file mode 100644 index 52ff519d..00000000 --- a/app/[lng]/evcp/(evcp)/items-tech/page.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import * as React from "react"
-import { type SearchParams } from "@/types/table"
-
-import { getValidFilters } from "@/lib/data-table"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { searchParamsCache } from "@/lib/items-tech/validations"
-import { getShipbuildingItems, getOffshoreTopItems, getOffshoreHullItems } from "@/lib/items-tech/service"
-import { OffshoreTopTable } from "@/lib/items-tech/table/top/offshore-top-table"
-import { OffshoreHullTable } from "@/lib/items-tech/table/hull/offshore-hull-table"
-
-// 대소문자 문제 해결 - 실제 파일명에 맞게 import
-import { ItemsShipTable } from "@/lib/items-tech/table/ship/Items-ship-table"
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function IndexPage({ searchParams }: IndexPageProps) {
- const params = await searchParams
- const search = searchParamsCache.parse(params)
- const validFilters = getValidFilters(search.filters || [])
-
- // URL에서 아이템 타입 가져오기
- const itemType = params.type || "ship"
-
- return (
- <div>
- {itemType === "ship" && (
- <ItemsShipTable
- promises={Promise.all([
- getShipbuildingItems({
- ...search,
- filters: validFilters,
- }),
- ]).then(([result]) => result)}
- />
- )}
-
- {itemType === "top" && (
- <OffshoreTopTable
- promises={Promise.all([
- getOffshoreTopItems({
- ...search,
- filters: validFilters,
- }),
- ]).then(([result]) => result)}
- />
- )}
-
- {itemType === "hull" && (
- <OffshoreHullTable
- promises={Promise.all([
- getOffshoreHullItems({
- ...search,
- filters: validFilters,
- }),
- ]).then(([result]) => result)}
- />
- )}
- </div>
- )
-}
diff --git a/app/[lng]/evcp/(evcp)/po-rfq/page.tsx b/app/[lng]/evcp/(evcp)/po-rfq/page.tsx index dfaa7708..bdeae25e 100644 --- a/app/[lng]/evcp/(evcp)/po-rfq/page.tsx +++ b/app/[lng]/evcp/(evcp)/po-rfq/page.tsx @@ -1,86 +1,61 @@ -import { Suspense } from "react" import { getPORfqs } from "@/lib/procurement-rfqs/services" import { searchParamsCache } from "@/lib/procurement-rfqs/validations" +import { getValidFilters } from "@/lib/data-table" import { Shell } from "@/components/shell" import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import RFQContainer from "@/components/po-rfq/po-rfq-container" +import { RFQListTable } from "@/lib/procurement-rfqs/table/rfq-table" +import { type SearchParams } from "@/types/table" +import * as React from "react" interface RfqPageProps { - searchParams: Promise<{ [key: string]: string | string[] | undefined }>; - title?: string; - description?: string; + searchParams: Promise<SearchParams> } -export default async function RfqPage({ - searchParams, - title = "발주용 견적", - description = "SAP으로부터 전송된 발주용 견적을 관리할 수 있습니다.", -}: RfqPageProps) { +export default async function RfqPage(props: 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) + const searchParams = await props.searchParams + + // 파라미터 파싱 + const search = searchParamsCache.parse(searchParams); + const validFilters = getValidFilters(search.filters); + + // RFQ 서버는 기본필터와 고급필터를 분리해서 받으므로 그대로 전달 + const promises = Promise.all([ + getPORfqs({ + ...search, // 모든 파라미터 전달 (page, perPage, sort, basicFilters, filters 등) + 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"> + <Shell variant="fullscreen" className="h-full"> {/* fullscreen variant 사용 */} + {/* 고정 헤더 영역 */} + <div className="flex-shrink-0"> + <div className="flex items-center justify-between"> <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> + + {/* 테이블 영역 - 남은 공간 모두 차지 */} + <div className="flex-1 min-h-0"> + <React.Suspense + fallback={ + <DataTableSkeleton + columnCount={8} + searchableColumnCount={2} + filterableColumnCount={3} + cellWidths={["10rem", "15rem", "12rem", "12rem", "8rem", "8rem", "10rem", "8rem"]} + shrinkZero + /> + } + > + <RFQListTable promises={promises} className="h-full" /> + </React.Suspense> + </div> </Shell> ) }
\ No newline at end of file diff --git a/app/[lng]/evcp/(evcp)/pq_new/[vendorId]/[submissionId]/page.tsx b/app/[lng]/evcp/(evcp)/pq_new/[vendorId]/[submissionId]/page.tsx new file mode 100644 index 00000000..28ce3128 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/pq_new/[vendorId]/[submissionId]/page.tsx @@ -0,0 +1,215 @@ +import * as React from "react" +import { Metadata } from "next" +import Link from "next/link" +import { notFound } from "next/navigation" +import { ArrowLeft } from "lucide-react" +import { Shell } from "@/components/shell" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { Separator } from "@/components/ui/separator" +import { getPQById, getPQDataByVendorId } from "@/lib/pq/service" +import { unstable_noStore as noStore } from 'next/cache' +import { PQReviewWrapper } from "@/components/pq-input/pq-review-wrapper" + +export const metadata: Metadata = { + title: "PQ 검토", + description: "협력업체의 Pre-Qualification 답변을 검토합니다.", +} + +// 페이지가 기본적으로 동적임을 나타냄 +export const dynamic = "force-dynamic" + +interface PQReviewPageProps { + params: Promise<{ + vendorId: string; + submissionId: string; + }> +} + +export default async function PQReviewPage(props: PQReviewPageProps) { + // 캐시 비활성화 + noStore() + + const params = await props.params + const vendorId = parseInt(params.vendorId, 10) + const submissionId = parseInt(params.submissionId, 10) + + try { + // PQ Submission 정보 조회 + const pqSubmission = await getPQById(submissionId, vendorId) + + // PQ 데이터 조회 (질문과 답변) + const pqData = await getPQDataByVendorId(vendorId, pqSubmission.projectId || undefined) + + // 프로젝트 정보 (프로젝트 PQ인 경우) + const projectInfo = pqSubmission.projectId ? { + id: pqSubmission.projectId, + projectCode: pqSubmission.projectCode || '', + projectName: pqSubmission.projectName || '', + status: pqSubmission.status, + submittedAt: pqSubmission.submittedAt, + } : null + + // PQ 유형 및 상태 레이블 + const typeLabel = pqSubmission.type === "GENERAL" ? "일반 PQ" : "프로젝트 PQ" + const statusLabel = getStatusLabel(pqSubmission.status) + const statusVariant = getStatusVariant(pqSubmission.status) + + // 수정 가능 여부 (SUBMITTED 상태일 때만 가능) + const canReview = pqSubmission.status === "SUBMITTED" + + return ( + <Shell className="gap-6 max-w-5xl"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-4"> + <Button variant="outline" size="sm" asChild> + <Link href="/evcp/pq_new"> + <ArrowLeft className="w-4 h-4 mr-2" /> + 목록으로 + </Link> + </Button> + <div> + <h2 className="text-2xl font-bold tracking-tight"> + {pqSubmission.vendorName} - {typeLabel} + </h2> + <div className="flex items-center gap-2 mt-1"> + <Badge variant={statusVariant}>{statusLabel}</Badge> + {projectInfo && ( + <span className="text-muted-foreground"> + {projectInfo.projectName} ({projectInfo.projectCode}) + </span> + )} + </div> + </div> + </div> + </div> + + {/* 상태별 알림 */} + {pqSubmission.status === "SUBMITTED" && ( + <Alert> + <AlertTitle>제출 완료</AlertTitle> + <AlertDescription> + 협력업체가 {formatDate(pqSubmission.submittedAt)}에 PQ를 제출했습니다. 검토 후 승인 또는 거부할 수 있습니다. + </AlertDescription> + </Alert> + )} + + {pqSubmission.status === "APPROVED" && ( + <Alert variant="success"> + <AlertTitle>승인됨</AlertTitle> + <AlertDescription> + {formatDate(pqSubmission.approvedAt)}에 승인되었습니다. + </AlertDescription> + </Alert> + )} + + {pqSubmission.status === "REJECTED" && ( + <Alert variant="destructive"> + <AlertTitle>거부됨</AlertTitle> + <AlertDescription> + {formatDate(pqSubmission.rejectedAt)}에 거부되었습니다. + {pqSubmission.rejectReason && ( + <div className="mt-2"> + <strong>사유:</strong> {pqSubmission.rejectReason} + </div> + )} + </AlertDescription> + </Alert> + )} + + <Separator /> + + {/* PQ 검토 컴포넌트 */} + <Tabs defaultValue="review" className="w-full"> + <TabsList> + <TabsTrigger value="review">PQ 검토</TabsTrigger> + <TabsTrigger value="vendor-info">협력업체 정보</TabsTrigger> + </TabsList> + + <TabsContent value="review" className="mt-4"> + <PQReviewWrapper + pqData={pqData} + vendorId={vendorId} + pqSubmission={pqSubmission} + canReview={canReview} + /> + </TabsContent> + + <TabsContent value="vendor-info" className="mt-4"> + <div className="rounded-md border p-4"> + <h3 className="text-lg font-medium mb-4">협력업체 정보</h3> + <div className="grid grid-cols-2 gap-4"> + <div> + <p className="text-sm font-medium text-muted-foreground">업체명</p> + <p>{pqSubmission.vendorName}</p> + </div> + <div> + <p className="text-sm font-medium text-muted-foreground">업체 코드</p> + <p>{pqSubmission.vendorCode}</p> + </div> + <div> + <p className="text-sm font-medium text-muted-foreground">상태</p> + <p>{pqSubmission.vendorStatus}</p> + </div> + {/* 필요시 추가 정보 표시 */} + </div> + </div> + </TabsContent> + </Tabs> + </Shell> + ) + } catch (error) { + console.error("Error loading PQ:", error) + notFound() + } +} + +// 상태 레이블 함수 +function getStatusLabel(status: string): string { + switch (status) { + case "REQUESTED": + return "요청됨"; + case "IN_PROGRESS": + return "진행 중"; + case "SUBMITTED": + return "제출됨"; + case "APPROVED": + return "승인됨"; + case "REJECTED": + return "거부됨"; + default: + return status; + } +} + +// 상태별 Badge 스타일 +function getStatusVariant(status: string): "default" | "outline" | "secondary" | "destructive" | "success" { + switch (status) { + case "REQUESTED": + return "outline"; + case "IN_PROGRESS": + return "secondary"; + case "SUBMITTED": + return "default"; + case "APPROVED": + return "success"; + case "REJECTED": + return "destructive"; + default: + return "outline"; + } +} + +// 날짜 형식화 함수 +function formatDate(date: Date | null) { + if (!date) return "날짜 없음"; + return new Date(date).toLocaleDateString("ko-KR", { + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit" + }); +}
\ No newline at end of file diff --git a/app/[lng]/evcp/(evcp)/pq_new/page.tsx b/app/[lng]/evcp/(evcp)/pq_new/page.tsx new file mode 100644 index 00000000..6598349b --- /dev/null +++ b/app/[lng]/evcp/(evcp)/pq_new/page.tsx @@ -0,0 +1,96 @@ +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 { searchParamsPQReviewCache } from "@/lib/pq/validations" +import { getPQSubmissions } from "@/lib/pq/service" +import { PQSubmissionsTable } from "@/lib/pq/pq-review-table-new/vendors-table" + +export const metadata: Metadata = { + title: "PQ 검토/실사 의뢰", + description: "", +} + +interface PQReviewPageProps { + searchParams: Promise<SearchParams> +} + +export default async function PQReviewPage(props: PQReviewPageProps) { + const searchParams = await props.searchParams + const search = searchParamsPQReviewCache.parse(searchParams) + const validFilters = getValidFilters(search.filters) + + // 디버깅 로그 추가 + console.log("=== PQ Page Debug ==="); + console.log("Raw searchParams:", searchParams); + console.log("Raw basicFilters param:", searchParams.basicFilters); + console.log("Raw pqBasicFilters param:", searchParams.pqBasicFilters); + console.log("Parsed search:", search); + console.log("search.filters:", search.filters); + console.log("search.basicFilters:", search.basicFilters); + console.log("search.pqBasicFilters:", search.pqBasicFilters); + console.log("validFilters:", validFilters); + + // 기본 필터 처리 (통일된 이름 사용) + let basicFilters = [] + if (search.basicFilters && search.basicFilters.length > 0) { + basicFilters = search.basicFilters + console.log("Using search.basicFilters:", basicFilters); + } else if (search.pqBasicFilters && search.pqBasicFilters.length > 0) { + // 하위 호환성을 위해 기존 이름도 지원 + basicFilters = search.pqBasicFilters + console.log("Using search.pqBasicFilters:", basicFilters); + } else { + console.log("No basic filters found"); + } + + // 모든 필터를 합쳐서 처리 + const allFilters = [...validFilters, ...basicFilters] + + console.log("Final allFilters:", allFilters); + + // 조인 연산자도 통일된 이름 사용 + const joinOperator = search.basicJoinOperator || search.pqBasicJoinOperator || search.joinOperator || 'and'; + console.log("Final joinOperator:", joinOperator); + + // Promise.all로 감싸서 전달 + const promises = Promise.all([ + getPQSubmissions({ + ...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> + <h2 className="text-2xl font-bold tracking-tight"> + PQ 검토/실사 의뢰 + </h2> + </div> + </div> + </div> + + {/* Items처럼 직접 테이블 렌더링 */} + <React.Suspense + key={JSON.stringify(searchParams)} // URL 파라미터가 변경될 때마다 강제 리렌더링 + fallback={ + <DataTableSkeleton + columnCount={8} + searchableColumnCount={2} + filterableColumnCount={3} + cellWidths={["10rem", "15rem", "12rem", "12rem", "8rem", "8rem", "10rem", "8rem"]} + shrinkZero + /> + } + > + <PQSubmissionsTable promises={promises} /> + </React.Suspense> + </Shell> + ) +}
\ No newline at end of file diff --git a/app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/items/page.tsx b/app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/items/page.tsx deleted file mode 100644 index 5ca4492e..00000000 --- a/app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/items/page.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { getVendorItemsByType, findVendorById } from "@/lib/tech-vendors/service" -import { type SearchParams } from "@/types/table" -import { TechVendorItemsTable } from "@/lib/tech-vendors/items-table/item-table" -import { notFound } from "next/navigation" - -interface PageProps { - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function Page(props: PageProps) { - const resolvedParams = await props.params - const id = resolvedParams.id - const vendorId = Number(id) - - if (isNaN(vendorId)) { - notFound() - } - - const vendor = await findVendorById(vendorId) - if (!vendor) { - notFound() - } - - const items = await getVendorItemsByType(vendorId, vendor.techVendorType) - - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - Possible Items - </h3> - <p className="text-sm text-muted-foreground"> - 딜리버리가 가능한 아이템 리스트를 확인할 수 있습니다. - </p> - </div> - <Separator /> - <div> - <TechVendorItemsTable - data={items.data.map(item => ({ - ...item, - vendorId, - itemName: item.itemCode, - vendorItemId: item.id - }))} - vendorId={vendorId} - vendorType={vendor.techVendorType} - /> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/layout.tsx b/app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/layout.tsx deleted file mode 100644 index 508ae82a..00000000 --- a/app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/layout.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { Metadata } from "next" - -import { Separator } from "@/components/ui/separator" -import { SidebarNav } from "@/components/layout/sidebar-nav" -import { findVendorById } from "@/lib/tech-vendors/service" -import { TechVendor } from "@/db/schema/techVendors" -import { Button } from "@/components/ui/button" -import { ArrowLeft } from "lucide-react" -import Link from "next/link" - -export const metadata: Metadata = { - title: "Tech Vendor Detail", -} - -export default async function SettingsLayout({ - children, - params, -}: { - children: React.ReactNode - params: { lng: string, id: string } -}) { - const resolvedParams = await params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - const vendor: TechVendor | null = await findVendorById(idAsNumber) - - const sidebarNavItems = [ - { - title: "연락처", - href: `/${lng}/evcp/tech-vendors/${id}/info`, - }, - { - title: "공급품목", - href: `/${lng}/evcp/tech-vendors/${id}/info/items`, - }, - ] - - return ( - <> - <div className="container py-6"> - <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow"> - <div className="hidden space-y-6 p-10 pb-16 md:block"> - <div className="flex items-center justify-end mb-4"> - <Link href={`/${lng}/evcp/tech-vendors`} passHref> - <Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto"> - <ArrowLeft className="mr-1 h-4 w-4" /> - <span>기술협력업체 목록으로 돌아가기</span> - </Button> - </Link> - </div> - <div className="space-y-0.5"> - <h2 className="text-2xl font-bold tracking-tight"> - {vendor - ? `${vendor.vendorCode ?? ""} - ${vendor.vendorName} 상세 정보` - : "Loading Vendor..."} - </h2> - <p className="text-muted-foreground">기술협력업체 관련 상세사항을 확인하세요.</p> - </div> - <Separator className="my-6" /> - <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0"> - <aside className="-mx-4 lg:w-1/5"> - <SidebarNav items={sidebarNavItems} /> - </aside> - <div className="flex-1">{children}</div> - </div> - </div> - </section> - </div> - </> - ) -}
\ No newline at end of file diff --git a/app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/page.tsx b/app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/page.tsx deleted file mode 100644 index 0092ee70..00000000 --- a/app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/page.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { getTechVendorContacts } from "@/lib/tech-vendors/service" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { searchParamsContactCache } from "@/lib/tech-vendors/validations" -import { TechVendorContactsTable } from "@/lib/tech-vendors/contacts-table/contact-table" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function SettingsAccountPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsContactCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - - - const promises = Promise.all([ - getTechVendorContacts({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - Contacts - </h3> - <p className="text-sm text-muted-foreground"> - 업무별 담당자 정보를 확인하세요. - </p> - </div> - <Separator /> - <div> - <TechVendorContactsTable promises={promises} vendorId={idAsNumber}/> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/evcp/(evcp)/tech-vendors/page.tsx b/app/[lng]/evcp/(evcp)/tech-vendors/page.tsx deleted file mode 100644 index 176a6fbc..00000000 --- a/app/[lng]/evcp/(evcp)/tech-vendors/page.tsx +++ /dev/null @@ -1,71 +0,0 @@ -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 { searchParamsCache } from "@/lib/tech-vendors/validations" -import { getTechVendors, getTechVendorStatusCounts } from "@/lib/tech-vendors/service" -import { TechVendorsTable } from "@/lib/tech-vendors/table/tech-vendors-table" -import { Ellipsis } from "lucide-react" - -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([ - getTechVendors({ - ...search, - filters: validFilters, - }), - getTechVendorStatusCounts(), - ]) - - 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> - 을 통해 담당자 연락처, 공급 가능 아이템 등을 확인할 수 있습니다. <br/> - 벤더의 상태에 따라 가입을 승인해주거나 거부할 수 있습니다. - </p> - </div> - </div> - </div> - - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - {/* 필요한 경우 데이터 범위 선택기 등의 추가 UI를 이곳에 배치할 수 있습니다 */} - </React.Suspense> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <TechVendorsTable promises={promises} /> - </React.Suspense> - </Shell> - ) -} diff --git a/app/[lng]/partners/(partners)/document-list/[contractId]/page.tsx b/app/[lng]/partners/(partners)/document-list/[contractId]/page.tsx index 65df0b1f..80621682 100644 --- a/app/[lng]/partners/(partners)/document-list/[contractId]/page.tsx +++ b/app/[lng]/partners/(partners)/document-list/[contractId]/page.tsx @@ -4,6 +4,8 @@ import { getValidFilters } from "@/lib/data-table" import { searchParamsCache } from "@/lib/vendor-document-list/validations" import { getVendorDocuments } from "@/lib/vendor-document-list/service" import { DocumentsTable } from "@/lib/vendor-document-list/table/doc-table" +import { EnhancedDocumentsTable } from "@/lib/vendor-document-list/table/enhanced-documents-table" +import { getEnhancedDocuments } from "@/lib/vendor-document-list/enhanced-document-service" interface IndexPageProps { params: { @@ -27,7 +29,7 @@ export default async function DocumentListPage(props: IndexPageProps) { const projectType = searchParams.projectType === "plant" ? "plant" : "ship" const promises = Promise.all([ - getVendorDocuments({ + getEnhancedDocuments({ ...search, filters: validFilters, }, idAsNumber) @@ -37,7 +39,7 @@ export default async function DocumentListPage(props: IndexPageProps) { return ( <div className="space-y-6"> <div> - <DocumentsTable promises={promises} selectedPackageId={idAsNumber} projectType={projectType}/> + <EnhancedDocumentsTable promises={promises} selectedPackageId={idAsNumber} projectType={projectType}/> </div> </div> ) diff --git a/app/[lng]/partners/(partners)/vendor-data/form/[packageId]/[formId]/[projectId]/[contractId]/page.tsx b/app/[lng]/partners/(partners)/vendor-data/form/[packageId]/[formId]/[projectId]/[contractId]/page.tsx index 71a02ab3..9a305318 100644 --- a/app/[lng]/partners/(partners)/vendor-data/form/[packageId]/[formId]/[projectId]/[contractId]/page.tsx +++ b/app/[lng]/partners/(partners)/vendor-data/form/[packageId]/[formId]/[projectId]/[contractId]/page.tsx @@ -57,7 +57,7 @@ export default async function FormPage({ params, searchParams }: IndexPageProps) // 7) 예외 처리 if (!columns) { return ( - <p className="text-red-500">해당 폼의 메타 정보를 불러올 수 없습니다.</p> + <p className="text-red-500">해당 폼의 메타 정보를 불러올 수 없습니다. ENG 모드의 경우에는 SHI 관리자에게 폼 생성 요청을 하시기 바랍니다.</p> ); } diff --git a/app/[lng]/partners/pq_new/[id]/page.tsx b/app/[lng]/partners/pq_new/[id]/page.tsx new file mode 100644 index 00000000..52085163 --- /dev/null +++ b/app/[lng]/partners/pq_new/[id]/page.tsx @@ -0,0 +1,205 @@ +import { Metadata } from "next"; +import Link from "next/link"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { Button } from "@/components/ui/button"; +import { ArrowLeft, LogIn } from "lucide-react"; +import { Shell } from "@/components/shell"; +import { getPQById, getPQDataByVendorId } from "@/lib/pq/service"; +import { unstable_noStore as noStore } from 'next/cache'; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { PQInputTabs } from "@/components/pq-input/pq-input-tabs"; + +export const metadata: Metadata = { + title: "사전 평가 (PQ) 작성", + description: "사전 평가 항목을 작성합니다.", +}; + +// 페이지가 기본적으로 동적임을 나타냄 +export const dynamic = "force-dynamic"; + +interface PQEditPageProps { + params: Promise<{ id: string }>; +} + +export default async function PQEditPage(props: PQEditPageProps) { + // 캐시 비활성화 + noStore(); + + const params = await props.params; + const pqSubmissionId = parseInt(params.id, 10); + + // 인증 확인 + 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"> + 사전 평가 (PQ) 작성 + </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> + <Button size="lg" asChild> + <Link href={`/partners?callbackUrl=/partners/pq/${pqSubmissionId}`}> + <LogIn className="mr-2 h-4 w-4" /> + 로그인하기 + </Link> + </Button> + </div> + </div> + </Shell> + ); + } + + // 세션에서 vendorId 가져오기 + const vendorId = session.user.companyId; + + // 벤더 권한 확인 + if (session.user.domain !== "partners" || !vendorId) { + 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 idAsNumber = Number(vendorId); + + try { + // PQ Submission 정보 조회 (vendorPQSubmissions 테이블에서) + const pqSubmission = await getPQById(pqSubmissionId, idAsNumber); + + // 이 PQ가 현재 로그인한 벤더의 것인지 확인 + if (pqSubmission.vendorId !== idAsNumber) { + throw new Error("Access denied - This PQ belongs to another vendor"); + } + + // PQ 데이터 조회 (pqCriterias와 답변) + const pqData = await getPQDataByVendorId(idAsNumber, pqSubmission.projectId || undefined); + + // 상태에 따른 읽기 전용 모드 결정 + const isReadOnly = ["SUBMITTED", "APPROVED"].includes(pqSubmission.status); + const statusText = pqSubmission.status === "SUBMITTED" ? "제출됨" : + pqSubmission.status === "APPROVED" ? "승인됨" : + pqSubmission.status === "REJECTED" ? "거부됨" : "작성 중"; + + const pageTitle = pqSubmission.type === "PROJECT" + ? `프로젝트 PQ - ${pqSubmission.projectName || pqSubmission.projectCode}` + : "일반 PQ"; + + // 프로젝트 정보 (프로젝트 PQ인 경우) + const projectPQ = pqSubmission.projectId ? { + id: pqSubmission.projectId, + projectCode: pqSubmission.projectCode || '', + projectName: pqSubmission.projectName || '', + status: pqSubmission.status, + submittedAt: pqSubmission.submittedAt, + } : null; + + return ( + <Shell className="gap-6"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-4"> + <Button variant="outline" size="sm" asChild> + <Link href="/partners/pq_new"> + <ArrowLeft className="w-4 h-4 mr-2" /> + 목록으로 + </Link> + </Button> + <div> + <h2 className="text-2xl font-bold tracking-tight"> + {pageTitle} + </h2> + <p className="text-muted-foreground"> + 상태: {statusText} + {pqSubmission.rejectReason && ( + <span className="text-destructive ml-2"> + (거부 사유: {pqSubmission.rejectReason}) + </span> + )} + </p> + </div> + </div> + </div> + + {/* 읽기 전용 모드 알림 */} + {isReadOnly && ( + <Alert> + <AlertDescription> + 이 PQ는 이미 제출되었습니다. 내용을 확인만 할 수 있습니다. + </AlertDescription> + </Alert> + )} + + {/* PQ 입력 컴포넌트 */} + <div className={isReadOnly ? "pointer-events-none opacity-60" : ""}> + <PQInputTabs + data={pqData} + vendorId={idAsNumber} + projectId={pqSubmission.projectId || undefined} + projectData={projectPQ} + isReadOnly={isReadOnly} + currentPQ={{ // 현재 PQ Submission 정보 전달 + id: pqSubmission.id, + status: pqSubmission.status, + type: pqSubmission.type + }} + /> + </div> + </Shell> + ); + } catch (error) { + console.error("Error loading PQ:", error); + + 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">PQ를 불러올 수 없습니다</h3> + <p className="mb-6 text-muted-foreground"> + 요청하신 PQ를 찾을 수 없거나 접근 권한이 없습니다. + </p> + <Button asChild> + <Link href="/partners/pq"> + <ArrowLeft className="mr-2 h-4 w-4" /> + 목록으로 돌아가기 + </Link> + </Button> + </div> + </div> + </Shell> + ); + } +}
\ No newline at end of file diff --git a/app/[lng]/partners/pq_new/page.tsx b/app/[lng]/partners/pq_new/page.tsx new file mode 100644 index 00000000..69498484 --- /dev/null +++ b/app/[lng]/partners/pq_new/page.tsx @@ -0,0 +1,267 @@ +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 { Badge } from "@/components/ui/badge"; +import { LogIn, Edit, Eye } from "lucide-react"; +import { Shell } from "@/components/shell"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { unstable_noStore as noStore } from 'next/cache'; +import { getAllPQsByVendorId, getPQStatusCounts } from "@/lib/pq/service"; + +export const metadata: Metadata = { + title: "사전 평가 (PQ) 목록", + description: "요청된 사전 평가 목록을 확인하고 작성합니다.", +}; + +// 페이지가 기본적으로 동적임을 나타냄 +export const dynamic = "force-dynamic"; + +function getStatusBadge(status: string) { + switch (status) { + case "REQUESTED": + return <Badge variant="outline">요청됨</Badge>; + case "IN_PROGRESS": + return <Badge variant="secondary">진행 중</Badge>; + case "SUBMITTED": + return <Badge variant="default">제출됨</Badge>; + case "APPROVED": + return <Badge variant="default">승인됨</Badge>; + case "REJECTED": + return <Badge variant="destructive">거부됨</Badge>; + default: + return <Badge variant="outline">{status}</Badge>; + } +} + +function getFormattedDate(date: Date | null) { + if (!date) return "-"; + return new Intl.DateTimeFormat("ko-KR", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(new Date(date)); +} + +export default async function PQListPage() { + // 캐시 비활성화 + noStore(); + + // 인증 확인 + 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"> + 사전 평가 (PQ) 목록 + </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=/partners/pq"> + <LogIn className="mr-2 h-4 w-4" /> + 로그인하기 + </Link> + </Button> + </div> + </div> + </Shell> + ); + } + + // 세션에서 vendorId 가져오기 + const vendorId = session.user.companyId; + + // 벤더 권한 확인 + if (session.user.domain !== "partners" || !vendorId) { + 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 idAsNumber = Number(vendorId); + + // 데이터 가져오기 (병렬 실행) + const [pqList, pqStatusCounts] = await Promise.all([ + getAllPQsByVendorId(idAsNumber), + getPQStatusCounts(idAsNumber), + ]); + + return ( + <Shell className="gap-6"> + <div className="flex justify-between items-center"> + <div> + <h2 className="text-2xl font-bold tracking-tight">사전 평가 (PQ) 목록</h2> + <p className="text-muted-foreground"> + 요청된 사전 평가 목록을 확인하고 작성합니다. + </p> + </div> + </div> + + {/* PQ 상태 요약 카드 */} + <div className="grid gap-4 md:grid-cols-4"> + <Card> + <CardHeader className="py-4"> + <CardTitle className="text-base">총 PQ</CardTitle> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold"> + {Object.values(pqStatusCounts).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"> + {(pqStatusCounts.REQUESTED || 0) + (pqStatusCounts.IN_PROGRESS || 0)}건 + </div> + </CardContent> + </Card> + <Card> + <CardHeader className="py-4"> + <CardTitle className="text-base">제출됨</CardTitle> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold"> + {pqStatusCounts.SUBMITTED || 0}건 + </div> + </CardContent> + </Card> + <Card> + <CardHeader className="py-4"> + <CardTitle className="text-base">승인됨</CardTitle> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold"> + {pqStatusCounts.APPROVED || 0}건 + </div> + </CardContent> + </Card> + </div> + + {/* PQ 목록 테이블 */} + <Card> + <CardHeader> + <CardTitle>PQ 목록</CardTitle> + </CardHeader> + <CardContent className="p-0"> + <Table> + <TableHeader> + <TableRow> + <TableHead>유형</TableHead> + <TableHead>프로젝트</TableHead> + <TableHead>상태</TableHead> + <TableHead>요청일</TableHead> + <TableHead>제출일</TableHead> + <TableHead>승인일</TableHead> + <TableHead>액션</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {pqList.length === 0 ? ( + <TableRow> + <TableCell colSpan={7} className="text-center py-8 text-muted-foreground"> + 요청된 PQ가 없습니다. + </TableCell> + </TableRow> + ) : ( + pqList.map((pq) => { + const canEdit = ["REQUESTED", "IN_PROGRESS", "REJECTED"].includes(pq.status); + const canView = ["SUBMITTED", "APPROVED"].includes(pq.status); + + return ( + <TableRow key={pq.id}> + <TableCell> + <Badge variant={pq.type === "PROJECT" ? "secondary" : "outline"}> + {pq.type === "PROJECT" ? "프로젝트" : "일반"} + </Badge> + </TableCell> + <TableCell> + {pq.projectName || "-"} + </TableCell> + <TableCell> + {getStatusBadge(pq.status)} + </TableCell> + <TableCell> + {getFormattedDate(pq.createdAt)} + </TableCell> + <TableCell> + {getFormattedDate(pq.submittedAt)} + </TableCell> + <TableCell> + {getFormattedDate(pq.approvedAt)} + </TableCell> + <TableCell> + <div className="flex gap-2"> + {canEdit && ( + <Button size="sm" variant="outline" asChild> + <Link href={`/partners/pq_new/${pq.id}`}> + <Edit className="w-4 h-4 mr-1" /> + 작성 + </Link> + </Button> + )} + {canView && ( + <Button size="sm" variant="outline" asChild> + <Link href={`/partners/pq_new/${pq.id}`}> + <Eye className="w-4 h-4 mr-1" /> + 보기 + </Link> + </Button> + )} + </div> + </TableCell> + </TableRow> + ); + }) + )} + </TableBody> + </Table> + </CardContent> + </Card> + </Shell> + ); +}
\ No newline at end of file |
