diff options
64 files changed, 7100 insertions, 2277 deletions
diff --git a/app/[lng]/evcp/(evcp)/layout.tsx b/app/[lng]/evcp/(evcp)/layout.tsx index 82b53307..7fe7f3e7 100644 --- a/app/[lng]/evcp/(evcp)/layout.tsx +++ b/app/[lng]/evcp/(evcp)/layout.tsx @@ -1,12 +1,40 @@ import { ReactNode } from 'react'; import { Header } from '@/components/layout/Header'; import { SiteFooter } from '@/components/layout/Footer'; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { verifyNonsapPermission } from "@/lib/nonsap/auth-service"; +import { PermissionChecker } from "@/components/common/permission-checker"; + +export default async function EvcpLayout({ children }: { children: ReactNode }) { + const session = await getServerSession(authOptions); + + let isAuthorized = true; + let authMessage = ""; + + // Only check permission if user is logged in + if (session?.user?.id) { + try { + const result = await verifyNonsapPermission( + parseInt(session.user.id), + ['SEARCH'] + ); + isAuthorized = result.authorized; + authMessage = result.message || ""; + } catch (error) { + console.error("Permission check failed:", error); + // Default to true in case of error to avoid blocking access due to system error + // but logic could be changed to false for strict security + isAuthorized = true; + authMessage = "Permission check error"; + } + } -export default function EvcpLayout({ children }: { children: ReactNode }) { return ( <div className="relative flex min-h-svh flex-col bg-background"> {/* <div className="relative flex min-h-svh flex-col bg-slate-100 "> */} <Header /> + <PermissionChecker authorized={isAuthorized} message={authMessage} /> <main className="flex flex-1 flex-col"> <div className='container-wrapper'> {children} @@ -15,4 +43,4 @@ export default function EvcpLayout({ children }: { children: ReactNode }) { <SiteFooter/> </div> ); -}
\ No newline at end of file +} 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/bidding/ProjectSelectorBid.tsx b/components/bidding/ProjectSelectorBid.tsx index 0fc567b3..a87c8dce 100644 --- a/components/bidding/ProjectSelectorBid.tsx +++ b/components/bidding/ProjectSelectorBid.tsx @@ -13,13 +13,15 @@ interface ProjectSelectorProps { onProjectSelect: (project: Project | null) => void; placeholder?: string; filterType?: string; // 옵션으로 필터 타입 지정 가능 + disabled?: boolean; } export function ProjectSelector({ selectedProjectId, onProjectSelect, placeholder = "프로젝트 선택...", - filterType + filterType, + disabled = false }: ProjectSelectorProps) { const [open, setOpen] = React.useState(false) const [searchTerm, setSearchTerm] = React.useState("") @@ -95,7 +97,7 @@ export function ProjectSelector({ role="combobox" aria-expanded={open} className="w-full justify-between" - disabled={isLoading} + disabled={isLoading || disabled} > {isLoading ? ( "프로젝트 로딩 중..." diff --git a/components/bidding/manage/bidding-items-editor.tsx b/components/bidding/manage/bidding-items-editor.tsx index 9d858f40..90e512d2 100644 --- a/components/bidding/manage/bidding-items-editor.tsx +++ b/components/bidding/manage/bidding-items-editor.tsx @@ -868,6 +868,7 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems }} title="1회성 품목 선택" description="1회성 품목을 검색하고 선택해주세요." + disabled={readonly} /> ) : ( <MaterialGroupSelectorDialogSingle @@ -893,6 +894,7 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems }} title="자재그룹 선택" description="자재그룹을 검색하고 선택해주세요." + disabled={readonly} /> )} </td> @@ -928,6 +930,7 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems }} title="자재 선택" description="자재를 검색하고 선택해주세요." + disabled={readonly} /> </td> <td className="border-r px-3 py-2"> 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/common/permission-checker.tsx b/components/common/permission-checker.tsx new file mode 100644 index 00000000..209e0022 --- /dev/null +++ b/components/common/permission-checker.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useEffect } from "react"; +import { toast } from "sonner"; +import { usePathname } from "next/navigation"; + +interface PermissionCheckerProps { + authorized: boolean; + message?: string; +} + +export function PermissionChecker({ authorized, message }: PermissionCheckerProps) { + const pathname = usePathname(); + + useEffect(() => { + // Only show toast if authorization failed + if (!authorized) { + toast.error("Permission Denied", { + description: message || "You do not have permission to view this page. (Dev Mode: Viewing anyway)", + duration: 5000, + action: { + label: "Close", + onClick: () => toast.dismiss(), + }, + }); + } else { + // Optional: Show success toast only if explicitly needed, + // but usually we don't show toast for success to avoid noise. + // Uncomment for debugging: + toast.success("Authorized", { description: "Access granted.", duration: 1000 }); + } + }, [authorized, message, pathname]); + + return null; +} + 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/agreementComments.ts b/db/schema/agreementComments.ts index 56631b1f..d7bbd2cb 100644 --- a/db/schema/agreementComments.ts +++ b/db/schema/agreementComments.ts @@ -38,6 +38,8 @@ export const agreementComments = pgTable('agreement_comments', { // 상태 관리 isDeleted: boolean('is_deleted').notNull().default(false), + isSubmitted: boolean('is_submitted').notNull().default(false), // 제출 여부 + submittedAt: timestamp('submitted_at'), // 제출 일시 // 감사 정보 createdAt: timestamp('created_at').defaultNow().notNull(), diff --git a/db/schema/bidding.ts b/db/schema/bidding.ts index bc31f6de..cc79f482 100644 --- a/db/schema/bidding.ts +++ b/db/schema/bidding.ts @@ -50,7 +50,8 @@ export const biddingStatusEnum = pgEnum('bidding_status', [ 'rebidding', // 재입찰 'disposal_cancelled', // 유찰취소 'bid_closure', // 폐찰 - 'round_increase' // 차수증가 + 'round_increase', // 차수증가 + 'deleted' // 삭제 ]) // 2. 계약구분 enum @@ -699,7 +700,8 @@ export const biddingStatusLabels = { rebidding: '입찰종료-재입찰', disposal_cancelled: '유찰취소', bid_closure: '폐찰', - round_increase: '입찰종료-차수증가' + round_increase: '입찰종료-차수증가', + deleted: '삭제' } as const export const contractTypeLabels = { 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/basic-contract/agreement-comments/actions.ts b/lib/basic-contract/agreement-comments/actions.ts index 32e9ce4c..bfcc68cf 100644 --- a/lib/basic-contract/agreement-comments/actions.ts +++ b/lib/basic-contract/agreement-comments/actions.ts @@ -3,7 +3,7 @@ import { revalidateTag } from "next/cache"; import db from "@/db/db"; import { eq, and, desc, inArray, sql, isNotNull, ne } from "drizzle-orm"; -import { agreementComments, basicContract, vendors, users } from "@/db/schema"; +import { agreementComments, basicContract, basicContractTemplates, vendors, users } from "@/db/schema"; import { saveFile, deleteFile } from "@/lib/file-stroage"; import { sendEmail } from "@/lib/mail/sendEmail"; import { getServerSession } from "next-auth/next"; @@ -28,6 +28,8 @@ export interface AgreementCommentData { authorName: string | null; comment: string; attachments: AgreementCommentAttachment[]; + isSubmitted: boolean; + submittedAt: Date | null; createdAt: Date; updatedAt: Date; } @@ -98,6 +100,8 @@ export async function getAgreementComments( ...comment, authorType: comment.authorType as AgreementCommentAuthorType, attachments, + isSubmitted: comment.isSubmitted || false, + submittedAt: comment.submittedAt || null, } as AgreementCommentData; }); @@ -163,7 +167,6 @@ export async function addAgreementComment(data: { } // 템플릿 이름 조회 - const { basicContractTemplates } = await import("@/db/schema"); let templateName: string | null = null; if (contract.templateId) { const [template] = await db @@ -213,6 +216,8 @@ export async function addAgreementComment(data: { authorName: data.authorName || user.name, comment: data.comment, attachments: uploadedAttachments.length > 0 ? JSON.stringify(uploadedAttachments) : JSON.stringify([]), + isSubmitted: data.shouldSendEmail || false, // 제출 여부 설정 + submittedAt: data.shouldSendEmail ? new Date() : null, // 제출 시 제출일시 설정 } as any) .returning(); @@ -248,6 +253,8 @@ export async function addAgreementComment(data: { ...newComment, authorType: newComment.authorType as AgreementCommentAuthorType, attachments: uploadedAttachments, + isSubmitted: newComment.isSubmitted || false, + submittedAt: newComment.submittedAt || null, } as AgreementCommentData, }; } catch (error) { @@ -336,6 +343,144 @@ export async function deleteAgreementComment( } /** + * 코멘트 제출 (이메일 발송) + */ +export async function submitAgreementComment( + commentId: number +): Promise<{ success: boolean; error?: string }> { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return { success: false, error: "인증이 필요합니다." }; + } + + // 코멘트 조회 + const [comment] = await db + .select() + .from(agreementComments) + .where(eq(agreementComments.id, commentId)); + + if (!comment) { + return { success: false, error: "코멘트를 찾을 수 없습니다." }; + } + + // 이미 제출된 코멘트인지 확인 + if (comment.isSubmitted) { + return { success: false, error: "이미 제출된 코멘트입니다." }; + } + + // 권한 확인 (작성자만 제출 가능) + const user = session.user as any; + const isVendor = !!user.companyId; + const canSubmit = + (isVendor && comment.authorVendorId === user.companyId) || + (!isVendor && comment.authorUserId === parseInt(user.id)); + + if (!canSubmit) { + return { success: false, error: "제출 권한이 없습니다." }; + } + + // 기본계약서 정보 조회 (이메일 발송을 위해) + const [contract] = await db + .select() + .from(basicContract) + .where(eq(basicContract.id, comment.basicContractId)) + .limit(1); + + if (!contract) { + return { success: false, error: "계약서를 찾을 수 없습니다." }; + } + + // 벤더 정보 조회 (이메일 발송용) + let vendor: any = null; + if (contract.vendorId) { + [vendor] = await db + .select() + .from(vendors) + .where(eq(vendors.id, contract.vendorId)) + .limit(1); + } + + // 요청자 정보 조회 (이메일 발송용) + let requester: any = null; + if (contract.requestedBy) { + [requester] = await db + .select() + .from(users) + .where(eq(users.id, contract.requestedBy)) + .limit(1); + } + + // 템플릿 이름 조회 + let templateName: string | null = null; + if (contract.templateId) { + const [template] = await db + .select() + .from(basicContractTemplates) + .where(eq(basicContractTemplates.id, contract.templateId)) + .limit(1); + templateName = template?.templateName || null; + } + + // 첨부파일 정보 파싱 + let attachments: AgreementCommentAttachment[] = []; + if (comment.attachments) { + try { + const attachmentsStr = typeof comment.attachments === 'string' + ? comment.attachments + : JSON.stringify(comment.attachments); + attachments = JSON.parse(attachmentsStr); + } catch (parseError) { + console.error("첨부파일 파싱 실패:", parseError); + } + } + + // 코멘트 제출 상태 업데이트 + await db + .update(agreementComments) + .set({ + isSubmitted: true, + submittedAt: new Date(), + updatedAt: new Date(), + } as any) + .where(eq(agreementComments.id, commentId)); + + // 이메일 알림 발송 + try { + await sendCommentNotificationEmail({ + comment: { + ...comment, + isSubmitted: true, + submittedAt: new Date(), + }, + contract, + vendor, + requester, + templateName, + authorType: comment.authorType as AgreementCommentAuthorType, + authorName: comment.authorName || user.name, + attachmentCount: attachments.length, + }); + } catch (emailError) { + console.error("이메일 발송 실패:", emailError); + // 이메일 실패는 제출 성공에 영향을 주지 않음 + } + + // 캐시 무효화: 코멘트 목록 + 기본계약서 목록 + revalidateTag(`agreement-comments-${comment.basicContractId}`); + revalidateTag(`basic-contracts`); // 기본계약서 목록 새로고침 + + return { success: true }; + } catch (error) { + console.error("코멘트 제출 실패:", error); + return { + success: false, + error: "코멘트 제출 중 오류가 발생했습니다.", + }; + } +} + +/** * 첨부파일 업로드 */ export async function uploadCommentAttachment( @@ -677,7 +822,6 @@ export async function completeNegotiation( } // 템플릿 이름 조회 - const { basicContractTemplates } = await import("@/db/schema"); let templateName: string | null = null; if (contract.templateId) { const [template] = await db diff --git a/lib/basic-contract/agreement-comments/agreement-comment-list.tsx b/lib/basic-contract/agreement-comments/agreement-comment-list.tsx index bad5aee5..fc64eab3 100644 --- a/lib/basic-contract/agreement-comments/agreement-comment-list.tsx +++ b/lib/basic-contract/agreement-comments/agreement-comment-list.tsx @@ -30,6 +30,7 @@ import { getAgreementComments, addAgreementComment, deleteAgreementComment, + submitAgreementComment, uploadCommentAttachment, deleteCommentAttachment, completeNegotiation, @@ -65,6 +66,7 @@ export function AgreementCommentList({ const [isSaving, setIsSaving] = useState(false); const [pendingFiles, setPendingFiles] = useState<File[]>([]); // 첨부 대기 중인 파일들 const [isCompletingNegotiation, setIsCompletingNegotiation] = useState(false); + const [submittingComments, setSubmittingComments] = useState<Set<number>>(new Set()); // 제출 중인 코멘트 ID // 코멘트 로드 const loadComments = useCallback(async () => { @@ -205,6 +207,33 @@ export function AgreementCommentList({ } }, []); // loadComments 제거 + // 코멘트 제출 핸들러 + const handleSubmitComment = useCallback(async (commentId: number) => { + if (!confirm("이 코멘트를 제출하시겠습니까?\n제출 시 상대방에게 이메일이 발송됩니다.")) { + return; + } + + setSubmittingComments(prev => new Set(prev).add(commentId)); + try { + const result = await submitAgreementComment(commentId); + if (result.success) { + toast.success("코멘트가 제출되었으며 상대방에게 이메일이 발송되었습니다."); + await loadComments(); // 목록 새로고침 + } else { + toast.error(result.error || "코멘트 제출에 실패했습니다."); + } + } catch (error) { + console.error('코멘트 제출 실패:', error); + toast.error("코멘트 제출에 실패했습니다."); + } finally { + setSubmittingComments(prev => { + const next = new Set(prev); + next.delete(commentId); + return next; + }); + } + }, []); // loadComments 제거 + // 협의 완료 핸들러 const handleCompleteNegotiation = useCallback(async () => { if (!confirm("협의를 완료하시겠습니까?\n협의 완료 후에는 법무검토 요청이 가능합니다.")) { @@ -525,7 +554,7 @@ export function AgreementCommentList({ <div className="space-y-3"> {/* 헤더: 작성자 정보 */} <div className="flex items-start justify-between"> - <div className="flex items-center space-x-2"> + <div className="flex items-center space-x-2 flex-1"> <Badge variant="outline" className={cn( @@ -552,18 +581,50 @@ export function AgreementCommentList({ {comment.authorName} </span> )} + {comment.isSubmitted && ( + <Badge + variant="outline" + className="bg-green-100 text-green-700 border-green-300 text-xs" + > + <Send className="h-3 w-3 mr-1" /> + 제출됨 + </Badge> + )} </div> - {!readOnly && isCommentOwner && ( - <Button - variant="ghost" - size="sm" - onClick={() => handleDeleteComment(comment.id)} - className="h-7 w-7 p-0 text-red-500 hover:text-red-700 hover:bg-red-50" - > - <Trash2 className="h-4 w-4" /> - </Button> - )} + <div className="flex items-center space-x-1"> + {!readOnly && isCommentOwner && !comment.isSubmitted && ( + <Button + variant="outline" + size="sm" + onClick={() => handleSubmitComment(comment.id)} + disabled={submittingComments.has(comment.id)} + className="h-7 text-xs bg-blue-50 text-blue-700 border-blue-300 hover:bg-blue-100" + > + {submittingComments.has(comment.id) ? ( + <> + <Loader2 className="h-3 w-3 mr-1 animate-spin" /> + 제출 중... + </> + ) : ( + <> + <Send className="h-3 w-3 mr-1" /> + 제출 + </> + )} + </Button> + )} + {!readOnly && isCommentOwner && ( + <Button + variant="ghost" + size="sm" + onClick={() => handleDeleteComment(comment.id)} + className="h-7 w-7 p-0 text-red-500 hover:text-red-700 hover:bg-red-50" + > + <Trash2 className="h-4 w-4" /> + </Button> + )} + </div> </div> {/* 코멘트 내용 */} @@ -667,16 +728,23 @@ export function AgreementCommentList({ </div> )} - {/* 푸터: 작성일시 */} + {/* 푸터: 작성일시 및 제출일시 */} <div className="flex items-center justify-between text-xs text-gray-500 pt-2 border-t border-gray-200"> - <span> - 작성일: {formatDateTime(comment.createdAt, "KR")} - </span> - {comment.updatedAt && comment.updatedAt.getTime() !== comment.createdAt.getTime() && ( + <div className="flex items-center space-x-3"> <span> - 수정일: {formatDateTime(comment.updatedAt, "KR")} + 작성일: {formatDateTime(comment.createdAt, "KR")} </span> - )} + {comment.updatedAt && comment.updatedAt.getTime() !== comment.createdAt.getTime() && ( + <span> + 수정일: {formatDateTime(comment.updatedAt, "KR")} + </span> + )} + {comment.isSubmitted && comment.submittedAt && ( + <span className="text-green-600 font-medium"> + 제출일: {formatDateTime(comment.submittedAt, "KR")} + </span> + )} + </div> </div> </div> </CardContent> diff --git a/lib/bidding/delete-action.ts b/lib/bidding/delete-action.ts new file mode 100644 index 00000000..32dc32ad --- /dev/null +++ b/lib/bidding/delete-action.ts @@ -0,0 +1,166 @@ +'use server' + +import { revalidatePath } from "next/cache"; +import db from "@/db/db"; +import { biddings, biddingCompanies } from "@/db/schema"; +import { eq, and, inArray } from "drizzle-orm"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { cancelRFQ } from "@/lib/soap/ecc/send/delete-rfq"; + +/** + * 입찰(Bidding) 삭제 서버 액션 + * 조건: + * 1. SAP 연동 건(ANFNR 존재)이어야 함 (필수) + * 2. 입찰 상태가 '입찰생성(bidding_generated)'이어야 함 + * + * 동작: + * 1. SAP 취소 I/F 전송 (필수) + * 2. 성공 시 입찰 상태를 '삭제(deleted)'로 변경 + * 3. 연관된 데이터(업체 응답 등) 상태 변경 + */ +export async function deleteBidding(biddingIds: number[], deleteReason?: string): Promise<{ + success: boolean; + message: string; + results?: Array<{ biddingId: number; success: boolean; error?: string }>; +}> { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.id) { + return { + success: false, + message: "인증이 필요합니다." + }; + } + + const userName = session.user.name || session.user.email || "Unknown"; + + // 1. Bidding 정보 조회 + const targets = await db.select({ + id: biddings.id, + biddingNumber: biddings.biddingNumber, + ANFNR: biddings.ANFNR, + status: biddings.status, + title: biddings.title + }) + .from(biddings) + .where(inArray(biddings.id, biddingIds)); + + // 2. 유효성 검증 + // 조건 1: ANFNR이 있어야 함 + // 조건 2: 상태가 'bidding_generated'(입찰생성) 이어야 함 + const validTargets = targets.filter(b => + b.status === 'bidding_generated' && + b.ANFNR && + b.ANFNR.trim() !== "" + ); + + if (validTargets.length === 0) { + // 실패 사유 분석 + const noAnfnr = targets.filter(b => !b.ANFNR || b.ANFNR.trim() === ""); + const wrongStatus = targets.filter(b => b.status !== 'bidding_generated'); + + let errorMsg = "삭제 가능한 입찰이 없습니다."; + if (noAnfnr.length > 0) errorMsg += " (SAP 연동 건(ANFNR)이 아님)"; + if (wrongStatus.length > 0) errorMsg += " ('입찰생성' 상태가 아님)"; + + return { + success: false, + message: errorMsg + }; + } + + const results: Array<{ biddingId: number; success: boolean; error?: string }> = []; + + // 3. 각 Bidding에 대해 처리 + for (const bidding of validTargets) { + try { + // 3-1. SAP 취소 요청 전송 (ANFNR 필수이므로 바로 호출) + const cancelResult = await cancelRFQ(bidding.ANFNR!); + + if (!cancelResult.success) { + results.push({ + biddingId: bidding.id, + success: false, + error: `SAP 전송 실패: ${cancelResult.message}` + }); + continue; + } + + // 3-2. DB 상태 변경 및 연관 데이터 정리 + await db.transaction(async (tx) => { + // 입찰 상태 변경 + await tx + .update(biddings) + .set({ + status: 'deleted', // 삭제 상태 처리 + remarks: deleteReason ? `[삭제사유] ${deleteReason}` : `[삭제됨] 사용자에 의한 삭제`, + updatedBy: userName, + updatedAt: new Date() + }) + .where(eq(biddings.id, bidding.id)); + + }); + + results.push({ + biddingId: bidding.id, + success: true + }); + + } catch (error) { + console.error(`입찰 삭제 실패 (ID: ${bidding.id}, ANFNR: ${bidding.ANFNR}):`, error); + results.push({ + biddingId: bidding.id, + success: false, + error: error instanceof Error ? error.message : "알 수 없는 오류" + }); + } + } + + const successCount = results.filter(r => r.success).length; + const failCount = results.length - successCount; + + // 캐시 갱신 + revalidatePath("/evcp/bid"); + + // 결과 메시지 조합 + let message = ""; + const invalidStatusCount = targets.filter(b => b.status !== 'bidding_generated').length; + const noAnfnrCount = targets.filter(b => !b.ANFNR || b.ANFNR.trim() === "").length; + + if (invalidStatusCount > 0) { + message += `'입찰생성' 상태가 아닌 건(${invalidStatusCount}건) 제외. `; + } + if (noAnfnrCount > 0) { + message += `SAP 미연동 건(${noAnfnrCount}건) 제외. `; + } + + if (failCount === 0 && successCount > 0) { + return { + success: true, + message: message + `입찰 삭제가 완료되었습니다. (${successCount}건)`, + results + }; + } else if (successCount > 0) { + return { + success: false, // 부분 성공 + message: message + `일부 삭제 실패 (성공: ${successCount}건, 실패: ${failCount}건)`, + results + }; + } else { + return { + success: false, + message: message + (failCount > 0 ? "삭제 처리에 실패했습니다." : "처리할 대상이 없습니다."), + results + }; + } + + } catch (error) { + console.error("입찰 삭제 처리 중 오류:", error); + return { + success: false, + message: error instanceof Error ? error.message : "입찰 삭제 처리 중 오류가 발생했습니다." + }; + } +} diff --git a/lib/bidding/list/biddings-delete-dialog.tsx b/lib/bidding/list/biddings-delete-dialog.tsx new file mode 100644 index 00000000..9291742f --- /dev/null +++ b/lib/bidding/list/biddings-delete-dialog.tsx @@ -0,0 +1,102 @@ +"use client" + +import * as React from "react" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { BiddingListItem } from "@/db/schema" +import { deleteBidding } from "@/lib/bidding/delete-action" +import { toast } from "sonner" +import { Loader2 } from "lucide-react" + +interface BiddingDeleteDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + bidding: BiddingListItem + onSuccess?: () => void +} + +export function BiddingDeleteDialog({ + open, + onOpenChange, + bidding, + onSuccess +}: BiddingDeleteDialogProps) { + const [isDeleting, setIsDeleting] = React.useState(false) + const [deleteReason, setDeleteReason] = React.useState("") + + const handleDelete = async () => { + if (!bidding) return + + setIsDeleting(true) + try { + const result = await deleteBidding([bidding.id], deleteReason) + + if (result.success) { + toast.success(result.message) + onOpenChange(false) + onSuccess?.() + } else { + toast.error(result.message) + } + } catch (error) { + toast.error("삭제 중 오류가 발생했습니다.") + console.error(error) + } finally { + setIsDeleting(false) + } + } + + return ( + <AlertDialog open={open} onOpenChange={onOpenChange}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>입찰 삭제</AlertDialogTitle> + <AlertDialogDescription> + 선택한 입찰({bidding?.biddingNumber})을 삭제하시겠습니까?<br/> + 삭제된 입찰은 복구할 수 없습니다. + <div className="mt-4"> + <Label htmlFor="deleteReason" className="mb-2 block">삭제 사유</Label> + <Input + id="deleteReason" + value={deleteReason} + onChange={(e) => setDeleteReason(e.target.value)} + placeholder="삭제 사유를 입력하세요" + /> + </div> + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel disabled={isDeleting}>취소</AlertDialogCancel> + <AlertDialogAction + onClick={(e) => { + e.preventDefault() + handleDelete() + }} + disabled={isDeleting} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {isDeleting ? ( + <> + <Loader2 className="mr-2 size-4 animate-spin" /> + 삭제 중... + </> + ) : ( + "삭제" + )} + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + ) +} + diff --git a/lib/bidding/list/biddings-table-toolbar-actions.tsx b/lib/bidding/list/biddings-table-toolbar-actions.tsx index a3851630..33368218 100644 --- a/lib/bidding/list/biddings-table-toolbar-actions.tsx +++ b/lib/bidding/list/biddings-table-toolbar-actions.tsx @@ -3,7 +3,7 @@ import * as React from "react" import { type Table } from "@tanstack/react-table" import { - Send, Download, FileSpreadsheet + Send, Download, FileSpreadsheet, Trash } from "lucide-react" import { toast } from "sonner" import { useSession } from "next-auth/react" @@ -21,6 +21,8 @@ import { BiddingCreateDialog } from "@/components/bidding/create/bidding-create- import { useForm } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import { createBiddingSchema } from "@/lib/bidding/validation" +import { deleteBidding } from "@/lib/bidding/delete-action" +import { BiddingDeleteDialog } from "./biddings-delete-dialog" interface BiddingsTableToolbarActionsProps { table: Table<BiddingListItem> @@ -30,7 +32,8 @@ export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActio const { data: session } = useSession() const [isExporting, setIsExporting] = React.useState(false) const [isTransmissionDialogOpen, setIsTransmissionDialogOpen] = React.useState(false) - + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false) + const userId = session?.user?.id ? Number(session.user.id) : 1 // 입찰 생성 폼 @@ -83,8 +86,11 @@ export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActio // 업체선정이 완료된 입찰만 전송 가능 const canTransmit = true - console.log(canTransmit, 'canTransmit') - console.log(selectedBiddings, 'selectedBiddings') + + // 삭제 가능 여부: 선택된 항목이 정확히 1개이고, '입찰생성' 상태여야 함 + const canDelete = React.useMemo(() => { + return selectedBiddings.length === 1 && selectedBiddings[0].status === 'bidding_generated' + }, [selectedBiddings]) return ( <> @@ -94,7 +100,6 @@ export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActio // 성공 시 테이블 새로고침 등 추가 작업 // window.location.reload() }} /> - {/* 전송하기 (업체선정 완료된 입찰만) */} <Button variant="default" @@ -106,6 +111,21 @@ export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActio <Send className="size-4" aria-hidden="true" /> <span className="hidden sm:inline">전송하기</span> </Button> + {/* 삭제 버튼 */} + + <Button + variant="destructive" + size="sm" + onClick={() => setIsDeleteDialogOpen(true)} + disabled={!canDelete} + className="gap-2" + > + <Trash className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">삭제</span> + </Button> + + + </div> {/* 전송 다이얼로그 */} @@ -115,6 +135,14 @@ export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActio bidding={selectedBiddings[0]} userId={userId} /> + + {/* 삭제 다이얼로그 */} + <BiddingDeleteDialog + open={isDeleteDialogOpen} + onOpenChange={setIsDeleteDialogOpen} + bidding={selectedBiddings[0]} + onSuccess={() => table.resetRowSelection()} + /> </> ) }
\ No newline at end of file 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/general-contracts/detail/general-contract-approval-request-dialog.tsx b/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx index 25c1fb9a..46251c71 100644 --- a/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx +++ b/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx @@ -29,8 +29,12 @@ import { getContractItems,
getSubcontractChecklist,
uploadContractApprovalFile,
- sendContractApprovalRequest
+ sendContractApprovalRequest,
+ getContractById,
+ getContractTemplateByContractType,
+ getStorageInfo
} from '../service'
+import { mapContractDataToTemplateVariables } from '../utils'
interface ContractApprovalRequestDialogProps {
contract: Record<string, unknown>
@@ -42,6 +46,7 @@ interface ContractSummary { basicInfo: Record<string, unknown>
items: Record<string, unknown>[]
subcontractChecklist: Record<string, unknown> | null
+ storageInfo?: Record<string, unknown>[]
}
export function ContractApprovalRequestDialog({
@@ -70,63 +75,6 @@ export function ContractApprovalRequestDialog({ const contractId = contract.id as number
const userId = session?.user?.id || ''
- // LOI 템플릿용 변수 매핑 함수
- const mapContractSummaryToLOITemplate = (contractSummary: ContractSummary) => {
- const { basicInfo, items } = contractSummary
- const firstItem = items && items.length > 0 ? items[0] : {}
-
- // 날짜 포맷팅 헬퍼 함수
- const formatDate = (date: any) => {
- if (!date) return ''
- try {
- const d = new Date(date)
- return d.toLocaleDateString('ko-KR')
- } catch {
- return ''
- }
- }
-
- return {
- // 날짜 관련 (템플릿에서 {{todayDate}} 형식으로 사용)
- todayDate: new Date().toLocaleDateString('ko-KR'),
-
- // 벤더 정보
- vendorName: basicInfo?.vendorName || '',
- representativeName: '', // 벤더 대표자 이름 - 현재 데이터에 없음, 향후 확장 가능
-
- // 계약 기본 정보
- contractNumber: basicInfo?.contractNumber || '',
-
- // 프로젝트 정보
- projectNumber: '', // 프로젝트 코드 - 현재 데이터에 없음, 향후 확장 가능
- projectName: basicInfo?.projectName || '',
- project: basicInfo?.projectName || '',
-
- // 아이템 정보
- item: firstItem?.itemInfo || '',
-
- // 무역 조건
- incoterms: basicInfo?.deliveryTerm || '', // Incoterms 대신 deliveryTerm 사용
- shippingLocation: basicInfo?.shippingLocation || '',
-
- // 금액 및 통화
- contractCurrency: basicInfo?.currency || '',
- contractAmount: basicInfo?.contractAmount || '',
- totalAmount: basicInfo?.contractAmount || '', // totalAmount가 없으면 contractAmount 사용
-
- // 수량
- quantity: firstItem?.quantity || '',
-
- // 납기일
- contractDeliveryDate: formatDate(basicInfo?.contractDeliveryDate),
-
- // 지급 조건
- paymentTerm: basicInfo?.paymentTerm || '',
-
- // 유효기간
- validityEndDate: formatDate(basicInfo?.endDate), // validityEndDate 대신 endDate 사용
- }
- }
// 기본계약 생성 함수 (최종 전송 시점에 호출)
const generateBasicContractPdf = async (
@@ -312,6 +260,18 @@ export function ContractApprovalRequestDialog({ } catch {
console.log('Subcontract Checklist 데이터 없음')
}
+
+ // 임치(물품보관) 계약 정보 확인 (SG)
+ try {
+ if (summary.basicInfo?.contractType === 'SG') {
+ const storageData = await getStorageInfo(contractId)
+ if (storageData && storageData.length > 0) {
+ summary.storageInfo = storageData
+ }
+ }
+ } catch {
+ console.log('임치계약 정보 없음')
+ }
console.log('contractSummary 구조:', summary)
console.log('basicInfo 내용:', summary.basicInfo)
@@ -324,55 +284,42 @@ export function ContractApprovalRequestDialog({ }
}, [contractId])
- // 3단계: 파일 업로드 처리
- const handleFileUpload = async (file: File) => {
- // 파일 확장자 검증
- const allowedExtensions = ['.doc', '.docx']
- const fileExtension = file.name.toLowerCase().substring(file.name.lastIndexOf('.'))
-
- if (!allowedExtensions.includes(fileExtension)) {
- toast.error('Word 문서(.doc, .docx) 파일만 업로드 가능합니다.')
- return
- }
-
- if (!userId) {
- toast.error('로그인이 필요합니다.')
+ // 3단계: PDF 생성 및 미리보기 (PDFTron 사용) - 템플릿 자동 로드
+ const generatePdf = async () => {
+ if (!contractSummary) {
+ toast.error('계약 정보가 필요합니다.')
return
}
setIsLoading(true)
try {
- // 서버액션을 사용하여 파일 저장 (본 계약문서로 고정)
- const result = await uploadContractApprovalFile(
- contractId,
- file,
- userId
- )
+ // 1. 계약 유형에 맞는 템플릿 조회
+ const contractType = contractSummary.basicInfo.contractType as string
+ const templateResult = await getContractTemplateByContractType(contractType)
- if (result.success) {
- setUploadedFile(file)
- toast.success('파일이 업로드되었습니다.')
- } else {
- throw new Error(result.error || '파일 업로드 실패')
+ if (!templateResult.success || !templateResult.template) {
+ throw new Error(templateResult.error || '템플릿을 찾을 수 없습니다.')
}
- } catch (error) {
- console.error('Error uploading file:', error)
- toast.error('파일 업로드 중 오류가 발생했습니다.')
- } finally {
- setIsLoading(false)
- }
- }
- // 4단계: PDF 생성 및 미리보기 (PDFTron 사용)
- const generatePdf = async () => {
- if (!uploadedFile || !contractSummary) {
- toast.error('업로드된 파일과 계약 정보가 필요합니다.')
- return
- }
+ const template = templateResult.template
- setIsLoading(true)
- try {
- // PDFTron을 사용해서 변수 치환 및 PDF 변환
+ // 2. 템플릿 파일 다운로드
+ const templateResponse = await fetch("/api/contracts/get-template", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ templatePath: template.filePath }),
+ })
+
+ if (!templateResponse.ok) {
+ throw new Error("템플릿 파일을 다운로드할 수 없습니다.")
+ }
+
+ const templateBlob = await templateResponse.blob()
+ const templateFile = new File([templateBlob], template.fileName || "template.docx", {
+ type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
+ })
+
+ // 3. PDFTron을 사용해서 변수 치환 및 PDF 변환
// @ts-ignore
const WebViewer = await import("@pdftron/webviewer").then(({ default: WebViewer }) => WebViewer)
@@ -394,34 +341,34 @@ export function ContractApprovalRequestDialog({ const { Core } = instance
const { createDocument } = Core
- // 템플릿 문서 생성 및 변수 치환
- const templateDoc = await createDocument(uploadedFile, {
- filename: uploadedFile.name,
- extension: 'docx',
- })
+ // 템플릿 문서 생성 및 변수 치환
+ const templateDoc = await createDocument(templateFile, {
+ filename: templateFile.name,
+ extension: 'docx',
+ })
- // LOI 템플릿용 변수 매핑
- const mappedTemplateData = mapContractSummaryToLOITemplate(contractSummary)
+ // 템플릿 변수 매핑
+ const mappedTemplateData = mapContractDataToTemplateVariables(contractSummary)
- console.log("🔄 변수 치환 시작:", mappedTemplateData)
- await templateDoc.applyTemplateValues(mappedTemplateData as any)
- console.log("✅ 변수 치환 완료")
+ console.log("🔄 변수 치환 시작:", mappedTemplateData)
+ await templateDoc.applyTemplateValues(mappedTemplateData as any)
+ console.log("✅ 변수 치환 완료")
- // PDF 변환
- const fileData = await templateDoc.getFileData()
+ // PDF 변환
+ const fileData = await templateDoc.getFileData()
const pdfBuffer = await (Core as any).officeToPDFBuffer(fileData, { extension: 'docx' })
- console.log(`✅ PDF 변환 완료: ${uploadedFile.name}`, `크기: ${pdfBuffer.byteLength} bytes`)
+ console.log(`✅ PDF 변환 완료: ${templateFile.name}`, `크기: ${pdfBuffer.byteLength} bytes`)
- // PDF 버퍼를 Blob URL로 변환하여 미리보기
- const pdfBlob = new Blob([pdfBuffer], { type: 'application/pdf' })
- const pdfUrl = URL.createObjectURL(pdfBlob)
- setGeneratedPdfUrl(pdfUrl)
-
- // PDF 버퍼를 상태에 저장 (최종 전송 시 사용)
- setGeneratedPdfBuffer(new Uint8Array(pdfBuffer))
-
- toast.success('PDF가 생성되었습니다.')
+ // PDF 버퍼를 Blob URL로 변환하여 미리보기
+ const pdfBlob = new Blob([pdfBuffer], { type: 'application/pdf' })
+ const pdfUrl = URL.createObjectURL(pdfBlob)
+ setGeneratedPdfUrl(pdfUrl)
+
+ // PDF 버퍼를 상태에 저장 (최종 전송 시 사용)
+ setGeneratedPdfBuffer(new Uint8Array(pdfBuffer))
+
+ toast.success('PDF가 생성되었습니다.')
} finally {
// 임시 WebViewer 정리
@@ -429,9 +376,10 @@ export function ContractApprovalRequestDialog({ document.body.removeChild(tempDiv)
}
- } catch (error) {
+ } catch (error: any) {
console.error('❌ PDF 생성 실패:', error)
- toast.error('PDF 생성 중 오류가 발생했습니다.')
+ const errorMessage = error instanceof Error ? error.message : (error?.message || '알 수 없는 오류')
+ toast.error(`PDF 생성 중 오류가 발생했습니다: ${errorMessage}`)
} finally {
setIsLoading(false)
}
@@ -498,13 +446,13 @@ export function ContractApprovalRequestDialog({ setPdfViewerInstance(instance)
// PDF 버퍼를 Blob으로 변환
- const pdfBlob = new Blob([generatedPdfBuffer], { type: 'application/pdf' })
+ const pdfBlob = new Blob([generatedPdfBuffer as any], { type: 'application/pdf' })
const pdfUrl = URL.createObjectURL(pdfBlob)
console.log("🔄 PDF Blob URL 생성:", pdfUrl)
// 문서 로드
console.log("🔄 문서 로드 시작")
- const { documentViewer } = instance.Core
+ const { documentViewer } = (instance as any).Core
// 문서 로드 이벤트 대기
await new Promise((resolve, reject) => {
@@ -553,7 +501,7 @@ export function ContractApprovalRequestDialog({ return
}
- const pdfBlob = new Blob([generatedPdfBuffer], { type: 'application/pdf' })
+ const pdfBlob = new Blob([generatedPdfBuffer as any], { type: 'application/pdf' })
const pdfUrl = URL.createObjectURL(pdfBlob)
const link = document.createElement('a')
@@ -715,7 +663,7 @@ export function ContractApprovalRequestDialog({ </DialogHeader>
<Tabs value={currentStep.toString()} className="w-full">
- <TabsList className="grid w-full grid-cols-4">
+ <TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="1" disabled={currentStep < 1}>
1. 계약 현황 정리
</TabsTrigger>
@@ -723,10 +671,7 @@ export function ContractApprovalRequestDialog({ 2. 기본계약 체크
</TabsTrigger>
<TabsTrigger value="3" disabled={currentStep < 3}>
- 3. 문서 업로드
- </TabsTrigger>
- <TabsTrigger value="4" disabled={currentStep < 4}>
- 4. PDF 미리보기
+ 3. PDF 미리보기
</TabsTrigger>
</TabsList>
@@ -843,7 +788,7 @@ export function ContractApprovalRequestDialog({ <div>
<span className="font-medium">계약성립조건:</span>
{contractSummary?.basicInfo?.contractEstablishmentConditions &&
- Object.entries(contractSummary.basicInfo.contractEstablishmentConditions)
+ Object.entries(contractSummary.basicInfo.contractEstablishmentConditions as Record<string, unknown>)
.filter(([, value]) => value === true)
.map(([key]) => key)
.join(', ') || '없음'}
@@ -851,7 +796,7 @@ export function ContractApprovalRequestDialog({ <div>
<span className="font-medium">계약해지조건:</span>
{contractSummary?.basicInfo?.contractTerminationConditions &&
- Object.entries(contractSummary.basicInfo.contractTerminationConditions)
+ Object.entries(contractSummary.basicInfo.contractTerminationConditions as Record<string, unknown>)
.filter(([, value]) => value === true)
.map(([key]) => key)
.join(', ') || '없음'}
@@ -878,9 +823,9 @@ export function ContractApprovalRequestDialog({ <div className="max-h-32 overflow-y-auto">
{contractSummary.items.slice(0, 3).map((item: Record<string, unknown>, index: number) => (
<div key={index} className="text-xs bg-gray-50 p-2 rounded">
- <div className="font-medium">{item.itemInfo || item.description || `품목 ${index + 1}`}</div>
+ <div className="font-medium">{String(item.itemInfo || item.description || `품목 ${index + 1}`)}</div>
<div className="text-muted-foreground">
- 수량: {item.quantity || 0} | 단가: {item.contractUnitPrice || item.unitPrice || 0}
+ 수량: {String(item.quantity || 0)} | 단가: {String(item.contractUnitPrice || item.unitPrice || 0)}
</div>
</div>
))}
@@ -1022,75 +967,11 @@ export function ContractApprovalRequestDialog({ </div>
</TabsContent>
- {/* 3단계: 문서 업로드 */}
+ {/* 3단계: PDF 미리보기 */}
<TabsContent value="3" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
- <Upload className="h-5 w-5 text-blue-600" />
- 계약서 업로드
- </CardTitle>
- </CardHeader>
- <CardContent className="space-y-4">
- <div className="space-y-4">
- <p className="text-lg text-muted-foreground">일반계약 표준문서 관리 페이지에 접속하여, 원하는 양식의 계약서를 다운받아 수정 후 업로드하세요.</p>
- <div>
- <Label htmlFor="file-upload">파일 업로드</Label>
- <Input
- id="file-upload"
- type="file"
- accept=".doc,.docx"
- onChange={(e) => {
- const file = e.target.files?.[0]
- if (file) handleFileUpload(file)
- }}
- />
- <p className="text-sm text-muted-foreground mt-1">
- Word 문서(.doc, .docx) 파일만 업로드 가능합니다.
- </p>
- </div>
-
- {/* ContractDocuments 컴포넌트 사용 */}
- {/* <div className="mt-4">
- <Label>업로드된 문서</Label>
- <ContractDocuments
- contractId={contractId}
- userId={userId}
- readOnly={false}
- />
- </div> */}
-
- {uploadedFile && (
- <div className="border rounded-lg p-4 bg-green-50">
- <div className="flex items-center gap-2">
- <CheckCircle className="h-4 w-4 text-green-600" />
- <span className="font-medium text-green-900">업로드 완료</span>
- </div>
- <p className="text-sm text-green-800 mt-1">{uploadedFile.name}</p>
- </div>
- )}
- </div>
- </CardContent>
- </Card>
-
- <div className="flex justify-between">
- <Button variant="outline" onClick={() => setCurrentStep(2)}>
- 이전 단계
- </Button>
- <Button
- onClick={() => setCurrentStep(4)}
- disabled={!uploadedFile}
- >
- 다음 단계
- </Button>
- </div>
- </TabsContent>
-
- {/* 4단계: PDF 미리보기 */}
- <TabsContent value="4" className="space-y-4">
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
<Eye className="h-5 w-5 text-purple-600" />
PDF 미리보기
</CardTitle>
@@ -1168,7 +1049,7 @@ export function ContractApprovalRequestDialog({ </Card>
<div className="flex justify-between">
- <Button variant="outline" onClick={() => setCurrentStep(3)}>
+ <Button variant="outline" onClick={() => setCurrentStep(2)}>
이전 단계
</Button>
<Button
diff --git a/lib/general-contracts/detail/general-contract-review-request-dialog.tsx b/lib/general-contracts/detail/general-contract-review-request-dialog.tsx index b487ae25..c31ce4ac 100644 --- a/lib/general-contracts/detail/general-contract-review-request-dialog.tsx +++ b/lib/general-contracts/detail/general-contract-review-request-dialog.tsx @@ -9,11 +9,9 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
-import { Input } from '@/components/ui/input'
import { toast } from 'sonner'
import {
FileText,
- Upload,
Eye,
Send,
CheckCircle,
@@ -23,10 +21,12 @@ import { getBasicInfo,
getContractItems,
getSubcontractChecklist,
- uploadContractReviewFile,
sendContractReviewRequest,
- getContractById
+ getContractById,
+ getContractTemplateByContractType,
+ getStorageInfo
} from '../service'
+import { mapContractDataToTemplateVariables } from '../utils'
interface ContractReviewRequestDialogProps {
contract: Record<string, unknown>
@@ -38,6 +38,7 @@ interface ContractSummary { basicInfo: Record<string, unknown>
items: Record<string, unknown>[]
subcontractChecklist: Record<string, unknown> | null
+ storageInfo?: Record<string, unknown>[]
}
export function ContractReviewRequestDialog({
@@ -58,63 +59,6 @@ export function ContractReviewRequestDialog({ const contractId = contract.id as number
const userId = session?.user?.id || ''
- // LOI 템플릿용 변수 매핑 함수
- const mapContractSummaryToLOITemplate = (contractSummary: ContractSummary) => {
- const { basicInfo, items } = contractSummary
- const firstItem = items && items.length > 0 ? items[0] : {}
-
- // 날짜 포맷팅 헬퍼 함수
- const formatDate = (date: unknown) => {
- if (!date) return ''
- try {
- const d = new Date(date)
- return d.toLocaleDateString('ko-KR')
- } catch {
- return ''
- }
- }
-
- return {
- // 날짜 관련 (템플릿에서 {{todayDate}} 형식으로 사용)
- todayDate: new Date().toLocaleDateString('ko-KR'),
-
- // 벤더 정보
- vendorName: basicInfo?.vendorName || '',
- representativeName: '', // 벤더 대표자 이름 - 현재 데이터에 없음, 향후 확장 가능
-
- // 계약 기본 정보
- contractNumber: basicInfo?.contractNumber || '',
-
- // 프로젝트 정보
- projectNumber: '', // 프로젝트 코드 - 현재 데이터에 없음, 향후 확장 가능
- projectName: basicInfo?.projectName || '',
- project: basicInfo?.projectName || '',
-
- // 아이템 정보
- item: firstItem?.itemInfo || '',
-
- // 무역 조건
- incoterms: basicInfo?.deliveryTerm || '', // Incoterms 대신 deliveryTerm 사용
- shippingLocation: basicInfo?.shippingLocation || '',
-
- // 금액 및 통화
- contractCurrency: basicInfo?.currency || '',
- contractAmount: basicInfo?.contractAmount || '',
- totalAmount: basicInfo?.contractAmount || '', // totalAmount가 없으면 contractAmount 사용
-
- // 수량
- quantity: firstItem?.quantity || '',
-
- // 납기일
- contractDeliveryDate: formatDate(basicInfo?.contractDeliveryDate),
-
- // 지급 조건
- paymentTerm: basicInfo?.paymentTerm || '',
-
- // 유효기간
- validityEndDate: formatDate(basicInfo?.endDate), // validityEndDate 대신 endDate 사용
- }
- }
// 1단계: 계약 현황 수집
const collectContractSummary = React.useCallback(async () => {
@@ -164,6 +108,18 @@ export function ContractReviewRequestDialog({ } catch {
console.log('Subcontract Checklist 데이터 없음')
}
+
+ // 임치(물품보관) 계약 정보 확인 (SG)
+ try {
+ if (summary.basicInfo?.contractType === 'SG') {
+ const storageData = await getStorageInfo(contractId)
+ if (storageData && storageData.length > 0) {
+ summary.storageInfo = storageData
+ }
+ }
+ } catch {
+ console.log('임치계약 정보 없음')
+ }
console.log('contractSummary 구조:', summary)
console.log('basicInfo 내용:', summary.basicInfo)
@@ -176,56 +132,43 @@ export function ContractReviewRequestDialog({ }
}, [contractId])
- // 3단계: 파일 업로드 처리
- const handleFileUpload = async (file: File) => {
- // 파일 확장자 검증
- const allowedExtensions = ['.doc', '.docx']
- const fileExtension = file.name.toLowerCase().substring(file.name.lastIndexOf('.'))
-
- if (!allowedExtensions.includes(fileExtension)) {
- toast.error('Word 문서(.doc, .docx) 파일만 업로드 가능합니다.')
- return
- }
-
- if (!userId) {
- toast.error('로그인이 필요합니다.')
+ // 2단계: PDF 생성 및 미리보기 (PDFTron 사용) - 템플릿 자동 로드
+ const generatePdf = async () => {
+ if (!contractSummary) {
+ toast.error('계약 정보가 필요합니다.')
return
}
setIsLoading(true)
try {
- // 서버액션을 사용하여 파일 저장 (조건검토용)
- const result = await uploadContractReviewFile(
- contractId,
- file,
- userId
- )
+ // 1. 계약 유형에 맞는 템플릿 조회
+ const contractType = contractSummary.basicInfo.contractType as string
+ const templateResult = await getContractTemplateByContractType(contractType)
- if (result.success) {
- setUploadedFile(file)
- toast.success('파일이 업로드되었습니다.')
- } else {
- throw new Error(result.error || '파일 업로드 실패')
+ if (!templateResult.success || !templateResult.template) {
+ throw new Error(templateResult.error || '템플릿을 찾을 수 없습니다.')
}
- } catch (error) {
- console.error('Error uploading file:', error)
- toast.error('파일 업로드 중 오류가 발생했습니다.')
- } finally {
- setIsLoading(false)
- }
- }
- // 4단계: PDF 생성 및 미리보기 (PDFTron 사용)
- const generatePdf = async () => {
- if (!uploadedFile || !contractSummary) {
- toast.error('업로드된 파일과 계약 정보가 필요합니다.')
- return
- }
+ const template = templateResult.template
- setIsLoading(true)
- try {
- // PDFTron을 사용해서 변수 치환 및 PDF 변환
- // @ts-expect-error - PDFTron WebViewer dynamic import
+ // 2. 템플릿 파일 다운로드
+ const templateResponse = await fetch("/api/contracts/get-template", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ templatePath: template.filePath }),
+ })
+
+ if (!templateResponse.ok) {
+ throw new Error("템플릿 파일을 다운로드할 수 없습니다.")
+ }
+
+ const templateBlob = await templateResponse.blob()
+ const templateFile = new File([templateBlob], template.fileName || "template.docx", {
+ type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
+ })
+
+ // 3. PDFTron을 사용해서 변수 치환 및 PDF 변환
+ // @ts-ignore
const WebViewer = await import("@pdftron/webviewer").then(({ default: WebViewer }) => WebViewer)
// 임시 WebViewer 인스턴스 생성 (DOM에 추가하지 않음)
@@ -246,34 +189,34 @@ export function ContractReviewRequestDialog({ const { Core } = instance
const { createDocument } = Core
- // 템플릿 문서 생성 및 변수 치환
- const templateDoc = await createDocument(uploadedFile, {
- filename: uploadedFile.name,
- extension: 'docx',
- })
+ // 템플릿 문서 생성 및 변수 치환
+ const templateDoc = await createDocument(templateFile, {
+ filename: templateFile.name,
+ extension: 'docx',
+ })
- // LOI 템플릿용 변수 매핑
- const mappedTemplateData = mapContractSummaryToLOITemplate(contractSummary)
+ // 템플릿 변수 매핑
+ const mappedTemplateData = mapContractDataToTemplateVariables(contractSummary)
- console.log("🔄 변수 치환 시작:", mappedTemplateData)
- await templateDoc.applyTemplateValues(mappedTemplateData as Record<string, unknown>)
- console.log("✅ 변수 치환 완료")
+ console.log("🔄 변수 치환 시작:", mappedTemplateData)
+ await templateDoc.applyTemplateValues(mappedTemplateData as any)
+ console.log("✅ 변수 치환 완료")
- // PDF 변환
- const fileData = await templateDoc.getFileData()
- const pdfBuffer = await (Core as { officeToPDFBuffer: (data: unknown, options: { extension: string }) => Promise<Uint8Array> }).officeToPDFBuffer(fileData, { extension: 'docx' })
+ // PDF 변환
+ const fileData = await templateDoc.getFileData()
+ const pdfBuffer = await (Core as any).officeToPDFBuffer(fileData, { extension: 'docx' })
- console.log(`✅ PDF 변환 완료: ${uploadedFile.name}`, `크기: ${pdfBuffer.byteLength} bytes`)
+ console.log(`✅ PDF 변환 완료: ${templateFile.name}`, `크기: ${pdfBuffer.byteLength} bytes`)
- // PDF 버퍼를 Blob URL로 변환하여 미리보기
- const pdfBlob = new Blob([pdfBuffer], { type: 'application/pdf' })
- const pdfUrl = URL.createObjectURL(pdfBlob)
- setGeneratedPdfUrl(pdfUrl)
-
- // PDF 버퍼를 상태에 저장 (최종 전송 시 사용)
- setGeneratedPdfBuffer(new Uint8Array(pdfBuffer))
-
- toast.success('PDF가 생성되었습니다.')
+ // PDF 버퍼를 Blob URL로 변환하여 미리보기
+ const pdfBlob = new Blob([pdfBuffer], { type: 'application/pdf' })
+ const pdfUrl = URL.createObjectURL(pdfBlob)
+ setGeneratedPdfUrl(pdfUrl)
+
+ // PDF 버퍼를 상태에 저장 (최종 전송 시 사용)
+ setGeneratedPdfBuffer(new Uint8Array(pdfBuffer))
+
+ toast.success('PDF가 생성되었습니다.')
} finally {
// 임시 WebViewer 정리
@@ -283,7 +226,8 @@ export function ContractReviewRequestDialog({ } catch (error) {
console.error('❌ PDF 생성 실패:', error)
- toast.error('PDF 생성 중 오류가 발생했습니다.')
+ const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류'
+ toast.error(`PDF 생성 중 오류가 발생했습니다: ${errorMessage}`)
} finally {
setIsLoading(false)
}
@@ -298,7 +242,7 @@ export function ContractReviewRequestDialog({ setIsLoading(true)
try {
- // @ts-expect-error - PDFTron WebViewer dynamic import
+ // @ts-ignore - PDFTron WebViewer dynamic import
const WebViewer = await import("@pdftron/webviewer").then(({ default: WebViewer }) => WebViewer)
// 기존 인스턴스가 있다면 정리
@@ -350,13 +294,13 @@ export function ContractReviewRequestDialog({ setPdfViewerInstance(instance)
// PDF 버퍼를 Blob으로 변환
- const pdfBlob = new Blob([generatedPdfBuffer], { type: 'application/pdf' })
+ const pdfBlob = new Blob([generatedPdfBuffer as any], { type: 'application/pdf' })
const pdfUrl = URL.createObjectURL(pdfBlob)
console.log("🔄 PDF Blob URL 생성:", pdfUrl)
// 문서 로드
console.log("🔄 문서 로드 시작")
- const { documentViewer } = instance.Core
+ const { documentViewer } = (instance as any).Core
// 문서 로드 이벤트 대기
await new Promise((resolve, reject) => {
@@ -406,7 +350,7 @@ export function ContractReviewRequestDialog({ return
}
- const pdfBlob = new Blob([generatedPdfBuffer], { type: 'application/pdf' })
+ const pdfBlob = new Blob([generatedPdfBuffer as any], { type: 'application/pdf' })
const pdfUrl = URL.createObjectURL(pdfBlob)
const link = document.createElement('a')
@@ -426,6 +370,8 @@ export function ContractReviewRequestDialog({ if (pdfViewerInstance) {
try {
console.log("🔄 WebViewer 인스턴스 정리")
+ // @ts-expect-error - PDFTron WebViewer dynamic import
+ // @ts-ignore
pdfViewerInstance.UI.dispose()
} catch (error) {
console.warn('WebViewer 정리 중 오류:', error)
@@ -500,7 +446,6 @@ export function ContractReviewRequestDialog({ closePdfPreview()
// 상태 초기화
setCurrentStep(1)
- setUploadedFile(null)
setGeneratedPdfUrl(null)
setGeneratedPdfBuffer(null)
setIsPdfPreviewVisible(false)
@@ -520,15 +465,12 @@ export function ContractReviewRequestDialog({ </DialogHeader>
<Tabs value={currentStep.toString()} className="w-full">
- <TabsList className="grid w-full grid-cols-3">
+ <TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="1" disabled={currentStep < 1}>
1. 미리보기
</TabsTrigger>
<TabsTrigger value="2" disabled={currentStep < 2}>
- 2. 템플릿 업로드
- </TabsTrigger>
- <TabsTrigger value="3" disabled={currentStep < 3}>
- 3. PDF 미리보기
+ 2. PDF 미리보기
</TabsTrigger>
</TabsList>
@@ -645,7 +587,7 @@ export function ContractReviewRequestDialog({ <div>
<span className="font-medium">계약성립조건:</span>
{contractSummary?.basicInfo?.contractEstablishmentConditions &&
- Object.entries(contractSummary.basicInfo.contractEstablishmentConditions)
+ Object.entries(contractSummary.basicInfo.contractEstablishmentConditions as Record<string, unknown>)
.filter(([, value]) => value === true)
.map(([key]) => key)
.join(', ') || '없음'}
@@ -653,7 +595,7 @@ export function ContractReviewRequestDialog({ <div>
<span className="font-medium">계약해지조건:</span>
{contractSummary?.basicInfo?.contractTerminationConditions &&
- Object.entries(contractSummary.basicInfo.contractTerminationConditions)
+ Object.entries(contractSummary.basicInfo.contractTerminationConditions as Record<string, unknown>)
.filter(([, value]) => value === true)
.map(([key]) => key)
.join(', ') || '없음'}
@@ -680,9 +622,9 @@ export function ContractReviewRequestDialog({ <div className="max-h-32 overflow-y-auto">
{contractSummary.items.slice(0, 3).map((item: Record<string, unknown>, index: number) => (
<div key={index} className="text-xs bg-gray-50 p-2 rounded">
- <div className="font-medium">{item.itemInfo || item.description || `품목 ${index + 1}`}</div>
+ <div className="font-medium">{String(item.itemInfo || item.description || `품목 ${index + 1}`)}</div>
<div className="text-muted-foreground">
- 수량: {item.quantity || 0} | 단가: {item.contractUnitPrice || item.unitPrice || 0}
+ 수량: {String(item.quantity || 0)} | 단가: {String(item.contractUnitPrice || item.unitPrice || 0)}
</div>
</div>
))}
@@ -734,65 +676,11 @@ export function ContractReviewRequestDialog({ </div>
</TabsContent>
- {/* 2단계: 문서 업로드 */}
+ {/* 2단계: PDF 미리보기 (자동 생성) */}
<TabsContent value="2" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
- <Upload className="h-5 w-5 text-blue-600" />
- 계약서 템플릿 업로드
- </CardTitle>
- </CardHeader>
- <CardContent className="space-y-4">
- <div className="space-y-4">
- <p className="text-lg text-muted-foreground">일반계약 표준문서 관리 페이지에 접속하여, 원하는 양식의 계약서를 다운받아 수정 후 업로드하세요.</p>
- <div>
- <Label htmlFor="file-upload-review">파일 업로드</Label>
- <Input
- id="file-upload-review"
- type="file"
- accept=".doc,.docx"
- onChange={(e) => {
- const file = e.target.files?.[0]
- if (file) handleFileUpload(file)
- }}
- />
- <p className="text-sm text-muted-foreground mt-1">
- Word 문서(.doc, .docx) 파일만 업로드 가능합니다.
- </p>
- </div>
-
- {uploadedFile && (
- <div className="border rounded-lg p-4 bg-green-50">
- <div className="flex items-center gap-2">
- <CheckCircle className="h-4 w-4 text-green-600" />
- <span className="font-medium text-green-900">업로드 완료</span>
- </div>
- <p className="text-sm text-green-800 mt-1">{uploadedFile.name}</p>
- </div>
- )}
- </div>
- </CardContent>
- </Card>
-
- <div className="flex justify-between">
- <Button variant="outline" onClick={() => setCurrentStep(1)}>
- 이전 단계
- </Button>
- <Button
- onClick={() => setCurrentStep(3)}
- disabled={!uploadedFile}
- >
- 다음 단계
- </Button>
- </div>
- </TabsContent>
-
- {/* 3단계: PDF 미리보기 */}
- <TabsContent value="3" className="space-y-4">
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
<Eye className="h-5 w-5 text-purple-600" />
PDF 미리보기
</CardTitle>
@@ -870,7 +758,7 @@ export function ContractReviewRequestDialog({ </Card>
<div className="flex justify-between">
- <Button variant="outline" onClick={() => setCurrentStep(2)}>
+ <Button variant="outline" onClick={() => setCurrentStep(1)}>
이전 단계
</Button>
<Button
@@ -888,4 +776,3 @@ export function ContractReviewRequestDialog({ </Dialog>
)
}
-
diff --git a/lib/general-contracts/main/create-general-contract-dialog.tsx b/lib/general-contracts/main/create-general-contract-dialog.tsx index bb251408..8a506e4f 100644 --- a/lib/general-contracts/main/create-general-contract-dialog.tsx +++ b/lib/general-contracts/main/create-general-contract-dialog.tsx @@ -18,12 +18,6 @@ import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
-import { Calendar } from "@/components/ui/calendar"
-import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
-import { CalendarIcon } from "lucide-react"
-import { format } from "date-fns"
-import { ko } from "date-fns/locale"
-import { cn } from "@/lib/utils"
import { createContract } from "@/lib/general-contracts/service"
import {
GENERAL_CONTRACT_CATEGORIES,
@@ -40,9 +34,9 @@ interface CreateContractForm { category: string
type: string
executionMethod: string
- startDate: Date | undefined
- endDate: Date | undefined
- validityEndDate: Date | undefined
+ startDate: string
+ endDate: string
+ validityEndDate: string
notes: string
}
@@ -59,9 +53,9 @@ export function CreateGeneralContractDialog() { category: '',
type: '',
executionMethod: '',
- startDate: undefined,
- endDate: undefined,
- validityEndDate: undefined,
+ startDate: '',
+ endDate: '',
+ validityEndDate: '',
notes: '',
})
@@ -106,9 +100,9 @@ export function CreateGeneralContractDialog() { executionMethod: form.executionMethod,
contractSourceType: 'manual',
vendorId: selectedVendor!.id,
- startDate: form.startDate!.toISOString().split('T')[0],
- endDate: form.endDate!.toISOString().split('T')[0],
- validityEndDate: (form.validityEndDate || form.endDate!).toISOString().split('T')[0],
+ startDate: form.startDate,
+ endDate: form.endDate,
+ validityEndDate: form.validityEndDate || form.endDate,
status: 'Draft',
registeredById: session?.user?.id || 1,
lastUpdatedById: session?.user?.id || 1,
@@ -138,9 +132,9 @@ export function CreateGeneralContractDialog() { category: '',
type: '',
executionMethod: '',
- startDate: undefined,
- endDate: undefined,
- validityEndDate: undefined,
+ startDate: '',
+ endDate: '',
+ validityEndDate: '',
notes: '',
})
setSelectedVendor(null)
@@ -276,81 +270,45 @@ export function CreateGeneralContractDialog() { <div className="grid grid-cols-3 gap-4">
<div className="grid gap-2">
- <Label>계약시작일 *</Label>
- <Popover>
- <PopoverTrigger asChild>
- <Button
- variant="outline"
- className={cn(
- "justify-start text-left font-normal",
- !form.startDate && "text-muted-foreground"
- )}
- >
- <CalendarIcon className="mr-2 h-4 w-4" />
- {form.startDate ? format(form.startDate, "yyyy-MM-dd", { locale: ko }) : "날짜 선택"}
- </Button>
- </PopoverTrigger>
- <PopoverContent className="w-auto p-0">
- <Calendar
- mode="single"
- selected={form.startDate}
- onSelect={(date) => setForm(prev => ({ ...prev, startDate: date }))}
- initialFocus
- />
- </PopoverContent>
- </Popover>
+ <Label htmlFor="startDate">
+ 계약시작일
+ {!['AD', 'LO', 'OF'].includes(form.type) && <span className="text-red-600 ml-1">*</span>}
+ </Label>
+ <Input
+ id="startDate"
+ type="date"
+ value={form.startDate}
+ onChange={(e) => setForm(prev => ({ ...prev, startDate: e.target.value }))}
+ min="1900-01-01"
+ max="2100-12-31"
+ />
</div>
<div className="grid gap-2">
- <Label>계약종료일 *</Label>
- <Popover>
- <PopoverTrigger asChild>
- <Button
- variant="outline"
- className={cn(
- "justify-start text-left font-normal",
- !form.endDate && "text-muted-foreground"
- )}
- >
- <CalendarIcon className="mr-2 h-4 w-4" />
- {form.endDate ? format(form.endDate, "yyyy-MM-dd", { locale: ko }) : "날짜 선택"}
- </Button>
- </PopoverTrigger>
- <PopoverContent className="w-auto p-0">
- <Calendar
- mode="single"
- selected={form.endDate}
- onSelect={(date) => setForm(prev => ({ ...prev, endDate: date }))}
- initialFocus
- />
- </PopoverContent>
- </Popover>
+ <Label htmlFor="endDate">
+ 계약종료일
+ {!['AD', 'LO', 'OF'].includes(form.type) && <span className="text-red-600 ml-1">*</span>}
+ </Label>
+ <Input
+ id="endDate"
+ type="date"
+ value={form.endDate}
+ onChange={(e) => setForm(prev => ({ ...prev, endDate: e.target.value }))}
+ min="1900-01-01"
+ max="2100-12-31"
+ />
</div>
<div className="grid gap-2">
- <Label>유효기간종료일</Label>
- <Popover>
- <PopoverTrigger asChild>
- <Button
- variant="outline"
- className={cn(
- "justify-start text-left font-normal",
- !form.validityEndDate && "text-muted-foreground"
- )}
- >
- <CalendarIcon className="mr-2 h-4 w-4" />
- {form.validityEndDate ? format(form.validityEndDate, "yyyy-MM-dd", { locale: ko }) : "날짜 선택"}
- </Button>
- </PopoverTrigger>
- <PopoverContent className="w-auto p-0">
- <Calendar
- mode="single"
- selected={form.validityEndDate}
- onSelect={(date) => setForm(prev => ({ ...prev, validityEndDate: date }))}
- initialFocus
- />
- </PopoverContent>
- </Popover>
+ <Label htmlFor="validityEndDate">유효기간종료일</Label>
+ <Input
+ id="validityEndDate"
+ type="date"
+ value={form.validityEndDate}
+ onChange={(e) => setForm(prev => ({ ...prev, validityEndDate: e.target.value }))}
+ min="1900-01-01"
+ max="2100-12-31"
+ />
</div>
</div>
<div className="grid gap-2">
diff --git a/lib/general-contracts/main/general-contract-update-sheet.tsx b/lib/general-contracts/main/general-contract-update-sheet.tsx index 8df74beb..02bde6c9 100644 --- a/lib/general-contracts/main/general-contract-update-sheet.tsx +++ b/lib/general-contracts/main/general-contract-update-sheet.tsx @@ -116,14 +116,23 @@ export function GeneralContractUpdateSheet({ React.useEffect(() => { if (contract) { console.log("Loading contract data:", contract) + + // 날짜 포맷팅 헬퍼 (YYYY-MM-DD) + const formatDateValue = (dateStr: string | null | undefined) => { + if (!dateStr) return "" + // KST 기준 날짜 변환 (입찰 로직과 동일) + const date = new Date(dateStr) + return new Date(date.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 10) + } + const formData = { category: contract.category || "", type: contract.type || "", executionMethod: contract.executionMethod || "", name: contract.name || "", - startDate: contract.startDate || "", - endDate: contract.endDate || "", - validityEndDate: contract.validityEndDate || "", + startDate: formatDateValue(contract.startDate), + endDate: formatDateValue(contract.endDate), + validityEndDate: formatDateValue(contract.validityEndDate), contractScope: contract.contractScope || "", notes: contract.notes || "", linkedRfqOrItb: contract.linkedRfqOrItb || "", diff --git a/lib/general-contracts/main/general-contracts-table-columns.tsx b/lib/general-contracts/main/general-contracts-table-columns.tsx index 0b3143fe..c43bb383 100644 --- a/lib/general-contracts/main/general-contracts-table-columns.tsx +++ b/lib/general-contracts/main/general-contracts-table-columns.tsx @@ -368,14 +368,27 @@ export function getGeneralContractsColumns({ setRowAction }: GetColumnsProps): C if (!startDate || !endDate) return <span className="text-muted-foreground">-</span>
+ // UI 표시용 KST 변환 (YYYY-MM-DD)
+ const formatKstDate = (d: string | Date) => {
+ const date = new Date(d)
+ return new Date(date.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 10)
+ }
+
+ const formattedStart = formatKstDate(startDate)
+ const formattedEnd = formatKstDate(endDate)
+
const now = new Date()
- const isActive = now >= new Date(startDate) && now <= new Date(endDate)
- const isExpired = now > new Date(endDate)
+ const startObj = new Date(startDate)
+ const endObj = new Date(endDate)
+
+ // 종료일의 경우 23:59:59까지 유효하다고 가정하거나, 단순히 날짜 비교
+ const isActive = now >= startObj && now <= new Date(endObj.getTime() + 24 * 60 * 60 * 1000 - 1)
+ const isExpired = now > new Date(endObj.getTime() + 24 * 60 * 60 * 1000 - 1)
return (
<div className="text-xs">
<div className={`${isActive ? 'text-green-600 font-medium' : isExpired ? 'text-red-600' : 'text-gray-600'}`}>
- {formatDate(startDate, "KR")} ~ {formatDate(endDate, "KR")}
+ {formattedStart} ~ {formattedEnd}
</div>
{isActive && (
<Badge variant="default" className="text-xs mt-1">진행중</Badge>
diff --git a/lib/general-contracts/service.ts b/lib/general-contracts/service.ts index 991616d9..3f3dc8de 100644 --- a/lib/general-contracts/service.ts +++ b/lib/general-contracts/service.ts @@ -8,6 +8,7 @@ import { promises as fs } from 'fs' import { generalContracts, generalContractItems, generalContractAttachments } from '@/db/schema/generalContract'
import { contracts, contractItems, contractEnvelopes, contractSigners } from '@/db/schema/contract'
import { basicContract, basicContractTemplates } from '@/db/schema/basicContractDocumnet'
+import { generalContractTemplates } from '@/db/schema'
import { vendors } from '@/db/schema/vendors'
import { users, roles, userRoles } from '@/db/schema/users'
import { projects } from '@/db/schema/projects'
@@ -2728,4 +2729,32 @@ export async function confirmContractReview( console.error('당사 검토 확정 오류:', error)
throw error
}
+}
+
+// 계약 유형에 맞는 최신 템플릿 조회
+export async function getContractTemplateByContractType(contractType: string) {
+ try {
+ // 1. 정확한 타입 매칭 시도
+ const templates = await db
+ .select()
+ .from(generalContractTemplates)
+ .where(
+ and(
+ eq(generalContractTemplates.contractTemplateType, contractType),
+ eq(generalContractTemplates.status, 'ACTIVE')
+ )
+ )
+ .orderBy(desc(generalContractTemplates.revision)) // 최신 리비전 우선
+ .limit(1)
+
+ if (templates.length > 0) {
+ return { success: true, template: templates[0] }
+ }
+
+ // 2. 매칭되는 템플릿이 없을 경우 (필요 시 로직 추가)
+ return { success: false, error: '해당 계약 유형에 맞는 템플릿을 찾을 수 없습니다.' }
+ } catch (error) {
+ console.error('템플릿 조회 오류:', error)
+ return { success: false, error: '템플릿 조회 중 오류가 발생했습니다.' }
+ }
}
\ No newline at end of file diff --git a/lib/general-contracts/utils.ts b/lib/general-contracts/utils.ts new file mode 100644 index 00000000..ec15a3a1 --- /dev/null +++ b/lib/general-contracts/utils.ts @@ -0,0 +1,304 @@ +import { format } from "date-fns" + +/** + * ContractSummary 인터페이스 (UI 컴포넌트와 맞춤) + */ +interface ContractSummary { + basicInfo: Record<string, any> + items: Record<string, any>[] + subcontractChecklist: Record<string, any> | null + storageInfo?: Record<string, any>[] // 임치(물품보관) 계약 정보 +} + +/** + * 계약 데이터를 템플릿 변수로 매핑하는 함수 + * + * @param contractSummary 계약 요약 정보 + * @returns PDFTron 템플릿에 적용할 변수 맵 (Key-Value) + */ +export function mapContractDataToTemplateVariables(contractSummary: ContractSummary) { + const { basicInfo, items, storageInfo } = contractSummary + const firstItem = items && items.length > 0 ? items[0] : {} + + // 날짜 포맷팅 헬퍼 (YYYY-MM-DD) + const formatDate = (date: any) => { + if (!date) return '' + try { + const d = new Date(date) + if (isNaN(d.getTime())) return String(date) + const year = d.getFullYear() + const month = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` + } catch { + return String(date) + } + } + + // 금액 포맷팅 헬퍼 (천단위 콤마) + const formatCurrency = (amount: any) => { + if (amount === undefined || amount === null || amount === '') return '' + const num = Number(amount) + if (isNaN(num)) return String(amount) + return num.toLocaleString('ko-KR') + } + + // 비율 포맷팅 (소수점 제거 등 필요 시) + const formatRate = (rate: any) => { + if (!rate) return '' + return String(rate) + } + + // 1. 프로젝트 정보 (Items에서 프로젝트명 추출 시도) + const projectName = basicInfo.projectName || (items.length > 0 ? items[0].projectName : '') || "Following potential projects" + const projectCode = basicInfo.projectCode || (items.length > 0 ? items[0].projectCode : '') || '' + + // 2. 계약금액 표시 로직 (단가/물량 계약은 '별첨 참조') + const contractScope = basicInfo.contractScope || '' + let displayContractAmount = formatCurrency(basicInfo.contractAmount) + let displayContractAmountText = '' + + if (contractScope === '단가' || contractScope === '물량(실적)') { + displayContractAmount = '별첨 참조' + displayContractAmountText = '별첨 참조' + } else { + displayContractAmountText = displayContractAmount + } + + // 공급가액 & 부가세 (임시 계산 로직 제거) + // 실제로는 taxType에 따라 다를 수 있음 (영세율 등) - 데이터가 있으면 매핑 + const supplyPrice = basicInfo.supplyPrice ? formatCurrency(basicInfo.supplyPrice) : '' + const vat = basicInfo.vat ? formatCurrency(basicInfo.vat) : '' + + // 3. 지급조건 상세 텍스트 생성 + // 납품 전 + const prePaymentData = basicInfo.paymentBeforeDelivery || {} + let prePaymentText = '' + const prePaymentParts: string[] = [] + if (prePaymentData.apBond) prePaymentParts.push(`AP Bond(${prePaymentData.apBondPercent}%)`) + if (prePaymentData.drawingSubmission) prePaymentParts.push(`도면제출(${prePaymentData.drawingSubmissionPercent}%)`) + if (prePaymentData.materialPurchase) prePaymentParts.push(`소재구매(${prePaymentData.materialPurchasePercent}%)`) + if (prePaymentData.additionalCondition) prePaymentParts.push(`추가조건(${prePaymentData.additionalConditionPercent}%)`) + if (prePaymentParts.length > 0) { + prePaymentText = `(선급금) ${prePaymentParts.join(', ')}` + } + + // 납품 + const deliveryPaymentText = basicInfo.paymentDelivery ? `(본품 납품) ${basicInfo.paymentDelivery}` : '' + + // 납품 후 + const postPaymentData = basicInfo.paymentAfterDelivery || {} + let postPaymentText = '' + const postPaymentParts: string[] = [] + if (postPaymentData.commissioning) postPaymentParts.push(`Commissioning(${postPaymentData.commissioningPercent}%)`) + if (postPaymentData.finalDocument) postPaymentParts.push(`최종문서(${postPaymentData.finalDocumentPercent}%)`) + if (postPaymentData.other) postPaymentParts.push(`기타(${postPaymentData.otherText})`) + if (postPaymentParts.length > 0) { + postPaymentText = `(납품 외) ${postPaymentParts.join(', ')}` + } + + // 4. 보증금 및 위약금 (DB 필드값 사용, 임시 계산 제거) + // DB에 해당 필드가 없으면 빈 값으로 매핑됨. + const contractDepositAmount = basicInfo.contractDepositAmount || '' + const defectDepositAmount = basicInfo.defectDepositAmount || '' + const paymentDepositAmount = basicInfo.paymentDepositAmount || '' + const unfairJointActPenaltyAmount = basicInfo.unfairJointActPenaltyAmount || '' + + // 지체상금 + const liquidatedDamagesRate = basicInfo.liquidatedDamagesPercent || '0' + + // 5. 조건 텍스트 변환 (JSON -> String) + // 계약해지조건 + let terminationConditionsText = '' + if (basicInfo.contractTerminationConditions) { + try { + const cond = typeof basicInfo.contractTerminationConditions === 'string' + ? JSON.parse(basicInfo.contractTerminationConditions) + : basicInfo.contractTerminationConditions + + const active: string[] = [] + if (cond.standardTermination) active.push('표준 계약해지조건') + if (cond.projectNotAwarded) active.push('프로젝트 미수주 시') + if (cond.other) active.push('기타') + terminationConditionsText = active.join(', ') + } catch (e) {} + } + + // 계약성립조건 + let establishmentConditionsText = '' + if (basicInfo.contractEstablishmentConditions) { + try { + const cond = typeof basicInfo.contractEstablishmentConditions === 'string' + ? JSON.parse(basicInfo.contractEstablishmentConditions) + : basicInfo.contractEstablishmentConditions + + const active: string[] = [] + if (cond.regularVendorRegistration) active.push('정규업체 등록(실사 포함) 시') + if (cond.projectAward) active.push('프로젝트 수주 시') + if (cond.ownerApproval) active.push('선주 승인 시') + if (cond.other) active.push('기타') + establishmentConditionsText = active.join(', ') + } catch (e) {} + } + + // 품질/하자보증기간 텍스트 + let warrantyPeriodText = '' + if (basicInfo.warrantyPeriod) { + try { + const wp = typeof basicInfo.warrantyPeriod === 'string' ? JSON.parse(basicInfo.warrantyPeriod) : basicInfo.warrantyPeriod + const parts: string[] = [] + if (wp.납품후?.enabled) parts.push(`납품 후 ${wp.납품후.period}개월`) + if (wp.인도후?.enabled) parts.push(`인도 후 ${wp.인도후.period}개월`) + if (wp.작업후?.enabled) parts.push(`작업 후 ${wp.작업후.period}개월`) + if (wp.기타?.enabled) parts.push(`기타`) + warrantyPeriodText = parts.join(', ') + } catch(e) {} + } + + // 6. 임치(물품보관) 계약 관련 (SG) + const storageItems = storageInfo || [] + // 템플릿에서 루프를 지원하지 않을 경우를 대비한 텍스트 포맷 (Fallback) + const storageTableText = storageItems.length > 0 + ? storageItems.map((item, idx) => + `${idx + 1}. PO No.: ${item.poNumber || '-'}, 호선: ${item.hullNumber || '-'}, 미입고 잔여금액: ${formatCurrency(item.remainingAmount)}` + ).join('\n') + : '' + + + // ═══════════════════════════════════════════════════════════════ + // 변수 매핑 시작 + // ═══════════════════════════════════════════════════════════════ + const variables: Record<string, any> = { + // ---------------------------------- + // 시스템/공통 + // ---------------------------------- + todayDate: formatDate(new Date()), // {{Today}} : 현재 날짜 + + // ---------------------------------- + // 계약 기본 정보 + // ---------------------------------- + contractName: basicInfo.contractName || basicInfo.name || '', // {{계약명}} + contractNumber: basicInfo.contractNumber || '', // {{계약번호}} + contractDate: formatDate(basicInfo.registeredAt || basicInfo.createdAt), // {{계약일자}} + + // ---------------------------------- + // 프로젝트 정보 + // ---------------------------------- + projectName: projectName, // {{프로젝트}}, {{대상호선}} : 없으면 'Following potential projects' + projectCode: projectCode, // {{프로젝트코드}} + + // ---------------------------------- + // 금액 정보 + // ---------------------------------- + contractAmount: displayContractAmount, // {{계약금액}} : '별첨 참조' 또는 금액 + supplyPrice: supplyPrice, // (공급가액) + vat: vat, // (부가가치세) + contractCurrency: basicInfo.currency || 'KRW', // 통화 + + // ---------------------------------- + // 협력업체(Vendor) 정보 + // ---------------------------------- + vendorName: basicInfo.vendorName || '', // {{VendorName}}, {{계약업체}}, {{수탁자}} + vendorAddress: basicInfo.vendorAddress || basicInfo.address || '', // {{VendorAddress}}, {{수탁자 주소}}, {{보관장소}} + vendorCeoName: basicInfo.vendorCeoName || basicInfo.representativeName || '', // {{Vendor_CEO_Name}}, {{대표이사}} + // vendorPhone, vendorEmail 등 필요시 추가 + + // ---------------------------------- + // 당사(SHI) 정보 (고정값/설정값) + // ---------------------------------- + shiAddress: "경기도 성남시 분당구 판교로 227번길 23", // {{SHI_Address}}, {{위탁자 주소}} + shiCeoName: "최성안", // {{SHI_CEO_Name}}, {{대표이사}} + + // ---------------------------------- + // 품목 정보 + // ---------------------------------- + // Frame Agreement 등의 {{자재그룹}}, {{자재그룹명}} + itemGroup: firstItem.itemCode || '', // {{자재그룹}} : 일단 ItemCode 매핑 (자재그룹 코드가 별도로 있다면 수정 필요) + itemGroupName: firstItem.itemInfo || '', // {{자재그룹명}} : ItemInfo 매핑 + pkgNo: firstItem.itemCode || '', // {{PKG No.}} + pkgName: firstItem.itemInfo || '', // {{PKG명}} + + // 일반 계약품목 / 임치 대상품목 + itemDescription: firstItem.itemInfo || firstItem.description || basicInfo.contractName || '', // {{계약품목}}, {{계약내용}} + itemInfo: firstItem.itemInfo || '', // {{Item 정보}} + itemName: firstItem.itemInfo || '', // {{ItemName}} + + // OF 배상품목 + reimbursementItem: firstItem.itemInfo || '', // {{배상품목}} + + // ---------------------------------- + // 사양 및 공급범위 + // ---------------------------------- + // {{사양 및 공급범위}} : 사양서 파일 유무에 따라 텍스트 변경 + // 실제 파일 존재 여부를 여기서 알기 어려우므로 specificationType으로 판단 + scopeOfSupply: basicInfo.specificationType === '첨부서류 참조' + ? '사양서 파일 참조(As per Technical agreement)' + : (basicInfo.specificationManualText || basicInfo.contractName || ''), + + // ---------------------------------- + // 계약 기간 및 유효기간 + // ---------------------------------- + contractPeriod: `${formatDate(basicInfo.startDate)} ~ ${formatDate(basicInfo.endDate)}`, // {{계약기간}}, {{FA 유효기간}}, {{보관날짜}} + contractStartDate: formatDate(basicInfo.startDate), + contractEndDate: formatDate(basicInfo.endDate), + validityEndDate: formatDate(basicInfo.validityEndDate || basicInfo.endDate), // {{LOI 유효기간}}, {{계약체결유효기간}} + + // ---------------------------------- + // 인도/지급 조건 + // ---------------------------------- + incoterms: basicInfo.deliveryTerm || '', // {{Incoterms}}, {{물품인도조건}} + paymentTerms: basicInfo.paymentTerm || '', // {{지급조건}}, {{대금지불조건}} - 코드값(L003 등)일 수 있음 + + // 상세 지급조건 (선급금, 납품, 납품 외) + // 템플릿에 (선급금) ... (본품 납품) ... 항목이 미리 적혀있는지, 변수로 넣어야 하는지에 따라 다름 + // 예시에서는 줄글로 보임. 각각 매핑. + prePaymentCondition: prePaymentText, // (선급금) 조건 텍스트 + deliveryPaymentCondition: deliveryPaymentText, // (본품 납품) 조건 텍스트 + postPaymentCondition: postPaymentText, // (납품 외) 조건 텍스트 + + // ---------------------------------- + // 보증기간 및 보증금 + // ---------------------------------- + warrantyPeriod: warrantyPeriodText, // {{품질/하자보증기간}} + + // 금액 계산 필드들 (DB 필드값이 없으면 빈 값) + contractDeposit: formatCurrency(contractDepositAmount), // {{계약보증금}} + defectDeposit: formatCurrency(defectDepositAmount), // {{하자보증금}} + paymentDeposit: formatCurrency(paymentDepositAmount), // {{지급보증금}} + + unfairJointActPenalty: formatCurrency(unfairJointActPenaltyAmount), // {{부정담합위약금}}, {{부당한공동행위}} + + // 지체상금 + liquidatedDamagesRate: formatRate(liquidatedDamagesRate), // {{지체상금비율}} + // liquidatedDamages: formatCurrency(liquidatedDamagesAmount), // 금액이 필요한 경우 사용 + + // ---------------------------------- + // 기타 조건 + // ---------------------------------- + terminationConditions: terminationConditionsText, // {{계약해지조건}} + establishmentConditions: establishmentConditionsText, // {{계약성립조건}} + subcontractInterlocking: basicInfo.interlockingSystem || 'N', // {{하도급연동}} + + // ---------------------------------- + // 참조/연결 정보 + // ---------------------------------- + // OF의 {{관련계약번호}} + linkedContractNumber: basicInfo.linkedPoNumber || basicInfo.linkedBidNumber || basicInfo.linkedRfqOrItb || '', + + // ---------------------------------- + // 임치(물품보관) 계약 (SG) + // ---------------------------------- + storageTableText: storageTableText, // {{storageTableText}} (fallback) + // PDFTron에서 배열을 받아 테이블 루프를 돌릴 수 있다면 아래 키를 사용 + storageList: storageItems, + } + + // 3. 모든 키를 순회하며 undefined나 null을 빈 문자열로 변환 (안전장치) + Object.keys(variables).forEach(key => { + if (variables[key] === undefined || variables[key] === null) { + variables[key] = '' + } + }) + + return variables +} diff --git a/lib/nonsap/auth-service.ts b/lib/nonsap/auth-service.ts new file mode 100644 index 00000000..5a338eea --- /dev/null +++ b/lib/nonsap/auth-service.ts @@ -0,0 +1,175 @@ +import { headers } from 'next/headers'; +import db from '@/db/db'; +import { users } from '@/db/schema/users'; +import { organization } from '@/db/schema/knox/organization'; +import { eq } from 'drizzle-orm'; +import { + getAllScreens, + getAuthsByScreenId, + getUserRoles, +} from './db'; + +export type AuthAction = 'SEARCH' | 'ADD' | 'DEL' | 'SAVE' | 'PRINT' | 'DOWN' | 'UP' | 'APPROVAL' | 'PREV' | 'NEXT'; + +interface AuthCheckResult { + authorized: boolean; + message?: string; +} + +async function getCurrentUrlFromHeaders(): Promise<string | null> { + const headerList = headers(); + return headerList.get('x-pathname') || null; +} + +/** + * NONSAP 권한 검증 함수 + * @param userId 사용자 ID (Postgres users.id) + * @param requiredActions 필요한 권한 목록 (예: ['SEARCH']) + * @param url (Optional) 검증할 URL. 생략 시 현재 요청의 URL을 자동으로 감지 시도. + */ +export async function verifyNonsapPermission( + userId: number, + requiredActions: AuthAction[], + url?: string, + options: { logic?: 'AND' | 'OR' } = { logic: 'AND' } +): Promise<AuthCheckResult> { + // 1. URL 결정 + const targetUrl = url || await getCurrentUrlFromHeaders(); + if (!targetUrl) { + return { authorized: false, message: "URL을 확인할 수 없습니다. URL 인자를 명시해주세요." }; + } + + // URL 정규화 (언어 코드 제거) + // 예: /en/partners/dashboard -> /partners/dashboard + const normalizedUrl = targetUrl.replace(/^\/[a-z]{2}(\/|$)/, '/'); + + // 2. 화면 식별 + const allScreens = await getAllScreens(); + + // DB URL과 현재 URL 매칭 (Longest Prefix Match) + const screen = allScreens + .filter(s => { + const dbUrl = s.SCR_URL.startsWith('/') ? s.SCR_URL : `/${s.SCR_URL}`; + // 정확히 일치하거나 하위 경로인 경우 + return normalizedUrl === dbUrl || normalizedUrl.startsWith(`${dbUrl}/`); + }) + .sort((a, b) => b.SCR_URL.length - a.SCR_URL.length)[0]; + + if (!screen) { + // 관리되지 않는 페이지 -> Pass + return { authorized: true, message: "관리되지 않는 페이지입니다." }; + } + + if (screen.DEL_YN === 'Y') { + return { authorized: false, message: "삭제된 화면입니다." }; + } + + if (screen.SCRT_CHK_YN === 'N') { + return { authorized: true, message: "권한 체크가 필요 없는 화면입니다." }; + } + + // 3. 사용자 정보 조회 (Postgres) + const user = await db.query.users.findFirst({ + where: eq(users.id, userId), + columns: { + nonsapUserId: true, + deptCode: true, + employeeNumber: true, + } + }); + + if (!user) { + return { authorized: false, message: "사용자 정보를 찾을 수 없습니다." }; + } + + // 4. 권한 정보 조회 + const auths = await getAuthsByScreenId(screen.SCR_ID); + + // 5. 권한 검증 (Merging) + const finalAuth = { + SEARCH: 'N', ADD: 'N', DEL: 'N', SAVE: 'N', PRINT: 'N', DOWN: 'N', UP: 'N', APPROVAL: 'N', PREV: 'N', NEXT: 'N' + }; + + // Role 정보 조회 + const myRoles = await getUserRoles(user.employeeNumber || user.nonsapUserId || ''); + + for (const auth of auths) { + let isMatch = false; + + if (auth.ACSR_GB_CD === 'U') { + // 사용자 직접 매핑 + if (auth.ACSR_ID === user.nonsapUserId) isMatch = true; + } else if (auth.ACSR_GB_CD === 'R') { + // 역할 매핑 + if (myRoles.some(r => r.ROLE_ID === auth.ACSR_ID)) isMatch = true; + } else if (auth.ACSR_GB_CD === 'D') { + // 부서 매핑 (정확히 일치) + if (auth.ACSR_ID === user.deptCode) isMatch = true; + } else if (auth.ACSR_GB_CD === 'E') { + // 부서 매핑 (하위 포함 - 상위 부서로 거슬러 올라가며 확인) + if (user.deptCode) { + let currentDept = user.deptCode; + let depth = 0; + // 최대 20단계까지만 확인 (무한루프 방지) + while (currentDept && depth < 20) { + if (currentDept === auth.ACSR_ID) { + isMatch = true; + break; + } + + // 상위 부서 조회 + // 성능 최적화를 위해 캐싱 고려 가능하나, 일단 직접 조회 + const org = await db.query.organization.findFirst({ + where: eq(organization.departmentCode, currentDept), + columns: { uprDepartmentCode: true } + }); + + if (!org || !org.uprDepartmentCode || org.uprDepartmentCode === 'TOP' || org.uprDepartmentCode === currentDept) { + break; + } + currentDept = org.uprDepartmentCode; + depth++; + } + } + } + + if (isMatch) { + // 권한 합치기 (OR) + if (auth.AUTH_CD_SEARCH === 'Y') finalAuth.SEARCH = 'Y'; + if (auth.AUTH_CD_ADD === 'Y') finalAuth.ADD = 'Y'; + if (auth.AUTH_CD_DEL === 'Y') finalAuth.DEL = 'Y'; + if (auth.AUTH_CD_SAVE === 'Y') finalAuth.SAVE = 'Y'; + if (auth.AUTH_CD_PRINT === 'Y') finalAuth.PRINT = 'Y'; + if (auth.AUTH_CD_DOWN === 'Y') finalAuth.DOWN = 'Y'; + if (auth.AUTH_CD_UP === 'Y') finalAuth.UP = 'Y'; + if (auth.AUTH_CD_APPROVAL === 'Y') finalAuth.APPROVAL = 'Y'; + if (auth.AUTH_CD_PREV === 'Y') finalAuth.PREV = 'Y'; + if (auth.AUTH_CD_NEXT === 'Y') finalAuth.NEXT = 'Y'; + } + } + + // 6. 요구사항 검증 + const check = (action: AuthAction) => { + switch (action) { + case 'SEARCH': return finalAuth.SEARCH === 'Y'; + case 'ADD': return finalAuth.ADD === 'Y'; + case 'DEL': return finalAuth.DEL === 'Y'; + case 'SAVE': return finalAuth.SAVE === 'Y'; + case 'PRINT': return finalAuth.PRINT === 'Y'; + case 'DOWN': return finalAuth.DOWN === 'Y'; + case 'UP': return finalAuth.UP === 'Y'; + case 'APPROVAL': return finalAuth.APPROVAL === 'Y'; + case 'PREV': return finalAuth.PREV === 'Y'; + case 'NEXT': return finalAuth.NEXT === 'Y'; + default: return false; + } + }; + + if (options.logic === 'OR') { + const authorized = requiredActions.some(action => check(action)); + return { authorized }; + } else { + const authorized = requiredActions.every(action => check(action)); + return { authorized }; + } +} diff --git a/lib/nonsap/db.ts b/lib/nonsap/db.ts new file mode 100644 index 00000000..2b3cbda3 --- /dev/null +++ b/lib/nonsap/db.ts @@ -0,0 +1,68 @@ +import { oracleKnex } from '@/lib/oracle-db/db'; +import { unstable_cache } from 'next/cache'; + +// Types +export interface ScreenEvcp { + SCR_ID: string; + SCR_URL: string; + SCRT_CHK_YN: string; // 'Y' | 'N' + DEL_YN: string; // 'Y' | 'N' +} + +export interface ScreenAuthEvcp { + SCR_ID: string; + ACSR_GB_CD: string; // 'U' | 'R' | 'D' | 'E' + ACSR_ID: string; + AUTH_CD_SEARCH: string; // 'Y' | 'N' + AUTH_CD_ADD: string; + AUTH_CD_DEL: string; + AUTH_CD_SAVE: string; + AUTH_CD_PRINT: string; + AUTH_CD_DOWN: string; + AUTH_CD_UP: string; + AUTH_CD_APPROVAL: string; + AUTH_CD_PREV: string; + AUTH_CD_NEXT: string; +} + +export interface RoleEvcp { + ROLE_ID: string; + ROLE_NM: string; +} + +export interface RoleRelEvcp { + ROLE_ID: string; + EMPNO: string; +} + +// Fetch functions with cache + +export const getAllScreens = unstable_cache( + async () => { + const result = await oracleKnex('CMCVW_SCR_EVCP') + .select('*'); + return result as ScreenEvcp[]; + }, + ['nonsap-all-screens'], + { revalidate: 60 } +); + +export const getAuthsByScreenId = unstable_cache( + async (scrId: string) => { + const result = await oracleKnex('CMCVW_SCR_AUTH_EVCP') + .where('SCR_ID', scrId); + return result as ScreenAuthEvcp[]; + }, + ['nonsap-auths-by-screen-id'], + { revalidate: 60 } +); + +export const getUserRoles = unstable_cache( + async (empNo: string) => { + const result = await oracleKnex('CMCVW_ROLE_REL_EVCP') + .where('EMPNO', empNo); + return result as RoleRelEvcp[]; + }, + ['nonsap-user-roles'], + { revalidate: 60 } +); diff --git a/lib/nonsap/nonsap-auth-plan.md b/lib/nonsap/nonsap-auth-plan.md new file mode 100644 index 00000000..fab5e7da --- /dev/null +++ b/lib/nonsap/nonsap-auth-plan.md @@ -0,0 +1,181 @@ +# 외부 시스템(NONSAP) 연동 권한 관리 구현 계획 (v2) + +본 문서는 외부 오라클 시스템(NONSAP)의 뷰 테이블을 연동하여 프로젝트의 권한 관리를 처리하기 위한 구현 계획입니다. + +## 1. 개요 + +- **목표**: 외부 시스템에서 관리되는 화면 및 권한 정보를 조회하여, 웹 애플리케이션의 페이지 접근 제어 및 기능별 권한 제어를 수행합니다. +- **환경**: Node.js Runtime (Not Edge/FaaS). +- **DB 연결**: `lib/oracle-db/db.ts`의 `oracleKnex`를 사용하여 오라클 DB에 접속합니다. + +## 2. 데이터 구조 및 관계 + +제공된 4개의 뷰 테이블을 사용하여 권한을 판단합니다. + +### 2.1 테이블 정의 + +1. **`CMCVW_SCR_EVCP` (화면 목록)** + * **역할**: 시스템의 모든 화면과 해당 URL을 정의합니다. + * **주요 컬럼**: `SCR_ID` (PK), `SCR_URL`, `SCRT_CHK_YN`, `DEL_YN` + +2. **`CMCVW_SCR_AUTH_EVCP` (화면 권한)** + * **역할**: 각 화면(`SCR_ID`)에 대해 접근 가능한 대상(`ACSR_ID`)과 권한 상세(`AUTH_CD`)를 정의합니다. + * **주요 컬럼**: `SCR_ID` (FK), `ACSR_GB_CD` (접근자 구분), `ACSR_ID`, `AUTH_CD_*` (권한 플래그) + * **ACSR_GB_CD 유형**: + * `'U'`: 사용자 (User) + * `'R'`: 역할 (Role) + * `'D'`: 부서 (Department - 하위 미포함) + * `'E'`: 부서 (Department - 하위 포함) + +3. **`CMCVW_ROLE_EVCP` (역할 목록)** + * **역할**: 시스템에 존재하는 역할(Role)을 정의합니다. + * **주요 컬럼**: `ROLE_ID` (PK), `ROLE_NM` + +4. **`CMCVW_ROLE_REL_EVCP` (역할-사용자 매핑)** + * **역할**: 각 역할(`ROLE_ID`)에 소속된 사용자(`EMPNO`)를 정의합니다. + * **주요 컬럼**: `ROLE_ID` (FK), `EMPNO` + +## 3. 구현 상세 + +### 3.1 데이터 페칭 및 캐싱 전략 + +* **DB Client**: `lib/oracle-db/db.ts`의 `oracleKnex` 사용. +* **캐싱**: `unstable_cache` (Next.js)를 사용하여 DB 부하를 줄이고 응답 속도를 확보합니다. (TTL: 60초) + +### 3.2 권한 검증 함수 설계 + +URL 인자를 선택적으로 받을 수 있도록 설계하여, 호출 편의성을 높입니다. + +```typescript +// lib/nonsap/auth-service.ts + +export type AuthAction = 'SEARCH' | 'ADD' | 'DEL' | 'SAVE' | 'PRINT' | 'DOWN' | 'UP' | 'APPROVAL' | 'PREV' | 'NEXT'; + +/** + * 위 Action의 의미는 아래와 같다. + * - 조회 + * - 추가 + * - 삭제 + * - 저장 + * - 출력 + * - 파일받기 + * - 파일올림 + * - 결재상신 + * - PrevPage + * - NextPage + */ + +interface AuthCheckResult { + authorized: boolean; + message?: string; +} + +/** + * NONSAP 권한 검증 함수 + * @param empNo 사용자 사번 + * @param requiredActions 필요한 권한 목록 (예: ['SEARCH']) + * @param url (Optional) 검증할 URL. 생략 시 현재 요청의 URL을 자동으로 감지 시도. + */ +export async function verifyNonsapPermission( + empNo: string, + requiredActions: AuthAction[], + url?: string, + options?: { logic?: 'AND' | 'OR' } // default: 'AND' +): Promise<AuthCheckResult> { + // 1. URL 결정 + const targetUrl = url || await getCurrentUrlFromHeaders(); + if (!targetUrl) { + throw new Error("URL을 확인할 수 없습니다. URL 인자를 명시해주세요."); + } + + // 2. 권한 검증 로직 수행 (oracleKnex 사용) + // ... +} + +// Helper: 헤더에서 URL 추출 (Middleware 사전 작업 필요) +import { headers } from 'next/headers'; +async function getCurrentUrlFromHeaders(): Promise<string | null> { + const headerList = headers(); + // Middleware에서 'x-pathname' 헤더를 심어주어야 함 + return headerList.get('x-pathname') || null; +} +``` + +### 3.3 권한 검증 로직 (Core Logic) + +1. **화면 식별**: + * `CMCVW_SCR_EVCP`에서 `SCR_URL`이 `targetUrl`과 일치하는 행 조회 (`oracleKnex` 사용). + * `DEL_YN` == 'N', `SCRT_CHK_YN` == 'Y' 확인. + * 일치 항목 없으면: 관리되지 않는 페이지로 간주 -> **Pass**. + +2. **권한 확인 (ACSR_GB_CD 유형별 처리)**: + * `CMCVW_SCR_AUTH_EVCP` 조회 (`SCR_ID` 기준). 조회된 각 권한 레코드(`authRecord`)에 대해 다음 로직 수행: + + * **Case U (User)**: `ACSR_GB_CD == 'U'` + * `authRecord.ACSR_ID`가 대상 사용자의 `nonsap_user_id` (Postgres `users` 테이블)와 일치하는지 확인. + + * **Case R (Role)**: `ACSR_GB_CD == 'R'` + * `authRecord.ACSR_ID` (Role ID)가 `CMCVW_ROLE_REL_EVCP` 테이블에서 해당 사용자의 사번(`EMPNO`)과 매핑되어 있는지 확인. + + * **Case D (Department - Exact)**: `ACSR_GB_CD == 'D'` + * `authRecord.ACSR_ID` (Dept Code)가 대상 사용자의 `deptCode` (Postgres `users` 테이블)와 정확히 일치하는지 확인. + + * **Case E (Department - Recursive)**: `ACSR_GB_CD == 'E'` + * 사용자의 부서(`user.deptCode`)부터 시작하여 상위 부서로 거슬러 올라가며 확인. + * **데이터 소스**: `db/schema/knox/organization.ts`의 `organization` 테이블 (Knox 조직도). + * **Logic**: + 1. `currentDeptCode = user.deptCode` + 2. Loop: + * `currentDeptCode == authRecord.ACSR_ID` 이면 **Match (권한 있음)**. + * `organization` 테이블에서 `departmentCode == currentDeptCode`인 행 조회. + * 상위 부서 코드(`uprDepartmentCode`) 확인. + * **종료 조건 (권한 없음)**: + * `uprDepartmentCode` is NULL + * `uprDepartmentCode == currentDeptCode` (자기 참조) + * `uprDepartmentCode == 'TOP'` + * `currentDeptCode = uprDepartmentCode` 로 갱신하고 반복. + +3. **Action 체크**: + * **Step 3-1. 권한 통합 (Merging)**: + * 위 단계에서 **Match**된 모든 권한 레코드들의 `AUTH_CD_*` 컬럼 값을 **OR 연산(Union)**하여 사용자의 '최종 보유 권한'을 산출합니다. + * 예: Role A(Search=Y, Save=N) + Role B(Search=N, Save=Y) => 최종(Search=Y, Save=Y). + * **Step 3-2. 요구사항 검증 (Checking)**: + * `requiredActions` 목록과 '최종 보유 권한'을 비교합니다. + * **AND 모드 (Default)**: `requiredActions`의 **모든** 항목에 대해 권한이 있어야 통과 (`every`). (보안상 안전) + * **OR 모드**: `requiredActions` 중 **하나라도** 권한이 있으면 통과 (`some`). (메뉴 노출 등 UI 제어용) + +## 4. 단계별 실행 계획 + +### Step 1: Middleware 설정 (URL 감지용) +* `middleware.ts`에서 요청 URL의 pathname을 `x-pathname` 헤더에 담아 넘겨주는 로직 추가. +* 이 헤더는 Server Component/Action에서 `headers()`를 통해 접근 가능해짐. + +### Step 2: 데이터 접근 레이어 구현 (`lib/nonsap/db.ts`) +* `oracleKnex`를 사용하여 4개 테이블을 조회하는 함수 구현. +* `unstable_cache` 적용. + +### Step 3: 권한 서비스 구현 (`lib/nonsap/auth-service.ts`) +* `verifyNonsapPermission` 함수 구현. +* URL 자동 감지 로직 포함. +* 부서 계층 구조 조회 로직(Case E) 구현 (Knox Organization 테이블 연동). + +### Step 4: 적용 (Layout/Page/Action) +* **페이지 접근 제어**: `app/[lng]/layout.tsx` 또는 각 Page 컴포넌트 상단에서 `verifyNonsapPermission(..., ['SEARCH'])` 호출. +* **기능 제어**: Server Action 내부에서 `verifyNonsapPermission(..., ['SAVE'])` 등 호출. + +## 5. 이슈 및 고려사항 + +1. **URL 매핑 정규화**: + * DB의 `SCR_URL`은 `/partners/dashboard` 형태이나, 실제 요청은 `/[lng]/partners/dashboard` 일 수 있음. + * Middleware 또는 Helper 함수에서 `lng` 파트(예: `/en`, `/ko`)를 제거하고 매칭하는 로직 필요. + +2. **Oracle DB 의존성**: + * `oracledb` 라이브러리는 Node.js 환경에서만 동작하므로, 권한 체크 로직은 반드시 **Server Component** 또는 **Server Action** 레벨에서 수행되어야 함. (Middleware에서 직접 DB 조회 불가) + +3. **부서 계층 정보**: + * Knox Organization 테이블(`db/schema/knox/organization.ts`)을 사용하여 상위 부서 정보를 조회합니다. + +## 구현 한 부분 + +evcp 경로의 layout.tsx에서 SEARCH 권한 있는지 authority check 함 +없으면 toast로 없다고 경고만 해줌 (nonsap측에 모든 설정 다 한건 아닐 테니)
\ No newline at end of file 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 diff --git a/middleware.ts b/middleware.ts index e74cb653..6a825e6f 100644 --- a/middleware.ts +++ b/middleware.ts @@ -332,7 +332,17 @@ export async function middleware(request: NextRequest) { /** * 10. 위 조건에 걸리지 않았다면 그대로 Next.js로 넘긴다. */ - const response = NextResponse.next(); + /** + * 10. 위 조건에 걸리지 않았다면 그대로 Next.js로 넘긴다. + */ + const requestHeaders = new Headers(request.headers); + requestHeaders.set('x-pathname', pathname); + + const response = NextResponse.next({ + request: { + headers: requestHeaders, + }, + }); // 만료된 세션 쿠키 정리 (공개 경로 포함) if (token) { |
