summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/[lng]/evcp/(evcp)/admin/if/items/page.tsx371
-rw-r--r--app/[lng]/evcp/(evcp)/items-tech/layout.tsx38
-rw-r--r--app/[lng]/evcp/(evcp)/items-tech/page.tsx62
-rw-r--r--app/[lng]/evcp/(evcp)/po-rfq/page.tsx107
-rw-r--r--app/[lng]/evcp/(evcp)/pq_new/[vendorId]/[submissionId]/page.tsx215
-rw-r--r--app/[lng]/evcp/(evcp)/pq_new/page.tsx96
-rw-r--r--app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/items/page.tsx56
-rw-r--r--app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/layout.tsx73
-rw-r--r--app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/page.tsx56
-rw-r--r--app/[lng]/evcp/(evcp)/tech-vendors/page.tsx71
-rw-r--r--app/[lng]/partners/(partners)/document-list/[contractId]/page.tsx6
-rw-r--r--app/[lng]/partners/(partners)/vendor-data/form/[packageId]/[formId]/[projectId]/[contractId]/page.tsx2
-rw-r--r--app/[lng]/partners/pq_new/[id]/page.tsx205
-rw-r--r--app/[lng]/partners/pq_new/page.tsx267
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