summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/[lng]/evcp/(evcp)/bid-projects/layout.tsx39
-rw-r--r--app/[lng]/evcp/(evcp)/bid-projects/page.tsx84
-rw-r--r--app/api/(S-ERP)/(ECC)/IF_ECC_EVCP_BIDDING_PROJECT/route.ts11
-rw-r--r--components/bidding-projects/bid-projects-container.tsx107
-rw-r--r--config/bidProjectsColumnsConfig.ts6
-rw-r--r--db/schema/projects.ts5
-rw-r--r--lib/bidding-projects/actions.ts237
-rw-r--r--lib/bidding-projects/table/projects-table-columns.tsx43
-rw-r--r--lib/bidding-projects/table/projects-table-toolbar-actions.tsx36
-rw-r--r--lib/bidding-projects/table/projects-table.tsx17
-rw-r--r--lib/bidding-projects/table/update-project-sheet.tsx274
-rw-r--r--lib/bidding-projects/validation.ts15
12 files changed, 795 insertions, 79 deletions
diff --git a/app/[lng]/evcp/(evcp)/bid-projects/layout.tsx b/app/[lng]/evcp/(evcp)/bid-projects/layout.tsx
new file mode 100644
index 00000000..9c142df1
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/bid-projects/layout.tsx
@@ -0,0 +1,39 @@
+import * as React from "react"
+import { BidProjectsContainer } from "@/components/bidding-projects/bid-projects-container"
+import { Shell } from "@/components/shell"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+
+// Layout 컴포넌트는 서버 컴포넌트입니다
+export default function BidProjectsLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ // 프로젝트 타입 정의
+ const projectTypes = [
+ { id: "all", name: "전체" },
+ { id: "SHIP", name: "조선" },
+ { id: "TOP", name: "해양 TOP" },
+ { id: "HULL", name: "해양 HULL" },
+ ]
+
+ return (
+ <Shell className="gap-4">
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <BidProjectsContainer projectTypes={projectTypes}>
+ {children}
+ </BidProjectsContainer>
+ </React.Suspense>
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/bid-projects/page.tsx b/app/[lng]/evcp/(evcp)/bid-projects/page.tsx
index 55f90dbb..e98d391b 100644
--- a/app/[lng]/evcp/(evcp)/bid-projects/page.tsx
+++ b/app/[lng]/evcp/(evcp)/bid-projects/page.tsx
@@ -2,13 +2,11 @@ import * as React from "react"
import { type SearchParams } from "@/types/table"
import { getValidFilters } from "@/lib/data-table"
-import { Skeleton } from "@/components/ui/skeleton"
import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { Shell } from "@/components/shell"
import { getBidProjectLists } from "@/lib/bidding-projects/service"
import { searchParamsBidProjectsCache } from "@/lib/bidding-projects/validation"
import { BidProjectsTable } from "@/lib/bidding-projects/table/projects-table"
-import { InformationButton } from "@/components/information/information-button"
+import type { Filter } from "@/types/table"
interface IndexPageProps {
searchParams: Promise<SearchParams>
@@ -18,60 +16,48 @@ export default async function IndexPage(props: IndexPageProps) {
const searchParams = await props.searchParams
const search = searchParamsBidProjectsCache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
+ // URL에서 프로젝트 타입 가져오기
+ const projectType = searchParams.type || "all"
+
+ // 기존 필터에 프로젝트 타입 필터 추가
+ let filters = search.filters || []
+
+ // 타입별 필터링 (전체가 아닌 경우만)
+ if (projectType !== "all") {
+ const typeFilter: Filter = {
+ id: "pjtType",
+ value: projectType,
+ type: "select",
+ operator: "eq"
+ }
+
+ // 기존 pjtType 필터가 있으면 제거하고 새로 추가
+ filters = filters.filter(f => f.id !== "pjtType")
+ filters = [...filters, typeFilter]
+ }
+
+ const validFilters = getValidFilters(filters)
const promises = Promise.all([
getBidProjectLists({
...search,
filters: validFilters,
}),
-
])
return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">
- 견적 프로젝트 리스트
- </h2>
- <InformationButton pagePath="evcp/bid-projects" />
- </div>
- <p className="text-muted-foreground">
- SAP(S-ERP)로부터 수신한 견적 프로젝트 데이터입니다. 기술영업의 Budgetary RFQ에서 사용됩니다.
- {/* <span className="inline-flex items-center whitespace-nowrap">
- <Ellipsis className="size-3" />
- <span className="ml-1">버튼</span>
- </span>
- 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. */}
- </p>
- </div>
- </div>
- </div>
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* <DateRangePicker
- triggerSize="sm"
- triggerClassName="ml-auto w-56 sm:w-60"
- align="end"
- shallow={false}
- /> */}
- </React.Suspense>
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <BidProjectsTable promises={promises} />
- </React.Suspense>
- </Shell>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <BidProjectsTable promises={promises} />
+ </React.Suspense>
)
}
diff --git a/app/api/(S-ERP)/(ECC)/IF_ECC_EVCP_BIDDING_PROJECT/route.ts b/app/api/(S-ERP)/(ECC)/IF_ECC_EVCP_BIDDING_PROJECT/route.ts
index a63fff40..0c36974f 100644
--- a/app/api/(S-ERP)/(ECC)/IF_ECC_EVCP_BIDDING_PROJECT/route.ts
+++ b/app/api/(S-ERP)/(ECC)/IF_ECC_EVCP_BIDDING_PROJECT/route.ts
@@ -279,6 +279,14 @@ async function saveToDatabase(data: RequestData) {
where: eq(biddingProjects.pspid, project.pspid)
});
+ // sector 값에 따라 pjtType 결정
+ let pjtType: 'SHIP' | 'HULL' | null = null;
+ if (project.sector === 'S') {
+ pjtType = 'SHIP';
+ } else if (project.sector === 'M') {
+ pjtType = 'HULL';
+ }
+
const projectValues: NewBiddingProject = {
pspid: project.pspid,
projNm: project.projNm,
@@ -296,7 +304,8 @@ async function saveToDatabase(data: RequestData) {
pmodelUom: project.pmodelUom,
txt04: project.txt04,
txt30: project.txt30,
- estmPm: project.estmPm
+ estmPm: project.estmPm,
+ pjtType: pjtType
};
if (existingProject) {
diff --git a/components/bidding-projects/bid-projects-container.tsx b/components/bidding-projects/bid-projects-container.tsx
new file mode 100644
index 00000000..cae01b04
--- /dev/null
+++ b/components/bidding-projects/bid-projects-container.tsx
@@ -0,0 +1,107 @@
+"use client"
+
+import * as React from "react"
+import { useRouter, usePathname, useSearchParams } from "next/navigation"
+import { ChevronDown } from "lucide-react"
+
+import { Button } from "@/components/ui/button"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { InformationButton } from "@/components/information/information-button"
+
+interface ProjectType {
+ id: string
+ name: string
+}
+
+interface BidProjectsContainerProps {
+ projectTypes: ProjectType[]
+ children: React.ReactNode
+}
+
+export function BidProjectsContainer({
+ projectTypes,
+ children,
+}: BidProjectsContainerProps) {
+ const router = useRouter()
+ const pathname = usePathname()
+ const searchParamsObj = useSearchParams()
+
+ // useSearchParams를 메모이제이션하여 안정적인 참조 생성
+ const searchParams = React.useMemo(
+ () => searchParamsObj || new URLSearchParams(),
+ [searchParamsObj]
+ )
+
+ // URL에서 현재 선택된 프로젝트 타입 가져오기
+ const projectType = searchParams.get("type") || "all"
+
+ // 선택한 프로젝트 타입에 해당하는 이름 찾기
+ const selectedProject = projectTypes.find((project) => project.id === projectType)?.name || "전체"
+
+ // 프로젝트 타입 변경 핸들러
+ const handleProjectTypeChange = React.useCallback((value: string) => {
+ const params = new URLSearchParams(searchParams.toString())
+
+ if (value === "all") {
+ params.delete("type")
+ } else {
+ params.set("type", value)
+ }
+
+ // 기존 필터를 제거하여 새로운 타입에 맞는 깨끗한 상태로 시작
+ params.delete("filters")
+
+ router.push(`${pathname}?${params.toString()}`)
+ }, [router, pathname, searchParams])
+
+ return (
+ <>
+ {/* 상단 영역: 제목 왼쪽 / 프로젝트 타입 선택기 오른쪽 */}
+ <div className="flex items-center justify-between">
+ {/* 왼쪽: 타이틀 & 설명 */}
+ <div>
+ <div className="flex items-center gap-2">
+ <h2 className="text-2xl font-bold tracking-tight">견적 프로젝트 리스트</h2>
+ <InformationButton pagePath="evcp/bid-projects" />
+ </div>
+ <p className="text-muted-foreground">
+ 데이터 출처 - SHIP/HULL: From ECC(S-ERP), TOP: From NONSAP
+ </p>
+ </div>
+
+ {/* 오른쪽: 프로젝트 타입 드롭다운 */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="outline" className="min-w-[150px]">
+ {selectedProject}
+ <ChevronDown className="ml-2 h-4 w-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-[200px]">
+ {projectTypes.map((project) => (
+ <DropdownMenuItem
+ key={project.id}
+ onClick={() => handleProjectTypeChange(project.id)}
+ className={project.id === projectType ? "bg-muted" : ""}
+ >
+ {project.name}
+ </DropdownMenuItem>
+ ))}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+
+ {/* 컨텐츠 영역 */}
+ <section className="overflow-hidden">
+ <div>
+ {children}
+ </div>
+ </section>
+ </>
+ )
+} \ No newline at end of file
diff --git a/config/bidProjectsColumnsConfig.ts b/config/bidProjectsColumnsConfig.ts
index 0a0e5c9c..e0ca11dc 100644
--- a/config/bidProjectsColumnsConfig.ts
+++ b/config/bidProjectsColumnsConfig.ts
@@ -10,6 +10,12 @@ export interface ProjectColumnConfig {
export const bidProjectsColumnsConfig: ProjectColumnConfig[] = [
{
+ id: "pjtType",
+ label: "프로젝트 타입",
+ excelHeader: "프로젝트 타입",
+ // group: "Basic Info",
+ },
+ {
id: "pspid",
label: "견적프로젝트번호",
excelHeader: "견적프로젝트번호",
diff --git a/db/schema/projects.ts b/db/schema/projects.ts
index 8401709a..a67b2c33 100644
--- a/db/schema/projects.ts
+++ b/db/schema/projects.ts
@@ -32,6 +32,11 @@ export const biddingProjects = pgTable("bidding_projects", {
txt04: char('txt04', { length: 4 }), // 견적상태코드
txt30: varchar('txt30', { length: 30 }), // 견적상태명
estmPm: varchar('estm_pm', { length: 30 }), // 견적대표PM 성명
+
+ // 조선, 해양 헐, 해양 탑 구분 컬럼
+ // 조선 및 해양 헐은 ECC에서 SOAP으로 가져오며, 해양 탑은 NONSAP에서 ESTM_PROJ_NO 테이블에서 가져온다.
+ // 조선/해양 헐을 구분할 수 있는 방법은 ECC에서 sector가 S(SHIP)인지 M(MARINE)인지 구분하면 된다.
+ pjtType: varchar('pjt_type', { enum: ['SHIP','TOP', 'HULL'] }),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
diff --git a/lib/bidding-projects/actions.ts b/lib/bidding-projects/actions.ts
new file mode 100644
index 00000000..bbb755aa
--- /dev/null
+++ b/lib/bidding-projects/actions.ts
@@ -0,0 +1,237 @@
+"use server";
+
+import { revalidatePath, revalidateTag } from "next/cache";
+import db from "@/db/db";
+import { biddingProjects, NewBiddingProject } from "@/db/schema/projects";
+import { plftbEstmProjMast } from "@/db/schema/NONSAP/nonsap";
+import { eq, desc } from "drizzle-orm";
+import { updateBiddingProjectSchema, type UpdateBiddingProjectSchema } from "./validation";
+
+/**
+ * NONSAP에서 견적프로젝트 데이터를 가져와서 biddingProjects 테이블에 upsert하는 서버 액션
+ */
+export async function syncProjectsFromNonSap() {
+ try {
+ // 1. NONSAP에서 IF_TRGT_YN = 'Y'인 데이터를 가져오되,
+ // 같은 ESTM_PROJ_NO에 대해 REV_NO가 가장 높은 것만 가져오기
+ const nonsapProjects = await db
+ .select()
+ .from(plftbEstmProjMast)
+ .where(eq(plftbEstmProjMast.IF_TRGT_YN, "Y"))
+ .orderBy(desc(plftbEstmProjMast.REV_NO));
+
+ if (nonsapProjects.length === 0) {
+ return {
+ success: true,
+ message: "동기화할 프로젝트가 없습니다.",
+ syncedCount: 0,
+ };
+ }
+
+ // 2. 프로젝트 번호별로 가장 높은 REV_NO만 유지
+ const latestProjects = new Map();
+ for (const project of nonsapProjects) {
+ const existingProject = latestProjects.get(project.ESTM_PROJ_NO);
+ if (
+ !existingProject ||
+ (project.REV_NO && project.REV_NO > existingProject.REV_NO)
+ ) {
+ latestProjects.set(project.ESTM_PROJ_NO, project);
+ }
+ }
+
+ let syncedCount = 0;
+ const errors: string[] = [];
+
+ // 3. 각 프로젝트를 biddingProjects 형식으로 변환하고 upsert
+ for (const nonsapProject of latestProjects.values()) {
+ try {
+ // pspid가 없으면 해당 프로젝트는 건너뛰기
+ if (!nonsapProject.ESTM_PROJ_NO?.trim()) {
+ console.warn(`프로젝트 번호가 없는 데이터 건너뛰기:`, nonsapProject);
+ continue;
+ }
+
+ // 컬럼 매핑 및 타입 변환
+ const biddingProject: NewBiddingProject = {
+ pspid: nonsapProject.ESTM_PROJ_NO.substring(0, 8), // 해양 TOP은 8자리만 사용
+ projNm: nonsapProject.ESTM_PROJ_NM?.substring(0, 90) || null, // 90자 제한
+ sector: nonsapProject.BIZ_CLS?.substring(0, 1) || null, // 1자 제한
+ projMsrm: nonsapProject.SERS_CNT
+ ? (() => {
+ const numValue = Number(nonsapProject.SERS_CNT);
+ return isNaN(numValue)
+ ? null
+ : String(Math.min(Math.max(numValue, 0), 999));
+ })()
+ : null, // precision 3,0 제한 (0~999, 숫자가 아니면 null)
+ kunnr: nonsapProject.OWNER_CD?.substring(0, 10) || null, // 10자 제한
+ kunnrNm: null, // NONSAP에 없음
+ cls1: nonsapProject.CLS_1?.substring(0, 10) || null, // 10자 제한
+ cls1Nm: null, // NONSAP에 없음
+ ptype: nonsapProject.SKND_CD?.substring(0, 3) || null, // 10 -> 3자 제한
+ ptypeNm: null, // NONSAP에 없음
+ pmodelCd: nonsapProject.SHTYPE_CD?.substring(0, 10) || null, // 10자 제한
+ pmodelNm: null, // NONSAP에 없음
+ pmodelSz: nonsapProject.SHTYPE_SIZE
+ ? String(nonsapProject.SHTYPE_SIZE).substring(0, 20)
+ : null, // numeric to varchar(20)
+ pmodelUom: nonsapProject.SHTYPE_UOM?.substring(0, 5) || null, // 10 -> 5자 제한
+ txt04: nonsapProject.ESTM_TYPE?.substring(0, 4) || null, // 1 -> 4자 제한
+ txt30: null, // NONSAP에 없음
+ estmPm: null, // NONSAP에 없음
+ pjtType: "TOP", // NONSAP에서 오는 것은 해양 탑으로 고정
+ };
+
+ // upsert 처리 (pspid를 기준으로)
+ const existingProject = await db.query.biddingProjects.findFirst({
+ where: eq(biddingProjects.pspid, biddingProject.pspid),
+ });
+
+ if (existingProject) {
+ // 업데이트 - 시트에서 수정 가능한 필드들은 제외하고 NONSAP 원본 데이터만 업데이트
+ await db
+ .update(biddingProjects)
+ .set({
+ // NONSAP 원본 데이터만 업데이트 (시트 수정 필드 제외)
+ pspid: biddingProject.pspid,
+ projNm: biddingProject.projNm,
+ sector: biddingProject.sector,
+ projMsrm: biddingProject.projMsrm,
+ kunnr: biddingProject.kunnr,
+ // kunnrNm: 시트에서 수정 가능하므로 제외
+ cls1: biddingProject.cls1,
+ // cls1Nm: 시트에서 수정 가능하므로 제외
+ ptype: biddingProject.ptype,
+ // ptypeNm: 시트에서 수정 가능하므로 제외
+ pmodelCd: biddingProject.pmodelCd,
+ // pmodelNm: 시트에서 수정 가능하므로 제외
+ pmodelSz: biddingProject.pmodelSz,
+ pmodelUom: biddingProject.pmodelUom,
+ txt04: biddingProject.txt04,
+ // txt30: 시트에서 수정 가능하므로 제외
+ // estmPm: 시트에서 수정 가능하므로 제외
+ pjtType: biddingProject.pjtType,
+ updatedAt: new Date(),
+ })
+ .where(eq(biddingProjects.pspid, biddingProject.pspid));
+ } else {
+ // 신규 등록
+ await db.insert(biddingProjects).values(biddingProject);
+ }
+
+ syncedCount++;
+ } catch (error) {
+ const errorMsg = `프로젝트 ${nonsapProject.ESTM_PROJ_NO} 동기화 실패: ${
+ error instanceof Error ? error.message : "알 수 없는 오류"
+ }`;
+ errors.push(errorMsg);
+ console.error(errorMsg, error);
+ }
+ }
+
+ // 4. 관련 페이지 재검증 (공격적으로)
+ revalidatePath("/ko/evcp/bid-projects"); // 국제화 경로
+ revalidatePath("/en/evcp/bid-projects"); // 영어 경로
+ revalidatePath("/ko/evcp/vendor-info/projects"); // 국제화 기존 경로
+ revalidatePath("/en/evcp/vendor-info/projects"); // 영어 기존 경로
+
+ // 관련 태그들 재검증
+ revalidateTag("bidding-projects");
+ revalidateTag("projects");
+ revalidateTag("bid-projects-list");
+ revalidateTag("nonsap-projects");
+ revalidateTag("vendor-info");
+
+ return {
+ success: true,
+ message: `${syncedCount}개 프로젝트가 성공적으로 동기화되었습니다.${
+ errors.length > 0 ? ` (${errors.length}개 오류 발생)` : ""
+ }`,
+ syncedCount,
+ errors: errors.length > 0 ? errors : undefined,
+ };
+ } catch (error) {
+ console.error("NONSAP 프로젝트 동기화 오류:", error);
+ return {
+ success: false,
+ error:
+ error instanceof Error
+ ? error.message
+ : "프로젝트 동기화에 실패했습니다.",
+ };
+ }
+}
+
+/**
+ * 견적 프로젝트 정보를 업데이트하는 서버 액션
+ * @param input 업데이트할 프로젝트 데이터
+ */
+export async function updateBiddingProject(input: UpdateBiddingProjectSchema) {
+ try {
+ // 입력 데이터 검증
+ const validatedInput = updateBiddingProjectSchema.parse(input);
+
+ // 프로젝트 존재 여부 확인
+ const existingProject = await db.query.biddingProjects.findFirst({
+ where: eq(biddingProjects.id, validatedInput.id),
+ });
+
+ if (!existingProject) {
+ return {
+ success: false,
+ error: "프로젝트를 찾을 수 없습니다.",
+ };
+ }
+
+ // TOP 타입만 수정 가능하도록 제한
+ if (existingProject.pjtType !== "TOP") {
+ return {
+ success: false,
+ error: "해양 TOP 프로젝트만 수정할 수 있습니다.",
+ };
+ }
+
+ // 업데이트 실행
+ await db
+ .update(biddingProjects)
+ .set({
+ projNm: validatedInput.projNm,
+ kunnrNm: validatedInput.kunnrNm,
+ cls1Nm: validatedInput.cls1Nm,
+ ptypeNm: validatedInput.ptypeNm,
+ pmodelNm: validatedInput.pmodelNm,
+ pmodelSz: validatedInput.pmodelSz,
+ txt30: validatedInput.txt30,
+ estmPm: validatedInput.estmPm,
+ updatedAt: new Date(),
+ })
+ .where(eq(biddingProjects.id, validatedInput.id));
+
+ // 관련 페이지 재검증 (공격적으로)
+ revalidatePath("/evcp/bid-projects"); // 해당 페이지
+ revalidatePath("/evcp"); // 상위 경로
+ revalidatePath("/"); // 루트 경로
+ revalidatePath("/ko/evcp/bid-projects"); // 국제화 경로
+ revalidatePath("/en/evcp/bid-projects"); // 영어 경로
+
+ // 관련 태그들 재검증
+ revalidateTag("bidding-projects");
+ revalidateTag("projects");
+ revalidateTag("bid-projects-list");
+
+ return {
+ success: true,
+ message: "프로젝트가 성공적으로 업데이트되었습니다.",
+ };
+ } catch (error) {
+ console.error("프로젝트 업데이트 오류:", error);
+ return {
+ success: false,
+ error:
+ error instanceof Error
+ ? error.message
+ : "프로젝트 업데이트에 실패했습니다.",
+ };
+ }
+}
diff --git a/lib/bidding-projects/table/projects-table-columns.tsx b/lib/bidding-projects/table/projects-table-columns.tsx
index 08530ff0..b8f3b91b 100644
--- a/lib/bidding-projects/table/projects-table-columns.tsx
+++ b/lib/bidding-projects/table/projects-table-columns.tsx
@@ -7,7 +7,7 @@ import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-
import { BiddingProjects } from "@/db/schema"
import { bidProjectsColumnsConfig } from "@/config/bidProjectsColumnsConfig"
import { Button } from "@/components/ui/button"
-import { ListFilter } from "lucide-react" // Import an icon for the button
+import { ListFilter, Edit } from "lucide-react" // Import an icon for the button
interface GetColumnsProps {
setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<BiddingProjects> | null>>
@@ -76,18 +76,37 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<Bidding
id: "actions",
header: "Actions",
cell: ({ row }) => {
+ const project = row.original
+ const isTopType = project.pjtType === "TOP"
+
return (
- <Button
- variant="ghost"
- size="sm"
- className="flex items-center gap-1"
- onClick={() => {
- setRowAction({ row,type: "view-series" })
- }}
- >
- <ListFilter className="h-4 w-4" />
- 시리즈 보기
- </Button>
+ <div className="flex items-center gap-1">
+ <Button
+ variant="ghost"
+ size="sm"
+ className="flex items-center gap-1"
+ onClick={() => {
+ setRowAction({ row, type: "view-series" })
+ }}
+ >
+ <ListFilter className="h-4 w-4" />
+ 시리즈 보기
+ </Button>
+
+ {isTopType && (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="flex items-center gap-1"
+ onClick={() => {
+ setRowAction({ row, type: "update" })
+ }}
+ >
+ <Edit className="h-4 w-4" />
+ 수정
+ </Button>
+ )}
+ </div>
)
},
}
diff --git a/lib/bidding-projects/table/projects-table-toolbar-actions.tsx b/lib/bidding-projects/table/projects-table-toolbar-actions.tsx
index ee2f8c4e..3e2f3392 100644
--- a/lib/bidding-projects/table/projects-table-toolbar-actions.tsx
+++ b/lib/bidding-projects/table/projects-table-toolbar-actions.tsx
@@ -8,6 +8,7 @@ import { toast } from "sonner"
import { exportTableToExcel } from "@/lib/export"
import { Button } from "@/components/ui/button"
import { BiddingProjects } from "@/db/schema"
+import { syncProjectsFromNonSap } from "../actions"
interface ItemsTableToolbarActionsProps {
table: Table<BiddingProjects>
@@ -16,28 +17,29 @@ interface ItemsTableToolbarActionsProps {
export function ProjectTableToolbarActions({ table }: ItemsTableToolbarActionsProps) {
const [isLoading, setIsLoading] = React.useState(false)
- // 프로젝트 동기화 API 호출 함수
+ // 프로젝트 동기화 서버 액션 호출 함수
const syncProjects = async () => {
try {
setIsLoading(true)
- // API 엔드포인트 호출
- const response = await fetch('/api/cron/bid-projects')
+ // 서버 액션 호출 (NONSAP에서 데이터 동기화)
+ const result = await syncProjectsFromNonSap()
- if (!response.ok) {
- const errorData = await response.json()
- throw new Error(errorData.error || 'Failed to sync projects')
+ if (result.success) {
+ // 성공 메시지 표시
+ toast.success(result.message)
+
+ // 오류가 있었다면 추가 정보 표시
+ if (result.errors && result.errors.length > 0) {
+ console.warn('동기화 오류:', result.errors)
+ toast.warning(`일부 프로젝트 동기화 중 오류가 발생했습니다. 콘솔을 확인해주세요.`)
+ }
+
+ // 페이지 새로고침으로 테이블 데이터 업데이트
+ window.location.reload()
+ } else {
+ throw new Error(result.error || '프로젝트 동기화에 실패했습니다.')
}
-
- const data = await response.json()
-
- // 성공 메시지 표시
- toast.success(
- `Projects synced successfully! ${data.result.items} items processed.`
- )
-
- // 페이지 새로고침으로 테이블 데이터 업데이트
- window.location.reload()
} catch (error) {
console.error('Error syncing projects:', error)
toast.error(
@@ -64,7 +66,7 @@ export function ProjectTableToolbarActions({ table }: ItemsTableToolbarActionsPr
aria-hidden="true"
/>
<span className="hidden sm:inline">
- {isLoading ? 'Syncing...' : 'Get Projects'}
+ {isLoading ? '동기화 중...' : '해양 TOP 견적물량시스템 동기화'}
</span>
</Button>
diff --git a/lib/bidding-projects/table/projects-table.tsx b/lib/bidding-projects/table/projects-table.tsx
index 0e0c48f9..130e1214 100644
--- a/lib/bidding-projects/table/projects-table.tsx
+++ b/lib/bidding-projects/table/projects-table.tsx
@@ -16,6 +16,7 @@ import { getBidProjectLists } from "../service"
import { BiddingProjects } from "@/db/schema"
import { ProjectTableToolbarActions } from "./projects-table-toolbar-actions"
import { ProjectSeriesDialog } from "./project-series-dialog"
+import { UpdateProjectSheet } from "./update-project-sheet"
interface ItemsTableProps {
promises: Promise<
@@ -65,6 +66,16 @@ export function BidProjectsTable({ promises }: ItemsTableProps) {
*/
const advancedFilterFields: DataTableAdvancedFilterField<BiddingProjects>[] = [
{
+ id: "pjtType",
+ label: "프로젝트 타입",
+ type: "select",
+ options: [
+ { label: "SHIP", value: "SHIP" },
+ { label: "HULL", value: "HULL" },
+ { label: "TOP", value: "TOP" },
+ ],
+ },
+ {
id: "pspid",
label: "견적프로젝트번호",
type: "text",
@@ -151,6 +162,12 @@ export function BidProjectsTable({ promises }: ItemsTableProps) {
onOpenChange={() => setRowAction(null)}
project={rowAction?.row.original ?? null}
/>
+
+ <UpdateProjectSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ project={rowAction?.row.original ?? null}
+ />
</>
)
}
diff --git a/lib/bidding-projects/table/update-project-sheet.tsx b/lib/bidding-projects/table/update-project-sheet.tsx
new file mode 100644
index 00000000..b3275d58
--- /dev/null
+++ b/lib/bidding-projects/table/update-project-sheet.tsx
@@ -0,0 +1,274 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Loader } from "lucide-react"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+
+import { BiddingProjects } from "@/db/schema/projects"
+import { updateBiddingProjectSchema, type UpdateBiddingProjectSchema } from "../validation"
+import { updateBiddingProject } from "../actions"
+
+interface UpdateProjectSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ project: BiddingProjects | null
+}
+
+export function UpdateProjectSheet({ project, ...props }: UpdateProjectSheetProps) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+
+ const form = useForm<UpdateBiddingProjectSchema>({
+ resolver: zodResolver(updateBiddingProjectSchema),
+ })
+
+ React.useEffect(() => {
+ if (project) {
+ form.reset({
+ id: project.id,
+ projNm: project.projNm || "",
+ kunnrNm: project.kunnrNm || "",
+ cls1Nm: project.cls1Nm || "",
+ ptypeNm: project.ptypeNm || "",
+ pmodelNm: project.pmodelNm || "",
+ pmodelSz: project.pmodelSz || "",
+ txt30: project.txt30 || "",
+ estmPm: project.estmPm || "",
+ })
+ }
+ }, [project, form])
+
+ function onSubmit(input: UpdateBiddingProjectSchema) {
+ startUpdateTransition(async () => {
+ const result = await updateBiddingProject(input)
+
+ if (result.success) {
+ toast.success(result.message)
+ props.onOpenChange?.(false)
+ form.reset()
+ } else {
+ toast.error(result.error)
+ }
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>프로젝트 정보 수정</SheetTitle>
+ <SheetDescription>
+ 해양 TOP 프로젝트의 정보를 수정할 수 있습니다.
+ {project && (
+ <div className="mt-2 text-sm font-mono bg-muted p-2 rounded">
+ {project.pspid} | {project.pjtType}
+ </div>
+ )}
+ </SheetDescription>
+ </SheetHeader>
+
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="flex flex-col gap-4"
+ >
+ {/* Hidden ID field */}
+ <FormField
+ control={form.control}
+ name="id"
+ render={({ field }) => (
+ <input type="hidden" {...field} />
+ )}
+ />
+
+ {/* 프로젝트명 */}
+ <FormField
+ control={form.control}
+ name="projNm"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>프로젝트명</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="프로젝트명을 입력하세요"
+ {...field}
+ value={field.value || ""}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 선주명 */}
+ <FormField
+ control={form.control}
+ name="kunnrNm"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>선주명</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="선주명을 입력하세요"
+ {...field}
+ value={field.value || ""}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 선급명 */}
+ <FormField
+ control={form.control}
+ name="cls1Nm"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>선급명</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="선급명을 입력하세요"
+ {...field}
+ value={field.value || ""}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 선종명 */}
+ <FormField
+ control={form.control}
+ name="ptypeNm"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>선종명</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="선종명을 입력하세요"
+ {...field}
+ value={field.value || ""}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 선형명 */}
+ <FormField
+ control={form.control}
+ name="pmodelNm"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>선형명</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="선형명을 입력하세요"
+ {...field}
+ value={field.value || ""}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 선형크기 */}
+ <FormField
+ control={form.control}
+ name="pmodelSz"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>선형크기</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="선형크기를 입력하세요"
+ {...field}
+ value={field.value || ""}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 견적상태명 */}
+ <FormField
+ control={form.control}
+ name="txt30"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>견적상태명</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="견적상태명을 입력하세요"
+ {...field}
+ value={field.value || ""}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 견적대표PM */}
+ <FormField
+ control={form.control}
+ name="estmPm"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>견적대표PM</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="견적대표PM을 입력하세요"
+ {...field}
+ value={field.value || ""}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ 취소
+ </Button>
+ </SheetClose>
+ <Button disabled={isUpdatePending}>
+ {isUpdatePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ 저장
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/bidding-projects/validation.ts b/lib/bidding-projects/validation.ts
index e5f8b121..a7728613 100644
--- a/lib/bidding-projects/validation.ts
+++ b/lib/bidding-projects/validation.ts
@@ -30,3 +30,18 @@ export const searchParamsBidProjectsCache = createSearchParamsCache({
export type GetBidProjectListsSchema = Awaited<ReturnType<typeof searchParamsBidProjectsCache.parse>>
+
+// 프로젝트 업데이트 스키마 (TOP 타입용)
+export const updateBiddingProjectSchema = z.object({
+ id: z.number().int().positive(),
+ projNm: z.string().max(90).optional().nullable(),
+ kunnrNm: z.string().max(30).optional().nullable(),
+ cls1Nm: z.string().max(30).optional().nullable(),
+ ptypeNm: z.string().max(40).optional().nullable(),
+ pmodelNm: z.string().max(40).optional().nullable(),
+ pmodelSz: z.string().max(20).optional().nullable(),
+ txt30: z.string().max(30).optional().nullable(),
+ estmPm: z.string().max(30).optional().nullable(),
+})
+
+export type UpdateBiddingProjectSchema = z.infer<typeof updateBiddingProjectSchema>