summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/[lng]/evcp/(evcp)/pcr/page.tsx64
-rw-r--r--app/[lng]/partners/(partners)/pcr/page.tsx76
-rw-r--r--db/schema/pcr.ts233
-rw-r--r--lib/pcr/actions.ts458
-rw-r--r--lib/pcr/service.ts753
-rw-r--r--lib/pcr/table/approve-reject-pcr-dialog.tsx231
-rw-r--r--lib/pcr/table/create-pcr-dialog.tsx642
-rw-r--r--lib/pcr/table/detail-table/create-pcr-pr-dialog.tsx598
-rw-r--r--lib/pcr/table/detail-table/pcr-detail-column.tsx333
-rw-r--r--lib/pcr/table/detail-table/pcr-detail-table.tsx121
-rw-r--r--lib/pcr/table/detail-table/pcr-detail-toolbar-action.tsx79
-rw-r--r--lib/pcr/table/edit-pcr-sheet.tsx237
-rw-r--r--lib/pcr/table/pcr-table-column.tsx416
-rw-r--r--lib/pcr/table/pcr-table-toolbar-actions.tsx120
-rw-r--r--lib/pcr/table/pcr-table.tsx396
-rw-r--r--lib/pcr/types.ts189
16 files changed, 4946 insertions, 0 deletions
diff --git a/app/[lng]/evcp/(evcp)/pcr/page.tsx b/app/[lng]/evcp/(evcp)/pcr/page.tsx
new file mode 100644
index 00000000..7617bf58
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/pcr/page.tsx
@@ -0,0 +1,64 @@
+import { Suspense } from "react"
+import { Shell } from "@/components/shell"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { InformationButton } from "@/components/information/information-button"
+
+import { PcrTable } from "@/lib/pcr/table/pcr-table";
+import { getPcrPoList } from "@/lib/pcr/service";
+
+export const metadata = {
+ title: "PCR 관리",
+ description: "Purchase Change Request를 생성하고 관리할 수 있습니다.",
+};
+
+async function PcrTableWrapper() {
+ // 기본 데이터 조회 (EvcP용 - 모든 데이터 조회)
+ const tableData = await getPcrPoList({
+ page: 1,
+ perPage: 10,
+ });
+
+ return <PcrTable tableData={tableData} isEvcpPage={true} currentVendorId={undefined} />;
+}
+
+export default function PcrPage() {
+ 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>
+ <div className="flex items-center gap-2">
+ <h2 className="text-2xl font-bold tracking-tight">
+ PCR 관리
+ </h2>
+ <InformationButton pagePath="evcp/pcr" />
+ </div>
+ <p className="text-muted-foreground">
+ Purchase Change Request를 생성하고 관리할 수 있습니다. PCR 승인 상태, 변경 구분, PO/계약 정보 등을 확인할 수 있습니다.
+ </p>
+ </div>
+ </div>
+ </div>
+
+ {/* ═══════════════════════════════════════════════════════════════ */}
+ {/* 메인 테이블 */}
+ {/* ═══════════════════════════════════════════════════════════════ */}
+ <Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={12}
+ searchableColumnCount={2}
+ filterableColumnCount={3}
+ cellWidths={["8rem", "8rem", "12rem", "12rem", "10rem", "12rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <PcrTableWrapper />
+ </Suspense>
+ </Shell>
+ );
+}
diff --git a/app/[lng]/partners/(partners)/pcr/page.tsx b/app/[lng]/partners/(partners)/pcr/page.tsx
new file mode 100644
index 00000000..dc639aa8
--- /dev/null
+++ b/app/[lng]/partners/(partners)/pcr/page.tsx
@@ -0,0 +1,76 @@
+import { Suspense } from "react"
+import { getServerSession } from "next-auth/next";
+import { authOptions } from "@/app/api/auth/[...nextauth]/route";
+import { redirect } from "next/navigation";
+import { Shell } from "@/components/shell"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { InformationButton } from "@/components/information/information-button"
+
+import { PcrTable } from "@/lib/pcr/table/pcr-table";
+import { getPcrPoListForPartners } from "@/lib/pcr/service";
+
+export const metadata = {
+ title: "PCR 관리",
+ description: "Purchase Change Request를 조회하고 관리할 수 있습니다.",
+};
+
+async function PartnersPcrTableWrapper() {
+ // 세션에서 사용자 정보 확인
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ redirect("/login");
+ }
+
+ const vendorId = session.user.companyId || 1;
+
+ // Partners용 데이터 조회 (현재 사용자의 vendorId에 해당하는 데이터만 조회)
+ const tableData = await getPcrPoListForPartners({
+ page: 1,
+ perPage: 10,
+ vendorId: vendorId,
+ });
+
+ return <PcrTable tableData={tableData} isEvcpPage={false} isPartnersPage={true} currentVendorId={vendorId} />;
+}
+
+export default function PartnersPcrPage() {
+ 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>
+ <div className="flex items-center gap-2">
+ <h2 className="text-2xl font-bold tracking-tight">
+ PCR 관리
+ </h2>
+ <InformationButton pagePath="partners/pcr" />
+ </div>
+ <p className="text-muted-foreground">
+ Purchase Change Request를 조회하고 관리할 수 있습니다. PCR 승인 상태, 변경 구분, PO/계약 정보 등을 확인할 수 있습니다.
+ </p>
+ </div>
+ </div>
+ </div>
+
+ {/* ═══════════════════════════════════════════════════════════════ */}
+ {/* 메인 테이블 */}
+ {/* ═══════════════════════════════════════════════════════════════ */}
+ <Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={12}
+ searchableColumnCount={2}
+ filterableColumnCount={3}
+ cellWidths={["8rem", "8rem", "12rem", "12rem", "10rem", "12rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <PartnersPcrTableWrapper />
+ </Suspense>
+ </Shell>
+ );
+}
diff --git a/db/schema/pcr.ts b/db/schema/pcr.ts
new file mode 100644
index 00000000..8d774f0d
--- /dev/null
+++ b/db/schema/pcr.ts
@@ -0,0 +1,233 @@
+/**
+ * PCR (Purchase Change Request) 관련 스키마
+ *
+ * PCR_PO: 구매/계약 정보 변경 요청 테이블
+ * PCR_PR: 구매 요청 세부 정보 테이블
+ */
+
+import {
+ pgTable,
+ serial,
+ varchar,
+ text,
+ timestamp,
+ integer,
+ date,
+ numeric,
+ index,
+} from "drizzle-orm/pg-core";
+import { relations } from "drizzle-orm";
+import { users } from "./users";
+import { vendors } from "./vendors";
+
+// ===== PCR_PR 첨부파일 테이블 =====
+export const pcrPrAttachment = pgTable("pcr_pr_attachment", {
+ id: serial("id").primaryKey(),
+ pcrPrId: integer("pcr_pr_id").notNull().references(() => pcrPr.id), // PCR_PR 참조
+ type: varchar("type", { length: 20 }).notNull(), // 'BEFORE' | 'AFTER' (변경전/변경후)
+ fileName: varchar("file_name", { length: 255 }).notNull(),
+ filePath: text("file_path").notNull(),
+ fileSize: integer("file_size"),
+ mimeType: varchar("mime_type", { length: 100 }),
+ createdBy: integer("created_by").references(() => users.id),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedBy: integer("updated_by").references(() => users.id),
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
+});
+
+// ===== PCR_PO 테이블 (상단 테이블) =====
+export const pcrPo = pgTable("pcr_po", {
+ id: serial("id").primaryKey(),
+ // 협력업체 정보 (EvcP 페이지에만 표시)
+ vendorId: integer("vendor_id").references(() => vendors.id),
+
+ // PCR 승인 상태
+ pcrApprovalStatus: varchar("pcr_approval_status", { length: 20 })
+ .default('PENDING'),
+
+ // 변경 구분
+ changeType: varchar("change_type", { length: 20 })
+ .default('OTHER'),
+
+ // 상세
+ details: text("details"),
+
+ // 프로젝트
+ project: varchar("project", { length: 255 }),
+
+ // PCR 요청 일자
+ pcrRequestDate: date("pcr_request_date", { mode: "date" }).notNull(),
+
+ // PO/계약 번호
+ poContractNumber: varchar("po_contract_number", { length: 100 }).notNull(),
+
+ // Rev./품번
+ revItemNumber: varchar("rev_item_number", { length: 100 }),
+
+ // 구매/계약 담당자
+ purchaseContractManager: varchar("purchase_contract_manager", { length: 100 }),
+
+ // PCR 생성자
+ pcrCreator: varchar("pcr_creator", { length: 100 }),
+
+ // PO/계약 금액 (전)
+ poContractAmountBefore: numeric("po_contract_amount_before", { precision: 15, scale: 2 }),
+
+ // PO/계약 금액 (후)
+ poContractAmountAfter: numeric("po_contract_amount_after", { precision: 15, scale: 2 }),
+
+ // 계약 통화
+ contractCurrency: varchar("contract_currency", { length: 10 }).default("KRW"),
+
+ // PCR 사유
+ pcrReason: text("pcr_reason"),
+
+ // 상세 사유
+ detailsReason: text("details_reason"),
+
+ // 거절 사유
+ rejectionReason: text("rejection_reason"),
+
+ // PCR 회신 일
+ pcrResponseDate: date("pcr_response_date", { mode: "date" }),
+
+
+ // 감사 필드
+ createdBy: integer("created_by")
+ .notNull()
+ .references(() => users.id, { onDelete: "set null" }),
+ updatedBy: integer("updated_by")
+ .notNull()
+ .references(() => users.id, { onDelete: "set null" }),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
+}, (table) => ({
+ // 인덱스 정의
+ poContractNumberIdx: index("pcr_po_po_contract_number_idx").on(table.poContractNumber),
+ vendorIdIdx: index("pcr_po_vendor_id_idx").on(table.vendorId),
+ pcrApprovalStatusIdx: index("pcr_po_approval_status_idx").on(table.pcrApprovalStatus),
+ createdAtIdx: index("pcr_po_created_at_idx").on(table.createdAt),
+}));
+
+// ===== PCR_PR 테이블 (하단 테이블) =====
+export const pcrPr = pgTable("pcr_pr", {
+ id: serial("id").primaryKey(),
+
+ // 자재번호
+ materialNumber: varchar("material_number", { length: 100 }).notNull(),
+
+ // 자재내역
+ materialDetails: text("material_details"),
+
+ // 수량 (변경전)
+ quantityBefore: numeric("quantity_before", { precision: 12, scale: 3 }),
+
+ // 수량 (변경후)
+ quantityAfter: numeric("quantity_after", { precision: 12, scale: 3 }),
+
+ // 중량 (변경전)
+ weightBefore: numeric("weight_before", { precision: 12, scale: 3 }),
+
+ // 중량 (변경후)
+ weightAfter: numeric("weight_after", { precision: 12, scale: 3 }),
+
+ // 사급중량 (변경전)
+ subcontractorWeightBefore: numeric("subcontractor_weight_before", { precision: 12, scale: 3 }),
+
+ // 사급중량 (변경후)
+ subcontractorWeightAfter: numeric("subcontractor_weight_after", { precision: 12, scale: 3 }),
+
+ // 도급중량 (변경전)
+ supplierWeightBefore: numeric("supplier_weight_before", { precision: 12, scale: 3 }),
+
+ // 도급중량 (변경후)
+ supplierWeightAfter: numeric("supplier_weight_after", { precision: 12, scale: 3 }),
+
+ // SPEC 도면 (변경전)
+ specDrawingBefore: text("spec_drawing_before"),
+
+ // SPEC 도면 (변경후)
+ specDrawingAfter: text("spec_drawing_after"),
+
+ // 최초 PO/계약 일
+ initialPoContractDate: date("initial_po_contract_date", { mode: "date" }),
+
+ // SPEC 변경 일
+ specChangeDate: date("spec_change_date", { mode: "date" }),
+
+ // PO/계약 수정 일
+ poContractModifiedDate: date("po_contract_modified_date", { mode: "date" }),
+
+ // 확인 일
+ confirmationDate: date("confirmation_date", { mode: "date" }),
+
+ // 설계 담당자
+ designManager: varchar("design_manager", { length: 100 }),
+
+ // PO/계약 번호 (PCR_PO와 연결)
+ poContractNumber: varchar("po_contract_number", { length: 100 }).notNull(),
+
+ // 감사 필드
+ createdBy: integer("created_by")
+ .notNull()
+ .references(() => users.id, { onDelete: "set null" }),
+ updatedBy: integer("updated_by")
+ .notNull()
+ .references(() => users.id, { onDelete: "set null" }),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
+}, (table) => ({
+ // 인덱스 정의
+ poContractNumberIdx: index("pcr_pr_po_contract_number_idx").on(table.poContractNumber),
+ materialNumberIdx: index("pcr_pr_material_number_idx").on(table.materialNumber),
+ createdAtIdx: index("pcr_pr_created_at_idx").on(table.createdAt),
+}));
+
+// ===== Relations 정의 =====
+
+export const pcrPoRelations = relations(pcrPo, ({ one, many }) => ({
+ // 생성자 관계
+ createdByUser: one(users, {
+ fields: [pcrPo.createdBy],
+ references: [users.id],
+ relationName: "pcrPoCreatedBy",
+ }),
+ updatedByUser: one(users, {
+ fields: [pcrPo.updatedBy],
+ references: [users.id],
+ relationName: "pcrPoUpdatedBy",
+ }),
+ // 협력업체 관계
+ vendor: one(vendors, {
+ fields: [pcrPo.vendorId],
+ references: [vendors.id],
+ }),
+ // 하위 PCR_PR 관계
+ pcrPrItems: many(pcrPr),
+}));
+
+export const pcrPrRelations = relations(pcrPr, ({ one }) => ({
+ // 생성자 관계
+ createdByUser: one(users, {
+ fields: [pcrPr.createdBy],
+ references: [users.id],
+ relationName: "pcrPrCreatedBy",
+ }),
+ updatedByUser: one(users, {
+ fields: [pcrPr.updatedBy],
+ references: [users.id],
+ relationName: "pcrPrUpdatedBy",
+ }),
+ // 상위 PCR_PO 관계
+ pcrPoItem: one(pcrPo, {
+ fields: [pcrPr.poContractNumber],
+ references: [pcrPo.poContractNumber],
+ }),
+}));
+
+// ===== 타입 정의 =====
+export type PcrPo = typeof pcrPo.$inferSelect;
+export type PcrPr = typeof pcrPr.$inferSelect;
+
+export type NewPcrPo = typeof pcrPo.$inferInsert;
+export type NewPcrPr = typeof pcrPr.$inferInsert;
diff --git a/lib/pcr/actions.ts b/lib/pcr/actions.ts
new file mode 100644
index 00000000..00c15f4b
--- /dev/null
+++ b/lib/pcr/actions.ts
@@ -0,0 +1,458 @@
+"use server"
+
+import { revalidatePath } from "next/cache"
+import { getServerSession } from "next-auth"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+import { confirmPCR } from "@/lib/soap/ecc/send/pcr-confirm"
+import { format } from "date-fns"
+import db from "@/db/db"
+import { pcrPo } from "@/db/schema"
+import { eq } from "drizzle-orm"
+import {
+ createPcrPo,
+ createPcrPr,
+ updatePcrPo,
+ updatePcrPr,
+ deletePcrPo,
+ deletePcrPr,
+ getPcrPoById,
+ getPcrPrById,
+} from "./service"
+
+/**
+ * PCR_PO 생성 Server Action
+ */
+export async function createPcrPoAction(data: {
+ pcrApprovalStatus?: string;
+ changeType?: string;
+ details?: string;
+ project?: string;
+ pcrRequestDate: Date;
+ poContractNumber: string;
+ revItemNumber?: string;
+ purchaseContractManager?: string;
+ pcrCreator?: string;
+ poContractAmountBefore?: number;
+ poContractAmountAfter?: number;
+ contractCurrency?: string;
+ pcrReason?: string;
+ detailsReason?: string;
+ rejectionReason?: string;
+ pcrResponseDate?: Date;
+ vendorId?: number;
+}) {
+ try {
+ const result = await createPcrPo(data);
+
+ if (result.success) {
+ // 관련 페이지들 재검증
+ revalidatePath("/evcp/pcr");
+ revalidatePath("/partners/pcr");
+
+ return { success: true, message: "PCR_PO가 성공적으로 생성되었습니다", data: result.data }
+ } else {
+ return { success: false, error: result.error }
+ }
+ } catch (error) {
+ console.error("PCR_PO 생성 액션 오류:", error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "PCR_PO 생성에 실패했습니다"
+ }
+ }
+}
+
+/**
+ * PCR_PR 생성 Server Action
+ */
+export async function createPcrPrAction(data: {
+ materialNumber: string;
+ materialDetails?: string;
+ quantityBefore?: number;
+ quantityAfter?: number;
+ weightBefore?: number;
+ weightAfter?: number;
+ subcontractorWeightBefore?: number;
+ subcontractorWeightAfter?: number;
+ supplierWeightBefore?: number;
+ supplierWeightAfter?: number;
+ specDrawingBefore?: string;
+ specDrawingAfter?: string;
+ initialPoContractDate?: Date;
+ specChangeDate?: Date;
+ poContractModifiedDate?: Date;
+ confirmationDate?: Date;
+ designManager?: string;
+ poContractNumber: string;
+}) {
+ try {
+ const result = await createPcrPr(data);
+
+ if (result.success) {
+ // 관련 페이지들 재검증
+ revalidatePath("/evcp/pcr");
+ revalidatePath("/partners/pcr");
+
+ return { success: true, message: "PCR_PR이 성공적으로 생성되었습니다", data: result.data }
+ } else {
+ return { success: false, error: result.error }
+ }
+ } catch (error) {
+ console.error("PCR_PR 생성 액션 오류:", error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "PCR_PR 생성에 실패했습니다"
+ }
+ }
+}
+
+/**
+ * PCR_PO 업데이트 Server Action
+ */
+export async function updatePcrPoAction(id: number, data: Partial<{
+ pcrApprovalStatus: string;
+ changeType: string;
+ details: string;
+ project: string;
+ pcrRequestDate: Date;
+ poContractNumber: string;
+ revItemNumber: string;
+ purchaseContractManager: string;
+ pcrCreator: string;
+ poContractAmountBefore: number;
+ poContractAmountAfter: number;
+ contractCurrency: string;
+ pcrReason: string;
+ detailsReason: string;
+ rejectionReason: string;
+ pcrResponseDate: Date;
+ vendorId: number;
+}>) {
+ try {
+ const result = await updatePcrPo(id, data);
+
+ if (result.success) {
+ // 관련 페이지들 재검증
+ revalidatePath("/evcp/pcr");
+ revalidatePath("/partners/pcr");
+
+ return { success: true, message: "PCR_PO가 성공적으로 업데이트되었습니다", data: result.data }
+ } else {
+ return { success: false, error: result.error }
+ }
+ } catch (error) {
+ console.error("PCR_PO 업데이트 액션 오류:", error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "PCR_PO 업데이트에 실패했습니다"
+ }
+ }
+}
+
+/**
+ * PCR_PR 업데이트 Server Action
+ */
+export async function updatePcrPrAction(id: number, data: Partial<{
+ materialNumber: string;
+ materialDetails: string;
+ quantityBefore: number;
+ quantityAfter: number;
+ weightBefore: number;
+ weightAfter: number;
+ subcontractorWeightBefore: number;
+ subcontractorWeightAfter: number;
+ supplierWeightBefore: number;
+ supplierWeightAfter: number;
+ specDrawingBefore: string;
+ specDrawingAfter: string;
+ initialPoContractDate: Date;
+ specChangeDate: Date;
+ poContractModifiedDate: Date;
+ confirmationDate: Date;
+ designManager: string;
+ poContractNumber: string;
+}>) {
+ try {
+ const result = await updatePcrPr(id, data);
+
+ if (result.success) {
+ // 관련 페이지들 재검증
+ revalidatePath("/evcp/pcr");
+ revalidatePath("/partners/pcr");
+
+ return { success: true, message: "PCR_PR이 성공적으로 업데이트되었습니다", data: result.data }
+ } else {
+ return { success: false, error: result.error }
+ }
+ } catch (error) {
+ console.error("PCR_PR 업데이트 액션 오류:", error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "PCR_PR 업데이트에 실패했습니다"
+ }
+ }
+}
+
+/**
+ * PCR_PO 삭제 Server Action
+ */
+export async function deletePcrPoAction(id: number) {
+ try {
+ const result = await deletePcrPo(id);
+
+ if (result.success) {
+ // 관련 페이지들 재검증
+ revalidatePath("/evcp/pcr");
+ revalidatePath("/partners/pcr");
+
+ return { success: true, message: "PCR_PO가 성공적으로 삭제되었습니다" }
+ } else {
+ return { success: false, error: result.error }
+ }
+ } catch (error) {
+ console.error("PCR_PO 삭제 액션 오류:", error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "PCR_PO 삭제에 실패했습니다"
+ }
+ }
+}
+
+/**
+ * PCR_PR 삭제 Server Action
+ */
+export async function deletePcrPrAction(id: number) {
+ try {
+ const result = await deletePcrPr(id);
+
+ if (result.success) {
+ // 관련 페이지들 재검증
+ revalidatePath("/evcp/pcr");
+ revalidatePath("/partners/pcr");
+
+ return { success: true, message: "PCR_PR이 성공적으로 삭제되었습니다" }
+ } else {
+ return { success: false, error: result.error }
+ }
+ } catch (error) {
+ console.error("PCR_PR 삭제 액션 오류:", error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "PCR_PR 삭제에 실패했습니다"
+ }
+ }
+}
+
+/**
+ * PCR_PO 단일 조회 Server Action
+ */
+export async function getPcrPoByIdAction(id: number) {
+ try {
+ const data = await getPcrPoById(id);
+ return { success: true, data }
+ } catch (error) {
+ console.error("PCR_PO 조회 액션 오류:", error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "PCR_PO 조회에 실패했습니다"
+ }
+ }
+}
+
+/**
+ * PCR_PR 단일 조회 Server Action
+ */
+export async function getPcrPrByIdAction(id: number) {
+ try {
+ const data = await getPcrPrById(id);
+ return { success: true, data }
+ } catch (error) {
+ console.error("PCR_PR 조회 액션 오류:", error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "PCR_PR 조회에 실패했습니다"
+ }
+ }
+}
+
+/**
+ * PCR 승인 Server Action
+ */
+export async function approvePcrAction(id: number, reason?: string) {
+ try {
+ // PCR 데이터 조회
+ const pcrData = await getPcrPoById(id);
+ if (!pcrData) {
+ return { success: false, error: "PCR 데이터를 찾을 수 없습니다" }
+ }
+
+ // PCR 확인 데이터 구성
+ const confirmData = {
+ PCR_REQ: pcrData.poContractNumber, // PO 계약번호를 PCR 요청번호로 사용
+ PCR_REQ_SEQ: String(pcrData.id).padStart(5, '0'), // ID를 순번으로 사용
+ PCR_DEC_DATE: format(new Date(), 'yyyyMMdd'), // 오늘 날짜
+ EBELN: pcrData.poContractNumber, // PO 계약번호
+ EBELP: pcrData.revItemNumber || '00010', // REV/품번 또는 기본값
+ PCR_STATUS: 'A', // 승인 상태
+ CONFIRM_CD: 'APPROVED',
+ CONFIRM_RSN: reason || 'PCR 승인 완료'
+ };
+
+ console.log(`🚀 PCR 승인 요청 시작: ${confirmData.PCR_REQ}-${confirmData.PCR_REQ_SEQ}`);
+
+ // ECC로 승인 요청 전송
+ const confirmResult = await confirmPCR(confirmData);
+
+ if (!confirmResult.success) {
+ console.error(`❌ PCR 승인 실패: ${confirmResult.message}`);
+ return { success: false, error: confirmResult.message }
+ }
+
+ // DB에서 PCR 상태 업데이트
+ const updateResult = await updatePcrPo(id, {
+ pcrApprovalStatus: 'APPROVED',
+ pcrResponseDate: new Date()
+ });
+
+ if (!updateResult.success) {
+ console.error(`❌ PCR 승인 상태 업데이트 실패: ${updateResult.error}`);
+ return { success: false, error: updateResult.error }
+ }
+
+ // 관련 페이지들 재검증
+ revalidatePath("/evcp/pcr");
+ revalidatePath("/partners/pcr");
+
+ console.log(`✅ PCR 승인 완료: ${confirmData.PCR_REQ}-${confirmData.PCR_REQ_SEQ}`);
+
+ return {
+ success: true,
+ message: "PCR이 성공적으로 승인되었습니다",
+ data: {
+ confirmResult,
+ updateResult: updateResult.data
+ }
+ }
+ } catch (error) {
+ console.error("PCR 승인 액션 오류:", error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "PCR 승인에 실패했습니다"
+ }
+ }
+}
+
+/**
+ * PCR 거절 Server Action
+ */
+export async function rejectPcrAction(id: number, reason: string) {
+ try {
+ // PCR 데이터 조회
+ const pcrData = await getPcrPoById(id);
+ if (!pcrData) {
+ return { success: false, error: "PCR 데이터를 찾을 수 없습니다" }
+ }
+
+ // PCR 확인 데이터 구성
+ const confirmData = {
+ PCR_REQ: pcrData.poContractNumber, // PO 계약번호를 PCR 요청번호로 사용
+ PCR_REQ_SEQ: String(pcrData.id).padStart(5, '0'), // ID를 순번으로 사용
+ PCR_DEC_DATE: format(new Date(), 'yyyyMMdd'), // 오늘 날짜
+ EBELN: pcrData.poContractNumber, // PO 계약번호
+ EBELP: pcrData.revItemNumber || '00010', // REV/품번 또는 기본값
+ PCR_STATUS: 'R', // 거절 상태
+ REJ_CD: 'REJECTED',
+ REJ_RSN: reason
+ };
+
+ console.log(`🚫 PCR 거절 요청 시작: ${confirmData.PCR_REQ}-${confirmData.PCR_REQ_SEQ}`);
+
+ // ECC로 거절 요청 전송
+ const confirmResult = await confirmPCR(confirmData);
+
+ if (!confirmResult.success) {
+ console.error(`❌ PCR 거절 실패: ${confirmResult.message}`);
+ return { success: false, error: confirmResult.message }
+ }
+
+ // DB에서 PCR 상태 업데이트
+ const updateResult = await updatePcrPo(id, {
+ pcrApprovalStatus: 'REJECTED',
+ rejectionReason: reason,
+ pcrResponseDate: new Date()
+ });
+
+ if (!updateResult.success) {
+ console.error(`❌ PCR 거절 상태 업데이트 실패: ${updateResult.error}`);
+ return { success: false, error: updateResult.error }
+ }
+
+ // 관련 페이지들 재검증
+ revalidatePath("/evcp/pcr");
+ revalidatePath("/partners/pcr");
+
+ console.log(`✅ PCR 거절 완료: ${confirmData.PCR_REQ}-${confirmData.PCR_REQ_SEQ}`);
+
+ return {
+ success: true,
+ message: "PCR이 성공적으로 거절되었습니다",
+ data: {
+ confirmResult,
+ updateResult: updateResult.data
+ }
+ }
+ } catch (error) {
+ console.error("PCR 거절 액션 오류:", error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "PCR 거절에 실패했습니다"
+ }
+ }
+}
+
+/**
+ * PCR 거절사유 수정 액션
+ */
+export async function updatePcrRejectionReasonAction(data: {
+ id: number;
+ rejectionReason?: string;
+}) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ throw new Error("로그인이 필요합니다.");
+ }
+
+ const now = new Date();
+ const updateData = {
+ rejectionReason: data.rejectionReason,
+ updatedBy: session.user.id,
+ updatedAt: now,
+ };
+
+ const result = await db
+ .update(pcrPo)
+ .set(updateData)
+ .where(eq(pcrPo.id, data.id))
+ .returning();
+
+ if (result.length === 0) {
+ throw new Error("PCR 데이터를 찾을 수 없습니다.");
+ }
+
+ // 캐시 무효화
+ revalidatePath("/evcp/pcr");
+ revalidatePath("/partners/pcr");
+
+ return {
+ success: true,
+ data: result[0],
+ };
+ } catch (error) {
+ console.error("PCR 수정 액션 오류:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "PCR 수정에 실패했습니다"
+ };
+ }
+} \ No newline at end of file
diff --git a/lib/pcr/service.ts b/lib/pcr/service.ts
new file mode 100644
index 00000000..8a85a9b1
--- /dev/null
+++ b/lib/pcr/service.ts
@@ -0,0 +1,753 @@
+"use server"
+
+import { unstable_noStore, revalidatePath } from "next/cache";
+import db from "@/db/db";
+import {
+ pcrPo,
+ pcrPr,
+ vendors,
+ pcrPrAttachment
+} from "@/db/schema";
+import { and, desc, eq, ilike, or, sql, inArray, count, asc } from "drizzle-orm";
+import { getErrorMessage } from "@/lib/handle-error";
+import {
+ type PcrPoFilters,
+} from "./types";
+import { getServerSession } from "next-auth/next";
+import { authOptions } from "@/app/api/auth/[...nextauth]/route";
+
+// 정렬 타입 정의
+// 의도적으로 any 사용 - drizzle ORM의 orderBy 타입이 복잡함
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type OrderByType = any;
+
+/**
+ * PCR_PO 데이터를 조회하는 기본 함수 (JOIN 포함)
+ */
+function selectPcrPoWithJoin() {
+ return db
+ .select({
+ // PCR_PO 필드들
+ id: pcrPo.id,
+ pcrApprovalStatus: pcrPo.pcrApprovalStatus,
+ changeType: pcrPo.changeType,
+ details: pcrPo.details,
+ project: pcrPo.project,
+ pcrRequestDate: pcrPo.pcrRequestDate,
+ poContractNumber: pcrPo.poContractNumber,
+ revItemNumber: pcrPo.revItemNumber,
+ purchaseContractManager: pcrPo.purchaseContractManager,
+ pcrCreator: pcrPo.pcrCreator,
+ poContractAmountBefore: pcrPo.poContractAmountBefore,
+ poContractAmountAfter: pcrPo.poContractAmountAfter,
+ contractCurrency: pcrPo.contractCurrency,
+ pcrReason: pcrPo.pcrReason,
+ detailsReason: pcrPo.detailsReason,
+ rejectionReason: pcrPo.rejectionReason,
+ pcrResponseDate: pcrPo.pcrResponseDate,
+ vendorId: pcrPo.vendorId,
+ createdBy: pcrPo.createdBy,
+ updatedBy: pcrPo.updatedBy,
+ createdAt: pcrPo.createdAt,
+ updatedAt: pcrPo.updatedAt,
+
+ // JOIN된 필드들
+ vendorName: vendors.vendorName,
+ })
+ .from(pcrPo)
+ .leftJoin(vendors, eq(pcrPo.vendorId, vendors.id))
+ .$dynamic();
+}
+
+/**
+ * PCR_PR 데이터를 조회하는 기본 함수
+ */
+function selectPcrPrWithJoin() {
+ return db
+ .select({
+ // PCR_PR 필드들
+ id: pcrPr.id,
+ materialNumber: pcrPr.materialNumber,
+ materialDetails: pcrPr.materialDetails,
+ quantityBefore: pcrPr.quantityBefore,
+ quantityAfter: pcrPr.quantityAfter,
+ weightBefore: pcrPr.weightBefore,
+ weightAfter: pcrPr.weightAfter,
+ subcontractorWeightBefore: pcrPr.subcontractorWeightBefore,
+ subcontractorWeightAfter: pcrPr.subcontractorWeightAfter,
+ supplierWeightBefore: pcrPr.supplierWeightBefore,
+ supplierWeightAfter: pcrPr.supplierWeightAfter,
+ specDrawingBefore: pcrPr.specDrawingBefore,
+ specDrawingAfter: pcrPr.specDrawingAfter,
+ initialPoContractDate: pcrPr.initialPoContractDate,
+ specChangeDate: pcrPr.specChangeDate,
+ poContractModifiedDate: pcrPr.poContractModifiedDate,
+ confirmationDate: pcrPr.confirmationDate,
+ designManager: pcrPr.designManager,
+ poContractNumber: pcrPr.poContractNumber,
+ createdBy: pcrPr.createdBy,
+ updatedBy: pcrPr.updatedBy,
+ createdAt: pcrPr.createdAt,
+ updatedAt: pcrPr.updatedAt,
+ })
+ .from(pcrPr)
+ .$dynamic();
+}
+
+/**
+ * PCR_PO 데이터를 조회하고 카운트하는 함수들
+ */
+const selectPcrPoWithJoinDynamic = selectPcrPoWithJoin();
+const countPcrPoWithJoin = () =>
+ db
+ .select({ count: count() })
+ .from(pcrPo)
+ .leftJoin(vendors, eq(pcrPo.vendorId, vendors.id));
+
+/**
+ * PCR_PR 데이터를 조회하고 카운트하는 함수들
+ */
+const selectPcrPrWithJoinDynamic = selectPcrPrWithJoin();
+
+/**
+ * PCR_PO 목록 조회 (EvcP 페이지용 - 모든 데이터 조회)
+ */
+export async function getPcrPoList(input: {
+ page?: number;
+ perPage?: number;
+ sort?: OrderByType;
+ filters?: PcrPoFilters;
+ search?: string;
+}) {
+ unstable_noStore();
+
+ try {
+ const { page = 1, perPage = 10, sort, filters, search } = input;
+
+ const offset = (page - 1) * perPage;
+
+ // 필터링 조건 구성
+ const whereConditions: any[] = [];
+
+ if (filters) {
+ if (filters.pcrApprovalStatus) {
+ whereConditions.push(eq(pcrPo.pcrApprovalStatus, filters.pcrApprovalStatus));
+ }
+ if (filters.changeType) {
+ whereConditions.push(eq(pcrPo.changeType, filters.changeType));
+ }
+ if (filters.project) {
+ whereConditions.push(ilike(pcrPo.project, `%${filters.project}%`));
+ }
+ if (filters.poContractNumber) {
+ whereConditions.push(ilike(pcrPo.poContractNumber, `%${filters.poContractNumber}%`));
+ }
+ if (filters.vendorId) {
+ whereConditions.push(eq(pcrPo.vendorId, filters.vendorId));
+ }
+ if (filters.startDate) {
+ whereConditions.push(sql`${pcrPo.pcrRequestDate} >= ${filters.startDate}`);
+ }
+ if (filters.endDate) {
+ whereConditions.push(sql`${pcrPo.pcrRequestDate} <= ${filters.endDate}`);
+ }
+ }
+
+ // 검색 조건
+ if (search) {
+ whereConditions.push(
+ or(
+ ilike(pcrPo.poContractNumber, `%${search}%`),
+ ilike(pcrPo.project, `%${search}%`),
+ ilike(pcrPo.pcrCreator, `%${search}%`),
+ ilike(vendors.vendorName, `%${search}%`)
+ )
+ );
+ }
+
+ const whereClause = whereConditions.length > 0 ? and(...whereConditions) : undefined;
+
+ // 데이터 조회
+ const data = await selectPcrPoWithJoinDynamic
+ .where(whereClause)
+ .orderBy(sort ? sort : desc(pcrPo.createdAt))
+ .limit(perPage)
+ .offset(offset);
+
+ // 전체 카운트
+ const totalCount = await countPcrPoWithJoin()
+ .where(whereClause)
+ .then((result) => result[0]?.count ?? 0);
+
+ return {
+ data,
+ totalCount,
+ pageCount: Math.ceil(totalCount / perPage),
+ };
+ } catch (error) {
+ console.error("PCR_PO 목록 조회 오류:", error);
+ throw new Error("PCR_PO 데이터를 조회하는데 실패했습니다.");
+ }
+}
+
+/**
+ * PCR_PO 목록 조회 (Partners 페이지용 - 현재 사용자의 vendorId에 해당하는 데이터만 조회)
+ */
+export async function getPcrPoListForPartners(input: {
+ page?: number;
+ perPage?: number;
+ sort?: OrderByType;
+ filters?: Omit<PcrPoFilters, 'vendorId'>;
+ search?: string;
+ vendorId: number;
+}) {
+ unstable_noStore();
+
+ try {
+ const { page = 1, perPage = 10, sort, filters, search, vendorId } = input;
+
+ const offset = (page - 1) * perPage;
+
+ // 필터링 조건 구성 (vendorId는 필수로 포함)
+ const whereConditions = [eq(pcrPo.vendorId, vendorId)];
+
+ if (filters) {
+ if (filters.pcrApprovalStatus) {
+ whereConditions.push(eq(pcrPo.pcrApprovalStatus, filters.pcrApprovalStatus));
+ }
+ if (filters.changeType) {
+ whereConditions.push(eq(pcrPo.changeType, filters.changeType));
+ }
+ if (filters.project) {
+ whereConditions.push(ilike(pcrPo.project, `%${filters.project}%`));
+ }
+ if (filters.poContractNumber) {
+ whereConditions.push(ilike(pcrPo.poContractNumber, `%${filters.poContractNumber}%`));
+ }
+ if (filters.startDate) {
+ whereConditions.push(sql`${pcrPo.pcrRequestDate} >= ${filters.startDate}`);
+ }
+ if (filters.endDate) {
+ whereConditions.push(sql`${pcrPo.pcrRequestDate} <= ${filters.endDate}`);
+ }
+ }
+
+ // 검색 조건
+ if (search) {
+ whereConditions.push(
+ or(
+ ilike(pcrPo.poContractNumber, `%${search}%`),
+ ilike(pcrPo.project, `%${search}%`),
+ ilike(pcrPo.pcrCreator, `%${search}%`)
+ )
+ );
+ }
+
+ const whereClause = and(...whereConditions);
+
+ // 데이터 조회
+ const data = await selectPcrPoWithJoinDynamic
+ .where(whereClause)
+ .orderBy(sort ? sort : desc(pcrPo.createdAt))
+ .limit(perPage)
+ .offset(offset);
+
+ // 전체 카운트
+ const totalCount = await countPcrPoWithJoin()
+ .where(whereClause)
+ .then((result) => result[0]?.count ?? 0);
+
+ return {
+ data,
+ totalCount,
+ pageCount: Math.ceil(totalCount / perPage),
+ };
+ } catch (error) {
+ console.error("PCR_PO Partners 목록 조회 오류:", error);
+ throw new Error("PCR_PO 데이터를 조회하는데 실패했습니다.");
+ }
+}
+
+/**
+ * 특정 PO/계약 번호에 해당하는 PCR_PR 데이터 조회
+ */
+export async function getPcrPrListByPoContractNumber(poContractNumber: string) {
+ unstable_noStore();
+
+ try {
+ // PCR_PR 데이터 조회
+ const pcrPrData = await selectPcrPrWithJoinDynamic
+ .where(eq(pcrPr.poContractNumber, poContractNumber))
+ .orderBy(asc(pcrPr.materialNumber));
+
+ // 첨부파일 데이터 조회 (해당 PCR_PR들의 ID로)
+ const pcrPrIds = pcrPrData.map(item => item.id);
+ let attachments: any[] = [];
+
+ if (pcrPrIds.length > 0) {
+ const attachmentsResult = await db
+ .select()
+ .from(pcrPrAttachment)
+ .where(inArray(pcrPrAttachment.pcrPrId, pcrPrIds))
+ .orderBy(pcrPrAttachment.pcrPrId, pcrPrAttachment.type);
+
+ attachments = attachmentsResult;
+ }
+
+ // PCR_PR 데이터에 첨부파일 추가
+ const dataWithAttachments = pcrPrData.map(pcrPrItem => ({
+ ...pcrPrItem,
+ attachments: attachments.filter(att => att.pcrPrId === pcrPrItem.id)
+ }));
+
+ return dataWithAttachments;
+ } catch (error) {
+ console.error("PCR_PR 조회 오류:", error);
+ throw new Error("PCR_PR 데이터를 조회하는데 실패했습니다.");
+ }
+}
+
+/**
+ * PCR_PO 단일 조회
+ */
+export async function getPcrPoById(id: number) {
+ unstable_noStore();
+
+ try {
+ const data = await selectPcrPoWithJoinDynamic
+ .where(eq(pcrPo.id, id))
+ .limit(1);
+
+ if (data.length === 0) {
+ throw new Error("PCR_PO 데이터를 찾을 수 없습니다.");
+ }
+
+ return data[0];
+ } catch (error) {
+ console.error("PCR_PO 단일 조회 오류:", error);
+ throw new Error("PCR_PO 데이터를 조회하는데 실패했습니다.");
+ }
+}
+
+/**
+ * PCR_PR 단일 조회
+ */
+export async function getPcrPrById(id: number) {
+ unstable_noStore();
+
+ try {
+ const data = await selectPcrPrWithJoinDynamic
+ .where(eq(pcrPr.id, id))
+ .limit(1);
+
+ if (data.length === 0) {
+ throw new Error("PCR_PR 데이터를 찾을 수 없습니다.");
+ }
+
+ return data[0];
+ } catch (error) {
+ console.error("PCR_PR 단일 조회 오류:", error);
+ throw new Error("PCR_PR 데이터를 조회하는데 실패했습니다.");
+ }
+}
+
+/**
+ * PCR_PO 생성
+ */
+export async function createPcrPo(data: {
+ pcrApprovalStatus?: string;
+ changeType?: string;
+ details?: string;
+ project?: string;
+ pcrRequestDate: Date;
+ poContractNumber: string;
+ revItemNumber?: string;
+ purchaseContractManager?: string;
+ pcrCreator?: string;
+ poContractAmountBefore?: number;
+ poContractAmountAfter?: number;
+ contractCurrency?: string;
+ pcrReason?: string;
+ detailsReason?: string;
+ rejectionReason?: string;
+ pcrResponseDate?: Date;
+ vendorId?: number;
+}) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ throw new Error("로그인이 필요합니다.");
+ }
+
+ const now = new Date();
+ const insertData = {
+ ...data,
+ pcrApprovalStatus: data.pcrApprovalStatus || 'PENDING',
+ changeType: data.changeType || 'OTHER',
+ contractCurrency: data.contractCurrency || 'KRW',
+ createdBy: session.user.id,
+ updatedBy: session.user.id,
+ createdAt: now,
+ updatedAt: now,
+ };
+
+ const result = await db.insert(pcrPo).values(insertData).returning();
+
+ // 캐시 무효화
+ revalidatePath("/evcp/pcr");
+ revalidatePath("/partners/pcr");
+
+ return {
+ success: true,
+ data: result[0],
+ message: "PCR_PO가 성공적으로 생성되었습니다."
+ };
+ } catch (error) {
+ console.error("PCR_PO 생성 오류:", error);
+ return {
+ success: false,
+ error: getErrorMessage(error),
+ };
+ }
+}
+
+/**
+ * PCR_PR 생성
+ */
+export async function createPcrPr(data: {
+ materialNumber: string;
+ materialDetails?: string;
+ quantityBefore?: number;
+ quantityAfter?: number;
+ weightBefore?: number;
+ weightAfter?: number;
+ subcontractorWeightBefore?: number;
+ subcontractorWeightAfter?: number;
+ supplierWeightBefore?: number;
+ supplierWeightAfter?: number;
+ specDrawingBefore?: string;
+ specDrawingAfter?: string;
+ initialPoContractDate?: Date;
+ specChangeDate?: Date;
+ poContractModifiedDate?: Date;
+ confirmationDate?: Date;
+ designManager?: string;
+ poContractNumber: string;
+}) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ throw new Error("로그인이 필요합니다.");
+ }
+
+ const now = new Date();
+ const insertData = {
+ ...data,
+ createdBy: session.user.id,
+ updatedBy: session.user.id,
+ createdAt: now,
+ updatedAt: now,
+ };
+
+ const result = await db.insert(pcrPr).values(insertData).returning();
+
+ // 캐시 무효화
+ revalidatePath("/evcp/pcr");
+ revalidatePath("/partners/pcr");
+
+ return {
+ success: true,
+ data: result[0],
+ message: "PCR_PR이 성공적으로 생성되었습니다."
+ };
+ } catch (error) {
+ console.error("PCR_PR 생성 오류:", error);
+ return {
+ success: false,
+ error: getErrorMessage(error),
+ };
+ }
+}
+
+/**
+ * PCR_PO 업데이트
+ */
+export async function updatePcrPo(id: number, data: Partial<{
+ pcrApprovalStatus: string;
+ changeType: string;
+ details: string;
+ project: string;
+ pcrRequestDate: Date;
+ poContractNumber: string;
+ revItemNumber: string;
+ purchaseContractManager: string;
+ pcrCreator: string;
+ poContractAmountBefore: number;
+ poContractAmountAfter: number;
+ contractCurrency: string;
+ pcrReason: string;
+ detailsReason: string;
+ rejectionReason: string;
+ pcrResponseDate: Date;
+ vendorId: number;
+}>) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ throw new Error("로그인이 필요합니다.");
+ }
+
+ const updateData = {
+ ...data,
+ updatedBy: session.user.id,
+ updatedAt: new Date(),
+ };
+
+ const result = await db
+ .update(pcrPo)
+ .set(updateData)
+ .where(eq(pcrPo.id, id))
+ .returning();
+
+ if (result.length === 0) {
+ return {
+ success: false,
+ error: "PCR_PO 데이터를 찾을 수 없습니다.",
+ };
+ }
+
+ // 캐시 무효화
+ revalidatePath("/evcp/pcr");
+ revalidatePath("/partners/pcr");
+
+ return {
+ success: true,
+ data: result[0],
+ message: "PCR_PO가 성공적으로 업데이트되었습니다."
+ };
+ } catch (error) {
+ console.error("PCR_PO 업데이트 오류:", error);
+ return {
+ success: false,
+ error: getErrorMessage(error),
+ };
+ }
+}
+
+/**
+ * PCR_PR 업데이트
+ */
+export async function updatePcrPr(id: number, data: Partial<{
+ materialNumber: string;
+ materialDetails: string;
+ quantityBefore: number;
+ quantityAfter: number;
+ weightBefore: number;
+ weightAfter: number;
+ subcontractorWeightBefore: number;
+ subcontractorWeightAfter: number;
+ supplierWeightBefore: number;
+ supplierWeightAfter: number;
+ specDrawingBefore: string;
+ specDrawingAfter: string;
+ initialPoContractDate: Date;
+ specChangeDate: Date;
+ poContractModifiedDate: Date;
+ confirmationDate: Date;
+ designManager: string;
+ poContractNumber: string;
+}>) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ throw new Error("로그인이 필요합니다.");
+ }
+
+ const updateData = {
+ ...data,
+ updatedBy: session.user.id,
+ updatedAt: new Date(),
+ };
+
+ const result = await db
+ .update(pcrPr)
+ .set(updateData)
+ .where(eq(pcrPr.id, id))
+ .returning();
+
+ if (result.length === 0) {
+ return {
+ success: false,
+ error: "PCR_PR 데이터를 찾을 수 없습니다.",
+ };
+ }
+
+ // 캐시 무효화
+ revalidatePath("/evcp/pcr");
+ revalidatePath("/partners/pcr");
+
+ return {
+ success: true,
+ data: result[0],
+ message: "PCR_PR이 성공적으로 업데이트되었습니다."
+ };
+ } catch (error) {
+ console.error("PCR_PR 업데이트 오류:", error);
+ return {
+ success: false,
+ error: getErrorMessage(error),
+ };
+ }
+}
+
+/**
+ * PCR_PO 삭제
+ */
+export async function deletePcrPo(id: number) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ throw new Error("로그인이 필요합니다.");
+ }
+
+ const result = await db
+ .delete(pcrPo)
+ .where(eq(pcrPo.id, id))
+ .returning();
+
+ if (result.length === 0) {
+ return {
+ success: false,
+ error: "PCR_PO 데이터를 찾을 수 없습니다.",
+ };
+ }
+
+ // 캐시 무효화
+ revalidatePath("/evcp/pcr");
+ revalidatePath("/partners/pcr");
+
+ return {
+ success: true,
+ message: "PCR_PO가 성공적으로 삭제되었습니다."
+ };
+ } catch (error) {
+ console.error("PCR_PO 삭제 오류:", error);
+ return {
+ success: false,
+ error: getErrorMessage(error),
+ };
+ }
+}
+
+/**
+ * PCR_PR 삭제
+ */
+export async function deletePcrPr(id: number) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ throw new Error("로그인이 필요합니다.");
+ }
+
+ const result = await db
+ .delete(pcrPr)
+ .where(eq(pcrPr.id, id))
+ .returning();
+
+ if (result.length === 0) {
+ return {
+ success: false,
+ error: "PCR_PR 데이터를 찾을 수 없습니다.",
+ };
+ }
+
+ // 캐시 무효화
+ revalidatePath("/evcp/pcr");
+ revalidatePath("/partners/pcr");
+
+ return {
+ success: true,
+ message: "PCR_PR이 성공적으로 삭제되었습니다."
+ };
+ } catch (error) {
+ console.error("PCR_PR 삭제 오류:", error);
+ return {
+ success: false,
+ error: getErrorMessage(error),
+ };
+ }
+}
+
+/**
+ * 간단한 협력업체 목록 조회 (PCR 생성용)
+ */
+export async function getVendorsForPcr(input?: {
+ vendorId?: number; // 특정 vendorId만 조회 (Partners 페이지용)
+ limit?: number; // 조회 개수 제한
+}) {
+ unstable_noStore();
+
+ try {
+ const { vendorId, limit = 100 } = input || {};
+
+ let query = db
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ })
+ .from(vendors)
+ .orderBy(asc(vendors.vendorName));
+
+ // 특정 vendorId 필터링 (Partners 페이지용)
+ if (vendorId) {
+ query = query.where(eq(vendors.id, vendorId));
+ }
+
+ // 개수 제한
+ if (limit) {
+ query = query.limit(limit);
+ }
+
+ const result = await query;
+
+ return {
+ success: true,
+ data: result,
+ };
+ } catch (error) {
+ console.error("협력업체 조회 오류:", error);
+ return {
+ success: false,
+ error: getErrorMessage(error),
+ data: [],
+ };
+ }
+}
+
+/**
+ * PCR_PR 첨부파일 조회
+ */
+export async function getPcrPrAttachments(pcrPrId: number) {
+ unstable_noStore();
+
+ try {
+ const result = await db
+ .select()
+ .from(pcrPrAttachment)
+ .where(eq(pcrPrAttachment.pcrPrId, pcrPrId))
+ .orderBy(pcrPrAttachment.type, pcrPrAttachment.createdAt);
+
+ return {
+ success: true,
+ data: result,
+ };
+ } catch (error) {
+ console.error("PCR_PR 첨부파일 조회 오류:", error);
+ return {
+ success: false,
+ error: getErrorMessage(error),
+ data: [],
+ };
+ }
+} \ No newline at end of file
diff --git a/lib/pcr/table/approve-reject-pcr-dialog.tsx b/lib/pcr/table/approve-reject-pcr-dialog.tsx
new file mode 100644
index 00000000..065a30fa
--- /dev/null
+++ b/lib/pcr/table/approve-reject-pcr-dialog.tsx
@@ -0,0 +1,231 @@
+"use client"
+
+import * as React from "react"
+import { toast } from "sonner"
+import { CheckCircle, XCircle, Loader2 } from "lucide-react"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+} from "@/components/ui/dialog"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Textarea } from "@/components/ui/textarea"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useForm } from "react-hook-form"
+import * as z from "zod"
+import { approvePcrAction, rejectPcrAction } from "@/lib/pcr/actions"
+import { PcrPoData } from "@/lib/pcr/types"
+
+// 승인 다이얼로그 스키마
+const approveSchema = z.object({
+ reason: z.string().optional(),
+})
+
+// 거절 다이얼로그 스키마
+const rejectSchema = z.object({
+ reason: z.string().min(1, "거절 사유를 입력해주세요."),
+})
+
+type ApproveFormValues = z.infer<typeof approveSchema>
+type RejectFormValues = z.infer<typeof rejectSchema>
+
+interface ApproveRejectPcrDialogProps {
+ open?: boolean
+ onOpenChange?: (open: boolean) => void
+ pcrData?: PcrPoData | null
+ actionType: 'approve' | 'reject'
+ onSuccess?: () => void
+}
+
+export function ApproveRejectPcrDialog({
+ open,
+ onOpenChange,
+ pcrData,
+ actionType,
+ onSuccess,
+}: ApproveRejectPcrDialogProps) {
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [internalOpen, setInternalOpen] = React.useState(false)
+
+ const dialogOpen = open !== undefined ? open : internalOpen
+ const setDialogOpen = onOpenChange || setInternalOpen
+
+ const isApprove = actionType === 'approve'
+
+ // 승인 폼
+ const approveForm = useForm<ApproveFormValues>({
+ resolver: zodResolver(approveSchema),
+ defaultValues: {
+ reason: "",
+ },
+ })
+
+ // 거절 폼
+ const rejectForm = useForm<RejectFormValues>({
+ resolver: zodResolver(rejectSchema),
+ defaultValues: {
+ reason: "",
+ },
+ })
+
+ const currentForm = isApprove ? approveForm : rejectForm
+
+ const handleSubmit = async (data: ApproveFormValues | RejectFormValues) => {
+ if (!pcrData) return
+
+ try {
+ setIsLoading(true)
+
+ const reason = 'reason' in data ? data.reason : undefined
+ const result = isApprove
+ ? await approvePcrAction(pcrData.id, reason)
+ : await rejectPcrAction(pcrData.id, reason || '')
+
+ if (result.success) {
+ toast.success(result.message)
+ currentForm.reset()
+ setDialogOpen(false)
+ onSuccess?.()
+ } else {
+ toast.error(result.error || `${isApprove ? '승인' : '거절'}에 실패했습니다.`)
+ }
+ } catch (error) {
+ console.error(`PCR ${isApprove ? '승인' : '거절'} 오류:`, error)
+ toast.error(`${isApprove ? '승인' : '거절'} 중 오류가 발생했습니다.`)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ if (!pcrData) return null
+
+ return (
+ <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
+ <DialogContent className="max-w-md">
+ <DialogHeader>
+ <div className="flex items-center gap-3">
+ {isApprove ? (
+ <CheckCircle className="size-6 text-green-600" />
+ ) : (
+ <XCircle className="size-6 text-red-600" />
+ )}
+ <DialogTitle>
+ PCR {isApprove ? '승인' : '거절'} 확인
+ </DialogTitle>
+ </div>
+ <DialogDescription>
+ 다음 PCR을 {isApprove ? '승인' : '거절'}하시겠습니까?
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* PCR 정보 표시 */}
+ <div className="space-y-3 p-4 bg-gray-50 rounded-lg">
+ <div className="grid grid-cols-2 gap-2 text-sm">
+ <div>
+ <span className="font-medium text-gray-600">PO/계약번호:</span>
+ <p className="font-mono text-gray-900">{pcrData.poContractNumber}</p>
+ </div>
+ <div>
+ <span className="font-medium text-gray-600">프로젝트:</span>
+ <p className="text-gray-900">{pcrData.project || '-'}</p>
+ </div>
+ <div>
+ <span className="font-medium text-gray-600">변경 구분:</span>
+ <p className="text-gray-900">{pcrData.changeType}</p>
+ </div>
+ <div>
+ <span className="font-medium text-gray-600">요청일자:</span>
+ <p className="text-gray-900">
+ {pcrData.pcrRequestDate.toLocaleDateString('ko-KR')}
+ </p>
+ </div>
+ </div>
+ {pcrData.details && (
+ <div>
+ <span className="font-medium text-gray-600 text-sm">상세:</span>
+ <p className="text-gray-900 text-sm mt-1">{pcrData.details}</p>
+ </div>
+ )}
+ </div>
+
+ {/* 승인/거절 사유 입력 폼 */}
+ <Form {...currentForm}>
+ <form onSubmit={currentForm.handleSubmit(handleSubmit)} className="space-y-4">
+ <FormField
+ control={currentForm.control}
+ name="reason"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ {isApprove ? '승인 사유 (선택)' : '거절 사유 (필수)'}
+ </FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder={
+ isApprove
+ ? "승인 사유를 입력하세요 (선택사항)"
+ : "거절 사유를 입력하세요"
+ }
+ className="resize-none"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 버튼들 */}
+ <div className="flex justify-end gap-3 pt-4">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setDialogOpen(false)}
+ disabled={isLoading}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ variant={isApprove ? "default" : "destructive"}
+ disabled={isLoading}
+ >
+ {isLoading ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ {isApprove ? '승인 중...' : '거절 중...'}
+ </>
+ ) : (
+ <>
+ {isApprove ? (
+ <>
+ <CheckCircle className="mr-2 h-4 w-4" />
+ 승인
+ </>
+ ) : (
+ <>
+ <XCircle className="mr-2 h-4 w-4" />
+ 거절
+ </>
+ )}
+ </>
+ )}
+ </Button>
+ </div>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/lib/pcr/table/create-pcr-dialog.tsx b/lib/pcr/table/create-pcr-dialog.tsx
new file mode 100644
index 00000000..cddb20d3
--- /dev/null
+++ b/lib/pcr/table/create-pcr-dialog.tsx
@@ -0,0 +1,642 @@
+"use client"
+
+import * as React from "react"
+import { toast } from "sonner"
+import { CalendarIcon, Loader2, Plus, Check, ChevronsUpDown } from "lucide-react"
+import { Calendar } from "@/components/ui/calendar"
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
+import { format } from "date-fns"
+import { ko } from "date-fns/locale"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useForm } from "react-hook-form"
+import * as z from "zod"
+import { createPcrPo, getVendorsForPcr } from "@/lib/pcr/service"
+import { PCR_CHANGE_TYPES } from "@/lib/pcr/types"
+import { cn } from "@/lib/utils"
+
+// PCR 생성 스키마
+const createPcrSchema = z.object({
+ changeType: z.string().optional(),
+ details: z.string().optional(),
+ project: z.string().optional(),
+ pcrRequestDate: z.date({
+ required_error: "PCR 요청일자를 선택해주세요.",
+ }),
+ poContractNumber: z.string().min(1, "PO/계약번호를 입력해주세요."),
+ revItemNumber: z.string().optional(),
+ purchaseContractManager: z.string().optional(),
+ pcrCreator: z.string().optional(),
+ poContractAmountBefore: z.number().optional(),
+ poContractAmountAfter: z.number().optional(),
+ contractCurrency: z.string().optional(),
+ pcrReason: z.string().optional(),
+ detailsReason: z.string().optional(),
+ rejectionReason: z.string().optional(),
+ pcrResponseDate: z.date().optional(),
+ vendorId: z.number({
+ required_error: "협력업체를 선택해주세요.",
+ }),
+})
+
+type CreatePcrFormValues = z.infer<typeof createPcrSchema>
+
+interface CreatePcrDialogProps {
+ open?: boolean
+ onOpenChange?: (open: boolean) => void
+ isEvcpPage?: boolean
+ onSuccess?: () => void
+ currentVendorId?: number // Partners 페이지에서 현재 사용자의 vendorId
+}
+
+interface Vendor {
+ id: number
+ vendorName: string
+ vendorCode: string
+}
+
+export function CreatePcrDialog({
+ open,
+ onOpenChange,
+ isEvcpPage = false,
+ onSuccess,
+ currentVendorId,
+}: CreatePcrDialogProps) {
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [internalOpen, setInternalOpen] = React.useState(false)
+ const [vendors, setVendors] = React.useState<Vendor[]>([])
+ const [vendorsLoading, setVendorsLoading] = React.useState(false)
+ const [vendorSearchOpen, setVendorSearchOpen] = React.useState(false)
+
+ const dialogOpen = open !== undefined ? open : internalOpen
+ const setDialogOpen = onOpenChange || setInternalOpen
+
+ const form = useForm<CreatePcrFormValues>({
+ resolver: zodResolver(createPcrSchema),
+ defaultValues: {
+ changeType: "OTHER",
+ contractCurrency: "KRW",
+ pcrRequestDate: new Date(),
+ },
+ })
+
+ const loadVendors = React.useCallback(async () => {
+ try {
+ setVendorsLoading(true)
+
+ let result
+ if (!isEvcpPage && currentVendorId) {
+ // Partners 페이지: 특정 vendorId만 조회
+ result = await getVendorsForPcr({
+ vendorId: currentVendorId,
+ limit: 1
+ })
+ } else {
+ // EvcP 페이지: 모든 협력업체 조회
+ result = await getVendorsForPcr({
+ limit: 1000
+ })
+ }
+
+ if (result.success) {
+ setVendors(result.data.map(vendor => ({
+ id: vendor.id,
+ vendorName: vendor.vendorName,
+ vendorCode: vendor.vendorCode || "",
+ })))
+
+ // Partners 페이지에서는 자동으로 해당 협력업체 선택
+ if (!isEvcpPage && currentVendorId && result.data.length > 0) {
+ form.setValue("vendorId", currentVendorId)
+ }
+ } else {
+ toast.error(result.error || "협력업체 목록을 불러오는 중 오류가 발생했습니다.")
+ }
+ } catch (error) {
+ console.error("협력업체 로드 오류:", error)
+ toast.error("협력업체 목록을 불러오는 중 오류가 발생했습니다.")
+ } finally {
+ setVendorsLoading(false)
+ }
+ }, [isEvcpPage, currentVendorId, form])
+
+ // 협력업체 데이터 로드
+ React.useEffect(() => {
+ if (dialogOpen) {
+ loadVendors()
+ }
+ }, [dialogOpen, loadVendors])
+
+ async function onSubmit(data: CreatePcrFormValues) {
+ try {
+ setIsLoading(true)
+
+ const result = await createPcrPo({
+ ...data,
+ pcrApprovalStatus: "PENDING",
+ contractCurrency: data.contractCurrency || "KRW",
+ changeType: data.changeType || "OTHER",
+ })
+
+ if (result.success) {
+ toast.success("PCR이 성공적으로 생성되었습니다.")
+ form.reset()
+ setDialogOpen(false)
+ onSuccess?.()
+ } else {
+ toast.error(result.error || "PCR 생성에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("PCR 생성 오류:", error)
+ toast.error("PCR 생성 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ return (
+ <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
+ <DialogTrigger asChild>
+ <Button variant="default" size="sm" className="gap-2">
+ <Plus className="size-4" />
+ PCR 생성
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>PCR 생성</DialogTitle>
+ </DialogHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
+ {/* 기본 정보 섹션 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-medium">기본 정보</h3>
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="changeType"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>변경 구분</FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="변경 구분을 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {Object.entries(PCR_CHANGE_TYPES).map(([key, value]) => (
+ <SelectItem key={key} value={key}>
+ {value}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="pcrRequestDate"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>PCR 요청일자 *</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ className={cn(
+ "w-full pl-3 text-left font-normal",
+ !field.value && "text-muted-foreground"
+ )}
+ >
+ {field.value ? (
+ format(field.value, "yyyy년 MM월 dd일", { locale: ko })
+ ) : (
+ <span>날짜를 선택하세요</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={field.onChange}
+ disabled={(date) =>
+ date > new Date() || date < new Date("1900-01-01")
+ }
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 협력업체 선택 */}
+ <FormField
+ control={form.control}
+ name="vendorId"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>협력업체 *</FormLabel>
+ <Popover open={vendorSearchOpen} onOpenChange={setVendorSearchOpen}>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={vendorSearchOpen}
+ className={cn(
+ "w-full justify-between",
+ !field.value && "text-muted-foreground"
+ )}
+ >
+ {field.value
+ ? vendors.find((vendor) => vendor.id === field.value)?.vendorName
+ : "협력업체를 선택하세요"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-full p-0" align="start">
+ <Command>
+ <CommandInput placeholder="협력업체 검색..." />
+ <CommandEmpty>
+ {vendorsLoading ? "로딩 중..." : "협력업체를 찾을 수 없습니다."}
+ </CommandEmpty>
+ <CommandGroup>
+ <CommandList>
+ {vendors.map((vendor) => (
+ <CommandItem
+ key={vendor.id}
+ value={`${vendor.vendorName} ${vendor.vendorCode}`}
+ onSelect={() => {
+ form.setValue("vendorId", vendor.id)
+ setVendorSearchOpen(false)
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ field.value === vendor.id ? "opacity-100" : "opacity-0"
+ )}
+ />
+ <div className="flex flex-col">
+ <span className="font-medium">{vendor.vendorName}</span>
+ {vendor.vendorCode && (
+ <span className="text-sm text-muted-foreground">
+ 코드: {vendor.vendorCode}
+ </span>
+ )}
+ </div>
+ </CommandItem>
+ ))}
+ </CommandList>
+ </CommandGroup>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="poContractNumber"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>PO/계약번호 *</FormLabel>
+ <FormControl>
+ <Input placeholder="PO/계약번호를 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="revItemNumber"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Rev./품번</FormLabel>
+ <FormControl>
+ <Input placeholder="Rev./품번을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <FormField
+ control={form.control}
+ name="project"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>프로젝트</FormLabel>
+ <FormControl>
+ <Input placeholder="프로젝트명을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="details"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>상세</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="상세 내용을 입력하세요"
+ className="resize-none"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 담당자 정보 섹션 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-medium">담당자 정보</h3>
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="purchaseContractManager"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>구매/계약 담당자</FormLabel>
+ <FormControl>
+ <Input placeholder="담당자명을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="pcrCreator"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>PCR 생성자</FormLabel>
+ <FormControl>
+ <Input placeholder="생성자명을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+
+ {/* 금액 정보 섹션 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-medium">금액 정보</h3>
+ <div className="grid grid-cols-3 gap-4">
+ <FormField
+ control={form.control}
+ name="contractCurrency"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>계약 통화</FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="통화를 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="KRW">KRW</SelectItem>
+ <SelectItem value="USD">USD</SelectItem>
+ <SelectItem value="EUR">EUR</SelectItem>
+ <SelectItem value="JPY">JPY</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="poContractAmountBefore"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>PO/계약금액 (전)</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ placeholder="금액을 입력하세요"
+ {...field}
+ onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="poContractAmountAfter"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>PO/계약금액 (후)</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ placeholder="금액을 입력하세요"
+ {...field}
+ onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+
+ {/* 사유 정보 섹션 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-medium">사유 정보</h3>
+ <div className="space-y-4">
+ <FormField
+ control={form.control}
+ name="pcrReason"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>PCR 사유</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="PCR 사유를 입력하세요"
+ className="resize-none"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="detailsReason"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>상세 사유</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="상세 사유를 입력하세요"
+ className="resize-none"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="rejectionReason"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>거절 사유</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="거절 사유를 입력하세요"
+ className="resize-none"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+
+ {/* PCR 회신일자 */}
+ <div className="space-y-4">
+ <FormField
+ control={form.control}
+ name="pcrResponseDate"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>PCR 회신일</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ className={cn(
+ "w-[200px] pl-3 text-left font-normal",
+ !field.value && "text-muted-foreground"
+ )}
+ >
+ {field.value ? (
+ format(field.value, "yyyy년 MM월 dd일", { locale: ko })
+ ) : (
+ <span>날짜를 선택하세요</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={field.onChange}
+ disabled={(date) =>
+ date < new Date("1900-01-01")
+ }
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 버튼들 */}
+ <div className="flex justify-end gap-4 pt-4">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setDialogOpen(false)}
+ disabled={isLoading}
+ >
+ 취소
+ </Button>
+ <Button type="submit" disabled={isLoading}>
+ {isLoading ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ 생성 중...
+ </>
+ ) : (
+ "PCR 생성"
+ )}
+ </Button>
+ </div>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/lib/pcr/table/detail-table/create-pcr-pr-dialog.tsx b/lib/pcr/table/detail-table/create-pcr-pr-dialog.tsx
new file mode 100644
index 00000000..1244d179
--- /dev/null
+++ b/lib/pcr/table/detail-table/create-pcr-pr-dialog.tsx
@@ -0,0 +1,598 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import * as z from "zod"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import { Calendar } from "@/components/ui/calendar"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import { CalendarIcon } from "lucide-react"
+import { format } from "date-fns"
+import { cn } from "@/lib/utils"
+import { toast } from "sonner"
+import { PcrPoData } from "@/lib/pcr/types"
+import { createPcrPrAction } from "@/lib/pcr/actions"
+
+const pcrPrSchema = z.object({
+ materialNumber: z.string().min(1, "자재번호는 필수입니다"),
+ materialDetails: z.string().optional(),
+ quantityBefore: z.number().optional(),
+ quantityAfter: z.number().optional(),
+ weightBefore: z.number().optional(),
+ weightAfter: z.number().optional(),
+ subcontractorWeightBefore: z.number().optional(),
+ subcontractorWeightAfter: z.number().optional(),
+ supplierWeightBefore: z.number().optional(),
+ supplierWeightAfter: z.number().optional(),
+ specDrawingBefore: z.string().optional(),
+ specDrawingAfter: z.string().optional(),
+ initialPoContractDate: z.date().optional(),
+ specChangeDate: z.date().optional(),
+ poContractModifiedDate: z.date().optional(),
+ confirmationDate: z.date().optional(),
+ designManager: z.string().optional(),
+})
+
+type PcrPrFormData = z.infer<typeof pcrPrSchema>
+
+interface CreatePcrPrDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ selectedPcrPo: PcrPoData | null
+ onSuccess: () => void
+}
+
+export function CreatePcrPrDialog({
+ open,
+ onOpenChange,
+ selectedPcrPo,
+ onSuccess,
+}: CreatePcrPrDialogProps) {
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+
+ const form = useForm<PcrPrFormData>({
+ resolver: zodResolver(pcrPrSchema),
+ defaultValues: {
+ materialNumber: "",
+ materialDetails: "",
+ quantityBefore: undefined,
+ quantityAfter: undefined,
+ weightBefore: undefined,
+ weightAfter: undefined,
+ subcontractorWeightBefore: undefined,
+ subcontractorWeightAfter: undefined,
+ supplierWeightBefore: undefined,
+ supplierWeightAfter: undefined,
+ specDrawingBefore: "",
+ specDrawingAfter: "",
+ initialPoContractDate: undefined,
+ specChangeDate: undefined,
+ poContractModifiedDate: undefined,
+ confirmationDate: undefined,
+ designManager: "",
+ },
+ })
+
+ const onSubmit = async (data: PcrPrFormData) => {
+ if (!selectedPcrPo) {
+ toast.error("선택된 PCR_PO가 없습니다")
+ return
+ }
+
+ setIsSubmitting(true)
+ try {
+ const result = await createPcrPrAction({
+ ...data,
+ poContractNumber: selectedPcrPo.poContractNumber,
+ })
+
+ if (result.success) {
+ toast.success("PCR_PR이 성공적으로 생성되었습니다")
+ form.reset()
+ onOpenChange(false)
+ onSuccess()
+ } else {
+ toast.error(result.error || "PCR_PR 생성에 실패했습니다")
+ }
+ } catch (error) {
+ console.error("PCR_PR 생성 오류:", error)
+ toast.error("PCR_PR 생성 중 오류가 발생했습니다")
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>PCR_PR 생성</DialogTitle>
+ <DialogDescription>
+ PO/계약번호: {selectedPcrPo?.poContractNumber}
+ </DialogDescription>
+ </DialogHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
+ {/* 기본 정보 섹션 */}
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="materialNumber"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>자재번호 *</FormLabel>
+ <FormControl>
+ <Input placeholder="자재번호를 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="designManager"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>설계 담당자</FormLabel>
+ <FormControl>
+ <Input placeholder="설계 담당자를 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <FormField
+ control={form.control}
+ name="materialDetails"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>자재내역</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="자재내역을 입력하세요"
+ className="resize-none"
+ rows={3}
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 수량 정보 섹션 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-medium text-muted-foreground">수량 정보</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="quantityBefore"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>수량 (변경전)</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ step="0.001"
+ placeholder="0.000"
+ {...field}
+ onChange={(e) => field.onChange(e.target.value ? parseFloat(e.target.value) : undefined)}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="quantityAfter"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>수량 (변경후)</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ step="0.001"
+ placeholder="0.000"
+ {...field}
+ onChange={(e) => field.onChange(e.target.value ? parseFloat(e.target.value) : undefined)}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+
+ {/* 중량 정보 섹션 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-medium text-muted-foreground">중량 정보</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="weightBefore"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>중량 (변경전)</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ step="0.001"
+ placeholder="0.000"
+ {...field}
+ onChange={(e) => field.onChange(e.target.value ? parseFloat(e.target.value) : undefined)}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="weightAfter"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>중량 (변경후)</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ step="0.001"
+ placeholder="0.000"
+ {...field}
+ onChange={(e) => field.onChange(e.target.value ? parseFloat(e.target.value) : undefined)}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="subcontractorWeightBefore"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>사급중량 (변경전)</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ step="0.001"
+ placeholder="0.000"
+ {...field}
+ onChange={(e) => field.onChange(e.target.value ? parseFloat(e.target.value) : undefined)}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="subcontractorWeightAfter"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>사급중량 (변경후)</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ step="0.001"
+ placeholder="0.000"
+ {...field}
+ onChange={(e) => field.onChange(e.target.value ? parseFloat(e.target.value) : undefined)}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="supplierWeightBefore"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>도급중량 (변경전)</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ step="0.001"
+ placeholder="0.000"
+ {...field}
+ onChange={(e) => field.onChange(e.target.value ? parseFloat(e.target.value) : undefined)}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="supplierWeightAfter"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>도급중량 (변경후)</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ step="0.001"
+ placeholder="0.000"
+ {...field}
+ onChange={(e) => field.onChange(e.target.value ? parseFloat(e.target.value) : undefined)}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+
+ {/* SPEC 도면 정보 섹션 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-medium text-muted-foreground">SPEC 도면 정보</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="specDrawingBefore"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>SPEC 도면 (변경전)</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="SPEC 도면 정보를 입력하세요"
+ className="resize-none"
+ rows={2}
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="specDrawingAfter"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>SPEC 도면 (변경후)</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="SPEC 도면 정보를 입력하세요"
+ className="resize-none"
+ rows={2}
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+
+ {/* 날짜 정보 섹션 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-medium text-muted-foreground">날짜 정보</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="initialPoContractDate"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>최초 PO/계약 일</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ className={cn(
+ "w-full pl-3 text-left font-normal",
+ !field.value && "text-muted-foreground"
+ )}
+ >
+ {field.value ? (
+ format(field.value, "yyyy년 MM월 dd일")
+ ) : (
+ <span>날짜를 선택하세요</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={field.onChange}
+ disabled={(date) =>
+ date > new Date() || date < new Date("1900-01-01")
+ }
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="specChangeDate"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>SPEC 변경 일</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ className={cn(
+ "w-full pl-3 text-left font-normal",
+ !field.value && "text-muted-foreground"
+ )}
+ >
+ {field.value ? (
+ format(field.value, "yyyy년 MM월 dd일")
+ ) : (
+ <span>날짜를 선택하세요</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={field.onChange}
+ disabled={(date) =>
+ date > new Date() || date < new Date("1900-01-01")
+ }
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="poContractModifiedDate"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>PO/계약 수정 일</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ className={cn(
+ "w-full pl-3 text-left font-normal",
+ !field.value && "text-muted-foreground"
+ )}
+ >
+ {field.value ? (
+ format(field.value, "yyyy년 MM월 dd일")
+ ) : (
+ <span>날짜를 선택하세요</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={field.onChange}
+ disabled={(date) =>
+ date > new Date() || date < new Date("1900-01-01")
+ }
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="confirmationDate"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>확인 일</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ className={cn(
+ "w-full pl-3 text-left font-normal",
+ !field.value && "text-muted-foreground"
+ )}
+ >
+ {field.value ? (
+ format(field.value, "yyyy년 MM월 dd일")
+ ) : (
+ <span>날짜를 선택하세요</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={field.onChange}
+ disabled={(date) =>
+ date > new Date() || date < new Date("1900-01-01")
+ }
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ <Button type="submit" disabled={isSubmitting}>
+ {isSubmitting ? "생성 중..." : "생성"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/lib/pcr/table/detail-table/pcr-detail-column.tsx b/lib/pcr/table/detail-table/pcr-detail-column.tsx
new file mode 100644
index 00000000..664f37ef
--- /dev/null
+++ b/lib/pcr/table/detail-table/pcr-detail-column.tsx
@@ -0,0 +1,333 @@
+import { ColumnDef } from "@tanstack/react-table"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { PcrPrData } from "@/lib/pcr/types"
+import { FileIcon, Download } from "lucide-react"
+import { downloadFile } from "@/lib/file-download"
+
+export function getPcrDetailColumns(): ColumnDef<PcrPrData>[] {
+ return [
+ {
+ accessorKey: "materialNumber",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="자재번호" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("materialNumber") as string
+ return (
+ <div className="font-medium">
+ {value || "-"}
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: "materialDetails",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="자재내역" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("materialDetails") as string
+ return (
+ <div className="max-w-[200px] truncate" title={value}>
+ {value || "-"}
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: "quantityBefore",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="수량(변경전)" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("quantityBefore") as number
+ return (
+ <div className="text-right font-mono text-sm">
+ {value ? value.toLocaleString() : "-"}
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: "quantityAfter",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="수량(변경후)" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("quantityAfter") as number
+ return (
+ <div className="text-right font-mono text-sm">
+ {value ? value.toLocaleString() : "-"}
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: "weightBefore",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="중량(변경전)" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("weightBefore") as number
+ return (
+ <div className="text-right font-mono text-sm">
+ {value ? `${value.toLocaleString()} kg` : "-"}
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: "weightAfter",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="중량(변경후)" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("weightAfter") as number
+ return (
+ <div className="text-right font-mono text-sm">
+ {value ? `${value.toLocaleString()} kg` : "-"}
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: "subcontractorWeightBefore",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="사급중량(변경전)" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("subcontractorWeightBefore") as number
+ return (
+ <div className="text-right font-mono text-sm">
+ {value ? `${value.toLocaleString()} kg` : "-"}
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: "subcontractorWeightAfter",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="사급중량(변경후)" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("subcontractorWeightAfter") as number
+ return (
+ <div className="text-right font-mono text-sm">
+ {value ? `${value.toLocaleString()} kg` : "-"}
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: "supplierWeightBefore",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="도급중량(변경전)" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("supplierWeightBefore") as number
+ return (
+ <div className="text-right font-mono text-sm">
+ {value ? `${value.toLocaleString()} kg` : "-"}
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: "supplierWeightAfter",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="도급중량(변경후)" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("supplierWeightAfter") as number
+ return (
+ <div className="text-right font-mono text-sm">
+ {value ? `${value.toLocaleString()} kg` : "-"}
+ </div>
+ )
+ },
+ },
+ {
+ id: "attachments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="첨부파일" />
+ ),
+ cell: ({ row }) => {
+ const attachments = row.original.attachments || []
+ const beforeAttachment = attachments.find(att => att.type === 'BEFORE')
+ const afterAttachment = attachments.find(att => att.type === 'AFTER')
+
+ if (!beforeAttachment && !afterAttachment) {
+ return (
+ <div className="text-center text-muted-foreground">
+ <FileIcon className="size-4 mx-auto" />
+ </div>
+ )
+ }
+
+ return (
+ <div className="flex gap-1 justify-center">
+ {/* 변경전 첨부파일 */}
+ {beforeAttachment && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => {
+ downloadFile(beforeAttachment.filePath, beforeAttachment.fileName)
+ }}
+ title={`변경전: ${beforeAttachment.fileName}`}
+ className="h-6 w-6 p-0"
+ >
+ <Download className="size-3" />
+ </Button>
+ )}
+
+ {/* 변경후 첨부파일 */}
+ {afterAttachment && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => {
+ downloadFile(afterAttachment.filePath, afterAttachment.fileName)
+ }}
+ title={`변경후: ${afterAttachment.fileName}`}
+ className="h-6 w-6 p-0"
+ >
+ <Download className="size-3" />
+ </Button>
+ )}
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: "initialPoContractDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="최초 PO/계약일" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("initialPoContractDate") as Date
+ if (!value) return "-"
+
+ return (
+ <div className="text-sm">
+ {value.toLocaleDateString('ko-KR', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit'
+ })}
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: "specChangeDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="SPEC 변경일" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("specChangeDate") as Date
+ if (!value) return "-"
+
+ return (
+ <div className="text-sm">
+ {value.toLocaleDateString('ko-KR', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit'
+ })}
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: "poContractModifiedDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="PO/계약수정일" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("poContractModifiedDate") as Date
+ if (!value) return "-"
+
+ return (
+ <div className="text-sm">
+ {value.toLocaleDateString('ko-KR', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit'
+ })}
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: "confirmationDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="확인일" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("confirmationDate") as Date
+ if (!value) return "-"
+
+ return (
+ <div className="text-sm">
+ {value.toLocaleDateString('ko-KR', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit'
+ })}
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: "designManager",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="설계담당자" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("designManager") as string
+ return (
+ <div className="max-w-[120px] truncate" title={value}>
+ {value || "-"}
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: "poContractNumber",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="PO/계약번호" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("poContractNumber") as string
+ return (
+ <div className="font-medium">
+ {value || "-"}
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="생성일" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("createdAt") as Date
+ if (!value) return "-"
+
+ return (
+ <div className="text-sm">
+ {value.toLocaleDateString('ko-KR', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit'
+ })}
+ </div>
+ )
+ },
+ },
+ ]
+}
diff --git a/lib/pcr/table/detail-table/pcr-detail-table.tsx b/lib/pcr/table/detail-table/pcr-detail-table.tsx
new file mode 100644
index 00000000..65a07146
--- /dev/null
+++ b/lib/pcr/table/detail-table/pcr-detail-table.tsx
@@ -0,0 +1,121 @@
+"use client"
+
+import * as React from "react"
+import { useEffect, useState, useCallback } from "react"
+import { getPcrPrListByPoContractNumber } from "@/lib/pcr/service"
+import { PcrPoData, PcrPrData } from "@/lib/pcr/types"
+import { ClientDataTable } from "@/components/client-data-table/data-table"
+import { getPcrDetailColumns } from "./pcr-detail-column"
+import { PcrDetailToolbarAction } from "./pcr-detail-toolbar-action"
+import { toast } from "sonner"
+import { Skeleton } from "@/components/ui/skeleton"
+import { Badge } from "@/components/ui/badge"
+
+// 프로퍼티 정의
+interface PcrDetailTablesProps {
+ selectedPcrPo: PcrPoData | null
+ maxHeight?: string | number
+ isPartnersPage?: boolean
+}
+
+export function PcrDetailTables({ selectedPcrPo, maxHeight, isPartnersPage = false }: PcrDetailTablesProps) {
+ // 상태 관리
+ const [isLoading, setIsLoading] = useState(false)
+ const [details, setDetails] = useState<PcrPrData[]>([])
+
+ // 데이터 새로고침 함수
+ const handleRefreshData = useCallback(async () => {
+ if (!selectedPcrPo?.poContractNumber) {
+ setDetails([])
+ return
+ }
+
+ try {
+ setIsLoading(true)
+
+ const result = await getPcrPrListByPoContractNumber(selectedPcrPo.poContractNumber)
+
+ // 데이터 변환
+ const transformedData = result.map(item => ({
+ ...item,
+ // 필요한 추가 필드 변환
+ }))
+
+ setDetails(transformedData)
+ } catch (error) {
+ console.error("PCR_PR 데이터 로드 오류:", error)
+ setDetails([])
+ toast.error("PCR_PR 데이터를 불러오는 중 오류가 발생했습니다")
+ } finally {
+ setIsLoading(false)
+ }
+ }, [selectedPcrPo?.poContractNumber])
+
+ // selectedPcrPo가 변경될 때 데이터 로드
+ useEffect(() => {
+ handleRefreshData()
+ }, [handleRefreshData])
+
+ // 칼럼 정의
+ const columns = React.useMemo(() =>
+ getPcrDetailColumns(),
+ []
+ )
+
+ if (!selectedPcrPo) {
+ return (
+ <div className="flex items-center justify-center h-full text-muted-foreground">
+ PCR_PO를 선택하세요
+ </div>
+ )
+ }
+
+ // 로딩 중인 경우
+ if (isLoading) {
+ return (
+ <div className="p-4 space-y-4">
+ <Skeleton className="h-8 w-1/2" />
+ <Skeleton className="h-24 w-full" />
+ <Skeleton className="h-48 w-full" />
+ </div>
+ )
+ }
+
+ return (
+ <div className="h-full overflow-hidden">
+ {/* 헤더 정보 */}
+ <div className="p-4 bg-muted/50 border-b">
+ <div className="flex items-center justify-between">
+ <div className="space-y-1">
+ <h3 className="text-sm font-medium">
+ PO/계약번호: {selectedPcrPo.poContractNumber}
+ </h3>
+ <p className="text-xs text-muted-foreground">
+ 프로젝트: {selectedPcrPo.project || "N/A"}
+ </p>
+ </div>
+ <div className="flex items-center gap-2">
+ <Badge variant="outline">
+ {details.length}건
+ </Badge>
+ <PcrDetailToolbarAction
+ selectedPcrPo={selectedPcrPo}
+ onRefresh={handleRefreshData}
+ isPartnersPage={isPartnersPage}
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 테이블 표시 */}
+ <ClientDataTable
+ columns={columns}
+ data={details}
+ maxHeight={maxHeight}
+ emptyStateMessage="PCR_PR 데이터가 없습니다. 새 항목을 생성해보세요."
+ />
+ </div>
+ )
+}
+
+
diff --git a/lib/pcr/table/detail-table/pcr-detail-toolbar-action.tsx b/lib/pcr/table/detail-table/pcr-detail-toolbar-action.tsx
new file mode 100644
index 00000000..92829055
--- /dev/null
+++ b/lib/pcr/table/detail-table/pcr-detail-toolbar-action.tsx
@@ -0,0 +1,79 @@
+"use client"
+
+import * as React from "react"
+import { Button } from "@/components/ui/button"
+import { Plus, Loader2, RefreshCw } from "lucide-react"
+import { CreatePcrPrDialog } from "./create-pcr-pr-dialog"
+import { PcrPoData } from "@/lib/pcr/types"
+
+interface PcrDetailToolbarActionProps {
+ selectedPcrPo: PcrPoData | null
+ onRefresh: () => void
+ isPartnersPage?: boolean
+}
+
+export function PcrDetailToolbarAction({
+ selectedPcrPo,
+ onRefresh,
+ isPartnersPage = false,
+}: PcrDetailToolbarActionProps) {
+ const [createDialogOpen, setCreateDialogOpen] = React.useState(false)
+ const [isRefreshing, setIsRefreshing] = React.useState(false)
+
+ const handleRefresh = async () => {
+ setIsRefreshing(true)
+ try {
+ await onRefresh()
+ } finally {
+ setIsRefreshing(false)
+ }
+ }
+
+ const handleCreateSuccess = () => {
+ onRefresh()
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+ {/* 새로고침 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleRefresh}
+ disabled={isRefreshing}
+ className="gap-2"
+ >
+ {isRefreshing ? (
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
+ ) : (
+ <RefreshCw className="size-4" aria-hidden="true" />
+ )}
+ <span>새로고침</span>
+ </Button>
+
+ {/* PCR_PR 생성 버튼 - Partners 페이지에서는 표시하지 않음 */}
+ {!isPartnersPage && (
+ <>
+ <Button
+ variant="default"
+ size="sm"
+ onClick={() => setCreateDialogOpen(true)}
+ disabled={!selectedPcrPo}
+ className="gap-2"
+ >
+ <Plus className="size-4" />
+ PCR_PR 생성
+ </Button>
+
+ {/* PCR_PR 생성 다이얼로그 */}
+ <CreatePcrPrDialog
+ open={createDialogOpen}
+ onOpenChange={setCreateDialogOpen}
+ selectedPcrPo={selectedPcrPo}
+ onSuccess={handleCreateSuccess}
+ />
+ </>
+ )}
+ </div>
+ )
+}
diff --git a/lib/pcr/table/edit-pcr-sheet.tsx b/lib/pcr/table/edit-pcr-sheet.tsx
new file mode 100644
index 00000000..df625515
--- /dev/null
+++ b/lib/pcr/table/edit-pcr-sheet.tsx
@@ -0,0 +1,237 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import * as z from "zod"
+import { Button } from "@/components/ui/button"
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Textarea } from "@/components/ui/textarea"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { toast } from "sonner"
+import { PcrPoData } from "@/lib/pcr/types"
+import { updatePcrRejectionReasonAction } from "@/lib/pcr/actions"
+
+const pcrEditSchema = z.object({
+ rejectionReasonType: z.string().optional(),
+ customRejectionReason: z.string().optional(),
+})
+
+const REJECTION_REASON_OPTIONS = [
+ { value: "제작완료", label: "제작완료" },
+ { value: "납품 준비 중", label: "납품 준비 중" },
+ { value: "제작을 위한 원소재 준비 완료", label: "제작을 위한 원소재 준비 완료" },
+ { value: "협력사 거래중지로 인한 승인 불가", label: "협력사 거래중지로 인한 승인 불가" },
+ { value: "기타", label: "기타" },
+]
+
+type PcrEditFormData = z.infer<typeof pcrEditSchema>
+
+interface EditPcrSheetProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ pcrData: PcrPoData | null
+ onSuccess: () => void
+}
+
+export function EditPcrSheet({
+ open,
+ onOpenChange,
+ pcrData,
+ onSuccess,
+}: EditPcrSheetProps) {
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+
+ const form = useForm<PcrEditFormData>({
+ resolver: zodResolver(pcrEditSchema),
+ defaultValues: {
+ rejectionReason: "",
+ },
+ })
+
+ // PCR 데이터가 변경될 때 폼 초기화
+ React.useEffect(() => {
+ if (pcrData) {
+ const rejectionReason = pcrData.rejectionReason || ""
+
+ // 기존 rejectionReason을 파싱해서 미리 정의된 옵션인지 확인
+ const predefinedOption = REJECTION_REASON_OPTIONS.find(option =>
+ option.value === rejectionReason
+ )
+
+ if (predefinedOption) {
+ form.reset({
+ rejectionReasonType: rejectionReason,
+ customRejectionReason: "",
+ })
+ } else {
+ form.reset({
+ rejectionReasonType: "기타",
+ customRejectionReason: rejectionReason,
+ })
+ }
+ }
+ }, [pcrData, form])
+
+ const onSubmit = async (data: PcrEditFormData) => {
+ if (!pcrData) {
+ toast.error("PCR 데이터가 없습니다")
+ return
+ }
+
+ setIsSubmitting(true)
+ try {
+ // rejectionReason 조합
+ let rejectionReason = ""
+ if (data.rejectionReasonType === "기타") {
+ rejectionReason = data.customRejectionReason || ""
+ } else {
+ rejectionReason = data.rejectionReasonType || ""
+ }
+
+ const result = await updatePcrRejectionReasonAction({
+ id: pcrData.id,
+ rejectionReason: rejectionReason,
+ })
+
+ if (result.success) {
+ toast.success("PCR이 성공적으로 수정되었습니다")
+ onOpenChange(false)
+ onSuccess()
+ } else {
+ toast.error(result.error || "PCR 수정에 실패했습니다")
+ }
+ } catch (error) {
+ console.error("PCR 수정 오류:", error)
+ toast.error("PCR 수정 중 오류가 발생했습니다")
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ if (!pcrData) return null
+
+ return (
+ <Sheet open={open} onOpenChange={onOpenChange}>
+ <SheetContent className="w-[400px] sm:w-[540px]">
+ <SheetHeader>
+ <SheetTitle>PCR 수정</SheetTitle>
+ <SheetDescription>
+ PO/계약번호: {pcrData.poContractNumber}
+ </SheetDescription>
+ </SheetHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
+ {/* 읽기 전용 정보 표시 */}
+ <div className="space-y-4">
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <label className="text-sm font-medium">프로젝트</label>
+ <p className="text-sm text-muted-foreground">{pcrData.project || "-"}</p>
+ </div>
+ <div>
+ <label className="text-sm font-medium">변경구분</label>
+ <p className="text-sm text-muted-foreground">{pcrData.changeType}</p>
+ </div>
+ </div>
+
+ <div>
+ <label className="text-sm font-medium">PCR 사유</label>
+ <p className="text-sm text-muted-foreground">{pcrData.pcrReason || "-"}</p>
+ </div>
+
+ <div>
+ <label className="text-sm font-medium">상세 사유</label>
+ <p className="text-sm text-muted-foreground">{pcrData.detailsReason || "-"}</p>
+ </div>
+ </div>
+
+ {/* 편집 가능한 필드 */}
+ <FormField
+ control={form.control}
+ name="rejectionReasonType"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>거절 사유 *</FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="거절 사유를 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {REJECTION_REASON_OPTIONS.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 기타 선택 시 텍스트 입력 필드 */}
+ {form.watch("rejectionReasonType") === "기타" && (
+ <FormField
+ control={form.control}
+ name="customRejectionReason"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>기타 거절 사유</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="거절 사유를 입력하세요"
+ className="resize-none"
+ rows={3}
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ )}
+
+ <SheetFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ <Button type="submit" disabled={isSubmitting}>
+ {isSubmitting ? "수정 중..." : "수정"}
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+}
diff --git a/lib/pcr/table/pcr-table-column.tsx b/lib/pcr/table/pcr-table-column.tsx
new file mode 100644
index 00000000..aa4936ce
--- /dev/null
+++ b/lib/pcr/table/pcr-table-column.tsx
@@ -0,0 +1,416 @@
+import { ColumnDef } from "@tanstack/react-table"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { PcrPoData, PCR_APPROVAL_STATUS_CONFIG, PCR_CHANGE_TYPE_CONFIG } from "@/lib/pcr/types"
+import { MoreHorizontal, Eye, Edit, Trash2 } from "lucide-react"
+import type { DataTableRowAction } from "@/types/table"
+import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<PcrPoData> | null>>
+ isEvcpPage?: boolean; // EvcP 페이지인지 여부
+}
+
+export function getColumns({ setRowAction, isEvcpPage = false }: GetColumnsProps): ColumnDef<PcrPoData>[] {
+ const columns: ColumnDef<PcrPoData>[] = [
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={table.getSelectedRowModel().rows.length > 0}
+ onCheckedChange={(value) => {
+ if (value) {
+ // 하나만 선택 가능하도록 현재 행만 선택
+ table.getRowModel().rows.forEach((row) => {
+ if (row.getIsSelected()) {
+ row.toggleSelected(false);
+ }
+ });
+ } else {
+ // 모든 선택 해제
+ table.toggleAllPageRowsSelected(false);
+ }
+ }}
+ aria-label="행 선택"
+ className="translate-y-[2px]"
+ />
+ ),
+ cell: ({ row, table }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => {
+ if (value) {
+ // 다른 모든 행 선택 해제 후 현재 행만 선택
+ table.getRowModel().rows.forEach((r) => {
+ if (r.id !== row.id) {
+ r.toggleSelected(false);
+ }
+ });
+ }
+ row.toggleSelected(!!value);
+ }}
+ aria-label="행 선택"
+ className="translate-y-[2px]"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ },
+ {
+ accessorKey: "pcrApprovalStatus",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="PCR 승인상태" />
+ ),
+ cell: ({ row }) => {
+ const status = row.getValue("pcrApprovalStatus") as string
+ const config = PCR_APPROVAL_STATUS_CONFIG[status as keyof typeof PCR_APPROVAL_STATUS_CONFIG]
+
+ if (!config) {
+ return <Badge variant="outline">{status}</Badge>
+ }
+
+ return (
+ <Badge
+ variant={config.variant as any}
+ className={config.color}
+ >
+ {config.label}
+ </Badge>
+ )
+ },
+ filterFn: (row, id, value) => {
+ return value.includes(row.getValue(id))
+ },
+ },
+ {
+ accessorKey: "changeType",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="변경구분" />
+ ),
+ cell: ({ row }) => {
+ const changeType = row.getValue("changeType") as string
+ const config = PCR_CHANGE_TYPE_CONFIG[changeType as keyof typeof PCR_CHANGE_TYPE_CONFIG]
+
+ if (!config) {
+ return <Badge variant="outline">{changeType}</Badge>
+ }
+
+ return (
+ <Badge variant="outline">
+ {config.label}
+ </Badge>
+ )
+ },
+ filterFn: (row, id, value) => {
+ return value.includes(row.getValue(id))
+ },
+ },
+ {
+ accessorKey: "poContractNumber",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="PO/계약번호" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("poContractNumber") as string
+ return (
+ <div className="font-medium">
+ {value || "-"}
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: "project",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="프로젝트" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("project") as string
+ return (
+ <div className="max-w-[200px] truncate" title={value}>
+ {value || "-"}
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: "pcrRequestDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="PCR 요청일자" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("pcrRequestDate") as Date
+ if (!value) return "-"
+
+ return (
+ <div className="text-sm">
+ {value.toLocaleDateString('ko-KR', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit'
+ })}
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: "revItemNumber",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Rev./품번" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("revItemNumber") as string
+ return (
+ <div className="max-w-[150px] truncate" title={value}>
+ {value || "-"}
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: "purchaseContractManager",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="구매/계약담당자" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("purchaseContractManager") as string
+ return (
+ <div className="max-w-[120px] truncate" title={value}>
+ {value || "-"}
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: "pcrCreator",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="PCR 생성자" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("pcrCreator") as string
+ return (
+ <div className="max-w-[120px] truncate" title={value}>
+ {value || "-"}
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: "poContractAmountBefore",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="PO/계약금액(전)" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("poContractAmountBefore") as number
+ const currency = row.original.contractCurrency
+
+ if (!value) return "-"
+
+ return (
+ <div className="text-right font-mono text-sm">
+ {currency} {value.toLocaleString()}
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: "poContractAmountAfter",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="PO/계약금액(후)" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("poContractAmountAfter") as number
+ const currency = row.original.contractCurrency
+
+ if (!value) return "-"
+
+ return (
+ <div className="text-right font-mono text-sm">
+ {currency} {value.toLocaleString()}
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: "contractCurrency",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="계약통화" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("contractCurrency") as string
+ return (
+ <div className="text-center">
+ {value || "-"}
+ </div>
+ )
+ },
+ },
+ // EvcP 페이지에서만 협력업체 정보 표시
+ ...(isEvcpPage ? [{
+ accessorKey: "vendorName" as const,
+ header: ({ column }: { column: any }) => (
+ <DataTableColumnHeaderSimple column={column} title="협력업체" />
+ ),
+ cell: ({ row }: { row: any }) => {
+ const vendorName = row.getValue("vendorName") as string
+ const vendorCode = row.original.vendorCode as string
+ const displayText = vendorName
+ ? vendorCode
+ ? `${vendorName} (${vendorCode})`
+ : vendorName
+ : "-"
+
+ return (
+ <div className="max-w-[150px] truncate" title={displayText}>
+ {displayText}
+ </div>
+ )
+ },
+ }] : []),
+ {
+ accessorKey: "details",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="상세" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("details") as string
+ return (
+ <div className="max-w-[200px] truncate" title={value}>
+ {value || "-"}
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: "pcrReason",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="PCR 사유" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("pcrReason") as string
+ return (
+ <div className="max-w-[150px] truncate" title={value}>
+ {value || "-"}
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: "detailsReason",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="상세 사유" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("detailsReason") as string
+ return (
+ <div className="max-w-[150px] truncate" title={value}>
+ {value || "-"}
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: "rejectionReason",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="거절 사유" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("rejectionReason") as string
+ return (
+ <div className="max-w-[150px] truncate" title={value}>
+ {value || "-"}
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: "pcrResponseDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="PCR 회신일" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("pcrResponseDate") as Date
+ if (!value) return "-"
+
+ return (
+ <div className="text-sm">
+ {value.toLocaleDateString('ko-KR', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit'
+ })}
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="생성일" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("createdAt") as Date
+ if (!value) return "-"
+
+ return (
+ <div className="text-sm">
+ {value.toLocaleDateString('ko-KR', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit'
+ })}
+ </div>
+ )
+ },
+ },
+ {
+ id: "actions",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="작업" />
+ ),
+ cell: ({ row }) => {
+ const pcrPo = row.original
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <MoreHorizontal className="size-4" />
+ <span className="sr-only">메뉴 열기</span>
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-[160px]">
+ <DropdownMenuLabel>작업</DropdownMenuLabel>
+ <DropdownMenuSeparator />
+
+ <DropdownMenuItem
+ onClick={() => setRowAction({ row, type: "update" })}
+ >
+ <Edit className="mr-2 size-4" />
+ 수정
+ </DropdownMenuItem>
+
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ enableSorting: false,
+ enableHiding: false,
+ },
+ ]
+
+ return columns
+}
diff --git a/lib/pcr/table/pcr-table-toolbar-actions.tsx b/lib/pcr/table/pcr-table-toolbar-actions.tsx
new file mode 100644
index 00000000..3e2394fb
--- /dev/null
+++ b/lib/pcr/table/pcr-table-toolbar-actions.tsx
@@ -0,0 +1,120 @@
+"use client"
+
+import * as React from "react"
+import { Button } from "@/components/ui/button"
+import { Loader2, RefreshCw, Download, CheckCircle, XCircle } from "lucide-react"
+import type { Table } from "@tanstack/react-table"
+import { CreatePcrDialog } from "./create-pcr-dialog"
+import { ApproveRejectPcrDialog } from "./approve-reject-pcr-dialog"
+import { PcrPoData } from "@/lib/pcr/types"
+
+interface PcrTableToolbarActionsProps<TData> {
+ selection: Table<TData>
+ onRefresh: () => void
+ isEvcpPage?: boolean
+ isPartnersPage?: boolean
+ currentVendorId?: number
+}
+
+export function PcrTableToolbarActions<TData>({
+ selection,
+ onRefresh,
+ isEvcpPage = false,
+ isPartnersPage = false,
+ currentVendorId,
+}: PcrTableToolbarActionsProps<TData>) {
+ const [approveDialogOpen, setApproveDialogOpen] = React.useState(false)
+ const [rejectDialogOpen, setRejectDialogOpen] = React.useState(false)
+ const [selectedPcr, setSelectedPcr] = React.useState<PcrPoData | null>(null)
+
+ // 선택된 행들 가져오기
+ const selectedRows = selection.getSelectedRowModel().rows
+ const selectedPcrData = selectedRows.length === 1 ? (selectedRows[0].original as PcrPoData) : null
+
+ // 승인/거절 가능 여부 확인
+ // const canApproveOrReject = selectedPcrData && selectedPcrData.pcrApprovalStatus === '승인대기'
+ const canApproveOrReject = selectedPcrData // 임시로 주석 처리하여 테스트
+
+
+
+ const handleExport = () => {
+ // 추후 구현
+ console.log("Export functionality to be implemented")
+ }
+
+ const handleApprove = () => {
+ if (selectedPcrData) {
+ setSelectedPcr(selectedPcrData)
+ setApproveDialogOpen(true)
+ }
+ }
+
+ const handleReject = () => {
+ if (selectedPcrData) {
+ setSelectedPcr(selectedPcrData)
+ setRejectDialogOpen(true)
+ }
+ }
+
+ const handleActionSuccess = () => {
+ // 액션 성공 시 선택 해제
+ selection.resetRowSelection()
+ // revalidatePath로 인해 자동으로 페이지 새로고침됨
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+
+ {/* 승인 버튼 */}
+ <Button
+ variant="default"
+ size="sm"
+ onClick={handleApprove}
+ disabled={!canApproveOrReject}
+ className="gap-2 bg-green-600 hover:bg-green-700"
+ >
+ <CheckCircle className="size-4" />
+ 승인
+ </Button>
+
+ {/* 거절 버튼 */}
+ <Button
+ variant="destructive"
+ size="sm"
+ onClick={handleReject}
+ disabled={!canApproveOrReject}
+ className="gap-2"
+ >
+ <XCircle className="size-4" />
+ 거절
+ </Button>
+
+ {/* PCR 생성 다이얼로그 - Partners 페이지에서는 표시하지 않음 */}
+ {!isPartnersPage && (
+ <CreatePcrDialog
+ isEvcpPage={isEvcpPage}
+ currentVendorId={currentVendorId}
+ onSuccess={onRefresh}
+ />
+ )}
+
+ {/* 승인 다이얼로그 */}
+ <ApproveRejectPcrDialog
+ open={approveDialogOpen}
+ onOpenChange={setApproveDialogOpen}
+ pcrData={selectedPcr}
+ actionType="approve"
+ onSuccess={handleActionSuccess}
+ />
+
+ {/* 거절 다이얼로그 */}
+ <ApproveRejectPcrDialog
+ open={rejectDialogOpen}
+ onOpenChange={setRejectDialogOpen}
+ pcrData={selectedPcr}
+ actionType="reject"
+ onSuccess={handleActionSuccess}
+ />
+ </div>
+ )
+}
diff --git a/lib/pcr/table/pcr-table.tsx b/lib/pcr/table/pcr-table.tsx
new file mode 100644
index 00000000..6538e820
--- /dev/null
+++ b/lib/pcr/table/pcr-table.tsx
@@ -0,0 +1,396 @@
+"use client"
+
+import * as React from "react"
+import { useSearchParams } from "next/navigation"
+import type {
+ DataTableRowAction,
+} from "@/types/table"
+import {
+ ResizablePanelGroup,
+ ResizablePanel,
+ ResizableHandle,
+} from "@/components/ui/resizable"
+
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { getColumns } from "./pcr-table-column"
+import { useEffect, useMemo } from "react"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { PcrTableToolbarActions } from "./pcr-table-toolbar-actions"
+import { getPcrPoList } from "@/lib/pcr/service"
+import { useTablePresets } from "@/components/data-table/use-table-presets"
+import { PcrDetailTables } from "./detail-table/pcr-detail-table"
+import { EditPcrSheet } from "./edit-pcr-sheet"
+import { cn } from "@/lib/utils"
+import { PcrPoData } from "@/lib/pcr/types"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+} from "@/types/table"
+
+interface PcrTableProps {
+ tableData: Awaited<ReturnType<typeof getPcrPoList>>
+ className?: string;
+ calculatedHeight?: string;
+ isEvcpPage?: boolean; // EvcP 페이지인지 여부
+ isPartnersPage?: boolean; // Partners 페이지인지 여부
+ currentVendorId?: number; // Partners 페이지에서 현재 사용자의 vendorId
+}
+
+export function PcrTable({
+ tableData,
+ className,
+ calculatedHeight,
+ isEvcpPage = false,
+ isPartnersPage = false,
+ currentVendorId,
+}: PcrTableProps) {
+ const searchParams = useSearchParams()
+
+
+ // 선택된 PCR_PO 상태
+ const [selectedPcrPo, setSelectedPcrPo] = React.useState<PcrPoData | null>(null)
+
+ // Edit sheet 상태
+ const [editSheetOpen, setEditSheetOpen] = React.useState(false)
+ const [editingPcr, setEditingPcr] = React.useState<PcrPoData | null>(null)
+
+
+ // 패널 collapse 상태
+ const [panelHeight, setPanelHeight] = React.useState<number>(55)
+
+ // RFQListTable 컴포넌트 내부의 rowAction 처리 부분 수정
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<PcrPoData> | null>(null)
+
+ // 고정 높이 설정을 위한 상수 (실제 측정값으로 조정 필요)
+ const LAYOUT_HEADER_HEIGHT = 64 // Layout Header 높이
+ const LAYOUT_FOOTER_HEIGHT = 60 // Layout Footer 높이 (있다면 실제 값)
+ const LOCAL_HEADER_HEIGHT = 72 // 로컬 헤더 바 높이 (p-4 + border)
+
+ console.log(calculatedHeight)
+
+ // 테이블 컨텐츠 높이 - 전달받은 높이에서 로컬 헤더 제외
+ const FIXED_TABLE_HEIGHT = calculatedHeight
+ ? `calc(${calculatedHeight} - ${LOCAL_HEADER_HEIGHT}px)`
+ : `calc(100vh - ${LAYOUT_HEADER_HEIGHT + LAYOUT_FOOTER_HEIGHT + LOCAL_HEADER_HEIGHT+76}px)` // fallback
+
+ // 데이터는 props로 직접 전달받음
+
+ // 초기 설정 정의
+ const initialSettings = React.useMemo(() => ({
+ page: parseInt(searchParams?.get('page') || '1'),
+ perPage: parseInt(searchParams?.get('perPage') || '10'),
+ sort: searchParams?.get('sort') ? JSON.parse(searchParams.get('sort')!) : [{ id: "createdAt", desc: true }],
+ columnVisibility: {},
+ columnOrder: [],
+ pinnedColumns: { left: [], right: [] },
+ filters: [],
+ joinOperator: "and" as const,
+ basicFilters: [],
+ basicJoinOperator: "and" as const,
+ search: "",
+ }), [searchParams])
+
+ // DB 기반 프리셋 훅 사용
+ const {
+ getCurrentSettings,
+ } = useTablePresets<PcrPoData>('pcr-po-table', initialSettings)
+
+
+ // 행 액션 처리
+ useEffect(() => {
+ if (rowAction) {
+ switch (rowAction.type) {
+ case "select":
+ // 객체 참조 안정화를 위해 필요한 필드만 추출
+ const pcrPoData = rowAction.row.original;
+ console.log("Row action select - PCR_PO 데이터:", pcrPoData)
+ setSelectedPcrPo({
+ id: pcrPoData.id,
+ no: pcrPoData.no,
+ pcrApprovalStatus: pcrPoData.pcrApprovalStatus,
+ changeType: pcrPoData.changeType,
+ details: pcrPoData.details || undefined,
+ project: pcrPoData.project || undefined,
+ pcrRequestDate: pcrPoData.pcrRequestDate,
+ poContractNumber: pcrPoData.poContractNumber,
+ revItemNumber: pcrPoData.revItemNumber || undefined,
+ purchaseContractManager: pcrPoData.purchaseContractManager || undefined,
+ pcrCreator: pcrPoData.pcrCreator || undefined,
+ poContractAmountBefore: pcrPoData.poContractAmountBefore ? Number(pcrPoData.poContractAmountBefore) : undefined as number | undefined,
+ poContractAmountAfter: pcrPoData.poContractAmountAfter ? Number(pcrPoData.poContractAmountAfter) : undefined as number | undefined,
+ contractCurrency: pcrPoData.contractCurrency || "KRW",
+ pcrReason: pcrPoData.pcrReason || undefined,
+ detailsReason: pcrPoData.detailsReason || undefined,
+ rejectionReason: pcrPoData.rejectionReason || undefined,
+ pcrResponseDate: pcrPoData.pcrResponseDate || undefined,
+ vendorId: pcrPoData.vendorId || undefined,
+ vendorName: pcrPoData.vendorName || undefined,
+ createdBy: pcrPoData.createdBy,
+ updatedBy: pcrPoData.updatedBy,
+ createdAt: pcrPoData.createdAt,
+ updatedAt: pcrPoData.updatedAt,
+ });
+ break;
+ case "update":
+ // PCR_PO 수정 시트 열기
+ setEditingPcr(rowAction.row.original)
+ setEditSheetOpen(true)
+ break;
+ case "delete":
+ console.log("Delete PCR_PO:", rowAction.row.original)
+ break;
+ }
+ setRowAction(null)
+ }
+ }, [rowAction])
+
+ const columns = React.useMemo(
+ () => getColumns({
+ setRowAction,
+ isEvcpPage,
+ }),
+ [setRowAction, isEvcpPage]
+ )
+
+ // 필터 필드 정의
+ const filterFields: DataTableFilterField<PcrPoData>[] = []
+
+ const advancedFilterFields: DataTableAdvancedFilterField<PcrPoData>[] = [
+ {
+ id: "pcrApprovalStatus",
+ label: "PCR 승인상태",
+ type: "multi-select",
+ options: [
+ { label: "승인대기", value: "승인대기" },
+ { label: "승인완료", value: "승인완료" },
+ { label: "거절", value: "거절" },
+ { label: "취소", value: "취소" },
+ ],
+ },
+ {
+ id: "changeType",
+ label: "변경구분",
+ type: "multi-select",
+ options: [
+ { label: "수량변경", value: "QUANTITY" },
+ { label: "금액변경", value: "AMOUNT" },
+ { label: "기간변경", value: "PERIOD" },
+ { label: "품목변경", value: "ITEM" },
+ { label: "기타", value: "OTHER" },
+ ],
+ },
+ {
+ id: "poContractNumber",
+ label: "PO/계약번호",
+ type: "text",
+ },
+ {
+ id: "project",
+ label: "프로젝트",
+ type: "text",
+ },
+ ...(isEvcpPage ? [{
+ id: "vendorName" as const,
+ label: "협력업체",
+ type: "text" as const,
+ }] : []),
+ {
+ id: "pcrRequestDate",
+ label: "PCR 요청일자",
+ type: "date",
+ },
+ {
+ id: "createdAt",
+ label: "생성일",
+ type: "date",
+ },
+ ]
+
+
+ // 현재 설정 가져오기
+ const currentSettings = useMemo(() => {
+ return getCurrentSettings()
+ }, [getCurrentSettings])
+
+ // useDataTable 초기 상태 설정
+ const initialState = useMemo(() => {
+ return {
+ sorting: initialSettings.sort.filter((sortItem: any) => {
+ const columnExists = columns.some((col: any) => col.accessorKey === sortItem.id)
+ return columnExists
+ }) as any,
+ columnVisibility: currentSettings.columnVisibility,
+ columnPinning: currentSettings.pinnedColumns,
+ }
+ }, [currentSettings, initialSettings.sort, columns])
+
+ // useDataTable 훅 설정
+ const { table } = useDataTable({
+ data: tableData?.data || [],
+ columns: columns as any,
+ pageCount: tableData?.pageCount || 0,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState,
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ columnResizeMode: "onEnd",
+ })
+
+ // 선택된 행들 감시하여 selectedPcrPo 설정 (checkbox selection)
+ React.useEffect(() => {
+ if (table) {
+ const selectedRows = table.getSelectedRowModel().rows
+ if (selectedRows.length === 1) {
+ const pcrPoData = selectedRows[0].original
+ const selectedData = {
+ id: pcrPoData.id,
+ no: (pcrPoData as any).no,
+ pcrApprovalStatus: pcrPoData.pcrApprovalStatus || "",
+ changeType: pcrPoData.changeType || "",
+ details: pcrPoData.details || undefined,
+ project: pcrPoData.project || undefined,
+ pcrRequestDate: pcrPoData.pcrRequestDate,
+ poContractNumber: pcrPoData.poContractNumber,
+ revItemNumber: pcrPoData.revItemNumber || undefined,
+ purchaseContractManager: pcrPoData.purchaseContractManager || undefined,
+ pcrCreator: pcrPoData.pcrCreator || undefined,
+ poContractAmountBefore: pcrPoData.poContractAmountBefore ? Number(pcrPoData.poContractAmountBefore) : undefined as number | undefined,
+ poContractAmountAfter: pcrPoData.poContractAmountAfter ? Number(pcrPoData.poContractAmountAfter) : undefined as number | undefined,
+ contractCurrency: pcrPoData.contractCurrency || "KRW",
+ pcrReason: pcrPoData.pcrReason || undefined,
+ detailsReason: pcrPoData.detailsReason || undefined,
+ rejectionReason: pcrPoData.rejectionReason || undefined,
+ pcrResponseDate: pcrPoData.pcrResponseDate || undefined,
+ vendorId: pcrPoData.vendorId || undefined,
+ vendorName: pcrPoData.vendorName || undefined,
+ createdBy: pcrPoData.createdBy,
+ updatedBy: pcrPoData.updatedBy,
+ createdAt: pcrPoData.createdAt,
+ updatedAt: pcrPoData.updatedAt,
+ }
+ setSelectedPcrPo(selectedData)
+ } else if (selectedRows.length === 0) {
+ // 선택이 해제되었을 때는 selectedPcrPo를 null로 설정하지 않음
+ // row action select가 우선권을 가짐
+ }
+ }
+ }, [table?.getSelectedRowModel().rows])
+
+
+ return (
+ <div
+ className={cn("flex flex-col relative", className)}
+ style={{ height: calculatedHeight }}
+ >
+
+ {/* Main Content */}
+ <div
+ className="flex flex-col"
+ style={{
+ height: '100%'
+ }}
+ >
+ {/* Header Bar - 고정 높이 */}
+ <div
+ className="flex items-center justify-between p-4 bg-background border-b"
+ style={{
+ height: `${LOCAL_HEADER_HEIGHT}px`,
+ flexShrink: 0
+ }}
+ >
+
+ {/* Right side info */}
+ <div className="text-sm text-muted-foreground">
+ {tableData && (
+ <span>총 {tableData.totalCount || 0}건</span>
+ )}
+ </div>
+ </div>
+
+ {/* Table Content Area - 계산된 높이 사용 */}
+ <div
+ className="relative bg-background"
+ style={{
+ height: FIXED_TABLE_HEIGHT,
+ display: 'grid',
+ gridTemplateRows: '1fr',
+ gridTemplateColumns: '1fr'
+ }}
+ >
+ <ResizablePanelGroup
+ direction="vertical"
+ className="w-full h-full"
+ >
+ <ResizablePanel
+ defaultSize={60}
+ minSize={25}
+ maxSize={75}
+ collapsible={false}
+ onResize={(size) => {
+ setPanelHeight(size)
+ }}
+ className="flex flex-col overflow-hidden"
+ >
+ {/* 상단 테이블 영역 */}
+ <div className="flex-1 min-h-0 overflow-hidden">
+ <DataTable
+ table={table}
+ maxHeight={`${panelHeight*0.5}vh`}
+ >
+ <DataTableAdvancedToolbar
+ table={table as any}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <div className="flex items-center gap-2">
+ <PcrTableToolbarActions
+ selection={table}
+ onRefresh={() => {}}
+ isEvcpPage={isEvcpPage}
+ isPartnersPage={isPartnersPage}
+ currentVendorId={currentVendorId}
+ />
+ </div>
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </div>
+ </ResizablePanel>
+
+ <ResizableHandle withHandle />
+
+ <ResizablePanel
+ minSize={25}
+ defaultSize={40}
+ collapsible={false}
+ className="flex flex-col overflow-hidden"
+ >
+ {/* 하단 상세 테이블 영역 */}
+ <div className="flex-1 min-h-0 overflow-hidden bg-background">
+ <PcrDetailTables
+ selectedPcrPo={selectedPcrPo}
+ maxHeight={`${(100-panelHeight)*0.4}vh`}
+ isPartnersPage={isPartnersPage}
+ />
+ </div>
+ </ResizablePanel>
+ </ResizablePanelGroup>
+ </div>
+ </div>
+
+ {/* PCR 수정 시트 */}
+ <EditPcrSheet
+ open={editSheetOpen}
+ onOpenChange={setEditSheetOpen}
+ pcrData={editingPcr}
+ onSuccess={() => {
+ // 데이터 새로고침
+ window.location.reload()
+ }}
+ />
+ </div>
+ )
+}
diff --git a/lib/pcr/types.ts b/lib/pcr/types.ts
new file mode 100644
index 00000000..cdbc7d2e
--- /dev/null
+++ b/lib/pcr/types.ts
@@ -0,0 +1,189 @@
+/**
+ * PCR (Purchase Change Request) 관련 타입 정의
+ *
+ * 이 파일에서 PCR 관련 모든 타입과 상수를 중앙 집중식으로 관리합니다.
+ */
+
+// ===== PCR 승인 상태 =====
+export const PCR_APPROVAL_STATUSES = {
+ PENDING: "승인대기",
+ APPROVED: "승인완료",
+ REJECTED: "거절",
+ CANCELLED: "취소",
+} as const;
+
+export type PcrApprovalStatus = typeof PCR_APPROVAL_STATUSES[keyof typeof PCR_APPROVAL_STATUSES];
+
+// ===== 변경 구분 =====
+export const PCR_CHANGE_TYPES = {
+ QUANTITY_CHANGE: "수량변경",
+ PRICE_CHANGE: "가격변경",
+ SPEC_CHANGE: "규격변경",
+ DELIVERY_CHANGE: "납기변경",
+ OTHER: "기타",
+} as const;
+
+export type PcrChangeType = typeof PCR_CHANGE_TYPES[keyof typeof PCR_CHANGE_TYPES];
+
+// ===== 상태 설정 객체 (UI에서 사용) =====
+export const PCR_APPROVAL_STATUS_CONFIG = {
+ [PCR_APPROVAL_STATUSES.PENDING]: {
+ label: "승인대기",
+ variant: "outline" as const,
+ description: "승인을 기다리고 있는 상태",
+ color: "text-yellow-600",
+ },
+ [PCR_APPROVAL_STATUSES.APPROVED]: {
+ label: "승인완료",
+ variant: "default" as const,
+ description: "승인이 완료된 상태",
+ color: "text-green-600",
+ },
+ [PCR_APPROVAL_STATUSES.REJECTED]: {
+ label: "거절",
+ variant: "destructive" as const,
+ description: "승인이 거절된 상태",
+ color: "text-red-600",
+ },
+ [PCR_APPROVAL_STATUSES.CANCELLED]: {
+ label: "취소",
+ variant: "secondary" as const,
+ description: "취소된 상태",
+ color: "text-gray-600",
+ },
+} as const;
+
+// ===== 변경 구분 설정 객체 (UI에서 사용) =====
+export const PCR_CHANGE_TYPE_CONFIG = {
+ [PCR_CHANGE_TYPES.QUANTITY_CHANGE]: {
+ label: "수량변경",
+ description: "수량 변경 요청",
+ },
+ [PCR_CHANGE_TYPES.PRICE_CHANGE]: {
+ label: "가격변경",
+ description: "가격 변경 요청",
+ },
+ [PCR_CHANGE_TYPES.SPEC_CHANGE]: {
+ label: "규격변경",
+ description: "규격 변경 요청",
+ },
+ [PCR_CHANGE_TYPES.DELIVERY_CHANGE]: {
+ label: "납기변경",
+ description: "납기 변경 요청",
+ },
+ [PCR_CHANGE_TYPES.OTHER]: {
+ label: "기타",
+ description: "기타 변경 요청",
+ },
+} as const;
+
+// ===== PCR 관련 인터페이스 =====
+
+export interface PcrPoData {
+ id: number;
+ no?: number;
+ pcrApprovalStatus: string;
+ changeType: string;
+ details?: string;
+ project?: string;
+ pcrRequestDate: Date;
+ poContractNumber: string;
+ revItemNumber?: string;
+ purchaseContractManager?: string;
+ pcrCreator?: string;
+ poContractAmountBefore?: number;
+ poContractAmountAfter?: number;
+ contractCurrency?: string;
+ pcrReason?: string;
+ detailsReason?: string;
+ rejectionReason?: string;
+ pcrResponseDate?: Date;
+ vendorId?: number;
+ vendorName?: string; // JOIN 결과 포함
+ createdBy: number;
+ updatedBy: number;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+export interface PcrPrData {
+ id: number;
+ no: number;
+ materialNumber: string;
+ materialDetails?: string;
+ quantityBefore?: number;
+ quantityAfter?: number;
+ weightBefore?: number;
+ weightAfter?: number;
+ subcontractorWeightBefore?: number;
+ subcontractorWeightAfter?: number;
+ supplierWeightBefore?: number;
+ supplierWeightAfter?: number;
+ specDrawingBefore?: string;
+ specDrawingAfter?: string;
+ initialPoContractDate?: Date;
+ specChangeDate?: Date;
+ poContractModifiedDate?: Date;
+ confirmationDate?: Date;
+ designManager?: string;
+ poContractNumber: string;
+ createdBy: number;
+ updatedBy: number;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+// ===== 필터 및 검색 관련 =====
+
+export interface PcrPoFilters {
+ pcrApprovalStatus?: string;
+ changeType?: string;
+ project?: string;
+ poContractNumber?: string;
+ vendorId?: number;
+ startDate?: Date;
+ endDate?: Date;
+}
+
+export interface PcrPrFilters {
+ materialNumber?: string;
+ poContractNumber?: string;
+ designManager?: string;
+}
+
+// ===== CRUD 작업 결과 =====
+
+export interface PcrOperationResult {
+ success: boolean;
+ data?: any;
+ error?: string;
+ message?: string;
+}
+
+// ===== 테이블 표시용 타입 =====
+
+export interface PcrPoTableData extends PcrPoData {
+ // 추가적인 표시용 필드들
+ statusConfig?: typeof PCR_APPROVAL_STATUS_CONFIG[keyof typeof PCR_APPROVAL_STATUS_CONFIG];
+ changeTypeConfig?: typeof PCR_CHANGE_TYPE_CONFIG[keyof typeof PCR_CHANGE_TYPE_CONFIG];
+}
+
+// ===== PCR_PR 첨부파일 타입 =====
+export interface PcrPrAttachmentData {
+ id: number;
+ pcrPrId: number;
+ type: 'BEFORE' | 'AFTER'; // 변경전/변경후
+ fileName: string;
+ filePath: string;
+ fileSize?: number;
+ mimeType?: string;
+ createdBy?: number;
+ createdAt: Date;
+ updatedBy?: number;
+ updatedAt: Date;
+}
+
+export interface PcrPrTableData extends PcrPrData {
+ // 추가적인 표시용 필드들
+ attachments?: PcrPrAttachmentData[];
+}