From cbb4c7fe0b94459162ad5e998bc05cd293e0ff96 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 11 Aug 2025 09:02:00 +0000 Subject: (대표님) 입찰, EDP 변경사항 대응, spreadJS 오류 수정, 벤더실사 수정 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.development | 3 +- .env.production | 3 +- app/[lng]/evcp/(evcp)/bid/page.tsx | 111 + app/[lng]/evcp/(evcp)/bidding-notice/page.tsx | 33 + app/api/auth/signup-with-vendor.tsx | 491 + components/ProjectSelector.tsx | 6 +- components/form-data/add-formTag-dialog.tsx | 58 +- components/form-data/delete-form-data-dialog.tsx | 67 +- .../form-data/form-data-report-batch-dialog.tsx | 57 +- components/form-data/form-data-report-dialog.tsx | 26 +- .../form-data-report-temp-upload-dialog.tsx | 116 +- .../form-data/form-data-report-temp-upload-tab.tsx | 53 +- .../form-data-report-temp-uploaded-list-tab.tsx | 37 +- components/form-data/form-data-table-columns.tsx | 108 +- components/form-data/form-data-table.tsx | 33 +- components/form-data/sedp-compare-dialog.tsx | 233 +- components/form-data/sedp-components.tsx | 50 +- components/form-data/sedp-excel-download.tsx | 43 +- components/form-data/spreadJS-dialog.tsx | 736 +- components/form-data/update-form-sheet.tsx | 55 +- components/form-data/var-list-download-btn.tsx | 28 +- components/signup/join-form.tsx | 614 +- config/menuConfig.ts | 36 +- config/vendorInvestigationsColumnsConfig.ts | 9 - db/migrations/0262_faulty_weapon_omega.sql | 226 + db/migrations/0263_good_banshee.sql | 23 + db/migrations/0264_careless_namora.sql | 10 + db/migrations/0265_dear_scarlet_witch.sql | 1 + db/migrations/0266_previous_bucky.sql | 4 + db/migrations/0267_bright_mindworm.sql | 8 + db/migrations/0268_striped_pepper_potts.sql | 196 + db/migrations/0269_remarkable_shard.sql | 219 + db/migrations/0270_safe_triathlon.sql | 2 + db/migrations/0271_awesome_kulan_gath.sql | 2 + db/migrations/0272_low_the_hood.sql | 28 + db/migrations/0273_flawless_justice.sql | 1 + db/migrations/0274_dusty_photon.sql | 12 + db/migrations/meta/0262_snapshot.json | 45104 ++++++++++++++++++ db/migrations/meta/0263_snapshot.json | 45231 ++++++++++++++++++ db/migrations/meta/0264_snapshot.json | 45191 ++++++++++++++++++ db/migrations/meta/0265_snapshot.json | 45169 ++++++++++++++++++ db/migrations/meta/0266_snapshot.json | 45169 ++++++++++++++++++ db/migrations/meta/0267_snapshot.json | 45223 ++++++++++++++++++ db/migrations/meta/0268_snapshot.json | 46552 ++++++++++++++++++ db/migrations/meta/0269_snapshot.json | 46803 ++++++++++++++++++ db/migrations/meta/0270_snapshot.json | 46823 ++++++++++++++++++ db/migrations/meta/0271_snapshot.json | 46842 ++++++++++++++++++ db/migrations/meta/0272_snapshot.json | 46983 ++++++++++++++++++ db/migrations/meta/0273_snapshot.json | 46984 +++++++++++++++++++ db/migrations/meta/0274_snapshot.json | 46972 ++++++++++++++++++ db/migrations/meta/_journal.json | 91 + db/schema/bidding.ts | 909 + db/schema/index.ts | 1 + db/schema/vendorData.ts | 23 +- db/schema/vendorDocu.ts | 8 +- i18n/locales/en/engineering.json | 347 + i18n/locales/ko/engineering.json | 347 + lib/bidding/bidding-notice-editor.tsx | 230 + lib/bidding/list/biddings-page-header.tsx | 41 + lib/bidding/list/biddings-stats-cards.tsx | 122 + lib/bidding/list/biddings-table-columns.tsx | 578 + .../list/biddings-table-toolbar-actions.tsx | 143 + lib/bidding/list/biddings-table.tsx | 135 + lib/bidding/list/create-bidding-dialog.tsx | 1674 + lib/bidding/list/edit-bidding-sheet.tsx | 505 + lib/bidding/service.ts | 815 + lib/bidding/validation.ts | 157 + lib/forms/services.ts | 16 +- lib/pq/helper.ts | 2 - lib/pq/service.ts | 3 - lib/sedp/get-form-tags.ts | 41 +- lib/sedp/get-tags.ts | 55 +- lib/sedp/sync-form.ts | 61 +- lib/tags/service.ts | 66 +- lib/tags/table/add-tag-dialog.tsx | 46 +- .../enhanced-document-service.ts | 11 +- .../plant/document-stage-dialogs.tsx | 892 +- .../plant/document-stages-expanded-content.tsx | 98 +- .../plant/document-stages-service.ts | 314 +- .../plant/document-stages-table.tsx | 10 - .../ship/import-from-dolce-button.tsx | 152 +- .../ship/send-to-shi-button.tsx | 127 +- 82 files changed, 609320 insertions(+), 1484 deletions(-) create mode 100644 app/[lng]/evcp/(evcp)/bid/page.tsx create mode 100644 app/[lng]/evcp/(evcp)/bidding-notice/page.tsx create mode 100644 app/api/auth/signup-with-vendor.tsx create mode 100644 db/migrations/0262_faulty_weapon_omega.sql create mode 100644 db/migrations/0263_good_banshee.sql create mode 100644 db/migrations/0264_careless_namora.sql create mode 100644 db/migrations/0265_dear_scarlet_witch.sql create mode 100644 db/migrations/0266_previous_bucky.sql create mode 100644 db/migrations/0267_bright_mindworm.sql create mode 100644 db/migrations/0268_striped_pepper_potts.sql create mode 100644 db/migrations/0269_remarkable_shard.sql create mode 100644 db/migrations/0270_safe_triathlon.sql create mode 100644 db/migrations/0271_awesome_kulan_gath.sql create mode 100644 db/migrations/0272_low_the_hood.sql create mode 100644 db/migrations/0273_flawless_justice.sql create mode 100644 db/migrations/0274_dusty_photon.sql create mode 100644 db/migrations/meta/0262_snapshot.json create mode 100644 db/migrations/meta/0263_snapshot.json create mode 100644 db/migrations/meta/0264_snapshot.json create mode 100644 db/migrations/meta/0265_snapshot.json create mode 100644 db/migrations/meta/0266_snapshot.json create mode 100644 db/migrations/meta/0267_snapshot.json create mode 100644 db/migrations/meta/0268_snapshot.json create mode 100644 db/migrations/meta/0269_snapshot.json create mode 100644 db/migrations/meta/0270_snapshot.json create mode 100644 db/migrations/meta/0271_snapshot.json create mode 100644 db/migrations/meta/0272_snapshot.json create mode 100644 db/migrations/meta/0273_snapshot.json create mode 100644 db/migrations/meta/0274_snapshot.json create mode 100644 db/schema/bidding.ts create mode 100644 i18n/locales/en/engineering.json create mode 100644 i18n/locales/ko/engineering.json create mode 100644 lib/bidding/bidding-notice-editor.tsx create mode 100644 lib/bidding/list/biddings-page-header.tsx create mode 100644 lib/bidding/list/biddings-stats-cards.tsx create mode 100644 lib/bidding/list/biddings-table-columns.tsx create mode 100644 lib/bidding/list/biddings-table-toolbar-actions.tsx create mode 100644 lib/bidding/list/biddings-table.tsx create mode 100644 lib/bidding/list/create-bidding-dialog.tsx create mode 100644 lib/bidding/list/edit-bidding-sheet.tsx create mode 100644 lib/bidding/service.ts create mode 100644 lib/bidding/validation.ts diff --git a/.env.development b/.env.development index c7bf50b2..3bc2267f 100644 --- a/.env.development +++ b/.env.development @@ -22,7 +22,8 @@ Email_From_Address=dujin.kim@dtsolution.co.kr # MUI NEXT_PUBLIC_MUI_KEY=da30586e1f20b93856a9783012fc9258Tz04ODI0MyxFPTE3NDQ0NTM2NzgwMDAsUz1wcmVtaXVtLExNPXN1YnNjcmlwdGlvbixLVj0y # PDFTRON KEYS -NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY=demo:1739264618684:616161d7030000000091db1c97c6f386d41d3506ab5b507381ef2ee2bd +NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY=demo:1712033365211:7f00e5e80300000000ef1ecbafeaacf47ddd5a5ba2430f4c4fa58b2d09 + NEXT_PUBLIC_PDFTRON_SERVER_KEY=demo:1740034881027:6175a0fc0300000000f155d153480e5ba091f17922a109cbd7cf6e40b3 # 메시어스 SPREAD JS NEXT_PUBLIC_SPREAD_LICENSE="43.203.251.114|60.101.108.100|evcp.dtsolution.io,261619561743613#B1ZaK2ycWtEd7Z4S0FENYlXOQhWRsd7M92GewBlQGV5Qu3WcZdESRJmZup4RwljYzoEettkToRFeZJ5LnBlRhdWSDZHbtdVTQBnZttiWHhWTntScoV6LtF6YrknUa9mVyV6RkljTWtCZ5ETZr24bLpnaXd4cUlXOuhTQvMWV8MWU524K5sWRiZnVzUjTPpESrMzaxJUdMlFVntiVLtkd4hVVax6K8sEMQBFdFRUekB7QwU7LsFWQC3Ed7gEWpd7bRtSOy26cJ56LE96T5REbqJ7bl36dEZXewcUR9wWR8lWZax4RSdUSL5mZ9cmWxFWTlRlTGhjUypGZvI4UplEMJdGSy9UVj54dJREWpl4QvR6bzdFN7sCcMBlZxgTTWt4cJpURyRkI0IyUiwiIBZTMxE4QzQjI0ICSiwyNyATMwgzN9ITM0IicfJye#4Xfd5nIIlkSCJiOiMkIsICOx8idgMlSgQWYlJHcTJiOi8kI1tlOiQmcQJCLiATN8IjMwACNyYDM5IDMyIiOiQncDJCLiQjM7ATNyAjMiojIwhXRiwiIvlmLu3Wa4VHbvNHdk9CcjZXZsADMx8COwEjLxATMuAjNsQTMx8SM5IjLzAjMuMDNiojIz5GRiwiIYWI1oO00UaI1wuY1US90iojIh94QiwiIzEjNzQzNxYTN9EjNxYjMiojIklkIs4XXiQXZlh6U4J7bwVmUiwiI4JXYoNUY4FGRiwiIlxmYhRFdvZXaQJCLiQXZlh6U4RnbhdkIbpjInxmZiwSZzxWYmpjIyNHZisnOiwmbBJye0ICRiwiI34TUYlDTrEGTjlnQtR4L52yK4UjbZNzcDlzYsFWdw96VEhTdVx4RrlGat3SRnRXcjpTNfh" diff --git a/.env.production b/.env.production index 28817a23..b49de50b 100644 --- a/.env.production +++ b/.env.production @@ -22,7 +22,8 @@ Email_From_Address=dujin.kim@dtsolution.co.kr # MUI NEXT_PUBLIC_MUI_KEY=da30586e1f20b93856a9783012fc9258Tz04ODI0MyxFPTE3NDQ0NTM2NzgwMDAsUz1wcmVtaXVtLExNPXN1YnNjcmlwdGlvbixLVj0y # PDFTRON KEYS -NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY=demo:1739264618684:616161d7030000000091db1c97c6f386d41d3506ab5b507381ef2ee2bd +# NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY=demo:1739264618684:616161d7030000000091db1c97c6f386d41d3506ab5b507381ef2ee2bd +NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY=demo:1712033365211:7f00e5e80300000000ef1ecbafeaacf47ddd5a5ba2430f4c4fa58b2d09 NEXT_PUBLIC_PDFTRON_SERVER_KEY=demo:1740034881027:6175a0fc0300000000f155d153480e5ba091f17922a109cbd7cf6e40b3 # 메시어스 SPREAD JS NEXT_PUBLIC_SPREAD_LICENSE="43.203.251.114|60.101.108.100|evcp.dtsolution.io,261619561743613#B1ZaK2ycWtEd7Z4S0FENYlXOQhWRsd7M92GewBlQGV5Qu3WcZdESRJmZup4RwljYzoEettkToRFeZJ5LnBlRhdWSDZHbtdVTQBnZttiWHhWTntScoV6LtF6YrknUa9mVyV6RkljTWtCZ5ETZr24bLpnaXd4cUlXOuhTQvMWV8MWU524K5sWRiZnVzUjTPpESrMzaxJUdMlFVntiVLtkd4hVVax6K8sEMQBFdFRUekB7QwU7LsFWQC3Ed7gEWpd7bRtSOy26cJ56LE96T5REbqJ7bl36dEZXewcUR9wWR8lWZax4RSdUSL5mZ9cmWxFWTlRlTGhjUypGZvI4UplEMJdGSy9UVj54dJREWpl4QvR6bzdFN7sCcMBlZxgTTWt4cJpURyRkI0IyUiwiIBZTMxE4QzQjI0ICSiwyNyATMwgzN9ITM0IicfJye#4Xfd5nIIlkSCJiOiMkIsICOx8idgMlSgQWYlJHcTJiOi8kI1tlOiQmcQJCLiATN8IjMwACNyYDM5IDMyIiOiQncDJCLiQjM7ATNyAjMiojIwhXRiwiIvlmLu3Wa4VHbvNHdk9CcjZXZsADMx8COwEjLxATMuAjNsQTMx8SM5IjLzAjMuMDNiojIz5GRiwiIYWI1oO00UaI1wuY1US90iojIh94QiwiIzEjNzQzNxYTN9EjNxYjMiojIklkIs4XXiQXZlh6U4J7bwVmUiwiI4JXYoNUY4FGRiwiIlxmYhRFdvZXaQJCLiQXZlh6U4RnbhdkIbpjInxmZiwSZzxWYmpjIyNHZisnOiwmbBJye0ICRiwiI34TUYlDTrEGTjlnQtR4L52yK4UjbZNzcDlzYsFWdw96VEhTdVx4RrlGat3SRnRXcjpTNfh" diff --git a/app/[lng]/evcp/(evcp)/bid/page.tsx b/app/[lng]/evcp/(evcp)/bid/page.tsx new file mode 100644 index 00000000..7480ce88 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/bid/page.tsx @@ -0,0 +1,111 @@ +import { Suspense } from "react" +import { Shell } from "@/components/shell" +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" +import { + getBiddings, + getBiddingStatusCounts, + getBiddingTypeCounts, + getBiddingManagerCounts, + getBiddingMonthlyStats +} from "@/lib/bidding/service" +import { searchParamsCache } from "@/lib/bidding/validation" +import { BiddingsPageHeader } from "@/lib/bidding/list/biddings-page-header" +import { BiddingsStatsCards } from "@/lib/bidding/list/biddings-stats-cards" +import { BiddingsTable } from "@/lib/bidding/list/biddings-table" + +export const metadata = { + title: "입찰 목록", + description: "입찰 공고를 생성하고 진행 상황을 관리할 수 있습니다.", +} + +interface BiddingsPageProps { + searchParams: Record +} + +export default async function BiddingsPage({ searchParams }: BiddingsPageProps) { + // ✅ nuqs searchParamsCache로 파싱 (타입 안전성 보장) + const search = searchParamsCache.parse(searchParams) + + // ✅ 모든 데이터를 병렬로 로드 + const promises = Promise.all([ + getBiddings(search), + getBiddingStatusCounts(), + getBiddingTypeCounts(), + getBiddingManagerCounts(), + getBiddingMonthlyStats(), + ]) + + return ( + + {/* ═══════════════════════════════════════════════════════════════ */} + {/* 페이지 헤더 */} + {/* ═══════════════════════════════════════════════════════════════ */} + + + {/* ═══════════════════════════════════════════════════════════════ */} + {/* 통계 카드들 */} + {/* ═══════════════════════════════════════════════════════════════ */} + }> + + + + {/* ═══════════════════════════════════════════════════════════════ */} + {/* 메인 테이블 */} + {/* ═══════════════════════════════════════════════════════════════ */} + + } + > + + + + ) +} + +// ═══════════════════════════════════════════════════════════════ +// 통계 카드 래퍼 컴포넌트 +// ═══════════════════════════════════════════════════════════════ +async function BiddingsStatsCardsWrapper({ + promises +}: { + promises: Promise<[ + Awaited>, + Awaited>, + Awaited>, + Awaited>, + Awaited> + ]> +}) { + const [biddingsResult, statusCounts, typeCounts, managerCounts, monthlyStats] = await promises + + return ( + + ) +} + +// 통계 카드 스켈레톤 +function BiddingsStatsCardsSkeleton() { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+
+
+
+ ))} +
+ ) +} \ No newline at end of file diff --git a/app/[lng]/evcp/(evcp)/bidding-notice/page.tsx b/app/[lng]/evcp/(evcp)/bidding-notice/page.tsx new file mode 100644 index 00000000..86e4bd6c --- /dev/null +++ b/app/[lng]/evcp/(evcp)/bidding-notice/page.tsx @@ -0,0 +1,33 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { BiddingNoticeEditor } from '@/lib/bidding/bidding-notice-editor' +import { getBiddingNoticeTemplate } from '@/lib/bidding/service' + +export default async function BiddingNoticePage() { + const template = await getBiddingNoticeTemplate() + + return ( +
+
+

입찰공고문 관리

+

+ 표준 입찰공고문 템플릿을 작성하고 관리할 수 있습니다. +

+
+ + + + 표준 입찰공고문 템플릿 + + 이 템플릿은 실제 입찰 공고 작성 시 기본 양식으로 사용됩니다. + 필요한 표준 정보와 서식을 미리 작성해두세요. + + + + + + +
+ ) +} \ No newline at end of file diff --git a/app/api/auth/signup-with-vendor.tsx b/app/api/auth/signup-with-vendor.tsx new file mode 100644 index 00000000..1274d59b --- /dev/null +++ b/app/api/auth/signup-with-vendor.tsx @@ -0,0 +1,491 @@ +// app/api/auth/signup-with-vendor/route.ts +import { NextRequest, NextResponse } from 'next/server' +import { unstable_noStore } from 'next/cache' +import { revalidateTag } from 'next/cache' +import { eq, and } from 'drizzle-orm' +import { PgTransaction } from 'drizzle-orm/pg-core' + +import db from '@/db/db' +import { + users, + vendors, + vendorContacts, + vendorAttachments, + userConsents, + consentLogs, + policyVersions +} from '@/db/schema' +import { insertVendor } from '@/lib/vendors/repository' +import { getErrorMessage } from '@/lib/handle-error' +import { saveFile, SaveFileResult } from '@/lib/file-stroage' + +// Types +interface AccountData { + name: string + email: string + phone: string + country: string +} + +interface VendorData { + vendorName: string + vendorCode?: string + address?: string + country?: string + phone?: string + email: string + website?: string + status?: string + taxId: string + vendorTypeId: number + items?: string + representativeName?: string + representativeBirth?: string + representativeEmail?: string + representativePhone?: string + corporateRegistrationNumber?: string + representativeWorkExpirence?: boolean + contacts: ContactData[] +} + +interface ContactData { + contactName: string + contactPosition?: string + contactDepartment?: string + contactTask?: string + contactEmail: string + contactPhone?: string + isPrimary?: boolean +} + +interface ConsentData { + privacy_policy: { + agreed: boolean + version: string + } + terms_of_service: { + agreed: boolean + version: string + } + marketing: { + agreed: boolean + version: string + } +} + +interface CompleteSignupData { + account: AccountData + vendor: VendorData + consents: ConsentData +} + +// File attachment types +const FILE_TYPES = { + BUSINESS_REGISTRATION: 'BUSINESS_REGISTRATION', + ISO_CERTIFICATION: 'ISO_CERTIFICATION', + CREDIT_REPORT: 'CREDIT_REPORT', + BANK_ACCOUNT_COPY: 'BANK_ACCOUNT_COPY' +} as const + +type FileType = typeof FILE_TYPES[keyof typeof FILE_TYPES] + +// 보안 강화된 파일 저장 함수 +async function storeVendorFiles( + tx: PgTransaction, + vendorId: number, + files: File[], + attachmentType: FileType, + userId?: number +) { + const vendorDirectory = `vendors/${vendorId}` + + for (const file of files) { + console.log(`📄 업체 파일 저장 시작: ${file.name} (타입: ${attachmentType})`) + + // 보안 강화된 파일 저장 + const saveResult: SaveFileResult = await saveFile({ + file, + directory: vendorDirectory, + originalName: file.name, + userId: userId?.toString(), + }) + + if (!saveResult.success) { + throw new Error(`파일 저장 실패 (${file.name}): ${saveResult.error}`) + } + + // 파일 정보 DB에 저장 + await tx.insert(vendorAttachments).values({ + vendorId, + fileName: saveResult.originalName || file.name, + filePath: saveResult.publicPath!, + attachmentType, + }) + + console.log(`✅ 업체 파일 저장 완료: ${saveResult.fileName}`) + } +} + +// 사용자 계정 생성 함수 (승인 대기 상태) +async function createUserAccount( + tx: PgTransaction, + accountData: AccountData, + vendorId?: number +) { + console.log(`👤 사용자 계정 생성: ${accountData.email} (승인 대기 상태)`) + + // 국가코드에 따른 언어 설정 + const language = accountData.country === 'KR' ? 'ko' : 'en' + + // 사용자 생성 (승인 대기 상태로) + const [newUser] = await tx.insert(users).values({ + name: accountData.name, + email: accountData.email.toLowerCase(), + phone: accountData.phone, + domain: 'partners', // 파트너 도메인 + companyId: vendorId || null, + language, // 🌍 국가코드 기반 언어 설정 (KR → 'ko', 그외 → 'en') + + // 🔒 승인 대기 상태로 설정 + isActive: false, // 관리자 승인 후 활성화 예정 + + // 보안 관련 초기 설정 + mfaEnabled: false, + isLocked: false, + failedLoginAttempts: 0, + passwordChangeRequired: false, // 패스워드 링크로 설정 예정 + + // 동의 관련 초기 설정 + requiresConsentUpdate: false, + lastConsentUpdate: new Date(), + }).returning() + + console.log(`✅ 사용자 계정 생성 완료: ${newUser.email} (ID: ${newUser.id}, 언어: ${language})`) + return newUser +} + +// 동의 정보 저장 함수 +async function saveUserConsents( + tx: PgTransaction, + userId: number, + consents: ConsentData, + clientIP?: string, + userAgent?: string +) { + const timestamp = new Date() + + // 각 동의 타입에 대해 처리 + const consentTypes = [ + { type: 'privacy_policy' as const, data: consents.privacy_policy }, + { type: 'terms_of_service' as const, data: consents.terms_of_service }, + { type: 'marketing' as const, data: consents.marketing } + ] + + for (const { type, data } of consentTypes) { + // 동의 상태 저장 + await tx.insert(userConsents).values({ + userId, + consentType: type, + consentStatus: data.agreed, + policyVersion: data.version, + consentedAt: timestamp, + ipAddress: clientIP || null, + userAgent: userAgent || null, + }) + + // 동의 로그 저장 + await tx.insert(consentLogs).values({ + userId, + consentType: type, + action: 'consent', + oldStatus: null, // 신규 사용자이므로 이전 상태 없음 + newStatus: data.agreed, + policyVersion: data.version, + ipAddress: clientIP || null, + userAgent: userAgent || null, + actionTimestamp: timestamp, + additionalData: { + source: 'signup', + initialConsent: true + } + }) + } + + // users 테이블의 동의 관련 필드 업데이트 + await tx.update(users) + .set({ + lastConsentUpdate: timestamp, + consentVersion: consents.privacy_policy.version, // 대표 버전으로 privacy policy 사용 + requiresConsentUpdate: false, + updatedAt: timestamp + }) + .where(eq(users.id, userId)) +} + +// 유효성 검사 함수들 +function validateAccountData(account: AccountData): string[] { + const errors: string[] = [] + + if (!account.name?.trim()) errors.push('이름은 필수입니다.') + if (!account.email?.trim()) errors.push('이메일은 필수입니다.') + if (!account.phone?.trim()) errors.push('전화번호는 필수입니다.') + if (!account.country?.trim()) errors.push('국가는 필수입니다.') + + // 이메일 형식 검증 + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + if (account.email && !emailRegex.test(account.email)) { + errors.push('유효한 이메일 형식이 아닙니다.') + } + + return errors +} + +function validateVendorData(vendor: VendorData): string[] { + const errors: string[] = [] + + if (!vendor.vendorName?.trim()) errors.push('업체명은 필수입니다.') + if (!vendor.vendorTypeId) errors.push('업체 유형은 필수입니다.') + if (!vendor.items?.trim()) errors.push('공급품목은 필수입니다.') + if (!vendor.taxId?.trim()) errors.push('사업자등록번호는 필수입니다.') + if (!vendor.country?.trim()) errors.push('국가는 필수입니다.') + if (!vendor.phone?.trim()) errors.push('대표 전화번호는 필수입니다.') + if (!vendor.email?.trim()) errors.push('대표 이메일은 필수입니다.') + + // 연락처 검증 + if (!vendor.contacts?.length) { + errors.push('최소 1명의 담당자 정보가 필요합니다.') + } else { + vendor.contacts.forEach((contact, index) => { + if (!contact.contactName?.trim()) { + errors.push(`담당자 ${index + 1}의 이름은 필수입니다.`) + } + if (!contact.contactEmail?.trim()) { + errors.push(`담당자 ${index + 1}의 이메일은 필수입니다.`) + } + }) + } + + return errors +} + +function validateConsents(consents: ConsentData): string[] { + const errors: string[] = [] + + // 필수 동의 항목 확인 + if (!consents.privacy_policy?.agreed) { + errors.push('개인정보 처리방침에 동의해주세요.') + } + if (!consents.terms_of_service?.agreed) { + errors.push('서비스 이용약관에 동의해주세요.') + } + + // 버전 정보 확인 + if (!consents.privacy_policy?.version) { + errors.push('개인정보 처리방침 버전 정보가 없습니다.') + } + if (!consents.terms_of_service?.version) { + errors.push('서비스 이용약관 버전 정보가 없습니다.') + } + + return errors +} + +export async function POST(request: NextRequest) { + unstable_noStore() + + try { + const formData = await request.formData() + + // 완전한 가입 데이터 파싱 + const completeDataString = formData.get('completeData') as string + if (!completeDataString) { + return NextResponse.json( + { error: '가입 정보가 누락되었습니다.' }, + { status: 400 } + ) + } + + const completeData: CompleteSignupData = JSON.parse(completeDataString) + const { account, vendor, consents } = completeData + + // 유효성 검사 + const accountErrors = validateAccountData(account) + const vendorErrors = validateVendorData(vendor) + const consentErrors = validateConsents(consents) + + const allErrors = [...accountErrors, ...vendorErrors, ...consentErrors] + if (allErrors.length > 0) { + return NextResponse.json( + { error: allErrors.join('\n') }, + { status: 400 } + ) + } + + // 파일 추출 + const businessRegistrationFiles = formData.getAll('businessRegistration') as File[] + const isoCertificationFiles = formData.getAll('isoCertification') as File[] + const creditReportFiles = formData.getAll('creditReport') as File[] + const bankAccountFiles = formData.getAll('bankAccount') as File[] + + // 필수 파일 검증 + if (businessRegistrationFiles.length === 0) { + return NextResponse.json( + { error: '사업자등록증을 업로드해주세요.' }, + { status: 400 } + ) + } + + if (isoCertificationFiles.length === 0) { + return NextResponse.json( + { error: 'ISO 인증서를 업로드해주세요.' }, + { status: 400 } + ) + } + + if (creditReportFiles.length === 0) { + return NextResponse.json( + { error: '신용평가보고서를 업로드해주세요.' }, + { status: 400 } + ) + } + + if (vendor.country !== "KR" && bankAccountFiles.length === 0) { + return NextResponse.json( + { error: '대금지급 통장사본을 업로드해주세요.' }, + { status: 400 } + ) + } + + // 중복 검사 + const existingUser = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.email, account.email.toLowerCase())) + .limit(1) + + if (existingUser.length > 0) { + return NextResponse.json( + { error: '이미 등록된 이메일입니다.' }, + { status: 400 } + ) + } + + const existingVendor = await db + .select({ id: vendors.id }) + .from(vendors) + .where(eq(vendors.taxId, vendor.taxId)) + .limit(1) + + if (existingVendor.length > 0) { + return NextResponse.json( + { error: '이미 등록된 사업자등록번호입니다.' }, + { status: 400 } + ) + } + + // 클라이언트 정보 추출 + const clientIP = request.headers.get('x-forwarded-for') || + request.headers.get('x-real-ip') || + request.ip || + '127.0.0.1' + const userAgent = request.headers.get('user-agent') || 'Unknown' + + let newUser: any + let newVendor: any + + // 트랜잭션으로 모든 데이터 생성 + await db.transaction(async (tx) => { + // 1. 업체 생성 + [newVendor] = await insertVendor(tx, { + vendorName: vendor.vendorName, + vendorCode: vendor.vendorCode || null, + address: vendor.address || null, + country: vendor.country || null, + phone: vendor.phone || null, + email: vendor.email, + website: vendor.website || null, + status: "PENDING_REVIEW", // 관리자 승인 대기 + taxId: vendor.taxId, + vendorTypeId: vendor.vendorTypeId, + items: vendor.items || null, + // 한국 사업자 정보 + representativeName: vendor.representativeName || null, + representativeBirth: vendor.representativeBirth || null, + representativeEmail: vendor.representativeEmail || null, + representativePhone: vendor.representativePhone || null, + corporateRegistrationNumber: vendor.corporateRegistrationNumber || null, + representativeWorkExpirence: vendor.representativeWorkExpirence || false, + }) + + // 2. 사용자 계정 생성 (업체 ID와 연결) + newUser = await createUserAccount(tx, account, newVendor.id) + + // 3. 동의 정보 저장 + await saveUserConsents(tx, newUser.id, consents, clientIP, userAgent) + + // 4. 파일 저장 (보안 강화된 파일 저장 함수 사용) + if (businessRegistrationFiles.length > 0) { + await storeVendorFiles(tx, newVendor.id, businessRegistrationFiles, FILE_TYPES.BUSINESS_REGISTRATION, newUser.id) + } + + if (isoCertificationFiles.length > 0) { + await storeVendorFiles(tx, newVendor.id, isoCertificationFiles, FILE_TYPES.ISO_CERTIFICATION, newUser.id) + } + + if (creditReportFiles.length > 0) { + await storeVendorFiles(tx, newVendor.id, creditReportFiles, FILE_TYPES.CREDIT_REPORT, newUser.id) + } + + if (bankAccountFiles.length > 0) { + await storeVendorFiles(tx, newVendor.id, bankAccountFiles, FILE_TYPES.BANK_ACCOUNT_COPY, newUser.id) + } + + // 5. 담당자 정보 저장 + for (const [index, contact] of vendor.contacts.entries()) { + await tx.insert(vendorContacts).values({ + vendorId: newVendor.id, + contactName: contact.contactName, + contactPosition: contact.contactPosition || null, + contactDepartment: contact.contactDepartment || null, + contactTask: contact.contactTask || null, + contactEmail: contact.contactEmail, + contactPhone: contact.contactPhone || null, + isPrimary: index === 0, // 첫 번째 담당자를 주담당자로 설정 + }) + } + }) + + // 캐시 무효화 + revalidateTag("vendors") + revalidateTag("users") + + console.log(`🎉 통합 회원가입 완료:`) + console.log(` - 사용자: ${newUser.email} (ID: ${newUser.id})`) + console.log(` - 업체: ${newVendor.vendorName} (ID: ${newVendor.id})`) + console.log(` - 상태: 승인 대기 (isActive: false)`) + console.log(` - 다음 단계: 관리자 승인 → 패스워드 설정 링크 발송`) + + return NextResponse.json({ + message: '회원가입 및 업체 등록이 완료되었습니다.', + notice: '관리자 승인 후 계정이 활성화됩니다. 승인 완료 시 패스워드 설정 링크가 이메일로 전송됩니다.', + data: { + userId: newUser.id, + vendorId: newVendor.id, + email: newUser.email, + status: 'PENDING_APPROVAL', // 승인 대기 상태 + isActive: false + } + }, { status: 201 }) + + } catch (error) { + console.error('통합 회원가입 처리 오류:', error) + return NextResponse.json( + { + error: '회원가입 및 업체 등록 처리 중 오류가 발생했습니다.', + details: getErrorMessage(error), + notice: '문제가 지속될 경우 관리자에게 문의해주세요.' + }, + { status: 500 } + ) + } +} diff --git a/components/ProjectSelector.tsx b/components/ProjectSelector.tsx index 50d5b9d5..652bf77b 100644 --- a/components/ProjectSelector.tsx +++ b/components/ProjectSelector.tsx @@ -90,7 +90,11 @@ export function ProjectSelector({ placeholder="프로젝트 코드/이름 검색..." onValueChange={setSearchTerm} /> - + { + e.stopPropagation(); // 이벤트 전파 차단 + const target = e.currentTarget; + target.scrollTop += e.deltaY; // 직접 스크롤 처리 + }}> 검색 결과가 없습니다 {isLoading ? (
로딩 중...
diff --git a/components/form-data/add-formTag-dialog.tsx b/components/form-data/add-formTag-dialog.tsx index 2cd336a0..9d80de8c 100644 --- a/components/form-data/add-formTag-dialog.tsx +++ b/components/form-data/add-formTag-dialog.tsx @@ -1,7 +1,7 @@ "use client" import * as React from "react" -import { useRouter } from "next/navigation" +import { useParams, useRouter } from "next/navigation"; import { useForm, useFieldArray } from "react-hook-form" import { toast } from "sonner" import { Loader2, ChevronsUpDown, Check, Plus, Trash2, Copy } from "lucide-react" @@ -57,6 +57,7 @@ import { getTagTypeByDescription, getSubfieldsByTagTypeForForm } from "@/lib/forms/services" +import { useTranslation } from "@/i18n/client"; // Form-specific tag mapping interface interface FormTagMapping { @@ -107,6 +108,9 @@ export function AddFormTagDialog({ onOpenChange: externalOnOpenChange }: AddFormTagDialogProps) { const router = useRouter() + const params = useParams(); + const lng = (params?.lng as string) || "ko"; + const { t } = useTranslation(lng, "engineering"); // Use external control if provided, otherwise use internal state const [internalOpen, setInternalOpen] = React.useState(false); @@ -439,7 +443,7 @@ export function AddFormTagDialog({ // --------------- // Render Class field // --------------- - function renderClassField(field: any) { + function renderClassField(field: any) { const [popoverOpen, setPopoverOpen] = React.useState(false) const buttonId = React.useMemo( @@ -460,7 +464,7 @@ export function AddFormTagDialog({ return ( - Class + {t("labels.class")} @@ -473,13 +477,13 @@ export function AddFormTagDialog({ > {isLoadingClasses ? ( <> - 클래스 로딩 중... + {t("messages.loadingClasses")} ) : ( <> - {field.value || "클래스 선택..."} + {field.value || t("placeholders.selectClass")} @@ -490,10 +494,10 @@ export function AddFormTagDialog({ - 검색 결과가 없습니다. + {t("messages.noSearchResults")} {classOptions.map((className, optIndex) => { if (!classOptionIdsRef.current[className]) { @@ -553,7 +557,7 @@ export function AddFormTagDialog({ return ( - Tag Type + {t("labels.tagType")} {isReadOnly ? (
@@ -569,7 +573,7 @@ export function AddFormTagDialog({ key={`tag-type-placeholder-${inputId}`} {...field} readOnly - placeholder="클래스 선택시 자동으로 결정됩니다" + placeholder={t("placeholders.autoSetByClass")} className="h-9 bg-muted" /> )} @@ -587,7 +591,7 @@ export function AddFormTagDialog({ return (
-
필드 로딩 중...
+
{t("messages.loadingFields")}
) } @@ -595,7 +599,7 @@ export function AddFormTagDialog({ if (subFields.length === 0 && selectedTagTypeCode) { return (
- 이 태그 유형에 대한 필드가 없습니다. + {t("messages.noFieldsForTagType")}
) } @@ -603,7 +607,7 @@ export function AddFormTagDialog({ if (subFields.length === 0) { return (
- 태그 데이터를 입력하려면 먼저 상단에서 클래스를 선택하세요. + {t("messages.selectClassFirst")}
) } @@ -612,10 +616,10 @@ export function AddFormTagDialog({
{/* 헤더 */}
-

태그 항목 ({fields.length}개)

+

{t("sections.tagItems")} ({fields.length}개)

{!areAllTagNosValid && ( - 유효하지 않은 태그 존재 + {t("messages.invalidTagsExist")} )}
@@ -628,10 +632,10 @@ export function AddFormTagDialog({ # -
Tag No
+
{t("labels.tagNo")}
-
Description
+
{t("labels.description")}
{/* Subfields */} @@ -653,7 +657,7 @@ export function AddFormTagDialog({ ))} - Actions + {t("labels.actions")}
@@ -711,7 +715,7 @@ export function AddFormTagDialog({ @@ -793,7 +797,7 @@ export function AddFormTagDialog({ -

행 복제

+

{t("tooltips.duplicateRow")}

@@ -816,7 +820,7 @@ export function AddFormTagDialog({ -

행 삭제

+

{t("tooltips.deleteRow")}

@@ -837,7 +841,7 @@ export function AddFormTagDialog({ disabled={!selectedTagTypeCode || isLoadingSubFields} > - 새 행 추가 + {t("buttons.addRow")}
@@ -877,16 +881,16 @@ export function AddFormTagDialog({ )} - 폼 태그 추가 - {formName || formCode} + {t("dialogs.addFormTag")} - {formName || formCode} - 클래스를 선택하여 태그 유형과 하위 필드를 로드한 다음, 여러 행을 추가하여 여러 태그를 생성하세요. + {t("dialogs.selectClassToLoadFields")} @@ -934,7 +938,7 @@ export function AddFormTagDialog({ }} disabled={isSubmitting} > - 취소 + {t("buttons.cancel")}
diff --git a/components/form-data/delete-form-data-dialog.tsx b/components/form-data/delete-form-data-dialog.tsx index ca2f8729..9298b43b 100644 --- a/components/form-data/delete-form-data-dialog.tsx +++ b/components/form-data/delete-form-data-dialog.tsx @@ -3,6 +3,8 @@ import * as React from "react" import { Loader, Trash } from "lucide-react" import { toast } from "sonner" +import { useParams } from "next/navigation" +import { useTranslation } from "@/i18n/client" import { useMediaQuery } from "@/hooks/use-media-query" import { Button } from "@/components/ui/button" @@ -55,22 +57,26 @@ export function DeleteFormDataDialog({ }: DeleteFormDataDialogProps) { const [isDeletePending, startDeleteTransition] = React.useTransition() const isDesktop = useMediaQuery("(min-width: 640px)") + + const params = useParams(); + const lng = (params?.lng as string) || "ko"; + const { t } = useTranslation(lng, "engineering"); // TAG_NO가 있는 항목들만 필터링 - const validItems = formData.filter(item => item.TAG_NO?.trim()) - const tagNos = validItems.map(item => item.TAG_NO).filter(Boolean) as string[] + const validItems = formData.filter(item => item.TAG_IDX?.trim()) + const tagIdxs = validItems.map(item => item.TAG_IDX).filter(Boolean) as string[] function onDelete() { startDeleteTransition(async () => { - if (tagNos.length === 0) { - toast.error("No valid items to delete") + if (tagIdxs.length === 0) { + toast.error(t("delete.noValidItems")) return } const result = await deleteFormDataByTags({ formCode, contractItemId, - tagNos, + tagIdxs, }) if (result.error) { @@ -88,12 +94,15 @@ export function DeleteFormDataDialog({ // 데이터 불일치 경고 console.warn(`Data inconsistency: FormEntries deleted: ${deletedCount}, Tags deleted: ${deletedTagsCount}`) toast.error( - `Deleted ${deletedCount} form entries and ${deletedTagsCount} tags (data inconsistency detected)` + t("delete.dataInconsistency", { deletedCount, deletedTagsCount }) ) } else { // 정상적인 삭제 완료 toast.success( - `Successfully deleted ${deletedCount} item${deletedCount === 1 ? "" : "s"}` + t("delete.successMessage", { + count: deletedCount, + items: deletedCount === 1 ? t("delete.item") : t("delete.items") + }) ) } @@ -101,7 +110,7 @@ export function DeleteFormDataDialog({ }) } - const itemCount = tagNos.length + const itemCount = tagIdxs.length const hasValidItems = itemCount > 0 if (isDesktop) { @@ -115,24 +124,25 @@ export function DeleteFormDataDialog({ disabled={!hasValidItems} >