diff options
42 files changed, 5481 insertions, 1754 deletions
diff --git a/app/[lng]/partners/(partners)/vendor-data-plant/[projectCode]/[packageCode]/eng/[formCode]/page.tsx b/app/[lng]/partners/(partners)/vendor-data-plant/[projectCode]/[packageCode]/eng/[formCode]/page.tsx new file mode 100644 index 00000000..351fbca3 --- /dev/null +++ b/app/[lng]/partners/(partners)/vendor-data-plant/[projectCode]/[packageCode]/eng/[formCode]/page.tsx @@ -0,0 +1,95 @@ +// app/[lng]/partners/vendor-data-plant/[projectCode]/[packageCode]/eng/[formCode]/page.tsx +import DynamicTable from "@/components/form-data-plant/form-data-table"; +import { getFormData, getFormId,getProjectIdByCode } from "@/lib/forms-plant/services"; +import { useTranslation } from "@/i18n"; +import { Skeleton } from "@/components/ui/skeleton"; + +interface EngineeringFormPageProps { + params: { + lng: string; + projectCode: string; + packageCode: string; + formCode: string; + }; + searchParams?: { + mode?: string; + }; +} + +export default async function EngineeringFormPage({ + params, + searchParams, +}: EngineeringFormPageProps) { + // 1) 구조 분해 할당 + const resolvedParams = await params; + + // 2) searchParams도 await 필요 + const resolvedSearchParams = await searchParams; + + // 3) 구조 분해 할당 + const { lng, projectCode, packageCode, formCode } = resolvedParams; + + // i18n 설정 + const { t } = await useTranslation(lng, 'engineering'); + + // URL 쿼리 파라미터에서 mode 가져오기 (await 해서 사용) + const mode = "ENG"; // 기본값은 IM + + // 4) DB 조회 - projectCode와 packageCode를 전달 + const { columns, data, editableFieldsMap } = await getFormData( + formCode, + projectCode, + packageCode + ); + + // 5) formId 조회 - projectCode와 packageCode를 전달 + const { formId } = await getFormId(projectCode, packageCode, formCode, mode); + + const projectId = await getProjectIdByCode(projectCode) + + // 6) 예외 처리 + if (!columns) { + return ( + <p className="text-red-500"> + {t('errors.form_meta_not_found')} + </p> + ); + } + + // 7) 렌더링 + return ( + <div className="space-y-4"> + <div className="flex items-center justify-between"> + <div> + <h3 className="text-lg font-semibold">Engineering Form</h3> + <p className="text-sm text-muted-foreground"> + Project: {projectCode} / Package: {packageCode} / Form: {formCode} + </p> + </div> + </div> + + <div className="space-y-6"> + <DynamicTable + projectId={projectId} + projectCode={projectCode} + packageCode={packageCode} + formCode={formCode} + formId={formId} + columnsJSON={columns} + dataJSON={data} + editableFieldsMap={editableFieldsMap} + mode={"ENG"} + /> + </div> + </div> + ); +} + +function TableSkeleton() { + return ( + <div className="space-y-4"> + <Skeleton className="h-10 w-full" /> + <Skeleton className="h-[400px] w-full" /> + </div> + ); +}
\ No newline at end of file diff --git a/app/[lng]/partners/(partners)/vendor-data-plant/[projectCode]/[packageCode]/im/[formCode]/page.tsx b/app/[lng]/partners/(partners)/vendor-data-plant/[projectCode]/[packageCode]/im/[formCode]/page.tsx new file mode 100644 index 00000000..29188061 --- /dev/null +++ b/app/[lng]/partners/(partners)/vendor-data-plant/[projectCode]/[packageCode]/im/[formCode]/page.tsx @@ -0,0 +1,95 @@ +// app/[lng]/partners/vendor-data-plant/[projectCode]/[packageCode]/eng/[formCode]/page.tsx +import DynamicTable from "@/components/form-data-plant/form-data-table"; +import { getFormData, getFormId, getProjectIdByCode } from "@/lib/forms-plant/services"; +import { useTranslation } from "@/i18n"; +import { Skeleton } from "@/components/ui/skeleton"; + +interface EngineeringFormPageProps { + params: { + lng: string; + projectCode: string; + packageCode: string; + formCode: string; + }; + searchParams?: { + mode?: string; + }; +} + +export default async function IMFormPage({ + params, + searchParams, +}: EngineeringFormPageProps) { + // 1) 구조 분해 할당 + const resolvedParams = await params; + + // 2) searchParams도 await 필요 + const resolvedSearchParams = await searchParams; + + // 3) 구조 분해 할당 + const { lng, projectCode, packageCode, formCode } = resolvedParams; + + // i18n 설정 + const { t } = await useTranslation(lng, 'engineering'); + + // URL 쿼리 파라미터에서 mode 가져오기 (await 해서 사용) + const mode = "IM"; // 기본값은 IM + + // 4) DB 조회 - projectCode와 packageCode를 전달 + const { columns, data, editableFieldsMap } = await getFormData( + formCode, + projectCode, + packageCode + ); + + // 5) formId 조회 - projectCode와 packageCode를 전달 + const { formId } = await getFormId(projectCode, packageCode, formCode, mode); + + const projectId = await getProjectIdByCode(projectCode) + + // 6) 예외 처리 + if (!columns) { + return ( + <p className="text-red-500"> + {t('errors.form_meta_not_found')} + </p> + ); + } + + // 7) 렌더링 + return ( + <div className="space-y-4"> + <div className="flex items-center justify-between"> + <div> + <h3 className="text-lg font-semibold">Engineering Form</h3> + <p className="text-sm text-muted-foreground"> + Project: {projectCode} / Package: {packageCode} / Form: {formCode} + </p> + </div> + </div> + + <div className="space-y-6"> + <DynamicTable + projectId={projectId} + projectCode={projectCode} + packageCode={packageCode} + formCode={formCode} + formId={formId} + columnsJSON={columns} + dataJSON={data} + editableFieldsMap={editableFieldsMap} + mode={"IM"} + /> + </div> + </div> + ); +} + +function TableSkeleton() { + return ( + <div className="space-y-4"> + <Skeleton className="h-10 w-full" /> + <Skeleton className="h-[400px] w-full" /> + </div> + ); +}
\ No newline at end of file diff --git a/app/[lng]/partners/(partners)/vendor-data-plant/[projectCode]/[packageCode]/page.tsx b/app/[lng]/partners/(partners)/vendor-data-plant/[projectCode]/[packageCode]/page.tsx new file mode 100644 index 00000000..4904a8ff --- /dev/null +++ b/app/[lng]/partners/(partners)/vendor-data-plant/[projectCode]/[packageCode]/page.tsx @@ -0,0 +1,37 @@ +// app/[lng]/partners/vendor-data-plant/[projectCode]/[packageCode]/page.tsx + +import { TagsTable } from "@/lib/tags-plant/table/tag-table" + +interface MasterTagListPageProps { + params: Promise<{ + lng: string + projectCode: string + packageCode: string + }> +} + +export default async function MasterTagListPage({ + params, +}: MasterTagListPageProps) { + const { projectCode, packageCode } = await params + + return ( + <div className="space-y-4"> + <div className="flex items-center justify-between"> + <div> + <h3 className="text-lg font-semibold">Master Tag List</h3> + <p className="text-sm text-muted-foreground"> + Project: {projectCode} / Package: {packageCode} + </p> + </div> + </div> + + {/* 완전 클라이언트 컴포넌트 */} + <TagsTable + projectCode={projectCode} + packageCode={packageCode} + formCode="MASTER" + /> + </div> + ) +}
\ No newline at end of file diff --git a/app/[lng]/partners/(partners)/vendor-data-plant/layout.tsx b/app/[lng]/partners/(partners)/vendor-data-plant/layout.tsx index 8a9c43e9..792a3a6a 100644 --- a/app/[lng]/partners/(partners)/vendor-data-plant/layout.tsx +++ b/app/[lng]/partners/(partners)/vendor-data-plant/layout.tsx @@ -2,41 +2,35 @@ import * as React from "react" import { cookies } from "next/headers" import { Shell } from "@/components/shell" -import { getVendorProjectsAndContracts } from "@/lib/vendor-data-plant/services" import { VendorDataContainer } from "@/components/vendor-data-plant/vendor-data-container" import { authOptions } from "@/app/api/auth/[...nextauth]/route" import { getServerSession } from "next-auth" import { InformationButton } from "@/components/information/information-button" import { useTranslation } from "@/i18n" +import { getVendorProjectsWithPackages } from "@/lib/vendor-data/services" interface VendorDataLayoutProps { children: React.ReactNode params: { lng?: string } } -// Layout 컴포넌트는 서버 컴포넌트입니다 export default async function VendorDataLayout({ children, params, }: VendorDataLayoutProps) { - // 기본 언어는 'ko'로 설정, params.locale이 있으면 사용 - const { lng } = await params; + const { lng } = await params const language = lng || 'en' const { t } = await useTranslation(language, 'engineering') const session = await getServerSession(authOptions) const vendorId = session?.user.companyId - // const vendorId = "17" const idAsNumber = Number(vendorId) - // 프로젝트 데이터 가져오기 (type=plant만) - const projects = await getVendorProjectsAndContracts(idAsNumber, "plant") + // 프로젝트 및 패키지 데이터 가져오기 + const projects = await getVendorProjectsWithPackages(idAsNumber, "plant") // 레이아웃 설정 쿠키 가져오기 - // Next.js 15에서는 cookies()가 Promise를 반환하므로 await 사용 const cookieStore = await cookies() - - // 이제 cookieStore.get() 메서드 사용 가능 const layout = cookieStore.get("react-resizable-panels:layout:mail") const collapsed = cookieStore.get("react-resizable-panels:collapsed") @@ -54,9 +48,6 @@ export default async function VendorDataLayout({ </h2> <InformationButton pagePath="partners/vendor-data-plant" /> </div> - {/* <p className="text-muted-foreground"> - 각종 Data 입력할 수 있습니다 - </p> */} </div> </div> </div> @@ -74,7 +65,6 @@ export default async function VendorDataLayout({ defaultCollapsed={defaultCollapsed} navCollapsedSize={4} > - {/* 페이지별 콘텐츠가 여기에 들어갑니다 */} {children} </VendorDataContainer> )} diff --git a/app/api/cron/form-tags-plant/start/route.ts b/app/api/cron/form-tags-plant/start/route.ts new file mode 100644 index 00000000..17eb8979 --- /dev/null +++ b/app/api/cron/form-tags-plant/start/route.ts @@ -0,0 +1,141 @@ +// app/api/cron/form-tags/start/route.ts +import { NextRequest } from 'next/server'; +import { v4 as uuidv4 } from 'uuid'; +import { revalidateTag } from 'next/cache'; + +// 동기화 작업의 상태를 저장할 Map +// 실제 프로덕션에서는 Redis 또는 DB에 저장하는 것이 좋습니다 +const syncJobs = new Map<string, { + status: 'queued' | 'processing' | 'completed' | 'failed'; + startTime: Date; + endTime?: Date; + result?: any; + error?: string; + progress?: number; + projectCode?: string; + formCode?: string; + packageCode?: string; + mode?: string +}>(); + +export async function POST(request: NextRequest) { + try { + // 요청 데이터 가져오기 + let projectCode: string | undefined; + let formCode: string | undefined; + let packageCode: string | undefined; + let mode: string | undefined; + + + const body = await request.json(); + projectCode = body.projectCode; + formCode = body.formCode; + packageCode = body.packageCode; + mode = body.mode; // 모드 정보 추출 + + + // 고유 ID 생성 + const syncId = uuidv4(); + + // 작업 상태 초기화 + syncJobs.set(syncId, { + status: 'queued', + startTime: new Date(), + formCode, + projectCode, + packageCode, + mode + + }); + + // 비동기 작업 시작 (백그라운드에서 실행) + processTagImport(syncId).catch(error => { + console.error('Background tag import job failed:', error); + syncJobs.set(syncId, { + ...syncJobs.get(syncId)!, + status: 'failed', + endTime: new Date(), + error: error.message || 'Unknown error occurred' + }); + }); + + // 즉시 응답 반환 (작업 ID 포함) + return Response.json({ + success: true, + message: 'Tag import job started', + syncId + }, { status: 200 }); + + } catch (error: any) { + console.error('Failed to start tag import job:', error); + return Response.json({ + success: false, + error: error.message || 'Failed to start tag import job' + }, { status: 500 }); + } +} + +// 백그라운드에서 실행되는 태그 가져오기 작업 +async function processTagImport(syncId: string) { + try { + const jobInfo = syncJobs.get(syncId)!; + const formCode = jobInfo.formCode; + const projectCode = jobInfo.projectCode; + const packageCode = jobInfo.packageCode || 0; + const mode = jobInfo.mode || 0; + + // 상태 업데이트: 처리 중 + syncJobs.set(syncId, { + ...jobInfo, + status: 'processing', + progress: 0, + }); + + if (!formCode || !projectCode ) { + throw new Error('formCode,projectCode is required'); + } + + // 여기서 실제 태그 가져오기 로직 import + const { importTagsFromSEDP } = await import('@/lib/sedp/get-form-tags-plant'); + + // 진행 상황 업데이트를 위한 콜백 함수 + const updateProgress = (progress: number) => { + syncJobs.set(syncId, { + ...syncJobs.get(syncId)!, + progress + }); + }; + + // 실제 태그 가져오기 실행 + const result = await importTagsFromSEDP(formCode, projectCode, packageCode, updateProgress); + + // 명시적으로 캐시 무효화 + revalidateTag(`forms-${packageCode}-${mode}`); + + // 상태 업데이트: 완료 + syncJobs.set(syncId, { + ...syncJobs.get(syncId)!, + status: 'completed', + endTime: new Date(), + result, + progress: 100, + }); + + return result; + } catch (error: any) { + // 에러 발생 시 상태 업데이트 + syncJobs.set(syncId, { + ...syncJobs.get(syncId)!, + status: 'failed', + endTime: new Date(), + error: error.message || 'Unknown error occurred', + }); + + throw error; // 에러 다시 던지기 + } +} + +// 서버 메모리에 저장된 작업 상태 접근 함수 (다른 API에서 사용) +export function getSyncJobStatus(id: string) { + return syncJobs.get(id); +}
\ No newline at end of file diff --git a/app/api/cron/form-tags-plant/status/route.ts b/app/api/cron/form-tags-plant/status/route.ts new file mode 100644 index 00000000..9d288f52 --- /dev/null +++ b/app/api/cron/form-tags-plant/status/route.ts @@ -0,0 +1,46 @@ +// app/api/cron/tags/status/route.ts +import { NextRequest } from 'next/server'; +import { getSyncJobStatus } from '../start/route'; + +export async function GET(request: NextRequest) { + try { + // URL에서 작업 ID 가져오기 + const searchParams = request.nextUrl.searchParams; + const syncId = searchParams.get('id'); + + if (!syncId) { + return Response.json({ + success: false, + error: 'Missing sync ID parameter' + }, { status: 400 }); + } + + // 작업 상태 조회 + const jobStatus = getSyncJobStatus(syncId); + + if (!jobStatus) { + return Response.json({ + success: false, + error: 'Sync job not found' + }, { status: 404 }); + } + + // 작업 상태 반환 + return Response.json({ + success: true, + status: jobStatus.status, + startTime: jobStatus.startTime, + endTime: jobStatus.endTime, + progress: jobStatus.progress, + result: jobStatus.result, + error: jobStatus.error + }, { status: 200 }); + + } catch (error: any) { + console.error('Error retrieving tag import status:', error); + return Response.json({ + success: false, + error: error.message || 'Failed to retrieve tag import status' + }, { status: 500 }); + } +}
\ No newline at end of file diff --git a/app/api/cron/tags-plant/start/route.ts b/app/api/cron/tags-plant/start/route.ts new file mode 100644 index 00000000..83e06935 --- /dev/null +++ b/app/api/cron/tags-plant/start/route.ts @@ -0,0 +1,149 @@ +// app/api/cron/tags/start/route.ts +import { NextRequest } from 'next/server'; +import { v4 as uuidv4 } from 'uuid'; +import { revalidateTag } from 'next/cache'; + +// 동기화 작업의 상태를 저장할 Map +// 실제 프로덕션에서는 Redis 또는 DB에 저장하는 것이 좋습니다 +const syncJobs = new Map<string, { + status: 'queued' | 'processing' | 'completed' | 'failed'; + startTime: Date; + endTime?: Date; + result?: any; + error?: string; + progress?: number; + projectCode?: string; + packageCode?: string; + mode?: string +}>(); + +export async function POST(request: NextRequest) { + try { + // 요청 데이터 가져오기 + let projectCode: string | undefined; + let packageCode: string | undefined; + let mode: string | undefined; + + try { + const body = await request.json(); + projectCode = body.projectCode; + packageCode = body.packageCode; + mode = body.mode; // 모드 정보 추출 + + } catch (error) { + // 요청 본문이 없거나 JSON이 아닌 경우, URL 파라미터 확인 + const searchParams = request.nextUrl.searchParams; + const projectCodeParam = searchParams.get('projectCode'); + const packageCodeParam = searchParams.get('packageCode'); + + if (projectCodeParam&&packageCodeParam) { + projectCode = projectCodeParam; + packageCode = packageCodeParam; + } + mode = searchParams.get('mode') || undefined; + } + + // 고유 ID 생성 + const syncId = uuidv4(); + + // 작업 상태 초기화 + syncJobs.set(syncId, { + status: 'queued', + startTime: new Date(), + projectCode, + packageCode, + mode + }); + + // 비동기 작업 시작 (백그라운드에서 실행) + processTagImport(syncId).catch(error => { + console.error('Background tag import job failed:', error); + syncJobs.set(syncId, { + ...syncJobs.get(syncId)!, + status: 'failed', + endTime: new Date(), + error: error.message || 'Unknown error occurred' + }); + }); + + // 즉시 응답 반환 (작업 ID 포함) + return Response.json({ + success: true, + message: 'Tag import job started', + syncId + }, { status: 200 }); + + } catch (error: any) { + console.error('Failed to start tag import job:', error); + return Response.json({ + success: false, + error: error.message || 'Failed to start tag import job' + }, { status: 500 }); + } +} + +// 백그라운드에서 실행되는 태그 가져오기 작업 +async function processTagImport(syncId: string) { + try { + const jobInfo = syncJobs.get(syncId)!; + const projectCode = jobInfo.projectCode; + const packageCode = jobInfo.packageCode; + const mode = jobInfo.mode; // 모드 정보 추출 + + + // 상태 업데이트: 처리 중 + syncJobs.set(syncId, { + ...jobInfo, + status: 'processing', + progress: 0, + }); + + if (!packageCode) { + throw new Error('Package is required'); + } + + // 여기서 실제 태그 가져오기 로직 import + const { importTagsFromSEDP } = await import('@/lib/sedp/get-tags-plant'); + + // 진행 상황 업데이트를 위한 콜백 함수 + const updateProgress = (progress: number) => { + syncJobs.set(syncId, { + ...syncJobs.get(syncId)!, + progress + }); + }; + + // 실제 태그 가져오기 실행 + const result = await importTagsFromSEDP(projectCode, packageCode,updateProgress, mode); + + // 명시적으로 캐시 무효화 + revalidateTag(`tags-${packageCode}`); + revalidateTag(`forms-${packageCode}-${mode}`); + + // 상태 업데이트: 완료 + syncJobs.set(syncId, { + ...syncJobs.get(syncId)!, + status: 'completed', + endTime: new Date(), + result, + progress: 100, + }); + + return result; + } catch (error: any) { + // 에러 발생 시 상태 업데이트 + syncJobs.set(syncId, { + ...syncJobs.get(syncId)!, + status: 'failed', + endTime: new Date(), + error: error.message || 'Unknown error occurred', + }); + + throw error; // 에러 다시 던지기 + } +} + +// 서버 메모리에 저장된 작업 상태 접근 함수 (다른 API에서 사용) +export function getSyncJobStatus(id: string) { + return syncJobs.get(id); +}
\ No newline at end of file diff --git a/app/api/cron/tags-plant/status/route.ts b/app/api/cron/tags-plant/status/route.ts new file mode 100644 index 00000000..9d288f52 --- /dev/null +++ b/app/api/cron/tags-plant/status/route.ts @@ -0,0 +1,46 @@ +// app/api/cron/tags/status/route.ts +import { NextRequest } from 'next/server'; +import { getSyncJobStatus } from '../start/route'; + +export async function GET(request: NextRequest) { + try { + // URL에서 작업 ID 가져오기 + const searchParams = request.nextUrl.searchParams; + const syncId = searchParams.get('id'); + + if (!syncId) { + return Response.json({ + success: false, + error: 'Missing sync ID parameter' + }, { status: 400 }); + } + + // 작업 상태 조회 + const jobStatus = getSyncJobStatus(syncId); + + if (!jobStatus) { + return Response.json({ + success: false, + error: 'Sync job not found' + }, { status: 404 }); + } + + // 작업 상태 반환 + return Response.json({ + success: true, + status: jobStatus.status, + startTime: jobStatus.startTime, + endTime: jobStatus.endTime, + progress: jobStatus.progress, + result: jobStatus.result, + error: jobStatus.error + }, { status: 200 }); + + } catch (error: any) { + console.error('Error retrieving tag import status:', error); + return Response.json({ + success: false, + error: error.message || 'Failed to retrieve tag import status' + }, { status: 500 }); + } +}
\ No newline at end of file diff --git a/components/client-data-table/data-table.tsx b/components/client-data-table/data-table.tsx index 3e009302..371a1dab 100644 --- a/components/client-data-table/data-table.tsx +++ b/components/client-data-table/data-table.tsx @@ -49,8 +49,9 @@ interface DataTableProps<TData, TValue> { children?: React.ReactNode /** 선택 상태 초기화 트리거 */ clearSelection?: boolean - initialColumnPinning?: ColumnPinningState // 추가 - + initialColumnPinning?: ColumnPinningState + /** Table 인스턴스를 상위 컴포넌트에 전달하는 콜백 */ + onTableReady?: (table: Table<TData>) => void } export function ClientDataTable<TData, TValue>({ @@ -63,7 +64,8 @@ export function ClientDataTable<TData, TValue>({ maxHeight, onSelectedRowsChange, clearSelection, - initialColumnPinning + initialColumnPinning, + onTableReady }: DataTableProps<TData, TValue>) { // (1) React Table 상태 @@ -118,6 +120,13 @@ export function ClientDataTable<TData, TValue>({ useAutoSizeColumns(table, autoSizeColumns) + // 🆕 Table 인스턴스를 상위 컴포넌트에 전달 + React.useEffect(() => { + if (onTableReady) { + onTableReady(table) + } + }, [table, onTableReady]) + React.useEffect(() => { if (!onSelectedRowsChange) return const selectedRows = table @@ -164,6 +173,7 @@ export function ClientDataTable<TData, TValue>({ }), } } + // 🎯 테이블 총 너비 계산 const getTableWidth = React.useCallback(() => { const totalSize = table.getCenterTotalSize() + table.getLeftTotalSize() + table.getRightTotalSize() @@ -206,174 +216,172 @@ export function ClientDataTable<TData, TValue>({ {children} </ClientDataTableAdvancedToolbar> - - <div - className="max-w-[100vw] overflow-auto" - style={{ maxHeight: maxHeight || '34rem' }} - onScroll={handleScroll} // 🎯 스크롤 이벤트 핸들러 추가 - > - <UiTable + <div + className="max-w-[100vw] overflow-auto" + style={{ maxHeight: maxHeight || '34rem' }} + onScroll={handleScroll} // 🎯 스크롤 이벤트 핸들러 추가 + > + <UiTable className={cn( "[&>thead]:sticky [&>thead]:top-0 [&>thead]:z-10", !hasNestedHeader && "table-fixed" // nested header가 없으면 table-fixed 적용 )} style={{ minWidth: hasNestedHeader ? getTableWidth() : undefined }}> - {/* nested header가 있으면 table-fixed 제거, 없으면 적용 */} - <TableHeader> - {table.getHeaderGroups().map((headerGroup) => ( - <TableRow key={headerGroup.id} className={compactStyles.headerRow}> - {headerGroup.headers.map((header) => { - // 만약 이 컬럼이 현재 "그룹핑" 상태라면 헤더도 표시하지 않음 - if (header.column.getIsGrouped()) { - return null - } + {/* nested header가 있으면 table-fixed 제거, 없으면 적용 */} + <TableHeader> + {table.getHeaderGroups().map((headerGroup) => ( + <TableRow key={headerGroup.id} className={compactStyles.headerRow}> + {headerGroup.headers.map((header) => { + // 만약 이 컬럼이 현재 "그룹핑" 상태라면 헤더도 표시하지 않음 + if (header.column.getIsGrouped()) { + return null + } - return ( - <TableHead - key={header.id} - colSpan={header.colSpan} - data-column-id={header.column.id} - className={compactStyles.header} - style={{ - ...getPinnedStyle(header.column, true), // 🎯 헤더임을 명시 - // 부모 그룹 헤더는 colSpan으로 너비가 결정되므로 width 설정하지 않음 - // 자식 헤더만 개별 width 설정 - ...(!('columns' in header.column.columnDef) && { width: header.getSize() }), - }} - > - <div style={{ position: "relative" }}> - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() - )} - - {/* 부모 그룹 헤더는 리사이즈 불가, 자식 헤더만 리사이즈 가능 */} - {header.column.getCanResize() && !('columns' in header.column.columnDef) && ( - <DataTableResizer header={header} /> + return ( + <TableHead + key={header.id} + colSpan={header.colSpan} + data-column-id={header.column.id} + className={compactStyles.header} + style={{ + ...getPinnedStyle(header.column, true), // 🎯 헤더임을 명시 + // 부모 그룹 헤더는 colSpan으로 너비가 결정되므로 width 설정하지 않음 + // 자식 헤더만 개별 width 설정 + ...(!('columns' in header.column.columnDef) && { width: header.getSize() }), + }} + > + <div style={{ position: "relative" }}> + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() )} - </div> - </TableHead> - ) - })} - </TableRow> - ))} - </TableHeader> - <TableBody> - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => { - // --------------------------------------------------- - // 1) "그룹핑 헤더" Row인지 확인 - // --------------------------------------------------- - if (row.getIsGrouped()) { - // row.groupingColumnId로 어떤 컬럼을 기준으로 그룹화 되었는지 알 수 있음 - const groupingColumnId = row.groupingColumnId ?? "" - const groupingColumn = table.getColumn(groupingColumnId) // 해당 column 객체 + + {/* 부모 그룹 헤더는 리사이즈 불가, 자식 헤더만 리사이즈 가능 */} + {header.column.getCanResize() && !('columns' in header.column.columnDef) && ( + <DataTableResizer header={header} /> + )} + </div> + </TableHead> + ) + })} + </TableRow> + ))} + </TableHeader> + <TableBody> + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => { + // --------------------------------------------------- + // 1) "그룹핑 헤더" Row인지 확인 + // --------------------------------------------------- + if (row.getIsGrouped()) { + // row.groupingColumnId로 어떤 컬럼을 기준으로 그룹화 되었는지 알 수 있음 + const groupingColumnId = row.groupingColumnId ?? "" + const groupingColumn = table.getColumn(groupingColumnId) // 해당 column 객체 - // 컬럼 라벨 가져오기 - let columnLabel = groupingColumnId - if (groupingColumn) { - const headerDef = groupingColumn.columnDef.meta?.excelHeader - if (typeof headerDef === "string") { - columnLabel = headerDef - } + // 컬럼 라벨 가져오기 + let columnLabel = groupingColumnId + if (groupingColumn) { + const headerDef = groupingColumn.columnDef.meta?.excelHeader + if (typeof headerDef === "string") { + columnLabel = headerDef } - - return ( - <TableRow - key={row.id} - className={compactStyles.groupRow} - data-state={row.getIsExpanded() && "expanded"} - > - {/* 그룹 헤더는 한 줄에 합쳐서 보여주고, 토글 버튼 + 그룹 라벨 + 값 표기 */} - <TableCell - colSpan={table.getVisibleFlatColumns().length} - className={compact ? "py-1 px-2" : ""} - > - {/* 확장/축소 버튼 (아이콘 중앙 정렬 + Indent) */} - {row.getCanExpand() && ( - <button - onClick={row.getToggleExpandedHandler()} - className="inline-flex items-center justify-center mr-2 w-5 h-5" - style={{ - // row.depth: 0이면 top-level, 1이면 그 하위 등 - marginLeft: `${row.depth * 1.5}rem`, - }} - > - {row.getIsExpanded() ? ( - <ChevronUp size={compact ? 14 : 16} /> - ) : ( - <ChevronRight size={compact ? 14 : 16} /> - )} - </button> - )} - - {/* Group Label + 값 */} - <span className="font-semibold"> - {columnLabel}: {row.getValue(groupingColumnId)} - </span> - <span className="ml-2 text-xs text-muted-foreground"> - ({row.subRows.length} rows) - </span> - </TableCell> - </TableRow> - ) } - // --------------------------------------------------- - // 2) 일반 Row - // → "그룹핑된 컬럼"은 숨긴다 - // --------------------------------------------------- return ( <TableRow key={row.id} - className={compactStyles.row} - data-state={row.getIsSelected() && "selected"} + className={compactStyles.groupRow} + data-state={row.getIsExpanded() && "expanded"} > - {row.getVisibleCells().map((cell) => { - // 이 셀의 컬럼이 grouped라면 숨긴다 - if (cell.column.getIsGrouped()) { - return null - } - - return ( - <TableCell - key={cell.id} - data-column-id={cell.column.id} - className={compactStyles.cell} + {/* 그룹 헤더는 한 줄에 합쳐서 보여주고, 토글 버튼 + 그룹 라벨 + 값 표기 */} + <TableCell + colSpan={table.getVisibleFlatColumns().length} + className={compact ? "py-1 px-2" : ""} + > + {/* 확장/축소 버튼 (아이콘 중앙 정렬 + Indent) */} + {row.getCanExpand() && ( + <button + onClick={row.getToggleExpandedHandler()} + className="inline-flex items-center justify-center mr-2 w-5 h-5" style={{ - ...getPinnedStyle(cell.column, false), // 🎯 바디 셀임을 명시 - width: cell.column.getSize() // 🎯 width 별도 설정 + // row.depth: 0이면 top-level, 1이면 그 하위 등 + marginLeft: `${row.depth * 1.5}rem`, }} > - {flexRender( - cell.column.columnDef.cell, - cell.getContext() + {row.getIsExpanded() ? ( + <ChevronUp size={compact ? 14 : 16} /> + ) : ( + <ChevronRight size={compact ? 14 : 16} /> )} - </TableCell> - ) - })} + </button> + )} + + {/* Group Label + 값 */} + <span className="font-semibold"> + {columnLabel}: {row.getValue(groupingColumnId)} + </span> + <span className="ml-2 text-xs text-muted-foreground"> + ({row.subRows.length} rows) + </span> + </TableCell> </TableRow> ) - }) - ) : ( + } + // --------------------------------------------------- - // 3) 데이터가 없을 때 + // 2) 일반 Row + // → "그룹핑된 컬럼"은 숨긴다 // --------------------------------------------------- - <TableRow> - <TableCell - colSpan={table.getAllColumns().length} - className={compactStyles.emptyRow + " text-center"} + return ( + <TableRow + key={row.id} + className={compactStyles.row} + data-state={row.getIsSelected() && "selected"} > - No results. - </TableCell> - </TableRow> - )} - </TableBody> - </UiTable> - </div> + {row.getVisibleCells().map((cell) => { + // 이 셀의 컬럼이 grouped라면 숨긴다 + if (cell.column.getIsGrouped()) { + return null + } + return ( + <TableCell + key={cell.id} + data-column-id={cell.column.id} + className={compactStyles.cell} + style={{ + ...getPinnedStyle(cell.column, false), // 🎯 바디 셀임을 명시 + width: cell.column.getSize() // 🎯 width 별도 설정 + }} + > + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + </TableCell> + ) + })} + </TableRow> + ) + }) + ) : ( + // --------------------------------------------------- + // 3) 데이터가 없을 때 + // --------------------------------------------------- + <TableRow> + <TableCell + colSpan={table.getAllColumns().length} + className={compactStyles.emptyRow + " text-center"} + > + No results. + </TableCell> + </TableRow> + )} + </TableBody> + </UiTable> + </div> <ClientDataTablePagination table={table} /> </div> diff --git a/components/form-data-plant/delete-form-data-dialog.tsx b/components/form-data-plant/delete-form-data-dialog.tsx index 6ac8f67c..2406407e 100644 --- a/components/form-data-plant/delete-form-data-dialog.tsx +++ b/components/form-data-plant/delete-form-data-dialog.tsx @@ -40,7 +40,8 @@ interface DeleteFormDataDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> { formData: GenericData[] formCode: string - contractItemId: number + projectCode: string + packageCode: string projectId?: number showTrigger?: boolean onSuccess?: () => void @@ -50,7 +51,8 @@ interface DeleteFormDataDialogProps export function DeleteFormDataDialog({ formData, formCode, - contractItemId, + projectCode, + packageCode, projectId, showTrigger = true, onSuccess, @@ -77,7 +79,8 @@ export function DeleteFormDataDialog({ const result = await deleteFormDataByTags({ formCode, - contractItemId, + projectCode, + packageCode, tagIdxs, projectId, }) diff --git a/components/form-data-plant/form-data-report-batch-dialog.tsx b/components/form-data-plant/form-data-report-batch-dialog.tsx index 24b5827b..ba41a3c2 100644 --- a/components/form-data-plant/form-data-report-batch-dialog.tsx +++ b/components/form-data-plant/form-data-report-batch-dialog.tsx @@ -71,7 +71,8 @@ interface FormDataReportBatchDialogProps { setOpen: Dispatch<SetStateAction<boolean>>; columnsJSON: DataTableColumnJSON[]; reportData: ReportData[]; - packageId: number; + projectCode: string; + packageCode: string; formId: number; formCode: string; } @@ -81,7 +82,8 @@ export const FormDataReportBatchDialog: FC<FormDataReportBatchDialogProps> = ({ setOpen, columnsJSON, reportData, - packageId, + projectCode, + packageCode, formId, formCode, }) => { @@ -100,8 +102,8 @@ export const FormDataReportBatchDialog: FC<FormDataReportBatchDialogProps> = ({ const [generatedFileBlob, setGeneratedFileBlob] = useState<Blob | null>(null); useEffect(() => { - updateReportTempList(packageId, formId, setTempList); - }, [packageId, formId]); + updateReportTempList(projectCode, packageCode, formId, setTempList); + }, [projectCode, packageCode, formId]); const onClose = () => { if (isUploading) { @@ -361,7 +363,8 @@ export const FormDataReportBatchDialog: FC<FormDataReportBatchDialogProps> = ({ <PublishDialog open={publishDialogOpen} onOpenChange={setPublishDialogOpen} - packageId={packageId} + projectCode={projectCode} + packageCode={packageCode} formCode={formCode} fileBlob={generatedFileBlob || undefined} /> @@ -409,17 +412,19 @@ const UploadFileItem: FC<UploadFileItemProps> = ({ }; type UpdateReportTempList = ( - packageId: number, + projectCode: string, + packageCode: string, formId: number, setPrevReportTemp: Dispatch<SetStateAction<tempFile[]>> ) => void; const updateReportTempList: UpdateReportTempList = async ( - packageId, + projectCode, + packageCode, formId, setTempList ) => { - const tempList = await getReportTempList(packageId, formId); + const tempList = await getReportTempList(projectCode,packageCode, formId); setTempList( tempList.map((c) => { diff --git a/components/form-data-plant/form-data-report-dialog.tsx b/components/form-data-plant/form-data-report-dialog.tsx index 9177ab36..2413fc28 100644 --- a/components/form-data-plant/form-data-report-dialog.tsx +++ b/components/form-data-plant/form-data-report-dialog.tsx @@ -49,7 +49,8 @@ interface FormDataReportDialogProps { columnsJSON: DataTableColumnJSON[]; reportData: ReportData[]; setReportData: Dispatch<SetStateAction<ReportData[]>>; - packageId: number; + projectCode: string; + packageCode: string; formId: number; formCode: string; } @@ -58,7 +59,8 @@ export const FormDataReportDialog: FC<FormDataReportDialogProps> = ({ columnsJSON, reportData, setReportData, - packageId, + projectCode, + packageCode, formId, formCode, }) => { @@ -76,8 +78,8 @@ export const FormDataReportDialog: FC<FormDataReportDialogProps> = ({ const [generatedFileBlob, setGeneratedFileBlob] = useState<Blob | null>(null); useEffect(() => { - updateReportTempList(packageId, formId, setTempList); - }, [packageId, formId]); + updateReportTempList(projectCode, packageCode, formId, setTempList); + }, [projectCode,packageCode, formId]); const onClose = async (value: boolean) => { if (fileLoading) { @@ -197,7 +199,8 @@ export const FormDataReportDialog: FC<FormDataReportDialogProps> = ({ <PublishDialog open={publishDialogOpen} onOpenChange={setPublishDialogOpen} - packageId={packageId} + projectCode={projectCode} + packageCode={packageCode} formCode={formCode} fileBlob={generatedFileBlob || undefined} /> @@ -394,17 +397,19 @@ const importReportData: ImportReportData = async ( }; type UpdateReportTempList = ( - packageId: number, + projectCode: string, + packageCode: string, formId: number, setPrevReportTemp: Dispatch<SetStateAction<tempFile[]>> ) => void; const updateReportTempList: UpdateReportTempList = async ( - packageId, + projectCode, + packageCode, formId, setTempList ) => { - const tempList = await getReportTempList(packageId, formId); + const tempList = await getReportTempList(projectCode,packageCode, formId); setTempList( tempList.map((c) => { diff --git a/components/form-data-plant/form-data-report-temp-upload-dialog.tsx b/components/form-data-plant/form-data-report-temp-upload-dialog.tsx index 59ea6ade..66915198 100644 --- a/components/form-data-plant/form-data-report-temp-upload-dialog.tsx +++ b/components/form-data-plant/form-data-report-temp-upload-dialog.tsx @@ -23,7 +23,8 @@ interface FormDataReportTempUploadDialogProps { columnsJSON: DataTableColumnJSON[]; open: boolean; setOpen: Dispatch<SetStateAction<boolean>>; - packageId: number; + projectCode: string; + packageCode: string; formCode: string; formId: number; uploaderType: string; @@ -35,7 +36,8 @@ export const FormDataReportTempUploadDialog: FC< columnsJSON, open, setOpen, - packageId, + projectCode, + packageCode, formId, formCode, uploaderType, @@ -83,14 +85,16 @@ export const FormDataReportTempUploadDialog: FC< </div> <TabsContent value="upload"> <FormDataReportTempUploadTab - packageId={packageId} + projectCode={projectCode} + packageCode={packageCode} formId={formId} uploaderType={uploaderType} /> </TabsContent> <TabsContent value="uploaded"> <FormDataReportTempUploadedListTab - packageId={packageId} + projectCode={projectCode} + packageCode={packageCode} formId={formId} /> </TabsContent> diff --git a/components/form-data-plant/form-data-report-temp-upload-tab.tsx b/components/form-data-plant/form-data-report-temp-upload-tab.tsx index 81186ba4..41466f90 100644 --- a/components/form-data-plant/form-data-report-temp-upload-tab.tsx +++ b/components/form-data-plant/form-data-report-temp-upload-tab.tsx @@ -36,14 +36,15 @@ import { uploadReportTemp } from "@/lib/forms-plant/services"; const MAX_FILE_SIZE = 3000000; interface FormDataReportTempUploadTabProps { - packageId: number; + projectCode: string; + packageCode: string; formId: number; uploaderType: string; } export const FormDataReportTempUploadTab: FC< FormDataReportTempUploadTabProps -> = ({ packageId, formId, uploaderType }) => { +> = ({ projectCode,packageCode, formId, uploaderType }) => { const { toast } = useToast(); const params = useParams(); const lng = (params?.lng as string) || "ko"; @@ -94,7 +95,7 @@ export const FormDataReportTempUploadTab: FC< formData.append("customFileName", file.name); formData.append("uploaderType", uploaderType); - await uploadReportTemp(packageId, formId, formData); + await uploadReportTemp(projectCode, packageCode, formId, formData); successCount++; setUploadProgress(Math.round((successCount / totalFiles) * 100)); diff --git a/components/form-data-plant/form-data-report-temp-uploaded-list-tab.tsx b/components/form-data-plant/form-data-report-temp-uploaded-list-tab.tsx index 4cfbad69..1b6cefaf 100644 --- a/components/form-data-plant/form-data-report-temp-uploaded-list-tab.tsx +++ b/components/form-data-plant/form-data-report-temp-uploaded-list-tab.tsx @@ -39,13 +39,14 @@ import { getReportTempList, deleteReportTempFile } from "@/lib/forms-plant/servi import { VendorDataReportTemps } from "@/db/schema/vendorData"; interface FormDataReportTempUploadedListTabProps { - packageId: number; + projectCode: string; + packageCode: string; formId: number; } export const FormDataReportTempUploadedListTab: FC< FormDataReportTempUploadedListTabProps -> = ({ packageId, formId }) => { +> = ({ projectCode,packageCode , formId }) => { const params = useParams(); const lng = (params?.lng as string) || "ko"; const { t } = useTranslation(lng, "engineering"); @@ -57,12 +58,12 @@ export const FormDataReportTempUploadedListTab: FC< useEffect(() => { const getTempFiles = async () => { - await updateReportTempList(packageId, formId, setPrevReportTemp); + await updateReportTempList(projectCode,packageCode, formId, setPrevReportTemp); setIsLoading(false); }; getTempFiles(); - }, [packageId, formId]); + }, [projectCode,packageCode, formId]); return ( <div> @@ -70,7 +71,7 @@ export const FormDataReportTempUploadedListTab: FC< <UploadedTempFiles prevReportTemp={prevReportTemp} updateReportTempList={() => - updateReportTempList(packageId, formId, setPrevReportTemp) + updateReportTempList(projectCode,packageCode, formId, setPrevReportTemp) } isLoading={isLoading} t={t} @@ -80,17 +81,19 @@ export const FormDataReportTempUploadedListTab: FC< }; type UpdateReportTempList = ( - packageId: number, + projectCode: string, + packageCode: string, formId: number, setPrevReportTemp: Dispatch<SetStateAction<VendorDataReportTemps[]>> ) => Promise<void>; const updateReportTempList: UpdateReportTempList = async ( - packageId, + projectCode, + packageCode, formId, setPrevReportTemp ) => { - const tempList = await getReportTempList(packageId, formId); + const tempList = await getReportTempList(projectCode, packageCode, formId); setPrevReportTemp(tempList); }; diff --git a/components/form-data-plant/form-data-table.tsx b/components/form-data-plant/form-data-table.tsx index 30c176bd..c6c79a69 100644 --- a/components/form-data-plant/form-data-table.tsx +++ b/components/form-data-plant/form-data-table.tsx @@ -76,7 +76,8 @@ interface GenericData { export interface DynamicTableProps { dataJSON: GenericData[]; columnsJSON: DataTableColumnJSON[]; - contractItemId: number; + projectCode: string; + packageCode: string; formCode: string; formId: number; projectId: number; @@ -89,7 +90,8 @@ export interface DynamicTableProps { export default function DynamicTable({ dataJSON, columnsJSON, - contractItemId, + projectCode, + packageCode, formCode, formId, projectId, @@ -156,7 +158,8 @@ export default function DynamicTable({ // 서버 액션 호출 const result = await excludeFormDataByTags({ formCode, - contractItemId, + projectCode, + packageCode, tagNumbers, }); @@ -288,7 +291,7 @@ export default function DynamicTable({ try { setIsLoadingStats(true); // getFormStatusByVendor 서버 액션 직접 호출 - const data = await getFormStatusByVendor(projectId, contractItemId, formCode); + const data = await getFormStatusByVendor(projectId, projectCode, packageCode,formCode); if (data && data.length > 0) { setFormStats(data[0]); @@ -339,9 +342,7 @@ export default function DynamicTable({ // SEDP compare dialog state const [sedpCompareOpen, setSedpCompareOpen] = React.useState(false); - const [projectCode, setProjectCode] = React.useState<string>(''); - const [projectType, setProjectType] = React.useState<string>('plant'); - const [packageCode, setPackageCode] = React.useState<string>(''); + const projectType = "plant"; // 새로 추가된 Template 다이얼로그 상태 const [templateDialogOpen, setTemplateDialogOpen] = React.useState(false); @@ -374,43 +375,13 @@ const [isLoadingRegisters, setIsLoadingRegisters] = React.useState(false); React.useEffect(() => { const getTempCount = async () => { - const tempList = await getReportTempList(contractItemId, formId); + const tempList = await getReportTempList(projectCode, packageCode, formId); setTempCount(tempList.length); }; getTempCount(); - }, [contractItemId, formId, tempUpDialog]); + }, [projectCode,packageCode, formId, tempUpDialog]); - React.useEffect(() => { - const getPackageCode = async () => { - try { - const packageCode = await getPackageCodeById(contractItemId); - setPackageCode(packageCode || ''); // 빈 문자열이나 다른 기본값 - } catch (error) { - console.error('패키지 조회 실패:', error); - setPackageCode(''); - } - }; - - getPackageCode(); - }, [contractItemId]) - // Get project code when component mounts - React.useEffect(() => { - const getProjectCode = async () => { - try { - const project = await getProjectById(projectId); - setProjectCode(project.code); - setProjectType(project.type); - } catch (error) { - console.error("Error fetching project code:", error); - toast.error("Failed to fetch project code"); - } - }; - - if (projectId) { - getProjectCode(); - } - }, [projectId]); // 선택된 행들의 실제 데이터 가져오기 const getSelectedRowsData = React.useCallback(() => { @@ -529,7 +500,7 @@ React.useEffect(() => { async function handleSyncTags() { try { setIsSyncingTags(true); - const result = await syncMissingTags(contractItemId, formCode); + const result = await syncMissingTags(projectCode,packageCode, formCode); // Prepare the toast messages based on what changed const changes = []; @@ -562,9 +533,9 @@ React.useEffect(() => { setIsLoadingTags(true); // API 엔드포인트 호출 - 작업 시작만 요청 - const response = await fetch('/api/cron/form-tags/start', { + const response = await fetch('/api/cron/form-tags-plant/start', { method: 'POST', - body: JSON.stringify({ projectCode, formCode, contractItemId }) + body: JSON.stringify({ projectCode, formCode, packageCode }) }); if (!response.ok) { @@ -603,7 +574,7 @@ React.useEffect(() => { // 5초마다 상태 확인 pollingRef.current = setInterval(async () => { try { - const response = await fetch(`/api/cron/form-tags/status?id=${id}`); + const response = await fetch(`/api/cron/form-tags-plant/status?id=${id}`); if (!response.ok) { throw new Error('Failed to get tag import status'); @@ -666,7 +637,8 @@ React.useEffect(() => { tableData, columnsJSON, formCode, - contractItemId, + projectCode, + packageCode, editableFieldsMap, // 추가: 편집 가능 필드 정보 전달 onPendingChange: setIsImporting, // Let importExcelData handle loading state onDataUpdate: (newData) => { @@ -747,7 +719,8 @@ React.useEffect(() => { const sedpResult = await sendFormDataToSEDP( formCode, // Send formCode instead of formName projectId, // Project ID - contractItemId, + projectCode, + packageCode, tableData.filter(v=>v.status !== 'excluded'), // Table data columnsJSON // Column definitions ); @@ -1226,7 +1199,8 @@ React.useEffect(() => { columns={columnsJSON} rowData={rowAction?.row.original ?? null} formCode={formCode} - contractItemId={contractItemId} + projectCode={projectCode} + packageCode={packageCode} editableFieldsMap={editableFieldsMap} onUpdateSuccess={(updatedValues) => { // Update the specific row in tableData when a single row is updated @@ -1244,7 +1218,8 @@ React.useEffect(() => { <DeleteFormDataDialog formData={deleteTarget} formCode={formCode} - contractItemId={contractItemId} + projectCode={projectCode} + packageCode={packageCode} projectId={projectId} open={deleteDialogOpen} onOpenChange={(open) => { @@ -1257,16 +1232,6 @@ React.useEffect(() => { showTrigger={false} /> - {/* Dialog for adding tags */} - {/* <AddFormTagDialog - projectId={projectId} - formCode={formCode} - formName={`Form ${formCode}`} - contractItemId={contractItemId} - packageCode={packageCode} - open={addTagDialogOpen} - onOpenChange={setAddTagDialogOpen} - /> */} {/* 새로 추가된 Template 다이얼로그 */} <TemplateViewDialog @@ -1276,7 +1241,8 @@ React.useEffect(() => { selectedRow={selectedRowsData[0]} // SPR_ITM_LST_SETUP용 tableData={tableData} // SPR_LST_SETUP용 - 새로 추가 formCode={formCode} - contractItemId={contractItemId} + projectCode={projectCode} + packageCode={packageCode} editableFieldsMap={editableFieldsMap} columnsJSON={columnsJSON} onUpdateSuccess={(updatedValues) => { @@ -1344,7 +1310,8 @@ React.useEffect(() => { columnsJSON={columnsJSON} open={tempUpDialog} setOpen={setTempUpDialog} - packageId={contractItemId} + projectCode={projectCode} + packageCode={packageCode} formCode={formCode} formId={formId} uploaderType="vendor" @@ -1356,7 +1323,8 @@ React.useEffect(() => { columnsJSON={columnsJSON} reportData={reportData} setReportData={setReportData} - packageId={contractItemId} + projectCode={projectCode} + packageCode={packageCode} formCode={formCode} formId={formId} /> @@ -1368,7 +1336,8 @@ React.useEffect(() => { setOpen={setBatchDownDialog} columnsJSON={columnsJSON} reportData={selectedRowCount > 0 ? getSelectedRowsData() : tableData} - packageId={contractItemId} + projectCode={projectCode} + packageCode={packageCode} formCode={formCode} formId={formId} /> diff --git a/components/form-data-plant/import-excel-form.tsx b/components/form-data-plant/import-excel-form.tsx index ffc6f2f9..8ac70c59 100644 --- a/components/form-data-plant/import-excel-form.tsx +++ b/components/form-data-plant/import-excel-form.tsx @@ -23,7 +23,8 @@ export interface ImportExcelOptions { tableData: GenericData[]; columnsJSON: DataTableColumnJSON[]; formCode?: string; - contractItemId?: number; + projectCode: string; + packageCode: string; editableFieldsMap?: Map<string, string[]>; // 새로 추가 onPendingChange?: (isPending: boolean) => void; onDataUpdate?: (updater: ((prev: GenericData[]) => GenericData[]) | GenericData[]) => void; @@ -218,7 +219,8 @@ export async function importExcelData({ tableData, columnsJSON, formCode, - contractItemId, + projectCode, + packageCode, editableFieldsMap = new Map(), // 새로 추가 onPendingChange, onDataUpdate @@ -527,14 +529,14 @@ export async function importExcelData({ } }); - // If formCode and contractItemId are provided, save directly to DB // importExcelData 함수에서 DB 저장 부분 - if (formCode && contractItemId) { + if (formCode && projectCode && packageCode) { try { // 배치 업데이트 함수 호출 const result = await updateFormDataBatchInDB( formCode, - contractItemId, + projectCode, + packageCode, importedData // 모든 imported rows를 한번에 전달 ); @@ -633,7 +635,6 @@ export async function importExcelData({ } } else { - // formCode나 contractItemId가 없는 경우 - 로컬 업데이트만 if (onDataUpdate) { onDataUpdate(() => mergedData); } diff --git a/components/form-data-plant/publish-dialog.tsx b/components/form-data-plant/publish-dialog.tsx index a3a2ef0b..f63c2db8 100644 --- a/components/form-data-plant/publish-dialog.tsx +++ b/components/form-data-plant/publish-dialog.tsx @@ -37,19 +37,21 @@ import { Loader2, Check, ChevronsUpDown } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { - createRevisionAction, - fetchDocumentsByPackageId, - fetchStagesByDocumentId, - fetchRevisionsByStageParams, - Document, - IssueStage, - Revision + createSubmissionAction, // 새로운 액션 이름 + fetchDocumentsByProjectAndPackage, // 업데이트된 액션 + fetchStagesByDocumentIdPlant, + fetchSubmissionsByStageParams, // revisions 대신 submissions } from "@/lib/vendor-document/service"; +import type { + StageDocument, + StageIssueStage, +} from "@/db/schema/vendorDocu"; interface PublishDialogProps { open: boolean; onOpenChange: (open: boolean) => void; - packageId: number; + projectCode: string; + packageCode: string; formCode: string; fileBlob?: Blob; } @@ -57,7 +59,8 @@ interface PublishDialogProps { export const PublishDialog: React.FC<PublishDialogProps> = ({ open, onOpenChange, - packageId, + projectCode, + packageCode, formCode, fileBlob, }) => { @@ -65,9 +68,10 @@ export const PublishDialog: React.FC<PublishDialogProps> = ({ const { data: session } = useSession(); // State for form data - const [documents, setDocuments] = useState<Document[]>([]); - const [stages, setStages] = useState<IssueStage[]>([]); - const [latestRevision, setLatestRevision] = useState<string>(""); + const [documents, setDocuments] = useState<StageDocument[]>([]); + const [stages, setStages] = useState<StageIssueStage[]>([]); + const [latestRevisionCode, setLatestRevisionCode] = useState<string>(""); + const [latestRevisionNumber, setLatestRevisionNumber] = useState<number>(0); // State for document search const [openDocumentCombobox, setOpenDocumentCombobox] = useState(false); @@ -77,9 +81,10 @@ export const PublishDialog: React.FC<PublishDialogProps> = ({ const [selectedDocId, setSelectedDocId] = useState<string>(""); const [selectedDocumentDisplay, setSelectedDocumentDisplay] = useState<string>(""); const [selectedStage, setSelectedStage] = useState<string>(""); - const [revisionInput, setRevisionInput] = useState<string>(""); - const [uploaderName, setUploaderName] = useState<string>(""); - const [comment, setComment] = useState<string>(""); + const [revisionCodeInput, setRevisionCodeInput] = useState<string>(""); + const [submitterName, setSubmitterName] = useState<string>(""); + const [submissionTitle, setSubmissionTitle] = useState<string>(""); + const [submissionDescription, setSubmissionDescription] = useState<string>(""); const [customFileName, setCustomFileName] = useState<string>(`${formCode}_document.docx`); // Loading states @@ -94,10 +99,10 @@ export const PublishDialog: React.FC<PublishDialogProps> = ({ ) : documents; - // Set uploader name from session when dialog opens + // Set submitter name from session when dialog opens useEffect(() => { if (open && session?.user?.name) { - setUploaderName(session.user.name); + setSubmitterName(session.user.name); } }, [open, session]); @@ -107,24 +112,26 @@ export const PublishDialog: React.FC<PublishDialogProps> = ({ setSelectedDocId(""); setSelectedDocumentDisplay(""); setSelectedStage(""); - setRevisionInput(""); - // Only set uploaderName if not already set from session - if (!session?.user?.name) setUploaderName(""); - setComment(""); - setLatestRevision(""); + setRevisionCodeInput(""); + setSubmissionTitle(""); + setSubmissionDescription(""); + // Only set submitterName if not already set from session + if (!session?.user?.name) setSubmitterName(""); + setLatestRevisionCode(""); + setLatestRevisionNumber(0); setCustomFileName(`${formCode}_document.docx`); setDocumentSearchValue(""); } }, [open, formCode, session]); - // Fetch documents based on packageId + // Fetch documents based on projectCode and packageCode useEffect(() => { async function loadDocuments() { - if (packageId && open) { + if (projectCode && packageCode && open) { setIsLoading(true); try { - const docs = await fetchDocumentsByPackageId(packageId); + const docs = await fetchDocumentsByProjectAndPackage(projectCode, packageCode); setDocuments(docs); } catch (error) { console.error("Error fetching documents:", error); @@ -136,7 +143,7 @@ export const PublishDialog: React.FC<PublishDialogProps> = ({ } loadDocuments(); - }, [packageId, open]); + }, [projectCode, packageCode, open]); // Fetch stages when document is selected useEffect(() => { @@ -146,11 +153,12 @@ export const PublishDialog: React.FC<PublishDialogProps> = ({ // Reset dependent fields setSelectedStage(""); - setRevisionInput(""); - setLatestRevision(""); + setRevisionCodeInput(""); + setLatestRevisionCode(""); + setLatestRevisionNumber(0); try { - const stagesList = await fetchStagesByDocumentId(parseInt(selectedDocId, 10)); + const stagesList = await fetchStagesByDocumentIdPlant(parseInt(selectedDocId, 10)); setStages(stagesList); } catch (error) { console.error("Error fetching stages:", error); @@ -166,65 +174,78 @@ export const PublishDialog: React.FC<PublishDialogProps> = ({ loadStages(); }, [selectedDocId]); - // Fetch latest revision when stage is selected (for reference) + // Fetch latest submission (revision) when stage is selected useEffect(() => { - async function loadLatestRevision() { + async function loadLatestSubmission() { if (selectedDocId && selectedStage) { setIsLoading(true); try { - const revsList = await fetchRevisionsByStageParams( + const submissionsList = await fetchSubmissionsByStageParams( parseInt(selectedDocId, 10), selectedStage ); - // Find the latest revision (assuming revisions are sorted by revision number) - if (revsList.length > 0) { - // Sort revisions if needed - const sortedRevisions = [...revsList].sort((a, b) => { - return b.revision.localeCompare(a.revision, undefined, { numeric: true }); - }); + // Find the latest submission (assuming sorted by revision number) + if (submissionsList.length > 0) { + // Sort submissions by revision number descending + const sortedSubmissions = [...submissionsList].sort((a, b) => + b.revisionNumber - a.revisionNumber + ); - setLatestRevision(sortedRevisions[0].revision); + const latestSubmission = sortedSubmissions[0]; + setLatestRevisionCode(latestSubmission.revisionCode); + setLatestRevisionNumber(latestSubmission.revisionNumber); - // Pre-fill the revision input with an incremented value if possible - if (sortedRevisions[0].revision.match(/^\d+$/)) { + // Auto-increment revision code + if (latestSubmission.revisionCode.match(/^\d+$/)) { // If it's a number, increment it - const nextRevision = String(parseInt(sortedRevisions[0].revision, 10) + 1); - setRevisionInput(nextRevision); - } else if (sortedRevisions[0].revision.match(/^[A-Za-z]$/)) { + const nextRevision = String(parseInt(latestSubmission.revisionCode, 10) + 1); + setRevisionCodeInput(nextRevision); + } else if (latestSubmission.revisionCode.match(/^[A-Za-z]$/)) { // If it's a single letter, get the next letter - const currentChar = sortedRevisions[0].revision.charCodeAt(0); + const currentChar = latestSubmission.revisionCode.charCodeAt(0); const nextChar = String.fromCharCode(currentChar + 1); - setRevisionInput(nextChar); + setRevisionCodeInput(nextChar); + } else if (latestSubmission.revisionCode.toLowerCase().startsWith("rev")) { + // Handle "Rev0", "Rev1" format + const numMatch = latestSubmission.revisionCode.match(/\d+$/); + if (numMatch) { + const nextNum = parseInt(numMatch[0], 10) + 1; + setRevisionCodeInput(`Rev${nextNum}`); + } else { + setRevisionCodeInput(""); + } } else { // For other formats, just show the latest as reference - setRevisionInput(""); + setRevisionCodeInput(""); } } else { - // If no revisions exist, set default values - setLatestRevision(""); - setRevisionInput("0"); + // If no submissions exist, set default values + setLatestRevisionCode(""); + setLatestRevisionNumber(0); + setRevisionCodeInput("Rev0"); // Start with Rev0 } } catch (error) { - console.error("Error fetching revisions:", error); - toast.error("Failed to load revision information"); + console.error("Error fetching submissions:", error); + toast.error("Failed to load submission information"); } finally { setIsLoading(false); } } else { - setLatestRevision(""); - setRevisionInput(""); + setLatestRevisionCode(""); + setLatestRevisionNumber(0); + setRevisionCodeInput(""); } } - loadLatestRevision(); + loadLatestSubmission(); }, [selectedDocId, selectedStage]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!selectedDocId || !selectedStage || !revisionInput || !fileBlob) { + if (!selectedDocId || !selectedStage || !revisionCodeInput || !fileBlob) { toast.error("Please fill in all required fields"); return; } @@ -235,17 +256,30 @@ export const PublishDialog: React.FC<PublishDialogProps> = ({ // Create FormData const formData = new FormData(); formData.append("documentId", selectedDocId); - formData.append("stage", selectedStage); - formData.append("revision", revisionInput); + formData.append("stageName", selectedStage); + formData.append("revisionCode", revisionCodeInput); formData.append("customFileName", customFileName); - formData.append("uploaderType", "vendor"); // Default value - if (uploaderName) { - formData.append("uploaderName", uploaderName); + if (submitterName) { + formData.append("submittedBy", submitterName); } - if (comment) { - formData.append("comment", comment); + if (session?.user?.email) { + formData.append("submittedByEmail", session.user.email); + } + + if (submissionTitle) { + formData.append("submissionTitle", submissionTitle); + } + + if (submissionDescription) { + formData.append("submissionDescription", submissionDescription); + } + + // Get vendor info from selected document + const selectedDoc = documents.find(doc => doc.id === parseInt(selectedDocId, 10)); + if (selectedDoc) { + formData.append("vendorId", String(selectedDoc.vendorId)); } // Append file as attachment @@ -256,12 +290,14 @@ export const PublishDialog: React.FC<PublishDialogProps> = ({ formData.append("attachment", file); } - // Call server action directly - const result = await createRevisionAction(formData); + // Call server action + const result = await createSubmissionAction(formData); - if (result) { + if (result.success) { toast.success("Document published successfully!"); onOpenChange(false); + } else { + toast.error(result.error || "Failed to publish document"); } } catch (error) { console.error("Error publishing document:", error); @@ -301,7 +337,6 @@ export const PublishDialog: React.FC<PublishDialogProps> = ({ className="w-full justify-between" disabled={isLoading || documents.length === 0} > - {/* Add text-overflow handling for selected document display */} <span className="truncate"> {selectedDocumentDisplay ? selectedDocumentDisplay @@ -338,7 +373,6 @@ export const PublishDialog: React.FC<PublishDialogProps> = ({ : "opacity-0" )} /> - {/* Add text-overflow handling for document items */} <span className="truncate">{doc.docNumber} - {doc.title}</span> </CommandItem> ))} @@ -366,7 +400,6 @@ export const PublishDialog: React.FC<PublishDialogProps> = ({ <SelectContent> {stages.map((stage) => ( <SelectItem key={stage.id} value={stage.stageName}> - {/* Add text-overflow handling for stage names */} <span className="truncate">{stage.stageName}</span> </SelectItem> ))} @@ -375,28 +408,42 @@ export const PublishDialog: React.FC<PublishDialogProps> = ({ </div> </div> - {/* Revision Input */} + {/* Revision Code Input */} <div className="grid grid-cols-4 items-center gap-4"> - <Label htmlFor="revision" className="text-right"> + <Label htmlFor="revisionCode" className="text-right"> Revision </Label> <div className="col-span-3"> <Input - id="revision" - value={revisionInput} - onChange={(e) => setRevisionInput(e.target.value)} - placeholder="Enter revision" + id="revisionCode" + value={revisionCodeInput} + onChange={(e) => setRevisionCodeInput(e.target.value)} + placeholder="Enter revision code (e.g., Rev0, A, 1)" disabled={isLoading || !selectedStage} /> - {latestRevision && ( + {latestRevisionCode && ( <p className="text-xs text-muted-foreground mt-1"> - Latest revision: {latestRevision} + Latest revision: {latestRevisionCode} (#{latestRevisionNumber}) </p> )} </div> </div> <div className="grid grid-cols-4 items-center gap-4"> + <Label htmlFor="submissionTitle" className="text-right"> + Title + </Label> + <div className="col-span-3"> + <Input + id="submissionTitle" + value={submissionTitle} + onChange={(e) => setSubmissionTitle(e.target.value)} + placeholder="Optional submission title" + /> + </div> + </div> + + <div className="grid grid-cols-4 items-center gap-4"> <Label htmlFor="fileName" className="text-right"> File Name </Label> @@ -411,16 +458,15 @@ export const PublishDialog: React.FC<PublishDialogProps> = ({ </div> <div className="grid grid-cols-4 items-center gap-4"> - <Label htmlFor="uploaderName" className="text-right"> - Uploader + <Label htmlFor="submitterName" className="text-right"> + Submitter </Label> <div className="col-span-3"> <Input - id="uploaderName" - value={uploaderName} - onChange={(e) => setUploaderName(e.target.value)} + id="submitterName" + value={submitterName} + onChange={(e) => setSubmitterName(e.target.value)} placeholder="Your name" - // Disable input but show a filled style className={session?.user?.name ? "opacity-70" : ""} readOnly={!!session?.user?.name} /> @@ -433,15 +479,15 @@ export const PublishDialog: React.FC<PublishDialogProps> = ({ </div> <div className="grid grid-cols-4 items-center gap-4"> - <Label htmlFor="comment" className="text-right"> - Comment + <Label htmlFor="description" className="text-right"> + Description </Label> <div className="col-span-3"> <Textarea - id="comment" - value={comment} - onChange={(e) => setComment(e.target.value)} - placeholder="Optional comment" + id="description" + value={submissionDescription} + onChange={(e) => setSubmissionDescription(e.target.value)} + placeholder="Optional submission description" className="resize-none" /> </div> @@ -451,7 +497,7 @@ export const PublishDialog: React.FC<PublishDialogProps> = ({ <DialogFooter> <Button type="submit" - disabled={isSubmitting || !selectedDocId || !selectedStage || !revisionInput} + disabled={isSubmitting || !selectedDocId || !selectedStage || !revisionCodeInput} > {isSubmitting ? ( <> diff --git a/components/form-data-plant/spreadJS-dialog.tsx b/components/form-data-plant/spreadJS-dialog.tsx index 2eb2c8ba..9f972676 100644 --- a/components/form-data-plant/spreadJS-dialog.tsx +++ b/components/form-data-plant/spreadJS-dialog.tsx @@ -92,7 +92,8 @@ interface TemplateViewDialogProps { tableData?: GenericData[]; formCode: string; columnsJSON: DataTableColumnJSON[] - contractItemId: number; + projectCode: string; + packageCode: string; editableFieldsMap?: Map<string, string[]>; onUpdateSuccess?: (updatedValues: Record<string, any> | GenericData[]) => void; } @@ -142,7 +143,8 @@ export function TemplateViewDialog({ selectedRow, tableData = [], formCode, - contractItemId, + projectCode, + packageCode, columnsJSON, editableFieldsMap = new Map(), onUpdateSuccess @@ -1435,7 +1437,8 @@ export function TemplateViewDialog({ const { success, message } = await updateFormDataInDB( formCode, - contractItemId, + projectCode, + packageCode, dataToSave ); @@ -1500,7 +1503,8 @@ export function TemplateViewDialog({ try { const { success, message } = await updateFormDataInDB( formCode, - contractItemId, + projectCode, + packageCode, dataToSave ); @@ -1551,7 +1555,8 @@ export function TemplateViewDialog({ selectedRow, tableData, formCode, - contractItemId, + projectCode, + packageCode, onUpdateSuccess, cellMappings, columnsJSON, diff --git a/components/form-data-plant/update-form-sheet.tsx b/components/form-data-plant/update-form-sheet.tsx index bd75d8f3..b7f56f7e 100644 --- a/components/form-data-plant/update-form-sheet.tsx +++ b/components/form-data-plant/update-form-sheet.tsx @@ -65,7 +65,8 @@ interface UpdateTagSheetProps extends React.ComponentPropsWithoutRef<typeof Shee columns: DataTableColumnJSON[]; rowData: Record<string, any> | null; formCode: string; - contractItemId: number; + projectCode: string; + packageCode: string; editableFieldsMap?: Map<string, string[]>; // 새로 추가 /** 업데이트 성공 시 호출될 콜백 */ onUpdateSuccess?: (updatedValues: Record<string, any>) => void; @@ -77,7 +78,8 @@ export function UpdateTagSheet({ columns, rowData, formCode, - contractItemId, + projectCode, + packageCode, editableFieldsMap = new Map(), onUpdateSuccess, ...props @@ -219,7 +221,8 @@ export function UpdateTagSheet({ const { success, message } = await updateFormDataInDB( formCode, - contractItemId, + projectCode, + packageCode, finalValues, ); diff --git a/components/vendor-data-plant/project-swicher.tsx b/components/vendor-data-plant/project-swicher.tsx index d3123709..9b8f9bea 100644 --- a/components/vendor-data-plant/project-swicher.tsx +++ b/components/vendor-data-plant/project-swicher.tsx @@ -1,6 +1,7 @@ "use client" import * as React from "react" +import { Check, ChevronsUpDown } from "lucide-react" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" import { @@ -16,149 +17,103 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover" -import { Check, ChevronsUpDown, Loader2 } from "lucide-react" -interface ContractInfo { - contractId: number - contractName: string +interface PackageData { + packageCode: string + packageName: string | null } -interface ProjectInfo { +interface ProjectData { projectId: number projectCode: string projectName: string - contracts: ContractInfo[] + projectType: string + packages: PackageData[] } interface ProjectSwitcherProps { isCollapsed: boolean - projects: ProjectInfo[] - - // 상위가 관리하는 "현재 선택된 contractId" - selectedContractId: number | null - - // 콜백: 사용자가 "어떤 contract"를 골랐는지 - // => 우리가 projectId도 찾아서 상위 state를 같이 갱신해야 함 - onSelectContract: (projectId: number, contractId: number) => void - - // 로딩 상태 (선택사항) - isLoading?: boolean + projects: ProjectData[] + selectedProjectId: number + selectedPackageCode: string | null + onSelectPackage: (projectId: number, packageCode: string) => void } export function ProjectSwitcher({ isCollapsed, projects, - selectedContractId, - onSelectContract, - isLoading = false, + selectedProjectId, + selectedPackageCode, + onSelectPackage, }: ProjectSwitcherProps) { - const [popoverOpen, setPopoverOpen] = React.useState(false) - const [searchTerm, setSearchTerm] = React.useState("") + const [open, setOpen] = React.useState(false) - // 현재 선택된 contract 객체 찾기 - const selectedContract = React.useMemo(() => { - if (!selectedContractId) return null - for (const proj of projects) { - const found = proj.contracts.find((c) => c.contractId === selectedContractId) - if (found) { - return { ...found, projectId: proj.projectId, projectName: proj.projectName } - } - } - return null - }, [projects, selectedContractId]) - - // Trigger label => 계약 이름 or placeholder - const triggerLabel = selectedContract?.contractName ?? "Select a contract" + // 현재 선택된 프로젝트와 패키지 정보 + const selectedProject = projects.find(p => p.projectId === selectedProjectId) + const selectedPackage = selectedProject?.packages.find( + pkg => pkg.packageCode === selectedPackageCode + ) - // 검색어에 따른 필터링된 프로젝트/계약 목록 - const filteredProjects = React.useMemo(() => { - if (!searchTerm) return projects - - return projects.map(project => ({ - ...project, - contracts: project.contracts.filter(contract => - contract.contractName.toLowerCase().includes(searchTerm.toLowerCase()) || - project.projectName.toLowerCase().includes(searchTerm.toLowerCase()) - ) - })).filter(project => project.contracts.length > 0) - }, [projects, searchTerm]) - // 계약 선택 핸들러 - function handleSelectContract(projectId: number, contractId: number) { - onSelectContract(projectId, contractId) - setPopoverOpen(false) - setSearchTerm("") // 검색어 초기화 - } + console.log(projects,"projects") - // 총 계약 수 계산 (빈 상태 표시용) - const totalContracts = filteredProjects.reduce((sum, project) => sum + project.contracts.length, 0) + const displayText = selectedPackage + ? `${selectedProject?.projectCode} - ${selectedPackage.packageCode}` + : selectedProject?.projectCode || "Select Package" return ( - <Popover open={popoverOpen} onOpenChange={setPopoverOpen}> + <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger asChild> <Button - type="button" variant="outline" - className={cn( - "justify-between relative", - isCollapsed ? "h-9 w-9 shrink-0 items-center justify-center p-0" : "w-full h-9" - )} - disabled={isLoading} - aria-label="Select Contract" + role="combobox" + aria-expanded={open} + aria-label="Select a package" + className={cn("w-full justify-between", isCollapsed && "w-[50px]")} > - {isLoading ? ( - <> - <span className={cn(isCollapsed && "hidden")}>Loading...</span> - <Loader2 className={cn("h-4 w-4 animate-spin", !isCollapsed && "ml-2")} /> - </> + {isCollapsed ? ( + <ChevronsUpDown className="h-4 w-4" /> ) : ( <> - <span className={cn("truncate flex-grow text-left", isCollapsed && "hidden")}> - {triggerLabel} - </span> - <ChevronsUpDown className={cn("h-4 w-4 opacity-50 flex-shrink-0", isCollapsed && "hidden")} /> + <span className="truncate">{displayText}</span> + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> </> )} </Button> </PopoverTrigger> - - <PopoverContent className="w-[320px] p-0" align="start"> + <PopoverContent className="w-[300px] p-0"> <Command> - <CommandInput - placeholder="Search contracts..." - value={searchTerm} - onValueChange={setSearchTerm} - /> - - <CommandList - className="max-h-[320px]" - onWheel={(e) => { - e.stopPropagation() // 이벤트 전파 차단 - const target = e.currentTarget - target.scrollTop += e.deltaY // 직접 스크롤 처리 - }} - > - <CommandEmpty> - {totalContracts === 0 ? "No contracts found." : "No search results."} - </CommandEmpty> - - {filteredProjects.map((project) => ( - <CommandGroup key={project.projectCode} heading={project.projectName}> - {project.contracts.map((contract) => ( + <CommandInput placeholder="Search package..." /> + <CommandList> + <CommandEmpty>No package found.</CommandEmpty> + {projects.map((project) => ( + <CommandGroup key={project.projectId} heading={project.projectName}> + {project.packages.map((pkg) => ( <CommandItem - key={contract.contractId} - onSelect={() => handleSelectContract(project.projectId, contract.contractId)} - value={`${project.projectName} ${contract.contractName}`} - className="truncate" - title={contract.contractName} + key={`${project.projectId}-${pkg.packageCode}`} + onSelect={() => { + onSelectPackage(project.projectId, pkg.packageCode) + setOpen(false) + }} + className="text-sm" > - <span className="truncate">{contract.contractName}</span> <Check className={cn( - "ml-auto h-4 w-4 flex-shrink-0", - selectedContractId === contract.contractId ? "opacity-100" : "opacity-0" + "mr-2 h-4 w-4", + selectedProjectId === project.projectId && + selectedPackageCode === pkg.packageCode + ? "opacity-100" + : "opacity-0" )} /> + <div className="flex flex-col"> + <span className="font-medium">{pkg.packageCode}</span> + {pkg.packageName && ( + <span className="text-xs text-muted-foreground"> + {pkg.packageName} + </span> + )} + </div> </CommandItem> ))} </CommandGroup> diff --git a/components/vendor-data-plant/sidebar.tsx b/components/vendor-data-plant/sidebar.tsx index 31ee6dc7..b746e69d 100644 --- a/components/vendor-data-plant/sidebar.tsx +++ b/components/vendor-data-plant/sidebar.tsx @@ -10,304 +10,265 @@ import { TooltipTrigger, TooltipContent, } from "@/components/ui/tooltip" -import { Package2, FormInput } from "lucide-react" -import { useRouter, usePathname } from "next/navigation" +import { List, FormInput, FileText } from "lucide-react" import { Skeleton } from "@/components/ui/skeleton" -import { type FormInfo } from "@/lib/forms/services" +import { getEngineeringForms, getIMForms } from "@/lib/tags-plant/service" -interface PackageData { - itemId: number - itemName: string +interface FormInfo { + formCode: string + formName: string } interface SidebarProps extends React.HTMLAttributes<HTMLDivElement> { isCollapsed: boolean - packages: PackageData[] - selectedPackageId: number | null - selectedProjectId: number | null - selectedContractId: number | null - onSelectPackage: (itemId: number) => void - forms?: FormInfo[] - onSelectForm: (formName: string) => void - isLoadingForms?: boolean - mode: "IM" | "ENG" + selectedPackageCode: string | null + selectedFormCode: string | null + currentMode: "master" | "engineering" | "im" | null + projectCode: string // 추가 + onMasterTagListClick: () => void + onEngineeringFormClick: (formCode: string) => void + onIMFormClick: (formCode: string) => void } export function Sidebar({ className, isCollapsed, - packages, - selectedPackageId, - selectedProjectId, - selectedContractId, - onSelectPackage, - forms, - onSelectForm, - isLoadingForms = false, - mode = "IM", + selectedPackageCode, + selectedFormCode, + currentMode, + projectCode, // 추가 + onMasterTagListClick, + onEngineeringFormClick, + onIMFormClick, }: SidebarProps) { - const router = useRouter() - const rawPathname = usePathname() - const pathname = rawPathname ?? "" + const [engineeringForms, setEngineeringForms] = React.useState<FormInfo[]>([]) + const [imForms, setIMForms] = React.useState<FormInfo[]>([]) + const [isLoadingEngineering, setIsLoadingEngineering] = React.useState(false) + const [isLoadingIM, setIsLoadingIM] = React.useState(false) - /** - * --------------------------- - * 1) URL에서 현재 패키지 / 폼 코드 추출 - * --------------------------- - */ - const segments = pathname.split("/").filter(Boolean) - - let currentItemId: number | null = null - let currentFormCode: string | null = null + // Engineering 폼 로드 + React.useEffect(() => { + if (!selectedPackageCode || !projectCode) { + setEngineeringForms([]) + return + } - const tagIndex = segments.indexOf("tag") - if (tagIndex !== -1 && segments[tagIndex + 1]) { - currentItemId = parseInt(segments[tagIndex + 1], 10) - } + const loadEngineeringForms = async () => { + setIsLoadingEngineering(true) + try { + const result = await getEngineeringForms(projectCode, selectedPackageCode) + setEngineeringForms(result) + } catch (error) { + console.error("Engineering 폼 로딩 오류:", error) + setEngineeringForms([]) + } finally { + setIsLoadingEngineering(false) + } + } - const formIndex = segments.indexOf("form") - if (formIndex !== -1) { - const itemSegment = segments[formIndex + 1] - const codeSegment = segments[formIndex + 2] + loadEngineeringForms() + }, [selectedPackageCode, projectCode]) - if (itemSegment) { - currentItemId = parseInt(itemSegment, 10) - } - if (codeSegment) { - currentFormCode = codeSegment + // IM 폼 로드 + React.useEffect(() => { + if (!selectedPackageCode || !projectCode) { + setIMForms([]) + return } - } - /** - * --------------------------- - * 2) 패키지 클릭 핸들러 (IM 모드) - * --------------------------- - */ - const handlePackageClick = (itemId: number) => { - // 상위 컴포넌트 상태 업데이트 - onSelectPackage(itemId) - - // 해당 태그 페이지로 라우팅 - // 예: /vendor-data-plant/tag/123 - const baseSegments = segments.slice(0, segments.indexOf("vendor-data-plant") + 1).join("/") - router.push(`/${baseSegments}/tag/${itemId}`) - } + const loadIMForms = async () => { + setIsLoadingIM(true) + try { + const result = await getIMForms(projectCode, selectedPackageCode) + setIMForms(result) + } catch (error) { + console.error("IM 폼 로딩 오류:", error) + setIMForms([]) + } finally { + setIsLoadingIM(false) + } + } - /** - * --------------------------- - * 3) 폼 클릭 핸들러 (IM 모드만 사용) - * --------------------------- - */ - const handleFormClick = (form: FormInfo) => { - // IM 모드에서만 사용 - if (selectedPackageId === null) return; - - onSelectForm(form.formName) - - const baseSegments = segments.slice(0, segments.indexOf("vendor-data-plant") + 1).join("/") - router.push(`/${baseSegments}/form/${selectedPackageId}/${form.formCode}/${selectedProjectId}/${selectedContractId}?mode=${mode}`) - } + loadIMForms() + }, [selectedPackageCode, projectCode]) - /** - * --------------------------- - * 4) 패키지 클릭 핸들러 (ENG 모드) - * --------------------------- - */ - const handlePackageUnderFormClick = (form: FormInfo, pkg: PackageData) => { - onSelectForm(form.formName) - onSelectPackage(pkg.itemId) - - const baseSegments = segments.slice(0, segments.indexOf("vendor-data-plant") + 1).join("/") - router.push(`/${baseSegments}/form/${pkg.itemId}/${form.formCode}/${selectedProjectId}/${selectedContractId}?mode=${mode}`) - } + const isMasterActive = currentMode === "master" + const isPackageSelected = selectedPackageCode !== null return ( <div className={cn("pb-12", className)}> <div className="space-y-4 py-4"> - {/* ---------- 패키지(Items) 목록 - IM 모드에서만 표시 ---------- */} - {mode === "IM" && ( - <> - <div className="py-1"> - <h2 className="relative px-7 text-lg font-semibold tracking-tight"> - {isCollapsed ? "P" : "Package Lists"} - </h2> - <ScrollArea className="h-[150px] px-1"> - <div className="space-y-1 p-2"> - {packages.map((pkg) => { - const isActive = pkg.itemId === currentItemId + {/* Master Tag List */} + <div className="py-1"> + <h2 className="relative px-7 text-lg font-semibold tracking-tight"> + {isCollapsed ? "M" : "Master"} + </h2> + <div className="space-y-1 p-2"> + {isCollapsed ? ( + <Tooltip delayDuration={0}> + <TooltipTrigger asChild> + <Button + variant="ghost" + className={cn( + "w-full justify-start font-normal", + isMasterActive && "bg-accent text-accent-foreground" + )} + onClick={onMasterTagListClick} + disabled={!isPackageSelected} + > + <List className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent side="right"> + Master Tag List + </TooltipContent> + </Tooltip> + ) : ( + <Button + variant="ghost" + className={cn( + "w-full justify-start font-normal", + isMasterActive && "bg-accent text-accent-foreground" + )} + onClick={onMasterTagListClick} + disabled={!isPackageSelected} + > + <List className="mr-2 h-4 w-4" /> + Master Tag List + </Button> + )} + </div> + </div> - return ( - <div key={pkg.itemId}> - {isCollapsed ? ( - <Tooltip delayDuration={0}> - <TooltipTrigger asChild> - <Button - variant="ghost" - className={cn( - "w-full justify-start font-normal", - isActive && "bg-accent text-accent-foreground" - )} - onClick={() => handlePackageClick(pkg.itemId)} - > - <Package2 className="mr-2 h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent side="right"> - {pkg.itemName} - </TooltipContent> - </Tooltip> - ) : ( - <Button - variant="ghost" - className={cn( - "w-full justify-start font-normal", - isActive && "bg-accent text-accent-foreground" - )} - onClick={() => handlePackageClick(pkg.itemId)} - > - <Package2 className="mr-2 h-4 w-4" /> - {pkg.itemName} - </Button> - )} - </div> - ) - })} - </div> - </ScrollArea> - </div> - <Separator /> - </> - )} + <Separator /> - {/* ---------- 폼 목록 (IM 모드) / 패키지와 폼 목록 (ENG 모드) ---------- */} + {/* Engineering Forms */} <div className="py-1"> <h2 className="relative px-7 text-lg font-semibold tracking-tight"> - {isCollapsed - ? (mode === "IM" ? "F" : "P") - : (mode === "IM" ? "Form Lists" : "Package Lists") - } + {isCollapsed ? "E" : "Engineering"} </h2> - <ScrollArea className={cn( - "px-1", - mode === "IM" ? "h-[300px]" : "h-[450px]" - )}> + <ScrollArea className="h-[250px] px-1"> <div className="space-y-1 p-2"> - {isLoadingForms ? ( + {isLoadingEngineering ? ( Array.from({ length: 3 }).map((_, index) => ( - <div key={`form-skeleton-${index}`} className="px-2 py-1.5"> + <div key={`eng-skeleton-${index}`} className="px-2 py-1.5"> <Skeleton className="h-8 w-full" /> </div> )) - ) : mode === "IM" ? ( - // =========== IM 모드: 폼만 표시 =========== - !forms || forms.length === 0 ? ( - <p className="text-sm text-muted-foreground px-2"> - (No forms loaded) - </p> - ) : ( - forms.map((form) => { - const isFormActive = form.formCode === currentFormCode - const isDisabled = currentItemId === null + ) : !isPackageSelected ? ( + <p className="text-sm text-muted-foreground px-2"> + Select a package first + </p> + ) : engineeringForms.length === 0 ? ( + <p className="text-sm text-muted-foreground px-2"> + No forms available + </p> + ) : ( + engineeringForms.map((form) => { + const isActive = + currentMode === "engineering" && + form.formCode === selectedFormCode - return isCollapsed ? ( - <Tooltip key={form.formCode} delayDuration={0}> - <TooltipTrigger asChild> - <Button - variant="ghost" - className={cn( - "w-full justify-start font-normal", - isFormActive && "bg-accent text-accent-foreground" - )} - onClick={() => handleFormClick(form)} - disabled={isDisabled} - > - <FormInput className="mr-2 h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent side="right"> - {form.formName} - </TooltipContent> - </Tooltip> - ) : ( - <Button - key={form.formCode} - variant="ghost" - className={cn( - "w-full justify-start font-normal", - isFormActive && "bg-accent text-accent-foreground" - )} - onClick={() => handleFormClick(form)} - disabled={isDisabled} - > - <FormInput className="mr-2 h-4 w-4" /> + return isCollapsed ? ( + <Tooltip key={form.formCode} delayDuration={0}> + <TooltipTrigger asChild> + <Button + variant="ghost" + className={cn( + "w-full justify-start font-normal", + isActive && "bg-accent text-accent-foreground" + )} + onClick={() => onEngineeringFormClick(form.formCode)} + > + <FormInput className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent side="right"> {form.formName} - </Button> - ) - }) - ) + </TooltipContent> + </Tooltip> + ) : ( + <Button + key={form.formCode} + variant="ghost" + className={cn( + "w-full justify-start font-normal", + isActive && "bg-accent text-accent-foreground" + )} + onClick={() => onEngineeringFormClick(form.formCode)} + > + <FormInput className="mr-2 h-4 w-4" /> + {form.formName} + </Button> + ) + }) + )} + </div> + </ScrollArea> + </div> + + <Separator /> + + {/* IM Forms */} + <div className="py-1"> + <h2 className="relative px-7 text-lg font-semibold tracking-tight"> + {isCollapsed ? "I" : "IM"} + </h2> + <ScrollArea className="h-[250px] px-1"> + <div className="space-y-1 p-2"> + {isLoadingIM ? ( + Array.from({ length: 3 }).map((_, index) => ( + <div key={`im-skeleton-${index}`} className="px-2 py-1.5"> + <Skeleton className="h-8 w-full" /> + </div> + )) + ) : !isPackageSelected ? ( + <p className="text-sm text-muted-foreground px-2"> + Select a package first + </p> + ) : imForms.length === 0 ? ( + <p className="text-sm text-muted-foreground px-2"> + No forms available + </p> ) : ( - // =========== ENG 모드: 패키지 > 폼 계층 구조 =========== - packages.length === 0 ? ( - <p className="text-sm text-muted-foreground px-2"> - (No packages loaded) - </p> - ) : ( - packages.map((pkg) => ( - <div key={pkg.itemId} className="space-y-1"> - {isCollapsed ? ( - <Tooltip delayDuration={0}> - <TooltipTrigger asChild> - <div className="px-2 py-1"> - <Package2 className="h-4 w-4" /> - </div> - </TooltipTrigger> - <TooltipContent side="right"> - {pkg.itemName} - </TooltipContent> - </Tooltip> - ) : ( - <> - {/* 패키지 이름 (클릭 불가능한 라벨) */} - <div className="flex items-center px-2 py-1 text-sm font-medium"> - <Package2 className="mr-2 h-4 w-4" /> - {pkg.itemName} - </div> - - {/* 폼 목록 바로 표시 */} - <div className="ml-6 space-y-1"> - {!forms || forms.length === 0 ? ( - <p className="text-xs text-muted-foreground px-2 py-1"> - No forms available - </p> - ) : ( - forms.map((form) => { - const isFormPackageActive = - pkg.itemId === currentItemId && - form.formCode === currentFormCode + imForms.map((form) => { + const isActive = + currentMode === "im" && + form.formCode === selectedFormCode - return ( - <Button - key={`${pkg.itemId}-${form.formCode}`} - variant="ghost" - size="sm" - className={cn( - "w-full justify-start font-normal text-sm", - isFormPackageActive && "bg-accent text-accent-foreground" - )} - onClick={() => handlePackageUnderFormClick(form, pkg)} - > - <FormInput className="mr-2 h-3 w-3" /> - {form.formName} - </Button> - ) - }) - )} - </div> - </> + return isCollapsed ? ( + <Tooltip key={form.formCode} delayDuration={0}> + <TooltipTrigger asChild> + <Button + variant="ghost" + className={cn( + "w-full justify-start font-normal", + isActive && "bg-accent text-accent-foreground" + )} + onClick={() => onIMFormClick(form.formCode)} + > + <FileText className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent side="right"> + {form.formName} + </TooltipContent> + </Tooltip> + ) : ( + <Button + key={form.formCode} + variant="ghost" + className={cn( + "w-full justify-start font-normal", + isActive && "bg-accent text-accent-foreground" )} - </div> - )) - ) + onClick={() => onIMFormClick(form.formCode)} + > + <FileText className="mr-2 h-4 w-4" /> + {form.formName} + </Button> + ) + }) )} </div> </ScrollArea> diff --git a/components/vendor-data-plant/vendor-data-container.tsx b/components/vendor-data-plant/vendor-data-container.tsx index 60ec2c94..7ce831df 100644 --- a/components/vendor-data-plant/vendor-data-container.tsx +++ b/components/vendor-data-plant/vendor-data-container.tsx @@ -4,28 +4,14 @@ import * as React from "react" import { TooltipProvider } from "@/components/ui/tooltip" import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable" import { cn } from "@/lib/utils" -import { ProjectSwitcher } from "./project-swicher" import { Sidebar } from "./sidebar" -import { usePathname, useRouter, useSearchParams } from "next/navigation" -import { getFormsByContractItemId, type FormInfo } from "@/lib/forms/services" +import { usePathname, useRouter } from "next/navigation" import { Separator } from "@/components/ui/separator" -import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs" -import { ScrollArea } from "@/components/ui/scroll-area" -import { Button } from "@/components/ui/button" -import { FormInput } from "lucide-react" -import { Skeleton } from "@/components/ui/skeleton" -import { selectedModeAtom } from '@/atoms' -import { useAtom } from 'jotai' +import { ProjectSwitcher } from "./project-swicher" interface PackageData { - itemId: number - itemName: string -} - -interface ContractData { - contractId: number - contractName: string - packages: PackageData[] + packageCode: string + packageName: string | null } interface ProjectData { @@ -33,7 +19,7 @@ interface ProjectData { projectCode: string projectName: string projectType: string - contracts: ContractData[] + packages: PackageData[] } interface VendorDataContainerProps { @@ -44,18 +30,39 @@ interface VendorDataContainerProps { children: React.ReactNode } -function getTagIdFromPathname(path: string | null): number | null { - if (!path) return null; +function getInfoFromPathname(path: string | null): { + projectCode: string | null + packageCode: string | null + formCode: string | null + mode: "master" | "engineering" | "im" | null +} { + if (!path) return { projectCode: null, packageCode: null, formCode: null, mode: null } + + const segments = path.split("/").filter(Boolean) + const vendorDataIndex = segments.indexOf("vendor-data-plant") + + if (vendorDataIndex === -1) { + return { projectCode: null, packageCode: null, formCode: null, mode: null } + } - // 태그 패턴 검사 (/tag/123) - const tagMatch = path.match(/\/tag\/(\d+)/) - if (tagMatch) return parseInt(tagMatch[1], 10) + const projectCode = segments[vendorDataIndex + 1] || null + const packageCode = segments[vendorDataIndex + 2] || null - // 폼 패턴 검사 (/form/123/...) - const formMatch = path.match(/\/form\/(\d+)/) - if (formMatch) return parseInt(formMatch[1], 10) + // /eng/{formCode} 또는 /im/{formCode} 패턴 체크 + const modeSegment = segments[vendorDataIndex + 3] + const formCode = segments[vendorDataIndex + 4] || null - return null + let mode: "master" | "engineering" | "im" | null = null + + if (modeSegment === "eng") { + mode = "engineering" + } else if (modeSegment === "im") { + mode = "im" + } else if (projectCode && packageCode && !modeSegment) { + mode = "master" + } + + return { projectCode, packageCode, formCode, mode } } export function VendorDataContainer({ @@ -67,267 +74,106 @@ export function VendorDataContainer({ }: VendorDataContainerProps) { const pathname = usePathname() const router = useRouter() - const searchParams = useSearchParams() - const tagIdNumber = getTagIdFromPathname(pathname) - - // 기본 상태 + // 상태 관리 const [selectedProjectId, setSelectedProjectId] = React.useState(projects[0]?.projectId || 0) const [isCollapsed, setIsCollapsed] = React.useState(defaultCollapsed) - const [selectedContractId, setSelectedContractId] = React.useState( - projects[0]?.contracts[0]?.contractId || 0 - ) - const [selectedPackageId, setSelectedPackageId] = React.useState<number | null>(null) - const [formList, setFormList] = React.useState<FormInfo[]>([]) + const [selectedPackageCode, setSelectedPackageCode] = React.useState<string | null>(null) const [selectedFormCode, setSelectedFormCode] = React.useState<string | null>(null) - const [isLoadingForms, setIsLoadingForms] = React.useState(false) + const [currentMode, setCurrentMode] = React.useState<"master" | "engineering" | "im" | null>(null) - console.log(selectedPackageId,"selectedPackageId") - - - // 현재 선택된 프로젝트/계약/패키지 + // 현재 선택된 프로젝트 const currentProject = projects.find((p) => p.projectId === selectedProjectId) ?? projects[0] - const currentContract = currentProject?.contracts.find((c) => c.contractId === selectedContractId) - ?? currentProject?.contracts[0] - - // 프로젝트 타입 확인 - ship인 경우 항상 ENG 모드 - const isShipProject = currentProject?.projectType === "ship" - - const [selectedMode, setSelectedMode] = useAtom(selectedModeAtom) - - // URL에서 모드 추출 (ship 프로젝트면 무조건 ENG로, 아니면 URL 또는 기본값) - const modeFromUrl = searchParams?.get('mode') - const initialMode ="ENG" - - // 모드 초기화 (기존의 useState 초기값 대신) - React.useEffect(() => { - setSelectedMode(initialMode as "IM" | "ENG") - }, [initialMode, setSelectedMode]) - - const isTagOrFormRoute = pathname ? (pathname.includes("/tag/") || pathname.includes("/form/")) : false - const currentPackageName = isTagOrFormRoute - ? currentContract?.packages.find((pkg) => pkg.itemId === selectedPackageId)?.itemName || "None" - : "None" - - // 폼 목록에서 고유한 폼 이름만 추출 - const formNames = React.useMemo(() => { - return [...new Set(formList.map((form) => form.formName))] - }, [formList]) - - // URL에서 현재 폼 코드 추출 - const getCurrentFormCode = (path: string): string | null => { - const segments = path.split("/").filter(Boolean) - const formIndex = segments.indexOf("form") - if (formIndex !== -1 && segments[formIndex + 2]) { - return segments[formIndex + 2] - } - return null - } - - const currentFormCode = React.useMemo(() => { - return pathname ? getCurrentFormCode(pathname) : null - }, [pathname]) - // URL에서 모드가 변경되면 상태도 업데이트 (ship 프로젝트가 아닐 때만) + // URL 변경 시 상태 동기화 React.useEffect(() => { - if (!isShipProject) { - const modeFromUrl = searchParams?.get('mode') - if (modeFromUrl === "ENG" || modeFromUrl === "IM") { - setSelectedMode(modeFromUrl) + const { projectCode, packageCode, formCode, mode } = getInfoFromPathname(pathname) + + if (projectCode && packageCode) { + // 프로젝트 찾기 + const project = projects.find(p => p.projectCode === projectCode) + if (project) { + setSelectedProjectId(project.projectId) + setSelectedPackageCode(packageCode) } } - }, [searchParams, isShipProject]) - - // 프로젝트 타입이 변경될 때 모드 업데이트 - React.useEffect(() => { - if (isShipProject) { - setSelectedMode("ENG") - - // URL 모드 파라미터도 업데이트 - const url = new URL(window.location.href); - url.searchParams.set('mode', 'ENG'); - router.replace(url.pathname + url.search); + + if (formCode) { + setSelectedFormCode(formCode) + } else { + setSelectedFormCode(null) } - }, [isShipProject, router]) - - // (1) 새로고침 시 URL 파라미터(tagIdNumber) → selectedPackageId 세팅 - React.useEffect(() => { - if (!currentContract) return - - if (tagIdNumber) { - setSelectedPackageId(tagIdNumber) + + if (mode) { + setCurrentMode(mode) } else { - // tagIdNumber가 없으면, 현재 계약의 첫 번째 패키지로 - if (currentContract.packages?.length) { - setSelectedPackageId(currentContract.packages[0].itemId) - } else { - setSelectedPackageId(null) - } + setCurrentMode(null) } - }, [tagIdNumber, currentContract]) - - // (2) 프로젝트 변경 시 계약 초기화 - // React.useEffect(() => { - // if (currentProject?.contracts.length) { - // setSelectedContractId(currentProject.contracts[0].contractId) - // } else { - // setSelectedContractId(0) - // } - // }, [currentProject]) + }, [pathname, projects]) + + // 베이스 URL 생성 헬퍼 + const getBaseUrl = () => { + const segments = pathname?.split("/").filter(Boolean) || [] + const vendorDataIndex = segments.indexOf("vendor-data-plant") + if (vendorDataIndex === -1) return "" + return "/" + segments.slice(0, vendorDataIndex + 1).join("/") + } - // (3) 패키지 ID와 모드가 변경될 때마다 폼 로딩 - React.useEffect(() => { - const packageId = getTagIdFromPathname(pathname) + // 프로젝트 및 패키지 선택 핸들러 + const handleSelectPackage = (projectId: number, packageCode: string) => { + const project = projects.find(p => p.projectId === projectId) + if (!project) return - if (packageId) { - setSelectedPackageId(packageId) - - // URL에서 패키지 ID를 얻었을 때 즉시 폼 로드 - loadFormsList(packageId, selectedMode); - } else if (currentContract?.packages?.length) { - const firstPackageId = currentContract.packages[0].itemId; - setSelectedPackageId(firstPackageId); - loadFormsList(firstPackageId, selectedMode); - } - }, [pathname, currentContract, selectedMode]) - - // 모드에 따른 폼 로드 함수 - const loadFormsList = async (packageId: number, mode: "IM" | "ENG") => { - if (!packageId) return; + setSelectedProjectId(projectId) + setSelectedPackageCode(packageCode) + setSelectedFormCode(null) + setCurrentMode("master") - setIsLoadingForms(true); - try { - const result = await getFormsByContractItemId(packageId, mode); - setFormList(result.forms || []); - } catch (error) { - console.error(`폼 로딩 오류 (${mode} 모드):`, error); - setFormList([]); - } finally { - setIsLoadingForms(false); - } - }; - - // 핸들러들 -// 수정된 handleSelectContract 함수 -async function handleSelectContract(projId: number, cId: number) { - setSelectedProjectId(projId) - setSelectedContractId(cId) - - // 선택된 계약의 첫 번째 패키지 찾기 - const selectedProject = projects.find(p => p.projectId === projId) - const selectedContract = selectedProject?.contracts.find(c => c.contractId === cId) + const baseUrl = getBaseUrl() + router.push(`${baseUrl}/${project.projectCode}/${packageCode}`) + } - if (selectedContract?.packages?.length) { - const firstPackageId = selectedContract.packages[0].itemId - setSelectedPackageId(firstPackageId) + // Master Tag List 클릭 핸들러 + const handleMasterTagListClick = () => { + if (!selectedPackageCode) return - // ENG 모드로 폼 목록 로드 - setIsLoadingForms(true) - try { - const result = await getFormsByContractItemId(firstPackageId, "ENG") - setFormList(result.forms || []) - - // 첫 번째 폼이 있으면 자동 선택 및 네비게이션 - if (result.forms && result.forms.length > 0) { - const firstForm = result.forms[0] - setSelectedFormCode(firstForm.formCode) - - // ENG 모드로 설정 - setSelectedMode("ENG") - - // 첫 번째 폼으로 네비게이션 - const baseSegments = pathname?.split("/").filter(Boolean).slice(0, pathname.split("/").filter(Boolean).indexOf("vendor-data-plant") + 1).join("/") - router.push(`/${baseSegments}/form/0/${firstForm.formCode}/${projId}/${cId}?mode=ENG`) - } else { - // 폼이 없는 경우에도 ENG 모드로 설정 - setSelectedMode("ENG") - setSelectedFormCode(null) - - const baseSegments = pathname?.split("/").filter(Boolean).slice(0, pathname.split("/").filter(Boolean).indexOf("vendor-data-plant") + 1).join("/") - router.push(`/${baseSegments}/form/0/0/${projId}/${cId}?mode=ENG`) - } - } catch (error) { - console.error("폼 로딩 오류:", error) - setFormList([]) - setSelectedFormCode(null) - - // 오류 발생 시에도 ENG 모드로 설정 - setSelectedMode("ENG") - } finally { - setIsLoadingForms(false) - } - } else { - // 패키지가 없는 경우 - setSelectedPackageId(null) - setFormList([]) + const project = projects.find(p => p.projectId === selectedProjectId) + if (!project) return + + setCurrentMode("master") setSelectedFormCode(null) - setSelectedMode("ENG") - } -} - - function handleSelectPackage(itemId: number) { - setSelectedPackageId(itemId) - } - - function handleSelectForm(formName: string) { - const form = formList.find((f) => f.formName === formName) - if (form) { - setSelectedFormCode(form.formCode) - } + + const baseUrl = getBaseUrl() + router.push(`${baseUrl}/${project.projectCode}/${selectedPackageCode}`) } - - // 모드 변경 핸들러 -// 모드 변경 핸들러 -const handleModeChange = async (mode: "IM" | "ENG") => { - // ship 프로젝트인 경우 모드 변경 금지 - if (isShipProject && mode !== "ENG") return; - setSelectedMode(mode); + // Engineering 폼 클릭 핸들러 + const handleEngineeringFormClick = (formCode: string) => { + if (!selectedPackageCode) return + + const project = projects.find(p => p.projectId === selectedProjectId) + if (!project) return + + setCurrentMode("engineering") + setSelectedFormCode(formCode) + + const baseUrl = getBaseUrl() + router.push(`${baseUrl}/${project.projectCode}/${selectedPackageCode}/eng/${formCode}`) + } - // 모드가 변경될 때 자동 네비게이션 - if (currentContract?.packages?.length) { - const firstPackageId = currentContract.packages[0].itemId; + // IM 폼 클릭 핸들러 + const handleIMFormClick = (formCode: string) => { + if (!selectedPackageCode) return - if (mode === "IM") { - // IM 모드: 첫 번째 패키지로 이동 - const baseSegments = pathname?.split("/").filter(Boolean).slice(0, pathname.split("/").filter(Boolean).indexOf("vendor-data-plant") + 1).join("/"); - router.push(`/${baseSegments}/tag/${firstPackageId}?mode=${mode}`); - } else { - // ENG 모드: 폼 목록을 먼저 로드 - setIsLoadingForms(true); - try { - const result = await getFormsByContractItemId(firstPackageId, mode); - setFormList(result.forms || []); - - // 폼이 있으면 첫 번째 폼으로 이동 - if (result.forms && result.forms.length > 0) { - const firstForm = result.forms[0]; - setSelectedFormCode(firstForm.formCode); - - const baseSegments = pathname?.split("/").filter(Boolean).slice(0, pathname.split("/").filter(Boolean).indexOf("vendor-data-plant") + 1).join("/"); - router.push(`/${baseSegments}/form/0/${firstForm.formCode}/${selectedProjectId}/${selectedContractId}?mode=${mode}`); - } else { - // 폼이 없으면 모드만 변경 - const baseSegments = pathname?.split("/").filter(Boolean).slice(0, pathname.split("/").filter(Boolean).indexOf("vendor-data-plant") + 1).join("/"); - router.push(`/${baseSegments}/form/0/0/${selectedProjectId}/${selectedContractId}?mode=${mode}`); - } - } catch (error) { - console.error(`폼 로딩 오류 (${mode} 모드):`, error); - // 오류 발생 시 모드만 변경 - const url = new URL(window.location.href); - url.searchParams.set('mode', mode); - router.replace(url.pathname + url.search); - } finally { - setIsLoadingForms(false); - } - } - } else { - // 패키지가 없는 경우, 모드만 변경 - const url = new URL(window.location.href); - url.searchParams.set('mode', mode); - router.replace(url.pathname + url.search); + const project = projects.find(p => p.projectId === selectedProjectId) + if (!project) return + + setCurrentMode("im") + setSelectedFormCode(formCode) + + const baseUrl = getBaseUrl() + router.push(`${baseUrl}/${project.projectCode}/${selectedPackageCode}/im/${formCode}`) } -}; return ( <TooltipProvider delayDuration={0}> @@ -351,151 +197,28 @@ const handleModeChange = async (mode: "IM" | "ENG") => { <ProjectSwitcher isCollapsed={isCollapsed} projects={projects} - selectedContractId={selectedContractId} - onSelectContract={handleSelectContract} + selectedProjectId={selectedProjectId} + selectedPackageCode={selectedPackageCode} + onSelectPackage={handleSelectPackage} /> </div> <Separator /> - {!isCollapsed ? ( - isShipProject ? ( - // 프로젝트 타입이 ship인 경우: 탭 없이 ENG 모드 사이드바만 바로 표시 - <div className="mt-0"> - <Sidebar - isCollapsed={isCollapsed} - packages={currentContract?.packages || []} - selectedPackageId={selectedPackageId} - selectedProjectId={selectedProjectId} - selectedContractId={selectedContractId} - onSelectPackage={handleSelectPackage} - forms={formList} - selectedForm={ - selectedFormCode - ? formList.find((f) => f.formCode === selectedFormCode)?.formName || null - : null - } - onSelectForm={handleSelectForm} - isLoadingForms={isLoadingForms} - mode="ENG" - className="hidden lg:block" - /> - </div> - ) : ( - // 프로젝트 타입이 ship이 아닌 경우: 기존 탭 UI 표시 - <Tabs - defaultValue={initialMode} - value={selectedMode} - onValueChange={(value) => handleModeChange(value as "IM" | "ENG")} - className="w-full" - > - <TabsList className="w-full"> - <TabsTrigger value="ENG" className="flex-1">Engineering</TabsTrigger> - <TabsTrigger value="IM" className="flex-1">Handover</TabsTrigger> - - </TabsList> - - <TabsContent value="IM" className="mt-0"> - <Sidebar - isCollapsed={isCollapsed} - packages={currentContract?.packages || []} - selectedPackageId={selectedPackageId} - selectedContractId={selectedContractId} - selectedProjectId={selectedProjectId} - onSelectPackage={handleSelectPackage} - forms={formList} - selectedForm={ - selectedFormCode - ? formList.find((f) => f.formCode === selectedFormCode)?.formName || null - : null - } - onSelectForm={handleSelectForm} - isLoadingForms={isLoadingForms} - mode="IM" - className="hidden lg:block" - /> - </TabsContent> - - <TabsContent value="ENG" className="mt-0"> - <Sidebar - isCollapsed={isCollapsed} - packages={currentContract?.packages || []} - selectedPackageId={selectedPackageId} - selectedContractId={selectedContractId} - selectedProjectId={selectedProjectId} - onSelectPackage={handleSelectPackage} - forms={formList} - selectedForm={ - selectedFormCode - ? formList.find((f) => f.formCode === selectedFormCode)?.formName || null - : null - } - onSelectForm={handleSelectForm} - isLoadingForms={isLoadingForms} - mode="ENG" - className="hidden lg:block" - /> - </TabsContent> - </Tabs> - ) - ) : ( - // 접혀있을 때 UI - <> - {!isShipProject && ( - // ship 프로젝트가 아닐 때만 모드 선택 버튼 표시 - <div className="flex justify-center space-x-1 my-2"> - - <Button - variant={selectedMode === "ENG" ? "default" : "ghost"} - size="sm" - className="h-8 px-2" - onClick={() => handleModeChange("ENG")} - > - Engineering - </Button> - <Button - variant={selectedMode === "IM" ? "default" : "ghost"} - size="sm" - className="h-8 px-2" - onClick={() => handleModeChange("IM")} - > - Handover - </Button> - </div> - )} - - <Sidebar - isCollapsed={isCollapsed} - packages={currentContract?.packages || []} - selectedPackageId={selectedPackageId} - selectedProjectId={selectedProjectId} - selectedContractId={selectedContractId} - onSelectPackage={handleSelectPackage} - forms={formList} - selectedForm={ - selectedFormCode - ? formList.find((f) => f.formCode === selectedFormCode)?.formName || null - : null - } - onSelectForm={handleSelectForm} - isLoadingForms={isLoadingForms} - mode={isShipProject ? "ENG" : selectedMode} - className="hidden lg:block" - /> - </> - )} + <Sidebar + isCollapsed={isCollapsed} + selectedPackageCode={selectedPackageCode} + selectedFormCode={selectedFormCode} + currentMode={currentMode} + onMasterTagListClick={handleMasterTagListClick} + onEngineeringFormClick={handleEngineeringFormClick} + onIMFormClick={handleIMFormClick} + /> </ResizablePanel> <ResizableHandle withHandle /> <ResizablePanel defaultSize={defaultLayout[1]} minSize={40}> <div className="p-4 h-full overflow-auto flex flex-col"> - <div className="flex items-center justify-between mb-4"> - <h2 className="text-lg font-bold"> - {isShipProject || selectedMode === "ENG" - ? "Engineering Mode" - : `Package: ${currentPackageName}`} - </h2> - </div> {children} </div> </ResizablePanel> diff --git a/db/schema/vendorData.ts b/db/schema/vendorData.ts index c3df6b53..5301e61a 100644 --- a/db/schema/vendorData.ts +++ b/db/schema/vendorData.ts @@ -41,6 +41,27 @@ export const forms = pgTable("forms", { } }) +export const formsPlant = pgTable("forms_plant", { + id: integer("id").primaryKey().generatedAlwaysAsIdentity(), + projectCode: varchar("project_code", { length: 100 }).notNull(), + packageCode: varchar("package_code", { length: 100 }).notNull(), + formCode: varchar("form_code", { length: 100 }).notNull(), + formName: varchar("form_name", { length: 255 }).notNull(), + // source: varchar("source", { length: 255 }), + // 새로 추가된 칼럼: eng와 im + eng: boolean("eng").default(false).notNull(), + im: boolean("im").default(false).notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), +}, (table) => { + return { + projectItemFormCodeUnique: uniqueIndex("project_item_form_code_unique").on( + table.projectCode, + table.formCode + ), + } +}) + // formMetas에 projectId 추가 export const formMetas = pgTable("form_metas", { id: serial("id").primaryKey(), @@ -73,6 +94,16 @@ export const formEntries = pgTable("form_entries", { updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), }) +export const formEntriesPlant = pgTable("form_entries_plant", { + id: serial("id").primaryKey(), + formCode: varchar("form_code", { length: 50 }).notNull(), + data: jsonb("data").notNull(), + projectCode: varchar("project_code", { length: 100 }).notNull(), + packageCode: varchar("package_code", { length: 100 }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), +}) + export const tags = pgTable("tags", { id: integer("id").primaryKey().generatedAlwaysAsIdentity(), contractItemId: integer("contract_item_id") @@ -108,6 +139,42 @@ export const tags = pgTable("tags", { }; }); + +export const tagsPlant = pgTable("tags_plant", { + id: integer("id").primaryKey().generatedAlwaysAsIdentity(), + + // SEDP에서 오는 고유 식별자 + tagIdx: varchar("tag_idx", { length: 100 }).notNull(), + + // 사용자가 편집 가능한 태그 번호 + tagNo: varchar("tag_no", { length: 100 }).notNull(), + + tagType: varchar("tag_type", { length: 50 }).notNull(), + class: varchar("class", { length: 100 }).notNull(), + tagClassId: integer("tag_class_id") + .references(() => tagClasses.id, { onDelete: "set null" }), + description: text("description"), + + // 동적 속성을 저장할 JSONB 컬럼 추가 + attributes: jsonb("attributes").$type<Record<string, string>>(), + + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), + + projectCode: varchar("project_code", { length: 100 }).notNull(), + packageCode: varchar("package_code", { length: 100 }).notNull(), + formId: integer("form_id"), + +}, (table) => { + return { + projectPackageTagIdxUnique: uniqueIndex("project_package_tag_idx_unique").on( + table.projectCode, + table.packageCode, + table.tagIdx + ), + } +}) + // tagTypes에 projectId 추가 및 복합 기본키 생성 export const tagTypes = pgTable("tag_types", { code: varchar("code", { length: 50 }).notNull(), @@ -334,6 +401,26 @@ export const vendorDataReportTemps = pgTable("vendor_data_report_temps", { export type VendorDataReportTemps = typeof vendorDataReportTemps.$inferSelect; +export const vendorDataReportTempsPlant = pgTable("vendor_data_report_temps_plant", { + id: serial("id").primaryKey(), + + projectCode: varchar("project_code", { length: 100 }).notNull(), + packageCode: varchar("package_code", { length: 100 }).notNull(), + formId: integer("form_id") + .notNull() + .references(() => forms.id, { onDelete: "cascade" }), + fileName: varchar("file_name", { length: 255 }).notNull(), + filePath: varchar("file_path", { length: 1024 }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .notNull(), +}); + +export type VendorDataReportTempsPlant = typeof vendorDataReportTempsPlant.$inferSelect; + export const formListsView = pgView("form_lists_view").as((qb) => { return qb diff --git a/lib/forms-plant/services.ts b/lib/forms-plant/services.ts index 219f36e4..7e1976e6 100644 --- a/lib/forms-plant/services.ts +++ b/lib/forms-plant/services.ts @@ -7,18 +7,18 @@ import fs from "fs/promises"; import { v4 as uuidv4 } from "uuid"; import db from "@/db/db"; import { - formEntries, - formMetas, + formEntries,formEntriesPlant, + formMetas,formsPlant, forms, tagClassAttributes, tagClasses, - tags, + tags,tagsPlant, tagSubfieldOptions, tagSubfields, tagTypeClassFormMappings, tagTypes, - vendorDataReportTemps, - VendorDataReportTemps, + vendorDataReportTempsPlant, + VendorDataReportTempsPlant, } from "@/db/schema/vendorData"; import { eq, and, desc, sql, DrizzleError, inArray, or, type SQL, type InferSelectModel } from "drizzle-orm"; import { unstable_cache } from "next/cache"; @@ -29,6 +29,7 @@ import { contractItems, contracts, items, projects } from "@/db/schema"; import { getSEDPToken } from "../sedp/sedp-token"; import { decryptWithServerAction } from "@/components/drm/drmUtils"; import { deleteFile, saveFile } from "@/lib/file-stroage"; +import { Register } from "@/components/form-data-plant/form-data-table-columns"; export type FormInfo = InferSelectModel<typeof forms>; @@ -164,7 +165,8 @@ export interface EditableFieldsInfo { // TAG별 편집 가능 필드 조회 함수 export async function getEditableFieldsByTag( - contractItemId: number, + projectCode: string, + packageCode: string, projectId: number ): Promise<Map<string, string[]>> { try { @@ -174,8 +176,11 @@ export async function getEditableFieldsByTag( tagNo: tags.tagNo, tagClass: tags.class }) - .from(tags) - .where(eq(tags.contractItemId, contractItemId)); + .from(tagsPlant) + .where( + eq(tagsPlant.projectCode, projectCode), + eq(tagsPlant.packageCode, packageCode), + ); const editableFieldsMap = new Map<string, string[]>(); @@ -228,26 +233,17 @@ export async function getEditableFieldsByTag( * data가 배열이면 그 배열을 반환, * 그리고 이 로직 전체를 unstable_cache로 감싸 캐싱. */ -export async function getFormData(formCode: string, contractItemId: number) { +export async function getFormData(formCode: string, projectCode: string, packageCode:string) { try { - // 기존 로직으로 projectId, columns, data 가져오기 - const contractItemResult = await db - .select({ - projectId: projects.id - }) - .from(contractItems) - .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) - .innerJoin(projects, eq(contracts.projectId, projects.id)) - .where(eq(contractItems.id, contractItemId)) - .limit(1); - - if (contractItemResult.length === 0) { - console.warn(`[getFormData] No contract item found with ID: ${contractItemId}`); - return { columns: null, data: [], editableFieldsMap: new Map() }; - } + const project = await db.query.projects.findFirst({ + where: eq(projects.code, projectCode), + columns: { + id: true + } + }); - const projectId = contractItemResult[0].projectId; + const projectId = project.id; const metaRows = await db .select() @@ -269,14 +265,15 @@ export async function getFormData(formCode: string, contractItemId: number) { const entryRows = await db .select() - .from(formEntries) + .from(formEntriesPlant) .where( and( - eq(formEntries.formCode, formCode), - eq(formEntries.contractItemId, contractItemId) + eq(formEntriesPlant.formCode, formCode), + eq(formEntriesPlant.projectCode, projectCode), + eq(formEntriesPlant.packageCode, packageCode), ) ) - .orderBy(desc(formEntries.updatedAt)) + .orderBy(desc(formEntriesPlant.updatedAt)) .limit(1); const entry = entryRows[0] ?? null; @@ -321,7 +318,7 @@ export async function getFormData(formCode: string, contractItemId: number) { } // *** 새로 추가: 편집 가능 필드 정보 계산 *** - const editableFieldsMap = await getEditableFieldsByTag(contractItemId, projectId); + const editableFieldsMap = await getEditableFieldsByTag(projectCode,packageCode ,projectId); return { columns, data, editableFieldsMap }; @@ -331,24 +328,16 @@ export async function getFormData(formCode: string, contractItemId: number) { // Fallback logic (기존과 동일하게 editableFieldsMap 추가) try { - console.log(`[getFormData] Fallback DB query for (${formCode}, ${contractItemId})`); - - const contractItemResult = await db - .select({ - projectId: projects.id - }) - .from(contractItems) - .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) - .innerJoin(projects, eq(contracts.projectId, projects.id)) - .where(eq(contractItems.id, contractItemId)) - .limit(1); + console.log(`[getFormData] Fallback DB query for (${formCode}, ${packageCode})`); - if (contractItemResult.length === 0) { - console.warn(`[getFormData] Fallback: No contract item found with ID: ${contractItemId}`); - return { columns: null, data: [], editableFieldsMap: new Map() }; - } + const project = await db.query.projects.findFirst({ + where: eq(projects.code, projectCode), + columns: { + id: true + } + }); - const projectId = contractItemResult[0].projectId; + const projectId = project.id; const metaRows = await db .select() @@ -370,14 +359,15 @@ export async function getFormData(formCode: string, contractItemId: number) { const entryRows = await db .select() - .from(formEntries) + .from(formEntriesPlant) .where( and( - eq(formEntries.formCode, formCode), - eq(formEntries.contractItemId, contractItemId) + eq(formEntriesPlant.formCode, formCode), + eq(formEntriesPlant.projectCode, projectCode), + eq(formEntriesPlant.packageCode, packageCode) ) ) - .orderBy(desc(formEntries.updatedAt)) + .orderBy(desc(formEntriesPlant.updatedAt)) .limit(1); const entry = entryRows[0] ?? null; @@ -406,7 +396,7 @@ export async function getFormData(formCode: string, contractItemId: number) { } // Fallback에서도 편집 가능 필드 정보 계산 - const editableFieldsMap = await getEditableFieldsByTag(contractItemId, projectId); + const editableFieldsMap = await getEditableFieldsByTag(projectCode, packageCode, projectId); return { columns, data, projectId, editableFieldsMap }; } catch (dbError) { @@ -415,7 +405,7 @@ export async function getFormData(formCode: string, contractItemId: number) { } } } -/**1 +/** * contractId와 formCode(itemCode)를 사용하여 contractItemId를 찾는 서버 액션 * * @param contractId - 계약 ID @@ -517,24 +507,26 @@ export async function getPackageCodeById(contractItemId: number): Promise<string export async function syncMissingTags( - contractItemId: number, + projectCode: string, + packageCode: string, formCode: string ) { // (1) Ensure there's a row in `forms` matching (contractItemId, formCode). const [formRow] = await db .select() - .from(forms) + .from(formsPlant) .where( and( - eq(forms.contractItemId, contractItemId), - eq(forms.formCode, formCode) + eq(formsPlant.projectCode, projectCode), + eq(formsPlant.packageCode, packageCode), + eq(formsPlant.formCode, formCode) ) ) .limit(1); if (!formRow) { throw new Error( - `Form not found for contractItemId=${contractItemId}, formCode=${formCode}` + `Form not found for projectCode=${projectCode}, formCode=${formCode}` ); } @@ -558,26 +550,28 @@ export async function syncMissingTags( // (3) Fetch all matching `tags` for the contractItemId + any of the (tagType, class) pairs. const tagRows = await db .select() - .from(tags) - .where(and(eq(tags.contractItemId, contractItemId), or(...orConditions))); + .from(tagsPlant) + .where(and(eq(tagsPlant.packageCode, packageCode),eq(tagsPlant.projectCode, projectCode), or(...orConditions))); // (4) Fetch (or create) a single `formEntries` row for (contractItemId, formCode). let [entry] = await db .select() - .from(formEntries) + .from(formEntriesPlant) .where( and( - eq(formEntries.contractItemId, contractItemId), - eq(formEntries.formCode, formCode) + eq(formEntriesPlant.packageCode, packageCode), + eq(formEntriesPlant.projectCode, projectCode), + eq(formEntriesPlant.formCode, formCode) ) ) .limit(1); if (!entry) { const [inserted] = await db - .insert(formEntries) + .insert(formEntriesPlant) .values({ - contractItemId, + projectCode, + packageCode, formCode, data: [], // Initialize with empty array }) @@ -646,13 +640,13 @@ export async function syncMissingTags( // (6) 실제로 추가되거나 수정되거나 삭제된 게 있다면 DB에 반영 if (createdCount > 0 || updatedCount > 0 || deletedCount > 0) { await db - .update(formEntries) + .update(formEntriesPlant) .set({ data: updatedData }) - .where(eq(formEntries.id, entry.id)); + .where(eq(formEntriesPlant.id, entry.id)); } // 캐시 무효화 등 후처리 - revalidateTag(`form-data-${formCode}-${contractItemId}`); + // revalidateTag(`form-data-${formCode}-${projectCode}`); return { createdCount, updatedCount, deletedCount }; } @@ -681,7 +675,8 @@ export interface UpdateResponse { export async function updateFormDataInDB( formCode: string, - contractItemId: number, + projectCode: string, + packageCode: string, newData: Record<string, any> ): Promise<UpdateResponse> { try { @@ -697,11 +692,12 @@ export async function updateFormDataInDB( // 2) row 찾기 (단 하나) const entries = await db .select() - .from(formEntries) + .from(formEntriesPlant) .where( and( eq(formEntries.formCode, formCode), - eq(formEntries.contractItemId, contractItemId) + eq(formEntries.projectCode, projectCode), + eq(formEntries.packageCode, packageCode), ) ) .limit(1); @@ -756,12 +752,12 @@ export async function updateFormDataInDB( // 6) DB UPDATE try { await db - .update(formEntries) + .update(formEntriesPlant) .set({ data: updatedArray, updatedAt: new Date(), // 업데이트 시간도 갱신 }) - .where(eq(formEntries.id, entry.id)); + .where(eq(formEntriesPlant.id, entry.id)); } catch (dbError) { console.error("Database update error:", dbError); @@ -781,7 +777,7 @@ export async function updateFormDataInDB( // 7) Cache 무효화 try { // 캐시 태그를 form-data-${formCode}-${contractItemId} 형태로 가정 - const cacheTag = `form-data-${formCode}-${contractItemId}`; + const cacheTag = `form-data-${formCode}-${projectCode}`; console.log(cacheTag, "update") revalidateTag(cacheTag); } catch (cacheError) { @@ -814,7 +810,8 @@ export async function updateFormDataInDB( export async function updateFormDataBatchInDB( formCode: string, - contractItemId: number, + projectCode: string, + packageCode: string, newDataArray: Record<string, any>[] ): Promise<UpdateResponse> { try { @@ -839,11 +836,12 @@ export async function updateFormDataBatchInDB( // 1) DB에서 현재 데이터 가져오기 const entries = await db .select() - .from(formEntries) + .from(formEntriesPlant) .where( and( - eq(formEntries.formCode, formCode), - eq(formEntries.contractItemId, contractItemId) + eq(formEntriesPlant.formCode, formCode), + eq(formEntriesPlant.projectCode, projectCode), + eq(formEntriesPlant.packageCode, packageCode), ) ) .limit(1); @@ -851,7 +849,7 @@ export async function updateFormDataBatchInDB( if (!entries || entries.length === 0) { return { success: false, - message: `폼 데이터를 찾을 수 없습니다. (formCode=${formCode}, contractItemId=${contractItemId})`, + message: `폼 데이터를 찾을 수 없습니다. (formCode=${formCode}, projectCode=${projectCode})`, }; } @@ -918,12 +916,12 @@ export async function updateFormDataBatchInDB( // 3) DB에 한 번만 저장 try { await db - .update(formEntries) + .update(formEntriesPlant) .set({ data: updatedArray, updatedAt: new Date(), }) - .where(eq(formEntries.id, entry.id)); + .where(eq(formEntriesPlant.id, entry.id)); } catch (dbError) { console.error("Database update error:", dbError); @@ -952,7 +950,7 @@ export async function updateFormDataBatchInDB( // 4) 캐시 무효화 try { - const cacheTag = `form-data-${formCode}-${contractItemId}`; + const cacheTag = `form-data-${formCode}-${projectCode}`; console.log(`Cache invalidated: ${cacheTag}`); revalidateTag(cacheTag); } catch (cacheError) { @@ -1043,26 +1041,37 @@ export async function fetchFormMetadata( } type GetReportFileList = ( - packageId: string, - formCode: string + projectCode: string, + packageCode: string, + formCode: string, + mode: string ) => Promise<{ formId: number; }>; -export const getFormId: GetReportFileList = async (packageId, formCode) => { +export const getFormId: GetReportFileList = async (projectCode, packageCode, formCode, mode) => { const result: { formId: number } = { formId: 0, }; try { + // mode에 따른 조건 배열 생성 + const conditions = [ + eq(formsPlant.formCode, formCode), + eq(formsPlant.projectCode, projectCode), + eq(formsPlant.packageCode, packageCode), + ]; + + // mode에 따라 추가 조건 설정 + if (mode === "IM") { + conditions.push(eq(formsPlant.im, true)); + } else if (mode === "ENG") { + conditions.push(eq(formsPlant.eng, true)); + } + const [targetForm] = await db .select() - .from(forms) - .where( - and( - eq(forms.formCode, formCode), - eq(forms.contractItemId, Number(packageId)) - ) - ); + .from(formsPlant) + .where(and(...conditions)); if (!targetForm) { throw new Error("Not Found Target Form"); @@ -1072,30 +1081,34 @@ export const getFormId: GetReportFileList = async (packageId, formCode) => { result.formId = formId; } catch (err) { + console.error("Error getting form ID:", err); } finally { return result; } }; type getReportTempList = ( - packageId: number, + projectCode: string, + packageCode: string, formId: number -) => Promise<VendorDataReportTemps[]>; +) => Promise<VendorDataReportTempsPlant[]>; export const getReportTempList: getReportTempList = async ( - packageId, + projectCode, + packageCode, formId ) => { - let result: VendorDataReportTemps[] = []; + let result: VendorDataReportTempsPlant[] = []; try { result = await db .select() - .from(vendorDataReportTemps) + .from(vendorDataReportTempsPlant) .where( and( - eq(vendorDataReportTemps.contractItemId, packageId), - eq(vendorDataReportTemps.formId, formId) + eq(vendorDataReportTempsPlant.projectCode, projectCode), + eq(vendorDataReportTempsPlant.packageCode, packageCode), + eq(vendorDataReportTempsPlant.formId, formId) ) ); } catch (err) { @@ -1105,7 +1118,8 @@ export const getReportTempList: getReportTempList = async ( }; export async function uploadReportTemp( - packageId: number, + projectCode: string, + packageCode: string, formId: number, formData: FormData ) { @@ -1128,9 +1142,10 @@ export async function uploadReportTemp( return db.transaction(async (tx) => { // 파일 정보를 테이블에 저장 await tx - .insert(vendorDataReportTemps) + .insert(vendorDataReportTempsPlant) .values({ - contractItemId: packageId, + projectCode, + packageCode, formId: formId, fileName: customFileName, filePath: saveResult.publicPath!, @@ -1160,16 +1175,16 @@ export const deleteReportTempFile: deleteReportTempFile = async (id) => { return db.transaction(async (tx) => { const [targetTempFile] = await tx .select() - .from(vendorDataReportTemps) - .where(eq(vendorDataReportTemps.id, id)); + .from(vendorDataReportTempsPlant) + .where(eq(vendorDataReportTempsPlant.id, id)); if (!targetTempFile) { throw new Error("해당 Template File을 찾을 수 없습니다."); } await tx - .delete(vendorDataReportTemps) - .where(eq(vendorDataReportTemps.id, id)); + .delete(vendorDataReportTempsPlant) + .where(eq(vendorDataReportTempsPlant.id, id)); const { filePath } = targetTempFile; @@ -1310,7 +1325,8 @@ async function transformDataToSEDPFormat( formCode: string, objectCode: string, projectNo: string, - contractItemId: number, // Add contractItemId parameter + packageCode: string, + contractItemId: string, designerNo: string = "253213" ): Promise<SEDPDataItem[]> { // Create a map for quick column lookup @@ -1331,9 +1347,6 @@ async function transformDataToSEDPFormat( // Cache for UOM factors to avoid duplicate API calls const uomFactorCache = new Map<string, number>(); - // Cache for packageCode to avoid duplicate DB queries for same tag - const packageCodeCache = new Map<string, string>(); - // Cache for tagClass code to avoid duplicate DB queries for same tag const tagClassCodeCache = new Map<string, string>(); @@ -1341,97 +1354,15 @@ async function transformDataToSEDPFormat( const transformedItems = []; for (const row of tableData) { + let tagClassCode = ""; - const cotractItem = await db.query.contractItems.findFirst({ - where: - eq(contractItems.id, contractItemId), - }); - - const item = await db.query.items.findFirst({ - where: - eq(items.id, cotractItem.itemId), - }); - - // Get packageCode for this specific tag - let packageCode = item.packageCode; // fallback to formCode - let tagClassCode = ""; // for CLS_ID - + // Get tagClass code if TAG_NO exists if (row.TAG_NO && contractItemId) { - // Check cache first const cacheKey = `${contractItemId}-${row.TAG_NO}`; - if (packageCodeCache.has(cacheKey)) { - packageCode = packageCodeCache.get(cacheKey)!; - } else { - try { - // Query to get packageCode for this specific tag - const tagResult = await db.query.tags.findFirst({ - where: and( - eq(tags.contractItemId, contractItemId), - eq(tags.tagNo, row.TAG_NO) - ) - }); - - if (tagResult) { - // Get tagClass code if tagClassId exists - if (tagResult.tagClassId) { - // Check tagClass cache first - if (tagClassCodeCache.has(cacheKey)) { - tagClassCode = tagClassCodeCache.get(cacheKey)!; - } else { - const tagClassResult = await db.query.tagClasses.findFirst({ - where: eq(tagClasses.id, tagResult.tagClassId) - }); - - if (tagClassResult) { - tagClassCode = tagClassResult.code; - console.log(`Found tagClass code for tag ${row.TAG_NO}: ${tagClassCode}`); - } else { - console.warn(`No tagClass found for tagClassId: ${tagResult.tagClassId}`); - } - - // Cache the tagClass code result - tagClassCodeCache.set(cacheKey, tagClassCode); - } - } - - // Get the contract item - const contractItemResult = await db.query.contractItems.findFirst({ - where: eq(contractItems.id, tagResult.contractItemId) - }); - - if (contractItemResult) { - // Get the first item with this itemId - const itemResult = await db.query.items.findFirst({ - where: eq(items.id, contractItemResult.itemId) - }); - - if (itemResult && itemResult.packageCode) { - packageCode = itemResult.packageCode; - console.log(`Found packageCode for tag ${row.TAG_NO}: ${packageCode}`); - } else { - console.warn(`No item found for contractItem.itemId: ${contractItemResult.itemId}, using fallback`); - } - } else { - console.warn(`No contractItem found for tag ${row.TAG_NO}, using fallback`); - } - } else { - console.warn(`No tag found for contractItemId: ${contractItemId}, tagNo: ${row.TAG_NO}, using fallback`); - } - - // Cache the result (even if it's the fallback value) - packageCodeCache.set(cacheKey, packageCode); - } catch (error) { - console.error(`Error fetching packageCode for tag ${row.TAG_NO}:`, error); - // Use fallback value and cache it - packageCodeCache.set(cacheKey, packageCode); - } - } - - // Get tagClass code if not already retrieved above - if (!tagClassCode && tagClassCodeCache.has(cacheKey)) { + if (tagClassCodeCache.has(cacheKey)) { tagClassCode = tagClassCodeCache.get(cacheKey)!; - } else if (!tagClassCode) { + } else { try { const tagResult = await db.query.tags.findFirst({ where: and( @@ -1440,22 +1371,20 @@ async function transformDataToSEDPFormat( ) }); - if (tagResult && tagResult.tagClassId) { + if (tagResult?.tagClassId) { const tagClassResult = await db.query.tagClasses.findFirst({ where: eq(tagClasses.id, tagResult.tagClassId) }); - + if (tagClassResult) { tagClassCode = tagClassResult.code; console.log(`Found tagClass code for tag ${row.TAG_NO}: ${tagClassCode}`); } } - // Cache the tagClass code result tagClassCodeCache.set(cacheKey, tagClassCode); } catch (error) { - console.error(`Error fetching tagClass code for tag ${row.TAG_NO}:`, error); - // Cache empty string as fallback + console.error(`Error fetching tagClass for tag ${row.TAG_NO}:`, error); tagClassCodeCache.set(cacheKey, ""); } } @@ -1466,17 +1395,16 @@ async function transformDataToSEDPFormat( TAG_NO: row.TAG_NO || "", TAG_DESC: row.TAG_DESC || "", ATTRIBUTES: [], - // SCOPE: objectCode, SCOPE: packageCode, - TOOLID: "eVCP", // Changed from VDCS + TOOLID: "eVCP", ITM_NO: row.TAG_NO || "", - OP_DELETE: row.status === "Deleted", // Set OP_DELETE based on status + OP_DELETE: row.status === "Deleted", MAIN_YN: true, LAST_REV_YN: true, CRTER_NO: designerNo, CHGER_NO: designerNo, - TYPE: formCode, // Use packageCode instead of formCode - CLS_ID: tagClassCode, // Add CLS_ID with tagClass code + TYPE: formCode, + CLS_ID: tagClassCode, PROJ_NO: projectNo, REV_NO: "00", CRTE_DTM: currentTimestamp, @@ -1522,7 +1450,7 @@ async function transformDataToSEDPFormat( const uomData = await response.json(); if (uomData && uomData.FACTOR !== undefined && uomData.FACTOR !== null) { factor = Number(uomData.FACTOR); - // Store in cache for future use (type assertion to ensure it's a number) + // Store in cache for future use uomFactorCache.set(column.uomId, factor); } } else { @@ -1533,7 +1461,7 @@ async function transformDataToSEDPFormat( } } - // Apply the factor if we got one + // Apply the factor if needed (currently commented out) // if (factor !== undefined && typeof value === 'number') { // value = value * factor; // } @@ -1541,7 +1469,7 @@ async function transformDataToSEDPFormat( const attribute: SEDPAttribute = { NAME: key, - VALUE: String(value), // 모든 값을 문자열로 변환 + VALUE: String(value), UOM: column?.uom || "", CLS_ID: tagClassCode || "", }; @@ -1569,7 +1497,7 @@ export async function transformFormDataToSEDP( formCode: string, objectCode: string, projectNo: string, - contractItemId: number, // Add contractItemId parameter + packageCode: string, // Add contractItemId parameter designerNo: string = "253213" ): Promise<SEDPDataItem[]> { return transformDataToSEDPFormat( @@ -1578,7 +1506,7 @@ export async function transformFormDataToSEDP( formCode, objectCode, projectNo, - contractItemId, // Pass contractItemId + packageCode, designerNo ); } @@ -1599,6 +1527,20 @@ export async function getProjectCodeById(projectId: number): Promise<string> { return projectRecord[0].code; } +export async function getProjectIdByCode(projectCode: string): Promise<number> { + const projectRecord = await db + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.code, projectCode)) + .limit(1); + + if (!projectRecord || projectRecord.length === 0) { + throw new Error(`Project not found with ID: ${projectId}`); + } + + return projectRecord[0].id; +} + export async function getProjectById(projectId: number): Promise<{ code: string; type: string; }> { const projectRecord = await db .select({ code: projects.code , type:projects.type}) @@ -1679,13 +1621,13 @@ export async function sendDataToSEDP( export async function sendFormDataToSEDP( formCode: string, projectId: number, - contractItemId: number, // contractItemId 파라미터 추가 + projectCode: string, // contractItemId 파라미터 추가 + packageCode: string, // contractItemId 파라미터 추가 formData: GenericData[], columns: DataTableColumnJSON[] ): Promise<{ success: boolean; message: string; data?: any }> { try { // 1. Get project code - const projectCode = await getProjectCodeById(projectId); // 2. Get class mapping const mappingsResult = await db.query.tagTypeClassFormMappings.findFirst({ @@ -1728,7 +1670,7 @@ export async function sendFormDataToSEDP( formCode, objectCode, projectCode, - contractItemId // Add contractItemId parameter + packageCode // Add contractItemId parameter ); // 4. Send to SEDP API @@ -1739,11 +1681,12 @@ export async function sendFormDataToSEDP( // Get the current formEntries data const entries = await db .select() - .from(formEntries) + .from(formEntriesPlant) .where( and( - eq(formEntries.formCode, formCode), - eq(formEntries.contractItemId, contractItemId) + eq(formEntriesPlant.formCode, formCode), + eq(formEntriesPlant.projectCode, projectCode), + eq(formEntriesPlant.packageCode, packageCode), ) ) .limit(1); @@ -1778,17 +1721,17 @@ export async function sendFormDataToSEDP( // Update the database await db - .update(formEntries) + .update(formEntriesPlant) .set({ data: updatedDataArray, updatedAt: new Date() }) - .where(eq(formEntries.id, entry.id)); + .where(eq(formEntriesPlant.id, entry.id)); console.log(`Updated status for ${sentTagNumbers.size} tags to "Sent to S-EDP"`); } } else { - console.warn(`No formEntries found for formCode: ${formCode}, contractItemId: ${contractItemId}`); + console.warn(`No formEntriesPlant found for formCode: ${formCode}`); } } catch (statusUpdateError) { // Status 업데이트 실패는 경고로만 처리 (SEDP 전송은 성공했으므로) @@ -1812,12 +1755,14 @@ export async function sendFormDataToSEDP( export async function deleteFormDataByTags({ formCode, - contractItemId, + projectCode, + packageCode, tagIdxs, projectId, }: { formCode: string - contractItemId: number + projectCode: string + packageCode: string tagIdxs: string[] projectId?: number }): Promise<{ @@ -1830,25 +1775,26 @@ export async function deleteFormDataByTags({ }> { try { // 입력 검증 - if (!formCode || !contractItemId || !Array.isArray(tagIdxs) || tagIdxs.length === 0) { + if (!formCode || !projectCode || !Array.isArray(tagIdxs) || tagIdxs.length === 0) { return { error: "Missing required parameters: formCode, contractItemId, tagIdxs", } } - console.log(`[DELETE ACTION] Deleting tags for formCode: ${formCode}, contractItemId: ${contractItemId}, tagIdxs:`, tagIdxs) + console.log(`[DELETE ACTION] Deleting tags for formCode: ${formCode}, projectCode: ${projectCode}, tagIdxs:`, tagIdxs) // 1. 트랜잭션 전에 삭제할 항목들을 미리 조회하여 저장 (S-EDP 전송용) const entryForSedp = await db .select() - .from(formEntries) + .from(formEntriesPlant) .where( and( - eq(formEntries.formCode, formCode), - eq(formEntries.contractItemId, contractItemId) + eq(formEntriesPlant.formCode, formCode), + eq(formEntriesPlant.projectCode, projectCode), + eq(formEntriesPlant.packageCode, packageCode) ) ) - .orderBy(desc(formEntries.updatedAt)) + .orderBy(desc(formEntriesPlant.updatedAt)) .limit(1) let itemsToSendToSedp: Record<string, unknown>[] = [] @@ -1868,14 +1814,15 @@ export async function deleteFormDataByTags({ // 2-1. 현재 formEntry 데이터 가져오기 const currentEntryResult = await tx .select() - .from(formEntries) + .from(formEntriesPlant) .where( and( - eq(formEntries.formCode, formCode), - eq(formEntries.contractItemId, contractItemId) + eq(formEntriesPlant.formCode, formCode), + eq(formEntriesPlant.projectCode, projectCode), + eq(formEntriesPlant.packageCode, packageCode) ) ) - .orderBy(desc(formEntries.updatedAt)) + .orderBy(desc(formEntriesPlant.updatedAt)) .limit(1) if (currentEntryResult.length === 0) { @@ -1903,14 +1850,15 @@ export async function deleteFormDataByTags({ // 2-3. tags 테이블에서 해당 태그들 삭제 const deletedTagsResult = await tx - .delete(tags) + .delete(tagsPlant) .where( and( - eq(tags.contractItemId, contractItemId), - inArray(tags.tagIdx, tagIdxs) + eq(tagsPlant.projectCode, projectCode), + eq(tagsPlant.packageCode, packageCode), + inArray(tagsPlant.tagIdx, tagIdxs) ) ) - .returning({ tagNo: tags.tagNo }) + .returning({ tagNo: tagsPlant.tagNo }) const deletedTagsCount = deletedTagsResult.length @@ -1919,15 +1867,16 @@ export async function deleteFormDataByTags({ // 2-4. formEntries 데이터 업데이트 (삭제된 항목 제외) await tx - .update(formEntries) + .update(formEntriesPlant) .set({ data: updatedData, updatedAt: new Date(), }) .where( and( - eq(formEntries.formCode, formCode), - eq(formEntries.contractItemId, contractItemId) + eq(formEntriesPlant.formCode, formCode), + eq(formEntriesPlant.projectCode, projectCode), + eq(formEntriesPlant.packageCode, packageCode), ) ) @@ -2020,7 +1969,8 @@ export async function deleteFormDataByTags({ const sedpResult = await sendFormDataToSEDP( formCode, projectId, - contractItemId, + projectCode, + packageCode, uniqueDeletedItems as GenericData[], formMetaResult.columns as DataTableColumnJSON[] ) @@ -2079,11 +2029,13 @@ export async function deleteFormDataByTags({ */ export async function excludeFormDataByTags({ formCode, - contractItemId, + projectCode, + packageCode, tagNumbers, }: { formCode: string - contractItemId: number + projectCode: string + packageCode: string tagNumbers: string[] }): Promise<{ error?: string @@ -2092,27 +2044,28 @@ export async function excludeFormDataByTags({ }> { try { // 입력 검증 - if (!formCode || !contractItemId || !Array.isArray(tagNumbers) || tagNumbers.length === 0) { + if (!formCode || !projectCode || !Array.isArray(tagNumbers) || tagNumbers.length === 0) { return { - error: "Missing required parameters: formCode, contractItemId, tagNumbers", + error: "Missing required parameters: formCode, projectCode, tagNumbers", } } - console.log(`[EXCLUDE ACTION] Excluding tags for formCode: ${formCode}, contractItemId: ${contractItemId}, tagNumbers:`, tagNumbers) + console.log(`[EXCLUDE ACTION] Excluding tags for formCode: ${formCode}, projectCode: ${projectCode}, tagNumbers:`, tagNumbers) // 트랜잭션으로 안전하게 처리 const result = await db.transaction(async (tx) => { // 1. 현재 formEntry 데이터 가져오기 const currentEntryResult = await tx .select() - .from(formEntries) + .from(formEntriesPlant) .where( and( - eq(formEntries.formCode, formCode), - eq(formEntries.contractItemId, contractItemId) + eq(formEntriesPlant.formCode, formCode), + eq(formEntriesPlant.projectCode, projectCode), + eq(formEntriesPlant.packageCode, packageCode) ) ) - .orderBy(desc(formEntries.updatedAt)) + .orderBy(desc(formEntriesPlant.updatedAt)) .limit(1) if (currentEntryResult.length === 0) { @@ -2146,15 +2099,16 @@ export async function excludeFormDataByTags({ // 3. formEntries 데이터 업데이트 await tx - .update(formEntries) + .update(formEntriesPlant) .set({ data: updatedData, updatedAt: new Date(), }) .where( and( - eq(formEntries.formCode, formCode), - eq(formEntries.contractItemId, contractItemId) + eq(formEntriesPlant.formCode, formCode), + eq(formEntriesPlant.projectCode, projectCode), + eq(formEntriesPlant.packageCode, packageCode) ) ) @@ -2165,7 +2119,7 @@ export async function excludeFormDataByTags({ }) // 4. 캐시 무효화 - const cacheKey = `form-data-${formCode}-${contractItemId}` + const cacheKey = `form-data-${formCode}-${packageCode}` revalidateTag(cacheKey) console.log(`[EXCLUDE ACTION] Transaction completed successfully`) diff --git a/lib/forms-plant/stat.ts b/lib/forms-plant/stat.ts index f13bab61..f734e782 100644 --- a/lib/forms-plant/stat.ts +++ b/lib/forms-plant/stat.ts @@ -1,7 +1,7 @@ "use server" import db from "@/db/db" -import { vendors, contracts, contractItems, forms, formEntries, formMetas, tags, tagClasses, tagClassAttributes, projects } from "@/db/schema" +import { vendors, contracts, contractItems, forms,formsPlant,formEntriesPlant, formEntries, formMetas, tags,tagsPlant, tagClasses, tagClassAttributes, projects } from "@/db/schema" import { eq, and, inArray } from "drizzle-orm" import { getEditableFieldsByTag } from "./services" import { getServerSession } from "next-auth/next" @@ -218,7 +218,7 @@ export async function getVendorFormStatus(projectId?: number): Promise<VendorFor -export async function getFormStatusByVendor(projectId: number, contractItemId: number, formCode: string): Promise<FormStatusByVendor[]> { +export async function getFormStatusByVendor(projectId: number, projectCode: string, packageCode: string, formCode: string): Promise<FormStatusByVendor[]> { try { const session = await getServerSession(authOptions) if (!session?.user?.id) { @@ -244,15 +244,16 @@ export async function getFormStatusByVendor(projectId: number, contractItemId: n // 4. contractItem별 forms 조회 const formsList = await db .select({ - id: forms.id, - formCode: forms.formCode, - contractItemId: forms.contractItemId + id: formsPlant.id, + formCode: formsPlant.formCode, + contractItemId: formsPlant.contractItemId }) - .from(forms) + .from(formsPlant) .where( and( - eq(forms.contractItemId, contractItemId), - eq(forms.formCode, formCode) + eq(formsPlant.projectCode, projectCode), + eq(formsPlant.packageCode, packageCode), + eq(formsPlant.formCode, formCode) ) ) @@ -261,20 +262,21 @@ export async function getFormStatusByVendor(projectId: number, contractItemId: n // 5. formEntries 조회 const entriesList = await db .select({ - id: formEntries.id, - formCode: formEntries.formCode, - data: formEntries.data + id: formEntriesPlant.id, + formCode: formEntriesPlant.formCode, + data: formEntriesPlant.data }) - .from(formEntries) + .from(formEntriesPlant) .where( and( - eq(formEntries.contractItemId, contractItemId), - eq(formEntries.formCode, formCode) + eq(formEntriesPlant.packageCode, packageCode), + eq(formEntriesPlant.projectCode, projectCode), + eq(formEntriesPlant.formCode, formCode) ) ) // 6. TAG별 편집 가능 필드 조회 - const editableFieldsByTag = await getEditableFieldsByTag(contractItemId, projectId) + const editableFieldsByTag = await getEditableFieldsByTag(projectCode,packageCode, projectId) const vendorStatusList: VendorFormStatus[] = [] diff --git a/lib/sedp/get-form-tags-plant.ts b/lib/sedp/get-form-tags-plant.ts new file mode 100644 index 00000000..176f1b3f --- /dev/null +++ b/lib/sedp/get-form-tags-plant.ts @@ -0,0 +1,933 @@ +import db from "@/db/db"; +import { + contractItems, + tagsPlant, + formsPlant,formEntriesPlant, + items, + tagTypeClassFormMappings, + projects, + tagTypes, + tagClasses, + formMetas, +} from "@/db/schema"; +import { eq, and, like, inArray } from "drizzle-orm"; +import { getSEDPToken } from "./sedp-token"; +import { getFormMappingsByTagTypebyProeject } from "../tags/form-mapping-service"; + + +interface Attribute { + ATT_ID: string; + VALUE: any; + VALUE_DBL: number; + UOM_ID: string | null; +} + +interface TagEntry { + TAG_IDX: string; + TAG_NO: string; + BF_TAG_NO: string; + TAG_DESC: string; + EP_ID: string; + TAG_TYPE_ID: string; + CLS_ID: string; + ATTRIBUTES: Attribute[]; + [key: string]: any; +} + +interface Column { + key: string; + label: string; + type: string; + shi?: string | null; +} + +interface newRegister { + PROJ_NO: string; + MAP_ID: string; + EP_ID: string; + CATEGORY: string; + BYPASS: boolean; + REG_TYPE_ID: string; + TOOL_ID: string; + TOOL_TYPE: string; + SCOPES: string[]; + MAP_CLS: { + TOOL_ATT_NAME: string; + ITEMS: ClassItmes[]; + }; + MAP_ATT: MapAttribute[]; + MAP_TMPLS: string[]; + CRTER_NO: string; + CRTE_DTM: string; + CHGER_NO: string; + _id: string; +} + +interface ClassItmes { + SEDP_OBJ_CLS_ID: string; + TOOL_VALS: string; + ISDEFALUT: boolean; +} + +interface MapAttribute { + SEDP_ATT_ID: string; + TOOL_ATT_NAME: string; + KEY_YN: boolean; + DUE_DATE: string; //"YYYY-MM-DDTHH:mm:ssZ" + INOUT: string | null; +} + + + +async function getNewRegisters(projectCode: string): Promise<newRegister[]> { + try { + // 토큰(API 키) 가져오기 + const apiKey = await getSEDPToken(); + + const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api'; + + const response = await fetch( + `${SEDP_API_BASE_URL}/AdapterDataMapping/GetByToolID`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'accept': '*/*', + 'ApiKey': apiKey, + 'ProjectNo': projectCode + }, + body: JSON.stringify({ + ProjectNo: projectCode, + "TOOL_ID": "eVCP" + }) + } + ); + + if (!response.ok) { + throw new Error(`새 레지스터 요청 실패: ${response.status} ${response.statusText}`); + } + + // 안전하게 JSON 파싱 + let data; + try { + data = await response.json(); + } catch (parseError) { + console.error(`프로젝트 ${projectCode}의 새 레지스터 응답 파싱 실패:`, parseError); + // 응답 내용 로깅 + const text = await response.clone().text(); + console.log(`응답 내용: ${text.substring(0, 200)}${text.length > 200 ? '...' : ''}`); + throw new Error(`새 레지스터 응답 파싱 실패: ${parseError instanceof Error ? parseError.message : String(parseError)}`); + } + + // 결과를 배열로 변환 (단일 객체인 경우 배열로 래핑) + let registers: newRegister[] = Array.isArray(data) ? data : [data]; + + console.log(`프로젝트 ${projectCode}에서 ${registers.length}개의 새 레지스터를 가져왔습니다.`); + return registers; + } catch (error) { + console.error(`프로젝트 ${projectCode}의 새 레지스터 가져오기 실패:`, error); + throw error; + } +} + + +/** + * 태그 가져오기 서비스 함수 + * formEntries와 tags 테이블 모두에 데이터를 저장 + */ +export async function importTagsFromSEDP( + formCode: string, + projectCode: string, + packageCode: string, + progressCallback?: (progress: number) => void +): Promise<{ + processedCount: number; + excludedCount: number; + totalEntries: number; + formCreated?: boolean; + errors?: string[]; +}> { + try { + // 진행 상황 보고 + if (progressCallback) progressCallback(5); + + // 에러 수집 배열 + const errors: string[] = []; + + // SEDP API에서 태그 데이터 가져오기 + const tagData = await fetchTagDataFromSEDP(projectCode, formCode); + const newRegisters = await getNewRegisters(projectCode); + + const registerMatched = newRegisters.find(v => v.REG_TYPE_ID === formCode).MAP_ATT + + + // 트랜잭션으로 모든 DB 작업 처리 + return await db.transaction(async (tx) => { + // 프로젝트 정보 가져오기 (type 포함) + const projectRecord = await tx.select({ id: projects.id, type: projects.type }) + .from(projects) + .where(eq(projects.code, projectCode)) + .limit(1); + + if (!projectRecord || projectRecord.length === 0) { + throw new Error(`Project not found for code: ${projectCode}`); + } + + const projectId = projectRecord[0].id; + const projectType = projectRecord[0].type; + + // 프로젝트 타입에 따라 packageCode를 찾을 ATT_ID 결정 + const packageCodeAttId = projectType === "ship" ? "CM3003" : "ME5074"; + + + + + const targetPackageCode = packageCode; + + // 데이터 형식 처리 - tagData의 첫 번째 키 사용 + const tableName = Object.keys(tagData)[0]; + + if (!tableName || !tagData[tableName]) { + throw new Error("Invalid tag data format from SEDP API"); + } + + const allTagEntries: TagEntry[] = tagData[tableName]; + + if (!Array.isArray(allTagEntries) || allTagEntries.length === 0) { + return { + processedCount: 0, + excludedCount: 0, + totalEntries: 0, + errors: ["No tag entries found in API response"] + }; + } + + // packageCode로 필터링 - ATTRIBUTES에서 지정된 ATT_ID의 VALUE와 packageCode 비교 + const tagEntries = allTagEntries.filter(entry => { + if (Array.isArray(entry.ATTRIBUTES)) { + const packageCodeAttr = entry.ATTRIBUTES.find(attr => attr.ATT_ID === packageCodeAttId); + if (packageCodeAttr && packageCodeAttr.VALUE === targetPackageCode) { + return true; + } + } + return false; + }); + + if (tagEntries.length === 0) { + return { + processedCount: 0, + excludedCount: 0, + totalEntries: allTagEntries.length, + errors: [`No tag entries found with ${packageCodeAttId} attribute value matching packageCode: ${targetPackageCode}`] + }; + } + + // 진행 상황 보고 + if (progressCallback) progressCallback(20); + + // 나머지 코드는 기존과 동일... + // form ID 가져오기 - 없으면 생성 + let formRecord = await tx.select({ id: formsPlant.id }) + .from(formsPlant) + .where(and( + eq(formsPlant.formCode, formCode), + eq(formsPlant.projectCode, projectCode), + eq(formsPlant.packageCode, packageCode) + )) + .limit(1); + + let formCreated = false; + + // form이 없으면 생성 + if (!formRecord || formRecord.length === 0) { + console.log(`[IMPORT TAGS] Form ${formCode} not found, attempting to create...`); + + // 첫 번째 태그의 정보를 사용해서 form mapping을 찾습니다 + // 모든 태그가 같은 formCode를 사용한다고 가정 + if (tagEntries.length > 0) { + const firstTag = tagEntries[0]; + + // tagType 조회 (TAG_TYPE_ID -> description) + let tagTypeDescription = firstTag.TAG_TYPE_ID; // 기본값 + if (firstTag.TAG_TYPE_ID) { + const tagTypeRecord = await tx.select({ description: tagTypes.description }) + .from(tagTypes) + .where(and( + eq(tagTypes.code, firstTag.TAG_TYPE_ID), + eq(tagTypes.projectId, projectId) + )) + .limit(1); + + if (tagTypeRecord && tagTypeRecord.length > 0) { + tagTypeDescription = tagTypeRecord[0].description; + } + } + + // tagClass 조회 (CLS_ID -> label) + let tagClassLabel = firstTag.CLS_ID; // 기본값 + if (firstTag.CLS_ID) { + const tagClassRecord = await tx.select({ id: tagClasses.id, label: tagClasses.label }) + .from(tagClasses) + .where(and( + eq(tagClasses.code, firstTag.CLS_ID), + eq(tagClasses.projectId, projectId) + )) + .limit(1); + + if (tagClassRecord && tagClassRecord.length > 0) { + tagClassLabel = tagClassRecord[0].label; + } + } + + // 태그 타입에 따른 폼 정보 가져오기 + const allFormMappings = await getFormMappingsByTagTypebyProeject( + projectId, + ); + + // 현재 formCode와 일치하는 매핑 찾기 + const targetFormMapping = allFormMappings.find(mapping => mapping.formCode === formCode); + + if (targetFormMapping) { + console.log(`[IMPORT TAGS] Found form mapping for ${formCode}, creating form...`); + + // form 생성 + const insertResult = await tx + .insert(formsPlant) + .values({ + projectCode, + packageCode, + formCode: targetFormMapping.formCode, + formName: targetFormMapping.formName, + eng: true, // ENG 모드에서 가져오는 것이므로 eng: true + im: targetFormMapping.ep === "IMEP" ? true : false + }) + .returning({ id: formsPlant.id }); + + formRecord = insertResult; + formCreated = true; + + console.log(`[IMPORT TAGS] Successfully created form:`, insertResult[0]); + } else { + console.log(`[IMPORT TAGS] No form mapping found for formCode: ${formCode}`); + console.log(`[IMPORT TAGS] Available mappings:`, allFormMappings.map(m => m.formCode)); + throw new Error(`Form ${formCode} not found and no mapping available for tag type ${tagTypeDescription}`); + } + } else { + throw new Error(`Form not found for formCode: ${formCode} and, and no tags to derive form mapping`); + } + } else { + console.log(`[IMPORT TAGS] Found existing form:`, formRecord[0].id); + + // 기존 form이 있는 경우 eng와 im 필드를 체크하고 업데이트 + const existingForm = await tx.select({ + eng: formsPlant.eng, + im: formsPlant.im + }) + .from(formsPlant) + .where(eq(formsPlant.id, formRecord[0].id)) + .limit(1); + + if (existingForm.length > 0) { + // form mapping 정보 가져오기 (im 필드 업데이트를 위해) + let shouldUpdateIm = false; + let targetImValue = false; + + // 첫 번째 태그의 정보를 사용해서 form mapping을 확인 + if (tagEntries.length > 0) { + const firstTag = tagEntries[0]; + + // tagType 조회 + let tagTypeDescription = firstTag.TAG_TYPE_ID; + if (firstTag.TAG_TYPE_ID) { + const tagTypeRecord = await tx.select({ description: tagTypes.description }) + .from(tagTypes) + .where(and( + eq(tagTypes.code, firstTag.TAG_TYPE_ID), + eq(tagTypes.projectId, projectId) + )) + .limit(1); + + if (tagTypeRecord && tagTypeRecord.length > 0) { + tagTypeDescription = tagTypeRecord[0].description; + } + } + + // tagClass 조회 + let tagClassLabel = firstTag.CLS_ID; + if (firstTag.CLS_ID) { + const tagClassRecord = await tx.select({ id: tagClasses.id, label: tagClasses.label }) + .from(tagClasses) + .where(and( + eq(tagClasses.code, firstTag.CLS_ID), + eq(tagClasses.projectId, projectId) + )) + .limit(1); + + if (tagClassRecord && tagClassRecord.length > 0) { + tagClassLabel = tagClassRecord[0].label; + } + } + + // form mapping 정보 가져오기 + const allFormMappings = await getFormMappingsByTagTypebyProeject( + projectId, + ); + + // 현재 formCode와 일치하는 매핑 찾기 + const targetFormMapping = allFormMappings.find(mapping => mapping.formCode === formCode); + + if (targetFormMapping) { + targetImValue = targetFormMapping.ep === "IMEP"; + shouldUpdateIm = existingForm[0].im !== targetImValue; + } + } + + // 업데이트할 필드들 준비 + const updates: any = {}; + let hasUpdates = false; + + // eng 필드 체크 + if (existingForm[0].eng !== true) { + updates.eng = true; + hasUpdates = true; + } + + // im 필드 체크 + if (shouldUpdateIm) { + updates.im = targetImValue; + hasUpdates = true; + } + + // 업데이트 실행 + if (hasUpdates) { + await tx + .update(formsPlant) + .set(updates) + .where(eq(formsPlant.id, formRecord[0].id)); + + console.log(`[IMPORT TAGS] Form ${formRecord[0].id} updated with:`, updates); + } + } + } + + const formId = formRecord[0].id; + + // 나머지 처리 로직은 기존과 동일... + // (양식 메타데이터 가져오기, 태그 처리 등) + + // 양식 메타데이터 가져오기 + const formMetaRecord = await tx.select({ columns: formMetas.columns }) + .from(formMetas) + .where(and( + eq(formMetas.projectId, projectId), + eq(formMetas.formCode, formCode) + )) + .limit(1); + + if (!formMetaRecord || formMetaRecord.length === 0) { + throw new Error(`Form metadata not found for formCode: ${formCode} and projectId: ${projectId}`); + } + + // 진행 상황 보고 + if (progressCallback) progressCallback(30); + + // 컬럼 정보 파싱 + const columnsJSON: Column[] = (formMetaRecord[0].columns); + + // 현재 formEntries 데이터 가져오기 + const existingEntries = await tx.select({ id: formEntriesPlant.id, data: formEntriesPlant.data }) + .from(formEntriesPlant) + .where(and( + eq(formEntriesPlant.formCode, formCode), + eq(formEntriesPlant.projectCode, projectCode), + eq(formEntriesPlant.packageCode, packageCode) + )); + + // 기존 tags 데이터 가져오기 + const existingTags = await tx.select() + .from(tagsPlant) + .where(and( + eq(tagsPlant.projectCode, projectCode), + eq(tagsPlant.packageCode, packageCode), + ) + ); + + // 진행 상황 보고 + if (progressCallback) progressCallback(50); + + // 기존 데이터를 맵으로 변환 + const existingTagMap = new Map(); + const existingTagsMap = new Map(); + + existingEntries.forEach(entry => { + const data = entry.data as any[]; + data.forEach(item => { + if (item.TAG_IDX) { + existingTagMap.set(item.TAG_IDX, { + entryId: entry.id, + data: item + }); + } + }); + }); + + existingTags.forEach(tag => { + existingTagsMap.set(tag.tagIdx, tag); + }); + + // 진행 상황 보고 + if (progressCallback) progressCallback(60); + + // 처리 결과 카운터 + let processedCount = 0; + let excludedCount = 0; + + // 새로운 태그 데이터와 업데이트할 데이터 준비 + const newTagData: any[] = []; + const upsertTagRecords: any[] = []; // 새로 추가되거나 업데이트될 태그들 + const updateData: { entryId: number, tagNo: string, updates: any }[] = []; + + // SEDP 태그 데이터 처리 + for (const tagEntry of tagEntries) { + try { + if (!tagEntry.TAG_IDX) { + excludedCount++; + errors.push(`Missing TAG_NO in tag entry`); + continue; + } + + // tagType 조회 (TAG_TYPE_ID -> description) + let tagTypeDescription = tagEntry.TAG_TYPE_ID; // 기본값 + if (tagEntry.TAG_TYPE_ID) { + const tagTypeRecord = await tx.select({ description: tagTypes.description }) + .from(tagTypes) + .where(and( + eq(tagTypes.code, tagEntry.TAG_TYPE_ID), + eq(tagTypes.projectId, projectId) + )) + .limit(1); + + if (tagTypeRecord && tagTypeRecord.length > 0) { + tagTypeDescription = tagTypeRecord[0].description; + } + } + + // tagClass 조회 (CLS_ID -> label) + let tagClassLabel = tagEntry.CLS_ID; // 기본값 + let tagClassId = null; // 기본값 + if (tagEntry.CLS_ID) { + const tagClassRecord = await tx.select({ id: tagClasses.id, label: tagClasses.label }) + .from(tagClasses) + .where(and( + eq(tagClasses.code, tagEntry.CLS_ID), + eq(tagClasses.projectId, projectId) + )) + .limit(1); + + if (tagClassRecord && tagClassRecord.length > 0) { + tagClassLabel = tagClassRecord[0].label; + tagClassId = tagClassRecord[0].id; + } + } + + const packageCode = projectType === "ship" ? tagEntry.ATTRIBUTES.find(v => v.ATT_ID === "CM3003")?.VALUE : tagEntry.ATTRIBUTES.find(v => v.ATT_ID === "ME5074")?.VALUE + + // 기본 태그 데이터 객체 생성 (formEntries용) + const tagObject: any = { + TAG_IDX: tagEntry.TAG_IDX, // SEDP 고유 식별자 + TAG_NO: tagEntry.TAG_NO, + TAG_DESC: tagEntry.TAG_DESC || "", + CLS_ID: tagEntry.CLS_ID || "", + VNDRCD: vendorRecord[0].vendorCode, + VNDRNM_1: vendorRecord[0].vendorName, + status: "From S-EDP", // SEDP에서 가져온 데이터임을 표시 + source: "S-EDP", // 태그 출처 (불변) - S-EDP에서 가져옴 + ...(projectType === "ship" ? { CM3003: packageCode } : { ME5074: packageCode }) + } + + let latestDueDate: Date | null = null; + + // tags 테이블용 데이터 (UPSERT용) + const tagRecord = { + projectCode, + packageCode, + formId: formId, + tagIdx: tagEntry.TAG_IDX, // SEDP 고유 식별자 + tagNo: tagEntry.TAG_NO, + tagType: tagTypeDescription || "", + class: tagClassLabel, + tagClassId: tagClassId, + description: tagEntry.TAG_DESC || null, + createdAt: new Date(), + updatedAt: new Date() + }; + + // ATTRIBUTES 필드에서 shi=true인 컬럼의 값 추출 + if (Array.isArray(tagEntry.ATTRIBUTES)) { + for (const attr of tagEntry.ATTRIBUTES) { + const columnInfo = columnsJSON.find(col => col.key === attr.ATT_ID); + if (columnInfo && (columnInfo.shi === "BOTH" || columnInfo.shi === "OUT" || columnInfo.shi === null)) { + if (columnInfo.type === "NUMBER") { + if (attr.VALUE !== undefined && attr.VALUE !== null) { + if (typeof attr.VALUE === 'string') { + const numberMatch = attr.VALUE.match(/(-?\d+(\.\d+)?)/); + if (numberMatch) { + tagObject[attr.ATT_ID] = parseFloat(numberMatch[0]); + } else { + const parsed = parseFloat(attr.VALUE); + if (!isNaN(parsed)) { + tagObject[attr.ATT_ID] = parsed; + } + } + } else if (typeof attr.VALUE === 'number') { + tagObject[attr.ATT_ID] = attr.VALUE; + } + } + } else if (attr.VALUE !== null && attr.VALUE !== undefined) { + tagObject[attr.ATT_ID] = attr.VALUE; + } + } + + // registerMatched에서 해당 SEDP_ATT_ID의 DUE_DATE 찾기 + if (registerMatched && Array.isArray(registerMatched)) { + const matchedAttribute = registerMatched.find( + regAttr => regAttr.SEDP_ATT_ID === attr.ATT_ID + ); + + if (matchedAttribute && matchedAttribute.DUE_DATE) { + try { + const dueDate = new Date(matchedAttribute.DUE_DATE); + + // 유효한 날짜인지 확인 + if (!isNaN(dueDate.getTime())) { + // 첫 번째 DUE_DATE이거나 현재까지 찾은 것보다 더 늦은 날짜인 경우 업데이트 + if (!latestDueDate || dueDate > latestDueDate) { + latestDueDate = dueDate; + } + } + } catch (dateError) { + console.warn(`Invalid DUE_DATE format for ${attr.ATT_ID}: ${matchedAttribute.DUE_DATE}`); + } + } + } + + } + } + + if (latestDueDate) { + // ISO 형식의 문자열로 저장 (또는 원하는 형식으로 변경 가능) + tagObject.DUE_DATE = latestDueDate.toISOString(); + + // 또는 YYYY-MM-DD 형식을 원한다면: + // tagObject.DUE_DATE = latestDueDate.toISOString().split('T')[0]; + + // 또는 원본 형식 그대로 유지하려면: + // tagObject.DUE_DATE = latestDueDate.toISOString().replace('Z', ''); + } + + + + // 기존 태그가 있는지 확인하고 처리 + const existingTag = existingTagMap.get(tagEntry.TAG_IDX); + + if (existingTag) { + // 기존 태그가 있으면 formEntries 업데이트 데이터 준비 + const updates: any = {}; + let hasUpdates = false; + + for (const key of Object.keys(tagObject)) { + if (key === "TAG_IDX") continue; + + if (key === "TAG_NO" && tagObject[key] !== existingTag.data[key]) { + updates[key] = tagObject[key]; + hasUpdates = true; + continue; + } + + + if (key === "TAG_DESC" && tagObject[key] !== existingTag.data[key]) { + updates[key] = tagObject[key]; + hasUpdates = true; + continue; + } + + if (key === "status" && tagObject[key] !== existingTag.data[key]) { + updates[key] = tagObject[key]; + hasUpdates = true; + continue; + } + if (key === "CLS_ID" && tagObject[key] !== existingTag.data[key]) { + updates[key] = tagObject[key]; + hasUpdates = true; + continue; + } + + if (key === "DUE_DATE" && tagObject[key] !== existingTag.data[key]) { + updates[key] = tagObject[key]; + hasUpdates = true; + continue; + } + + const columnInfo = columnsJSON.find(col => col.key === key); + if (columnInfo && (columnInfo.shi === "BOTH" || columnInfo.shi === "OUT" || columnInfo.shi === null)) { + if (existingTag.data[key] !== tagObject[key]) { + updates[key] = tagObject[key]; + hasUpdates = true; + } + } + } + + if (hasUpdates) { + updateData.push({ + entryId: existingTag.entryId, + tagIdx: tagEntry.TAG_IDX, // TAG_IDX로 변경 + updates + }); + } + } else { + // 기존 태그가 없으면 새로 추가 + newTagData.push(tagObject); + } + + // tags 테이블에는 항상 upsert (새로 추가되거나 업데이트) + upsertTagRecords.push(tagRecord); + + processedCount++; + } catch (error) { + excludedCount++; + errors.push(`Error processing tag ${tagEntry.TAG_IDX || 'unknown'}: ${error}`); + } + } + + // 진행 상황 보고 + if (progressCallback) progressCallback(80); + + // formEntries 업데이트 실행 + // entryId별로 업데이트를 그룹화 + const updatesByEntryId = new Map(); + + for (const update of updateData) { + if (!updatesByEntryId.has(update.entryId)) { + updatesByEntryId.set(update.entryId, []); + } + updatesByEntryId.get(update.entryId).push(update); + } + + // 그룹화된 업데이트를 처리 + for (const [entryId, updates] of updatesByEntryId) { + try { + const entry = existingEntries.find(e => e.id === entryId); + if (!entry) continue; + + const data = entry.data as any[]; + + // 해당 entryId의 모든 업데이트를 한 번에 적용 + const updatedData = data.map(item => { + let updatedItem = { ...item }; + + // 현재 item에 적용할 모든 업데이트를 찾아서 적용 + for (const update of updates) { + if (item.TAG_IDX === update.tagIdx) { + updatedItem = { ...updatedItem, ...update.updates }; + } + } + + return updatedItem; + }); + + // entryId별로 한 번만 DB 업데이트 + await tx.update(formEntriesPlant) + .set({ + data: updatedData, + updatedAt: new Date() + }) + .where(eq(formEntriesPlant.id, entryId)); + + } catch (error) { + const tagNos = updates.map(u => u.tagNo || u.tagIdx).join(', '); + errors.push(`Error updating formEntry ${entryId} for tags ${tagNos}: ${error}`); + } + } + + // 새 태그 추가 (formEntriesPlant) + if (newTagData.length > 0) { + if (existingEntries.length > 0) { + const firstEntry = existingEntries[0]; + const existingData = firstEntry.data as any[]; + const updatedData = [...existingData, ...newTagData]; + + await tx.update(formEntriesPlant) + .set({ + data: updatedData, + updatedAt: new Date() + }) + .where(eq(formEntriesPlant.id, firstEntry.id)); + } else { + await tx.insert(formEntriesPlant) + .values({ + formCode, + projectCode, + packageCode, + data: newTagData, + createdAt: new Date(), + updatedAt: new Date() + }); + } + } + + // tags 테이블 처리 (INSERT + UPDATE 분리) + if (upsertTagRecords.length > 0) { + const newTagRecords: any[] = []; + const updateTagRecords: { tagId: number, updates: any }[] = []; + + // 각 태그를 확인하여 신규/업데이트 분류 + for (const tagRecord of upsertTagRecords) { + const existingTagRecord = existingTagsMap.get(tagRecord.tagIdx); + + if (existingTagRecord) { + // 기존 태그가 있으면 업데이트 준비 + const tagUpdates: any = {}; + let hasTagUpdates = false; + + // tagNo도 업데이트 가능 (편집된 경우) + if (existingTagRecord.tagNo !== tagRecord.tagNo) { + tagUpdates.tagNo = tagRecord.tagNo; + hasTagUpdates = true; + } + + if (existingTagRecord.tagType !== tagRecord.tagType) { + tagUpdates.tagType = tagRecord.tagType; + hasTagUpdates = true; + } + if (existingTagRecord.class !== tagRecord.class) { + tagUpdates.class = tagRecord.class; + hasTagUpdates = true; + } + if (existingTagRecord.tagClassId !== tagRecord.tagClassId) { + tagUpdates.tagClassId = tagRecord.tagClassId; + hasTagUpdates = true; + } + + if (existingTagRecord.description !== tagRecord.description) { + tagUpdates.description = tagRecord.description; + hasTagUpdates = true; + } + if (existingTagRecord.formId !== tagRecord.formId) { + tagUpdates.formId = tagRecord.formId; + hasTagUpdates = true; + } + + if (hasTagUpdates) { + updateTagRecords.push({ + tagId: existingTagRecord.id, + updates: { ...tagUpdates, updatedAt: new Date() } + }); + } + } else { + // 새로운 태그 + newTagRecords.push(tagRecord); + } + } + + // 새 태그 삽입 + if (newTagRecords.length > 0) { + try { + await tx.insert(tagsPlant) + .values(newTagRecords) + .onConflictDoNothing({ + target: [tagsPlant.projectCode,tagsPlant.packageCode, tagsPlant.tagIdx] + }); + } catch (error) { + // 개별 삽입으로 재시도 + for (const tagRecord of newTagRecords) { + try { + await tx.insert(tagsPlant) + .values(tagRecord) + .onConflictDoNothing({ + target: [tagsPlant.projectCode,tagsPlant.packageCode, tagsPlant.tagIdx] + }); + } catch (individualError) { + errors.push(`Error inserting tag ${tagRecord.tagIdx}: ${individualError}`); + } + } + } + } + + // 기존 태그 업데이트 + for (const update of updateTagRecords) { + try { + await tx.update(tagsPlant) + .set(update.updates) + .where(eq(tagsPlant.id, update.tagId)); + } catch (error) { + errors.push(`Error updating tag record ${update.tagId}: ${error}`); + } + } + } + + // 진행 상황 보고 + if (progressCallback) progressCallback(100); + + // 최종 결과 반환 + return { + processedCount, + excludedCount, + totalEntries: tagEntries.length, + formCreated, + errors: errors.length > 0 ? errors : undefined + }; + }); + + } catch (error: any) { + console.error("Tag import error:", error); + throw error; + } +} +/** + * SEDP API에서 태그 데이터 가져오기 + * + * @param projectCode 프로젝트 코드 + * @param formCode 양식 코드 + * @returns API 응답 데이터 + */ +async function fetchTagDataFromSEDP(projectCode: string, formCode: string): Promise<any> { + try { + // Get the token + const apiKey = await getSEDPToken(); + + // Define the API base URL + const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api'; + + // Make the API call + const response = await fetch( + `${SEDP_API_BASE_URL}/Data/GetPubData`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'accept': '*/*', + 'ApiKey': apiKey, + 'ProjectNo': projectCode + }, + body: JSON.stringify({ + ProjectNo: projectCode, + REG_TYPE_ID: formCode, + // TODO: 이창국 프로 요청으로, ContainDeleted: true로 변경예정, EDP에서 삭제된 데이터도 가져올 수 있어야 한다고 함. + // 삭제된 게 들어오면 eVCP내에서 지우거나, 비활성화 하는 등의 처리를 해야 할 걸로 보임 + ContainDeleted: false + }) + } + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`SEDP API request failed: ${response.status} ${response.statusText} - ${errorText}`); + } + + const data = await response.json(); + return data; + } catch (error: any) { + console.error('Error calling SEDP API:', error); + throw new Error(`Failed to fetch data from SEDP API: ${error.message || 'Unknown error'}`); + } +}
\ No newline at end of file diff --git a/lib/sedp/get-tags-plant.ts b/lib/sedp/get-tags-plant.ts new file mode 100644 index 00000000..d1957db4 --- /dev/null +++ b/lib/sedp/get-tags-plant.ts @@ -0,0 +1,639 @@ +import db from "@/db/db"; +import { + tagsPlant, + formsPlant, + formEntriesPlant, + items, + tagTypeClassFormMappings, + projects, + tagTypes, + tagClasses, +} from "@/db/schema"; +import { eq, and, like, inArray } from "drizzle-orm"; +import { revalidateTag } from "next/cache"; // 추가 +import { getSEDPToken } from "./sedp-token"; + +/** + * 태그 가져오기 서비스 함수 + * contractItemId(packageId)를 기반으로 외부 시스템에서 태그 데이터를 가져와 DB에 저장 + * TAG_IDX를 기준으로 태그를 식별합니다. + * + * @param projectCode 계약 아이템 ID (contractItemId) + * @param packageCode 계약 아이템 ID (contractItemId) + * @param progressCallback 진행 상황을 보고하기 위한 콜백 함수 + * @returns 처리 결과 정보 (처리된 태그 수, 오류 목록 등) + */ +export async function importTagsFromSEDP( + projectCode: string, + packageCode: string, + progressCallback?: (progress: number) => void, + mode?: string +): Promise<{ + processedCount: number; + excludedCount: number; + totalEntries: number; + errors?: string[]; +}> { + try { + // 진행 상황 보고 + if (progressCallback) progressCallback(5); + + const project = await db.query.projects.findFirst({ + where: eq(projects.code, projectCode), + columns: { + id: true + } + }); + + + // 프로젝트 ID 획득 + const projectId = project?.id; + + // Step 1-2: Get the item using itemId from contractItem + const item = await db.query.items.findFirst({ + where: and(eq(items.ProjectNo, projectCode), eq(items.packageCode, packageCode)) + }); + + if (!item) { + throw new Error(`Item with ID ${item?.id} not found`); + } + + const itemCode = item.itemCode; + + // 진행 상황 보고 + if (progressCallback) progressCallback(10); + + // 기본 매핑 검색 - 모든 모드에서 사용 + const baseMappings = await db.query.tagTypeClassFormMappings.findMany({ + where: and( + like(tagTypeClassFormMappings.remark, `%${itemCode}%`), + eq(tagTypeClassFormMappings.projectId, projectId) + ) + }); + + if (baseMappings.length === 0) { + throw new Error(`No mapping found for item code ${itemCode}`); + } + + // Step 2: Find the mapping entries - 모드에 따라 다른 조건 적용 + let mappings = []; + + if (mode === 'IM') { + // IM 모드일 때는 먼저 SEDP에서 태그 데이터를 가져와 TAG_TYPE_ID 리스트 확보 + + // 프로젝트 코드 가져오기 + const project = await db.query.projects.findFirst({ + where: eq(projects.id, projectId) + }); + + if (!project) { + throw new Error(`Project with ID ${projectId} not found`); + } + + // 각 매핑의 formCode에 대해 태그 데이터 조회 + const tagTypeIds = new Set<string>(); + + for (const mapping of baseMappings) { + try { + // SEDP에서 태그 데이터 가져오기 + const tagData = await fetchTagDataFromSEDP(project.code, mapping.formCode); + + // 첫 번째 키를 테이블 이름으로 사용 + const tableName = Object.keys(tagData)[0]; + const tagEntries = tagData[tableName]; + + if (Array.isArray(tagEntries)) { + // 모든 태그에서 TAG_TYPE_ID 수집 + for (const entry of tagEntries) { + if (entry.TAG_TYPE_ID && entry.TAG_TYPE_ID !== "") { + tagTypeIds.add(entry.TAG_TYPE_ID); + } + } + } + } catch (error) { + console.error(`Error fetching tag data for formCode ${mapping.formCode}:`, error); + } + } + + if (tagTypeIds.size === 0) { + throw new Error('No valid TAG_TYPE_ID found in SEDP tag data'); + } + + // 수집된 TAG_TYPE_ID로 tagTypes에서 정보 조회 + const tagTypeInfo = await db.query.tagTypes.findMany({ + where: and( + inArray(tagTypes.code, Array.from(tagTypeIds)), + eq(tagTypes.projectId, projectId) + ) + }); + + if (tagTypeInfo.length === 0) { + throw new Error('No matching tag types found for the collected TAG_TYPE_IDs'); + } + + // 태그 타입 설명 수집 + const tagLabels = tagTypeInfo.map(tt => tt.description); + + // IM 모드에 맞는 매핑 조회 - ep가 "IMEP"인 항목만 + mappings = await db.query.tagTypeClassFormMappings.findMany({ + where: and( + inArray(tagTypeClassFormMappings.tagTypeLabel, tagLabels), + eq(tagTypeClassFormMappings.projectId, projectId), + eq(tagTypeClassFormMappings.ep, "IMEP") + ) + }); + + } else { + // ENG 모드 또는 기본 모드일 때 - 기본 매핑 사용 + mappings = [...baseMappings]; + + // ENG 모드에서는 ep 필드가 "IMEP"가 아닌 매핑만 필터링 + if (mode === 'ENG') { + mappings = mappings.filter(mapping => mapping.ep !== "IMEP"); + } + } + + // 매핑이 없는 경우 모드에 따라 다른 오류 메시지 사용 + if (mappings.length === 0) { + if (mode === 'IM') { + throw new Error('No suitable mappings found for IM mode'); + } else { + throw new Error(`No mapping found for item code ${itemCode}`); + } + } + + // 진행 상황 보고 + if (progressCallback) progressCallback(15); + + // 결과 누적을 위한 변수들 초기화 + let totalProcessedCount = 0; + let totalExcludedCount = 0; + let totalEntriesCount = 0; + const allErrors: string[] = []; + + // 각 매핑에 대해 처리 + for (let mappingIndex = 0; mappingIndex < mappings.length; mappingIndex++) { + const mapping = mappings[mappingIndex]; + + // Step 3: Get the project code + const project = await db.query.projects.findFirst({ + where: eq(projects.id, mapping.projectId) + }); + + if (!project) { + allErrors.push(`Project with ID ${mapping.projectId} not found`); + continue; // 다음 매핑으로 진행 + } + + // IM 모드에서는 baseMappings에서 같은 formCode를 가진 매핑을 찾음 + let formCode = mapping.formCode; + if (mode === 'IM') { + // baseMapping에서 동일한 formCode를 가진 매핑 찾기 + const originalMapping = baseMappings.find( + baseMapping => baseMapping.formCode === mapping.formCode + ); + + // 찾았으면 해당 formCode 사용, 못 찾았으면 현재 매핑의 formCode 유지 + if (originalMapping) { + formCode = originalMapping.formCode; + } + } + + // 진행 상황 보고 - 매핑별 진행률 조정 + if (progressCallback) { + const baseProgress = 15; + const mappingProgress = Math.floor(15 * (mappingIndex + 1) / mappings.length); + progressCallback(baseProgress + mappingProgress); + } + + // Step 4: Find the form ID + const form = await db.query.formsPlant.findFirst({ + where: and( + eq(formsPlant.projectCode, projectCode), + eq(formsPlant.packageCode, packageCode), + eq(formsPlant.formCode, formCode) + ) + }); + + let formId; + + // If form doesn't exist, create it + if (!form) { + // 폼이 없는 경우 새로 생성 - 모드에 따른 필드 설정 + const insertValues: any = { + projectCode, + packageCode, + formCode: formCode, + formName: mapping.formName + }; + + // 모드 정보가 있으면 해당 필드 설정 + if (mode) { + if (mode === "ENG") { + insertValues.eng = true; + } else if (mode === "IM") { + insertValues.im = true; + if (mapping.remark && mapping.remark.includes("VD_")) { + insertValues.eng = true; + } + } + } + + const insertResult = await db.insert(formsPlant).values(insertValues).returning({ id: formsPlant.id }); + + if (insertResult.length === 0) { + allErrors.push(`Failed to create form record for formCode ${formCode}`); + continue; // 다음 매핑으로 진행 + } + + formId = insertResult[0].id; + } else { + // 폼이 이미 존재하는 경우 - 필요시 모드 필드 업데이트 + formId = form.id; + + if (mode) { + let shouldUpdate = false; + const updateValues: any = {}; + + if (mode === "ENG" && form.eng !== true) { + updateValues.eng = true; + shouldUpdate = true; + } else if (mode === "IM" && form.im !== true) { + updateValues.im = true; + shouldUpdate = true; + } + + if (shouldUpdate) { + await db.update(formsPlant) + .set({ + ...updateValues, + updatedAt: new Date() + }) + .where(eq(formsPlant.id, formId)); + + console.log(`Updated form ${formId} with ${mode} mode enabled`); + } + } + } + + // 진행 상황 보고 - 매핑별 진행률 조정 + if (progressCallback) { + const baseProgress = 30; + const mappingProgress = Math.floor(20 * (mappingIndex + 1) / mappings.length); + progressCallback(baseProgress + mappingProgress); + } + + try { + // Step 5: Call the external API to get tag data + const tagData = await fetchTagDataFromSEDP(projectCode, baseMappings[0].formCode); + + // 진행 상황 보고 + if (progressCallback) { + const baseProgress = 50; + const mappingProgress = Math.floor(10 * (mappingIndex + 1) / mappings.length); + progressCallback(baseProgress + mappingProgress); + } + + // Step 6: Process the data and insert into the tags table + let processedCount = 0; + let excludedCount = 0; + + // Get the first key from the response as the table name + const tableName = Object.keys(tagData)[0]; + const tagEntries = tagData[tableName]; + + if (!Array.isArray(tagEntries) || tagEntries.length === 0) { + allErrors.push(`No tag data found in the API response for formCode ${baseMappings[0].formCode}`); + continue; // 다음 매핑으로 진행 + } + + const entriesCount = tagEntries.length; + totalEntriesCount += entriesCount; + + // formEntries를 위한 데이터 수집 + const newTagsForFormEntry: Array<{ + TAG_IDX: string; // 변경: TAG_NO → TAG_IDX + TAG_NO?: string; // TAG_NO도 함께 저장 (편집 가능한 필드) + TAG_DESC: string | null; + status: string; + [key: string]: any; + }> = []; + + const registerResponse = await fetch( + `${SEDP_API_BASE_URL}/Register/GetByID`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'accept': '*/*', + 'ApiKey': apiKey, + 'ProjectNo': projectCode + }, + body: JSON.stringify({ + ProjectNo: projectCode, + TYPE_ID: baseMappings[0].formCode, // 또는 mapping.formCode + ContainDeleted: false + }) + } + ) + + if (!registerResponse.ok) { + allErrors.push(`Failed to fetch register details for ${baseMappings[0].formCode}`) + continue + } + + const registerDetail: Register = await registerResponse.json() + + // ✅ MAP_ATT에서 허용된 ATT_ID 목록 추출 + const allowedAttIds = new Set<string>() + if (Array.isArray(registerDetail.MAP_ATT)) { + for (const mapAttr of registerDetail.MAP_ATT) { + if (mapAttr.ATT_ID) { + allowedAttIds.add(mapAttr.ATT_ID) + } + } + } + + + // Process each tag entry + for (let i = 0; i < tagEntries.length; i++) { + try { + const entry = tagEntries[i]; + + // TAG_IDX가 없는 경우 제외 (변경: TAG_NO → TAG_IDX 체크) + if (!entry.TAG_IDX) { + excludedCount++; + totalExcludedCount++; + + // 주기적으로 진행 상황 보고 (건너뛰어도 진행률은 업데이트) + if (progressCallback && (i % 10 === 0 || i === tagEntries.length - 1)) { + const baseProgress = 60; + const entryProgress = Math.floor(30 * ((mappingIndex * entriesCount + i) / (mappings.length * entriesCount))); + progressCallback(baseProgress + entryProgress); + } + + continue; // 이 항목은 건너뜀 + } + + const attributes: Record<string, string> = {} + if (Array.isArray(entry.ATTRIBUTES)) { + for (const attr of entry.ATTRIBUTES) { + // MAP_ATT에 정의된 ATT_ID만 포함 + if (attr.ATT_ID && allowedAttIds.has(attr.ATT_ID)) { + if (attr.VALUE !== null && attr.VALUE !== undefined) { + attributes[attr.ATT_ID] = String(attr.VALUE) + } + } + } + } + + + // TAG_TYPE_ID가 null이거나 빈 문자열인 경우 제외 + if (entry.TAG_TYPE_ID === null || entry.TAG_TYPE_ID === "") { + excludedCount++; + totalExcludedCount++; + + // 주기적으로 진행 상황 보고 (건너뛰어도 진행률은 업데이트) + if (progressCallback && (i % 10 === 0 || i === tagEntries.length - 1)) { + const baseProgress = 60; + const entryProgress = Math.floor(30 * ((mappingIndex * entriesCount + i) / (mappings.length * entriesCount))); + progressCallback(baseProgress + entryProgress); + } + + continue; // 이 항목은 건너뜀 + } + + // Get tag type description + const tagType = await db.query.tagTypes.findFirst({ + where: and( + eq(tagTypes.code, entry.TAG_TYPE_ID), + eq(tagTypes.projectId, mapping.projectId) + ) + }); + + // Get tag class label + const tagClass = await db.query.tagClasses.findFirst({ + where: and( + eq(tagClasses.code, entry.CLS_ID), + eq(tagClasses.projectId, mapping.projectId) + ) + }); + + // Insert or update the tag - tagIdx 필드 추가 + await db.insert(tagsPlant).values({ + projectCode, + packageCode, + formId: formId, + tagIdx: entry.TAG_IDX, + tagNo: entry.TAG_NO || entry.TAG_IDX, + tagType: tagType?.description || entry.TAG_TYPE_ID, + tagClassId: tagClass?.id, + class: tagClass?.label || entry.CLS_ID, + description: entry.TAG_DESC, + attributes: attributes, // JSONB로 저장 + }).onConflictDoUpdate({ + target: [tagsPlant.projectCode, tagsPlant.packageCode, tagsPlant.tagIdx], + set: { + formId: formId, + tagNo: entry.TAG_NO || entry.TAG_IDX, + tagType: tagType?.description || entry.TAG_TYPE_ID, + class: tagClass?.label || entry.CLS_ID, + description: entry.TAG_DESC, + attributes: attributes, // JSONB 업데이트 + updatedAt: new Date() + } + }) + // formEntries용 데이터 수집 + const tagDataForFormEntry = { + TAG_IDX: entry.TAG_IDX, // 변경: TAG_NO → TAG_IDX + TAG_NO: entry.TAG_NO || entry.TAG_IDX, // TAG_NO도 함께 저장 + TAG_DESC: entry.TAG_DESC || null, + status: "From S-EDP", // SEDP에서 가져온 데이터임을 표시 + source: "S-EDP" // 태그 출처 (불변) - S-EDP에서 가져옴 + }; + + // ATTRIBUTES가 있으면 추가 (SHI 필드들) + if (Array.isArray(entry.ATTRIBUTES)) { + for (const attr of entry.ATTRIBUTES) { + if (attr.ATT_ID && attr.VALUE !== null && attr.VALUE !== undefined) { + tagDataForFormEntry[attr.ATT_ID] = attr.VALUE; + } + } + } + + newTagsForFormEntry.push(tagDataForFormEntry); + + processedCount++; + totalProcessedCount++; + + // 주기적으로 진행 상황 보고 + if (progressCallback && (i % 10 === 0 || i === tagEntries.length - 1)) { + const baseProgress = 60; + const entryProgress = Math.floor(30 * ((mappingIndex * entriesCount + i) / (mappings.length * entriesCount))); + progressCallback(baseProgress + entryProgress); + } + } catch (error: any) { + console.error(`Error processing tag entry:`, error); + allErrors.push(error.message || 'Unknown error'); + } + } + + // Step 7: formEntries 업데이트 - TAG_IDX 기준으로 변경 + if (newTagsForFormEntry.length > 0) { + try { + // 기존 formEntry 가져오기 + const existingEntry = await db.query.formEntriesPlant.findFirst({ + where: and( + eq(formEntriesPlant.formCode, formCode), + eq(formEntriesPlant.projectCode, projectCode), + eq(formEntriesPlant.packageCode, packageCode) + ) + }); + + if (existingEntry && existingEntry.id) { + // 기존 formEntry가 있는 경우 + let existingData: Array<{ + TAG_IDX?: string; // 추가: TAG_IDX 필드 + TAG_NO?: string; + TAG_DESC?: string; + status?: string; + [key: string]: any; + }> = []; + + if (Array.isArray(existingEntry.data)) { + existingData = existingEntry.data; + } + + // 기존 TAG_IDX들 추출 (변경: TAG_NO → TAG_IDX) + const existingTagIdxs = new Set( + existingData + .map(item => item.TAG_IDX) + .filter(tagIdx => tagIdx !== undefined && tagIdx !== null) + ); + + // 중복되지 않은 새 태그들만 필터링 (변경: TAG_NO → TAG_IDX) + const newUniqueTagsData = newTagsForFormEntry.filter( + tagData => !existingTagIdxs.has(tagData.TAG_IDX) + ); + + // 기존 태그들의 status와 ATTRIBUTES 업데이트 (변경: TAG_NO → TAG_IDX) + const updatedExistingData = existingData.map(existingItem => { + const newTagData = newTagsForFormEntry.find( + newItem => newItem.TAG_IDX === existingItem.TAG_IDX + ); + + if (newTagData) { + // 기존 태그가 있으면 SEDP 데이터로 업데이트 + return { + ...existingItem, + ...newTagData, + TAG_IDX: existingItem.TAG_IDX // TAG_IDX는 유지 + }; + } + + return existingItem; + }); + + const finalData = [...updatedExistingData, ...newUniqueTagsData]; + + await db + .update(formEntriesPlant) + .set({ + data: finalData, + updatedAt: new Date() + }) + .where(eq(formEntriesPlant.id, existingEntry.id)); + + console.log(`[IMPORT SEDP] Updated formEntry with ${newUniqueTagsData.length} new tags, updated ${updatedExistingData.length - newUniqueTagsData.length} existing tags for form ${formCode}`); + } else { + // formEntry가 없는 경우 새로 생성 + await db.insert(formEntriesPlant).values({ + formCode: formCode, + projectCode, + packageCode, + data: newTagsForFormEntry, + createdAt: new Date(), + updatedAt: new Date(), + }); + + console.log(`[IMPORT SEDP] Created new formEntry with ${newTagsForFormEntry.length} tags for form ${formCode}`); + } + + // 캐시 무효화 + revalidateTag(`form-data-${formCode}-${packageId}`); + } catch (formEntryError) { + console.error(`[IMPORT SEDP] Error updating formEntry for form ${formCode}:`, formEntryError); + allErrors.push(`Error updating formEntry for form ${formCode}: ${formEntryError}`); + } + } + + } catch (error: any) { + console.error(`Error processing mapping for formCode ${formCode}:`, error); + allErrors.push(`Error with formCode ${formCode}: ${error.message || 'Unknown error'}`); + } + } + + // 모든 매핑 처리 완료 - 진행률 100% + if (progressCallback) { + progressCallback(100); + } + + // 최종 결과 반환 + return { + processedCount: totalProcessedCount, + excludedCount: totalExcludedCount, + totalEntries: totalEntriesCount, + errors: allErrors.length > 0 ? allErrors : undefined + }; + } catch (error: any) { + console.error("Tag import error:", error); + throw error; + } +} + +/** + * SEDP API에서 태그 데이터 가져오기 + * + * @param projectCode 프로젝트 코드 + * @param formCode 양식 코드 + * @returns API 응답 데이터 + */ +async function fetchTagDataFromSEDP(projectCode: string, formCode: string): Promise<any> { + try { + // Get the token + const apiKey = await getSEDPToken(); + + // Define the API base URL + const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api'; + + // Make the API call + const response = await fetch( + `${SEDP_API_BASE_URL}/Data/GetPubData`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'accept': '*/*', + 'ApiKey': apiKey, + 'ProjectNo': projectCode + }, + body: JSON.stringify({ + ProjectNo: projectCode, + REG_TYPE_ID: formCode, + ContainDeleted: false + }) + } + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`SEDP API request failed: ${response.status} ${response.statusText} - ${errorText}`); + } + + const data = await response.json(); + return data; + } catch (error: any) { + console.error('Error calling SEDP API:', error); + throw new Error(`Failed to fetch data from SEDP API: ${error.message || 'Unknown error'}`); + } +}
\ No newline at end of file diff --git a/lib/sedp/sync-form.ts b/lib/sedp/sync-form.ts index 904d27ba..a6d473ad 100644 --- a/lib/sedp/sync-form.ts +++ b/lib/sedp/sync-form.ts @@ -94,7 +94,7 @@ interface Register { SEQ: number; CMPLX_YN: boolean; CMPL_SETT: any | null; - MAP_ATT: any[]; + MAP_ATT: MapAttribute2[]; MAP_CLS_ID: string[]; MAP_OPER: any | null; LNK_ATT: LinkAttribute[]; @@ -157,6 +157,13 @@ interface MapAttribute { INOUT: string | null; } +interface MapAttribute2 { + ATT_ID: string; + VALUE: string; + IS_PARA: boolean; + OPER: string | null; +} + interface Attribute { PROJ_NO: string; ATT_ID: string; diff --git a/lib/tags-plant/column-builder.service.ts b/lib/tags-plant/column-builder.service.ts new file mode 100644 index 00000000..9a552d6e --- /dev/null +++ b/lib/tags-plant/column-builder.service.ts @@ -0,0 +1,34 @@ +// lib/vendor-data-plant/column-builder.service.ts +import { ColumnDef } from "@tanstack/react-table" +import { Tag } from "@/db/schema/vendorData" + +/** + * 동적 속성 컬럼 생성 (ATT_ID만 사용, 라벨 없음) + */ +export function createDynamicAttributeColumns( + attributeKeys: string[] +): ColumnDef<Tag>[] { + return attributeKeys.map(key => ({ + id: `attr_${key}`, + accessorFn: (row: Tag) => { + if (row.attributes && typeof row.attributes === 'object') { + return (row.attributes as Record<string, string>)[key] || '' + } + return '' + }, + header: key, // 단순 문자열로 반환 + cell: ({ getValue }) => { + const value = getValue() + return value as string || "-" + }, + meta: { + excelHeader: key + }, + enableSorting: true, + enableColumnFilter: true, + filterFn: "includesString", + enableResizing: true, + minSize: 100, + size: 150, + })) +}
\ No newline at end of file diff --git a/lib/tags-plant/queries.ts b/lib/tags-plant/queries.ts new file mode 100644 index 00000000..a0d28b1e --- /dev/null +++ b/lib/tags-plant/queries.ts @@ -0,0 +1,68 @@ +// lib/vendor-data-plant/queries.ts +"use server" + +import db from "@/db/db" + +import { tagsPlant } from "@/db/schema/vendorData" +import { eq, and } from "drizzle-orm" + +/** + * 모든 태그 가져오기 (클라이언트 렌더링용) + */ +export async function getAllTagsPlant( + projectCode: string, + packageCode: string +) { + try { + const tags = await db + .select() + .from(tagsPlant) + .where( + and( + eq(tagsPlant.projectCode, projectCode), + eq(tagsPlant.packageCode, packageCode) + ) + ) + .orderBy(tagsPlant.createdAt) + + return tags + } catch (error) { + console.error("Error fetching all tags:", error) + return [] + } +} + +/** + * 고유 속성 키 추출 + */ +export async function getUniqueAttributeKeys( + projectCode: string, + packageCode: string +): Promise<string[]> { + try { + const result = await db + .select({ + attributes: tagsPlant.attributes + }) + .from(tagsPlant) + .where( + and( + eq(tagsPlant.projectCode, projectCode), + eq(tagsPlant.packageCode, packageCode) + ) + ) + + const allKeys = new Set<string>() + + for (const row of result) { + if (row.attributes && typeof row.attributes === 'object') { + Object.keys(row.attributes).forEach(key => allKeys.add(key)) + } + } + + return Array.from(allKeys).sort() + } catch (error) { + console.error("Error getting unique attribute keys:", error) + return [] + } +}
\ No newline at end of file diff --git a/lib/tags-plant/repository.ts b/lib/tags-plant/repository.ts index b5d48335..bbe36f66 100644 --- a/lib/tags-plant/repository.ts +++ b/lib/tags-plant/repository.ts @@ -1,5 +1,5 @@ import db from "@/db/db"; -import { NewTag, tags } from "@/db/schema/vendorData"; +import { NewTag, tags, tagsPlant } from "@/db/schema/vendorData"; import { eq, inArray, @@ -69,3 +69,43 @@ export async function deleteTagsByIds( ) { return tx.delete(tags).where(inArray(tags.id, ids)); } + + +export async function selectTagsPlant( + tx: PgTransaction<any, any, any>, + params: { + where?: any; // drizzle-orm의 조건식 (and, eq...) 등 + orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[]; + offset?: number; + limit?: number; + } +) { + const { where, orderBy, offset = 0, limit = 10 } = params; + + return tx + .select() + .from(tagsPlant) + .where(where) + .orderBy(...(orderBy ?? [])) + .offset(offset) + .limit(limit); +} +/** 총 개수 count */ +export async function countTagsPlant( + tx: PgTransaction<any, any, any>, + where?: any +) { + const res = await tx.select({ count: count() }).from(tagsPlant).where(where); + return res[0]?.count ?? 0; +} + +export async function insertTagPlant( + tx: PgTransaction<any, any, any>, + data: NewTag // DB와 동일한 insert 가능한 타입 + ) { + // returning() 사용 시 배열로 돌아오므로 [0]만 리턴 + return tx + .insert(tagsPlant) + .values(data) + .returning({ id: tagsPlant.id, createdAt: tagsPlant.createdAt }); + }
\ No newline at end of file diff --git a/lib/tags-plant/service.ts b/lib/tags-plant/service.ts index 778ab89d..02bd33be 100644 --- a/lib/tags-plant/service.ts +++ b/lib/tags-plant/service.ts @@ -1,14 +1,14 @@ "use server" import db from "@/db/db" -import { formEntries, forms, tagClasses, tags, tagSubfieldOptions, tagSubfields, tagTypes } from "@/db/schema/vendorData" +import { formEntries, forms,items,formsPlant, tagClasses, tags, tagsPlant, tagSubfieldOptions, tagSubfields, tagTypes,formEntriesPlant } from "@/db/schema" // import { eq } from "drizzle-orm" import { createTagSchema, GetTagsSchema, updateTagSchema, UpdateTagSchema, type CreateTagSchema } from "./validations" import { revalidateTag, unstable_noStore } from "next/cache"; import { filterColumns } from "@/lib/filter-columns"; import { unstable_cache } from "@/lib/unstable-cache"; import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq, sql, ne, count, isNull } from "drizzle-orm"; -import { countTags, insertTag, selectTags } from "./repository"; +import { countTags, insertTag, selectTags, selectTagsPlant, countTagsPlant,insertTagPlant } from "./repository"; import { getErrorMessage } from "../handle-error"; import { getFormMappingsByTagType } from './form-mapping-service'; import { contractItems, contracts } from "@/db/schema/contract"; @@ -32,7 +32,8 @@ function generateTagIdx(): string { return randomBytes(12).toString('hex'); // 12바이트 = 24자리 16진수 } -export async function getTags(input: GetTagsSchema, packagesId: number) { + +export async function getTagsPlant(input: GetTagsSchema, projectCode: string,packageCode: string ) { // return unstable_cache( // async () => { @@ -41,7 +42,7 @@ export async function getTags(input: GetTagsSchema, packagesId: number) { // (1) advancedWhere const advancedWhere = filterColumns({ - table: tags, + table: tagsPlant, filters: input.filters, joinOperator: input.joinOperator, }); @@ -51,31 +52,31 @@ export async function getTags(input: GetTagsSchema, packagesId: number) { if (input.search) { const s = `%${input.search}%`; globalWhere = or( - ilike(tags.tagNo, s), - ilike(tags.tagType, s), - ilike(tags.description, s) + ilike(tagsPlant.tagNo, s), + ilike(tagsPlant.tagType, s), + ilike(tagsPlant.description, s) ); } - // (4) 최종 where - const finalWhere = and(advancedWhere, globalWhere, eq(tags.contractItemId, packagesId)); + // (4) 최종 projectCode + const finalWhere = and(advancedWhere, globalWhere, eq(tagsPlant.projectCode, projectCode), eq(tagsPlant.packageCode, packageCode)); // (5) 정렬 const orderBy = input.sort.length > 0 ? input.sort.map((item) => - item.desc ? desc(tags[item.id]) : asc(tags[item.id]) + item.desc ? desc(tagsPlant[item.id]) : asc(tagsPlant[item.id]) ) - : [asc(tags.createdAt)]; + : [asc(tagsPlant.createdAt)]; // 트랜잭션 내부에서 Repository 호출 const { data, total } = await db.transaction(async (tx) => { - const data = await selectTags(tx, { + const data = await selectTagsPlant(tx, { where: finalWhere, orderBy, offset, limit: input.perPage, }); - const total = await countTags(tx, finalWhere); + const total = await countTagsPlant(tx, finalWhere); return { data, total }; @@ -101,9 +102,10 @@ export async function getTags(input: GetTagsSchema, packagesId: number) { export async function createTag( formData: CreateTagSchema, - selectedPackageId: number | null + projectCode: string, + packageCode: string, ) { - if (!selectedPackageId) { + if (!projectCode) { return { error: "No selectedPackageId provided" } } @@ -119,33 +121,23 @@ export async function createTag( try { // 하나의 트랜잭션에서 모든 작업 수행 return await db.transaction(async (tx) => { - // 1) 선택된 contractItem의 contractId 가져오기 - const contractItemResult = await tx - .select({ - contractId: contractItems.contractId, - projectId: contracts.projectId // projectId 추가 - }) - .from(contractItems) - .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) // contracts 테이블 조인 - .where(eq(contractItems.id, selectedPackageId)) - .limit(1) - - if (contractItemResult.length === 0) { - return { error: "Contract item not found" } - } + const project = await db.query.projects.findFirst({ + where: eq(projects.code, projectCode), + columns: { + id: true + } + }); - const contractId = contractItemResult[0].contractId - const projectId = contractItemResult[0].projectId + const projectId = project.id // 2) 해당 계약 내에서 같은 tagNo를 가진 태그가 있는지 확인 const duplicateCheck = await tx .select({ count: sql<number>`count(*)` }) - .from(tags) - .innerJoin(contractItems, eq(tags.contractItemId, contractItems.id)) + .from(tagsPlant) .where( and( - eq(contractItems.contractId, contractId), - eq(tags.tagNo, validated.data.tagNo) + eq(tagsPlant.projectCode, projectCode), + eq(tagsPlant.tagNo, validated.data.tagNo) ) ) @@ -182,16 +174,16 @@ export async function createTag( const createdOrExistingForms: CreatedOrExistingForm[] = [] if (formMappings && formMappings.length > 0) { - console.log(selectedPackageId, formMappings) for (const formMapping of formMappings) { // 4-1) 이미 존재하는 폼인지 확인 const existingForm = await tx - .select({ id: forms.id, im: forms.im, eng: forms.eng }) // eng 필드도 추가로 조회 - .from(forms) + .select({ id: formsPlant.id, im: formsPlant.im, eng: formsPlant.eng }) // eng 필드도 추가로 조회 + .from(formsPlant) .where( and( - eq(forms.contractItemId, selectedPackageId), - eq(forms.formCode, formMapping.formCode) + eq(formsPlant.projectCode, projectCode), + eq(formsPlant.packageCode, packageCode), + eq(formsPlant.formCode, formMapping.formCode) ) ) .limit(1) @@ -219,9 +211,9 @@ export async function createTag( if (shouldUpdate) { await tx - .update(forms) + .update(formsPlant) .set(updateValues) - .where(eq(forms.id, formId)) + .where(eq(formsPlant.id, formId)) console.log(`Form ${formId} updated with:`, updateValues) } @@ -235,7 +227,8 @@ export async function createTag( } else { // 존재하지 않으면 새로 생성 const insertValues: any = { - contractItemId: selectedPackageId, + projectCode: projectCode, + packageCode: packageCode, formCode: formMapping.formCode, formName: formMapping.formName, im: true, @@ -247,9 +240,9 @@ export async function createTag( } const insertResult = await tx - .insert(forms) + .insert(formsPlant) .values(insertValues) - .returning({ id: forms.id, formCode: forms.formCode, formName: forms.formName }) + .returning({ id: formsPlant.id, formCode: formsPlant.formCode, formName: formsPlant.formName }) console.log("insertResult:", insertResult) formId = insertResult[0].id @@ -273,8 +266,9 @@ export async function createTag( console.log(`[CREATE TAG] Generated tagIdx: ${generatedTagIdx}`); // 5) 새 Tag 생성 (tagIdx 추가) - const [newTag] = await insertTag(tx, { - contractItemId: selectedPackageId, + const [newTag] = await insertTagPlant(tx, { + packageCode:packageCode, + projectCode:projectCode, formId: primaryFormId, tagIdx: generatedTagIdx, // 🆕 생성된 16진수 24자리 추가 tagNo: validated.data.tagNo, @@ -283,7 +277,6 @@ export async function createTag( description: validated.data.description ?? null, }) - console.log(`tags-${selectedPackageId}`, "create", newTag) // 6) 생성된 각 form에 대해 formEntries에 데이터 추가 (TAG_IDX 포함) for (const form of createdOrExistingForms) { @@ -291,8 +284,9 @@ export async function createTag( // 기존 formEntry 가져오기 const existingEntry = await tx.query.formEntries.findFirst({ where: and( - eq(formEntries.formCode, form.formCode), - eq(formEntries.contractItemId, selectedPackageId) + eq(formEntriesPlant.formCode, form.formCode), + eq(formEntriesPlant.projectCode, projectCode), + eq(formEntriesPlant.packageCode, packageCode) ) }); @@ -329,12 +323,12 @@ export async function createTag( const updatedData = [...existingData, newTagData]; await tx - .update(formEntries) + .update(formEntriesPlant) .set({ data: updatedData, updatedAt: new Date() }) - .where(eq(formEntries.id, existingEntry.id)); + .where(eq(formEntriesPlant.id, existingEntry.id)); console.log(`[CREATE TAG] Added tag ${validated.data.tagNo} with tagIdx ${generatedTagIdx} to existing formEntry for form ${form.formCode}`); } else { @@ -342,9 +336,10 @@ export async function createTag( } } else { // formEntry가 없는 경우 새로 생성 (TAG_IDX 포함) - await tx.insert(formEntries).values({ + await tx.insert(formEntriesPlant).values({ formCode: form.formCode, - contractItemId: selectedPackageId, + projectCode: projectCode, + packageCode: packageCode, data: [newTagData], createdAt: new Date(), updatedAt: new Date(), @@ -358,16 +353,6 @@ export async function createTag( } } - // 7) 캐시 무효화 (React 서버 액션에서 캐싱 사용 시) - revalidateTag(`tags-${selectedPackageId}`) - revalidateTag(`forms-${selectedPackageId}-ENG`) - revalidateTag("tags") - - // 생성된 각 form의 캐시도 무효화 - createdOrExistingForms.forEach(form => { - revalidateTag(`form-data-${form.formCode}-${selectedPackageId}`) - }) - // 8) 성공 시 반환 (tagIdx 추가) return { success: true, @@ -666,10 +651,11 @@ export async function createTagInForm( export async function updateTag( formData: UpdateTagSchema & { id: number }, - selectedPackageId: number | null + projectCode: string, + packageCode: string, ) { - if (!selectedPackageId) { - return { error: "No selectedPackageId provided" } + if (!projectCode) { + return { error: "No projectCode provided" } } if (!formData.id) { @@ -701,35 +687,25 @@ export async function updateTag( const originalTag = existingTag[0] - // 2) 선택된 contractItem의 contractId 가져오기 - const contractItemResult = await tx - .select({ - contractId: contractItems.contractId, - projectId: contracts.projectId // projectId 추가 - }) - .from(contractItems) - .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) // contracts 테이블 조인 - .where(eq(contractItems.id, selectedPackageId)) - .limit(1) - - if (contractItemResult.length === 0) { - return { error: "Contract item not found" } - } + const project = await db.query.projects.findFirst({ + where: eq(projects.code, projectCode), + columns: { + id: true + } + }); - const contractId = contractItemResult[0].contractId - const projectId = contractItemResult[0].projectId + const projectId = project.id // 3) 태그 번호가 변경되었고, 해당 계약 내에서 같은 tagNo를 가진 다른 태그가 있는지 확인 if (originalTag.tagNo !== validated.data.tagNo) { const duplicateCheck = await tx .select({ count: sql<number>`count(*)` }) - .from(tags) - .innerJoin(contractItems, eq(tags.contractItemId, contractItems.id)) + .from(tagsPlant) .where( and( - eq(contractItems.contractId, contractId), - eq(tags.tagNo, validated.data.tagNo), - ne(tags.id, formData.id) // 자기 자신은 제외 + eq(tagsPlant.projectCode, projectCode), + eq(tagsPlant.tagNo, validated.data.tagNo), + ne(tagsPlant.id, formData.id) // 자기 자신은 제외 ) ) @@ -774,11 +750,12 @@ export async function updateTag( // 이미 존재하는 폼인지 확인 const existingForm = await tx .select({ id: forms.id }) - .from(forms) + .from(formsPlant) .where( and( - eq(forms.contractItemId, selectedPackageId), - eq(forms.formCode, formMapping.formCode) + eq(formsPlant.projectCode, projectCode), + eq(formsPlant.packageCode, packageCode), + eq(formsPlant.formCode, formMapping.formCode) ) ) .limit(1) @@ -796,13 +773,14 @@ export async function updateTag( } else { // 존재하지 않으면 새로 생성 const insertResult = await tx - .insert(forms) + .insert(formsPlant) .values({ - contractItemId: selectedPackageId, + projectCode, + packageCode, formCode: formMapping.formCode, formName: formMapping.formName, }) - .returning({ id: forms.id, formCode: forms.formCode, formName: forms.formName }) + .returning({ id: formsPlant.id, formCode: formsPlant.formCode, formName: formsPlant.formName }) formId = insertResult[0].id createdOrExistingForms.push({ @@ -823,9 +801,10 @@ export async function updateTag( // 5) 태그 업데이트 const [updatedTag] = await tx - .update(tags) + .update(tagsPlant) .set({ - contractItemId: selectedPackageId, + projectCode, + packageCode, formId: primaryFormId, tagNo: validated.data.tagNo, class: validated.data.class, @@ -833,12 +812,9 @@ export async function updateTag( description: validated.data.description ?? null, updatedAt: new Date(), }) - .where(eq(tags.id, formData.id)) + .where(eq(tagsPlant.id, formData.id)) .returning() - // 6) 캐시 무효화 (React 서버 액션에서 캐싱 사용 시) - revalidateTag(`tags-${selectedPackageId}`) - revalidateTag(`forms-${selectedPackageId}`) revalidateTag("tags") // 7) 성공 시 반환 @@ -867,7 +843,8 @@ export interface TagInputData { // 새로운 서버 액션 export async function bulkCreateTags( tagsfromExcel: TagInputData[], - selectedPackageId: number + projectCode: string, + packageCode: string ) { unstable_noStore(); @@ -879,31 +856,22 @@ export async function bulkCreateTags( // 단일 트랜잭션으로 모든 작업 처리 return await db.transaction(async (tx) => { // 1. 컨트랙트 ID 및 프로젝트 ID 조회 (한 번만) - const contractItemResult = await tx - .select({ - contractId: contractItems.contractId, - projectId: contracts.projectId // projectId 추가 - }) - .from(contractItems) - .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) // contracts 테이블 조인 - .where(eq(contractItems.id, selectedPackageId)) - .limit(1); - - if (contractItemResult.length === 0) { - return { error: "Contract item not found" }; - } + const project = await db.query.projects.findFirst({ + where: eq(projects.code, projectCode), + columns: { + id: true + } + }); - const contractId = contractItemResult[0].contractId; - const projectId = contractItemResult[0].projectId; // projectId 추출 + const projectId = project.id // 2. 모든 태그 번호 중복 검사 (한 번에) const tagNos = tagsfromExcel.map(tag => tag.tagNo); const duplicateCheck = await tx .select({ tagNo: tags.tagNo }) .from(tags) - .innerJoin(contractItems, eq(tags.contractItemId, contractItems.id)) .where(and( - eq(contractItems.contractId, contractId), + eq(tags.projectCode, projectCode), inArray(tags.tagNo, tagNos) )); @@ -969,12 +937,13 @@ export async function bulkCreateTags( for (const formMapping of formMappings) { // 해당 폼이 이미 존재하는지 확인 const existingForm = await tx - .select({ id: forms.id, im: forms.im }) - .from(forms) + .select({ id: formsPlant.id, im: formsPlant.im }) + .from(formsPlant) .where( and( - eq(forms.contractItemId, selectedPackageId), - eq(forms.formCode, formMapping.formCode) + eq(formsPlant.packageCode, packageCode), + eq(formsPlant.projectCode, projectCode), + eq(formsPlant.formCode, formMapping.formCode) ) ) .limit(1); @@ -987,9 +956,9 @@ export async function bulkCreateTags( // im 필드 업데이트 (필요한 경우) if (existingForm[0].im !== true) { await tx - .update(forms) + .update(formsPlant) .set({ im: true }) - .where(eq(forms.id, formId)); + .where(eq(formsPlant.id, formId)); } createdOrExistingForms.push({ @@ -1001,14 +970,15 @@ export async function bulkCreateTags( } else { // 존재하지 않으면 새로 생성 const insertResult = await tx - .insert(forms) + .insert(formsPlant) .values({ - contractItemId: selectedPackageId, + packageCode:packageCode, + projectCode:projectCode, formCode: formMapping.formCode, formName: formMapping.formName, im: true }) - .returning({ id: forms.id, formCode: forms.formCode, formName: forms.formName }); + .returning({ id: formsPlant.id, formCode: formsPlant.formCode, formName: formsPlant.formName }); formId = insertResult[0].id; createdOrExistingForms.push({ @@ -1048,8 +1018,9 @@ export async function bulkCreateTags( } // 태그 생성 - const [newTag] = await insertTag(tx, { - contractItemId: selectedPackageId, + const [newTag] = await insertTagPlant(tx, { + packageCode:packageCode, + projectCode:projectCode, formId: primaryFormId, tagNo: tagData.tagNo, class: tagData.class || "", @@ -1067,14 +1038,15 @@ export async function bulkCreateTags( }); } - // 4. formEntries 업데이트 처리 + // 4. formEntriesPlant 업데이트 처리 for (const [formCode, newTagsData] of tagsByFormCode.entries()) { try { // 기존 formEntry 가져오기 - const existingEntry = await tx.query.formEntries.findFirst({ + const existingEntry = await tx.query.formEntriesPlant.findFirst({ where: and( - eq(formEntries.formCode, formCode), - eq(formEntries.contractItemId, selectedPackageId) + eq(formEntriesPlant.formCode, formCode), + eq(formEntriesPlant.packageCode, packageCode), + eq(formEntriesPlant.projectCode, projectCode) ) }); @@ -1103,12 +1075,12 @@ export async function bulkCreateTags( const updatedData = [...existingData, ...newUniqueTagsData]; await tx - .update(formEntries) + .update(formEntriesPlant) .set({ data: updatedData, updatedAt: new Date() }) - .where(eq(formEntries.id, existingEntry.id)); + .where(eq(formEntriesPlant.id, existingEntry.id)); console.log(`[BULK CREATE] Added ${newUniqueTagsData.length} tags to existing formEntry for form ${formCode}`); } else { @@ -1116,9 +1088,10 @@ export async function bulkCreateTags( } } else { // formEntry가 없는 경우 새로 생성 - await tx.insert(formEntries).values({ + await tx.insert(formEntriesPlant).values({ formCode: formCode, - contractItemId: selectedPackageId, + projectCode:projectCode, + packageCode:packageCode, data: newTagsData, createdAt: new Date(), updatedAt: new Date(), @@ -1132,16 +1105,6 @@ export async function bulkCreateTags( } } - // 5. 캐시 무효화 (한 번만) - revalidateTag(`tags-${selectedPackageId}`); - revalidateTag(`forms-${selectedPackageId}`); - revalidateTag("tags"); - - // 업데이트된 모든 form의 캐시도 무효화 - for (const formCode of tagsByFormCode.keys()) { - revalidateTag(`form-data-${formCode}-${selectedPackageId}`); - } - return { success: true, data: { @@ -1160,7 +1123,8 @@ export async function bulkCreateTags( /** 복수 삭제 */ interface RemoveTagsInput { ids: number[]; - selectedPackageId: number; + projectCode: string; + packageCode: string; } @@ -1178,36 +1142,29 @@ function removeTagFromDataJson( export async function removeTags(input: RemoveTagsInput) { unstable_noStore() // React 서버 액션 무상태 함수 - const { ids, selectedPackageId } = input + const { ids, projectCode, packageCode } = input try { await db.transaction(async (tx) => { - const packageInfo = await tx - .select({ - projectId: contracts.projectId - }) - .from(contractItems) - .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) - .where(eq(contractItems.id, selectedPackageId)) - .limit(1); - - if (packageInfo.length === 0) { - throw new Error(`Contract item with ID ${selectedPackageId} not found`); - } - - const projectId = packageInfo[0].projectId; + const project = await db.query.projects.findFirst({ + where: eq(projects.code, projectCode), + columns: { + id: true + } + }); + const projectId = project.id; // 1) 삭제 대상 tag들을 미리 조회 const tagsToDelete = await tx .select({ - id: tags.id, - tagNo: tags.tagNo, - tagType: tags.tagType, - class: tags.class, + id: tagsPlant.id, + tagNo: tagsPlant.tagNo, + tagType: tagsPlant.tagType, + class: tagsPlant.class, }) - .from(tags) - .where(inArray(tags.id, ids)) + .from(tagsPlant) + .where(inArray(tagsPlant.id, ids)) // 2) 태그 타입과 클래스의 고유 조합 추출 const uniqueTypeClassCombinations = [...new Set( @@ -1222,13 +1179,14 @@ export async function removeTags(input: RemoveTagsInput) { // 3-1) 삭제 중인 태그들 외에, 동일한 태그 타입/클래스를 가진 다른 태그가 있는지 확인 const otherTagsWithSameTypeClass = await tx .select({ count: count() }) - .from(tags) + .from(tagsPlant) .where( and( - eq(tags.tagType, tagType), - classValue ? eq(tags.class, classValue) : isNull(tags.class), - not(inArray(tags.id, ids)), // 현재 삭제 중인 태그들은 제외 - eq(tags.contractItemId, selectedPackageId) // 같은 contractItemId 내에서만 확인 + eq(tagsPlant.tagType, tagType), + classValue ? eq(tagsPlant.class, classValue) : isNull(tagsPlant.class), + not(inArray(tagsPlant.id, ids)), + eq(tags.packageCode, packageCode), + eq(tags.projectCode, projectCode) // 같은 contractItemId 내에서만 확인 ) ) @@ -1249,21 +1207,23 @@ export async function removeTags(input: RemoveTagsInput) { if (otherTagsWithSameTypeClass[0].count === 0) { // 폼 삭제 await tx - .delete(forms) + .delete(formsPlant) .where( and( - eq(forms.contractItemId, selectedPackageId), - eq(forms.formCode, formMapping.formCode) + eq(formsPlant.projectCode, projectCode), + eq(formsPlant.packageCode, packageCode), + eq(formsPlant.formCode, formMapping.formCode) ) ) // formEntries 테이블에서도 해당 formCode 관련 데이터 삭제 await tx - .delete(formEntries) + .delete(formEntriesPlant) .where( and( - eq(formEntries.contractItemId, selectedPackageId), - eq(formEntries.formCode, formMapping.formCode) + eq(formEntriesPlant.projectCode, projectCode), + eq(formEntriesPlant.packageCode, packageCode), + eq(formEntriesPlant.formCode, formMapping.formCode) ) ) } @@ -1271,14 +1231,15 @@ export async function removeTags(input: RemoveTagsInput) { else if (relevantTagNos.length > 0) { const formEntryRecords = await tx .select({ - id: formEntries.id, - data: formEntries.data, + id: formEntriesPlant.id, + data: formEntriesPlant.data, }) - .from(formEntries) + .from(formEntriesPlant) .where( and( - eq(formEntries.contractItemId, selectedPackageId), - eq(formEntries.formCode, formMapping.formCode) + eq(formEntriesPlant.projectCode, projectCode), + eq(formEntriesPlant.packageCode, packageCode), + eq(formEntriesPlant.formCode, formMapping.formCode) ) ) @@ -1305,9 +1266,6 @@ export async function removeTags(input: RemoveTagsInput) { await tx.delete(tags).where(inArray(tags.id, ids)) }) - // 5) 캐시 무효화 - revalidateTag(`tags-${selectedPackageId}`) - revalidateTag(`forms-${selectedPackageId}`) return { data: null, error: null } } catch (err) { @@ -1328,25 +1286,26 @@ export interface ClassOption { * Class 옵션 목록을 가져오는 함수 * 이제 각 클래스는 연결된 tagTypeCode와 tagTypeDescription을 포함 */ -export async function getClassOptions(selectedPackageId: number): Promise<UpdatedClassOption[]> { +export async function getClassOptions( + packageCode: string, + projectCode: string +): Promise<UpdatedClassOption[]> { try { - // 1. 먼저 contractItems에서 projectId 조회 - const packageInfo = await db - .select({ - projectId: contracts.projectId - }) - .from(contractItems) - .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) - .where(eq(contractItems.id, selectedPackageId)) + // 1. 프로젝트 정보 조회 + const projectInfo = await db + .select() + .from(projects) + .where(eq(projects.code, projectCode)) .limit(1); - if (packageInfo.length === 0) { - throw new Error(`Contract item with ID ${selectedPackageId} not found`); + if (projectInfo.length === 0) { + throw new Error(`Project with code ${projectCode} not found`); } - const projectId = packageInfo[0].projectId; + const projectId = projectInfo[0].id; + - // 2. 태그 클래스들을 서브클래스 정보와 함께 조회 + // 3. 태그 클래스들을 서브클래스 정보와 함께 조회 const tagClassesWithSubclasses = await db .select({ id: tagClasses.id, @@ -1360,8 +1319,8 @@ export async function getClassOptions(selectedPackageId: number): Promise<Update .where(eq(tagClasses.projectId, projectId)) .orderBy(tagClasses.code); - // 3. 태그 타입 정보도 함께 조회 (description을 위해) - const tagTypesMap = new Map(); + // 4. 태그 타입 정보도 함께 조회 (description을 위해) + const tagTypesMap = new Map<string, string>(); const tagTypesList = await db .select({ code: tagTypes.code, @@ -1370,21 +1329,24 @@ export async function getClassOptions(selectedPackageId: number): Promise<Update .from(tagTypes) .where(eq(tagTypes.projectId, projectId)); - tagTypesList.forEach(tagType => { + tagTypesList.forEach((tagType) => { tagTypesMap.set(tagType.code, tagType.description); }); - // 4. 클래스 옵션으로 변환 - const classOptions: UpdatedClassOption[] = tagClassesWithSubclasses.map(cls => ({ - value: cls.code, - label: cls.label, - code: cls.code, - description: cls.label, - tagTypeCode: cls.tagTypeCode, - tagTypeDescription: tagTypesMap.get(cls.tagTypeCode) || cls.tagTypeCode, - subclasses: cls.subclasses || [], - subclassRemark: cls.subclassRemark || {}, - })); + // 5. 클래스 옵션으로 변환 + const classOptions: UpdatedClassOption[] = tagClassesWithSubclasses.map( + (cls) => ({ + value: cls.code, + label: cls.label, + code: cls.code, + description: cls.label, + tagTypeCode: cls.tagTypeCode, + tagTypeDescription: + tagTypesMap.get(cls.tagTypeCode) || cls.tagTypeCode, + subclasses: cls.subclasses || [], + subclassRemark: cls.subclassRemark || {}, + }) + ); return classOptions; } catch (error) { @@ -1392,6 +1354,8 @@ export async function getClassOptions(selectedPackageId: number): Promise<Update throw new Error("Failed to fetch class options"); } } + + interface SubFieldDef { name: string label: string @@ -1403,26 +1367,20 @@ interface SubFieldDef { export async function getSubfieldsByTagType( tagTypeCode: string, - selectedPackageId: number, + projectCode: string, subclassRemark: string = "", subclass: string = "", ) { try { - // 1. 먼저 contractItems에서 projectId 조회 - const packageInfo = await db - .select({ - projectId: contracts.projectId - }) - .from(contractItems) - .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) - .where(eq(contractItems.id, selectedPackageId)) - .limit(1); + const project = await db.query.projects.findFirst({ + where: eq(projects.code, projectCode), + columns: { + id: true + } + }); - if (packageInfo.length === 0) { - throw new Error(`Contract item with ID ${selectedPackageId} not found`); - } - const projectId = packageInfo[0].projectId; + const projectId = project.id // 2. 올바른 projectId를 사용하여 tagSubfields 조회 const rows = await db @@ -1623,29 +1581,314 @@ export interface TagTypeOption { label: string; // tagTypes.description 값 } -export async function getProjectIdFromContractItemId(contractItemId: number): Promise<number | null> { +export async function getProjectIdFromContractItemId( + projectCode: string +): Promise<number | null> { try { // First get the contractId from contractItems - const contractItem = await db.query.contractItems.findFirst({ - where: eq(contractItems.id, contractItemId), + const project = await db.query.projects.findFirst({ + where: eq(projects.code, projectCode), columns: { - contractId: true + id: true } }); - if (!contractItem) return null; - - // Then get the projectId from contracts - const contract = await db.query.contracts.findFirst({ - where: eq(contracts.id, contractItem.contractId), - columns: { - projectId: true - } - }); + if (!project) return null; - return contract?.projectId || null; + return project?.id || null; } catch (error) { console.error("Error fetching projectId:", error); return null; } +} + +const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api'; + + +/** + * Engineering 폼 목록 가져오기 + */ +export async function getEngineeringForms( + projectCode: string, + packageCode: string +): Promise<FormInfo[]> { + try { + // 1. DB에서 eng=true인 폼 조회 + const existingForms = await db + .select({ + formCode: formsPlant.formCode, + formName: formsPlant.formName, + }) + .from(formsPlant) + .where( + and( + eq(formsPlant.projectCode, projectCode), + eq(formsPlant.packageCode, packageCode), + eq(formsPlant.eng, true) + ) + ) + + // DB에 데이터가 있으면 반환 + if (existingForms.length > 0) { + return existingForms + } + + // 2. DB에 없으면 SEDP API에서 가져오기 + const apiKey = await getSEDPToken() + + // 2-1. GetByToolID로 레지스터 매핑 정보 가져오기 + const mappingResponse = await fetch( + `${SEDP_API_BASE_URL}/AdapterDataMapping/GetByToolID`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'accept': '*/*', + 'ApiKey': apiKey, + 'ProjectNo': projectCode + }, + body: JSON.stringify({ + ProjectNo: projectCode, + TOOL_ID: "eVCP" + }) + } + ) + + if (!mappingResponse.ok) { + throw new Error( + `레지스터 매핑 요청 실패: ${mappingResponse.status} ${mappingResponse.statusText}` + ) + } + + const mappingData = await mappingResponse.json() + const registers: NewRegister[] = Array.isArray(mappingData) + ? mappingData + : [mappingData] + + // 2-2. packageCode가 SCOPES에 포함된 레지스터 필터링 + const matchingRegisters = registers.filter(register => + register.SCOPES.includes(packageCode) + ) + + if (matchingRegisters.length === 0) { + console.log(`패키지 ${packageCode}에 해당하는 레지스터가 없습니다.`) + return [] + } + + // 2-3. 각 레지스터의 상세 정보 가져오기 + const formInfos: FormInfo[] = [] + const formsToInsert: typeof formsPlant.$inferInsert[] = [] + + for (const register of matchingRegisters) { + try { + const detailResponse = await fetch( + `${SEDP_API_BASE_URL}/Register/GetByID`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'accept': '*/*', + 'ApiKey': apiKey, + 'ProjectNo': projectCode + }, + body: JSON.stringify({ + ProjectNo: projectCode, + TYPE_ID: register.REG_TYPE_ID, + ContainDeleted: false + }) + } + ) + + if (!detailResponse.ok) { + console.error( + `레지스터 상세 정보 요청 실패 (${register.REG_TYPE_ID}): ${detailResponse.status}` + ) + continue + } + + const detail: RegisterDetail = await detailResponse.json() + + // DELETED가 true이거나 DESC가 없으면 스킵 + if (detail.DELETED || !detail.DESC) { + continue + } + + formInfos.push({ + formCode: detail.TYPE_ID, + formName: detail.DESC + }) + + // DB 삽입용 데이터 준비 + formsToInsert.push({ + projectCode: projectCode, + packageCode: packageCode, + formCode: detail.TYPE_ID, + formName: detail.DESC, + eng: true, + im: false + }) + } catch (error) { + console.error( + `레지스터 ${register.REG_TYPE_ID} 상세 정보 가져오기 실패:`, + error + ) + continue + } + } + + // 2-4. DB에 저장 + if (formsToInsert.length > 0) { + await db.insert(formsPlant).values(formsToInsert).onConflictDoNothing() + console.log(`${formsToInsert.length}개의 Engineering 폼을 DB에 저장했습니다.`) + } + + return formInfos + } catch (error) { + console.error("Engineering 폼 가져오기 실패:", error) + throw new Error("Failed to fetch engineering forms") + } +} + +/** + * IM 폼 목록 가져오기 + */ +export async function getIMForms( + projectCode: string, + packageCode: string +): Promise<FormInfo[]> { + try { + // 1. DB에서 im=true인 폼 조회 + const existingForms = await db + .select({ + formCode: formsPlant.formCode, + formName: formsPlant.formName, + }) + .from(formsPlant) + .where( + and( + eq(formsPlant.projectCode, projectCode), + eq(formsPlant.packageCode, packageCode), + eq(formsPlant.im, true) + ) + ) + + // DB에 데이터가 있으면 반환 + if (existingForms.length > 0) { + return existingForms + } + + // 2. DB에 없으면 SEDP API에서 가져오기 + const apiKey = await getSEDPToken() + + // 2-1. GetByToolID로 레지스터 매핑 정보 가져오기 + const mappingResponse = await fetch( + `${SEDP_API_BASE_URL}/AdapterDataMapping/GetByToolID`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'accept': '*/*', + 'ApiKey': apiKey, + 'ProjectNo': projectCode + }, + body: JSON.stringify({ + ProjectNo: projectCode, + TOOL_ID: "eVCP" + }) + } + ) + + if (!mappingResponse.ok) { + throw new Error( + `레지스터 매핑 요청 실패: ${mappingResponse.status} ${mappingResponse.statusText}` + ) + } + + const mappingData = await mappingResponse.json() + const registers: NewRegister[] = Array.isArray(mappingData) + ? mappingData + : [mappingData] + + // 2-2. packageCode가 SCOPES에 포함된 레지스터 필터링 + const matchingRegisters = registers.filter(register => + register.SCOPES.includes(packageCode) + ) + + if (matchingRegisters.length === 0) { + console.log(`패키지 ${packageCode}에 해당하는 레지스터가 없습니다.`) + return [] + } + + // 2-3. 각 레지스터의 상세 정보 가져오기 + const formInfos: FormInfo[] = [] + const formsToInsert: typeof formsPlant.$inferInsert[] = [] + + for (const register of matchingRegisters) { + try { + const detailResponse = await fetch( + `${SEDP_API_BASE_URL}/Register/GetByID`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'accept': '*/*', + 'ApiKey': apiKey, + 'ProjectNo': projectCode + }, + body: JSON.stringify({ + ProjectNo: projectCode, + TYPE_ID: register.REG_TYPE_ID, + ContainDeleted: false + }) + } + ) + + if (!detailResponse.ok) { + console.error( + `레지스터 상세 정보 요청 실패 (${register.REG_TYPE_ID}): ${detailResponse.status}` + ) + continue + } + + const detail: RegisterDetail = await detailResponse.json() + + // DELETED가 true이거나 DESC가 없으면 스킵 + if (detail.DELETED || !detail.DESC) { + continue + } + + formInfos.push({ + formCode: detail.TYPE_ID, + formName: detail.DESC + }) + + // DB 삽입용 데이터 준비 + formsToInsert.push({ + projectCode: projectCode, + packageCode: packageCode, + formCode: detail.TYPE_ID, + formName: detail.DESC, + eng: false, + im: true + }) + } catch (error) { + console.error( + `레지스터 ${register.REG_TYPE_ID} 상세 정보 가져오기 실패:`, + error + ) + continue + } + } + + // 2-4. DB에 저장 + if (formsToInsert.length > 0) { + await db.insert(formsPlant).values(formsToInsert).onConflictDoNothing() + console.log(`${formsToInsert.length}개의 IM 폼을 DB에 저장했습니다.`) + } + + return formInfos + } catch (error) { + console.error("IM 폼 가져오기 실패:", error) + throw new Error("Failed to fetch IM forms") + } }
\ No newline at end of file diff --git a/lib/tags-plant/table/add-tag-dialog.tsx b/lib/tags-plant/table/add-tag-dialog.tsx index 9c82bf1a..41731f63 100644 --- a/lib/tags-plant/table/add-tag-dialog.tsx +++ b/lib/tags-plant/table/add-tag-dialog.tsx @@ -61,7 +61,7 @@ import { getClassOptions, type ClassOption, TagTypeOption, -} from "@/lib/tags/service" +} from "@/lib/tags-plant/service" import { ScrollArea } from "@/components/ui/scroll-area" // Updated to support multiple rows and subclass @@ -98,10 +98,11 @@ interface UpdatedClassOption extends ClassOption { } interface AddTagDialogProps { - selectedPackageId: number + projectCode: string + packageCode: string } -export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) { +export function AddTagDialog({ projectCode, packageCode }: AddTagDialogProps) { const router = useRouter() const params = useParams() const lng = (params?.lng as string) || "ko" @@ -125,7 +126,6 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) { const fieldIdsRef = React.useRef<Record<string, string>>({}) const classOptionIdsRef = React.useRef<Record<string, string>>({}) - console.log(selectedPackageId, "tag") // --------------- // Load Class Options (서브클래스 정보 포함) @@ -135,7 +135,7 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) { setIsLoadingClasses(true) try { // getClassOptions 함수가 서브클래스 정보도 포함하도록 수정되었다고 가정 - const result = await getClassOptions(selectedPackageId) + const result = await getClassOptions(packageCode, projectCode) setClassOptions(result) } catch (err) { toast.error(t("toast.classOptionsLoadFailed")) @@ -147,7 +147,7 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) { if (open) { loadClassOptions() } - }, [open, selectedPackageId]) + }, [open, projectCode, packageCode]) // --------------- // react-hook-form with fieldArray support for multiple rows @@ -176,7 +176,7 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) { setIsLoadingSubFields(true) try { // 수정된 getSubfieldsByTagType 함수 호출 (subclassRemark 파라미터 추가) - const { subFields: apiSubFields } = await getSubfieldsByTagType(tagTypeCode, selectedPackageId, subclassRemark, subclass) + const { subFields: apiSubFields } = await getSubfieldsByTagType(tagTypeCode, projectCode, subclassRemark, subclass) const formattedSubFields: SubFieldDef[] = apiSubFields.map(field => ({ name: field.name, label: field.label, @@ -313,7 +313,7 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) { // Submit handler for multiple tags (서브클래스 정보 포함) // --------------- async function onSubmit(data: MultiTagFormValues) { - if (!selectedPackageId) { + if (!projectCode) { toast.error(t("toast.noSelectedPackageId")); return; } @@ -343,7 +343,7 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) { }; try { - const res = await createTag(tagData, selectedPackageId); + const res = await createTag(tagData, projectCode, packageCode); if ("error" in res) { console.log(res.error) failedTags.push({ tag: row.tagNo, error: res.error }); diff --git a/lib/tags-plant/table/delete-tags-dialog.tsx b/lib/tags-plant/table/delete-tags-dialog.tsx index 6a024cda..69a4f4a6 100644 --- a/lib/tags-plant/table/delete-tags-dialog.tsx +++ b/lib/tags-plant/table/delete-tags-dialog.tsx @@ -4,7 +4,6 @@ import * as React from "react" import { type Row } from "@tanstack/react-table" import { Loader, Trash } from "lucide-react" import { toast } from "sonner" - import { useMediaQuery } from "@/hooks/use-media-query" import { Button } from "@/components/ui/button" import { @@ -27,15 +26,15 @@ import { DrawerTitle, DrawerTrigger, } from "@/components/ui/drawer" - -import { removeTags } from "@/lib//tags/service" +import { removeTags } from "@/lib//tags-plant/service" import { Tag } from "@/db/schema/vendorData" interface DeleteTasksDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> { tags: Row<Tag>["original"][] showTrigger?: boolean - selectedPackageId: number + projectCode: string + packageCode: string onSuccess?: () => void } @@ -43,7 +42,8 @@ export function DeleteTagsDialog({ tags, showTrigger = true, onSuccess, - selectedPackageId, + projectCode, + packageCode, ...props }: DeleteTasksDialogProps) { const [isDeletePending, startDeleteTransition] = React.useTransition() @@ -52,7 +52,7 @@ export function DeleteTagsDialog({ function onDelete() { startDeleteTransition(async () => { const { error } = await removeTags({ - ids: tags.map((tag) => tag.id),selectedPackageId + ids: tags.map((tag) => tag.id),projectCode, packageCode }) if (error) { diff --git a/lib/tags-plant/table/tag-table.tsx b/lib/tags-plant/table/tag-table.tsx index 1986d933..2fdcd5fc 100644 --- a/lib/tags-plant/table/tag-table.tsx +++ b/lib/tags-plant/table/tag-table.tsx @@ -1,3 +1,4 @@ +// components/vendor-data-plant/tags-table.tsx "use client" import * as React from "react" @@ -6,40 +7,177 @@ import type { DataTableFilterField, DataTableRowAction, } from "@/types/table" +import { useRouter } from "next/navigation" +import { toast } from "sonner" +import { Trash2, Download, Upload, Loader2, RefreshCcw, Plus } from "lucide-react" +import ExcelJS from "exceljs" +import type { Table as TanstackTable } from "@tanstack/react-table" -import { toSentenceCase } from "@/lib/utils" -import { useDataTable } from "@/hooks/use-data-table" -import { DataTable } from "@/components/data-table/data-table" -import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" - +import { ClientDataTable } from "@/components/client-data-table/data-table" import { getColumns } from "./tag-table-column" import { Tag } from "@/db/schema/vendorData" import { DeleteTagsDialog } from "./delete-tags-dialog" -import { TagsTableToolbarActions } from "./tags-table-toolbar-actions" -import { TagsTableFloatingBar } from "./tags-table-floating-bar" -import { getTags } from "../service" import { UpdateTagSheet } from "./update-tag-sheet" +import { AddTagDialog } from "./add-tag-dialog" import { useAtomValue } from 'jotai' import { selectedModeAtom } from '@/atoms' +import { Skeleton } from "@/components/ui/skeleton" +import type { ColumnDef } from "@tanstack/react-table" +import { createDynamicAttributeColumns } from "../column-builder.service" +import { getAllTagsPlant, getUniqueAttributeKeys } from "../queries" +import { Button } from "@/components/ui/button" +import { exportTagsToExcel } from "./tags-export" +import { + bulkCreateTags, + getClassOptions, + getProjectIdFromContractItemId, + getSubfieldsByTagType +} from "../service" +import { decryptWithServerAction } from "@/components/drm/drmUtils" -// 여기서 받은 `promises`로부터 태그 목록을 가져와 상태를 세팅 -// 예: "selectedPackageId"는 props로 전달 interface TagsTableProps { - promises: Promise< [ Awaited<ReturnType<typeof getTags>> ] > - selectedPackageId: number + projectCode: string + packageCode: string +} + +// 태그 넘버링 룰 인터페이스 (Import용) +interface TagNumberingRule { + attributesId: string; + attributesDescription: string; + expression: string | null; + delimiter: string | null; + sortOrder: number; +} + +interface ClassOption { + code: string; + label: string; + tagTypeCode: string; + tagTypeDescription: string; +} + +interface SubFieldDef { + name: string; + label: string; + type: "select" | "text"; + options?: { value: string; label: string }[]; + expression?: string; + delimiter?: string; } -export function TagsTable({ promises, selectedPackageId }: TagsTableProps) { - // 1) 데이터를 가져옴 (server component -> use(...) pattern) - const [{ data, pageCount }] = React.use(promises) +export function TagsTable({ + projectCode, + packageCode, +}: TagsTableProps) { + const router = useRouter() const selectedMode = useAtomValue(selectedModeAtom) + // 상태 관리 + const [tableData, setTableData] = React.useState<Tag[]>([]) + const [columns, setColumns] = React.useState<ColumnDef<Tag>[]>([]) + const [isLoading, setIsLoading] = React.useState(true) const [rowAction, setRowAction] = React.useState<DataTableRowAction<Tag> | null>(null) + + // 선택된 행 관리 + const [selectedRowsData, setSelectedRowsData] = React.useState<Tag[]>([]) + const [clearSelection, setClearSelection] = React.useState(false) + + // 다이얼로그 상태 + const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false) + const [deleteTarget, setDeleteTarget] = React.useState<Tag[]>([]) + const [addTagDialogOpen, setAddTagDialogOpen] = React.useState(false) - const columns = React.useMemo( - () => getColumns({ setRowAction }), - [setRowAction] - ) + // Import/Export 상태 + const [isPending, setIsPending] = React.useState(false) + const [isExporting, setIsExporting] = React.useState(false) + const fileInputRef = React.useRef<HTMLInputElement>(null) + + // Sync 상태 + const [isSyncing, setIsSyncing] = React.useState(false) + const [syncId, setSyncId] = React.useState<string | null>(null) + const pollingRef = React.useRef<NodeJS.Timeout | null>(null) + + // Table ref for export + const tableRef = React.useRef<TanstackTable<Tag> | null>(null) + + // Cache for validation + const [classOptions, setClassOptions] = React.useState<ClassOption[]>([]) + const [subfieldCache, setSubfieldCache] = React.useState<Record<string, SubFieldDef[]>>({}) + const [projectId, setProjectId] = React.useState<number | null>(null) + + // Load project ID + React.useEffect(() => { + const fetchProjectId = async () => { + if (packageCode && projectCode) { + try { + const pid = await getProjectIdFromContractItemId(projectCode) + setProjectId(pid) + } catch (error) { + console.error("Failed to fetch project ID:", error) + } + } + } + fetchProjectId() + }, [projectCode]) + + // Load class options + React.useEffect(() => { + const loadClassOptions = async () => { + try { + const options = await getClassOptions(packageCode, projectCode) + setClassOptions(options) + } catch (error) { + console.error("Failed to load class options:", error) + } + } + loadClassOptions() + }, [packageCode, projectCode]) + + // 데이터 및 컬럼 로드 + React.useEffect(() => { + async function loadTableData() { + try { + setIsLoading(true) + + const [tagsData, attributeKeys] = await Promise.all([ + getAllTagsPlant(projectCode, packageCode), + getUniqueAttributeKeys(projectCode, packageCode), + ]) + + const baseColumns = getColumns({ + setRowAction, + onDeleteClick: handleDeleteRow + }) + + let dynamicColumns: ColumnDef<Tag>[] = [] + if (attributeKeys.length > 0) { + dynamicColumns = createDynamicAttributeColumns(attributeKeys) + } + + const actionsColumn = baseColumns.pop() + const finalColumns = [ + ...baseColumns, + ...dynamicColumns, + actionsColumn + ].filter(Boolean) as ColumnDef<Tag>[] + + setTableData(tagsData) + setColumns(finalColumns) + } catch (error) { + console.error("Error loading table data:", error) + toast.error("Failed to load table data") + setTableData([]) + setColumns(getColumns({ + setRowAction, + onDeleteClick: handleDeleteRow + })) + } finally { + setIsLoading(false) + } + } + + loadTableData() + }, [projectCode, packageCode]) // Filter fields const filterFields: DataTableFilterField<Tag>[] = [ @@ -67,6 +205,11 @@ export function TagsTable({ promises, selectedPackageId }: TagsTableProps) { type: "text", }, { + id: "class", + label: "Class", + type: "text", + }, + { id: "createdAt", label: "Created at", type: "date", @@ -78,78 +221,562 @@ export function TagsTable({ promises, selectedPackageId }: TagsTableProps) { }, ] - // 3) useDataTable 훅으로 react-table 구성 - const { table } = useDataTable({ - data: data, // <-- 여기서 tableData 사용 - columns, - pageCount, - filterFields, - enablePinning: true, - enableAdvancedFilter: true, - initialState: { - // sorting: [{ id: "createdAt", desc: true }], - columnPinning: { right: ["actions"] }, - }, - getRowId: (originalRow) => String(originalRow.id), - shallow: false, - clearOnDefault: true, - columnResizeMode: "onEnd", + // 선택된 행 개수 + const selectedRowCount = React.useMemo(() => { + return selectedRowsData.length + }, [selectedRowsData]) + + // 개별 행 삭제 + const handleDeleteRow = React.useCallback((rowData: Tag) => { + setDeleteTarget([rowData]) + setDeleteDialogOpen(true) + }, []) + + // 배치 삭제 + const handleBatchDelete = React.useCallback(() => { + if (selectedRowsData.length === 0) { + toast.error("삭제할 항목을 선택해주세요.") + return + } + setDeleteTarget(selectedRowsData) + setDeleteDialogOpen(true) + }, [selectedRowsData]) + + // 삭제 성공 후 처리 + const handleDeleteSuccess = React.useCallback(() => { + const tagNosToDelete = deleteTarget + .map(item => item.tagNo) + .filter(Boolean) + + setTableData(prev => + prev.filter(item => !tagNosToDelete.includes(item.tagNo)) + ) + + setSelectedRowsData([]) + setClearSelection(prev => !prev) + setDeleteTarget([]) + + toast.success("삭제되었습니다.") + }, [deleteTarget]) + + // 클래스 라벨로 태그 타입 코드 찾기 + const getTagTypeCodeByClassLabel = React.useCallback((classLabel: string): string | null => { + const classOption = classOptions.find(opt => opt.label === classLabel) + return classOption?.tagTypeCode || null + }, [classOptions]) + + // 태그 타입에 따른 서브필드 가져오기 + const fetchSubfieldsByTagType = React.useCallback(async (tagTypeCode: string): Promise<SubFieldDef[]> => { + if (subfieldCache[tagTypeCode]) { + return subfieldCache[tagTypeCode] + } + + try { + const { subFields } = await getSubfieldsByTagType(tagTypeCode, projectCode, "", "") + const formattedSubFields: SubFieldDef[] = subFields.map(field => ({ + name: field.name, + label: field.label, + type: field.type, + options: field.options || [], + expression: field.expression ?? undefined, + delimiter: field.delimiter ?? undefined, + })) + + setSubfieldCache(prev => ({ + ...prev, + [tagTypeCode]: formattedSubFields + })) + + return formattedSubFields + } catch (error) { + console.error(`Error fetching subfields for tagType ${tagTypeCode}:`, error) + return [] + } + }, [subfieldCache, projectCode]) + + // Class 기반 태그 번호 형식 검증 + const validateTagNumberByClass = React.useCallback(async ( + tagNo: string, + classLabel: string + ): Promise<string> => { + if (!tagNo) return "Tag number is empty." + if (!classLabel) return "Class is empty." + + try { + const tagTypeCode = getTagTypeCodeByClassLabel(classLabel) + if (!tagTypeCode) { + return `No tag type found for class '${classLabel}'.` + } + + const subfields = await fetchSubfieldsByTagType(tagTypeCode) + if (!subfields || subfields.length === 0) { + return `No subfields found for tag type code '${tagTypeCode}'.` + } + + let remainingTagNo = tagNo + + for (const field of subfields) { + const delimiter = field.delimiter || "" + let nextDelimiterPos + + if (delimiter && remainingTagNo.includes(delimiter)) { + nextDelimiterPos = remainingTagNo.indexOf(delimiter) + } else { + nextDelimiterPos = remainingTagNo.length + } + + const part = remainingTagNo.substring(0, nextDelimiterPos) + + if (!part) { + return `Empty part for field '${field.label}'.` + } + + if (field.expression) { + try { + let cleanPattern = field.expression.replace(/^\^/, '').replace(/\$$/, '') + const regex = new RegExp(`^${cleanPattern}$`) + + if (!regex.test(part)) { + return `Part '${part}' for field '${field.label}' does not match the pattern '${field.expression}'.` + } + } catch (error) { + console.error(`Invalid regex pattern: ${field.expression}`, error) + return `Invalid pattern for field '${field.label}': ${field.expression}` + } + } + + if (field.type === "select" && field.options && field.options.length > 0) { + const validValues = field.options.map(opt => opt.value) + if (!validValues.includes(part)) { + return `'${part}' is not a valid value for field '${field.label}'. Valid options: ${validValues.join(", ")}.` + } + } + + if (delimiter && nextDelimiterPos < remainingTagNo.length) { + remainingTagNo = remainingTagNo.substring(nextDelimiterPos + delimiter.length) + } else { + remainingTagNo = "" + break + } + } + + if (remainingTagNo) { + return `Tag number has extra parts: '${remainingTagNo}'.` + } - }) + return "" + } catch (error) { + console.error("Error validating tag number by class:", error) + return "Error validating tag number format." + } + }, [getTagTypeCodeByClassLabel, fetchSubfieldsByTagType]) - const [isCompact, setIsCompact] = React.useState<boolean>(false) + // Import 파일 선택 + const handleImportClick = () => { + fileInputRef.current?.click() + } + // Import 파일 처리 + const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => { + const file = e.target.files?.[0] + if (!file) return - const handleCompactChange = React.useCallback((compact: boolean) => { - setIsCompact(compact) + e.target.value = "" + setIsPending(true) + + try { + const workbook = new ExcelJS.Workbook() + const arrayBuffer = await decryptWithServerAction(file) + await workbook.xlsx.load(arrayBuffer) + + const worksheet = workbook.worksheets[0] + const lastColIndex = worksheet.columnCount + 1 + worksheet.getRow(1).getCell(lastColIndex).value = "Error" + + const headerRowValues = worksheet.getRow(1).values as ExcelJS.CellValue[] + + // Excel header to accessor mapping + const excelHeaderToAccessor: Record<string, string> = {} + for (const col of columns) { + const meta = col.meta as { excelHeader?: string } | undefined + if (meta?.excelHeader) { + const accessor = col.id as string + excelHeaderToAccessor[meta.excelHeader] = accessor + } + } + + const accessorIndexMap: Record<string, number> = {} + for (let i = 1; i < headerRowValues.length; i++) { + const cellVal = String(headerRowValues[i] ?? "").trim() + if (!cellVal) continue + const accessor = excelHeaderToAccessor[cellVal] + if (accessor) { + accessorIndexMap[accessor] = i + } + } + + let errorCount = 0 + const importedRows: Tag[] = [] + const fileTagNos = new Set<string>() + const lastRow = worksheet.lastRow?.number || 1 + + for (let rowNum = 2; rowNum <= lastRow; rowNum++) { + const row = worksheet.getRow(rowNum) + const rowVals = row.values as ExcelJS.CellValue[] + if (!rowVals || rowVals.length <= 1) continue + + let errorMsg = "" + + const tagNoIndex = accessorIndexMap["tagNo"] + const classIndex = accessorIndexMap["class"] + + const tagNo = tagNoIndex ? String(rowVals[tagNoIndex] ?? "").trim() : "" + const classVal = classIndex ? String(rowVals[classIndex] ?? "").trim() : "" + + if (!tagNo) { + errorMsg += `Tag No is empty. ` + } + if (!classVal) { + errorMsg += `Class is empty. ` + } + + if (tagNo) { + const dup = tableData.find(t => t.tagNo === tagNo) + if (dup) { + errorMsg += `TagNo '${tagNo}' already exists. ` + } + + if (fileTagNos.has(tagNo)) { + errorMsg += `TagNo '${tagNo}' is duplicated within this file. ` + } else { + fileTagNos.add(tagNo) + } + } + + if (tagNo && classVal && !errorMsg) { + const classValidationError = await validateTagNumberByClass(tagNo, classVal) + if (classValidationError) { + errorMsg += classValidationError + " " + } + } + + if (errorMsg) { + row.getCell(lastColIndex).value = errorMsg.trim() + errorCount++ + } else { + const finalTagType = getTagTypeCodeByClassLabel(classVal) ?? "" + + importedRows.push({ + id: 0, + packageCode: packageCode, + projectCode: projectCode, + formId: null, + tagNo, + tagType: finalTagType, + class: classVal, + description: String(rowVals[accessorIndexMap["description"] ?? 0] ?? "").trim(), + createdAt: new Date(), + updatedAt: new Date(), + }) + } + } + + if (errorCount > 0) { + const outBuf = await workbook.xlsx.writeBuffer() + const errorFile = new Blob([outBuf]) + const url = URL.createObjectURL(errorFile) + const link = document.createElement("a") + link.href = url + link.download = "tag_import_errors.xlsx" + link.click() + URL.revokeObjectURL(url) + + toast.error(`There are ${errorCount} error row(s). Please see downloaded file.`) + return + } + + if (importedRows.length > 0) { + const result = await bulkCreateTags(importedRows, projectCode, packageCode) + if ("error" in result) { + toast.error(result.error) + } else { + toast.success(`${result.data.createdCount}개의 태그가 성공적으로 생성되었습니다.`) + router.refresh() + } + } + } catch (err) { + console.error(err) + toast.error("파일 업로드 중 오류가 발생했습니다.") + } finally { + setIsPending(false) + } + } + + // Export 함수 + const handleExport = async () => { + if (!tableRef.current) { + toast.error("테이블이 준비되지 않았습니다.") + return + } + + try { + setIsExporting(true) + await exportTagsToExcel(tableRef.current, packageCode, projectCode, { + filename: `Tags_${packageCode}_${projectCode}`, + excludeColumns: ["select", "actions", "createdAt", "updatedAt"], + }) + toast.success("태그 목록이 성공적으로 내보내졌습니다.") + } catch (error) { + console.error("Export error:", error) + toast.error("태그 목록 내보내기 중 오류가 발생했습니다.") + } finally { + setIsExporting(false) + } + } + + // Sync 함수 + const startGetTags = async () => { + try { + setIsSyncing(true) + + const response = await fetch('/api/cron/tags-plant/start', { + method: 'POST', + body: JSON.stringify({ + projectCode: projectCode, + packageCode: packageCode, + mode: selectedMode + }) + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || 'Failed to start tag import') + } + + const data = await response.json() + + if (data.syncId) { + setSyncId(data.syncId) + toast.info('Tag import started. This may take a while...') + startPolling(data.syncId) + } else { + throw new Error('No import ID returned from server') + } + } catch (error) { + console.error('Error starting tag import:', error) + toast.error( + error instanceof Error + ? error.message + : 'An error occurred while starting tag import' + ) + setIsSyncing(false) + } + } + + const startPolling = (id: string) => { + if (pollingRef.current) { + clearInterval(pollingRef.current) + } + + pollingRef.current = setInterval(async () => { + try { + const response = await fetch(`/api/cron/tags-plant/status?id=${id}`) + + if (!response.ok) { + throw new Error('Failed to get tag import status') + } + + const data = await response.json() + + if (data.status === 'completed') { + if (pollingRef.current) { + clearInterval(pollingRef.current) + pollingRef.current = null + } + + router.refresh() + setIsSyncing(false) + setSyncId(null) + + toast.success( + `Tags imported successfully! ${data.result?.processedCount || 0} items processed.` + ) + } else if (data.status === 'failed') { + if (pollingRef.current) { + clearInterval(pollingRef.current) + pollingRef.current = null + } + + setIsSyncing(false) + setSyncId(null) + toast.error(data.error || 'Import failed') + } + } catch (error) { + console.error('Error checking importing status:', error) + } + }, 5000) + } + + // rowAction 처리 + React.useEffect(() => { + if (rowAction?.type === "delete") { + handleDeleteRow(rowAction.row.original) + setRowAction(null) + } + }, [rowAction, handleDeleteRow]) + + // Cleanup + React.useEffect(() => { + return () => { + if (pollingRef.current) { + clearInterval(pollingRef.current) + } + } }, []) - - + + // 로딩 중 + if (isLoading) { + return ( + <div className="space-y-4"> + <Skeleton className="h-10 w-full" /> + <Skeleton className="h-[500px] w-full" /> + <Skeleton className="h-10 w-full" /> + </div> + ) + } + return ( <> - <DataTable - table={table} - compact={isCompact} - - floatingBar={<TagsTableFloatingBar table={table} selectedPackageId={selectedPackageId}/>} + <ClientDataTable + data={tableData} + columns={columns} + advancedFilterFields={advancedFilterFields} + autoSizeColumns + onSelectedRowsChange={setSelectedRowsData} + clearSelection={clearSelection} + onTableReady={(table) => { + tableRef.current = table + }} > - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - shallow={false} - enableCompactToggle={true} - compactStorageKey="tagTableCompact" - onCompactChange={handleCompactChange} - > - {/* - 4) ToolbarActions에 tableData, setTableData 넘겨서 - import 시 상태 병합 - */} - <TagsTableToolbarActions - table={table} - selectedPackageId={selectedPackageId} - tableData={data} // <-- pass current data - selectedMode={selectedMode} - /> - </DataTableAdvancedToolbar> - </DataTable> + <div className="flex items-center gap-2"> + {/* 삭제 버튼 - 선택된 항목이 있을 때만 */} + {selectedRowCount > 0 && ( + <Button + variant="destructive" + size="sm" + onClick={handleBatchDelete} + > + <Trash2 className="mr-2 size-4" /> + Delete ({selectedRowCount}) + </Button> + )} + + {/* Get Tags 버튼 */} + <Button + variant="samsung" + size="sm" + onClick={startGetTags} + disabled={isSyncing} + > + <RefreshCcw className={`size-4 mr-2 ${isSyncing ? 'animate-spin' : ''}`} /> + <span className="hidden sm:inline"> + {isSyncing ? 'Syncing...' : 'Get Tags'} + </span> + </Button> + + {/* Add Tag 버튼 */} + <AddTagDialog + projectCode={projectCode} + packageCode={packageCode}/> + {/* Import 버튼 */} + <Button + variant="outline" + size="sm" + onClick={handleImportClick} + disabled={isPending || isExporting} + > + {isPending ? ( + <Loader2 className="size-4 mr-2 animate-spin" /> + ) : ( + <Upload className="size-4 mr-2" /> + )} + <span className="hidden sm:inline">Import</span> + </Button> + + {/* Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={handleExport} + disabled={isPending || isExporting || !tableRef.current} + > + {isExporting ? ( + <Loader2 className="size-4 mr-2 animate-spin" /> + ) : ( + <Download className="size-4 mr-2" /> + )} + <span className="hidden sm:inline">Export</span> + </Button> + </div> + </ClientDataTable> + + {/* Hidden file input */} + <input + ref={fileInputRef} + type="file" + accept=".xlsx,.xls" + className="hidden" + onChange={handleFileChange} + /> + + {/* Update Sheet */} <UpdateTagSheet open={rowAction?.type === "update"} - onOpenChange={() => setRowAction(null)} + onOpenChange={(open) => { + if (!open) setRowAction(null) + }} tag={rowAction?.row.original ?? null} - selectedPackageId={selectedPackageId} + packageCode={packageCode} + projectCode={projectCode} + onUpdateSuccess={(updatedValues) => { + if (rowAction?.row.original?.tagNo) { + const tagNo = rowAction.row.original.tagNo + setTableData(prev => + prev.map(item => + item.tagNo === tagNo ? updatedValues : item + ) + ) + } + }} /> - + {/* Delete Dialog */} <DeleteTagsDialog - open={rowAction?.type === "delete"} - onOpenChange={() => setRowAction(null)} - tags={rowAction?.row.original ? [rowAction?.row.original] : []} + tags={deleteTarget} + packageCode={packageCode} + projectCode={projectCode} + open={deleteDialogOpen} + onOpenChange={(open) => { + if (!open) { + setDeleteDialogOpen(false) + setDeleteTarget([]) + } + }} + onSuccess={handleDeleteSuccess} showTrigger={false} - onSuccess={() => rowAction?.row.toggleSelected(false)} - selectedPackageId={selectedPackageId} /> + + {/* Add Tag Dialog */} + {/* <AddTagDialog + projectCode={projectCode} + packageCode={packageCode} + open={addTagDialogOpen} + onOpenChange={setAddTagDialogOpen} + onSuccess={() => { + router.refresh() + }} + /> */} </> ) }
\ No newline at end of file diff --git a/lib/tags-plant/table/tags-export.tsx b/lib/tags-plant/table/tags-export.tsx index fa85148d..a3255a0b 100644 --- a/lib/tags-plant/table/tags-export.tsx +++ b/lib/tags-plant/table/tags-export.tsx @@ -15,7 +15,8 @@ import { getClassOptions } from "../service" */ export async function exportTagsToExcel( table: Table<Tag>, - selectedPackageId: number, + packageCode: string, + projectCode: string, { filename = "Tags", excludeColumns = ["select", "actions", "createdAt", "updatedAt"], @@ -42,7 +43,7 @@ export async function exportTagsToExcel( const worksheet = workbook.addWorksheet("Tags") // 3. Tag Class 옵션 가져오기 - const classOptions = await getClassOptions(selectedPackageId) + const classOptions = await getClassOptions(packageCode, projectCode) // 4. 유효성 검사 시트 생성 const validationSheet = workbook.addWorksheet("ValidationData") diff --git a/lib/tags-plant/table/tags-table-floating-bar.tsx b/lib/tags-plant/table/tags-table-floating-bar.tsx index 8d55b7ac..eadbfb12 100644 --- a/lib/tags-plant/table/tags-table-floating-bar.tsx +++ b/lib/tags-plant/table/tags-table-floating-bar.tsx @@ -36,12 +36,13 @@ import { Tag } from "@/db/schema/vendorData" interface TagsTableFloatingBarProps { table: Table<Tag> - selectedPackageId: number + packageCode: string + projectCode: string } -export function TagsTableFloatingBar({ table, selectedPackageId }: TagsTableFloatingBarProps) { +export function TagsTableFloatingBar({ table, packageCode, projectCode}: TagsTableFloatingBarProps) { const rows = table.getFilteredSelectedRowModel().rows const [isPending, startTransition] = React.useTransition() diff --git a/lib/tags-plant/table/tags-table-toolbar-actions.tsx b/lib/tags-plant/table/tags-table-toolbar-actions.tsx index cc2d82b4..c80a600e 100644 --- a/lib/tags-plant/table/tags-table-toolbar-actions.tsx +++ b/lib/tags-plant/table/tags-table-toolbar-actions.tsx @@ -52,7 +52,8 @@ interface TagsTableToolbarActionsProps { /** react-table 객체 */ table: Table<Tag> /** 현재 선택된 패키지 ID */ - selectedPackageId: number + packageCode: string + projectCode: string /** 현재 태그 목록(상태) */ tableData: Tag[] /** 태그 목록을 갱신하는 setState */ @@ -68,7 +69,8 @@ interface TagsTableToolbarActionsProps { */ export function TagsTableToolbarActions({ table, - selectedPackageId, + packageCode, + projectCode, tableData, selectedMode }: TagsTableToolbarActionsProps) { @@ -94,7 +96,7 @@ export function TagsTableToolbarActions({ React.useEffect(() => { const loadClassOptions = async () => { try { - const options = await getClassOptions(selectedPackageId) + const options = await getClassOptions(packageCode, projectCode) setClassOptions(options) } catch (error) { console.error("Failed to load class options:", error) @@ -102,7 +104,7 @@ export function TagsTableToolbarActions({ } loadClassOptions() - }, [selectedPackageId]) + }, [packageCode, projectCode]) // 숨겨진 <input>을 클릭 function handleImportClick() { @@ -135,12 +137,11 @@ export function TagsTableToolbarActions({ const [projectId, setProjectId] = React.useState<number | null>(null); - // Add useEffect to fetch projectId when selectedPackageId changes React.useEffect(() => { const fetchProjectId = async () => { - if (selectedPackageId) { + if (packageCode && projectCode) { try { - const pid = await getProjectIdFromContractItemId(selectedPackageId); + const pid = await getProjectIdFromContractItemId(projectCode ); setProjectId(pid); } catch (error) { console.error("Failed to fetch project ID:", error); @@ -150,7 +151,7 @@ export function TagsTableToolbarActions({ }; fetchProjectId(); - }, [selectedPackageId]); + }, [projectCode]); // 특정 attributesId에 대한 옵션 가져오기 const fetchOptions = React.useCallback(async (attributesId: string): Promise<TagOption[]> => { @@ -195,7 +196,7 @@ export function TagsTableToolbarActions({ } try { - const { subFields } = await getSubfieldsByTagType(tagTypeCode, selectedPackageId) + const { subFields } = await getSubfieldsByTagType(tagTypeCode, projectCode) // API 응답을 SubFieldDef 형식으로 변환 const formattedSubFields: SubFieldDef[] = subFields.map(field => ({ @@ -478,7 +479,7 @@ export function TagsTableToolbarActions({ if (tagNo) { // 이미 tableData 내 존재 여부 const dup = tableData.find( - (t) => t.contractItemId === selectedPackageId && t.tagNo === tagNo + (t) => t.tagNo === tagNo ) if (dup) { errorMsg += `TagNo '${tagNo}' already exists. ` @@ -523,7 +524,8 @@ export function TagsTableToolbarActions({ // 정상 행을 importedRows에 추가 importedRows.push({ id: 0, // 임시 - contractItemId: selectedPackageId, + packageCode: packageCode, + projectCode: projectCode, formId: null, tagNo, tagType: finalTagType, // ← 코드로 저장할지, Description으로 저장할지 결정 @@ -552,7 +554,7 @@ export function TagsTableToolbarActions({ // 정상 행이 있으면 태그 생성 요청 if (importedRows.length > 0) { - const result = await bulkCreateTags(importedRows, selectedPackageId); + const result = await bulkCreateTags(importedRows, projectCode, packageCode); if ("error" in result) { toast.error(result.error); } else { @@ -575,8 +577,8 @@ export function TagsTableToolbarActions({ setIsExporting(true) // 유효성 검사가 포함된 새로운 엑셀 내보내기 함수 호출 - await exportTagsToExcel(table, selectedPackageId, { - filename: `Tags_${selectedPackageId}`, + await exportTagsToExcel(table, packageCode,projectCode, { + filename: `Tags_${packageCode}_${projectCode}`, excludeColumns: ["select", "actions", "createdAt", "updatedAt"], }) @@ -594,10 +596,11 @@ export function TagsTableToolbarActions({ setIsLoading(true) // API 엔드포인트 호출 - 작업 시작만 요청 - const response = await fetch('/api/cron/tags/start', { + const response = await fetch('/api/cron/tags-plant/start', { method: 'POST', body: JSON.stringify({ - packageId: selectedPackageId, + projectCode: projectCode, + packageCode: packageCode, mode: selectedMode // 모드 정보 추가 }) }) @@ -638,7 +641,7 @@ export function TagsTableToolbarActions({ // 5초마다 상태 확인 pollingRef.current = setInterval(async () => { try { - const response = await fetch(`/api/cron/tags/status?id=${id}`) + const response = await fetch(`/api/cron/tags-plant/status?id=${id}`) if (!response.ok) { throw new Error('Failed to get tag import status') @@ -699,7 +702,8 @@ export function TagsTableToolbarActions({ .getFilteredSelectedRowModel() .rows.map((row) => row.original)} onSuccess={() => table.toggleAllRowsSelected(false)} - selectedPackageId={selectedPackageId} + projectCode={projectCode} + packageCode={packageCode} /> ) : null} <Button @@ -715,7 +719,7 @@ export function TagsTableToolbarActions({ </span> </Button> - <AddTagDialog selectedPackageId={selectedPackageId} /> + <AddTagDialog projectCode={projectCode} packageCode={packageCode} /> {/* Import */} <Button diff --git a/lib/tags-plant/table/update-tag-sheet.tsx b/lib/tags-plant/table/update-tag-sheet.tsx index 613abaa9..2be1e732 100644 --- a/lib/tags-plant/table/update-tag-sheet.tsx +++ b/lib/tags-plant/table/update-tag-sheet.tsx @@ -50,7 +50,7 @@ import { Badge } from "@/components/ui/badge" import { cn } from "@/lib/utils" import { Tag } from "@/db/schema/vendorData" -import { updateTag, getSubfieldsByTagType, getClassOptions, TagTypeOption } from "@/lib/tags/service" +import { updateTag, getSubfieldsByTagType, getClassOptions, TagTypeOption } from "@/lib/tags-plant/service" // SubFieldDef 인터페이스 interface SubFieldDef { @@ -84,10 +84,11 @@ type UpdateTagSchema = z.infer<typeof updateTagSchema> & Record<string, string> interface UpdateTagSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { tag: Tag | null - selectedPackageId: number + packageCode: string + projectCode: string } -export function UpdateTagSheet({ tag, selectedPackageId, ...props }: UpdateTagSheetProps) { +export function UpdateTagSheet({ tag, packageCode, projectCode,...props }: UpdateTagSheetProps) { const [isUpdatePending, startUpdateTransition] = React.useTransition() const [tagTypeList, setTagTypeList] = React.useState<TagTypeOption[]>([]) const [selectedTagTypeCode, setSelectedTagTypeCode] = React.useState<string | null>(null) @@ -110,7 +111,7 @@ export function UpdateTagSheet({ tag, selectedPackageId, ...props }: UpdateTagSh setIsLoadingClasses(true) try { - const result = await getClassOptions(selectedPackageId) + const result = await getClassOptions(packageCode, projectCode) setClassOptions(result) } catch (err) { toast.error("클래스 옵션을 불러오는데 실패했습니다.") @@ -164,7 +165,7 @@ export function UpdateTagSheet({ tag, selectedPackageId, ...props }: UpdateTagSh async function loadSubFieldsByTagTypeCode(tagTypeCode: string) { setIsLoadingSubFields(true) try { - const { subFields: apiSubFields } = await getSubfieldsByTagType(tagTypeCode, selectedPackageId) + const { subFields: apiSubFields } = await getSubfieldsByTagType(tagTypeCode, projectCode) const formattedSubFields: SubFieldDef[] = apiSubFields.map(field => ({ name: field.name, label: field.label, @@ -221,7 +222,7 @@ export function UpdateTagSheet({ tag, selectedPackageId, ...props }: UpdateTagSh ), } - const result = await updateTag(tagData, selectedPackageId) + const result = await updateTag(tagData, projectCode,packageCode ) if ("error" in result) { toast.error(result.error) diff --git a/lib/vendor-data/services.ts b/lib/vendor-data/services.ts index 8c8b21d2..fe4e56ae 100644 --- a/lib/vendor-data/services.ts +++ b/lib/vendor-data/services.ts @@ -62,6 +62,8 @@ export async function getVendorProjectsAndContracts( itemId: contractItems.id, itemName: items.itemName, + packageCode: items.packageCode, + packageName: items.description, }) .from(contracts) .innerJoin(projects, eq(contracts.projectId, projects.id)) @@ -126,3 +128,94 @@ export async function getVendorProjectsAndContracts( return Array.from(projectMap.values()) } +interface ProjectWithPackages { + projectId: number + projectCode: string + projectName: string + projectType: "ship" | "plant" + packages: { + packageCode: string + packageName: string | null + }[] +} + +export async function getVendorProjectsWithPackages( + vendorId?: number, + projectType?: "ship" | "plant" +): Promise<ProjectWithPackages[]> { + // 세션에서 도메인 정보 가져오기 + const session = await getServerSession(authOptions) + + // EVCP 도메인일 때만 전체 조회 + const isEvcpDomain = session?.user?.domain === "evcp" + + // where 조건들을 배열로 관리 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const whereConditions: any[] = [] + + // vendorId 조건 추가 + if (!isEvcpDomain && vendorId) { + whereConditions.push(eq(contracts.vendorId, vendorId)) + } + + // projectType 조건 추가 + if (projectType) { + whereConditions.push(eq(projects.type, projectType)) + } + + const query = db + .select({ + projectId: projects.id, + projectCode: projects.code, + projectName: projects.name, + projectType: projects.type, + + packageCode: items.packageCode, + packageName: items.description, + }) + .from(contracts) + .innerJoin(projects, eq(contracts.projectId, projects.id)) + .innerJoin(contractItems, eq(contractItems.contractId, contracts.id)) + .innerJoin(items, eq(contractItems.itemId, items.id)) + + // 조건이 있으면 where 절 추가 + if (whereConditions.length > 0) { + query.where(and(...whereConditions)) + } + + const rows = await query + + const projectMap = new Map<number, ProjectWithPackages>() + + for (const row of rows) { + // 1) 프로젝트 그룹 찾기 + let projectEntry = projectMap.get(row.projectId) + if (!projectEntry) { + // 새 프로젝트 항목 생성 + projectEntry = { + projectId: row.projectId, + projectCode: row.projectCode, + projectName: row.projectName, + projectType: row.projectType, + packages: [], + } + projectMap.set(row.projectId, projectEntry) + } + + // 2) 프로젝트의 packages 배열에 패키지 추가 (중복 체크) + // packageCode가 같은 항목이 이미 존재하는지 확인 + const existingPackage = projectEntry.packages.find( + (pkg) => pkg.packageCode === row.packageCode + ) + + // 같은 packageCode가 없는 경우에만 추가 + if (!existingPackage) { + projectEntry.packages.push({ + packageCode: row.packageCode, + packageName: row.packageName, + }) + } + } + + return Array.from(projectMap.values()) +}
\ No newline at end of file diff --git a/lib/vendor-document/service.ts b/lib/vendor-document/service.ts index bf2b0b7a..48e3fa3f 100644 --- a/lib/vendor-document/service.ts +++ b/lib/vendor-document/service.ts @@ -2,14 +2,14 @@ import { eq, SQL } from "drizzle-orm" import db from "@/db/db" -import { documentAttachments, documents, issueStages, revisions, vendorDocumentsView } from "@/db/schema/vendorDocu" +import { stageSubmissions, stageDocuments, stageIssueStages,documentAttachments, documents, issueStages, revisions, stageDocumentsView,vendorDocumentsView ,stageSubmissionAttachments, StageIssueStage, StageDocumentsView, StageDocument,} from "@/db/schema/vendorDocu" import { GetVendorDcoumentsSchema } from "./validations" import { unstable_cache } from "@/lib/unstable-cache"; import { filterColumns } from "@/lib/filter-columns"; import { getErrorMessage } from "@/lib/handle-error"; import { asc, desc, ilike, inArray, and, gte, lte, not, or , isNotNull, isNull} from "drizzle-orm"; import { countVendorDocuments, selectVendorDocuments } from "./repository" -import { contractItems } from "@/db/schema" +import { contractItems, projects, items,contracts } from "@/db/schema" import { saveFile } from "../file-stroage" import path from "path" @@ -494,4 +494,706 @@ export async function fetchRevisionsByStageParams( console.error("Error fetching revisions:", error); return []; } +} + +// 타입 정의 +type SubmissionInfo = { + id: number; + revisionNumber: number; + revisionCode: string; + revisionType: string; + submissionStatus: string; + submittedBy: string; + submittedAt: Date; + reviewStatus: string | null; + buyerSystemStatus: string | null; + syncStatus: string; +}; + +type AttachmentInfo = { + id: number; + fileName: string; + originalFileName: string; + fileSize: number; + fileType: string | null; + storageUrl: string | null; + syncStatus: string; + buyerSystemStatus: string | null; + uploadedAt: Date; +}; + +// Server Action: Fetch documents by projectCode and packageCode +export async function fetchDocumentsByProjectAndPackage( + projectCode: string, + packageCode: string +): Promise<StageDocument[]> { + try { + // First, find the project by code + const projectResult = await db + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.code, projectCode)) + .limit(1); + + if (!projectResult.length) { + return []; + } + + const projectId = projectResult[0].id; + + // Find contract through contractItems joined with items table + const contractItemResult = await db + .select({ + contractId: contractItems.contractId + }) + .from(contractItems) + .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) + .innerJoin(items, eq(contractItems.itemId, items.id)) + .where( + and( + eq(contracts.projectId, projectId), + eq(items.packageCode, packageCode) + ) + ) + .limit(1); + + if (!contractItemResult.length) { + return []; + } + + const contractId = contractItemResult[0].contractId; + + // Get stage documents + const docsResult = await db + .select({ + id: stageDocuments.id, + docNumber: stageDocuments.docNumber, + title: stageDocuments.title, + vendorDocNumber: stageDocuments.vendorDocNumber, + status: stageDocuments.status, + issuedDate: stageDocuments.issuedDate, + docClass: stageDocuments.docClass, + projectId: stageDocuments.projectId, + vendorId: stageDocuments.vendorId, + contractId: stageDocuments.contractId, + buyerSystemStatus: stageDocuments.buyerSystemStatus, + buyerSystemComment: stageDocuments.buyerSystemComment, + lastSyncedAt: stageDocuments.lastSyncedAt, + syncStatus: stageDocuments.syncStatus, + syncError: stageDocuments.syncError, + syncVersion: stageDocuments.syncVersion, + lastModifiedBy: stageDocuments.lastModifiedBy, + createdAt: stageDocuments.createdAt, + updatedAt: stageDocuments.updatedAt, + }) + .from(stageDocuments) + .where( + and( + eq(stageDocuments.projectId, projectId), + eq(stageDocuments.contractId, contractId), + eq(stageDocuments.status, "ACTIVE") + ) + ) + .orderBy(stageDocuments.docNumber); + + return docsResult; + } catch (error) { + console.error("Error fetching documents:", error); + return []; + } +} + +// Server Action: Fetch stages by documentId +export async function fetchStagesByDocumentIdPlant( + documentId: number +): Promise<StageIssueStage[]> { + try { + const stagesResult = await db + .select({ + id: stageIssueStages.id, + documentId: stageIssueStages.documentId, + stageName: stageIssueStages.stageName, + planDate: stageIssueStages.planDate, + actualDate: stageIssueStages.actualDate, + stageStatus: stageIssueStages.stageStatus, + stageOrder: stageIssueStages.stageOrder, + priority: stageIssueStages.priority, + assigneeId: stageIssueStages.assigneeId, + assigneeName: stageIssueStages.assigneeName, + reminderDays: stageIssueStages.reminderDays, + description: stageIssueStages.description, + notes: stageIssueStages.notes, + createdAt: stageIssueStages.createdAt, + updatedAt: stageIssueStages.updatedAt, + }) + .from(stageIssueStages) + .where(eq(stageIssueStages.documentId, documentId)) + .orderBy(stageIssueStages.stageOrder, stageIssueStages.stageName); + + return stagesResult; + } catch (error) { + console.error("Error fetching stages:", error); + return []; + } +} + +// Server Action: Fetch submissions (revisions) by documentId and stageName +export async function fetchSubmissionsByStageParams( + documentId: number, + stageName: string +): Promise<SubmissionInfo[]> { + try { + // First, find the stageId + const stageResult = await db + .select({ id: stageIssueStages.id }) + .from(stageIssueStages) + .where( + and( + eq(stageIssueStages.documentId, documentId), + eq(stageIssueStages.stageName, stageName) + ) + ) + .limit(1); + + if (!stageResult.length) { + return []; + } + + const stageId = stageResult[0].id; + + // Then, get submissions for this stage + const submissionsResult = await db + .select({ + id: stageSubmissions.id, + revisionNumber: stageSubmissions.revisionNumber, + revisionCode: stageSubmissions.revisionCode, + revisionType: stageSubmissions.revisionType, + submissionStatus: stageSubmissions.submissionStatus, + submittedBy: stageSubmissions.submittedBy, + submittedAt: stageSubmissions.submittedAt, + reviewStatus: stageSubmissions.reviewStatus, + buyerSystemStatus: stageSubmissions.buyerSystemStatus, + syncStatus: stageSubmissions.syncStatus, + }) + .from(stageSubmissions) + .where(eq(stageSubmissions.stageId, stageId)) + .orderBy(stageSubmissions.revisionNumber); + + return submissionsResult; + } catch (error) { + console.error("Error fetching submissions:", error); + return []; + } +} + +// View를 활용한 더 효율적인 조회 +export async function fetchDocumentsViewByProjectAndPackage( + projectCode: string, + packageCode: string +): Promise<StageDocumentsView[]> { + try { + // First, find the project by code + const projectResult = await db + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.code, projectCode)) + .limit(1); + + if (!projectResult.length) { + return []; + } + + const projectId = projectResult[0].id; + + // Find contract through contractItems joined with items + const contractItemResult = await db + .select({ + contractId: contractItems.contractId + }) + .from(contractItems) + .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) + .innerJoin(items, eq(contractItems.itemId, items.id)) + .where( + and( + eq(contracts.projectId, projectId), + eq(items.packageCode, packageCode) + ) + ) + .limit(1); + + if (!contractItemResult.length) { + return []; + } + + const contractId = contractItemResult[0].contractId; + + // Use the view for enriched data (includes progress, current stage, etc.) + const documentsViewResult = await db + .select() + .from(stageDocumentsView) + .where( + and( + eq(stageDocumentsView.projectId, projectId), + eq(stageDocumentsView.contractId, contractId), + eq(stageDocumentsView.status, "ACTIVE") + ) + ) + .orderBy(stageDocumentsView.docNumber); + + return documentsViewResult; + } catch (error) { + console.error("Error fetching documents view:", error); + return []; + } +} + +// Server Action: Fetch submission attachments by submissionId +export async function fetchAttachmentsBySubmissionId( + submissionId: number +): Promise<AttachmentInfo[]> { + try { + const attachmentsResult = await db + .select({ + id: stageSubmissionAttachments.id, + fileName: stageSubmissionAttachments.fileName, + originalFileName: stageSubmissionAttachments.originalFileName, + fileSize: stageSubmissionAttachments.fileSize, + fileType: stageSubmissionAttachments.fileType, + storageUrl: stageSubmissionAttachments.storageUrl, + syncStatus: stageSubmissionAttachments.syncStatus, + buyerSystemStatus: stageSubmissionAttachments.buyerSystemStatus, + uploadedAt: stageSubmissionAttachments.uploadedAt, + }) + .from(stageSubmissionAttachments) + .where( + and( + eq(stageSubmissionAttachments.submissionId, submissionId), + eq(stageSubmissionAttachments.status, "ACTIVE") + ) + ) + .orderBy(stageSubmissionAttachments.uploadedAt); + + return attachmentsResult; + } catch (error) { + console.error("Error fetching attachments:", error); + return []; + } +} + +// 추가 헬퍼: 특정 제출의 상세 정보 (첨부파일 포함) +export async function getSubmissionWithAttachments(submissionId: number) { + try { + const [submission] = await db + .select({ + id: stageSubmissions.id, + stageId: stageSubmissions.stageId, + documentId: stageSubmissions.documentId, + revisionNumber: stageSubmissions.revisionNumber, + revisionCode: stageSubmissions.revisionCode, + revisionType: stageSubmissions.revisionType, + submissionStatus: stageSubmissions.submissionStatus, + submittedBy: stageSubmissions.submittedBy, + submittedByEmail: stageSubmissions.submittedByEmail, + submittedAt: stageSubmissions.submittedAt, + reviewedBy: stageSubmissions.reviewedBy, + reviewedAt: stageSubmissions.reviewedAt, + submissionTitle: stageSubmissions.submissionTitle, + submissionDescription: stageSubmissions.submissionDescription, + reviewStatus: stageSubmissions.reviewStatus, + reviewComments: stageSubmissions.reviewComments, + vendorId: stageSubmissions.vendorId, + totalFiles: stageSubmissions.totalFiles, + buyerSystemStatus: stageSubmissions.buyerSystemStatus, + syncStatus: stageSubmissions.syncStatus, + createdAt: stageSubmissions.createdAt, + updatedAt: stageSubmissions.updatedAt, + }) + .from(stageSubmissions) + .where(eq(stageSubmissions.id, submissionId)) + .limit(1); + + if (!submission) { + return null; + } + + const attachments = await fetchAttachmentsBySubmissionId(submissionId); + + return { + ...submission, + attachments, + }; + } catch (error) { + console.error("Error getting submission with attachments:", error); + return null; + } +} + + +interface CreateSubmissionResult { + success: boolean; + error?: string; + submissionId?: number; +} + +export async function createSubmissionAction( + formData: FormData +): Promise<CreateSubmissionResult> { + try { + // Extract form data + const documentId = formData.get("documentId") as string; + const stageName = formData.get("stageName") as string; + const revisionCode = formData.get("revisionCode") as string; + const customFileName = formData.get("customFileName") as string; + const submittedBy = formData.get("submittedBy") as string; + const submittedByEmail = formData.get("submittedByEmail") as string | null; + const submissionTitle = formData.get("submissionTitle") as string | null; + const submissionDescription = formData.get("submissionDescription") as string | null; + const vendorId = formData.get("vendorId") as string; + const attachment = formData.get("attachment") as File | null; + + // Validate required fields + if (!documentId || !stageName || !revisionCode || !submittedBy || !vendorId) { + return { + success: false, + error: "Missing required fields", + }; + } + + const parsedDocumentId = parseInt(documentId, 10); + const parsedVendorId = parseInt(vendorId, 10); + + // Validate parsed numbers + if (isNaN(parsedDocumentId) || isNaN(parsedVendorId)) { + return { + success: false, + error: "Invalid documentId or vendorId", + }; + } + + // Find the document + const [document] = await db + .select() + .from(stageDocuments) + .where(eq(stageDocuments.id, parsedDocumentId)) + .limit(1); + + if (!document) { + return { + success: false, + error: "Document not found", + }; + } + + // Find the stage + const [stage] = await db + .select() + .from(stageIssueStages) + .where( + and( + eq(stageIssueStages.documentId, parsedDocumentId), + eq(stageIssueStages.stageName, stageName) + ) + ) + .limit(1); + + if (!stage) { + return { + success: false, + error: `Stage "${stageName}" not found for this document`, + }; + } + + const stageId = stage.id; + + // Get the latest revision number for this stage + const existingSubmissions = await db + .select({ + revisionNumber: stageSubmissions.revisionNumber, + }) + .from(stageSubmissions) + .where(eq(stageSubmissions.stageId, stageId)) + .orderBy(desc(stageSubmissions.revisionNumber)) + .limit(1); + + const nextRevisionNumber = existingSubmissions.length > 0 + ? existingSubmissions[0].revisionNumber + 1 + : 1; + + // Check if revision code already exists for this stage + const [existingRevisionCode] = await db + .select() + .from(stageSubmissions) + .where( + and( + eq(stageSubmissions.stageId, stageId), + eq(stageSubmissions.revisionCode, revisionCode) + ) + ) + .limit(1); + + if (existingRevisionCode) { + return { + success: false, + error: `Revision code "${revisionCode}" already exists for this stage`, + }; + } + + // Get vendor code from vendors table + const [vendor] = await db + .select({ vendorCode: vendors.vendorCode }) + .from(vendors) + .where(eq(vendors.id, parsedVendorId)) + .limit(1); + + const vendorCode = vendor?.vendorCode || parsedVendorId.toString(); + + // Determine revision type + const revisionType = nextRevisionNumber === 1 ? "INITIAL" : "RESUBMISSION"; + + // Create the submission + const [newSubmission] = await db + .insert(stageSubmissions) + .values({ + stageId, + documentId: parsedDocumentId, + revisionNumber: nextRevisionNumber, + revisionCode, + revisionType, + submissionStatus: "SUBMITTED", + submittedBy, + submittedByEmail: submittedByEmail || undefined, + submittedAt: new Date(), + submissionTitle: submissionTitle || undefined, + submissionDescription: submissionDescription || undefined, + vendorId: parsedVendorId, + vendorCode, + totalFiles: attachment ? 1 : 0, + totalFileSize: attachment ? attachment.size : 0, + syncStatus: "pending", + syncVersion: 0, + lastModifiedBy: "EVCP", + totalFilesToSync: attachment ? 1 : 0, + syncedFilesCount: 0, + failedFilesCount: 0, + }) + .returning(); + + if (!newSubmission) { + return { + success: false, + error: "Failed to create submission", + }; + } + + // Upload attachment if provided + if (attachment) { + try { + // Generate unique filename + const fileExtension = customFileName.split(".").pop() || "docx"; + const timestamp = Date.now(); + const randomString = crypto.randomBytes(8).toString("hex"); + const uniqueFileName = `submissions/${parsedDocumentId}/${stageId}/${timestamp}_${randomString}.${fileExtension}`; + + // Calculate checksum + const buffer = await attachment.arrayBuffer(); + const checksum = crypto + .createHash("md5") + .update(Buffer.from(buffer)) + .digest("hex"); + + // Upload to Vercel Blob (or your storage solution) + const blob = await put(uniqueFileName, attachment, { + access: "public", + contentType: attachment.type || "application/octet-stream", + }); + + // Create attachment record + await db.insert(stageSubmissionAttachments).values({ + submissionId: newSubmission.id, + fileName: uniqueFileName, + originalFileName: customFileName, + fileType: attachment.type || "application/octet-stream", + fileExtension, + fileSize: attachment.size, + storageType: "S3", + storagePath: blob.url, + storageUrl: blob.url, + mimeType: attachment.type || "application/octet-stream", + checksum, + documentType: "DOCUMENT", + uploadedBy: submittedBy, + uploadedAt: new Date(), + status: "ACTIVE", + syncStatus: "pending", + syncVersion: 0, + lastModifiedBy: "EVCP", + isPublic: false, + }); + + // Update submission with file info + await db + .update(stageSubmissions) + .set({ + totalFiles: 1, + totalFileSize: attachment.size, + totalFilesToSync: 1, + updatedAt: new Date(), + }) + .where(eq(stageSubmissions.id, newSubmission.id)); + } catch (uploadError) { + console.error("Error uploading attachment:", uploadError); + + // Rollback: Delete the submission if file upload fails + await db + .delete(stageSubmissions) + .where(eq(stageSubmissions.id, newSubmission.id)); + + return { + success: false, + error: uploadError instanceof Error + ? `File upload failed: ${uploadError.message}` + : "File upload failed", + }; + } + } + + // Update stage status to SUBMITTED + await db + .update(stageIssueStages) + .set({ + stageStatus: "SUBMITTED", + updatedAt: new Date(), + }) + .where(eq(stageIssueStages.id, stageId)); + + // Update document's last modified info + await db + .update(stageDocuments) + .set({ + lastModifiedBy: "EVCP", + syncVersion: document.syncVersion + 1, + updatedAt: new Date(), + }) + .where(eq(stageDocuments.id, parsedDocumentId)); + + // Revalidate relevant paths + revalidatePath(`/projects/${document.projectId}/documents`); + revalidatePath(`/vendor/documents`); + revalidatePath(`/vendor/submissions`); + + return { + success: true, + submissionId: newSubmission.id, + }; + } catch (error) { + console.error("Error creating submission:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error occurred", + }; + } +} + +// Additional helper: Update submission status +export async function updateSubmissionStatus( + submissionId: number, + status: string, + reviewedBy?: string, + reviewComments?: string +): Promise<CreateSubmissionResult> { + try { + const reviewStatus = + status === "APPROVED" ? "APPROVED" : + status === "REJECTED" ? "REJECTED" : + "PENDING"; + + await db + .update(stageSubmissions) + .set({ + submissionStatus: status, + reviewStatus, + reviewComments: reviewComments || undefined, + reviewedBy: reviewedBy || undefined, + reviewedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(stageSubmissions.id, submissionId)); + + // If approved, update stage status + if (status === "APPROVED") { + const [submission] = await db + .select({ stageId: stageSubmissions.stageId }) + .from(stageSubmissions) + .where(eq(stageSubmissions.id, submissionId)) + .limit(1); + + if (submission) { + await db + .update(stageIssueStages) + .set({ + stageStatus: "APPROVED", + actualDate: new Date().toISOString().split('T')[0], + updatedAt: new Date(), + }) + .where(eq(stageIssueStages.id, submission.stageId)); + } + } + + return { success: true }; + } catch (error) { + console.error("Error updating submission status:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Failed to update submission status" + }; + } +} + +// Helper: Delete submission +export async function deleteSubmissionAction( + submissionId: number +): Promise<CreateSubmissionResult> { + try { + // Get submission info first + const [submission] = await db + .select() + .from(stageSubmissions) + .where(eq(stageSubmissions.id, submissionId)) + .limit(1); + + if (!submission) { + return { + success: false, + error: "Submission not found", + }; + } + + // Delete attachments from storage + const attachments = await db + .select() + .from(stageSubmissionAttachments) + .where(eq(stageSubmissionAttachments.submissionId, submissionId)); + + // TODO: Delete files from blob storage + // for (const attachment of attachments) { + // await del(attachment.storageUrl); + // } + + // Delete submission (cascade will delete attachments) + await db + .delete(stageSubmissions) + .where(eq(stageSubmissions.id, submissionId)); + + // Revalidate paths + revalidatePath(`/vendor/documents`); + revalidatePath(`/vendor/submissions`); + + return { success: true }; + } catch (error) { + console.error("Error deleting submission:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Failed to delete submission", + }; + } }
\ No newline at end of file |
