summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-12-08 14:19:37 +0900
committerjoonhoekim <26rote@gmail.com>2025-12-08 14:19:37 +0900
commit2ac7deb8494cf4123f0cff3321860585a44f157c (patch)
tree789b6980c8f863a0f675fad38c4a17d91ba28bf3
parent71c0ba1f01b98770ec2c60cdb935ffb36c1830a9 (diff)
parente37cce51ccfa3dcb91904b2492df3a29970fadf7 (diff)
Merge remote-tracking branch 'origin/sec-patch' into table-v2
-rw-r--r--.env.development5
-rw-r--r--.env.production6
-rw-r--r--README.md4
-rw-r--r--app/[lng]/evcp/(evcp)/(eng)/cover/page.tsx6
-rw-r--r--app/[lng]/evcp/(evcp)/(eng)/document-list-ship/page.tsx8
-rw-r--r--app/[lng]/evcp/(evcp)/(eng)/vendor-data/layout.tsx11
-rw-r--r--app/[lng]/evcp/(evcp)/(master-data)/buyer-signature/page.tsx2
-rw-r--r--app/[lng]/evcp/(evcp)/(master-data)/esg-check-list/page.tsx6
-rw-r--r--app/[lng]/evcp/(evcp)/(master-data)/evaluation-check-list/page.tsx6
-rw-r--r--app/[lng]/evcp/(evcp)/(master-data)/evaluation-target-list/page.tsx6
-rw-r--r--app/[lng]/evcp/(evcp)/(master-data)/general-contract-template/page.tsx8
-rw-r--r--app/[lng]/evcp/(evcp)/(master-data)/material-groups/page.tsx9
-rw-r--r--app/[lng]/evcp/(evcp)/(master-data)/p-items/page.tsx9
-rw-r--r--app/[lng]/evcp/(evcp)/(master-data)/payment-conditions/page.tsx6
-rw-r--r--app/[lng]/evcp/(evcp)/(master-data)/pq-criteria/page.tsx6
-rw-r--r--app/[lng]/evcp/(evcp)/(master-data)/vendor-check-list/page.tsx7
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/avl/avl-page-client.tsx7
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/basic-contract/page.tsx7
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/bid-failure/page.tsx9
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/bid-receive/page.tsx9
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/bid-selection/page.tsx15
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/bid/page.tsx4
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/evaluation-input/page.tsx12
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/evaluation/page.tsx6
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/general-contracts/page.tsx8
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/itb-create/page.tsx15
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/pcr/page.tsx14
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/po/page.tsx17
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/pq_new/page.tsx7
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/rfq-last/page.tsx6
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/risk-management/page.tsx8
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/tbe-last/page.tsx15
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/vendor-candidates/page.tsx6
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/vendor-investigation/page.tsx6
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/vendor-pool/page.tsx8
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/vendor-regular-registrations/page.tsx8
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/vendors/page.tsx6
-rw-r--r--app/[lng]/evcp/(evcp)/(sales)/budgetary-tech-sales-hull/page.tsx8
-rw-r--r--app/[lng]/evcp/(evcp)/(sales)/budgetary-tech-sales-ship/page.tsx8
-rw-r--r--app/[lng]/evcp/(evcp)/(sales)/budgetary-tech-sales-top/page.tsx7
-rw-r--r--app/[lng]/evcp/(evcp)/(sales)/tech-contact-possible-items/page.tsx9
-rw-r--r--app/[lng]/evcp/(evcp)/(sales)/tech-project-avl/page.tsx4
-rw-r--r--app/[lng]/evcp/(evcp)/(sales)/tech-vendors/page.tsx6
-rw-r--r--app/[lng]/evcp/(evcp)/(system)/approval/line/page.tsx7
-rw-r--r--app/[lng]/evcp/(evcp)/(system)/approval/log/page.tsx14
-rw-r--r--app/[lng]/evcp/(evcp)/(system)/approval/template/page.tsx8
-rw-r--r--app/[lng]/evcp/(evcp)/(system)/change-vendor/page.tsx20
-rw-r--r--app/[lng]/evcp/(evcp)/(system)/email-log/page.tsx6
-rw-r--r--app/[lng]/evcp/(evcp)/(system)/email-template/page.tsx9
-rw-r--r--app/[lng]/evcp/(evcp)/(system)/email-whitelist/page.tsx9
-rw-r--r--app/[lng]/evcp/(evcp)/(system)/information/page.tsx4
-rw-r--r--app/[lng]/evcp/(evcp)/(system)/integration-log/page.tsx13
-rw-r--r--app/[lng]/evcp/(evcp)/(system)/integration/page.tsx13
-rw-r--r--app/[lng]/evcp/(evcp)/(system)/login-history/page.tsx8
-rw-r--r--app/[lng]/evcp/(evcp)/(system)/menu-access-dept/page.tsx22
-rw-r--r--app/[lng]/evcp/(evcp)/(system)/menu-access/page.tsx10
-rw-r--r--app/[lng]/evcp/(evcp)/(system)/menu-list/page.tsx97
-rw-r--r--app/[lng]/evcp/(evcp)/(system)/notice/page.tsx14
-rw-r--r--app/[lng]/evcp/(evcp)/(system)/page-visits/page.tsx8
-rw-r--r--app/[lng]/evcp/(evcp)/edp-progress/page.tsx10
-rw-r--r--app/[lng]/evcp/(evcp)/layout.tsx18
-rw-r--r--app/[lng]/partners/(partners)/basic-contract/page.tsx10
-rw-r--r--app/[lng]/partners/(partners)/bid/page.tsx13
-rw-r--r--app/[lng]/partners/(partners)/document-list-ship/page.tsx6
-rw-r--r--app/[lng]/partners/(partners)/evaluation/page.tsx10
-rw-r--r--app/[lng]/partners/(partners)/general-contract-review/page.tsx14
-rw-r--r--app/[lng]/partners/(partners)/layout.tsx4
-rw-r--r--app/[lng]/partners/(partners)/pcr/page.tsx14
-rw-r--r--app/[lng]/partners/(partners)/po/page.tsx8
-rw-r--r--app/[lng]/partners/(partners)/pq_new/page.tsx22
-rw-r--r--app/[lng]/partners/(partners)/rfq-last/page.tsx12
-rw-r--r--app/[lng]/partners/(partners)/swp-document-upload/page.tsx13
-rw-r--r--app/[lng]/partners/(partners)/tbe-last/page.tsx8
-rw-r--r--app/[lng]/partners/(partners)/techsales/rfq-offshore-hull/page.tsx10
-rw-r--r--app/[lng]/partners/(partners)/techsales/rfq-offshore-top/page.tsx10
-rw-r--r--app/[lng]/partners/(partners)/techsales/rfq-ship/page.tsx11
-rw-r--r--app/api/cron/tags-plant/start/route.ts38
-rw-r--r--app/api/general-contracts/upload-pdf/route.ts73
-rw-r--r--components/bidding/create/bidding-create-dialog.tsx51
-rw-r--r--components/bidding/manage/bidding-basic-info-editor.tsx2
-rw-r--r--components/bidding/manage/bidding-companies-editor.tsx279
-rw-r--r--components/bidding/manage/bidding-detail-vendor-create-dialog.tsx15
-rw-r--r--components/bidding/manage/bidding-items-editor.tsx181
-rw-r--r--components/bidding/manage/bidding-schedule-editor.tsx343
-rw-r--r--components/bidding/manage/create-pre-quote-rfq-dialog.tsx128
-rw-r--r--components/common/date-picker/date-picker-with-input.tsx322
-rw-r--r--components/common/date-picker/index.ts3
-rw-r--r--components/common/legal/cpvw-wab-qust-list-view-dialog.tsx364
-rw-r--r--components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx4
-rw-r--r--components/docu-list-rule/docu-list-rule-client.tsx9
-rw-r--r--components/form-data/form-data-table.tsx5
-rw-r--r--components/information/information-button.tsx2
-rw-r--r--components/items-tech/item-tech-container.tsx9
-rw-r--r--components/layout/DynamicMenuRender.tsx146
-rw-r--r--components/layout/HeaderV2.tsx295
-rw-r--r--components/layout/MobileMenuV2.tsx160
-rw-r--r--db/schema/basicContractDocumnet.ts15
-rw-r--r--db/schema/bidding.ts15
-rw-r--r--db/schema/generalContract.ts2
-rw-r--r--db/schema/index.ts4
-rw-r--r--db/schema/menu-v2.ts88
-rw-r--r--db/seeds/menu-v2-seed.js231
-rw-r--r--db/seeds/menu-v2-seed.ts145
-rw-r--r--hooks/use-visible-menu-tree.ts49
-rw-r--r--i18n/locales/en/dolce.json1
-rw-r--r--i18n/locales/en/menu.json5
-rw-r--r--i18n/locales/ko/dolce.json1
-rw-r--r--lib/approval/handlers-registry.ts7
-rw-r--r--lib/approval/templates/일반계약 결재.html3024
-rw-r--r--lib/basic-contract/cpvw-service.ts236
-rw-r--r--lib/basic-contract/service.ts267
-rw-r--r--lib/basic-contract/sslvw-service.ts126
-rw-r--r--lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx194
-rw-r--r--lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx39
-rw-r--r--lib/basic-contract/status-detail/basic-contracts-detail-table.tsx1
-rw-r--r--lib/bidding/actions.ts32
-rw-r--r--lib/bidding/approval-actions.ts18
-rw-r--r--lib/bidding/detail/service.ts508
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-columns.tsx78
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-table.tsx46
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx228
-rw-r--r--lib/bidding/detail/table/price-adjustment-dialog.tsx195
-rw-r--r--lib/bidding/detail/table/vendor-price-adjustment-view-dialog.tsx324
-rw-r--r--lib/bidding/handlers.ts132
-rw-r--r--lib/bidding/list/biddings-page-header.tsx10
-rw-r--r--lib/bidding/list/biddings-table-columns.tsx31
-rw-r--r--lib/bidding/list/biddings-table-toolbar-actions.tsx54
-rw-r--r--lib/bidding/list/export-biddings-to-excel.ts209
-rw-r--r--lib/bidding/manage/export-bidding-items-to-excel.ts161
-rw-r--r--lib/bidding/manage/import-bidding-items-from-excel.ts273
-rw-r--r--lib/bidding/manage/project-utils.ts87
-rw-r--r--lib/bidding/pre-quote/service.ts54
-rw-r--r--lib/bidding/receive/biddings-receive-columns.tsx808
-rw-r--r--lib/bidding/receive/biddings-receive-table.tsx593
-rw-r--r--lib/bidding/selection/actions.ts185
-rw-r--r--lib/bidding/selection/bidding-info-card.tsx14
-rw-r--r--lib/bidding/selection/bidding-item-table.tsx205
-rw-r--r--lib/bidding/selection/bidding-selection-detail-content.tsx11
-rw-r--r--lib/bidding/selection/biddings-selection-columns.tsx7
-rw-r--r--lib/bidding/selection/biddings-selection-table.tsx6
-rw-r--r--lib/bidding/selection/selection-result-form.tsx213
-rw-r--r--lib/bidding/selection/vendor-selection-table.tsx4
-rw-r--r--lib/bidding/service.ts646
-rw-r--r--lib/bidding/validation.ts2
-rw-r--r--lib/bidding/vendor/components/pr-items-pricing-table.tsx143
-rw-r--r--lib/bidding/vendor/export-partners-biddings-to-excel.ts275
-rw-r--r--lib/bidding/vendor/partners-bidding-attendance-dialog.tsx1
-rw-r--r--lib/bidding/vendor/partners-bidding-detail.tsx18
-rw-r--r--lib/bidding/vendor/partners-bidding-list-columns.tsx56
-rw-r--r--lib/bidding/vendor/partners-bidding-list.tsx1
-rw-r--r--lib/bidding/vendor/partners-bidding-toolbar-actions.tsx34
-rw-r--r--lib/dolce/actions.ts6
-rw-r--r--lib/dolce/dialogs/add-and-modify-detail-drawing-dialog.tsx2
-rw-r--r--lib/dolce/table/drawing-list-columns.tsx11
-rw-r--r--lib/dolce/table/gtt-drawing-list-columns.tsx13
-rw-r--r--lib/forms-plant/services.ts161
-rw-r--r--lib/general-contracts/approval-actions.ts136
-rw-r--r--lib/general-contracts/approval-template-variables.ts345
-rw-r--r--lib/general-contracts/detail/general-contract-approval-request-dialog.tsx2266
-rw-r--r--lib/general-contracts/detail/general-contract-basic-info.tsx478
-rw-r--r--lib/general-contracts/detail/general-contract-items-table.tsx162
-rw-r--r--lib/general-contracts/handlers.ts157
-rw-r--r--lib/general-contracts/service.ts13
-rw-r--r--lib/information/service.ts32
-rw-r--r--lib/items-tech/table/add-items-dialog.tsx65
-rw-r--r--lib/menu-v2/components/add-node-dialog.tsx186
-rw-r--r--lib/menu-v2/components/domain-tabs.tsx25
-rw-r--r--lib/menu-v2/components/edit-node-dialog.tsx215
-rw-r--r--lib/menu-v2/components/menu-tree-manager.tsx364
-rw-r--r--lib/menu-v2/components/menu-tree.tsx282
-rw-r--r--lib/menu-v2/components/move-to-dialog.tsx87
-rw-r--r--lib/menu-v2/components/unassigned-menus-panel.tsx178
-rw-r--r--lib/menu-v2/permission-service.ts186
-rw-r--r--lib/menu-v2/service.ts605
-rw-r--r--lib/menu-v2/types.ts103
-rw-r--r--lib/procurement-items/service.ts15
-rw-r--r--lib/sedp/get-tags-plant.ts1013
-rw-r--r--lib/soap/ecc/mapper/bidding-and-pr-mapper.ts22
-rw-r--r--lib/soap/ecc/send/chemical-substance-check.ts449
-rw-r--r--lib/tags-plant/queries.ts2
-rw-r--r--lib/tags-plant/service.ts158
-rw-r--r--lib/tags-plant/table/add-tag-dialog.tsx2
-rw-r--r--lib/tags-plant/table/tag-table-column.tsx19
-rw-r--r--lib/tags-plant/table/tag-table.tsx3
-rw-r--r--lib/tech-vendors/possible-items/connect-item-vendor-dialog.tsx406
-rw-r--r--lib/tech-vendors/possible-items/possible-items-table.tsx18
-rw-r--r--lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx17
-rw-r--r--lib/tech-vendors/service.ts261
-rw-r--r--lib/techsales-rfq/repository.ts1
-rw-r--r--lib/techsales-rfq/service.ts197
-rw-r--r--lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx42
-rw-r--r--lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx50
-rw-r--r--lib/techsales-rfq/table/detail-table/vendor-contact-selection-dialog.tsx22
-rw-r--r--lib/techsales-rfq/table/tech-sales-vendor-eml-attachments-sheet.tsx348
-rw-r--r--lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx13
-rw-r--r--lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx7
-rw-r--r--lib/vendor-document-list/plant/document-stage-dialogs.tsx87
-rw-r--r--lib/vendor-document-list/plant/document-stages-service.ts158
-rw-r--r--lib/vendor-investigation/service.ts3
-rw-r--r--lib/vendor-investigation/validations.ts5
-rw-r--r--lib/vendors/items-table/item-action-dialog.tsx477
-rw-r--r--next.config.ts51
-rw-r--r--package-lock.json4917
-rw-r--r--package.json12
-rw-r--r--types/table.d.ts2
205 files changed, 21025 insertions, 7166 deletions
diff --git a/.env.development b/.env.development
index 30a88aa9..df02cd91 100644
--- a/.env.development
+++ b/.env.development
@@ -197,5 +197,6 @@ NEXT_PUBLIC_HIDE_PARTNERS_MENU_BEFORE_OPEN=false
# DOLCE Local Uplaod Directory (only for v3. currently not used)
DOLCE_LOCAL_UPLOAD_ABSOLUTE_DIRECTORY="/evcp/data/dolce"
-# 서버액션 고차컴포넌트가 실제로 인가에 따라 실행을 막을지를 결정하는 환경변수 (권한 셋업이 되기 전까지는 false)
-CHECK_NONSAP_AUTH_HOC=false \ No newline at end of file
+# 권한관리
+SKIP_ORACLE_PERMISSION_CHECK=true # 화면 조회 권한 확인 여부
+CHECK_NONSAP_AUTH_HOC=false # 서버액션 고차컴포넌트가 실제로 인가에 따라 실행을 막을지를 결정하는 환경변수 (권한 셋업이 되기 전까지는 false) \ No newline at end of file
diff --git a/.env.production b/.env.production
index a8121cf6..0ad720c0 100644
--- a/.env.production
+++ b/.env.production
@@ -199,5 +199,7 @@ NEXT_PUBLIC_HIDE_PARTNERS_MENU_BEFORE_OPEN=false
# DOLCE Local Uplaod Directory (only for v3. currently not used)
DOLCE_LOCAL_UPLOAD_ABSOLUTE_DIRECTORY="/evcp/data/dolce"
-# 서버액션 고차컴포넌트가 실제로 인가에 따라 실행을 막을지를 결정하는 환경변수 (권한 셋업이 되기 전까지는 false)
-CHECK_NONSAP_AUTH_HOC=false \ No newline at end of file
+# 권한관리
+SKIP_ORACLE_PERMISSION_CHECK=true # 화면 조회 권한 확인 여부
+CHECK_NONSAP_AUTH_HOC=false # 서버액션 고차컴포넌트가 실제로 인가에 따라 실행을 막을지를 결정하는 환경변수 (권한 셋업이 되기 전까지는 false)
+
diff --git a/README.md b/README.md
index 060e57e1..fbf7b0e9 100644
--- a/README.md
+++ b/README.md
@@ -5,9 +5,9 @@
1. 프로젝트 압축
```bash
-zip -r public/archive-$(date +%Y%m%d-%H%M%S).zip . -x "./public/*" "./.git/*" "./.next/*" "./tmp/*" "./temp/*" "./.cursor/*" "./_docker/*" "./db/migrations/*"
+zip -r public/archive-$(date +%Y%m%d-%H%M%S).zip . -x "./public/*" "./.git/*" "./.next/*" "./tmp/*" "./temp/*" "./.cursor/*" "./_docker/*" "./db/migrations/*" "./_info/*"
-zip -r public/archive-$(date +%Y%m%d-%H%M%S).zip . -x "./public/*" "./.git/*" "./.next/*" "./tmp/*" "./temp/*" "./.cursor/*" "./node_modules/*" "./_docker/*" "./db/migrations/*"
+zip -r public/archive-$(date +%Y%m%d-%H%M%S).zip . -x "./public/*" "./.git/*" "./.next/*" "./tmp/*" "./temp/*" "./.cursor/*" "./node_modules/*" "./_docker/*" "./db/migrations/*" "./_info/*"
```
2. 내부망으로 이동해서 경로 생성 후 압축 풀기
diff --git a/app/[lng]/evcp/(evcp)/(eng)/cover/page.tsx b/app/[lng]/evcp/(evcp)/(eng)/cover/page.tsx
index 9f2b2e61..891296db 100644
--- a/app/[lng]/evcp/(evcp)/(eng)/cover/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(eng)/cover/page.tsx
@@ -9,12 +9,16 @@ import { searchParamsProjectsCache } from "@/lib/projects/validation"
import { InformationButton } from "@/components/information/information-button"
import { getProjectListsForCover } from "@/lib/cover/service"
import { ProjectsTableForCover } from "@/lib/cover/table/projects-table"
+import { useTranslation } from "@/i18n"
interface IndexPageProps {
+ params: Promise<{ lng: string }>
searchParams: Promise<SearchParams>
}
export default async function IndexPage(props: IndexPageProps) {
+ const { lng } = await props.params
+ const { t } = await useTranslation(lng, 'menu')
const searchParams = await props.searchParams
const search = searchParamsProjectsCache.parse(searchParams)
@@ -35,7 +39,7 @@ export default async function IndexPage(props: IndexPageProps) {
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- 프로젝트 리스트
+ {t('menu.engineering_management.cover')}
</h2>
<InformationButton pagePath="evcp/projects" />
</div>
diff --git a/app/[lng]/evcp/(evcp)/(eng)/document-list-ship/page.tsx b/app/[lng]/evcp/(evcp)/(eng)/document-list-ship/page.tsx
index 822e7cd4..018c5e73 100644
--- a/app/[lng]/evcp/(evcp)/(eng)/document-list-ship/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(eng)/document-list-ship/page.tsx
@@ -15,11 +15,15 @@ import { getUserVendorDocumentStats, getUserVendorDocumentStatsAll, getUserVendo
import { UserVendorDocumentDisplay } from "@/components/ship-vendor-document/user-vendor-document-table-container"
import { InformationButton } from "@/components/information/information-button"
import { UserVendorALLDocumentDisplay } from "@/components/ship-vendor-document-all/user-vendor-document-table-container"
+import { useTranslation } from "@/i18n"
interface IndexPageProps {
+ params: Promise<{ lng: string }>
searchParams: Promise<SearchParams>
}
export default async function IndexPage(props: IndexPageProps) {
+ const { lng } = await props.params
+ const { t } = await useTranslation(lng, 'menu')
const searchParams = await props.searchParams
const search = searchParamsShipDocuCache.parse(searchParams)
const validFilters = getValidFilters(search.filters)
@@ -35,11 +39,11 @@ export default async function IndexPage(props: IndexPageProps) {
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- 문서 관리
+ {t('menu.engineering_management.document_list_ship')}
</h2>
</div>
{/* <p className="text-muted-foreground">
- 소속 회사의 모든 도서/도면을 확인하고 관리합니다.
+ {t('menu.engineering_management.document_list_ship_desc')}
</p> */}
</div>
</div>
diff --git a/app/[lng]/evcp/(evcp)/(eng)/vendor-data/layout.tsx b/app/[lng]/evcp/(evcp)/(eng)/vendor-data/layout.tsx
index 7d00359c..be6bb5eb 100644
--- a/app/[lng]/evcp/(evcp)/(eng)/vendor-data/layout.tsx
+++ b/app/[lng]/evcp/(evcp)/(eng)/vendor-data/layout.tsx
@@ -5,12 +5,19 @@ import { Shell } from "@/components/shell"
import { getVendorProjectsAndContracts } from "@/lib/vendor-data/services"
import { VendorDataContainer } from "@/components/vendor-data/vendor-data-container"
import { InformationButton } from "@/components/information/information-button"
+import { useTranslation } from "@/i18n"
+
// Layout 컴포넌트는 서버 컴포넌트입니다
export default async function VendorDataLayout({
+ params,
children,
}: {
+ params: Promise<{ lng: string }>
children: React.ReactNode
}) {
+ const { lng } = await params
+ const { t } = await useTranslation(lng, 'menu')
+
// evcp: 전체 계약 대상으로 프로젝트 데이터 가져오기
const projects = await getVendorProjectsAndContracts()
@@ -32,7 +39,7 @@ export default async function VendorDataLayout({
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- 협력업체 데이터 입력
+ {t('menu.engineering_management.vendor_data')}
</h2>
<InformationButton pagePath="partners/vendor-data" />
</div>
@@ -64,4 +71,4 @@ export default async function VendorDataLayout({
</section>
</Shell>
)
-} \ No newline at end of file
+}
diff --git a/app/[lng]/evcp/(evcp)/(master-data)/buyer-signature/page.tsx b/app/[lng]/evcp/(evcp)/(master-data)/buyer-signature/page.tsx
index 96bf783c..dfbd605b 100644
--- a/app/[lng]/evcp/(evcp)/(master-data)/buyer-signature/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(master-data)/buyer-signature/page.tsx
@@ -15,7 +15,7 @@ export default async function BuyerSignaturePage(props: { params: Promise<{ lng:
<div>
<h1 className="text-3xl font-bold">{t('menu.master_data.buyer_signature')}</h1>
<p className="text-muted-foreground mt-2">
- 계약서에 자동으로 적용될 삼성중공업 서명을 관리합니다.
+ {t('menu.master_data.buyer_signaturee_desc')}
</p>
</div>
diff --git a/app/[lng]/evcp/(evcp)/(master-data)/esg-check-list/page.tsx b/app/[lng]/evcp/(evcp)/(master-data)/esg-check-list/page.tsx
index a573c77b..4575e65b 100644
--- a/app/[lng]/evcp/(evcp)/(master-data)/esg-check-list/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(master-data)/esg-check-list/page.tsx
@@ -9,14 +9,18 @@ import { getEsgEvaluations } from "@/lib/esg-check-list/service"
import { getEsgEvaluationsSchema } from "@/lib/esg-check-list/validation"
import { EsgEvaluationsTable } from "@/lib/esg-check-list/table/esg-table"
import { InformationButton } from "@/components/information/information-button"
+import { useTranslation } from "@/i18n"
interface IndexPageProps {
+ params: Promise<{lng: string}>
searchParams: Promise<SearchParams>
}
export default async function IndexPage(props: IndexPageProps) {
const searchParams = await props.searchParams
const search = getEsgEvaluationsSchema.parse(searchParams)
+ const {lng} = await props.params
+ const {t} = await useTranslation(lng, 'menu')
const validFilters = getValidFilters(search.filters)
@@ -35,7 +39,7 @@ export default async function IndexPage(props: IndexPageProps) {
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- ESG 자가진단평가서 항목 관리
+ {t('menu.master_data.esg_checklist')}
</h2>
<InformationButton pagePath="evcp/esg-check-list" />
</div>
diff --git a/app/[lng]/evcp/(evcp)/(master-data)/evaluation-check-list/page.tsx b/app/[lng]/evcp/(evcp)/(master-data)/evaluation-check-list/page.tsx
index ccd6766b..5547b097 100644
--- a/app/[lng]/evcp/(evcp)/(master-data)/evaluation-check-list/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(master-data)/evaluation-check-list/page.tsx
@@ -9,10 +9,12 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Suspense } from 'react';
import { type SearchParams } from '@/types/table';
import { InformationButton } from '@/components/information/information-button';
+import { useTranslation } from '@/i18n';
// ----------------------------------------------------------------------------------------------------
/* TYPES */
interface EvaluationCriteriaPageProps {
+ params: Promise<{ lng: string }>
searchParams: Promise<SearchParams>
}
@@ -23,6 +25,8 @@ async function EvaluationCriteriaPage(props: EvaluationCriteriaPageProps) {
const searchParams = await props.searchParams;
const search = searchParamsCache.parse(searchParams);
const validFilters = getValidFilters(search.filters);
+ const { lng } = await props.params
+ const { t } = await useTranslation(lng, 'menu')
const promises = Promise.all([
getRegEvalCriteria({
...search,
@@ -37,7 +41,7 @@ async function EvaluationCriteriaPage(props: EvaluationCriteriaPageProps) {
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- 협력업체 평가기준표 관리
+ {t('menu.master_data.evaluation_checklist')}
</h2>
<InformationButton pagePath="evcp/evaluation-check-list" />
</div>
diff --git a/app/[lng]/evcp/(evcp)/(master-data)/evaluation-target-list/page.tsx b/app/[lng]/evcp/(evcp)/(master-data)/evaluation-target-list/page.tsx
index 325037d8..b42c2037 100644
--- a/app/[lng]/evcp/(evcp)/(master-data)/evaluation-target-list/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(master-data)/evaluation-target-list/page.tsx
@@ -9,6 +9,7 @@ import { getDefaultEvaluationYear, searchParamsEvaluationTargetsCache } from "@/
import { getEvaluationTargets } from "@/lib/evaluation-target-list/service"
import { InformationButton } from "@/components/information/information-button"
import { EvaluationTargetsTable } from "@/lib/evaluation-target-list/table/evaluation-target-table"
+import { useTranslation } from "@/i18n"
export const dynamic = 'force-dynamic'
export const revalidate = 0
@@ -19,11 +20,14 @@ export const metadata: Metadata = {
}
interface EvaluationTargetsPageProps {
+ params: Promise<{ lng: string }>
searchParams: Promise<SearchParams>
}
export default async function EvaluationTargetsPage(props: EvaluationTargetsPageProps) {
const searchParams = await props.searchParams
+ const { lng } = await props.params
+ const { t } = await useTranslation(lng, 'menu')
// ✅ 간소화된 파싱
const search = searchParamsEvaluationTargetsCache.parse(searchParams)
@@ -42,7 +46,7 @@ export default async function EvaluationTargetsPage(props: EvaluationTargetsPage
<div className="flex items-center justify-between space-y-2">
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- 협력업체 평가 대상 관리
+ {t('menu.master_data.evaluation_target')}
</h2>
<InformationButton pagePath="evcp/evaluation-target-list" />
<Badge variant="outline" className="text-sm">
diff --git a/app/[lng]/evcp/(evcp)/(master-data)/general-contract-template/page.tsx b/app/[lng]/evcp/(evcp)/(master-data)/general-contract-template/page.tsx
index 8a652690..c82f6b5a 100644
--- a/app/[lng]/evcp/(evcp)/(master-data)/general-contract-template/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(master-data)/general-contract-template/page.tsx
@@ -9,14 +9,18 @@ import { getContractTemplates } from "@/lib/general-contract-template/service"
import { searchParamsTemplatesCache } from "@/lib/general-contract-template/validations"
import { ContractTemplateTable } from "@/lib/general-contract-template/template/general-contract-template"
import { InformationButton } from "@/components/information/information-button"
+import { useTranslation } from "@/i18n"
interface IndexPageProps {
+ params: Promise<{lng: string}>
searchParams: Promise<SearchParams>
}
export default async function IndexPage(props: IndexPageProps) {
const searchParams = await props.searchParams
const search = searchParamsTemplatesCache.parse(searchParams)
+ const {lng} = await props.params
+ const {t} = await useTranslation(lng, 'menu')
const validFilters = getValidFilters(search.filters)
@@ -34,12 +38,12 @@ export default async function IndexPage(props: IndexPageProps) {
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- 일반계약(Contract) 표준양식 관리
+ {t('menu.master_data.general_contract_template')}
</h2>
<InformationButton pagePath="evcp/general-contract-template" />
</div>
<p className="text-muted-foreground mt-2">
- 다양한 계약 유형의 표준양식을 관리합니다. LO, FA, PO, CS, EU 등 계약 종류별 템플릿을 등록하고 편집할 수 있습니다.
+ {t('menu.master_data.general_contract_template_desc')}
</p>
</div>
</div>
diff --git a/app/[lng]/evcp/(evcp)/(master-data)/material-groups/page.tsx b/app/[lng]/evcp/(evcp)/(master-data)/material-groups/page.tsx
index 3d019182..708fac1e 100644
--- a/app/[lng]/evcp/(evcp)/(master-data)/material-groups/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(master-data)/material-groups/page.tsx
@@ -15,14 +15,17 @@ import { MaterialGroupTable } from "@/lib/material-groups/table/material-group-t
import { InformationButton } from "@/components/information/information-button"
import { MaterialGroupSyncButton } from "@/components/material-groups/sync-button"
import { searchParamsCache } from "@/lib/material-groups/validations"
+import { useTranslation } from "@/i18n"
interface MaterialGroupPageProps {
+ params: Promise<{ lng: string }>
searchParams: Promise<SearchParams>
}
export default async function MaterialGroupPage(props: MaterialGroupPageProps) {
const searchParams = await props.searchParams
-
+ const { lng } = await props.params
+ const { t } = await useTranslation(lng, 'menu')
// searchParamsCache를 사용해서 파라미터 파싱
const search = searchParamsCache.parse(searchParams)
@@ -43,12 +46,12 @@ export default async function MaterialGroupPage(props: MaterialGroupPageProps) {
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- 자재그룹
+ {t('menu.master_data.material_master')}
</h2>
<InformationButton pagePath="evcp/material-groups" />
</div>
<p className="text-muted-foreground">
- MDG로부터 수신된 자재그룹 정보
+ {t('menu.master_data.material_master_desc')}
</p>
</div>
<div className="flex items-center gap-2">
diff --git a/app/[lng]/evcp/(evcp)/(master-data)/p-items/page.tsx b/app/[lng]/evcp/(evcp)/(master-data)/p-items/page.tsx
index 2b907a75..fa0cef19 100644
--- a/app/[lng]/evcp/(evcp)/(master-data)/p-items/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(master-data)/p-items/page.tsx
@@ -8,14 +8,17 @@ import { getProcurementItems } from "@/lib/procurement-items/service"
import { ProcurementItemsTable } from "@/lib/procurement-items/table/procurement-items-table"
import { searchParamsCache } from "@/lib/procurement-items/validations"
import { InformationButton } from "@/components/information/information-button"
-
+import { useTranslation } from "@/i18n"
interface IndexPageProps {
+ params: Promise<{lng: string}>
searchParams: Promise<SearchParams>
}
export default async function IndexPage(props: IndexPageProps) {
const searchParams = await props.searchParams
const search = searchParamsCache.parse(searchParams)
+ const {lng} = await props.params
+ const {t} = await useTranslation(lng, 'menu')
const validFilters = getValidFilters(search.filters)
@@ -33,12 +36,12 @@ export default async function IndexPage(props: IndexPageProps) {
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- 1회성 품목 관리
+ {t('menu.master_data.procurement_items')}
</h2>
<InformationButton pagePath="evcp/procurement-items" />
</div>
<p className="text-muted-foreground">
- 입찰에서 사용하는 1회성 품목을 등록하고 관리합니다.
+ {t('menu.master_data.procurement_items_desc')}
</p>
</div>
</div>
diff --git a/app/[lng]/evcp/(evcp)/(master-data)/payment-conditions/page.tsx b/app/[lng]/evcp/(evcp)/(master-data)/payment-conditions/page.tsx
index 49724328..ff6d24b2 100644
--- a/app/[lng]/evcp/(evcp)/(master-data)/payment-conditions/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(master-data)/payment-conditions/page.tsx
@@ -8,7 +8,9 @@ import { SearchParamsCache } from "@/lib/payment-terms/validations";
import { getPaymentTerms } from "@/lib/payment-terms/service";
import { PaymentTermsTable } from "@/lib/payment-terms/table/payment-terms-table";
import { InformationButton } from "@/components/information/information-button";
+import { useTranslation } from "@/i18n"
interface IndexPageProps {
+ params: Promise<{ lng: string }>
searchParams: Promise<SearchParams>;
}
@@ -16,6 +18,8 @@ export default async function IndexPage(props: IndexPageProps) {
const searchParams = await props.searchParams;
const search = SearchParamsCache.parse(searchParams);
const validFilters = getValidFilters(search.filters);
+ const {lng} = await props.params
+ const {t} = await useTranslation(lng, 'menu')
const promises = Promise.all([
getPaymentTerms({
@@ -29,7 +33,7 @@ export default async function IndexPage(props: IndexPageProps) {
<div className="flex items-center justify-between space-y-2">
<div>
<div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">지급 조건 관리</h2>
+ <h2 className="text-2xl font-bold tracking-tight">{t('menu.master_data.payment_conditions')}</h2>
<InformationButton pagePath="evcp/payment-conditions" />
</div>
{/* <p className="text-muted-foreground">
diff --git a/app/[lng]/evcp/(evcp)/(master-data)/pq-criteria/page.tsx b/app/[lng]/evcp/(evcp)/(master-data)/pq-criteria/page.tsx
index 1a337cc9..e83696ce 100644
--- a/app/[lng]/evcp/(evcp)/(master-data)/pq-criteria/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(master-data)/pq-criteria/page.tsx
@@ -7,14 +7,18 @@ import { searchParamsCache } from "@/lib/pq/validations"
import { getPQLists } from "@/lib/pq/service"
import { PqListsTable } from "@/lib/pq/table/pq-lists-table"
import { getProjects } from "@/lib/pq/service"
+import { useTranslation } from "@/i18n"
interface ProjectPageProps {
+ params: Promise<{ lng: string }>
searchParams: Promise<SearchParams>
}
export default async function ProjectPage(props: ProjectPageProps) {
const searchParams = await props.searchParams
const search = searchParamsCache.parse(searchParams)
+ const {lng} = await props.params
+ const {t} = await useTranslation(lng, 'menu')
// filters가 없는 경우를 처리
const validFilters = getValidFilters(search.filters)
@@ -33,7 +37,7 @@ export default async function ProjectPage(props: ProjectPageProps) {
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">
- PQ 리스트 관리
+ {t('menu.master_data.pq_criteria')}
</h2>
{/* <p className="text-muted-foreground">
협력업체 등록을 위한, 협력업체가 제출할 PQ 항목을: 프로젝트별로 관리할 수 있습니다.
diff --git a/app/[lng]/evcp/(evcp)/(master-data)/vendor-check-list/page.tsx b/app/[lng]/evcp/(evcp)/(master-data)/vendor-check-list/page.tsx
index 955c95f0..4cc5a9a0 100644
--- a/app/[lng]/evcp/(evcp)/(master-data)/vendor-check-list/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(master-data)/vendor-check-list/page.tsx
@@ -9,14 +9,19 @@ import { getGenralEvaluationsSchema } from "@/lib/general-check-list/validation"
import { GeneralEvaluationsTable } from "@/lib/general-check-list/table/general-check-list-table"
import { getGeneralEvaluations } from "@/lib/general-check-list/service"
import { InformationButton } from "@/components/information/information-button"
+import { useTranslation } from "@/i18n"
+
interface IndexPageProps {
+ params: Promise<{lng: string}>
searchParams: Promise<SearchParams>
}
export default async function IndexPage(props: IndexPageProps) {
const searchParams = await props.searchParams
const search = getGenralEvaluationsSchema.parse(searchParams)
+ const { lng } = await props.params
+ const { t } = await useTranslation(lng, 'menu')
const validFilters = getValidFilters(search.filters)
@@ -35,7 +40,7 @@ export default async function IndexPage(props: IndexPageProps) {
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- 협력업체 평가자료 문항 관리
+ {t('menu.master_data.vendor_checklist')}
</h2>
<InformationButton pagePath="evcp/vendor-check-list" />
</div>
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/avl/avl-page-client.tsx b/app/[lng]/evcp/(evcp)/(procurement)/avl/avl-page-client.tsx
index 7152bdc2..cb191dd4 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/avl/avl-page-client.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/avl/avl-page-client.tsx
@@ -7,6 +7,8 @@ import { getAvlLists } from "@/lib/avl/service"
import { AvlListItem } from "@/lib/avl/types"
import { toast } from "sonner"
import { InformationButton } from "@/components/information/information-button"
+import { useTranslation } from "@/i18n/client"
+import { useParams } from "next/navigation"
interface AvlPageClientProps {
initialData: AvlListItem[]
@@ -17,6 +19,9 @@ export function AvlPageClient({ initialData }: AvlPageClientProps) {
const [isLoading, setIsLoading] = useState(false)
const [registrationMode, setRegistrationMode] = useState<'standard' | 'project' | null>(null)
+ const params = useParams<{lng: string}>()
+ const lng = params?.lng ?? 'ko'
+ const {t} = useTranslation(lng, 'menu')
// 초기 데이터 설정
useEffect(() => {
setAvlListData(initialData)
@@ -73,7 +78,7 @@ export function AvlPageClient({ initialData }: AvlPageClientProps) {
{/* info button and header section */}
<div className="flex items-center gap-2 mt-2">
<h2 className="text-2xl font-bold tracking-tight">
- AVL(Approved Vendor List) 목록
+ {t('menu.vendor_management.avl_management')}
</h2>
<InformationButton pagePath="evcp/avl" />
</div>
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/basic-contract/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/basic-contract/page.tsx
index 66b3ee31..57fceadf 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/basic-contract/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/basic-contract/page.tsx
@@ -9,15 +9,18 @@ import { getBasicContracts } from "@/lib/basic-contract/service"
import { searchParamsCache } from "@/lib/basic-contract/validations"
import { BasicContractsTable } from "@/lib/basic-contract/status/basic-contract-table"
import { InformationButton } from "@/components/information/information-button"
-
+import { useTranslation } from "@/i18n"
interface IndexPageProps {
+ params: Promise<{lng: string}>
searchParams: Promise<SearchParams>
}
export default async function IndexPage(props: IndexPageProps) {
const searchParams = await props.searchParams
const search = searchParamsCache.parse(searchParams)
+ const {lng} = await props.params
+ const {t} = await useTranslation(lng, 'menu')
const validFilters = getValidFilters(search.filters)
@@ -36,7 +39,7 @@ export default async function IndexPage(props: IndexPageProps) {
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- 기본계약서/서약서 관리
+ {t('menu.vendor_management.basic_contract')}
</h2>
<InformationButton pagePath="evcp/basic-contract" />
</div>
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/bid-failure/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/bid-failure/page.tsx
index b6c181dc..254fdc13 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/bid-failure/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/bid-failure/page.tsx
@@ -2,6 +2,7 @@ import { Metadata } from 'next'
import { getBiddingsForFailure } from '@/lib/bidding/service'
import { GetBiddingsSchema, searchParamsCache } from '@/lib/bidding/validation'
import { BiddingsFailureTable } from '@/lib/bidding/failure/biddings-failure-table'
+import { useTranslation } from "@/i18n"
export const metadata: Metadata = {
title: '폐찰 및 재입찰',
@@ -9,15 +10,19 @@ export const metadata: Metadata = {
}
interface BiddingFailurePageProps {
+ params: Promise<{lng: string}>
searchParams: Promise<Record<string, string | string[] | undefined>>
}
export default async function BiddingFailurePage({
+ params,
searchParams,
}: BiddingFailurePageProps) {
// URL 파라미터 검증
const searchParamsResolved = await searchParams
const search = searchParamsCache.parse(searchParamsResolved)
+ const {lng} = await params
+ const {t} = await useTranslation(lng, 'menu')
// 데이터 조회
const biddingsPromise = getBiddingsForFailure(search)
@@ -26,9 +31,9 @@ export default async function BiddingFailurePage({
<div className="flex flex-col gap-4 p-4">
<div className="flex items-center justify-between">
<div>
- <h1 className="text-2xl font-bold tracking-tight">폐찰 및 재입찰</h1>
+ <h1 className="text-2xl font-bold tracking-tight">{t('menu.procurement.bid_failure')}</h1>
<p className="text-muted-foreground">
- 유찰된 입찰 내역을 확인하고 재입찰을 진행할 수 있습니다.
+ {t('menu.procurement.bid_failure_desc')}
</p>
</div>
</div>
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/bid-receive/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/bid-receive/page.tsx
index 4f6e9715..a087b09f 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/bid-receive/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/bid-receive/page.tsx
@@ -2,6 +2,7 @@ import { Metadata } from 'next'
import { getBiddingsForReceive } from '@/lib/bidding/service'
import { GetBiddingsSchema, searchParamsCache } from '@/lib/bidding/validation'
import { BiddingsReceiveTable } from '@/lib/bidding/receive/biddings-receive-table'
+import { useTranslation } from "@/i18n"
export const metadata: Metadata = {
title: '입찰서 접수 및 마감',
@@ -9,15 +10,19 @@ export const metadata: Metadata = {
}
interface BiddingReceivePageProps {
+ params: Promise<{lng: string}>
searchParams: Promise<Record<string, string | string[] | undefined>>
}
export default async function BiddingReceivePage({
+ params,
searchParams,
}: BiddingReceivePageProps) {
// URL 파라미터 검증
const searchParamsResolved = await searchParams
const search = searchParamsCache.parse(searchParamsResolved)
+ const {lng} = await params
+ const {t} = await useTranslation(lng, 'menu')
// 데이터 조회
const biddingsPromise = getBiddingsForReceive(search)
@@ -26,9 +31,9 @@ export default async function BiddingReceivePage({
<div className="flex flex-col gap-4 p-4">
<div className="flex items-center justify-between">
<div>
- <h1 className="text-2xl font-bold tracking-tight">입찰서 접수 및 마감</h1>
+ <h1 className="text-2xl font-bold tracking-tight">{t('menu.procurement.bid_receive')}</h1>
<p className="text-muted-foreground">
- 입찰서 접수 현황을 확인하고 개찰을 진행할 수 있습니다.
+ {t('menu.procurement.bid_receive_desc')}
</p>
</div>
</div>
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/bid-selection/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/bid-selection/page.tsx
index 40b714de..f2fa33cd 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/bid-selection/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/bid-selection/page.tsx
@@ -1,23 +1,22 @@
-import { Metadata } from 'next'
import { getBiddingsForSelection } from '@/lib/bidding/service'
import { GetBiddingsSchema, searchParamsCache } from '@/lib/bidding/validation'
import { BiddingsSelectionTable } from '@/lib/bidding/selection/biddings-selection-table'
-
-export const metadata: Metadata = {
- title: '입찰선정',
- description: '개찰 이후 입찰가를 확인하고 낙찰업체를 선정할 수 있습니다.',
-}
+import { useTranslation } from "@/i18n"
interface BiddingSelectionPageProps {
+ params: Promise<{lng: string}>
searchParams: Promise<Record<string, string | string[] | undefined>>
}
export default async function BiddingSelectionPage({
+ params,
searchParams,
}: BiddingSelectionPageProps) {
// URL 파라미터 검증
const searchParamsResolved = await searchParams
const search = searchParamsCache.parse(searchParamsResolved)
+ const {lng} = await params
+ const {t} = await useTranslation(lng, 'menu')
// 데이터 조회
const biddingsPromise = getBiddingsForSelection(search)
@@ -26,9 +25,9 @@ export default async function BiddingSelectionPage({
<div className="flex flex-col gap-4 p-4">
<div className="flex items-center justify-between">
<div>
- <h1 className="text-2xl font-bold tracking-tight">입찰선정</h1>
+ <h1 className="text-2xl font-bold tracking-tight">{t('menu.procurement.bid_selection')}</h1>
<p className="text-muted-foreground">
- 개찰 이후 입찰가를 확인하고 낙찰업체를 선정할 수 있습니다.
+ {t('menu.procurement.bid_selection_desc')}
</p>
</div>
</div>
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/bid/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/bid/page.tsx
index 973593d8..55dbae32 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/bid/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/bid/page.tsx
@@ -17,6 +17,7 @@ export const metadata = {
}
interface IndexPageProps {
+ params: Promise<{lng: string}>
searchParams: Promise<SearchParams>
}
@@ -24,6 +25,7 @@ export default async function BiddingsPage(props: IndexPageProps) {
// ✅ nuqs searchParamsCache로 파싱 (타입 안전성 보장)
const searchParams = await props.searchParams
const search = searchParamsCache.parse(searchParams)
+ const {lng} = await props.params
const validFilters = getValidFilters(search.filters)
@@ -42,7 +44,7 @@ export default async function BiddingsPage(props: IndexPageProps) {
{/* ═══════════════════════════════════════════════════════════════ */}
{/* 페이지 헤더 */}
{/* ═══════════════════════════════════════════════════════════════ */}
- <BiddingsPageHeader />
+ <BiddingsPageHeader lng={lng} />
{/* ═══════════════════════════════════════════════════════════════ */}
{/* 메인 테이블 */}
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/evaluation-input/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/evaluation-input/page.tsx
index 51478cc0..635169ce 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/evaluation-input/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/evaluation-input/page.tsx
@@ -12,8 +12,10 @@ import { LogIn } from "lucide-react"
import { getSHIEvaluationSubmissions } from "@/lib/evaluation-submit/service"
import { getSHIEvaluationsSubmitSchema } from "@/lib/evaluation-submit/validation"
import { SHIEvaluationSubmissionsTable } from "@/lib/evaluation-submit/table/submit-table"
+import { useTranslation } from "@/i18n"
interface IndexPageProps {
+ params: Promise<{lng: string}>
searchParams: Promise<SearchParams>
}
@@ -21,7 +23,9 @@ export default async function IndexPage(props: IndexPageProps) {
const searchParams = await props.searchParams
const search = getSHIEvaluationsSubmitSchema.parse(searchParams)
const validFilters = getValidFilters(search.filters)
-
+ const {lng} = await props.params
+ const {t} = await useTranslation(lng, 'menu')
+
// Get session
const session = await getServerSession(authOptions)
@@ -34,7 +38,7 @@ export default async function IndexPage(props: IndexPageProps) {
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- 협력업체 정기평가 입력
+ {t('menu.vendor_management.evaluation_input')}
</h2>
</div>
{/* <p className="text-muted-foreground">
@@ -74,7 +78,7 @@ export default async function IndexPage(props: IndexPageProps) {
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">
- 협력업체 정기평가 입력
+ {t('menu.vendor_management.evaluation_input')}
</h2>
</div>
</div>
@@ -104,7 +108,7 @@ export default async function IndexPage(props: IndexPageProps) {
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">
- 협력업체 정기평가 입력
+ {t('menu.vendor_management.evaluation_input')}
</h2>
{/* <p className="text-muted-foreground">
요청된 정기평가를 입력하고 제출할 수 있습니다.
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/evaluation/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/evaluation/page.tsx
index 0d3848d9..bf30cfc9 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/evaluation/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/evaluation/page.tsx
@@ -24,6 +24,7 @@ import {
type GetEvaluationsSchema
} from "@/lib/evaluation/validation"
import { InformationButton } from "@/components/information/information-button"
+import { useTranslation } from "@/i18n"
export const metadata: Metadata = {
title: "협력업체 정기평가",
@@ -31,6 +32,7 @@ export const metadata: Metadata = {
}
interface PeriodicEvaluationsPageProps {
+ params: Promise<{lng: string}>
searchParams: Promise<SearchParams>
}
@@ -133,6 +135,8 @@ function AggregatedModeNotice({ isAggregated }: { isAggregated: boolean }) {
export default async function PeriodicEvaluationsPage(props: PeriodicEvaluationsPageProps) {
const searchParams = await props.searchParams
+ const {lng} = await props.params
+ const {t} = await useTranslation(lng, 'menu')
// ✅ nuqs 기반 파라미터 파싱
const search = searchParamsEvaluationsCache.parse(searchParams)
@@ -156,7 +160,7 @@ export default async function PeriodicEvaluationsPage(props: PeriodicEvaluations
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- 협력업체 정기평가
+ {t('menu.vendor_management.evaluation')}
</h2>
<InformationButton pagePath="evcp/evaluation" />
{/* <ProcessGuidePopover /> */}
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/general-contracts/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/general-contracts/page.tsx
index a6d5057c..96f03d09 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/general-contracts/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/general-contracts/page.tsx
@@ -12,6 +12,7 @@ import { GeneralContractsTable } from "@/lib/general-contracts/main/general-cont
import { getValidFilters } from "@/lib/data-table"
import { type SearchParams } from "@/types/table"
import { InformationButton } from "@/components/information/information-button"
+import { useTranslation } from "@/i18n"
export const metadata = {
title: "일반계약 관리",
@@ -19,10 +20,13 @@ export const metadata = {
}
interface IndexPageProps {
+ params: Promise<{ lng: string }>
searchParams: Promise<SearchParams>
}
export default async function GeneralContractsPage(props: IndexPageProps) {
+ const { lng } = await props.params
+ const { t } = await useTranslation(lng, 'menu')
// ✅ searchParams 파싱
const searchParams = await props.searchParams
const search = searchParamsCache.parse(searchParams)
@@ -52,12 +56,12 @@ export default async function GeneralContractsPage(props: IndexPageProps) {
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- 일반계약 관리
+ {t('menu.procurement.general_contract')}
</h2>
<InformationButton pagePath="evcp/general-contracts" />
</div>
<p className="text-muted-foreground">
- 일반계약을 생성하고 관리할 수 있습니다. 계약 상세정보, 품목정보, 납품확인서 등을 관리할 수 있습니다.
+ {t('menu.procurement.general_contract_desc')}
</p>
</div>
</div>
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/itb-create/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/itb-create/page.tsx
index 54040e7f..77dc54ee 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/itb-create/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/itb-create/page.tsx
@@ -12,17 +12,16 @@ import Link from "next/link";
import { searchParamsPurchaseRequestCache } from "@/lib/itb/validations";
import { getAllPurchaseRequests, getPurchaseRequestStats } from "@/lib/itb/service";
import { PurchaseRequestsTable } from "@/lib/itb/table/purchase-requests-table";
+import { useTranslation } from "@/i18n"
interface PurchaseRequestsPageProps {
- params: {
- lng: string;
- };
+ params: Promise<{ lng: string }>;
searchParams: Promise<SearchParams>;
}
export default async function PurchaseRequestsPage(props: PurchaseRequestsPageProps) {
- const resolvedParams = await props.params;
- const lng = resolvedParams.lng;
+ const { lng } = await props.params
+ const { t } = await useTranslation(lng, 'menu')
const searchParams = await props.searchParams;
@@ -44,10 +43,10 @@ export default async function PurchaseRequestsPage(props: PurchaseRequestsPagePr
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">
- 구매 요청 관리
+ {t('menu.engineering_management.itb')}
</h2>
<p className="text-muted-foreground">
- 프로젝트별 자재 구매 요청을 생성하고 관리합니다.
+ {t('menu.engineering_management.itb_desc')}
</p>
</div>
</div>
@@ -161,4 +160,4 @@ async function PurchaseRequestStats({
export const metadata = {
title: "Purchase Request Management",
description: "Create and manage material purchase requests for projects",
-}; \ No newline at end of file
+};
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/pcr/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/pcr/page.tsx
index 7617bf58..22e0c124 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/pcr/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/pcr/page.tsx
@@ -5,12 +5,17 @@ import { InformationButton } from "@/components/information/information-button"
import { PcrTable } from "@/lib/pcr/table/pcr-table";
import { getPcrPoList } from "@/lib/pcr/service";
+import { useTranslation } from "@/i18n"
export const metadata = {
title: "PCR 관리",
description: "Purchase Change Request를 생성하고 관리할 수 있습니다.",
};
+interface IndexPageProps {
+ params: Promise<{ lng: string }>
+}
+
async function PcrTableWrapper() {
// 기본 데이터 조회 (EvcP용 - 모든 데이터 조회)
const tableData = await getPcrPoList({
@@ -21,7 +26,10 @@ async function PcrTableWrapper() {
return <PcrTable tableData={tableData} isEvcpPage={true} currentVendorId={undefined} />;
}
-export default function PcrPage() {
+export default async function PcrPage({ params }: IndexPageProps) {
+ const { lng } = await params
+ const { t } = await useTranslation(lng, 'menu')
+
return (
<Shell className="gap-4">
{/* ═══════════════════════════════════════════════════════════════ */}
@@ -32,12 +40,12 @@ export default function PcrPage() {
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- PCR 관리
+ {t('menu.procurement.pcr')}
</h2>
<InformationButton pagePath="evcp/pcr" />
</div>
<p className="text-muted-foreground">
- Purchase Change Request를 생성하고 관리할 수 있습니다. PCR 승인 상태, 변경 구분, PO/계약 정보 등을 확인할 수 있습니다.
+ {t('menu.procurement.pcr_desc')}
</p>
</div>
</div>
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/po/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/po/page.tsx
index 292ef1cb..59b793ab 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/po/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/po/page.tsx
@@ -1,4 +1,4 @@
-import * as React from "react"
+
import { type SearchParams } from "@/types/table"
import { getValidFilters } from "@/lib/data-table"
@@ -9,14 +9,19 @@ import { getVendorPOs } from "@/lib/po/vendor-table/service"
import { vendorPoSearchParamsCache } from "@/lib/po/vendor-table/validations"
import { ShiVendorPoTable } from "@/lib/po/vendor-table/shi-vendor-po-table"
import { InformationButton } from "@/components/information/information-button"
+import { useTranslation } from "@/i18n"
+import { Suspense } from "react"
interface VendorPOPageProps {
+ params: Promise<{lng: string}>
searchParams: Promise<SearchParams>
}
export default async function VendorPONew(props: VendorPOPageProps) {
const searchParams = await props.searchParams
const search = vendorPoSearchParamsCache.parse(searchParams)
+ const { lng } = await props.params
+ const { t } = await useTranslation(lng, 'menu')
const validFilters = getValidFilters(search.filters)
@@ -34,7 +39,7 @@ export default async function VendorPONew(props: VendorPOPageProps) {
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- PO/계약 관리
+ {t('menu.procurement.po_issuance')}
</h2>
<InformationButton pagePath="evcp/po-new" />
</div>
@@ -42,9 +47,9 @@ export default async function VendorPONew(props: VendorPOPageProps) {
</div>
</div>
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- </React.Suspense>
- <React.Suspense
+ <Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ </Suspense>
+ <Suspense
fallback={
<DataTableSkeleton
columnCount={8}
@@ -56,7 +61,7 @@ export default async function VendorPONew(props: VendorPOPageProps) {
}
>
<ShiVendorPoTable promises={promises} />
- </React.Suspense>
+ </Suspense>
</Shell>
)
}
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/pq_new/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/pq_new/page.tsx
index 6a992ee5..e1fcd80d 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/pq_new/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/pq_new/page.tsx
@@ -8,12 +8,15 @@ import { searchParamsPQReviewCache } from "@/lib/pq/validations"
import { getPQSubmissions } from "@/lib/pq/service"
import { PQSubmissionsTable } from "@/lib/pq/pq-review-table-new/vendors-table"
import { InformationButton } from "@/components/information/information-button"
+import { useTranslation } from "@/i18n"
+
export const metadata: Metadata = {
title: "협력업체 PQ/실사 현황",
description: "",
}
interface PQReviewPageProps {
+ params: Promise<{lng: string}>
searchParams: Promise<SearchParams>
}
@@ -21,6 +24,8 @@ export default async function PQReviewPage(props: PQReviewPageProps) {
const searchParams = await props.searchParams
const search = searchParamsPQReviewCache.parse(searchParams)
const validFilters = getValidFilters(search.filters)
+ const {lng} = await props.params
+ const {t} = await useTranslation(lng, 'menu')
// 디버깅 로그 추가
console.log("=== PQ Page Debug ===");
@@ -71,7 +76,7 @@ export default async function PQReviewPage(props: PQReviewPageProps) {
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- 협력업체 PQ/실사 현황
+ {t('menu.vendor_management.pq_status')}
</h2>
<InformationButton pagePath="evcp/pq_new" />
</div>
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/page.tsx
index 6830dbe9..223046b7 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/page.tsx
@@ -23,6 +23,7 @@ import { RfqTable } from "@/lib/rfq-last/table/rfq-table";
import { getRfqs } from "@/lib/rfq-last/service";
import { searchParamsRfqCache } from "@/lib/rfq-last/validations";
import { InformationButton } from "@/components/information/information-button";
+import { useTranslation } from "@/i18n"
export const metadata: Metadata = {
title: "RFQ 관리",
@@ -30,6 +31,7 @@ export const metadata: Metadata = {
};
interface RfqPageProps {
+ params: Promise<{lng: string}>
searchParams: Promise<SearchParams>;
}
@@ -63,6 +65,8 @@ async function getTabCounts() {
export default async function RfqPage(props: RfqPageProps) {
const searchParams = await props.searchParams;
+ const {lng} = await props.params
+ const {t} = await useTranslation(lng, 'menu')
// nuqs 기반 파라미터 파싱
const search = searchParamsRfqCache.parse(searchParams);
@@ -89,7 +93,7 @@ export default async function RfqPage(props: RfqPageProps) {
<div className="flex items-center justify-between space-y-2">
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- 견적목록관리
+ {t('menu.procurement.budget_rfq')}
</h2>
<InformationButton pagePath="evcp/rfq-last" />
</div>
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/risk-management/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/risk-management/page.tsx
index c372865e..d2334ba5 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/risk-management/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/risk-management/page.tsx
@@ -13,11 +13,13 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Suspense } from 'react';
import { type DateRange } from 'react-day-picker';
import { type SearchParams } from '@/types/table';
+import { useTranslation } from "@/i18n"
// ----------------------------------------------------------------------------------------------------
/* TYPES */
interface RiskManagementPageProps {
+ params: Promise<{lng: string}>
searchParams: Promise<SearchParams>;
}
@@ -29,6 +31,8 @@ async function RiskManagementPage(props: RiskManagementPageProps) {
const searchParamsResult = await searchParams;
const search = searchParamsCache.parse(searchParamsResult);
const validFilters = getValidFilters(search.filters);
+ const {lng} = await props.params
+ const {t} = await useTranslation(lng, 'menu')
const promises = Promise.all([
getRisksView({
...search,
@@ -52,12 +56,12 @@ async function RiskManagementPage(props: RiskManagementPageProps) {
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- 협력업체 리스크 관리
+ {t('menu.vendor_management.risk_by_agency')}
</h2>
<InformationButton pagePath="evcp/risk-management" />
</div>
<p className="text-muted-foreground">
- 신용평가사 정보를 기반으로 국내 협력업체 리스크를 관리할 수 있습니다.
+ {t('menu.vendor_management.risk_by_agency_desc')}
</p>
</div>
</div>
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/tbe-last/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/tbe-last/page.tsx
index 61e7ce05..81d2af51 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/tbe-last/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/tbe-last/page.tsx
@@ -10,17 +10,16 @@ import { Shell } from "@/components/shell"
import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
import { Button } from "@/components/ui/button"
import { Plus } from "lucide-react"
+import { useTranslation } from "@/i18n"
interface TbeLastPageProps {
- params: {
- lng: string
- }
+ params: Promise<{ lng: string }>
searchParams: Promise<SearchParams>
}
export default async function TbeLastPage(props: TbeLastPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
+ const { lng } = await props.params
+ const { t } = await useTranslation(lng, 'menu')
const searchParams = await props.searchParams
@@ -41,10 +40,10 @@ export default async function TbeLastPage(props: TbeLastPageProps) {
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">
- Technical Bid Evaluation (TBE)
+ {t('menu.engineering_management.tbe')}
</h2>
<p className="text-muted-foreground">
- RFQ 발송 후 기술 평가를 진행하고 문서를 검토합니다.
+ {t('menu.engineering_management.tbe_desc')}
</p>
</div>
@@ -71,4 +70,4 @@ export default async function TbeLastPage(props: TbeLastPageProps) {
export const metadata = {
title: "TBE Management",
description: "Technical Bid Evaluation for RFQ responses",
-} \ No newline at end of file
+}
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/vendor-candidates/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/vendor-candidates/page.tsx
index be6debce..04df8e6b 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/vendor-candidates/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/vendor-candidates/page.tsx
@@ -11,13 +11,17 @@ import { searchParamsCandidateCache } from "@/lib/vendor-candidates/validations"
import { VendorCandidateTable } from "@/lib/vendor-candidates/table/candidates-table"
import { DateRangePicker } from "@/components/date-range-picker"
import { InformationButton } from "@/components/information/information-button"
+import { useTranslation } from "@/i18n"
interface IndexPageProps {
+ params: Promise<{lng: string}>
searchParams: Promise<SearchParams>
}
export default async function IndexPage(props: IndexPageProps) {
const searchParams = await props.searchParams
const search = searchParamsCandidateCache.parse(searchParams)
+ const {lng} = await props.params
+ const {t} = await useTranslation(lng, 'menu')
const validFilters = getValidFilters(search.filters)
@@ -37,7 +41,7 @@ export default async function IndexPage(props: IndexPageProps) {
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- 발굴업체 등록 관리
+ {t('menu.vendor_management.candidates')}
</h2>
<InformationButton pagePath="evcp/vendor-candidates" />
</div>
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/vendor-investigation/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/vendor-investigation/page.tsx
index 1d7786a5..799c3b5a 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/vendor-investigation/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/vendor-investigation/page.tsx
@@ -10,13 +10,17 @@ import { VendorsInvestigationTable } from "@/lib/vendor-investigation/table/inve
import { getVendorsInvestigation } from "@/lib/vendor-investigation/service"
import { searchParamsInvestigationCache } from "@/lib/vendor-investigation/validations"
import { InformationButton } from "@/components/information/information-button"
+import { useTranslation } from "@/i18n"
interface IndexPageProps {
searchParams: Promise<SearchParams>
+ params: Promise<{ lng: string }>
}
export default async function IndexPage(props: IndexPageProps) {
const searchParams = await props.searchParams
const search = searchParamsInvestigationCache.parse(searchParams)
+ const { lng } = await props.params
+ const { t } = await useTranslation(lng, 'menu')
const validFilters = getValidFilters(search.filters)
@@ -35,7 +39,7 @@ export default async function IndexPage(props: IndexPageProps) {
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- 협력업체 실사 관리
+ {t('menu.vendor_management.investigation')}
</h2>
<InformationButton pagePath="evcp/vendor-investigation" />
</div>
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/vendor-pool/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/vendor-pool/page.tsx
index f18716a3..362974df 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/vendor-pool/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/vendor-pool/page.tsx
@@ -7,10 +7,16 @@ import { VendorPoolVirtualTable } from "@/lib/vendor-pool/table/vendor-pool-virt
import { Skeleton } from "@/components/ui/skeleton"
import type { VendorPoolItem } from "@/lib/vendor-pool/table/vendor-pool-table-columns"
import { toast } from "sonner"
+import { useTranslation } from "@/i18n/client"
+import { useParams } from "next/navigation"
+
export default function VendorPoolPage() {
const [data, setData] = React.useState<VendorPoolItem[]>([])
const [isLoading, setIsLoading] = React.useState(true)
+ const params = useParams<{lng: string}>()
+ const lng = params?.lng ?? 'ko'
+ const {t} = useTranslation(lng, 'menu')
// 전체 데이터 로드
const loadData = React.useCallback(async () => {
@@ -52,7 +58,7 @@ export default function VendorPoolPage() {
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- Vendor Pool
+ {t('menu.vendor_management.vendor_pool')}
</h2>
<InformationButton pagePath="evcp/vendor-pool" />
</div>
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/vendor-regular-registrations/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/vendor-regular-registrations/page.tsx
index e8433c55..4915edcd 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/vendor-regular-registrations/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/vendor-regular-registrations/page.tsx
@@ -10,13 +10,15 @@ import { Shell } from "@/components/shell"
import { fetchVendorRegularRegistrations } from "@/lib/vendor-regular-registrations/service"
import { VendorRegularRegistrationsTable } from "@/lib/vendor-regular-registrations/table/vendor-regular-registrations-table"
import { InformationButton } from "@/components/information/information-button"
+import { useTranslation } from "@/i18n"
-
-export default async function VendorRegularRegistrationsPage() {
+export default async function VendorRegularRegistrationsPage(props: {params: Promise<{lng: string}>}) {
const promises = Promise.all([
fetchVendorRegularRegistrations(),
])
+ const {lng} = await props.params
+ const {t} = await useTranslation(lng, 'menu')
return (
<Shell className="gap-2">
@@ -25,7 +27,7 @@ export default async function VendorRegularRegistrationsPage() {
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- 정규업체 등록관리
+ {t('menu.vendor_management.vendor_regular_registrations')}
</h2>
<InformationButton pagePath="evcp/vendor-regular-registrations" />
</div>
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/vendors/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/vendors/page.tsx
index fb7bb14c..4a071ee0 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/vendors/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/vendors/page.tsx
@@ -12,13 +12,17 @@ import { getVendors, getVendorStatusCounts } from "@/lib/vendors/service"
import { VendorsTable } from "@/lib/vendors/table/vendors-table"
import { Ellipsis } from "lucide-react"
import { InformationButton } from "@/components/information/information-button"
+import { useTranslation } from "@/i18n"
interface IndexPageProps {
+ params: Promise<{lng: string}>
searchParams: Promise<SearchParams>
}
export default async function IndexPage(props: IndexPageProps) {
const searchParams = await props.searchParams
const search = searchParamsCache.parse(searchParams)
+ const {lng} = await props.params
+ const {t} = await useTranslation(lng, 'menu')
const validFilters = getValidFilters(search.filters)
@@ -38,7 +42,7 @@ export default async function IndexPage(props: IndexPageProps) {
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- 협력업체 관리
+ {t('menu.vendor_management.vendors')}
</h2>
<InformationButton pagePath="evcp/vendors" />
</div>
diff --git a/app/[lng]/evcp/(evcp)/(sales)/budgetary-tech-sales-hull/page.tsx b/app/[lng]/evcp/(evcp)/(sales)/budgetary-tech-sales-hull/page.tsx
index 97e53567..2ca48091 100644
--- a/app/[lng]/evcp/(evcp)/(sales)/budgetary-tech-sales-hull/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(sales)/budgetary-tech-sales-hull/page.tsx
@@ -7,14 +7,18 @@ import { RFQListTable } from "@/lib/techsales-rfq/table/rfq-table"
import { type SearchParams } from "@/types/table"
import * as React from "react"
import { InformationButton } from "@/components/information/information-button"
+import { useTranslation } from "@/i18n"
interface HullRfqPageProps {
+ params: Promise<{lng: string}>
searchParams: Promise<SearchParams>
}
export default async function HullRfqPage(props: HullRfqPageProps) {
// searchParams를 await하여 resolve
const searchParams = await props.searchParams
-
+ const {lng} = await props.params
+ const {t} = await useTranslation(lng, 'menu')
+
// 해양 HULL용 파라미터 파싱
const search = searchParamsHullCache.parse(searchParams);
const validFilters = getValidFilters(search.filters);
@@ -35,7 +39,7 @@ export default async function HullRfqPage(props: HullRfqPageProps) {
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- 기술영업-해양 Hull Budgetary RFQ
+ {t('menu.tech_sales.budgetary_hull')}
</h2>
<InformationButton pagePath="evcp/budgetary-tech-sales-hull" />
</div>
diff --git a/app/[lng]/evcp/(evcp)/(sales)/budgetary-tech-sales-ship/page.tsx b/app/[lng]/evcp/(evcp)/(sales)/budgetary-tech-sales-ship/page.tsx
index 779b9ac9..0791a9bd 100644
--- a/app/[lng]/evcp/(evcp)/(sales)/budgetary-tech-sales-ship/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(sales)/budgetary-tech-sales-ship/page.tsx
@@ -7,14 +7,18 @@ import { RFQListTable } from "@/lib/techsales-rfq/table/rfq-table"
import { type SearchParams } from "@/types/table"
import * as React from "react"
import { InformationButton } from "@/components/information/information-button"
+import { useTranslation } from "@/i18n"
interface RfqPageProps {
+ params: Promise<{lng: string}>
searchParams: Promise<SearchParams>
}
export default async function RfqPage(props: RfqPageProps) {
// searchParams를 await하여 resolve
const searchParams = await props.searchParams
-
+ const {lng} = await props.params
+ const {t} = await useTranslation(lng, 'menu')
+
// 조선용 파라미터 파싱
const search = searchParamsShipCache.parse(searchParams);
const validFilters = getValidFilters(search.filters);
@@ -35,7 +39,7 @@ export default async function RfqPage(props: RfqPageProps) {
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- 기술영업-조선 Budgetary RFQ
+ {t('menu.tech_sales.budgetary_ship')}
</h2>
<InformationButton pagePath="evcp/budgetary-tech-sales-ship" />
</div>
diff --git a/app/[lng]/evcp/(evcp)/(sales)/budgetary-tech-sales-top/page.tsx b/app/[lng]/evcp/(evcp)/(sales)/budgetary-tech-sales-top/page.tsx
index 5c96c85d..0949d9a0 100644
--- a/app/[lng]/evcp/(evcp)/(sales)/budgetary-tech-sales-top/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(sales)/budgetary-tech-sales-top/page.tsx
@@ -7,14 +7,17 @@ import { RFQListTable } from "@/lib/techsales-rfq/table/rfq-table"
import { type SearchParams } from "@/types/table"
import * as React from "react"
import { InformationButton } from "@/components/information/information-button"
+import { useTranslation } from "@/i18n"
interface HullRfqPageProps {
+ params: Promise<{lng: string}>
searchParams: Promise<SearchParams>
}
export default async function HullRfqPage(props: HullRfqPageProps) {
// searchParams를 await하여 resolve
const searchParams = await props.searchParams
-
+ const {lng} = await props.params
+ const {t} = await useTranslation(lng, 'menu')
// 해양 TOP용 파라미터 파싱
const search = searchParamsTopCache.parse(searchParams);
const validFilters = getValidFilters(search.filters);
@@ -35,7 +38,7 @@ export default async function HullRfqPage(props: HullRfqPageProps) {
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- 기술영업-해양 TOP Budgetary RFQ
+ {t('menu.tech_sales.budgetary_top')}
</h2>
<InformationButton pagePath="evcp/budgetary-tech-sales-top" />
</div>
diff --git a/app/[lng]/evcp/(evcp)/(sales)/tech-contact-possible-items/page.tsx b/app/[lng]/evcp/(evcp)/(sales)/tech-contact-possible-items/page.tsx
index cf35530d..ad7c7b36 100644
--- a/app/[lng]/evcp/(evcp)/(sales)/tech-contact-possible-items/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(sales)/tech-contact-possible-items/page.tsx
@@ -6,17 +6,22 @@ import { searchParamsCache } from "@/lib/contact-possible-items/validations"
import { ContactPossibleItemsTable } from "@/lib/contact-possible-items/table/contact-possible-items-table"
import { getValidFilters } from "@/lib/data-table"
import { type SearchParams } from "@/types/table"
+import { useTranslation } from "@/i18n"
interface ContactPossibleItemsPageProps {
+ params: Promise<{lng: string}>
searchParams: Promise<SearchParams>
}
export default async function ContactPossibleItemsPage({
+ params,
searchParams,
}: ContactPossibleItemsPageProps) {
// ✅ searchParams 파싱
const resolvedSearchParams = await searchParams
const search = searchParamsCache.parse(resolvedSearchParams)
+ const {lng} = await params
+ const {t} = await useTranslation(lng, 'menu')
console.log("Parsed search params:", search)
@@ -39,10 +44,10 @@ export default async function ContactPossibleItemsPage({
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">
- 담당자별 자재 관리
+ {t('menu.tech_sales.contact_items')}
</h2>
<p className="text-muted-foreground">
- 기술영업 담당자별 자재를 관리합니다.
+ {t('menu.tech_sales.contact_items_desc')}
</p>
</div>
</div>
diff --git a/app/[lng]/evcp/(evcp)/(sales)/tech-project-avl/page.tsx b/app/[lng]/evcp/(evcp)/(sales)/tech-project-avl/page.tsx
index 4ce018cd..c9ec6075 100644
--- a/app/[lng]/evcp/(evcp)/(sales)/tech-project-avl/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(sales)/tech-project-avl/page.tsx
@@ -12,6 +12,7 @@ import { getValidFilters } from "@/lib/data-table"
import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
import { Ellipsis } from "lucide-react"
import { InformationButton } from "@/components/information/information-button"
+import { useTranslation } from "@/i18n"
export interface PageProps {
params: Promise<{ lng: string }>
searchParams: Promise<SearchParams>
@@ -22,6 +23,7 @@ export default async function AcceptedQuotationsPage({
searchParams,
}: PageProps) {
const { lng } = await params
+ const {t} = await useTranslation(lng, 'menu')
const session = await getServerSession(authOptions)
if (!session) {
@@ -47,7 +49,7 @@ export default async function AcceptedQuotationsPage({
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- 견적 Result 전송
+ {t('menu.tech_sales.result_transmission')}
</h2>
<InformationButton pagePath="evcp/tech-project-avl" />
</div>
diff --git a/app/[lng]/evcp/(evcp)/(sales)/tech-vendors/page.tsx b/app/[lng]/evcp/(evcp)/(sales)/tech-vendors/page.tsx
index 736a7bad..7475c274 100644
--- a/app/[lng]/evcp/(evcp)/(sales)/tech-vendors/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(sales)/tech-vendors/page.tsx
@@ -8,14 +8,18 @@ import { Shell } from "@/components/shell"
import { searchParamsCache } from "@/lib/tech-vendors/validations"
import { getTechVendors, getTechVendorStatusCounts } from "@/lib/tech-vendors/service"
import { TechVendorsTable } from "@/lib/tech-vendors/table/tech-vendors-table"
+import { useTranslation } from "@/i18n"
interface IndexPageProps {
+ params: Promise<{lng: string}>
searchParams: Promise<SearchParams>
}
export default async function IndexPage(props: IndexPageProps) {
const searchParams = await props.searchParams
const search = searchParamsCache.parse(searchParams)
+ const {lng} = await props.params
+ const {t} = await useTranslation(lng, 'menu')
const validFilters = getValidFilters(search.filters)
@@ -33,7 +37,7 @@ export default async function IndexPage(props: IndexPageProps) {
{/* 왼쪽: 타이틀 & 설명 */}
<div>
<div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">기술영업 협력업체 관리</h2>
+ <h2 className="text-2xl font-bold tracking-tight">{t('menu.tech_sales.vendors')}</h2>
{/* InformationButton은 필요시 추가 */}
{/* <InformationButton pagePath="evcp/tech-vendors" /> */}
</div>
diff --git a/app/[lng]/evcp/(evcp)/(system)/approval/line/page.tsx b/app/[lng]/evcp/(evcp)/(system)/approval/line/page.tsx
index 2e96b434..0e227e0b 100644
--- a/app/[lng]/evcp/(evcp)/(system)/approval/line/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(system)/approval/line/page.tsx
@@ -9,16 +9,21 @@ import { getApprovalLineList } from '@/lib/approval-line/service';
import { SearchParamsApprovalLineCache } from '@/lib/approval-line/validations';
import { ApprovalLineTable } from '@/lib/approval-line/table/approval-line-table';
+import { useTranslation } from "@/i18n";
+
export const metadata: Metadata = {
title: '결재선 관리',
description: '결재용 결재선을 관리합니다.',
};
interface PageProps {
+ params: Promise<{ lng: string }>;
searchParams: Promise<SearchParams>;
}
export default async function ApprovalLinePage(props: PageProps) {
+ const { lng } = await props.params
+ const { t } = await useTranslation(lng, 'menu')
const searchParams = await props.searchParams;
const search = SearchParamsApprovalLineCache.parse(searchParams);
// getValidFilters 반환값이 undefined 인 경우 폴백
@@ -37,7 +42,7 @@ export default async function ApprovalLinePage(props: PageProps) {
<div className="flex items-center justify-between space-y-2">
<div>
<div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">결재선 관리</h2>
+ <h2 className="text-2xl font-bold tracking-tight">{t('menu.information_system.approval_line')}</h2>
</div>
</div>
</div>
diff --git a/app/[lng]/evcp/(evcp)/(system)/approval/log/page.tsx b/app/[lng]/evcp/(evcp)/(system)/approval/log/page.tsx
index f5b069df..d6ab6282 100644
--- a/app/[lng]/evcp/(evcp)/(system)/approval/log/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(system)/approval/log/page.tsx
@@ -5,8 +5,16 @@ import { Skeleton } from "@/components/ui/skeleton";
import { ApprovalLogTable } from "@/lib/approval-log/table/approval-log-table";
import { getApprovalLogList } from "@/lib/approval-log/service";
import React from "react";
+import { useTranslation } from "@/i18n";
+
+interface approvalLogPageProps {
+ params: Promise<{ lng: string }>
+}
+
+export default async function ApprovalLogPage({ params }: approvalLogPageProps) {
+ const { lng } = await params
+ const { t } = await useTranslation(lng, 'menu')
-export default async function ApprovalLogPage() {
// 기본 데이터 조회 (첫 페이지, 기본 정렬)
const promises = Promise.all([
getApprovalLogList({
@@ -23,7 +31,7 @@ export default async function ApprovalLogPage() {
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- 결재 로그
+ {t('menu.information_system.approval_log')}
</h2>
<InformationButton pagePath="evcp/approval/log" />
</div>
@@ -46,4 +54,4 @@ export default async function ApprovalLogPage() {
</React.Suspense>
</Shell>
)
-} \ No newline at end of file
+}
diff --git a/app/[lng]/evcp/(evcp)/(system)/approval/template/page.tsx b/app/[lng]/evcp/(evcp)/(system)/approval/template/page.tsx
index c5834b05..91118d90 100644
--- a/app/[lng]/evcp/(evcp)/(system)/approval/template/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(system)/approval/template/page.tsx
@@ -8,6 +8,7 @@ import { getValidFilters } from '@/lib/data-table';
import { getApprovalTemplateList } from '@/lib/approval-template/service';
import { SearchParamsApprovalTemplateCache } from '@/lib/approval-template/validations';
import { ApprovalTemplateTable } from '@/lib/approval-template/table/approval-template-table';
+import { useTranslation } from "@/i18n";
export const metadata: Metadata = {
title: '결재 템플릿 관리',
@@ -15,10 +16,13 @@ export const metadata: Metadata = {
};
interface PageProps {
+ params: Promise<{ lng: string }>
searchParams: Promise<SearchParams>;
}
export default async function ApprovalTemplatePage(props: PageProps) {
+ const { lng } = await props.params
+ const { t } = await useTranslation(lng, 'menu')
const searchParams = await props.searchParams;
const search = SearchParamsApprovalTemplateCache.parse(searchParams);
// getValidFilters 반환값이 undefined 인 경우 폴백
@@ -37,7 +41,7 @@ export default async function ApprovalTemplatePage(props: PageProps) {
<div className="flex items-center justify-between space-y-2">
<div>
<div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">결재 템플릿 관리</h2>
+ <h2 className="text-2xl font-bold tracking-tight">{t('menu.information_system.approval_template')}</h2>
</div>
</div>
</div>
@@ -66,4 +70,4 @@ export default async function ApprovalTemplatePage(props: PageProps) {
</React.Suspense>
</Shell>
);
-} \ No newline at end of file
+}
diff --git a/app/[lng]/evcp/(evcp)/(system)/change-vendor/page.tsx b/app/[lng]/evcp/(evcp)/(system)/change-vendor/page.tsx
index 4b4b0a8d..8b4ba5b5 100644
--- a/app/[lng]/evcp/(evcp)/(system)/change-vendor/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(system)/change-vendor/page.tsx
@@ -2,6 +2,11 @@ import * as React from 'react';
import { type Metadata } from 'next';
import { Shell } from '@/components/shell';
import { ChangeVendorClient } from './change-vendor-client';
+import { useTranslation } from "@/i18n";
+
+interface changeVendorPageProps {
+ params: Promise<{ lng: string }>
+}
export const metadata: Metadata = {
title: '벤더 변경',
@@ -10,15 +15,20 @@ export const metadata: Metadata = {
export const dynamic = 'force-dynamic';
-export default async function ChangeVendorPage() {
+export default async function ChangeVendorPage({ params }: changeVendorPageProps) {
+ const { lng } = await params
+ const { t } = await useTranslation(lng, 'menu')
+
return (
<Shell className="gap-6">
<div className="flex items-center justify-between space-y-2">
<div>
- <h2 className="text-2xl font-bold tracking-tight">벤더 변경</h2>
- <p className="text-muted-foreground">
- 유저를 검색하고 선택한 후, 해당 유저의 벤더를 변경할 수 있습니다.
- </p>
+ <h2 className="text-2xl font-bold tracking-tight">
+ {t('menu.information_system.change_vendor')}
+ </h2>
+ {/* <p className="text-muted-foreground"> */}
+ {/* 유저를 검색하고 선택한 후, 해당 유저의 벤더를 변경할 수 있습니다. */}
+ {/* </p> */}
</div>
</div>
diff --git a/app/[lng]/evcp/(evcp)/(system)/email-log/page.tsx b/app/[lng]/evcp/(evcp)/(system)/email-log/page.tsx
index 41001cc7..07699daf 100644
--- a/app/[lng]/evcp/(evcp)/(system)/email-log/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(system)/email-log/page.tsx
@@ -7,6 +7,7 @@ import { SearchParams } from "@/types/table"
import { SearchParamsEmailLogCache } from "@/lib/email-log/validations"
import { getEmailLogList } from "@/lib/email-log/service"
import { EmailLogTable } from "@/lib/email-log/table/email-log-table"
+import { useTranslation } from "@/i18n"
export const metadata: Metadata = {
title: "이메일 발신 이력 조회",
@@ -14,10 +15,13 @@ export const metadata: Metadata = {
}
interface EmailLogPageProps {
+ params: Promise<{ lng: string }>
searchParams: Promise<SearchParams>
}
export default async function EmailLogPage(props: EmailLogPageProps) {
+ const { lng } = await props.params
+ const { t } = await useTranslation(lng, 'menu')
const searchParams = await props.searchParams
const search = SearchParamsEmailLogCache.parse(searchParams)
@@ -31,7 +35,7 @@ export default async function EmailLogPage(props: EmailLogPageProps) {
<div className="flex items-center justify-between space-y-2">
<div>
<div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">이메일 발신 이력 조회</h2>
+ <h2 className="text-2xl font-bold tracking-tight">{t('menu.information_system.email_log')}</h2>
</div>
</div>
</div>
diff --git a/app/[lng]/evcp/(evcp)/(system)/email-template/page.tsx b/app/[lng]/evcp/(evcp)/(system)/email-template/page.tsx
index 16c75dab..c4437994 100644
--- a/app/[lng]/evcp/(evcp)/(system)/email-template/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(system)/email-template/page.tsx
@@ -15,6 +15,7 @@ import { Shell } from "@/components/shell"
import { getValidFilters } from "@/lib/data-table"
import { Skeleton } from "@/components/ui/skeleton"
import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { useTranslation } from "@/i18n"
export const metadata: Metadata = {
title: "템플릿 관리",
@@ -22,10 +23,14 @@ export const metadata: Metadata = {
}
interface TemplatePageProps {
+ params: Promise<{ lng: string }>
searchParams: Promise<SearchParams>
}
export default async function TemplatePage(props: TemplatePageProps) {
+ const { lng } = await props.params
+ const { t } = await useTranslation(lng, 'menu')
+
const searchParams = await props.searchParams
const search = SearchParamsEmailTemplateCache.parse(searchParams)
@@ -48,7 +53,7 @@ export default async function TemplatePage(props: TemplatePageProps) {
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- 이메일 템플릿 관리
+ {t('menu.information_system.email_template')}
</h2>
{/* <InformationButton pagePath="evcp/equip-class" /> */}
</div>
@@ -73,4 +78,4 @@ export default async function TemplatePage(props: TemplatePageProps) {
</React.Suspense>
</Shell>
)
-} \ No newline at end of file
+}
diff --git a/app/[lng]/evcp/(evcp)/(system)/email-whitelist/page.tsx b/app/[lng]/evcp/(evcp)/(system)/email-whitelist/page.tsx
index 95abd556..2613ce65 100644
--- a/app/[lng]/evcp/(evcp)/(system)/email-whitelist/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(system)/email-whitelist/page.tsx
@@ -17,6 +17,7 @@ import { WhitelistTable } from "@/lib/email-whitelist/table/whitelist-table"
import { Shell } from "@/components/shell"
import { Skeleton } from "@/components/ui/skeleton"
import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { useTranslation } from "@/i18n"
// export const metadata: Metadata = {
// title: "이메일 화이트리스트 관리",
@@ -24,11 +25,13 @@ import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
// }
interface WhitelistPageProps {
+ params: Promise<{ lng: string }>
searchParams: SearchParams
}
export default async function WhitelistPage(props: WhitelistPageProps) {
-
+ const { lng } = await props.params
+ const { t } = await useTranslation(lng, 'menu')
const searchParams = await props.searchParams
// 기본 검색 파라미터 처리
@@ -52,7 +55,7 @@ export default async function WhitelistPage(props: WhitelistPageProps) {
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- 이메일 화이트리스트 관리
+ {t('menu.information_system.email_whitelist')}
</h2>
</div>
<p className="text-muted-foreground">
@@ -79,4 +82,4 @@ export default async function WhitelistPage(props: WhitelistPageProps) {
</React.Suspense>
</Shell>
)
-} \ No newline at end of file
+}
diff --git a/app/[lng]/evcp/(evcp)/(system)/information/page.tsx b/app/[lng]/evcp/(evcp)/(system)/information/page.tsx
index 8a6d348b..ff8c7024 100644
--- a/app/[lng]/evcp/(evcp)/(system)/information/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(system)/information/page.tsx
@@ -39,7 +39,7 @@ export default async function InformationPage({ params }: InformationPageProps)
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- 안내사항 관리
+ {t('menu.information_system.information')}
</h2>
<InformationButton pagePath="/evcp/information" />
</div>
@@ -49,4 +49,4 @@ export default async function InformationPage({ params }: InformationPageProps)
<InformationClient initialData={translatedData} />
</Shell>
)
-} \ No newline at end of file
+}
diff --git a/app/[lng]/evcp/(evcp)/(system)/integration-log/page.tsx b/app/[lng]/evcp/(evcp)/(system)/integration-log/page.tsx
index c10a41ea..75796b98 100644
--- a/app/[lng]/evcp/(evcp)/(system)/integration-log/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(system)/integration-log/page.tsx
@@ -7,12 +7,16 @@ import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton";
import { SearchParamsCache } from "@/lib/integration-log/validations";
import { getIntegrationLogs } from "@/lib/integration-log/service";
import { IntegrationLogTable } from "@/lib/integration-log/table/integration-log-table";
+import { useTranslation } from "@/i18n";
interface IntegrationLogPageProps {
+ params: Promise<{ lng: string }>
searchParams: Promise<SearchParams>;
}
export default async function IntegrationLogPage(props: IntegrationLogPageProps) {
+ const { lng } = await props.params
+ const { t } = await useTranslation(lng, 'menu')
const searchParams = await props.searchParams;
const search = SearchParamsCache.parse(searchParams);
const validFilters = getValidFilters(search.filters);
@@ -28,10 +32,9 @@ export default async function IntegrationLogPage(props: IntegrationLogPageProps)
<Shell className="gap-2">
<div className="flex items-center justify-between space-y-2">
<div>
- <h2 className="text-2xl font-bold tracking-tight">인터페이스 이력 조회</h2>
- <p className="text-muted-foreground">
- 인터페이스 실행 이력을 조회합니다. 검색, 필터링, 정렬이 가능합니다.
- </p>
+ <h2 className="text-2xl font-bold tracking-tight">
+ {t('menu.information_system.integration_log')}
+ </h2>
</div>
</div>
<React.Suspense fallback={<Skeleton className="h-7 w-52" />}></React.Suspense>
@@ -50,4 +53,4 @@ export default async function IntegrationLogPage(props: IntegrationLogPageProps)
</React.Suspense>
</Shell>
);
-} \ No newline at end of file
+}
diff --git a/app/[lng]/evcp/(evcp)/(system)/integration/page.tsx b/app/[lng]/evcp/(evcp)/(system)/integration/page.tsx
index f2266bca..6aeaf041 100644
--- a/app/[lng]/evcp/(evcp)/(system)/integration/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(system)/integration/page.tsx
@@ -7,12 +7,16 @@ import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton";
import { SearchParamsCache } from "@/lib/integration/validations";
import { getIntegrations } from "@/lib/integration/service";
import { IntegrationTable } from "@/lib/integration/table/integration-table";
+import { useTranslation } from "@/i18n";
interface IndexPageProps {
+ params: Promise<{ lng: string }>
searchParams: Promise<SearchParams>;
}
export default async function IndexPage(props: IndexPageProps) {
+ const { lng } = await props.params
+ const { t } = await useTranslation(lng, 'menu')
const searchParams = await props.searchParams;
const search = SearchParamsCache.parse(searchParams);
const validFilters = getValidFilters(search.filters);
@@ -28,10 +32,9 @@ export default async function IndexPage(props: IndexPageProps) {
<Shell className="gap-2">
<div className="flex items-center justify-between space-y-2">
<div>
- <h2 className="text-2xl font-bold tracking-tight">인터페이스 관리</h2>
- <p className="text-muted-foreground">
- 시스템 인터페이스를 등록, 수정, 삭제할 수 있습니다.
- </p>
+ <h2 className="text-2xl font-bold tracking-tight">
+ {t('menu.information_system.integration_list')}
+ </h2>
</div>
</div>
<React.Suspense fallback={<Skeleton className="h-7 w-52" />}></React.Suspense>
@@ -50,4 +53,4 @@ export default async function IndexPage(props: IndexPageProps) {
</React.Suspense>
</Shell>
);
-} \ No newline at end of file
+}
diff --git a/app/[lng]/evcp/(evcp)/(system)/login-history/page.tsx b/app/[lng]/evcp/(evcp)/(system)/login-history/page.tsx
index dbc8089f..3c3d9255 100644
--- a/app/[lng]/evcp/(evcp)/(system)/login-history/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(system)/login-history/page.tsx
@@ -9,12 +9,16 @@ import { Shell } from "@/components/shell"
import { getLoginSessions } from "@/lib/login-session/service"
import { searchParamsCache } from "@/lib/login-session/validation"
import { LoginSessionsTable } from "@/lib/login-session/table/login-sessions-table"
+import { useTranslation } from "@/i18n"
interface LoginHistoryPageProps {
+ params: Promise<{ lng: string }>
searchParams: Promise<SearchParams>
}
export default async function LoginHistoryPage(props: LoginHistoryPageProps) {
+ const { lng } = await props.params
+ const { t } = await useTranslation(lng, 'menu')
const searchParams = await props.searchParams
const search = searchParamsCache.parse(searchParams)
@@ -34,7 +38,7 @@ export default async function LoginHistoryPage(props: LoginHistoryPageProps) {
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- 로그인 세션 이력
+ {t('menu.information_system.login_history')}
</h2>
</div>
{/* <p className="text-muted-foreground">
@@ -63,4 +67,4 @@ export default async function LoginHistoryPage(props: LoginHistoryPageProps) {
</React.Suspense>
</Shell>
)
-} \ No newline at end of file
+}
diff --git a/app/[lng]/evcp/(evcp)/(system)/menu-access-dept/page.tsx b/app/[lng]/evcp/(evcp)/(system)/menu-access-dept/page.tsx
index dfda9172..25c27ada 100644
--- a/app/[lng]/evcp/(evcp)/(system)/menu-access-dept/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(system)/menu-access-dept/page.tsx
@@ -3,8 +3,16 @@ import { Separator } from "@/components/ui/separator";
import { Shell } from "@/components/shell";
import { DepartmentMenuAccessManager } from "./_components/department-menu-access-manager";
import { getAllDepartmentsTree, getCurrentCompanyInfo } from "@/lib/users/knox-service";
+import { useTranslation } from "@/i18n";
+
+interface menuAccessDeptPageProps {
+ params: Promise<{ lng: string }>
+}
+
+export default async function DepartmentMenuAccessPage({ params }: menuAccessDeptPageProps) {
+ const { lng } = await params
+ const { t } = await useTranslation(lng, 'menu')
-export default async function DepartmentMenuAccessPage() {
// Promise들을 생성하여 클라이언트 컴포넌트에 전달
const departmentsPromise = getAllDepartmentsTree();
const companyInfo = await getCurrentCompanyInfo();
@@ -14,11 +22,13 @@ export default async function DepartmentMenuAccessPage() {
<div className="space-y-6">
{/* 헤더 섹션 */}
<div className="space-y-2">
- <h1 className="text-2xl font-bold tracking-tight">부서별 메뉴 접근권한 관리</h1>
- <p className="text-muted-foreground">
- Knox 조직도를 기반으로 부서별 도메인을 할당하여 메뉴 접근 권한을 관리할 수 있습니다.
- 상위 부서를 선택하면 하위 부서들도 자동으로 포함됩니다.
- </p>
+ <h1 className="text-2xl font-bold tracking-tight">
+ {t('menu.information_system.menu_access_dept')}
+ </h1>
+ {/* <p className="text-muted-foreground"> */}
+ {/* Knox 조직도를 기반으로 부서별 도메인을 할당하여 메뉴 접근 권한을 관리할 수 있습니다. */}
+ {/* 상위 부서를 선택하면 하위 부서들도 자동으로 포함됩니다. */}
+ {/* </p> */}
</div>
<Separator />
diff --git a/app/[lng]/evcp/(evcp)/(system)/menu-access/page.tsx b/app/[lng]/evcp/(evcp)/(system)/menu-access/page.tsx
index 7f5228df..4fa712f1 100644
--- a/app/[lng]/evcp/(evcp)/(system)/menu-access/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(system)/menu-access/page.tsx
@@ -8,11 +8,15 @@ import { searchParamsUsersCache } from "@/lib/admin-users/validations"
import { getUsersNotPartners } from "@/lib/users/service";
import { UserAccessControlTable } from "@/lib/users/access-control/users-table";
import { InformationButton } from "@/components/information/information-button";
+import { useTranslation } from "@/i18n";
interface IndexPageProps {
+ params: Promise<{ lng: string }>
searchParams: Promise<SearchParams>;
}
export default async function IndexPage(props: IndexPageProps) {
+ const { lng } = await props.params
+ const { t } = await useTranslation(lng, 'menu')
const searchParams = await props.searchParams;
const search = searchParamsUsersCache.parse(searchParams);
const validFilters = getValidFilters(search.filters);
@@ -29,7 +33,9 @@ export default async function IndexPage(props: IndexPageProps) {
<div className="flex items-center justify-between space-y-2">
<div>
<div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">메뉴 접근제어 관리</h2>
+ <h2 className="text-2xl font-bold tracking-tight">
+ {t('menu.information_system.menu_access')}
+ </h2>
<InformationButton pagePath="evcp/menu-access" />
</div>
{/* <p className="text-muted-foreground">
@@ -53,4 +59,4 @@ export default async function IndexPage(props: IndexPageProps) {
</React.Suspense>
</Shell>
);
-} \ No newline at end of file
+}
diff --git a/app/[lng]/evcp/(evcp)/(system)/menu-list/page.tsx b/app/[lng]/evcp/(evcp)/(system)/menu-list/page.tsx
index 5a1f71a5..79923397 100644
--- a/app/[lng]/evcp/(evcp)/(system)/menu-list/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(system)/menu-list/page.tsx
@@ -1,91 +1,20 @@
-// app/evcp/menu-list/page.tsx
+import { MenuTreeManager } from "@/lib/menu-v2/components/menu-tree-manager";
-import { Suspense } from "react";
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
-import { Button } from "@/components/ui/button";
-import { RefreshCw, Settings } from "lucide-react";
-import { getActiveUsers, getMenuAssignments } from "@/lib/menu-list/servcie";
-import { InitializeButton } from "@/lib/menu-list/table/initialize-button";
-import { MenuListTable } from "@/lib/menu-list/table/menu-list-table";
-import { Shell } from "@/components/shell"
-import * as React from "react"
-import { InformationButton } from "@/components/information/information-button";
-import { useTranslation } from "@/i18n";
-interface MenuListPageProps {
- params: Promise<{ lng: string }>
+interface PageProps {
+ params: Promise<{ lng: string }>;
}
-export default async function MenuListPage({ params }: MenuListPageProps) {
- const { lng } = await params
- const { t } = await useTranslation(lng, 'menu')
+export default async function MenuV2Page({ params }: PageProps) {
+ const { lng } = await params;
- // 초기 데이터 로드
- const [menusResult, usersResult] = await Promise.all([
- getMenuAssignments(),
- getActiveUsers()
- ]);
-
- // 서버사이드에서 번역된 메뉴 데이터 생성
- const translatedMenus = menusResult.data?.map(menu => ({
- ...menu,
- sectionTitle: menu.sectionTitle || "",
- translatedMenuTitle: t(menu.menuTitle || ""),
- translatedSectionTitle: t(menu.sectionTitle || ""),
- translatedMenuGroup: menu.menuGroup ? t(menu.menuGroup) : null,
- translatedMenuDescription: menu.menuDescription ? t(menu.menuDescription) : null
- })) || [];
-
return (
- <Shell className="gap-2">
- <div className="flex items-center justify-between space-y-2">
- <div className="flex items-center justify-between space-y-2">
- <div>
- <div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">
- 메뉴 관리
- </h2>
- <InformationButton pagePath="evcp/menu-list" />
- </div>
- {/* <p className="text-muted-foreground">
- 각 메뉴별로 담당자를 지정하고 관리할 수 있습니다.
- </p> */}
- </div>
- </div>
-
+ <div className="container mx-auto py-6 space-y-6">
+ <div>
+ <h1 className="text-2xl font-bold tracking-tight">Menu Management</h1>
</div>
-
-
- <React.Suspense
- fallback={
- ""
- }
- >
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- <Settings className="h-5 w-5" />
- 메뉴 리스트
- </CardTitle>
- <CardDescription>
- 시스템의 모든 메뉴와 담당자 정보를 확인할 수 있습니다.
- {menusResult.data?.length > 0 && (
- <span className="ml-2 text-sm">
- 총 {menusResult.data.length}개의 메뉴
- </span>
- )}
- </CardDescription>
- </CardHeader>
- <CardContent>
- <Suspense fallback={<div className="text-center py-8">로딩 중...</div>}>
- <MenuListTable
- initialMenus={translatedMenus}
- initialUsers={usersResult.data || []}
- />
- </Suspense>
- </CardContent>
- </Card>
- </React.Suspense>
- </Shell>
-
+
+ <MenuTreeManager initialDomain="evcp" />
+ </div>
);
-} \ No newline at end of file
+}
+
diff --git a/app/[lng]/evcp/(evcp)/(system)/notice/page.tsx b/app/[lng]/evcp/(evcp)/(system)/notice/page.tsx
index a4157d1b..ab01edfa 100644
--- a/app/[lng]/evcp/(evcp)/(system)/notice/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(system)/notice/page.tsx
@@ -7,13 +7,21 @@ import { NoticeClient } from "@/components/notice/notice-client"
import { InformationButton } from "@/components/information/information-button"
import { getNoticeLists } from "@/lib/notice/service"
import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+import { useTranslation } from "@/i18n"
export const metadata: Metadata = {
title: "공지사항 관리",
description: "페이지별 공지사항을 관리합니다.",
}
-export default async function NoticePage() {
+interface noticePageProps {
+ params: Promise<{ lng: string }>
+}
+
+export default async function NoticePage({ params }: noticePageProps) {
+ const { lng } = await params
+ const { t } = await useTranslation(lng, 'menu')
+
noStore()
// 세션에서 사용자 ID 가져오기
@@ -47,7 +55,7 @@ export default async function NoticePage() {
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- 공지사항 관리
+ {t('menu.information_system.notice')}
</h2>
<InformationButton pagePath="evcp/notice" />
</div>
@@ -57,4 +65,4 @@ export default async function NoticePage() {
<NoticeClient initialData={initialData?.data || []} currentUserId={currentUserId} />
</Shell>
)
-} \ No newline at end of file
+}
diff --git a/app/[lng]/evcp/(evcp)/(system)/page-visits/page.tsx b/app/[lng]/evcp/(evcp)/(system)/page-visits/page.tsx
index 07275cad..6b8e4a2a 100644
--- a/app/[lng]/evcp/(evcp)/(system)/page-visits/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(system)/page-visits/page.tsx
@@ -8,12 +8,16 @@ import { Shell } from "@/components/shell"
import { getPageVisits } from "@/lib/page-visits/service"
import { searchParamsCache } from "@/lib/page-visits/validation"
import { PageVisitsTable } from "@/lib/page-visits/table/page-visits-table"
+import { useTranslation } from "@/i18n"
interface PageVisitsPageProps {
+ params: Promise<{ lng: string }>
searchParams: Promise<SearchParams>
}
export default async function PageVisitsPage(props: PageVisitsPageProps) {
+ const { lng } = await props.params
+ const { t } = await useTranslation(lng, 'menu')
const searchParams = await props.searchParams
const search = searchParamsCache.parse(searchParams)
@@ -33,7 +37,7 @@ export default async function PageVisitsPage(props: PageVisitsPageProps) {
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- 페이지 방문 이력
+ {t('menu.information_system.page_visits')}
</h2>
</div>
{/* <p className="text-muted-foreground">
@@ -58,4 +62,4 @@ export default async function PageVisitsPage(props: PageVisitsPageProps) {
</React.Suspense>
</Shell>
)
-} \ No newline at end of file
+}
diff --git a/app/[lng]/evcp/(evcp)/edp-progress/page.tsx b/app/[lng]/evcp/(evcp)/edp-progress/page.tsx
index fe040709..b205dd77 100644
--- a/app/[lng]/evcp/(evcp)/edp-progress/page.tsx
+++ b/app/[lng]/evcp/(evcp)/edp-progress/page.tsx
@@ -4,15 +4,21 @@ import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
import { Shell } from "@/components/shell"
import { InformationButton } from "@/components/information/information-button"
import { VendorFormStatusTable } from "@/components/form-data-stat/form-data-stat-table"
+import { useTranslation } from "@/i18n"
+interface edpProgressPageProps {
+ params: Promise<{ lng: string }>
+}
-export default async function IndexPage() {
+export default async function IndexPage(props: edpProgressPageProps) {
+ const { lng } = await props.params
+ const { t } = await useTranslation(lng, 'menu')
return (
<Shell className="gap-2">
<div className="flex items-center justify-between space-y-2">
<div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">벤더 데이터 진척도 현황</h2>
+ <h2 className="text-2xl font-bold tracking-tight">{t('menu.engineering_management.vendor_progress')}</h2>
<InformationButton pagePath="evcp/edp-progress" />
</div>
</div>
diff --git a/app/[lng]/evcp/(evcp)/layout.tsx b/app/[lng]/evcp/(evcp)/layout.tsx
index 7fe7f3e7..093d9301 100644
--- a/app/[lng]/evcp/(evcp)/layout.tsx
+++ b/app/[lng]/evcp/(evcp)/layout.tsx
@@ -1,5 +1,5 @@
import { ReactNode } from 'react';
-import { Header } from '@/components/layout/Header';
+import { HeaderV2 } from '@/components/layout/HeaderV2';
import { SiteFooter } from '@/components/layout/Footer';
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
@@ -12,12 +12,16 @@ export default async function EvcpLayout({ children }: { children: ReactNode })
let isAuthorized = true;
let authMessage = "";
- // Only check permission if user is logged in
- if (session?.user?.id) {
+ // Skip permission check if environment variable is set
+ const skipPermissionCheck = process.env.SKIP_ORACLE_PERMISSION_CHECK === 'true';
+
+ // Only check permission if user is logged in and check is not skipped
+ if (session?.user?.id && !skipPermissionCheck) {
try {
const result = await verifyNonsapPermission(
parseInt(session.user.id),
- ['SEARCH']
+ // ['SEARCH']
+ [] // 아무런 실제 권한이 없어도, 등록된 상태라면 화면에 'SEARCH' 권한이 있는것처럼 동작하게 해달라고 함. (김희은 프로)
);
isAuthorized = result.authorized;
authMessage = result.message || "";
@@ -33,8 +37,10 @@ export default async 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} />
+ <HeaderV2 />
+ {!skipPermissionCheck && (
+ <PermissionChecker authorized={isAuthorized} message={authMessage} />
+ )}
<main className="flex flex-1 flex-col">
<div className='container-wrapper'>
{children}
diff --git a/app/[lng]/partners/(partners)/basic-contract/page.tsx b/app/[lng]/partners/(partners)/basic-contract/page.tsx
index e2213c57..c651ee54 100644
--- a/app/[lng]/partners/(partners)/basic-contract/page.tsx
+++ b/app/[lng]/partners/(partners)/basic-contract/page.tsx
@@ -11,13 +11,15 @@ import { BasicContractsVendorTable } from "@/lib/basic-contract/vendor-table/bas
import { getServerSession } from "next-auth"
import { authOptions } from "@/app/api/auth/[...nextauth]/route"
import { InformationButton } from "@/components/information/information-button"
+import { useTranslation } from "@/i18n"
interface IndexPageProps {
+ params: Promise<{ lng: string }>
searchParams: Promise<SearchParams>
}
export default async function IndexPage(props: IndexPageProps) {
-
-
+ const { lng } = await props.params;
+ const { t } = await useTranslation(lng, 'menu')
const session = await getServerSession(authOptions)
const vendorId = session?.user.companyId
@@ -43,7 +45,7 @@ export default async function IndexPage(props: IndexPageProps) {
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- 기본계약서 서명 요청
+ {t('menu.vendor.procurement.basic_contract_sign')}
</h2>
<InformationButton pagePath="partners/basic-contract" />
</div>
@@ -77,4 +79,4 @@ export default async function IndexPage(props: IndexPageProps) {
</React.Suspense>
</Shell>
)
-} \ No newline at end of file
+}
diff --git a/app/[lng]/partners/(partners)/bid/page.tsx b/app/[lng]/partners/(partners)/bid/page.tsx
index a09dec72..87b52021 100644
--- a/app/[lng]/partners/(partners)/bid/page.tsx
+++ b/app/[lng]/partners/(partners)/bid/page.tsx
@@ -5,8 +5,15 @@ import { authOptions } from "@/app/api/auth/[...nextauth]/route"
import { getBiddingListForPartners } from '@/lib/bidding/detail/service'
import { Shell } from '@/components/shell'
import { DataTableSkeleton } from '@/components/data-table/data-table-skeleton'
+import { useTranslation } from "@/i18n"
-export default async function PartnersBidPage() {
+interface IndexPageProps {
+ params: Promise<{ lng: string }>
+}
+
+export default async function PartnersBidPage({ params }: IndexPageProps) {
+ const { lng } = await params;
+ const { t } = await useTranslation(lng, 'menu')
// 세션에서 companyId 가져오기
const session = await getServerSession(authOptions)
const companyId = session?.user?.companyId
@@ -31,9 +38,9 @@ export default async function PartnersBidPage() {
<div className="container mx-auto py-6 space-y-6">
<div className="flex items-center justify-between">
<div>
- <h1 className="text-3xl font-bold">입찰 참여</h1>
+ <h1 className="text-3xl font-bold">{t('menu.vendor.bidding.list')}</h1>
<p className="text-muted-foreground mt-2">
- 참여 가능한 입찰 목록을 확인하고 응찰하실 수 있습니다.
+ {t('menu.vendor.bidding.list_desc')}
</p>
</div>
</div>
diff --git a/app/[lng]/partners/(partners)/document-list-ship/page.tsx b/app/[lng]/partners/(partners)/document-list-ship/page.tsx
index 46800a77..d67e96df 100644
--- a/app/[lng]/partners/(partners)/document-list-ship/page.tsx
+++ b/app/[lng]/partners/(partners)/document-list-ship/page.tsx
@@ -3,6 +3,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import DolceUploadPageV2 from "./dolce-upload-page-v2";
import { Shell } from "@/components/shell";
+import { useTranslation } from "@/i18n"
// ============================================================================
// 로딩 스켈레톤
@@ -39,6 +40,7 @@ export default async function DolceUploadPageWrapper({
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const { lng } = await params;
+ const { t } = await useTranslation(lng, 'menu')
const resolvedParams = await searchParams;
return (
@@ -47,9 +49,7 @@ export default async function DolceUploadPageWrapper({
<div className="flex items-center justify-between flex-shrink-0">
<div>
<h2 className="text-2xl font-bold tracking-tight">
- {lng === "ko"
- ? "조선 도면 업로드"
- : "Shipbuilding Drawing Upload"}
+ {t('menu.vendor.engineering.document_list_ship')}
</h2>
</div>
</div>
diff --git a/app/[lng]/partners/(partners)/evaluation/page.tsx b/app/[lng]/partners/(partners)/evaluation/page.tsx
index be88ef3e..3680a826 100644
--- a/app/[lng]/partners/(partners)/evaluation/page.tsx
+++ b/app/[lng]/partners/(partners)/evaluation/page.tsx
@@ -13,11 +13,15 @@ import { getEvaluationSubmissions } from "@/lib/vendor-evaluation-submit/service
import { getEvaluationsSubmitSchema } from "@/lib/vendor-evaluation-submit/validation"
import { EvaluationSubmissionsTable } from "@/lib/vendor-evaluation-submit/table/submit-table"
import { InformationButton } from "@/components/information/information-button"
+import { useTranslation } from "@/i18n"
interface IndexPageProps {
+ params: Promise<{ lng: string }>
searchParams: Promise<SearchParams>
}
export default async function IndexPage(props: IndexPageProps) {
+ const { lng } = await props.params;
+ const { t } = await useTranslation(lng, 'menu')
const searchParams = await props.searchParams
const search = getEvaluationsSubmitSchema.parse(searchParams)
const validFilters = getValidFilters(search.filters)
@@ -34,7 +38,7 @@ export default async function IndexPage(props: IndexPageProps) {
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- 평가자료 입력
+ {t('menu.vendor.procurement.evaluation_input')}
</h2>
<InformationButton pagePath="partners/evaluation" />
</div>
@@ -105,7 +109,7 @@ export default async function IndexPage(props: IndexPageProps) {
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">
- 평가자료 입력
+ {t('menu.vendor.procurement.evaluation_input')}
</h2>
{/* <p className="text-muted-foreground">
요청된 정기평가를 입력하고 제출할 수 있습니다.
@@ -133,4 +137,4 @@ export default async function IndexPage(props: IndexPageProps) {
</React.Suspense>
</Shell>
)
-} \ No newline at end of file
+}
diff --git a/app/[lng]/partners/(partners)/general-contract-review/page.tsx b/app/[lng]/partners/(partners)/general-contract-review/page.tsx
index 27afd859..5af40906 100644
--- a/app/[lng]/partners/(partners)/general-contract-review/page.tsx
+++ b/app/[lng]/partners/(partners)/general-contract-review/page.tsx
@@ -6,10 +6,16 @@ import { getVendorContractReviews } from "@/lib/general-contracts/service"
import { VendorGeneralContractReviewTable } from "./vendor-general-contract-review-table"
import { InformationButton } from "@/components/information/information-button"
import { unstable_noStore as noStore } from 'next/cache'
+import { useTranslation } from "@/i18n"
-export default async function VendorGeneralContractReviewPage() {
+interface pageProps {
+ params: { lng: string }
+}
+
+export default async function VendorGeneralContractReviewPage({ params }: pageProps) {
noStore()
-
+ const { lng } = await params;
+ const { t } = await useTranslation(lng, 'menu')
const session = await getServerSession(authOptions)
if (!session?.user?.companyId) {
@@ -31,12 +37,12 @@ export default async function VendorGeneralContractReviewPage() {
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- 일반계약 조건검토
+ {t('menu.vendor.procurement.general_contract_review')}
</h2>
<InformationButton pagePath="partners/general-contract-review" />
</div>
<p className="text-muted-foreground">
- 조건검토 요청된 계약 목록을 확인하고 검토합니다.
+ {t('menu.vendor.procurement.general_contract_review_desc')}
</p>
</div>
</div>
diff --git a/app/[lng]/partners/(partners)/layout.tsx b/app/[lng]/partners/(partners)/layout.tsx
index 9dc39f7b..51a30028 100644
--- a/app/[lng]/partners/(partners)/layout.tsx
+++ b/app/[lng]/partners/(partners)/layout.tsx
@@ -1,11 +1,11 @@
import { ReactNode } from 'react';
-import { Header } from '@/components/layout/Header';
+import { HeaderV2 } from '@/components/layout/HeaderV2';
import { SiteFooter } from '@/components/layout/Footer';
export default function EvcpLayout({ children }: { children: ReactNode }) {
return (
<div className="relative flex min-h-svh flex-col bg-background">
- <Header />
+ <HeaderV2 />
<main className="flex flex-1 flex-col">
<div className='container-wrapper'>
{children}
diff --git a/app/[lng]/partners/(partners)/pcr/page.tsx b/app/[lng]/partners/(partners)/pcr/page.tsx
index dc639aa8..7148aedd 100644
--- a/app/[lng]/partners/(partners)/pcr/page.tsx
+++ b/app/[lng]/partners/(partners)/pcr/page.tsx
@@ -5,7 +5,7 @@ import { redirect } from "next/navigation";
import { Shell } from "@/components/shell"
import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
import { InformationButton } from "@/components/information/information-button"
-
+import { useTranslation } from "@/i18n"
import { PcrTable } from "@/lib/pcr/table/pcr-table";
import { getPcrPoListForPartners } from "@/lib/pcr/service";
@@ -14,6 +14,10 @@ export const metadata = {
description: "Purchase Change Request를 조회하고 관리할 수 있습니다.",
};
+interface pageProps {
+ params: { lng: string }
+}
+
async function PartnersPcrTableWrapper() {
// 세션에서 사용자 정보 확인
const session = await getServerSession(authOptions);
@@ -33,7 +37,9 @@ async function PartnersPcrTableWrapper() {
return <PcrTable tableData={tableData} isEvcpPage={false} isPartnersPage={true} currentVendorId={vendorId} />;
}
-export default function PartnersPcrPage() {
+export default async function PartnersPcrPage({ params }: pageProps) {
+ const { lng } = await params;
+ const { t } = await useTranslation(lng, 'menu')
return (
<Shell className="gap-4">
{/* ═══════════════════════════════════════════════════════════════ */}
@@ -44,12 +50,12 @@ export default function PartnersPcrPage() {
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- PCR 관리
+ {t('menu.vendor.procurement.pcr')}
</h2>
<InformationButton pagePath="partners/pcr" />
</div>
<p className="text-muted-foreground">
- Purchase Change Request를 조회하고 관리할 수 있습니다. PCR 승인 상태, 변경 구분, PO/계약 정보 등을 확인할 수 있습니다.
+ {t('menu.vendor.procurement.pcr_desc')}
</p>
</div>
</div>
diff --git a/app/[lng]/partners/(partners)/po/page.tsx b/app/[lng]/partners/(partners)/po/page.tsx
index 709b975c..a2ab2916 100644
--- a/app/[lng]/partners/(partners)/po/page.tsx
+++ b/app/[lng]/partners/(partners)/po/page.tsx
@@ -11,12 +11,16 @@ import { getVendorPOs } from "@/lib/po/vendor-table/service"
import { vendorPoSearchParamsCache } from "@/lib/po/vendor-table/validations"
import { VendorPoTable } from "@/lib/po/vendor-table/vendor-po-table"
import { InformationButton } from "@/components/information/information-button"
+import { useTranslation } from "@/i18n"
interface VendorPOPageProps {
+ params: Promise<{ lng: string }>
searchParams: Promise<SearchParams>
}
export default async function VendorPO(props: VendorPOPageProps) {
+ const { lng } = await props.params;
+ const { t } = await useTranslation(lng, 'menu')
const searchParams = await props.searchParams
const search = vendorPoSearchParamsCache.parse(searchParams)
@@ -47,7 +51,7 @@ export default async function VendorPO(props: VendorPOPageProps) {
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- PO/계약 목록
+ {t('menu.vendor.procurement.po')}
</h2>
<InformationButton pagePath="partners/po" />
</div>
@@ -72,4 +76,4 @@ export default async function VendorPO(props: VendorPOPageProps) {
</React.Suspense>
</Shell>
)
-} \ No newline at end of file
+}
diff --git a/app/[lng]/partners/(partners)/pq_new/page.tsx b/app/[lng]/partners/(partners)/pq_new/page.tsx
index eea5b21d..fb77ce0e 100644
--- a/app/[lng]/partners/(partners)/pq_new/page.tsx
+++ b/app/[lng]/partners/(partners)/pq_new/page.tsx
@@ -25,6 +25,11 @@ import {
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
+import { useTranslation } from "@/i18n"
+
+interface IndexPageProps {
+ params: Promise<{ lng: string }>
+}
export const metadata: Metadata = {
title: "사전 평가 (PQ) 목록",
@@ -60,10 +65,11 @@ function getFormattedDate(date: Date | null) {
}).format(new Date(date));
}
-export default async function PQListPage() {
+export default async function PQListPage({ params }: IndexPageProps) {
// 캐시 비활성화
noStore();
-
+ const { lng } = await params;
+ const { t } = await useTranslation(lng, 'menu')
// 인증 확인
const session = await getServerSession(authOptions);
@@ -74,10 +80,10 @@ export default async function PQListPage() {
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">
- 사전 평가 (PQ) 목록
+ {t('menu.vendor.procurement.pq_new')}
</h2>
<p className="text-muted-foreground">
- 요청된 사전 평가 목록을 확인하고 작성합니다.
+ {t('menu.vendor.procurement.pq_new_desc')}
</p>
</div>
</div>
@@ -139,11 +145,13 @@ export default async function PQListPage() {
<div className="flex justify-between items-center">
<div>
<div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">사전 평가 (PQ) 목록</h2>
+ <h2 className="text-2xl font-bold tracking-tight">
+ {t('menu.vendor.procurement.pq_new')}
+ </h2>
<InformationButton pagePath="partners/pq_new" />
</div>
<p className="text-muted-foreground">
- 요청된 사전 평가 목록을 확인하고 작성합니다.
+ {t('menu.vendor.procurement.pq_new_desc')}
</p>
</div>
</div>
@@ -295,4 +303,4 @@ export default async function PQListPage() {
</Card>
</Shell>
);
-} \ No newline at end of file
+}
diff --git a/app/[lng]/partners/(partners)/rfq-last/page.tsx b/app/[lng]/partners/(partners)/rfq-last/page.tsx
index a49ed648..5a64d4b2 100644
--- a/app/[lng]/partners/(partners)/rfq-last/page.tsx
+++ b/app/[lng]/partners/(partners)/rfq-last/page.tsx
@@ -15,6 +15,7 @@ import { searchParamsVendorRfqCache } from "@/lib/rfq-last/vendor-response/valid
import { InformationButton } from "@/components/information/information-button"
import { getVendorQuotationsLast,getQuotationStatusCountsLast } from "@/lib/rfq-last/vendor-response/service";
import { VendorQuotationsTableLast } from "@/lib/rfq-last/vendor-response/vendor-quotations-table";
+import { useTranslation } from "@/i18n"
export const metadata: Metadata = {
title: "견적 목록",
@@ -22,11 +23,14 @@ export const metadata: Metadata = {
};
interface IndexPageProps {
+ params: Promise<{ lng: string }>
searchParams: Promise<SearchParams>
}
export default async function IndexPage(props: IndexPageProps) {
+ const { lng } = await props.params;
+ const { t } = await useTranslation(lng, 'menu')
const searchParams = await props.searchParams
const search = searchParamsVendorRfqCache.parse(searchParams)
const validFilters = getValidFilters(search.filters)
@@ -41,7 +45,7 @@ export default async function IndexPage(props: IndexPageProps) {
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- 견적 목록
+ {t('menu.vendor.procurement.rfq_response')}
</h2>
<InformationButton pagePath="partners/rfq-last" />
</div>
@@ -112,9 +116,9 @@ export default async function IndexPage(props: IndexPageProps) {
<Shell className="gap-6">
<div className="flex justify-between items-center">
<div>
- <h2 className="text-2xl font-bold tracking-tight">견적 목록</h2>
+ <h2 className="text-2xl font-bold tracking-tight">{t('menu.vendor.procurement.rfq_response')}</h2>
<p className="text-muted-foreground">
- 진행 중인 견적서 목록을 확인하고 관리합니다.
+ {t('menu.vendor.procurement.rfq_response_desc')}
</p>
</div>
</div>
@@ -190,4 +194,4 @@ export default async function IndexPage(props: IndexPageProps) {
</React.Suspense>
</Shell>
);
-} \ No newline at end of file
+}
diff --git a/app/[lng]/partners/(partners)/swp-document-upload/page.tsx b/app/[lng]/partners/(partners)/swp-document-upload/page.tsx
index b97bc7fc..55073062 100644
--- a/app/[lng]/partners/(partners)/swp-document-upload/page.tsx
+++ b/app/[lng]/partners/(partners)/swp-document-upload/page.tsx
@@ -3,6 +3,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import VendorDocumentPage from "./vendor-document-page";
import { Shell } from "@/components/shell";
+import { useTranslation } from "@/i18n"
export const metadata = {
title: "Document Search and Upload",
@@ -31,11 +32,15 @@ function VendorDocumentSkeleton() {
}
export default async function DocumentUploadPage({
+ params,
searchParams,
}: {
+ params: Promise<{ lng: string }>
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
- const params = await searchParams;
+ const { lng } = await params;
+ const { t } = await useTranslation(lng, 'menu')
+ const resolvedParams = await searchParams;
return (
<Shell>
@@ -43,15 +48,15 @@ export default async function DocumentUploadPage({
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">
- SWP Document Submission
+ {t('menu.vendor.engineering.document_submission')}
</h2>
</div>
</div>
{/* 메인 컨텐츠 */}
<Suspense fallback={<VendorDocumentSkeleton />}>
- <VendorDocumentPage searchParams={params} />
+ <VendorDocumentPage searchParams={resolvedParams} />
</Suspense>
</Shell>
);
-} \ No newline at end of file
+}
diff --git a/app/[lng]/partners/(partners)/tbe-last/page.tsx b/app/[lng]/partners/(partners)/tbe-last/page.tsx
index 62a982c7..c8da944a 100644
--- a/app/[lng]/partners/(partners)/tbe-last/page.tsx
+++ b/app/[lng]/partners/(partners)/tbe-last/page.tsx
@@ -10,6 +10,8 @@ import { Skeleton } from "@/components/ui/skeleton"
import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
import { Shell } from "@/components/shell"
import { InformationButton } from "@/components/information/information-button"
+import { useTranslation } from "@/i18n"
+
interface IndexPageProps {
// Next.js 13 App Router에서 기본으로 주어지는 객체들
params: {
@@ -20,8 +22,8 @@ interface IndexPageProps {
}
export default async function RfqTBEPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
+ const { lng } = await props.params
+ const { t } = await useTranslation(lng, 'menu')
// 2) SearchParams 파싱 (Zod)
// - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
@@ -51,7 +53,7 @@ export default async function RfqTBEPage(props: IndexPageProps) {
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- TBE 관리
+ {t('menu.vendor.engineering.tbe')}
</h2>
<InformationButton pagePath="partners/tbe" />
</div>
diff --git a/app/[lng]/partners/(partners)/techsales/rfq-offshore-hull/page.tsx b/app/[lng]/partners/(partners)/techsales/rfq-offshore-hull/page.tsx
index ee31c95b..1254e992 100644
--- a/app/[lng]/partners/(partners)/techsales/rfq-offshore-hull/page.tsx
+++ b/app/[lng]/partners/(partners)/techsales/rfq-offshore-hull/page.tsx
@@ -8,12 +8,16 @@ import { LogIn } from "lucide-react";
import { Shell } from "@/components/shell";
import { VendorQuotationsTable } from "@/lib/techsales-rfq/vendor-response/table/vendor-quotations-table";
import { InformationButton } from "@/components/information/information-button"
+import { useTranslation } from "@/i18n";
+
export const metadata: Metadata = {
title: "기술영업 해양HULL RFQ 관리",
description: "기술영업 해양HULL RFQ를 관리합니다.",
};
-export default async function VendorQuotationsHullPage() {
+export default async function VendorQuotationsHullPage(props: { params: { lng: string } }) {
+ const { lng } = props.params
+ const { t } = await useTranslation(lng, 'menu')
// 세션 확인
const session = await getServerSession(authOptions);
@@ -62,11 +66,11 @@ export default async function VendorQuotationsHullPage() {
<div className="flex-shrink-0 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<div className="flex items-center gap-2">
- <h1 className="text-2xl font-bold tracking-tight">기술영업 해양HULL RFQ</h1>
+ <h1 className="text-2xl font-bold tracking-tight">{t('menu.vendor.sales.offshore_hull_rfq')}</h1>
<InformationButton pagePath="partners/techsales/rfq-offshore-hull" />
</div>
<p className="text-muted-foreground">
- 할당받은 해양HULL RFQ에 대한 견적서를 작성하고 관리합니다.
+ {t('menu.vendor.sales.offshore_hull_rfq_desc')}
</p>
</div>
</div>
diff --git a/app/[lng]/partners/(partners)/techsales/rfq-offshore-top/page.tsx b/app/[lng]/partners/(partners)/techsales/rfq-offshore-top/page.tsx
index 36697a3f..467f2bdb 100644
--- a/app/[lng]/partners/(partners)/techsales/rfq-offshore-top/page.tsx
+++ b/app/[lng]/partners/(partners)/techsales/rfq-offshore-top/page.tsx
@@ -8,13 +8,17 @@ import { LogIn } from "lucide-react";
import { Shell } from "@/components/shell";
import { InformationButton } from "@/components/information/information-button"
import { VendorQuotationsTable } from "@/lib/techsales-rfq/vendor-response/table/vendor-quotations-table";
+import { useTranslation } from "@/i18n";
+
export const metadata: Metadata = {
title: "기술영업 해양TOP RFQ 관리",
description: "기술영업 해양TOP RFQ를 관리합니다.",
};
-export default async function VendorQuotationsTopPage() {
+export default async function VendorQuotationsTopPage(props: { params: { lng: string } }) {
+ const { lng } = props.params
+ const { t } = await useTranslation(lng, 'menu')
// 세션 확인
const session = await getServerSession(authOptions);
@@ -64,11 +68,11 @@ export default async function VendorQuotationsTopPage() {
<div className="flex-shrink-0 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<div className="flex items-center gap-2">
- <h1 className="text-2xl font-bold tracking-tight">기술영업 해양TOP RFQ</h1>
+ <h1 className="text-2xl font-bold tracking-tight">{t('menu.vendor.sales.offshore_top_rfq')}</h1>
<InformationButton pagePath="partners/techsales/rfq-offshore-top" />
</div>
<p className="text-muted-foreground">
- 할당받은 해양TOP RFQ에 대한 견적서를 작성하고 관리합니다.
+ {t('menu.vendor.sales.offshore_top_rfq_desc')}
</p>
</div>
</div>
diff --git a/app/[lng]/partners/(partners)/techsales/rfq-ship/page.tsx b/app/[lng]/partners/(partners)/techsales/rfq-ship/page.tsx
index bd3291f4..271d62c7 100644
--- a/app/[lng]/partners/(partners)/techsales/rfq-ship/page.tsx
+++ b/app/[lng]/partners/(partners)/techsales/rfq-ship/page.tsx
@@ -9,12 +9,17 @@ import { LogIn } from "lucide-react";
import { Shell } from "@/components/shell";
import { VendorQuotationsTable } from "@/lib/techsales-rfq/vendor-response/table/vendor-quotations-table";
import { InformationButton } from "@/components/information/information-button"
+import { useTranslation } from "@/i18n";
+
+
export const metadata: Metadata = {
title: "기술영업 조선 RFQ 관리",
description: "기술영업 조선 RFQ를 관리합니다.",
};
-export default async function VendorQuotationsPage() {
+export default async function VendorQuotationsPage(props: { params: { lng: string } }) {
+ const { lng } = props.params
+ const { t } = await useTranslation(lng, 'menu')
// 세션 확인
const session = await getServerSession(authOptions);
console.log(session, "session")
@@ -67,11 +72,11 @@ export default async function VendorQuotationsPage() {
<div className="flex-shrink-0 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<div className="flex items-center gap-2">
- <h1 className="text-2xl font-bold tracking-tight">기술영업 조선 RFQ</h1>
+ <h1 className="text-2xl font-bold tracking-tight">{t('menu.vendor.sales.ship_rfq')}</h1>
<InformationButton pagePath="partners/techsales/rfq-ship" />
</div>
<p className="text-muted-foreground">
- 할당받은 조선 RFQ에 대한 견적서를 작성하고 관리합니다.
+ {t('menu.vendor.sales.ship_rfq_desc')}
</p>
</div>
</div>
diff --git a/app/api/cron/tags-plant/start/route.ts b/app/api/cron/tags-plant/start/route.ts
index 83e06935..17a96ed7 100644
--- a/app/api/cron/tags-plant/start/route.ts
+++ b/app/api/cron/tags-plant/start/route.ts
@@ -88,8 +88,6 @@ async function processTagImport(syncId: string) {
const jobInfo = syncJobs.get(syncId)!;
const projectCode = jobInfo.projectCode;
const packageCode = jobInfo.packageCode;
- const mode = jobInfo.mode; // 모드 정보 추출
-
// 상태 업데이트: 처리 중
syncJobs.set(syncId, {
@@ -102,23 +100,40 @@ async function processTagImport(syncId: string) {
throw new Error('Package is required');
}
- // 여기서 실제 태그 가져오기 로직 import
const { importTagsFromSEDP } = await import('@/lib/sedp/get-tags-plant');
- // 진행 상황 업데이트를 위한 콜백 함수
- const updateProgress = (progress: number) => {
+ // ENG 모드 실행 (0~50%)
+ const updateProgressENG = (progress: number) => {
+ syncJobs.set(syncId, {
+ ...syncJobs.get(syncId)!,
+ progress: Math.floor(progress * 0.5)
+ });
+ };
+
+ const engResult = await importTagsFromSEDP(projectCode, packageCode, updateProgressENG, 'ENG');
+
+ // IM 모드 실행 (50~100%)
+ const updateProgressIM = (progress: number) => {
syncJobs.set(syncId, {
...syncJobs.get(syncId)!,
- progress
+ progress: 50 + Math.floor(progress * 0.5)
});
};
- // 실제 태그 가져오기 실행
- const result = await importTagsFromSEDP(projectCode, packageCode,updateProgress, mode);
+ const imResult = await importTagsFromSEDP(projectCode, packageCode, updateProgressIM, 'IM');
- // 명시적으로 캐시 무효화
+ // 캐시 무효화
revalidateTag(`tags-${packageCode}`);
- revalidateTag(`forms-${packageCode}-${mode}`);
+ revalidateTag(`forms-${packageCode}-ENG`);
+ revalidateTag(`forms-${packageCode}-IM`);
+
+ // 결과 합산
+ const result = {
+ processedCount: engResult.processedCount + imResult.processedCount,
+ excludedCount: engResult.excludedCount + imResult.excludedCount,
+ totalEntries: engResult.totalEntries + imResult.totalEntries,
+ errors: [...(engResult.errors || []), ...(imResult.errors || [])].filter(Boolean)
+ };
// 상태 업데이트: 완료
syncJobs.set(syncId, {
@@ -131,7 +146,6 @@ async function processTagImport(syncId: string) {
return result;
} catch (error: any) {
- // 에러 발생 시 상태 업데이트
syncJobs.set(syncId, {
...syncJobs.get(syncId)!,
status: 'failed',
@@ -139,7 +153,7 @@ async function processTagImport(syncId: string) {
error: error.message || 'Unknown error occurred',
});
- throw error; // 에러 다시 던지기
+ throw error;
}
}
diff --git a/app/api/general-contracts/upload-pdf/route.ts b/app/api/general-contracts/upload-pdf/route.ts
new file mode 100644
index 00000000..9480f7f5
--- /dev/null
+++ b/app/api/general-contracts/upload-pdf/route.ts
@@ -0,0 +1,73 @@
+/**
+ * 일반계약 PDF 업로드 API
+ * 클라이언트에서 생성된 PDF를 서버에 저장
+ */
+
+import { NextRequest, NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth/next';
+import { authOptions } from '@/app/api/auth/[...nextauth]/route';
+import { saveBuffer } from '@/lib/file-stroage';
+
+export async function POST(request: NextRequest) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ return NextResponse.json(
+ { success: false, error: '인증이 필요합니다' },
+ { status: 401 }
+ );
+ }
+
+ const formData = await request.formData();
+ const file = formData.get('file') as File;
+ const contractId = formData.get('contractId') as string;
+
+ if (!file) {
+ return NextResponse.json(
+ { success: false, error: '파일이 제공되지 않았습니다' },
+ { status: 400 }
+ );
+ }
+
+ // 파일을 ArrayBuffer로 읽기
+ const arrayBuffer = await file.arrayBuffer();
+ const buffer = Buffer.from(arrayBuffer);
+
+ // saveBuffer 함수를 사용해서 파일 저장
+ const saveResult = await saveBuffer({
+ buffer: buffer,
+ fileName: `${Date.now()}_${file.name}`,
+ directory: "generalContracts",
+ originalName: file.name,
+ userId: session.user.id
+ });
+
+ if (!saveResult.success) {
+ return NextResponse.json(
+ { success: false, error: saveResult.error || 'PDF 파일 저장에 실패했습니다.' },
+ { status: 500 }
+ );
+ }
+
+ const finalFilePath = saveResult.publicPath
+ ? saveResult.publicPath.replace('/api/files/', '')
+ : `/generalContracts/${saveResult.fileName}`;
+
+ return NextResponse.json({
+ success: true,
+ filePath: finalFilePath,
+ fileName: saveResult.fileName,
+ publicPath: saveResult.publicPath,
+ });
+ } catch (error) {
+ console.error('PDF 업로드 오류:', error);
+ return NextResponse.json(
+ {
+ success: false,
+ error: error instanceof Error ? error.message : 'PDF 업로드 중 오류가 발생했습니다.'
+ },
+ { status: 500 }
+ );
+ }
+}
+
diff --git a/components/bidding/create/bidding-create-dialog.tsx b/components/bidding/create/bidding-create-dialog.tsx
index b3972e11..af33f1f6 100644
--- a/components/bidding/create/bidding-create-dialog.tsx
+++ b/components/bidding/create/bidding-create-dialog.tsx
@@ -63,7 +63,7 @@ import { PurchaseGroupCodeSelector } from '@/components/common/selectors/purchas
import { ProcurementManagerSelector } from '@/components/common/selectors/procurement-manager'
import type { PurchaseGroupCodeWithUser } from '@/components/common/selectors/purchase-group-code/purchase-group-code-service'
import type { ProcurementManagerWithUser } from '@/components/common/selectors/procurement-manager/procurement-manager-service'
-import { createBidding } from '@/lib/bidding/service'
+import { createBidding, getUserDetails } from '@/lib/bidding/service'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
@@ -97,13 +97,6 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp
sparePartOptions: '',
})
- // 구매요청자 정보 (현재 사용자)
- // React.useEffect(() => {
- // // 실제로는 현재 로그인한 사용자의 정보를 가져와야 함
- // // 임시로 기본값 설정
- // form.setValue('requesterName', '김두진') // 실제로는 API에서 가져와야 함
- // }, [form])
-
const [shiAttachmentFiles, setShiAttachmentFiles] = React.useState<File[]>([])
const [vendorAttachmentFiles, setVendorAttachmentFiles] = React.useState<File[]>([])
@@ -164,13 +157,41 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp
React.useEffect(() => {
if (isOpen) {
- if (userId && session?.user?.name) {
- // 현재 사용자의 정보를 임시로 입찰담당자로 설정
- form.setValue('bidPicName', session.user.name)
- form.setValue('bidPicId', userId)
- // userCode는 현재 세션에 없으므로 이름으로 설정 (실제로는 API에서 가져와야 함)
- // form.setValue('bidPicCode', session.user.name)
+ const initUser = async () => {
+ if (userId) {
+ try {
+ const user = await getUserDetails(userId)
+ if (user) {
+ // 현재 사용자의 정보를 입찰담당자로 설정
+ form.setValue('bidPicName', user.name)
+ form.setValue('bidPicId', user.id)
+ form.setValue('bidPicCode', user.userCode || '')
+
+ // 담당자 selector 상태 업데이트
+ setSelectedBidPic({
+ PURCHASE_GROUP_CODE: user.userCode || '',
+ DISPLAY_NAME: user.name,
+ EMPLOYEE_NUMBER: user.employeeNumber || '',
+ user: {
+ id: user.id,
+ name: user.name,
+ email: '',
+ employeeNumber: user.employeeNumber
+ }
+ } as any)
+ }
+ } catch (error) {
+ console.error('Failed to fetch user details:', error)
+ // 실패 시 세션 정보로 폴백
+ if (session?.user?.name) {
+ form.setValue('bidPicName', session.user.name)
+ form.setValue('bidPicId', userId)
+ }
+ }
+ }
}
+ initUser()
+
loadPaymentTerms()
loadIncoterms()
loadShippingPlaces()
@@ -181,7 +202,7 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp
form.setValue('biddingConditions.taxConditions', 'V1')
}
}
- }, [isOpen, loadPaymentTerms, loadIncoterms, loadShippingPlaces, loadDestinationPlaces, form])
+ }, [isOpen, userId, session, form, loadPaymentTerms, loadIncoterms, loadShippingPlaces, loadDestinationPlaces])
// SHI용 파일 첨부 핸들러
const handleShiFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
diff --git a/components/bidding/manage/bidding-basic-info-editor.tsx b/components/bidding/manage/bidding-basic-info-editor.tsx
index 27a2c097..13c58311 100644
--- a/components/bidding/manage/bidding-basic-info-editor.tsx
+++ b/components/bidding/manage/bidding-basic-info-editor.tsx
@@ -88,7 +88,6 @@ interface BiddingBasicInfo {
contractEndDate?: string
submissionStartDate?: string
submissionEndDate?: string
- evaluationDate?: string
hasSpecificationMeeting?: boolean
hasPrDocument?: boolean
currency?: string
@@ -252,7 +251,6 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
contractEndDate: formatDate(bidding.contractEndDate),
submissionStartDate: formatDateTime(bidding.submissionStartDate),
submissionEndDate: formatDateTime(bidding.submissionEndDate),
- evaluationDate: formatDateTime(bidding.evaluationDate),
hasSpecificationMeeting: bidding.hasSpecificationMeeting || false,
hasPrDocument: bidding.hasPrDocument || false,
currency: bidding.currency || 'KRW',
diff --git a/components/bidding/manage/bidding-companies-editor.tsx b/components/bidding/manage/bidding-companies-editor.tsx
index 6634f528..9bfea90e 100644
--- a/components/bidding/manage/bidding-companies-editor.tsx
+++ b/components/bidding/manage/bidding-companies-editor.tsx
@@ -1,7 +1,7 @@
'use client'
import * as React from 'react'
-import { Building, User, Plus, Trash2 } from 'lucide-react'
+import { Building, User, Plus, Trash2, Users } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
@@ -11,7 +11,9 @@ import {
createBiddingCompanyContact,
deleteBiddingCompanyContact,
getVendorContactsByVendorId,
- updateBiddingCompanyPriceAdjustmentQuestion
+ updateBiddingCompanyPriceAdjustmentQuestion,
+ getBiddingCompaniesByBidPicId,
+ addBiddingCompanyFromOtherBidding
} from '@/lib/bidding/service'
import { deleteBiddingCompany } from '@/lib/bidding/pre-quote/service'
import { BiddingDetailVendorCreateDialog } from './bidding-detail-vendor-create-dialog'
@@ -36,6 +38,7 @@ import {
} from '@/components/ui/table'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
+import { PurchaseGroupCodeSelector, PurchaseGroupCodeWithUser } from '@/components/common/selectors/purchase-group-code/purchase-group-code-selector'
interface QuotationVendor {
id: number // biddingCompanies.id
@@ -102,6 +105,26 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC
const [isLoadingVendorContacts, setIsLoadingVendorContacts] = React.useState(false)
const [selectedContactFromVendor, setSelectedContactFromVendor] = React.useState<VendorContact | null>(null)
+ // 협력사 멀티 선택 다이얼로그
+ const [multiSelectDialogOpen, setMultiSelectDialogOpen] = React.useState(false)
+ const [selectedBidPic, setSelectedBidPic] = React.useState<PurchaseGroupCodeWithUser | undefined>(undefined)
+ const [biddingCompaniesList, setBiddingCompaniesList] = React.useState<Array<{
+ biddingId: number
+ biddingNumber: string
+ biddingTitle: string
+ companyId: number
+ vendorCode: string
+ vendorName: string
+ updatedAt: Date
+ }>>([])
+ const [isLoadingBiddingCompanies, setIsLoadingBiddingCompanies] = React.useState(false)
+ const [selectedBiddingCompany, setSelectedBiddingCompany] = React.useState<{
+ biddingId: number
+ companyId: number
+ } | null>(null)
+ const [selectedBiddingCompanyContacts, setSelectedBiddingCompanyContacts] = React.useState<BiddingCompanyContact[]>([])
+ const [isLoadingCompanyContacts, setIsLoadingCompanyContacts] = React.useState(false)
+
// 업체 목록 다시 로딩 함수
const reloadVendors = React.useCallback(async () => {
try {
@@ -494,10 +517,16 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC
</p>
</div>
{!readonly && (
- <Button onClick={() => setAddVendorDialogOpen(true)} className="flex items-center gap-2" disabled={readonly}>
- <Plus className="h-4 w-4" />
- 업체 추가
- </Button>
+ <div className="flex gap-2">
+ <Button onClick={() => setMultiSelectDialogOpen(true)} className="flex items-center gap-2" disabled={readonly} variant="outline">
+ <Users className="h-4 w-4" />
+ 협력사 멀티 선택
+ </Button>
+ <Button onClick={() => setAddVendorDialogOpen(true)} className="flex items-center gap-2" disabled={readonly}>
+ <Plus className="h-4 w-4" />
+ 업체 추가
+ </Button>
+ </div>
)}
</CardHeader>
<CardContent>
@@ -537,7 +566,22 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC
</TableCell>
<TableCell className="font-medium">{vendor.vendorName}</TableCell>
<TableCell>{vendor.vendorCode}</TableCell>
- <TableCell>{vendor.businessSize || '-'}</TableCell>
+ <TableCell>
+ {(() => {
+ switch (vendor.businessSize) {
+ case 'A':
+ return '대기업';
+ case 'B':
+ return '중견기업';
+ case 'C':
+ return '중소기업';
+ case 'D':
+ return '소기업';
+ default:
+ return '-';
+ }
+ })()}
+ </TableCell>
<TableCell>
{vendor.companyId && vendorFirstContacts.has(vendor.companyId)
? vendorFirstContacts.get(vendor.companyId)!.contactName
@@ -740,6 +784,227 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC
</DialogContent>
</Dialog>
+ {/* 협력사 멀티 선택 다이얼로그 */}
+ <Dialog open={multiSelectDialogOpen} onOpenChange={setMultiSelectDialogOpen}>
+ <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>참여협력사 선택</DialogTitle>
+ <DialogDescription>
+ 입찰담당자를 선택하여 해당 담당자의 입찰 업체를 조회하고 선택할 수 있습니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4 py-4">
+ {/* 입찰담당자 선택 */}
+ <div className="space-y-2">
+ <Label>입찰담당자 선택</Label>
+ <PurchaseGroupCodeSelector
+ selectedCode={selectedBidPic}
+ onCodeSelect={async (code) => {
+ setSelectedBidPic(code)
+ if (code.user?.id) {
+ setIsLoadingBiddingCompanies(true)
+ try {
+ const result = await getBiddingCompaniesByBidPicId(code.user.id)
+ if (result.success && result.data) {
+ setBiddingCompaniesList(result.data)
+ } else {
+ toast.error(result.error || '입찰 업체 조회에 실패했습니다.')
+ setBiddingCompaniesList([])
+ }
+ } catch (error) {
+ console.error('Failed to load bidding companies:', error)
+ toast.error('입찰 업체 조회에 실패했습니다.')
+ setBiddingCompaniesList([])
+ } finally {
+ setIsLoadingBiddingCompanies(false)
+ }
+ }
+ }}
+ placeholder="입찰담당자 선택"
+ disabled={readonly}
+ />
+ </div>
+
+ {/* 입찰 업체 목록 */}
+ {isLoadingBiddingCompanies ? (
+ <div className="flex items-center justify-center py-8">
+ <Loader2 className="w-4 h-4 mr-2 animate-spin" />
+ <span className="text-sm text-muted-foreground">입찰 업체를 불러오는 중...</span>
+ </div>
+ ) : biddingCompaniesList.length === 0 && selectedBidPic ? (
+ <div className="text-center py-8 text-muted-foreground">
+ 해당 입찰담당자의 입찰 업체가 없습니다.
+ </div>
+ ) : biddingCompaniesList.length > 0 ? (
+ <div className="space-y-2">
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-[50px]">선택</TableHead>
+ <TableHead>입찰번호</TableHead>
+ <TableHead>입찰명</TableHead>
+ <TableHead>협력사코드</TableHead>
+ <TableHead>협력사명</TableHead>
+ <TableHead>입찰 업데이트일</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {biddingCompaniesList.map((company) => {
+ const isSelected = selectedBiddingCompany?.biddingId === company.biddingId &&
+ selectedBiddingCompany?.companyId === company.companyId
+ return (
+ <TableRow
+ key={`${company.biddingId}-${company.companyId}`}
+ className={`cursor-pointer hover:bg-muted/50 ${
+ isSelected ? 'bg-muted/50' : ''
+ }`}
+ onClick={async () => {
+ if (isSelected) {
+ setSelectedBiddingCompany(null)
+ setSelectedBiddingCompanyContacts([])
+ return
+ }
+ setSelectedBiddingCompany({
+ biddingId: company.biddingId,
+ companyId: company.companyId
+ })
+ setIsLoadingCompanyContacts(true)
+ try {
+ const contactsResult = await getBiddingCompanyContacts(company.biddingId, company.companyId)
+ if (contactsResult.success && contactsResult.data) {
+ setSelectedBiddingCompanyContacts(contactsResult.data)
+ } else {
+ setSelectedBiddingCompanyContacts([])
+ }
+ } catch (error) {
+ console.error('Failed to load company contacts:', error)
+ setSelectedBiddingCompanyContacts([])
+ } finally {
+ setIsLoadingCompanyContacts(false)
+ }
+ }}
+ >
+ <TableCell onClick={(e) => e.stopPropagation()}>
+ <Checkbox
+ checked={isSelected}
+ onCheckedChange={() => {
+ // 클릭 이벤트는 TableRow의 onClick에서 처리
+ }}
+ disabled={readonly}
+ />
+ </TableCell>
+ <TableCell className="font-medium">{company.biddingNumber}</TableCell>
+ <TableCell>{company.biddingTitle}</TableCell>
+ <TableCell>{company.vendorCode}</TableCell>
+ <TableCell>{company.vendorName}</TableCell>
+ <TableCell>
+ {company.updatedAt ? new Date(company.updatedAt).toLocaleDateString('ko-KR') : '-'}
+ </TableCell>
+ </TableRow>
+ )
+ })}
+ </TableBody>
+ </Table>
+
+ {/* 선택한 입찰 업체의 담당자 정보 */}
+ {selectedBiddingCompany !== null && (
+ <div className="mt-4 p-4 border rounded-lg">
+ <h4 className="font-medium mb-2">담당자 정보</h4>
+ {isLoadingCompanyContacts ? (
+ <div className="flex items-center justify-center py-4">
+ <Loader2 className="w-4 h-4 mr-2 animate-spin" />
+ <span className="text-sm text-muted-foreground">담당자 정보를 불러오는 중...</span>
+ </div>
+ ) : selectedBiddingCompanyContacts.length === 0 ? (
+ <div className="text-sm text-muted-foreground">등록된 담당자가 없습니다.</div>
+ ) : (
+ <div className="space-y-2">
+ {selectedBiddingCompanyContacts.map((contact) => (
+ <div key={contact.id} className="text-sm">
+ <span className="font-medium">{contact.contactName}</span>
+ <span className="text-muted-foreground ml-2">{contact.contactEmail}</span>
+ {contact.contactNumber && (
+ <span className="text-muted-foreground ml-2">{contact.contactNumber}</span>
+ )}
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+ ) : null}
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => {
+ setMultiSelectDialogOpen(false)
+ setSelectedBidPic(undefined)
+ setBiddingCompaniesList([])
+ setSelectedBiddingCompany(null)
+ setSelectedBiddingCompanyContacts([])
+ }}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={async () => {
+ if (!selectedBiddingCompany) {
+ toast.error('입찰 업체를 선택해주세요.')
+ return
+ }
+
+ const selectedCompany = biddingCompaniesList.find(
+ c => c.biddingId === selectedBiddingCompany.biddingId &&
+ c.companyId === selectedBiddingCompany.companyId
+ )
+
+ if (!selectedCompany) {
+ toast.error('선택한 입찰 업체 정보를 찾을 수 없습니다.')
+ return
+ }
+
+ try {
+ const contacts = selectedBiddingCompanyContacts.map(c => ({
+ contactName: c.contactName,
+ contactEmail: c.contactEmail,
+ contactNumber: c.contactNumber || undefined,
+ }))
+
+ const result = await addBiddingCompanyFromOtherBidding(
+ biddingId,
+ selectedCompany.biddingId,
+ selectedCompany.companyId,
+ contacts.length > 0 ? contacts : undefined
+ )
+
+ if (result.success) {
+ toast.success('업체가 성공적으로 추가되었습니다.')
+ setMultiSelectDialogOpen(false)
+ setSelectedBidPic(undefined)
+ setBiddingCompaniesList([])
+ setSelectedBiddingCompany(null)
+ setSelectedBiddingCompanyContacts([])
+ await reloadVendors()
+ } else {
+ toast.error(result.error || '업체 추가에 실패했습니다.')
+ }
+ } catch (error) {
+ console.error('Failed to add bidding company:', error)
+ toast.error('업체 추가에 실패했습니다.')
+ }
+ }}
+ disabled={!selectedBiddingCompany || readonly}
+ >
+ 추가
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
{/* 벤더 담당자에서 추가 다이얼로그 */}
<Dialog open={addContactFromVendorDialogOpen} onOpenChange={setAddContactFromVendorDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
diff --git a/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx b/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx
index 0dd9f0eb..489f104d 100644
--- a/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx
+++ b/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx
@@ -408,7 +408,20 @@ export function BiddingDetailVendorCreateDialog({
연동제 적용요건 문의
</Label>
<span className="text-xs text-muted-foreground">
- 기업규모: {businessSizeMap[item.vendor.id] || '미정'}
+ 기업규모: {(() => {
+ switch (businessSizeMap[item.vendor.id]) {
+ case 'A':
+ return '대기업';
+ case 'B':
+ return '중견기업';
+ case 'C':
+ return '중소기업';
+ case 'D':
+ return '소기업';
+ default:
+ return '-';
+ }
+ })()}
</span>
</div>
</div>
diff --git a/components/bidding/manage/bidding-items-editor.tsx b/components/bidding/manage/bidding-items-editor.tsx
index 90e512d2..452cdc3c 100644
--- a/components/bidding/manage/bidding-items-editor.tsx
+++ b/components/bidding/manage/bidding-items-editor.tsx
@@ -1,7 +1,7 @@
'use client'
import * as React from 'react'
-import { Package, Plus, Trash2, Save, RefreshCw, FileText } from 'lucide-react'
+import { Package, Plus, Trash2, Save, RefreshCw, FileText, FileSpreadsheet, Upload } from 'lucide-react'
import { getPRItemsForBidding } from '@/lib/bidding/detail/service'
import { updatePrItem } from '@/lib/bidding/detail/service'
import { toast } from 'sonner'
@@ -26,7 +26,7 @@ import { CostCenterSingleSelector } from '@/components/common/selectors/cost-cen
import { GlAccountSingleSelector } from '@/components/common/selectors/gl-account/gl-account-single-selector'
// PR 아이템 정보 타입 (create-bidding-dialog와 동일)
-interface PRItemInfo {
+export interface PRItemInfo {
id: number // 실제 DB ID
prNumber?: string | null
projectId?: number | null
@@ -84,6 +84,16 @@ import { CreatePreQuoteRfqDialog } from './create-pre-quote-rfq-dialog'
import { ProcurementItemSelectorDialogSingle } from '@/components/common/selectors/procurement-item/procurement-item-selector-dialog-single'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
+import { exportBiddingItemsToExcel } from '@/lib/bidding/manage/export-bidding-items-to-excel'
+import { importBiddingItemsFromExcel } from '@/lib/bidding/manage/import-bidding-items-from-excel'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItemsEditorProps) {
const { data: session } = useSession()
@@ -114,6 +124,11 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
isPriceAdjustmentApplicable?: boolean | null
sparePartOptions?: string | null
} | null>(null)
+ const [importDialogOpen, setImportDialogOpen] = React.useState(false)
+ const [importFile, setImportFile] = React.useState<File | null>(null)
+ const [importErrors, setImportErrors] = React.useState<string[]>([])
+ const [isImporting, setIsImporting] = React.useState(false)
+ const [isExporting, setIsExporting] = React.useState(false)
// 초기 데이터 로딩 - 기존 품목이 있으면 자동으로 로드
React.useEffect(() => {
@@ -492,7 +507,7 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
materialGroupInfo: null,
materialNumber: null,
materialInfo: null,
- priceUnit: 1,
+ priceUnit: '1',
purchaseUnit: 'EA',
materialWeight: null,
wbsCode: null,
@@ -644,6 +659,76 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
const totals = calculateTotals()
+ // Excel 내보내기 핸들러
+ const handleExport = React.useCallback(async () => {
+ if (items.length === 0) {
+ toast.error('내보낼 품목이 없습니다.')
+ return
+ }
+
+ try {
+ setIsExporting(true)
+ await exportBiddingItemsToExcel(items, {
+ filename: `입찰품목목록_${biddingId}`,
+ })
+ toast.success('Excel 파일이 다운로드되었습니다.')
+ } catch (error) {
+ console.error('Excel export error:', error)
+ toast.error('Excel 내보내기 중 오류가 발생했습니다.')
+ } finally {
+ setIsExporting(false)
+ }
+ }, [items, biddingId])
+
+ // Excel 가져오기 핸들러
+ const handleImportFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
+ const file = event.target.files?.[0]
+ if (file) {
+ if (!file.name.endsWith('.xlsx') && !file.name.endsWith('.xls')) {
+ toast.error('Excel 파일(.xlsx, .xls)만 업로드 가능합니다.')
+ return
+ }
+ setImportFile(file)
+ setImportErrors([])
+ }
+ }
+
+ const handleImport = async () => {
+ if (!importFile) return
+
+ setIsImporting(true)
+ setImportErrors([])
+
+ try {
+ const result = await importBiddingItemsFromExcel(importFile)
+
+ if (result.errors.length > 0) {
+ setImportErrors(result.errors)
+ toast.warning(
+ `${result.items.length}개의 품목을 파싱했지만 ${result.errors.length}개의 오류가 있습니다.`
+ )
+ return
+ }
+
+ if (result.items.length === 0) {
+ toast.error('가져올 품목이 없습니다.')
+ return
+ }
+
+ // 기존 아이템에 추가
+ setItems((prev) => [...prev, ...result.items])
+ setImportDialogOpen(false)
+ setImportFile(null)
+ setImportErrors([])
+ toast.success(`${result.items.length}개의 품목이 추가되었습니다.`)
+ } catch (error) {
+ console.error('Excel import error:', error)
+ toast.error('Excel 가져오기 중 오류가 발생했습니다.')
+ } finally {
+ setIsImporting(false)
+ }
+ }
+
if (isLoading) {
return (
<div className="flex items-center justify-center p-8">
@@ -1372,6 +1457,14 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
<FileText className="h-4 w-4" />
사전견적
</Button>
+ <Button onClick={handleExport} variant="outline" className="flex items-center gap-2" disabled={readonly || isExporting || items.length === 0}>
+ <FileSpreadsheet className="h-4 w-4" />
+ {isExporting ? "내보내는 중..." : "Excel 내보내기"}
+ </Button>
+ <Button onClick={() => setImportDialogOpen(true)} variant="outline" className="flex items-center gap-2" disabled={readonly}>
+ <Upload className="h-4 w-4" />
+ Excel 가져오기
+ </Button>
<Button onClick={handleAddItem} className="flex items-center gap-2" disabled={readonly}>
<Plus className="h-4 w-4" />
품목 추가
@@ -1492,6 +1585,88 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
toast.success('사전견적용 일반견적이 생성되었습니다')
}}
/>
+
+ {/* Excel 가져오기 다이얼로그 */}
+ <Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
+ <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>Excel 가져오기</DialogTitle>
+ <DialogDescription>
+ Excel 파일을 업로드하여 품목을 일괄 추가합니다.
+ </DialogDescription>
+ </DialogHeader>
+ <div className="space-y-4">
+ <div>
+ <Label htmlFor="import-file">Excel 파일 선택</Label>
+ <Input
+ id="import-file"
+ type="file"
+ accept=".xlsx,.xls"
+ onChange={handleImportFileSelect}
+ className="mt-2"
+ disabled={isImporting}
+ />
+ {importFile && (
+ <p className="text-sm text-muted-foreground mt-2">
+ 선택된 파일: {importFile.name}
+ </p>
+ )}
+ </div>
+
+ {importErrors.length > 0 && (
+ <div className="space-y-2">
+ <Label className="text-destructive">오류 목록</Label>
+ <div className="max-h-60 overflow-y-auto border rounded-md p-3 bg-destructive/5">
+ <ul className="list-disc list-inside space-y-1">
+ {importErrors.map((error, index) => (
+ <li key={index} className="text-sm text-destructive">
+ {error}
+ </li>
+ ))}
+ </ul>
+ </div>
+ </div>
+ )}
+
+ <div className="text-sm text-muted-foreground space-y-1">
+ <p className="font-semibold">필수 컬럼:</p>
+ <ul className="list-disc list-inside ml-2">
+ <li>자재그룹코드, 자재그룹명</li>
+ <li>수량 또는 중량 (둘 중 하나 필수)</li>
+ <li>수량단위 또는 중량단위</li>
+ <li>납품요청일 (YYYY-MM-DD 형식)</li>
+ <li>내정단가</li>
+ </ul>
+ </div>
+ </div>
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => {
+ setImportDialogOpen(false)
+ setImportFile(null)
+ setImportErrors([])
+ }}
+ disabled={isImporting}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={handleImport}
+ disabled={!importFile || isImporting}
+ >
+ {isImporting ? (
+ <>
+ <RefreshCw className="w-4 h-4 mr-2 animate-spin" />
+ 가져오는 중...
+ </>
+ ) : (
+ "가져오기"
+ )}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
</div>
)
diff --git a/components/bidding/manage/bidding-schedule-editor.tsx b/components/bidding/manage/bidding-schedule-editor.tsx
index 49659ae7..72961c3d 100644
--- a/components/bidding/manage/bidding-schedule-editor.tsx
+++ b/components/bidding/manage/bidding-schedule-editor.tsx
@@ -21,8 +21,10 @@ import { registerBidding } from '@/lib/bidding/detail/service'
import { useToast } from '@/hooks/use-toast'
import { format } from 'date-fns'
interface BiddingSchedule {
- submissionStartDate?: string
- submissionEndDate?: string
+ submissionStartOffset?: number // 시작일 오프셋 (결재 후 n일)
+ submissionStartTime?: string // 시작 시간 (HH:MM)
+ submissionDurationDays?: number // 기간 (시작일 + n일)
+ submissionEndTime?: string // 마감 시간 (HH:MM)
remarks?: string
isUrgent?: boolean
hasSpecificationMeeting?: boolean
@@ -149,6 +151,47 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
return new Date(kstTime).toISOString().slice(0, 16)
}
+ // timestamp에서 시간(HH:MM) 추출
+ // 수정: Backend에서 setUTCHours로 저장했으므로, 읽을 때도 getUTCHours로 읽어야
+ // 브라우저 타임존(KST)의 간섭 없이 원래 저장한 숫자를 가져올 수 있습니다.
+ const extractTimeFromTimestamp = (date: string | Date | undefined | null): string => {
+ if (!date) return ''
+ const d = new Date(date)
+
+ // 중요: Backend에서 setUTCHours로 저장했으므로, 읽을 때도 getUTCHours로 읽어야
+ // 브라우저 타임존(KST)의 간섭 없이 원래 저장한 숫자(09:00)를 가져올 수 있습니다.
+ const hours = d.getUTCHours().toString().padStart(2, '0')
+ const minutes = d.getUTCMinutes().toString().padStart(2, '0')
+
+ return `${hours}:${minutes}`
+ }
+
+ // 예상 일정 계산 (오늘 기준 미리보기)
+ const getPreviewDates = () => {
+ const today = new Date()
+ today.setHours(0, 0, 0, 0)
+
+ const startOffset = schedule.submissionStartOffset ?? 0
+ const durationDays = schedule.submissionDurationDays ?? 7
+ const startTime = schedule.submissionStartTime || '09:00'
+ const endTime = schedule.submissionEndTime || '18:00'
+
+ // 시작일 계산
+ const startDate = new Date(today)
+ startDate.setDate(startDate.getDate() + startOffset)
+ const [startHour, startMinute] = startTime.split(':').map(Number)
+ startDate.setHours(startHour, startMinute, 0, 0)
+
+ // 마감일 계산
+ const endDate = new Date(startDate)
+ endDate.setHours(0, 0, 0, 0) // 시작일의 날짜만
+ endDate.setDate(endDate.getDate() + durationDays)
+ const [endHour, endMinute] = endTime.split(':').map(Number)
+ endDate.setHours(endHour, endMinute, 0, 0)
+
+ return { startDate, endDate }
+ }
+
// 데이터 로딩
React.useEffect(() => {
const loadSchedule = async () => {
@@ -165,36 +208,36 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
})
setSchedule({
- submissionStartDate: toKstInputValue(bidding.submissionStartDate),
- submissionEndDate: toKstInputValue(bidding.submissionEndDate),
+ submissionStartOffset: bidding.submissionStartOffset ?? 1,
+ submissionStartTime: extractTimeFromTimestamp(bidding.submissionStartDate) || '09:00',
+ submissionDurationDays: bidding.submissionDurationDays ?? 7,
+ submissionEndTime: extractTimeFromTimestamp(bidding.submissionEndDate) || '18:00',
remarks: bidding.remarks || '',
isUrgent: bidding.isUrgent || false,
hasSpecificationMeeting: bidding.hasSpecificationMeeting || false,
})
- // 사양설명회 정보 로드
- if (bidding.hasSpecificationMeeting) {
- try {
- const meetingDetails = await getSpecificationMeetingDetailsAction(biddingId)
- if (meetingDetails.success && meetingDetails.data) {
- const meeting = meetingDetails.data
- setSpecMeetingInfo({
- meetingDate: toKstInputValue(meeting.meetingDate),
- meetingTime: meeting.meetingTime || '',
- location: meeting.location || '',
- address: meeting.address || '',
- contactPerson: meeting.contactPerson || '',
- contactPhone: meeting.contactPhone || '',
- contactEmail: meeting.contactEmail || '',
- agenda: meeting.agenda || '',
- materials: meeting.materials || '',
- notes: meeting.notes || '',
- isRequired: meeting.isRequired || false,
- })
- }
- } catch (error) {
- console.error('Failed to load specification meeting details:', error)
+ // 사양설명회 정보 로드 (T/F 무관하게 기존 데이터가 있으면 로드)
+ try {
+ const meetingDetails = await getSpecificationMeetingDetailsAction(biddingId)
+ if (meetingDetails.success && meetingDetails.data) {
+ const meeting = meetingDetails.data
+ setSpecMeetingInfo({
+ meetingDate: toKstInputValue(meeting.meetingDate),
+ meetingTime: meeting.meetingTime || '',
+ location: meeting.location || '',
+ address: meeting.address || '',
+ contactPerson: meeting.contactPerson || '',
+ contactPhone: meeting.contactPhone || '',
+ contactEmail: meeting.contactEmail || '',
+ agenda: meeting.agenda || '',
+ materials: meeting.materials || '',
+ notes: meeting.notes || '',
+ isRequired: meeting.isRequired || false,
+ })
}
+ } catch (error) {
+ console.error('Failed to load specification meeting details:', error)
}
}
} catch (error) {
@@ -258,10 +301,18 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
const handleBiddingInvitationClick = async () => {
try {
// 1. 입찰서 제출기간 검증
- if (!schedule.submissionStartDate || !schedule.submissionEndDate) {
+ if (schedule.submissionStartOffset === undefined || schedule.submissionDurationDays === undefined) {
toast({
title: '입찰서 제출기간 미설정',
- description: '입찰서 제출 시작일시와 마감일시를 모두 설정해주세요.',
+ description: '입찰서 제출 시작일 오프셋과 기간을 모두 설정해주세요.',
+ variant: 'destructive',
+ })
+ return
+ }
+ if (!schedule.submissionStartTime || !schedule.submissionEndTime) {
+ toast({
+ title: '입찰서 제출시간 미설정',
+ description: '입찰 시작 시간과 마감 시간을 모두 설정해주세요.',
variant: 'destructive',
})
return
@@ -484,10 +535,48 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
const userId = session?.user?.id?.toString() || '1'
// 입찰서 제출기간 필수 검증
- if (!schedule.submissionStartDate || !schedule.submissionEndDate) {
+ if (schedule.submissionStartOffset === undefined || schedule.submissionDurationDays === undefined) {
toast({
title: '입찰서 제출기간 미설정',
- description: '입찰서 제출 시작일시와 마감일시를 모두 설정해주세요.',
+ description: '입찰서 제출 시작일 오프셋과 기간을 모두 설정해주세요.',
+ variant: 'destructive',
+ })
+ setIsSubmitting(false)
+ return
+ }
+ if (!schedule.submissionStartTime || !schedule.submissionEndTime) {
+ toast({
+ title: '입찰서 제출시간 미설정',
+ description: '입찰 시작 시간과 마감 시간을 모두 설정해주세요.',
+ variant: 'destructive',
+ })
+ setIsSubmitting(false)
+ return
+ }
+ // 오프셋/기간 검증
+ if (schedule.submissionStartOffset < 0) {
+ toast({
+ title: '시작일 오프셋 오류',
+ description: '시작일 오프셋은 0 이상이어야 합니다.',
+ variant: 'destructive',
+ })
+ setIsSubmitting(false)
+ return
+ }
+ if (schedule.submissionDurationDays < 1) {
+ toast({
+ title: '기간 오류',
+ description: '입찰 기간은 최소 1일 이상이어야 합니다.',
+ variant: 'destructive',
+ })
+ setIsSubmitting(false)
+ return
+ }
+ // 긴급 입찰이 아닌 경우 당일 시작 불가 (오프셋 0)
+ if (!schedule.isUrgent && schedule.submissionStartOffset === 0) {
+ toast({
+ title: '시작일 오류',
+ description: '긴급 입찰이 아닌 경우 당일 시작(오프셋 0)은 불가능합니다.',
variant: 'destructive',
})
setIsSubmitting(false)
@@ -538,62 +627,55 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
}
}
- const handleScheduleChange = (field: keyof BiddingSchedule, value: string | boolean) => {
- // 마감일시 검증 - 현재일 이전 설정 불가
- if (field === 'submissionEndDate' && typeof value === 'string' && value) {
- const selectedDate = new Date(value)
- const now = new Date()
- now.setHours(0, 0, 0, 0) // 시간을 00:00:00으로 설정하여 날짜만 비교
-
- if (selectedDate < now) {
+ const handleScheduleChange = (field: keyof BiddingSchedule, value: string | boolean | number) => {
+ // 시작일 오프셋 검증
+ if (field === 'submissionStartOffset' && typeof value === 'number') {
+ if (value < 0) {
+ toast({
+ title: '시작일 오프셋 오류',
+ description: '시작일 오프셋은 0 이상이어야 합니다.',
+ variant: 'destructive',
+ })
+ return
+ }
+ // 긴급 입찰이 아닌 경우 당일 시작(오프셋 0) 불가
+ if (!schedule.isUrgent && value === 0) {
toast({
- title: '마감일시 오류',
- description: '마감일시는 현재일 이전으로 설정할 수 없습니다.',
+ title: '시작일 오프셋 오류',
+ description: '긴급 입찰이 아닌 경우 당일 시작(오프셋 0)은 불가능합니다.',
variant: 'destructive',
})
- return // 변경을 적용하지 않음
+ return
}
}
- // 긴급여부 미선택 시 당일 제출시작 불가
- if (field === 'submissionStartDate' && typeof value === 'string' && value) {
- const selectedDate = new Date(value)
- const today = new Date()
- today.setHours(0, 0, 0, 0) // 시간을 00:00:00으로 설정
- selectedDate.setHours(0, 0, 0, 0)
-
- // 현재 긴급 여부 확인 (field가 'isUrgent'인 경우 value 사용, 아니면 기존 schedule 값)
- const isUrgent = field === 'isUrgent' ? (value as boolean) : schedule.isUrgent || false
+ // 기간 검증
+ if (field === 'submissionDurationDays' && typeof value === 'number') {
+ if (value < 1) {
+ toast({
+ title: '기간 오류',
+ description: '입찰 기간은 최소 1일 이상이어야 합니다.',
+ variant: 'destructive',
+ })
+ return
+ }
+ }
- // 긴급이 아닌 경우 당일 시작 불가
- if (!isUrgent && selectedDate.getTime() === today.getTime()) {
+ // 시간 형식 검증 (HH:MM)
+ if ((field === 'submissionStartTime' || field === 'submissionEndTime') && typeof value === 'string') {
+ const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/
+ if (value && !timeRegex.test(value)) {
toast({
- title: '제출 시작일시 오류',
- description: '긴급 입찰이 아닌 경우 당일 제출 시작은 불가능합니다.',
+ title: '시간 형식 오류',
+ description: '시간은 HH:MM 형식으로 입력해주세요.',
variant: 'destructive',
})
- return // 변경을 적용하지 않음
+ return
}
}
setSchedule(prev => ({ ...prev, [field]: value }))
-
- // 사양설명회 실시 여부가 false로 변경되면 상세 정보 초기화
- if (field === 'hasSpecificationMeeting' && value === false) {
- setSpecMeetingInfo({
- meetingDate: '',
- meetingTime: '',
- location: '',
- address: '',
- contactPerson: '',
- contactPhone: '',
- contactEmail: '',
- agenda: '',
- materials: '',
- notes: '',
- isRequired: false,
- })
- }
+ // 사양설명회 실시 여부가 false로 변경되어도 상세 정보 초기화하지 않음 (기존 데이터 유지)
}
if (isLoading) {
@@ -624,40 +706,98 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
<Clock className="h-4 w-4" />
입찰서 제출 기간
</h3>
+ <p className="text-sm text-muted-foreground">
+ 입찰공고(결재 완료) 시점을 기준으로 일정이 자동 계산됩니다.
+ </p>
+
+ {/* 시작일 설정 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
- <Label htmlFor="submission-start">제출 시작일시 <span className="text-red-500">*</span></Label>
+ <Label htmlFor="submission-start-offset">시작일 (결재 후) <span className="text-red-500">*</span></Label>
+ <div className="flex items-center gap-2">
+ <Input
+ id="submission-start-offset"
+ type="number"
+ min={schedule.isUrgent ? 0 : 1}
+ value={schedule.submissionStartOffset ?? ''}
+ onChange={(e) => handleScheduleChange('submissionStartOffset', parseInt(e.target.value) || 0)}
+ className={schedule.submissionStartOffset === undefined ? 'border-red-200' : ''}
+ disabled={readonly}
+ placeholder="0"
+ />
+ <span className="text-sm text-muted-foreground whitespace-nowrap">일 후</span>
+ </div>
+ {schedule.submissionStartOffset === undefined && (
+ <p className="text-sm text-red-500">시작일 오프셋은 필수입니다</p>
+ )}
+ {!schedule.isUrgent && schedule.submissionStartOffset === 0 && (
+ <p className="text-sm text-amber-600">긴급 입찰만 당일 시작(0일) 가능</p>
+ )}
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="submission-start-time">시작 시간 <span className="text-red-500">*</span></Label>
<Input
- id="submission-start"
- type="datetime-local"
- value={schedule.submissionStartDate}
- onChange={(e) => handleScheduleChange('submissionStartDate', e.target.value)}
- className={!schedule.submissionStartDate ? 'border-red-200' : ''}
+ id="submission-start-time"
+ type="time"
+ value={schedule.submissionStartTime || ''}
+ onChange={(e) => handleScheduleChange('submissionStartTime', e.target.value)}
+ className={!schedule.submissionStartTime ? 'border-red-200' : ''}
disabled={readonly}
- min="1900-01-01T00:00"
- max="2100-12-31T23:59"
/>
- {!schedule.submissionStartDate && (
- <p className="text-sm text-red-500">제출 시작일시는 필수입니다</p>
+ {!schedule.submissionStartTime && (
+ <p className="text-sm text-red-500">시작 시간은 필수입니다</p>
+ )}
+ </div>
+ </div>
+
+ {/* 마감일 설정 */}
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="submission-duration">입찰 기간 (시작일 +) <span className="text-red-500">*</span></Label>
+ <div className="flex items-center gap-2">
+ <Input
+ id="submission-duration"
+ type="number"
+ min={1}
+ value={schedule.submissionDurationDays ?? ''}
+ onChange={(e) => handleScheduleChange('submissionDurationDays', parseInt(e.target.value) || 1)}
+ className={schedule.submissionDurationDays === undefined ? 'border-red-200' : ''}
+ disabled={readonly}
+ placeholder="7"
+ />
+ <span className="text-sm text-muted-foreground whitespace-nowrap">일간</span>
+ </div>
+ {schedule.submissionDurationDays === undefined && (
+ <p className="text-sm text-red-500">입찰 기간은 필수입니다</p>
)}
</div>
<div className="space-y-2">
- <Label htmlFor="submission-end">제출 마감일시 <span className="text-red-500">*</span></Label>
+ <Label htmlFor="submission-end-time">마감 시간 <span className="text-red-500">*</span></Label>
<Input
- id="submission-end"
- type="datetime-local"
- value={schedule.submissionEndDate}
- onChange={(e) => handleScheduleChange('submissionEndDate', e.target.value)}
- className={!schedule.submissionEndDate ? 'border-red-200' : ''}
+ id="submission-end-time"
+ type="time"
+ value={schedule.submissionEndTime || ''}
+ onChange={(e) => handleScheduleChange('submissionEndTime', e.target.value)}
+ className={!schedule.submissionEndTime ? 'border-red-200' : ''}
disabled={readonly}
- min="1900-01-01T00:00"
- max="2100-12-31T23:59"
/>
- {!schedule.submissionEndDate && (
- <p className="text-sm text-red-500">제출 마감일시는 필수입니다</p>
+ {!schedule.submissionEndTime && (
+ <p className="text-sm text-red-500">마감 시간은 필수입니다</p>
)}
</div>
</div>
+
+ {/* 예상 일정 미리보기 */}
+ {schedule.submissionStartOffset !== undefined && schedule.submissionDurationDays !== undefined && (
+ <div className="p-3 bg-blue-50 rounded-lg border border-blue-200">
+ <p className="text-sm font-medium text-blue-800 mb-1">📅 예상 일정 (오늘 공고 기준)</p>
+ <div className="text-sm text-blue-700">
+ <span>시작: {format(getPreviewDates().startDate, "yyyy-MM-dd HH:mm")}</span>
+ <span className="mx-2">~</span>
+ <span>마감: {format(getPreviewDates().endDate, "yyyy-MM-dd HH:mm")}</span>
+ </div>
+ </div>
+ )}
</div>
{/* 긴급 여부 */}
@@ -690,8 +830,8 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
/>
</div>
- {/* 사양설명회 상세 정보 */}
- {schedule.hasSpecificationMeeting && (
+ {/* 사양설명회 상세 정보 - T/F와 무관하게 기존 데이터가 있거나 hasSpecificationMeeting이 true면 표시 */}
+ {(schedule.hasSpecificationMeeting) && (
<div className="space-y-6 p-4 border rounded-lg bg-muted/50">
<div className="grid grid-cols-2 gap-4">
<div>
@@ -834,10 +974,19 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
<CardContent>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
- <span className="font-medium">입찰서 제출 기간:</span>
+ <span className="font-medium">시작일:</span>
+ <span>
+ {schedule.submissionStartOffset !== undefined
+ ? `결재 후 ${schedule.submissionStartOffset}일, ${schedule.submissionStartTime || '미설정'}`
+ : '미설정'
+ }
+ </span>
+ </div>
+ <div className="flex justify-between">
+ <span className="font-medium">마감일:</span>
<span>
- {schedule.submissionStartDate && schedule.submissionEndDate
- ? `${format(new Date(schedule.submissionStartDate), "yyyy-MM-dd HH:mm")} ~ ${format(new Date(schedule.submissionEndDate), "yyyy-MM-dd HH:mm")}`
+ {schedule.submissionDurationDays !== undefined
+ ? `시작일 + ${schedule.submissionDurationDays}일, ${schedule.submissionEndTime || '미설정'}`
: '미설정'
}
</span>
diff --git a/components/bidding/manage/create-pre-quote-rfq-dialog.tsx b/components/bidding/manage/create-pre-quote-rfq-dialog.tsx
index de3c19ff..b0cecc25 100644
--- a/components/bidding/manage/create-pre-quote-rfq-dialog.tsx
+++ b/components/bidding/manage/create-pre-quote-rfq-dialog.tsx
@@ -26,13 +26,6 @@ import {
FormMessage,
FormDescription,
} from "@/components/ui/form"
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import {
@@ -41,20 +34,15 @@ import {
PopoverTrigger,
} from "@/components/ui/popover"
import { Calendar } from "@/components/ui/calendar"
-import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Separator } from "@/components/ui/separator"
import { createPreQuoteRfqAction } from "@/lib/bidding/pre-quote/service"
-import { previewGeneralRfqCode } from "@/lib/rfq-last/service"
-import { MaterialGroupSelectorDialogSingle } from "@/components/common/material/material-group-selector-dialog-single"
-import { MaterialSearchItem } from "@/lib/material/material-group-service"
-import { MaterialSelectorDialogSingle } from "@/components/common/selectors/material/material-selector-dialog-single"
-import { MaterialSearchItem as SAPMaterialSearchItem } from "@/components/common/selectors/material/material-service"
import { PurchaseGroupCodeSelector } from "@/components/common/selectors/purchase-group-code/purchase-group-code-selector"
import type { PurchaseGroupCodeWithUser } from "@/components/common/selectors/purchase-group-code"
import { getBiddingById } from "@/lib/bidding/service"
+import { getProjectIdByCodeAndName } from "@/lib/bidding/manage/project-utils"
// 아이템 스키마
const itemSchema = z.object({
@@ -64,6 +52,8 @@ const itemSchema = z.object({
materialName: z.string().optional(),
quantity: z.number().min(1, "수량은 1 이상이어야 합니다"),
uom: z.string().min(1, "단위를 입력해주세요"),
+ totalWeight: z.union([z.number(), z.string(), z.null()]).optional(), // 중량 추가
+ weightUnit: z.string().optional().nullable(), // 중량단위 추가
remark: z.string().optional(),
})
@@ -125,8 +115,6 @@ export function CreatePreQuoteRfqDialog({
onSuccess
}: CreatePreQuoteRfqDialogProps) {
const [isLoading, setIsLoading] = React.useState(false)
- const [previewCode, setPreviewCode] = React.useState("")
- const [isLoadingPreview, setIsLoadingPreview] = React.useState(false)
const [selectedBidPic, setSelectedBidPic] = React.useState<PurchaseGroupCodeWithUser | undefined>(undefined)
const { data: session } = useSession()
@@ -143,6 +131,8 @@ export function CreatePreQuoteRfqDialog({
materialName: item.materialInfo || "",
quantity: item.quantity ? parseFloat(item.quantity) : 1,
uom: item.quantityUnit || item.weightUnit || "EA",
+ totalWeight: item.totalWeight ? parseFloat(item.totalWeight) : null,
+ weightUnit: item.weightUnit || null,
remark: "",
}))
}, [biddingItems])
@@ -164,6 +154,8 @@ export function CreatePreQuoteRfqDialog({
materialName: "",
quantity: 1,
uom: "",
+ totalWeight: null,
+ weightUnit: null,
remark: "",
},
],
@@ -231,6 +223,14 @@ export function CreatePreQuoteRfqDialog({
const pName = bidding.projectName || "";
setProjectInfo(pCode && pName ? `${pCode} - ${pName}` : pCode || pName || "");
+ // 프로젝트 ID 조회
+ if (pCode && pName) {
+ const fetchedProjectId = await getProjectIdByCodeAndName(pCode, pName)
+ if (fetchedProjectId) {
+ form.setValue("projectId", fetchedProjectId)
+ }
+ }
+
// 폼 값 설정
form.setValue("rfqTitle", rfqTitle);
form.setValue("rfqType", "pre_bidding"); // 기본값 설정
@@ -264,36 +264,15 @@ export function CreatePreQuoteRfqDialog({
materialName: "",
quantity: 1,
uom: "",
+ totalWeight: null,
+ weightUnit: null,
remark: "",
},
],
})
- setPreviewCode("")
}
}, [open, initialItems, form, selectedBidPic, biddingId])
- // 견적담당자 선택 시 RFQ 코드 미리보기 생성
- React.useEffect(() => {
- if (!selectedBidPic?.user?.id) {
- setPreviewCode("")
- return
- }
-
- // 즉시 실행 함수 패턴 사용
- (async () => {
- setIsLoadingPreview(true)
- try {
- const code = await previewGeneralRfqCode(selectedBidPic.user!.id)
- setPreviewCode(code)
- } catch (error) {
- console.error("코드 미리보기 오류:", error)
- setPreviewCode("")
- } finally {
- setIsLoadingPreview(false)
- }
- })()
- }, [selectedBidPic])
-
// 견적 종류 변경
const handleRfqTypeChange = (value: string) => {
form.setValue("rfqType", value)
@@ -315,12 +294,13 @@ export function CreatePreQuoteRfqDialog({
materialName: "",
quantity: 1,
uom: "",
+ totalWeight: null,
+ weightUnit: null,
remark: "",
},
],
})
setSelectedBidPic(undefined)
- setPreviewCode("")
onOpenChange(false)
}
@@ -350,15 +330,17 @@ export function CreatePreQuoteRfqDialog({
biddingNumber: data.biddingNumber, // 추가
contractStartDate: data.contractStartDate, // 추가
contractEndDate: data.contractEndDate, // 추가
- items: data.items as Array<{
- itemCode: string;
- itemName: string;
- materialCode?: string;
- materialName?: string;
- quantity: number;
- uom: string;
- remark?: string;
- }>,
+ items: data.items.map(item => ({
+ itemCode: item.itemCode || "",
+ itemName: item.itemName || "",
+ materialCode: item.materialCode,
+ materialName: item.materialName,
+ quantity: item.quantity,
+ uom: item.uom,
+ totalWeight: item.totalWeight,
+ weightUnit: item.weightUnit,
+ remark: item.remark,
+ })),
biddingConditions: biddingConditions || undefined,
createdBy: userId,
updatedBy: userId,
@@ -465,7 +447,7 @@ export function CreatePreQuoteRfqDialog({
)}
>
{field.value ? (
- format(field.value, "yyyy-MM-dd")
+ format(field.value, "yyyy-MM-dd HH:mm")
) : (
<span>제출마감일을 선택하세요 (선택)</span>
)}
@@ -477,12 +459,40 @@ export function CreatePreQuoteRfqDialog({
<Calendar
mode="single"
selected={field.value}
- onSelect={field.onChange}
- disabled={(date) =>
- date < new Date() || date < new Date("1900-01-01")
- }
+ onSelect={(date) => {
+ if (!date) {
+ field.onChange(undefined)
+ return
+ }
+ const newDate = new Date(date)
+ if (field.value) {
+ newDate.setHours(field.value.getHours(), field.value.getMinutes())
+ } else {
+ newDate.setHours(0, 0, 0, 0)
+ }
+ field.onChange(newDate)
+ }}
+ disabled={(date) => {
+ const today = new Date()
+ today.setHours(0, 0, 0, 0)
+ return date < today || date < new Date("1900-01-01")
+ }}
initialFocus
/>
+ <div className="p-3 border-t border-border">
+ <Input
+ type="time"
+ value={field.value ? format(field.value, "HH:mm") : ""}
+ onChange={(e) => {
+ if (field.value) {
+ const [hours, minutes] = e.target.value.split(':').map(Number)
+ const newDate = new Date(field.value)
+ newDate.setHours(hours, minutes)
+ field.onChange(newDate)
+ }
+ }}
+ />
+ </div>
</PopoverContent>
</Popover>
<FormMessage />
@@ -562,17 +572,7 @@ export function CreatePreQuoteRfqDialog({
</FormItem>
)}
/>
- {/* RFQ 코드 미리보기 */}
- {previewCode && (
- <div className="flex items-center gap-2">
- <Badge variant="secondary" className="font-mono text-sm">
- 예상 RFQ 코드: {previewCode}
- </Badge>
- {isLoadingPreview && (
- <Loader2 className="h-3 w-3 animate-spin" />
- )}
- </div>
- )}
+
{/* 계약기간 */}
<div className="grid grid-cols-2 gap-4">
diff --git a/components/common/date-picker/date-picker-with-input.tsx b/components/common/date-picker/date-picker-with-input.tsx
new file mode 100644
index 00000000..6e768601
--- /dev/null
+++ b/components/common/date-picker/date-picker-with-input.tsx
@@ -0,0 +1,322 @@
+"use client"
+
+import * as React from "react"
+import { format, parse, isValid } from "date-fns"
+import { ko } from "date-fns/locale"
+import { CalendarIcon, ChevronLeft, ChevronRight } from "lucide-react"
+import { DayPicker } from "react-day-picker"
+
+import { cn } from "@/lib/utils"
+import { Button, buttonVariants } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+
+export interface DatePickerWithInputProps {
+ value?: Date
+ onChange?: (date: Date | undefined) => void
+ disabled?: boolean
+ placeholder?: string
+ className?: string
+ minDate?: Date
+ maxDate?: Date
+ dateFormat?: string
+ inputClassName?: string
+ locale?: "ko" | "en"
+}
+
+/**
+ * DatePickerWithInput - 캘린더 선택 및 직접 입력이 가능한 날짜 선택기
+ *
+ * 사용법:
+ * ```tsx
+ * <DatePickerWithInput
+ * value={selectedDate}
+ * onChange={(date) => setSelectedDate(date)}
+ * placeholder="날짜를 선택하세요"
+ * minDate={new Date()}
+ * />
+ * ```
+ */
+export function DatePickerWithInput({
+ value,
+ onChange,
+ disabled = false,
+ placeholder = "YYYY-MM-DD",
+ className,
+ minDate,
+ maxDate,
+ dateFormat = "yyyy-MM-dd",
+ inputClassName,
+ locale: localeProp = "en",
+}: DatePickerWithInputProps) {
+ const [open, setOpen] = React.useState(false)
+ const [inputValue, setInputValue] = React.useState<string>("")
+ const [month, setMonth] = React.useState<Date>(value || new Date())
+ const [hasError, setHasError] = React.useState(false)
+ const [errorMessage, setErrorMessage] = React.useState<string>("")
+
+ // 외부 value가 변경되면 inputValue도 업데이트
+ React.useEffect(() => {
+ if (value && isValid(value)) {
+ setInputValue(format(value, dateFormat))
+ setMonth(value)
+ setHasError(false)
+ setErrorMessage("")
+ } else {
+ setInputValue("")
+ }
+ }, [value, dateFormat])
+
+ // 날짜 유효성 검사 및 에러 메시지 설정
+ const validateDate = (date: Date): { valid: boolean; message: string } => {
+ if (minDate) {
+ const minDateStart = new Date(minDate)
+ minDateStart.setHours(0, 0, 0, 0)
+ const dateToCheck = new Date(date)
+ dateToCheck.setHours(0, 0, 0, 0)
+ if (dateToCheck < minDateStart) {
+ return {
+ valid: false,
+ message: `${format(minDate, dateFormat)} 이후 날짜를 선택해주세요`
+ }
+ }
+ }
+ if (maxDate) {
+ const maxDateEnd = new Date(maxDate)
+ maxDateEnd.setHours(23, 59, 59, 999)
+ if (date > maxDateEnd) {
+ return {
+ valid: false,
+ message: `${format(maxDate, dateFormat)} 이전 날짜를 선택해주세요`
+ }
+ }
+ }
+ return { valid: true, message: "" }
+ }
+
+ // 캘린더에서 날짜 선택
+ const handleCalendarSelect = React.useCallback((date: Date | undefined, e?: React.MouseEvent) => {
+ // 이벤트 전파 중지
+ if (e) {
+ e.preventDefault()
+ e.stopPropagation()
+ }
+
+ if (date) {
+ const validation = validateDate(date)
+ if (validation.valid) {
+ setInputValue(format(date, dateFormat))
+ setHasError(false)
+ setErrorMessage("")
+ onChange?.(date)
+ setMonth(date)
+ } else {
+ setHasError(true)
+ setErrorMessage(validation.message)
+ }
+ }
+ setOpen(false)
+ }, [dateFormat, onChange, minDate, maxDate])
+
+ // 직접 입력값 변경
+ const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ const newValue = e.target.value
+ setInputValue(newValue)
+
+ // 입력 중에는 에러 상태 초기화
+ if (hasError) {
+ setHasError(false)
+ setErrorMessage("")
+ }
+
+ // YYYY-MM-DD 형식인 경우에만 파싱 시도
+ if (/^\d{4}-\d{2}-\d{2}$/.test(newValue)) {
+ const parsedDate = parse(newValue, dateFormat, new Date())
+
+ if (isValid(parsedDate)) {
+ const validation = validateDate(parsedDate)
+ if (validation.valid) {
+ setHasError(false)
+ setErrorMessage("")
+ onChange?.(parsedDate)
+ setMonth(parsedDate)
+ } else {
+ setHasError(true)
+ setErrorMessage(validation.message)
+ }
+ } else {
+ setHasError(true)
+ setErrorMessage("유효하지 않은 날짜 형식입니다")
+ }
+ }
+ }
+
+ // 입력 완료 시 (blur) 유효성 검사
+ const handleInputBlur = () => {
+ if (!inputValue) {
+ setHasError(false)
+ setErrorMessage("")
+ onChange?.(undefined)
+ return
+ }
+
+ const parsedDate = parse(inputValue, dateFormat, new Date())
+
+ if (isValid(parsedDate)) {
+ const validation = validateDate(parsedDate)
+ if (validation.valid) {
+ setHasError(false)
+ setErrorMessage("")
+ onChange?.(parsedDate)
+ } else {
+ setHasError(true)
+ setErrorMessage(validation.message)
+ // 유효 범위를 벗어난 경우 입력값은 유지하되 에러 표시
+ }
+ } else {
+ // 유효하지 않은 형식인 경우
+ setHasError(true)
+ setErrorMessage("YYYY-MM-DD 형식으로 입력해주세요")
+ }
+ }
+
+ // 키보드 이벤트 처리 (Enter 키로 완료)
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
+ if (e.key === "Enter") {
+ handleInputBlur()
+ }
+ }
+
+ // 날짜 비활성화 체크 (캘린더용)
+ const isDateDisabled = (date: Date) => {
+ if (disabled) return true
+ if (minDate) {
+ const minDateStart = new Date(minDate)
+ minDateStart.setHours(0, 0, 0, 0)
+ const dateToCheck = new Date(date)
+ dateToCheck.setHours(0, 0, 0, 0)
+ if (dateToCheck < minDateStart) return true
+ }
+ if (maxDate) {
+ const maxDateEnd = new Date(maxDate)
+ maxDateEnd.setHours(23, 59, 59, 999)
+ if (date > maxDateEnd) return true
+ }
+ return false
+ }
+
+ // 캘린더 버튼 클릭 핸들러
+ const handleCalendarButtonClick = (e: React.MouseEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ setOpen(!open)
+ }
+
+ // Popover 상태 변경 핸들러
+ const handleOpenChange = (newOpen: boolean) => {
+ setOpen(newOpen)
+ }
+
+ return (
+ <div className={cn("relative", className)}>
+ <div className="flex items-center gap-1">
+ <Input
+ type="text"
+ value={inputValue}
+ onChange={handleInputChange}
+ onBlur={handleInputBlur}
+ onKeyDown={handleKeyDown}
+ placeholder={placeholder}
+ disabled={disabled}
+ className={cn(
+ "pr-10",
+ hasError && "border-red-500 focus-visible:ring-red-500",
+ inputClassName
+ )}
+ />
+ <Popover open={open} onOpenChange={handleOpenChange} modal={true}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 h-full px-3 hover:bg-transparent"
+ disabled={disabled}
+ type="button"
+ onClick={handleCalendarButtonClick}
+ >
+ <CalendarIcon className={cn(
+ "h-4 w-4",
+ hasError ? "text-red-500" : "text-muted-foreground"
+ )} />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent
+ className="w-auto p-0"
+ align="end"
+ onPointerDownOutside={(e) => e.preventDefault()}
+ onInteractOutside={(e) => e.preventDefault()}
+ >
+ <div
+ onClick={(e) => e.stopPropagation()}
+ onMouseDown={(e) => e.stopPropagation()}
+ >
+ <DayPicker
+ mode="single"
+ selected={value}
+ onSelect={(date, selectedDay, activeModifiers, e) => {
+ handleCalendarSelect(date, e as unknown as React.MouseEvent)
+ }}
+ month={month}
+ onMonthChange={setMonth}
+ disabled={isDateDisabled}
+ locale={localeProp === "ko" ? ko : undefined}
+ showOutsideDays
+ className="p-3"
+ classNames={{
+ months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
+ month: "space-y-4",
+ caption: "flex justify-center pt-1 relative items-center",
+ caption_label: "text-sm font-medium",
+ nav: "space-x-1 flex items-center",
+ nav_button: cn(
+ buttonVariants({ variant: "outline" }),
+ "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
+ ),
+ nav_button_previous: "absolute left-1",
+ nav_button_next: "absolute right-1",
+ table: "w-full border-collapse space-y-1",
+ head_row: "flex",
+ head_cell: "text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
+ row: "flex w-full mt-2",
+ cell: "relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected])]:rounded-md",
+ day: cn(
+ buttonVariants({ variant: "ghost" }),
+ "h-8 w-8 p-0 font-normal aria-selected:opacity-100"
+ ),
+ day_selected: "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
+ day_today: "bg-accent text-accent-foreground",
+ day_outside: "text-muted-foreground opacity-50",
+ day_disabled: "text-muted-foreground opacity-50",
+ day_hidden: "invisible",
+ }}
+ components={{
+ IconLeft: () => <ChevronLeft className="h-4 w-4" />,
+ IconRight: () => <ChevronRight className="h-4 w-4" />,
+ }}
+ />
+ </div>
+ </PopoverContent>
+ </Popover>
+ </div>
+ {/* 에러 메시지 표시 */}
+ {hasError && errorMessage && (
+ <p className="text-xs text-red-500 mt-1">{errorMessage}</p>
+ )}
+ </div>
+ )
+}
+
diff --git a/components/common/date-picker/index.ts b/components/common/date-picker/index.ts
new file mode 100644
index 00000000..85c0c259
--- /dev/null
+++ b/components/common/date-picker/index.ts
@@ -0,0 +1,3 @@
+// 공용 날짜 선택기 컴포넌트
+export { DatePickerWithInput, type DatePickerWithInputProps } from './date-picker-with-input'
+
diff --git a/components/common/legal/cpvw-wab-qust-list-view-dialog.tsx b/components/common/legal/cpvw-wab-qust-list-view-dialog.tsx
new file mode 100644
index 00000000..aeefbb84
--- /dev/null
+++ b/components/common/legal/cpvw-wab-qust-list-view-dialog.tsx
@@ -0,0 +1,364 @@
+"use client"
+
+import * as React from "react"
+import { Loader, Database, Check } from "lucide-react"
+import { toast } from "sonner"
+import {
+ useReactTable,
+ getCoreRowModel,
+ getPaginationRowModel,
+ getFilteredRowModel,
+ ColumnDef,
+ flexRender,
+} from "@tanstack/react-table"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import { Checkbox } from "@/components/ui/checkbox"
+import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"
+
+import { getCPVWWabQustListViewData, CPVWWabQustListView } from "@/lib/basic-contract/cpvw-service"
+
+interface CPVWWabQustListViewDialogProps {
+ onConfirm?: (selectedRows: CPVWWabQustListView[]) => void
+ requireSingleSelection?: boolean
+ triggerDisabled?: boolean
+ triggerTitle?: string
+}
+
+export function CPVWWabQustListViewDialog({
+ onConfirm,
+ requireSingleSelection = false,
+ triggerDisabled = false,
+ triggerTitle,
+}: CPVWWabQustListViewDialogProps) {
+ const [open, setOpen] = React.useState(false)
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [data, setData] = React.useState<CPVWWabQustListView[]>([])
+ const [error, setError] = React.useState<string | null>(null)
+ const [rowSelection, setRowSelection] = React.useState<Record<string, boolean>>({})
+
+ const loadData = async () => {
+ setIsLoading(true)
+ setError(null)
+ try {
+ const result = await getCPVWWabQustListViewData()
+ if (result.success) {
+ setData(result.data)
+ if (result.isUsingFallback) {
+ toast.info("테스트 데이터를 표시합니다.")
+ }
+ } else {
+ setError(result.error || "데이터 로딩 실패")
+ toast.error(result.error || "데이터 로딩 실패")
+ }
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : "알 수 없는 오류"
+ setError(errorMessage)
+ toast.error(errorMessage)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ React.useEffect(() => {
+ if (open) {
+ loadData()
+ } else {
+ // 다이얼로그 닫힐 때 데이터 초기화
+ setData([])
+ setError(null)
+ setRowSelection({})
+ }
+ }, [open])
+
+ // 테이블 컬럼 정의 (동적 생성)
+ const columns = React.useMemo<ColumnDef<CPVWWabQustListView>[]>(() => {
+ if (data.length === 0) return []
+
+ const dataKeys = Object.keys(data[0])
+
+ return [
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="모든 행 선택"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="행 선택"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ },
+ ...dataKeys.map((key) => ({
+ accessorKey: key,
+ header: key,
+ cell: ({ getValue }: any) => {
+ const value = getValue()
+ return value !== null && value !== undefined ? String(value) : ""
+ },
+ })),
+ ]
+ }, [data])
+
+ // 테이블 인스턴스 생성
+ const table = useReactTable({
+ data,
+ columns,
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ onRowSelectionChange: setRowSelection,
+ state: {
+ rowSelection,
+ },
+ })
+
+ // 선택된 행들 가져오기
+ const selectedRows = table.getFilteredSelectedRowModel().rows.map(row => row.original)
+
+ // 확인 버튼 핸들러
+ const handleConfirm = () => {
+ if (selectedRows.length === 0) {
+ toast.error("행을 선택해주세요.")
+ return
+ }
+
+ if (requireSingleSelection && selectedRows.length !== 1) {
+ toast.error("하나의 행만 선택해주세요.")
+ return
+ }
+
+ if (onConfirm) {
+ onConfirm(selectedRows)
+ toast.success(
+ requireSingleSelection
+ ? "선택한 행으로 준법문의 상태를 동기화합니다."
+ : `${selectedRows.length}개의 행을 선택했습니다.`
+ )
+ } else {
+ // 임시로 선택된 데이터 콘솔 출력
+ console.log("선택된 행들:", selectedRows)
+ toast.success(`${selectedRows.length}개의 행이 선택되었습니다. (콘솔 확인)`)
+ }
+
+ setOpen(false)
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={setOpen}>
+ <DialogTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ disabled={triggerDisabled}
+ title={triggerTitle}
+ >
+ <Database className="mr-2 size-4" aria-hidden="true" />
+ 준법문의 요청 데이터 조회
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="max-w-7xl h-[90vh] flex flex-col overflow-hidden">
+ <DialogHeader>
+ <DialogTitle>준법문의 요청 데이터</DialogTitle>
+ <DialogDescription>
+ 준법문의 요청 데이터를 조회합니다.
+ {data.length > 0 && ` (${data.length}건, ${selectedRows.length}개 선택됨)`}
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="flex flex-col flex-1 min-h-0">
+ {isLoading ? (
+ <div className="flex items-center justify-center flex-1 min-h-[200px]">
+ <Loader className="mr-2 size-6 animate-spin" />
+ <span>데이터 로딩 중...</span>
+ </div>
+ ) : error ? (
+ <div className="flex items-center justify-center flex-1 min-h-[200px] text-red-500">
+ <span>오류: {error}</span>
+ </div>
+ ) : data.length === 0 ? (
+ <div className="flex items-center justify-center flex-1 min-h-[200px] text-muted-foreground">
+ <span>데이터가 없습니다.</span>
+ </div>
+ ) : (
+ <div className="flex flex-col flex-1 min-h-0">
+ {/* 테이블 영역 - 스크롤 가능 */}
+ <ScrollArea className="flex-1 overflow-auto border rounded-md">
+ <Table className="min-w-full">
+ <TableHeader className="sticky top-0 bg-background z-10">
+ {table.getHeaderGroups().map((headerGroup) => (
+ <TableRow key={headerGroup.id}>
+ {headerGroup.headers.map((header) => (
+ <TableHead key={header.id} className="font-medium bg-background">
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+ </TableHead>
+ ))}
+ </TableRow>
+ ))}
+ </TableHeader>
+ <TableBody>
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => (
+ <TableRow
+ key={row.id}
+ data-state={row.getIsSelected() && "selected"}
+ >
+ {row.getVisibleCells().map((cell) => (
+ <TableCell key={cell.id} className="text-sm">
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+ </TableCell>
+ ))}
+ </TableRow>
+ ))
+ ) : (
+ <TableRow>
+ <TableCell
+ colSpan={columns.length}
+ className="h-24 text-center"
+ >
+ 데이터가 없습니다.
+ </TableCell>
+ </TableRow>
+ )}
+ </TableBody>
+ </Table>
+ <ScrollBar orientation="horizontal" />
+ </ScrollArea>
+
+ {/* 페이지네이션 컨트롤 - 고정 영역 */}
+ <div className="flex items-center justify-between px-2 py-4 border-t bg-background flex-shrink-0">
+ <div className="flex-1 text-sm text-muted-foreground">
+ {table.getFilteredSelectedRowModel().rows.length}개 행 선택됨
+ </div>
+ <div className="flex items-center space-x-6 lg:space-x-8">
+ <div className="flex items-center space-x-2">
+ <p className="text-sm font-medium">페이지당 행 수</p>
+ <select
+ value={table.getState().pagination.pageSize}
+ onChange={(e) => {
+ table.setPageSize(Number(e.target.value))
+ }}
+ className="h-8 w-[70px] rounded border border-input bg-transparent px-3 py-1 text-sm ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2"
+ >
+ {[10, 20, 30, 40, 50].map((pageSize) => (
+ <option key={pageSize} value={pageSize}>
+ {pageSize}
+ </option>
+ ))}
+ </select>
+ </div>
+ <div className="flex w-[100px] items-center justify-center text-sm font-medium">
+ {table.getState().pagination.pageIndex + 1} /{" "}
+ {table.getPageCount()}
+ </div>
+ <div className="flex items-center space-x-2">
+ <Button
+ variant="outline"
+ className="h-8 w-8 p-0"
+ onClick={() => table.setPageIndex(0)}
+ disabled={!table.getCanPreviousPage()}
+ >
+ <span className="sr-only">첫 페이지로</span>
+ {"<<"}
+ </Button>
+ <Button
+ variant="outline"
+ className="h-8 w-8 p-0"
+ onClick={() => table.previousPage()}
+ disabled={!table.getCanPreviousPage()}
+ >
+ <span className="sr-only">이전 페이지</span>
+ {"<"}
+ </Button>
+ <Button
+ variant="outline"
+ className="h-8 w-8 p-0"
+ onClick={() => table.nextPage()}
+ disabled={!table.getCanNextPage()}
+ >
+ <span className="sr-only">다음 페이지</span>
+ {">"}
+ </Button>
+ <Button
+ variant="outline"
+ className="h-8 w-8 p-0"
+ onClick={() => table.setPageIndex(table.getPageCount() - 1)}
+ disabled={!table.getCanNextPage()}
+ >
+ <span className="sr-only">마지막 페이지로</span>
+ {">>"}
+ </Button>
+ </div>
+ </div>
+ </div>
+ </div>
+ )}
+ </div>
+
+ <DialogFooter className="gap-2 flex-shrink-0">
+ <Button variant="outline" onClick={() => setOpen(false)}>
+ 닫기
+ </Button>
+ <Button onClick={loadData} disabled={isLoading} variant="outline">
+ {isLoading ? (
+ <>
+ <Loader className="mr-2 size-4 animate-spin" />
+ 로딩 중...
+ </>
+ ) : (
+ "새로고침"
+ )}
+ </Button>
+ <Button
+ onClick={handleConfirm}
+ disabled={
+ requireSingleSelection
+ ? selectedRows.length !== 1
+ : selectedRows.length === 0
+ }
+ >
+ <Check className="mr-2 size-4" />
+ 확인 ({selectedRows.length})
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
+
diff --git a/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx b/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx
index 84fd85ff..a1b98468 100644
--- a/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx
+++ b/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx
@@ -23,6 +23,7 @@ export interface ProcurementItemSelectorDialogSingleProps {
title?: string;
description?: string;
showConfirmButtons?: boolean;
+ disabled?: boolean;
}
/**
@@ -78,6 +79,7 @@ export function ProcurementItemSelectorDialogSingle({
title = "1회성 품목 선택",
description = "1회성 품목을 검색하고 선택해주세요.",
showConfirmButtons = false,
+ disabled = false,
}: ProcurementItemSelectorDialogSingleProps) {
const [open, setOpen] = useState(false);
const [tempSelectedProcurementItem, setTempSelectedProcurementItem] =
@@ -128,7 +130,7 @@ export function ProcurementItemSelectorDialogSingle({
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
- <Button variant={triggerVariant} size={triggerSize}>
+ <Button variant={triggerVariant} size={triggerSize} disabled={disabled}>
{selectedProcurementItem ? (
<span className="truncate">
{`${selectedProcurementItem.itemCode}`}
diff --git a/components/docu-list-rule/docu-list-rule-client.tsx b/components/docu-list-rule/docu-list-rule-client.tsx
index ae3cdece..587ec7ff 100644
--- a/components/docu-list-rule/docu-list-rule-client.tsx
+++ b/components/docu-list-rule/docu-list-rule-client.tsx
@@ -2,9 +2,10 @@
import * as React from "react"
import { useRouter, useParams } from "next/navigation"
import { ProjectSelector } from "../ProjectSelector"
+import { useTranslation } from "@/i18n/client"
interface DocuListRuleClientProps {
- children: React.ReactNode
+ children: React.ReactNode;
}
export default function DocuListRuleClient({
@@ -13,7 +14,7 @@ export default function DocuListRuleClient({
const router = useRouter()
const params = useParams()
const lng = (params?.lng as string) || "ko"
-
+ const { t } = useTranslation(lng, 'menu')
// Get the projectId from route parameters
const projectIdFromUrl = React.useMemo(() => {
if (params?.projectId) {
@@ -53,10 +54,10 @@ export default function DocuListRuleClient({
{/* 왼쪽: 타이틀 & 설명 */}
<div>
<div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">Document Numbering Rule (해양)</h2>
+ <h2 className="text-2xl font-bold tracking-tight">{t('menu.master_data.document_numbering_rule')}</h2>
</div>
<p className="text-muted-foreground">
- 벤더 제출 문서 리스트 작성 시에 사용되는 넘버링
+ {t('menu.master_data.document_numbering_rule_desc')}
</p>
</div>
diff --git a/components/form-data/form-data-table.tsx b/components/form-data/form-data-table.tsx
index 70e93a68..d9915b66 100644
--- a/components/form-data/form-data-table.tsx
+++ b/components/form-data/form-data-table.tsx
@@ -1097,7 +1097,8 @@ export default function DynamicTable({
</Button>
{/* COMPARE WITH SEDP 버튼 */}
- <Button
+ {/* TODO: 스마트엑셀 No.184 조치 전까지 비활성화 요청 받아 주석처리 */}
+ {/* <Button
variant="outline"
size="sm"
onClick={handleSEDPCompareClick}
@@ -1105,7 +1106,7 @@ export default function DynamicTable({
>
<GitCompareIcon className="mr-2 size-4" />
{t("buttons.compareWithSEDP")}
- </Button>
+ </Button> */}
{/* SEDP 전송 버튼 */}
<Button
diff --git a/components/information/information-button.tsx b/components/information/information-button.tsx
index e03fffd9..744b0867 100644
--- a/components/information/information-button.tsx
+++ b/components/information/information-button.tsx
@@ -589,4 +589,4 @@ export function InformationButton({
/> */}
</>
)
-} \ No newline at end of file
+}
diff --git a/components/items-tech/item-tech-container.tsx b/components/items-tech/item-tech-container.tsx
index 65e4ac93..38750658 100644
--- a/components/items-tech/item-tech-container.tsx
+++ b/components/items-tech/item-tech-container.tsx
@@ -12,6 +12,8 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { InformationButton } from "@/components/information/information-button"
+import { useTranslation } from "@/i18n/client"
+import { useParams } from "next/navigation"
interface ItemType {
id: string
name: string
@@ -29,7 +31,10 @@ export function ItemTechContainer({
const router = useRouter()
const pathname = usePathname()
const searchParamsObj = useSearchParams()
-
+
+ const params = useParams<{lng: string}>()
+ const lng = params?.lng ?? 'ko'
+ const {t} = useTranslation(lng, 'menu')
// useSearchParams를 메모이제이션하여 안정적인 참조 생성
const searchParams = React.useMemo(
() => searchParamsObj || new URLSearchParams(),
@@ -57,7 +62,7 @@ export function ItemTechContainer({
{/* 왼쪽: 타이틀 & 설명 */}
<div>
<div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">자재 관리</h2>
+ <h2 className="text-2xl font-bold tracking-tight">{t('menu.tech_sales.items')}</h2>
<InformationButton pagePath="evcp/items-tech" />
</div>
{/* <p className="text-muted-foreground">
diff --git a/components/layout/DynamicMenuRender.tsx b/components/layout/DynamicMenuRender.tsx
new file mode 100644
index 00000000..f94223ae
--- /dev/null
+++ b/components/layout/DynamicMenuRender.tsx
@@ -0,0 +1,146 @@
+"use client";
+
+import Link from "next/link";
+import { cn } from "@/lib/utils";
+import { NavigationMenuLink } from "@/components/ui/navigation-menu";
+import type { MenuTreeNode } from "@/lib/menu-v2/types";
+
+interface DynamicMenuRenderProps {
+ groups: MenuTreeNode[] | undefined;
+ lng: string;
+ getTitle: (node: MenuTreeNode) => string;
+ getDescription: (node: MenuTreeNode) => string | null;
+ onItemClick?: () => void;
+}
+
+export default function DynamicMenuRender({
+ groups,
+ lng,
+ getTitle,
+ getDescription,
+ onItemClick,
+}: DynamicMenuRenderProps) {
+ if (!groups || groups.length === 0) {
+ return (
+ <div className="p-4 text-sm text-muted-foreground">
+ 메뉴가 없습니다.
+ </div>
+ );
+ }
+
+ // 그룹별로 메뉴 분류
+ const groupedMenus = new Map<string, MenuTreeNode[]>();
+ const ungroupedMenus: MenuTreeNode[] = [];
+
+ for (const item of groups) {
+ if (item.nodeType === "group") {
+ // 그룹인 경우, 그룹의 children을 해당 그룹에 추가
+ const groupTitle = getTitle(item);
+ if (!groupedMenus.has(groupTitle)) {
+ groupedMenus.set(groupTitle, []);
+ }
+ if (item.children) {
+ groupedMenus.get(groupTitle)!.push(...item.children);
+ }
+ } else if (item.nodeType === "menu") {
+ // 직접 메뉴인 경우 (그룹 없이 직접 메뉴그룹에 속한 경우)
+ ungroupedMenus.push(item);
+ }
+ }
+
+ // 그룹이 없고 메뉴만 있는 경우 - 단순 그리드 렌더링
+ if (groupedMenus.size === 0 && ungroupedMenus.length > 0) {
+ return (
+ <ul className="grid w-[400px] gap-3 p-4 md:w-[500px] md:grid-cols-2 lg:w-[600px]">
+ {ungroupedMenus.map((menu) => (
+ <MenuListItem
+ key={menu.id}
+ href={`/${lng}${menu.menuPath}`}
+ title={getTitle(menu)}
+ onClick={onItemClick}
+ >
+ {getDescription(menu)}
+ </MenuListItem>
+ ))}
+ </ul>
+ );
+ }
+
+ // 그룹별 렌더링 - 가로 스크롤 지원
+ // 컨텐츠가 85vw를 초과할 때만 스크롤 발생
+ return (
+ <div className="p-4 max-w-[85vw]">
+ <div className="flex gap-6 overflow-x-auto">
+ {/* 그룹화되지 않은 메뉴 (있는 경우) */}
+ {ungroupedMenus.length > 0 && (
+ <div className="w-[200px] flex-shrink-0">
+ <ul className="space-y-2">
+ {ungroupedMenus.map((menu) => (
+ <MenuListItem
+ key={menu.id}
+ href={`/${lng}${menu.menuPath}`}
+ title={getTitle(menu)}
+ onClick={onItemClick}
+ >
+ {getDescription(menu)}
+ </MenuListItem>
+ ))}
+ </ul>
+ </div>
+ )}
+
+ {/* 그룹별 메뉴 - 순서대로 가로 배치 */}
+ {Array.from(groupedMenus.entries()).map(([groupTitle, menus]) => (
+ <div key={groupTitle} className="w-[200px] flex-shrink-0">
+ <h4 className="mb-2 text-sm font-semibold text-muted-foreground whitespace-nowrap">
+ {groupTitle}
+ </h4>
+ <ul className="space-y-2">
+ {menus.map((menu) => (
+ <MenuListItem
+ key={menu.id}
+ href={`/${lng}${menu.menuPath}`}
+ title={getTitle(menu)}
+ onClick={onItemClick}
+ >
+ {getDescription(menu)}
+ </MenuListItem>
+ ))}
+ </ul>
+ </div>
+ ))}
+ </div>
+ </div>
+ );
+}
+
+interface MenuListItemProps {
+ href: string;
+ title: string;
+ children?: React.ReactNode;
+ onClick?: () => void;
+}
+
+function MenuListItem({ href, title, children, onClick }: MenuListItemProps) {
+ return (
+ <li>
+ <NavigationMenuLink asChild>
+ <Link
+ href={href}
+ onClick={onClick}
+ className={cn(
+ "block select-none space-y-1 rounded-md p-2 leading-none no-underline outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
+ )}
+ >
+ <div className="text-sm font-medium leading-none">{title}</div>
+ {children && (
+ <p className="line-clamp-2 text-xs leading-snug text-muted-foreground">
+ {children}
+ </p>
+ )}
+ </Link>
+ </NavigationMenuLink>
+ </li>
+ );
+}
+
diff --git a/components/layout/HeaderV2.tsx b/components/layout/HeaderV2.tsx
new file mode 100644
index 00000000..88d50cc5
--- /dev/null
+++ b/components/layout/HeaderV2.tsx
@@ -0,0 +1,295 @@
+"use client";
+
+import * as React from "react";
+import Link from "next/link";
+import { Button } from "@/components/ui/button";
+import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ NavigationMenu,
+ NavigationMenuContent,
+ NavigationMenuItem,
+ NavigationMenuLink,
+ NavigationMenuList,
+ NavigationMenuTrigger,
+ navigationMenuTriggerStyle,
+} from "@/components/ui/navigation-menu";
+import { SearchIcon, Loader2 } from "lucide-react";
+import { useParams, usePathname } from "next/navigation";
+import { cn } from "@/lib/utils";
+import Image from "next/image";
+import { useSession } from "next-auth/react";
+import { customSignOut } from "@/lib/auth/custom-signout";
+import DynamicMenuRender from "./DynamicMenuRender";
+import { MobileMenuV2 } from "./MobileMenuV2";
+import { CommandMenu } from "./command-menu";
+import { NotificationDropdown } from "./NotificationDropdown";
+import { useVisibleMenuTree } from "@/hooks/use-visible-menu-tree";
+import { useTranslation } from "@/i18n/client";
+import type { MenuDomain, MenuTreeNode } from "@/lib/menu-v2/types";
+
+// 도메인별 브랜드명
+const domainBrandingKeys: Record<MenuDomain, string> = {
+ evcp: "branding.evcp_main",
+ partners: "branding.evcp_partners",
+};
+
+export function HeaderV2() {
+ const params = useParams();
+ const lng = (params?.lng as string) || "ko";
+ const pathname = usePathname();
+ const { data: session } = useSession();
+ const { t } = useTranslation(lng, "menu");
+
+ // 현재 도메인 결정
+ const domain: MenuDomain = pathname?.includes("/partners") ? "partners" : "evcp";
+
+ // 메뉴 데이터 로드 (tree에 드롭다운과 단일 링크가 모두 포함됨)
+ const { tree, isLoading } = useVisibleMenuTree(domain);
+
+ const userName = session?.user?.name || "";
+ const initials = userName
+ .split(" ")
+ .map((word) => word[0]?.toUpperCase())
+ .join("");
+
+ const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false);
+ const [openMenuKey, setOpenMenuKey] = React.useState<string>("");
+
+ const toggleMobileMenu = () => {
+ setIsMobileMenuOpen(!isMobileMenuOpen);
+ };
+
+ const toggleMenu = React.useCallback((menuKey: string) => {
+ setOpenMenuKey((prev) => (prev === menuKey ? "" : menuKey));
+ }, []);
+
+ // 페이지 이동 시 메뉴 닫기
+ React.useEffect(() => {
+ setOpenMenuKey("");
+ }, [pathname]);
+
+ // 브랜딩 및 경로 설정
+ const brandNameKey = domainBrandingKeys[domain];
+ const logoHref = `/${lng}/${domain}`;
+ const basePath = `/${lng}/${domain}`;
+
+ // 다국어 텍스트 선택
+ const getTitle = (node: MenuTreeNode) =>
+ lng === "ko" ? node.titleKo : node.titleEn || node.titleKo;
+
+ const getDescription = (node: MenuTreeNode) =>
+ lng === "ko"
+ ? node.descriptionKo
+ : node.descriptionEn || node.descriptionKo;
+
+ // 메뉴 노드가 드롭다운(자식 있음)인지 단일 링크인지 판단
+ const isDropdownMenu = (node: MenuTreeNode) =>
+ node.nodeType === 'menu_group' && node.children && node.children.length > 0;
+
+ return (
+ <>
+ <header className="border-grid sticky top-0 z-40 w-full border-b bg-slate-100 backdrop-blur supports-[backdrop-filter]:bg-background/60">
+ <div className="container-wrapper">
+ <div className="container flex h-14 items-center">
+ {/* 햄버거 메뉴 버튼 (모바일) */}
+ <Button
+ onClick={toggleMobileMenu}
+ variant="ghost"
+ className="-ml-2 mr-2 h-8 w-8 px-0 text-base hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 md:hidden"
+ >
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ strokeWidth="1.5"
+ stroke="currentColor"
+ className="!size-6"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ d="M3.75 9h16.5m-16.5 6.75h16.5"
+ />
+ </svg>
+ <span className="sr-only">{t("menu.toggle_menu")}</span>
+ </Button>
+
+ {/* 로고 영역 */}
+ <div className="mr-4 flex-shrink-0 flex items-center gap-2 lg:mr-6">
+ <Link href={logoHref} className="flex items-center gap-2">
+ <Image
+ className="dark:invert"
+ src="/images/vercel.svg"
+ alt="EVCP Logo"
+ width={20}
+ height={20}
+ />
+ <span className="hidden font-bold lg:inline-block">
+ {t(brandNameKey)}
+ </span>
+ </Link>
+ </div>
+
+ {/* 네비게이션 메뉴 */}
+ <div className="hidden md:block flex-1 min-w-0">
+ {isLoading ? (
+ <div className="flex items-center justify-center h-10">
+ <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
+ </div>
+ ) : (
+ <NavigationMenu
+ className="relative z-50"
+ value={openMenuKey}
+ onValueChange={setOpenMenuKey}
+ >
+ <div className="w-full overflow-x-auto pb-1">
+ <NavigationMenuList className="flex-nowrap w-max">
+ {tree.map((node) => {
+ // 드롭다운 메뉴 (menu_group with children)
+ if (isDropdownMenu(node)) {
+ return (
+ <NavigationMenuItem
+ key={node.id}
+ value={String(node.id)}
+ >
+ <NavigationMenuTrigger
+ className="px-2 xl:px-3 text-sm whitespace-nowrap"
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ toggleMenu(String(node.id));
+ }}
+ onPointerEnter={(e) => e.preventDefault()}
+ onPointerMove={(e) => e.preventDefault()}
+ onPointerLeave={(e) => e.preventDefault()}
+ >
+ {getTitle(node)}
+ </NavigationMenuTrigger>
+
+ <NavigationMenuContent
+ className="max-h-[80vh] overflow-y-auto overflow-x-hidden"
+ onPointerEnter={(e) => e.preventDefault()}
+ onPointerLeave={(e) => e.preventDefault()}
+ forceMount={
+ openMenuKey === String(node.id)
+ ? true
+ : undefined
+ }
+ >
+ <DynamicMenuRender
+ groups={node.children}
+ lng={lng}
+ getTitle={getTitle}
+ getDescription={getDescription}
+ onItemClick={() => setOpenMenuKey("")}
+ />
+ </NavigationMenuContent>
+ </NavigationMenuItem>
+ );
+ }
+
+ // 단일 링크 메뉴 (최상위 menu)
+ if (node.nodeType === 'menu' && node.menuPath) {
+ return (
+ <NavigationMenuItem key={node.id}>
+ <Link
+ href={`/${lng}${node.menuPath}`}
+ legacyBehavior
+ passHref
+ >
+ <NavigationMenuLink
+ className={cn(
+ navigationMenuTriggerStyle(),
+ "px-2 xl:px-3 text-sm whitespace-nowrap"
+ )}
+ onPointerEnter={(e) => e.preventDefault()}
+ onPointerLeave={(e) => e.preventDefault()}
+ >
+ {getTitle(node)}
+ </NavigationMenuLink>
+ </Link>
+ </NavigationMenuItem>
+ );
+ }
+
+ return null;
+ })}
+ </NavigationMenuList>
+ </div>
+ </NavigationMenu>
+ )}
+ </div>
+
+ {/* 우측 영역 */}
+ <div className="ml-auto flex flex-shrink-0 items-center space-x-2">
+ {/* 데스크탑에서는 CommandMenu, 모바일에서는 검색 아이콘만 */}
+ <div className="hidden md:block md:w-auto">
+ <CommandMenu />
+ </div>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="md:hidden"
+ aria-label={t("common.search")}
+ >
+ <SearchIcon className="h-5 w-5" />
+ </Button>
+
+ {/* 알림 버튼 */}
+ <NotificationDropdown />
+
+ {/* 사용자 메뉴 */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Avatar className="cursor-pointer h-8 w-8">
+ <AvatarImage
+ src={`${session?.user?.image}` || "/user-avatar.jpg"}
+ alt="User Avatar"
+ />
+ <AvatarFallback>{initials || "?"}</AvatarFallback>
+ </Avatar>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent className="w-48" align="end">
+ <DropdownMenuLabel>{t("user.my_account")}</DropdownMenuLabel>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem asChild>
+ <Link href={`${basePath}/settings`}>{t("user.settings")}</Link>
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onSelect={() =>
+ customSignOut({
+ callbackUrl: `${window.location.origin}${basePath}`,
+ })
+ }
+ >
+ {t("user.logout")}
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+ </div>
+ </div>
+
+ {/* 모바일 메뉴 */}
+ {isMobileMenuOpen && (
+ <MobileMenuV2
+ lng={lng}
+ onClose={toggleMobileMenu}
+ tree={tree}
+ getTitle={getTitle}
+ getDescription={getDescription}
+ />
+ )}
+ </header>
+ </>
+ );
+}
diff --git a/components/layout/MobileMenuV2.tsx b/components/layout/MobileMenuV2.tsx
new file mode 100644
index 00000000..c83ba779
--- /dev/null
+++ b/components/layout/MobileMenuV2.tsx
@@ -0,0 +1,160 @@
+"use client";
+
+import * as React from "react";
+import Link from "next/link";
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { X, ChevronDown, ChevronRight } from "lucide-react";
+import type { MenuTreeNode } from "@/lib/menu-v2/types";
+
+interface MobileMenuV2Props {
+ lng: string;
+ onClose: () => void;
+ tree: MenuTreeNode[];
+ getTitle: (node: MenuTreeNode) => string;
+ getDescription: (node: MenuTreeNode) => string | null;
+}
+
+export function MobileMenuV2({
+ lng,
+ onClose,
+ tree,
+ getTitle,
+ getDescription,
+}: MobileMenuV2Props) {
+ const [expandedGroups, setExpandedGroups] = React.useState<Set<number>>(
+ new Set()
+ );
+
+ const toggleGroup = (groupId: number) => {
+ setExpandedGroups((prev) => {
+ const next = new Set(prev);
+ if (next.has(groupId)) {
+ next.delete(groupId);
+ } else {
+ next.add(groupId);
+ }
+ return next;
+ });
+ };
+
+ // 드롭다운 메뉴인지 판단
+ const isDropdownMenu = (node: MenuTreeNode) =>
+ node.nodeType === 'menu_group' && node.children && node.children.length > 0;
+
+ return (
+ <div className="fixed inset-0 z-50 bg-background md:hidden">
+ {/* 헤더 */}
+ <div className="flex items-center justify-between px-4 h-14 border-b">
+ <span className="font-semibold">메뉴</span>
+ <Button variant="ghost" size="icon" onClick={onClose}>
+ <X className="h-5 w-5" />
+ <span className="sr-only">닫기</span>
+ </Button>
+ </div>
+
+ {/* 스크롤 영역 */}
+ <ScrollArea className="h-[calc(100vh-56px)]">
+ <div className="px-4 py-4 space-y-2">
+ {tree.map((node) => {
+ // 드롭다운 메뉴 (menu_group with children)
+ if (isDropdownMenu(node)) {
+ return (
+ <div key={node.id} className="space-y-2">
+ {/* 메뉴그룹 헤더 */}
+ <button
+ onClick={() => toggleGroup(node.id)}
+ className="flex items-center justify-between w-full py-2 text-left font-semibold"
+ >
+ <span>{getTitle(node)}</span>
+ {expandedGroups.has(node.id) ? (
+ <ChevronDown className="h-4 w-4" />
+ ) : (
+ <ChevronRight className="h-4 w-4" />
+ )}
+ </button>
+
+ {/* 하위 메뉴 */}
+ {expandedGroups.has(node.id) && (
+ <div className="pl-4 space-y-1">
+ {node.children?.map((item) => {
+ if (item.nodeType === "group") {
+ // 그룹인 경우
+ return (
+ <div key={item.id} className="space-y-1">
+ <div className="text-xs text-muted-foreground font-medium py-1">
+ {getTitle(item)}
+ </div>
+ <div className="pl-2 space-y-1">
+ {item.children?.map((menu) => (
+ <MobileMenuLink
+ key={menu.id}
+ href={`/${lng}${menu.menuPath}`}
+ title={getTitle(menu)}
+ onClick={onClose}
+ />
+ ))}
+ </div>
+ </div>
+ );
+ } else if (item.nodeType === "menu") {
+ // 직접 메뉴인 경우
+ return (
+ <MobileMenuLink
+ key={item.id}
+ href={`/${lng}${item.menuPath}`}
+ title={getTitle(item)}
+ onClick={onClose}
+ />
+ );
+ }
+ return null;
+ })}
+ </div>
+ )}
+ </div>
+ );
+ }
+
+ // 단일 링크 메뉴 (최상위 menu)
+ if (node.nodeType === 'menu' && node.menuPath) {
+ return (
+ <MobileMenuLink
+ key={node.id}
+ href={`/${lng}${node.menuPath}`}
+ title={getTitle(node)}
+ onClick={onClose}
+ />
+ );
+ }
+
+ return null;
+ })}
+ </div>
+ </ScrollArea>
+ </div>
+ );
+}
+
+interface MobileMenuLinkProps {
+ href: string;
+ title: string;
+ onClick: () => void;
+}
+
+function MobileMenuLink({ href, title, onClick }: MobileMenuLinkProps) {
+ return (
+ <Link
+ href={href}
+ onClick={onClick}
+ className={cn(
+ "block py-2 px-2 rounded-md text-sm",
+ "hover:bg-accent hover:text-accent-foreground",
+ "transition-colors"
+ )}
+ >
+ {title}
+ </Link>
+ );
+}
diff --git a/db/schema/basicContractDocumnet.ts b/db/schema/basicContractDocumnet.ts
index 944c4b2c..e571c7e0 100644
--- a/db/schema/basicContractDocumnet.ts
+++ b/db/schema/basicContractDocumnet.ts
@@ -67,6 +67,12 @@ export const basicContract = pgTable('basic_contract', {
legalReviewRegNo: varchar('legal_review_reg_no', { length: 100 }), // 법무 시스템 REG_NO
legalReviewProgressStatus: varchar('legal_review_progress_status', { length: 255 }), // PRGS_STAT_DSC 값
+ // 준법문의 관련 필드
+ complianceReviewRequestedAt: timestamp('compliance_review_requested_at'), // 준법문의 요청일
+ complianceReviewCompletedAt: timestamp('compliance_review_completed_at'), // 준법문의 완료일
+ complianceReviewRegNo: varchar('compliance_review_reg_no', { length: 100 }), // 준법문의 시스템 REG_NO
+ complianceReviewProgressStatus: varchar('compliance_review_progress_status', { length: 255 }), // 준법문의 PRGS_STAT_DSC 값
+
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow(),
completedAt: timestamp('completed_at'), // 계약 체결 완료 날짜
@@ -99,6 +105,12 @@ export const basicContractView = pgView('basic_contract_view').as((qb) => {
legalReviewRegNo: sql<string | null>`${basicContract.legalReviewRegNo}`.as('legal_review_reg_no'),
legalReviewProgressStatus: sql<string | null>`${basicContract.legalReviewProgressStatus}`.as('legal_review_progress_status'),
+ // 준법문의 관련 필드
+ complianceReviewRequestedAt: sql<Date | null>`${basicContract.complianceReviewRequestedAt}`.as('compliance_review_requested_at'),
+ complianceReviewCompletedAt: sql<Date | null>`${basicContract.complianceReviewCompletedAt}`.as('compliance_review_completed_at'),
+ complianceReviewRegNo: sql<string | null>`${basicContract.complianceReviewRegNo}`.as('compliance_review_reg_no'),
+ complianceReviewProgressStatus: sql<string | null>`${basicContract.complianceReviewProgressStatus}`.as('compliance_review_progress_status'),
+
createdAt: sql<Date>`${basicContract.createdAt}`.as('created_at'),
updatedAt: sql<Date>`${basicContract.updatedAt}`.as('updated_at'),
completedAt: sql<Date | null>`${basicContract.completedAt}`.as('completed_at'),
@@ -121,6 +133,9 @@ export const basicContractView = pgView('basic_contract_view').as((qb) => {
// 법무검토 상태 (PRGS_STAT_DSC 동기화 값)
legalReviewStatus: sql<string | null>`${basicContract.legalReviewProgressStatus}`.as('legal_review_status'),
+
+ // 준법문의 상태 (PRGS_STAT_DSC 동기화 값)
+ complianceReviewStatus: sql<string | null>`${basicContract.complianceReviewProgressStatus}`.as('compliance_review_status'),
// 템플릿 파일 정보
templateFilePath: sql<string | null>`${basicContractTemplates.filePath}`.as('template_file_path'),
diff --git a/db/schema/bidding.ts b/db/schema/bidding.ts
index c08ea921..8e5fe823 100644
--- a/db/schema/bidding.ts
+++ b/db/schema/bidding.ts
@@ -176,8 +176,10 @@ export const biddings = pgTable('biddings', {
// 일정 관리
preQuoteDate: date('pre_quote_date'), // 사전견적일
biddingRegistrationDate: date('bidding_registration_date'), // 입찰등록일
- submissionStartDate: timestamp('submission_start_date'), // 입찰서제출기간 시작
- submissionEndDate: timestamp('submission_end_date'), // 입찰서제출기간 끝
+ submissionStartDate: timestamp('submission_start_date'), // 입찰서제출기간 시작 (시간만 저장, 결재완료 후 실제 날짜로 계산)
+ submissionEndDate: timestamp('submission_end_date'), // 입찰서제출기간 끝 (시간만 저장, 결재완료 후 실제 날짜로 계산)
+ submissionStartOffset: integer('submission_start_offset'), // 시작일 오프셋 (결재완료일 + n일)
+ submissionDurationDays: integer('submission_duration_days'), // 입찰 기간 (시작일 + n일)
evaluationDate: timestamp('evaluation_date'),
// 사양설명회
@@ -188,11 +190,13 @@ export const biddings = pgTable('biddings', {
budget: decimal('budget', { precision: 15, scale: 2 }), // 예산
targetPrice: decimal('target_price', { precision: 15, scale: 2 }), // 내정가
targetPriceCalculationCriteria: text('target_price_calculation_criteria'), // 내정가 산정 기준
+ actualPrice: decimal('actual_price', { precision: 15, scale: 2 }), // 실적가
finalBidPrice: decimal('final_bid_price', { precision: 15, scale: 2 }), // 최종입찰가
// PR 정보
prNumber: varchar('pr_number', { length: 50 }), // PR No.
hasPrDocument: boolean('has_pr_document').default(false), // PR 문서 여부
+ plant: varchar('plant', { length: 10 }), // 플랜트 코드(WERKS), ECC 연동 시 설정
// 상태 및 설정
status: biddingStatusEnum('status').default('bidding_generated').notNull(),
@@ -297,7 +301,7 @@ export const prItemsForBidding = pgTable('pr_items_for_bidding', {
// 수량 및 중량
quantity: decimal('quantity', { precision: 10, scale: 3 }), // 수량
- quantityUnit: varchar('quantity_unit', { length: 50 }), // 수량단위 (구매단위)
+ quantityUnit: varchar('quantity_unit', { length: 50 }), // 수량단위
totalWeight: decimal('total_weight', { precision: 10, scale: 3 }), // 총 중량
weightUnit: varchar('weight_unit', { length: 50 }), // 중량단위 (자재순중량)
@@ -403,6 +407,11 @@ export const biddingCompanies = pgTable('bidding_companies', {
//연동제 적용요건 문의 여부
isPriceAdjustmentApplicableQuestion: boolean('is_price_adjustment_applicable_question').default(false), // 연동제 적용요건 문의 여부
+ // SHI 연동제 적용여부 및 관련 정보
+ shiPriceAdjustmentApplied: boolean('shi_price_adjustment_applied'), // SHI 연동제 적용여부 (null: 미정, true: 적용, false: 미적용)
+ priceAdjustmentNote: text('price_adjustment_note'), // 연동제 Note (textarea)
+ hasChemicalSubstance: boolean('has_chemical_substance'), // 화학물질여부
+
// 기타
notes: text('notes'), // 특이사항
contactPerson: varchar('contact_person', { length: 100 }), // 업체 담당자
diff --git a/db/schema/generalContract.ts b/db/schema/generalContract.ts
index 6f48581f..7cc6cd6e 100644
--- a/db/schema/generalContract.ts
+++ b/db/schema/generalContract.ts
@@ -37,7 +37,7 @@ export const generalContracts = pgTable('general_contracts', {
// ═══════════════════════════════════════════════════════════════
// 계약 분류 및 상태
// ═══════════════════════════════════════════════════════════════
- status: varchar('status', { length: 50 }).notNull(), // 계약 상태 (Draft, Complete the Contract, Contract Delete 등)
+ status: varchar('status', { length: 50 }).notNull(), // 계약 상태 (Draft, Complete the Contract, Contract Delete, approval request 등)
category: varchar('category', { length: 50 }).notNull(), // 계약구분 (단가계약, 일반계약, 매각계약)
type: varchar('type', { length: 50 }), // 계약종류 (UP, LE, IL, AL 등)
executionMethod: varchar('execution_method', { length: 50 }), // 체결방식 (오프라인, 온라인 등)
diff --git a/db/schema/index.ts b/db/schema/index.ts
index 459cc9e4..da17b069 100644
--- a/db/schema/index.ts
+++ b/db/schema/index.ts
@@ -29,7 +29,11 @@ export * from './evaluation';
export * from './evaluationTarget';
export * from './evaluationCriteria';
export * from './projectGtc';
+// 기존 menu 스키마 (deprecated - menu-v2로 대체됨)
export * from './menu';
+
+// 새로운 메뉴 트리 스키마 (v2)
+export * from './menu-v2';
export * from './information';
export * from './qna';
export * from './notice';
diff --git a/db/schema/menu-v2.ts b/db/schema/menu-v2.ts
new file mode 100644
index 00000000..2d0282fa
--- /dev/null
+++ b/db/schema/menu-v2.ts
@@ -0,0 +1,88 @@
+// db/schema/menu-v2.ts
+import { pgTable, pgEnum, integer, varchar, text, timestamp, boolean, index, uniqueIndex } from "drizzle-orm/pg-core";
+import { relations } from "drizzle-orm";
+import { users } from "./users";
+
+export const menuTreeNodeTypeEnum = pgEnum('menu_tree_node_type', [
+ 'menu_group', // 메뉴그룹 (1단계) - 헤더에 표시되는 드롭다운 트리거
+ 'group', // 그룹 (2단계) - 드롭다운 내 구분 영역
+ 'menu', // 메뉴 (3단계) - 드롭다운 내 링크
+ 'additional' // 추가 메뉴 - 최상위 단일 링크 (Dashboard, QNA, FAQ 등)
+]);
+
+export const menuDomainEnum = pgEnum('menu_domain', [
+ 'evcp', // 내부 사용자용
+ 'partners' // 협력업체용
+]);
+
+export const menuTreeNodes = pgTable("menu_tree_nodes", {
+ id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
+
+ // 도메인 구분
+ domain: menuDomainEnum("domain").notNull(),
+
+ // 트리 구조
+ parentId: integer("parent_id").references((): any => menuTreeNodes.id, { onDelete: "cascade" }),
+ nodeType: menuTreeNodeTypeEnum("node_type").notNull(),
+ sortOrder: integer("sort_order").notNull().default(0),
+
+ // 다국어 텍스트 (DB 직접 관리)
+ titleKo: varchar("title_ko", { length: 255 }).notNull(),
+ titleEn: varchar("title_en", { length: 255 }),
+ descriptionKo: text("description_ko"),
+ descriptionEn: text("description_en"),
+
+ // 메뉴 전용 필드 (nodeType === 'menu' 또는 'additional'일 때)
+ menuPath: varchar("menu_path", { length: 255 }), // href 값 (예: /evcp/projects)
+ icon: varchar("icon", { length: 100 }),
+
+ // 권한 연동
+ // evcp: Oracle DB SCR_ID 참조
+ // partners: 자체 권한 시스템 (TODO)
+ scrId: varchar("scr_id", { length: 100 }),
+
+ // 상태
+ isActive: boolean("is_active").default(true).notNull(),
+
+ // 담당자 (evcp 전용)
+ manager1Id: integer("manager1_id").references(() => users.id, { onDelete: "set null" }),
+ manager2Id: integer("manager2_id").references(() => users.id, { onDelete: "set null" }),
+
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
+}, (table) => ({
+ domainIdx: index("menu_tree_domain_idx").on(table.domain),
+ parentIdx: index("menu_tree_parent_idx").on(table.parentId),
+ sortOrderIdx: index("menu_tree_sort_order_idx").on(table.sortOrder),
+ menuPathUnique: uniqueIndex("menu_tree_path_unique_idx").on(table.menuPath),
+ scrIdIdx: index("menu_tree_scr_id_idx").on(table.scrId),
+}));
+
+// Relations 정의
+export const menuTreeNodesRelations = relations(menuTreeNodes, ({ one, many }) => ({
+ parent: one(menuTreeNodes, {
+ fields: [menuTreeNodes.parentId],
+ references: [menuTreeNodes.id],
+ relationName: "parentChild",
+ }),
+ children: many(menuTreeNodes, {
+ relationName: "parentChild",
+ }),
+ manager1: one(users, {
+ fields: [menuTreeNodes.manager1Id],
+ references: [users.id],
+ relationName: "menuManager1",
+ }),
+ manager2: one(users, {
+ fields: [menuTreeNodes.manager2Id],
+ references: [users.id],
+ relationName: "menuManager2",
+ }),
+}));
+
+// Type exports
+export type MenuTreeNode = typeof menuTreeNodes.$inferSelect;
+export type NewMenuTreeNode = typeof menuTreeNodes.$inferInsert;
+export type NodeType = (typeof menuTreeNodeTypeEnum.enumValues)[number];
+export type MenuDomain = (typeof menuDomainEnum.enumValues)[number];
+
diff --git a/db/seeds/menu-v2-seed.js b/db/seeds/menu-v2-seed.js
new file mode 100644
index 00000000..e332f044
--- /dev/null
+++ b/db/seeds/menu-v2-seed.js
@@ -0,0 +1,231 @@
+"use strict";
+var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
+ return new (P || (P = Promise))(function (resolve, reject) {
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
+ });
+};
+var __generator = (this && this.__generator) || function (thisArg, body) {
+ var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
+ return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
+ function verb(n) { return function (v) { return step([n, v]); }; }
+ function step(op) {
+ if (f) throw new TypeError("Generator is already executing.");
+ while (g && (g = 0, op[0] && (_ = 0)), _) try {
+ if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
+ if (y = 0, t) op = [op[0] & 2, t.value];
+ switch (op[0]) {
+ case 0: case 1: t = op; break;
+ case 4: _.label++; return { value: op[1], done: false };
+ case 5: _.label++; y = op[1]; op = [0]; continue;
+ case 7: op = _.ops.pop(); _.trys.pop(); continue;
+ default:
+ if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
+ if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
+ if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
+ if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
+ if (t[2]) _.ops.pop();
+ _.trys.pop(); continue;
+ }
+ op = body.call(thisArg, _);
+ } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
+ if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
+ }
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.seedMenuTree = seedMenuTree;
+// db/seeds/menu-v2-seed.ts
+var menuConfig_1 = require("@/config/menuConfig");
+var menu_json_1 = require("@/i18n/locales/ko/menu.json");
+var menu_json_2 = require("@/i18n/locales/en/menu.json");
+var db_1 = require("@/db/db");
+var menu_v2_1 = require("@/db/schema/menu-v2");
+// 중첩 키로 번역 값 가져오기
+function getTranslation(key, locale) {
+ var translations = locale === 'ko' ? menu_json_1.default : menu_json_2.default;
+ var keys = key.split('.');
+ var value = translations;
+ for (var _i = 0, keys_1 = keys; _i < keys_1.length; _i++) {
+ var k = keys_1[_i];
+ if (typeof value === 'object' && value !== null) {
+ value = value[k];
+ }
+ else {
+ return key;
+ }
+ if (value === undefined)
+ return key;
+ }
+ return typeof value === 'string' ? value : key;
+}
+function seedMenuTree() {
+ return __awaiter(this, void 0, void 0, function () {
+ return __generator(this, function (_a) {
+ switch (_a.label) {
+ case 0:
+ console.log('🌱 Starting menu tree seeding...');
+ // 기존 데이터 삭제
+ return [4 /*yield*/, db_1.default.delete(menu_v2_1.menuTreeNodes)];
+ case 1:
+ // 기존 데이터 삭제
+ _a.sent();
+ console.log('✅ Cleared existing menu tree data');
+ // evcp 도메인 seed
+ return [4 /*yield*/, seedDomainMenus('evcp', menuConfig_1.mainNav, menuConfig_1.additionalNav)];
+ case 2:
+ // evcp 도메인 seed
+ _a.sent();
+ console.log('✅ Seeded evcp menu tree');
+ // partners 도메인 seed
+ return [4 /*yield*/, seedDomainMenus('partners', menuConfig_1.mainNavVendor, menuConfig_1.additionalNavVendor)];
+ case 3:
+ // partners 도메인 seed
+ _a.sent();
+ console.log('✅ Seeded partners menu tree');
+ console.log('🎉 Menu tree seeding completed!');
+ return [2 /*return*/];
+ }
+ });
+ });
+}
+function seedDomainMenus(domain, navConfig, additionalConfig) {
+ return __awaiter(this, void 0, void 0, function () {
+ var globalSortOrder, _loop_1, _i, navConfig_1, section, additionalSortOrder, _a, additionalConfig_1, item;
+ return __generator(this, function (_b) {
+ switch (_b.label) {
+ case 0:
+ globalSortOrder = 0;
+ _loop_1 = function (section) {
+ var menuGroup, groupedItems, groupSortOrder, _c, groupedItems_1, _d, groupKey, items, parentId, group, menuSortOrder, _e, items_1, item;
+ return __generator(this, function (_f) {
+ switch (_f.label) {
+ case 0: return [4 /*yield*/, db_1.default.insert(menu_v2_1.menuTreeNodes).values({
+ domain: domain,
+ parentId: null,
+ nodeType: 'menu_group',
+ titleKo: getTranslation(section.titleKey, 'ko'),
+ titleEn: getTranslation(section.titleKey, 'en'),
+ sortOrder: globalSortOrder++,
+ isActive: true,
+ }).returning()];
+ case 1:
+ menuGroup = (_f.sent())[0];
+ groupedItems = new Map();
+ section.items.forEach(function (item) {
+ var groupKey = item.groupKey || '__default__';
+ if (!groupedItems.has(groupKey)) {
+ groupedItems.set(groupKey, []);
+ }
+ groupedItems.get(groupKey).push(item);
+ });
+ groupSortOrder = 0;
+ _c = 0, groupedItems_1 = groupedItems;
+ _f.label = 2;
+ case 2:
+ if (!(_c < groupedItems_1.length)) return [3 /*break*/, 9];
+ _d = groupedItems_1[_c], groupKey = _d[0], items = _d[1];
+ parentId = menuGroup.id;
+ if (!(groupKey !== '__default__')) return [3 /*break*/, 4];
+ return [4 /*yield*/, db_1.default.insert(menu_v2_1.menuTreeNodes).values({
+ domain: domain,
+ parentId: menuGroup.id,
+ nodeType: 'group',
+ titleKo: getTranslation(groupKey, 'ko'),
+ titleEn: getTranslation(groupKey, 'en'),
+ sortOrder: groupSortOrder++,
+ isActive: true,
+ }).returning()];
+ case 3:
+ group = (_f.sent())[0];
+ parentId = group.id;
+ _f.label = 4;
+ case 4:
+ menuSortOrder = 0;
+ _e = 0, items_1 = items;
+ _f.label = 5;
+ case 5:
+ if (!(_e < items_1.length)) return [3 /*break*/, 8];
+ item = items_1[_e];
+ return [4 /*yield*/, db_1.default.insert(menu_v2_1.menuTreeNodes).values({
+ domain: domain,
+ parentId: parentId,
+ nodeType: 'menu',
+ titleKo: getTranslation(item.titleKey, 'ko'),
+ titleEn: getTranslation(item.titleKey, 'en'),
+ descriptionKo: item.descriptionKey ? getTranslation(item.descriptionKey, 'ko') : null,
+ descriptionEn: item.descriptionKey ? getTranslation(item.descriptionKey, 'en') : null,
+ menuPath: item.href,
+ icon: item.icon || null,
+ sortOrder: menuSortOrder++,
+ isActive: true,
+ })];
+ case 6:
+ _f.sent();
+ _f.label = 7;
+ case 7:
+ _e++;
+ return [3 /*break*/, 5];
+ case 8:
+ _c++;
+ return [3 /*break*/, 2];
+ case 9: return [2 /*return*/];
+ }
+ });
+ };
+ _i = 0, navConfig_1 = navConfig;
+ _b.label = 1;
+ case 1:
+ if (!(_i < navConfig_1.length)) return [3 /*break*/, 4];
+ section = navConfig_1[_i];
+ return [5 /*yield**/, _loop_1(section)];
+ case 2:
+ _b.sent();
+ _b.label = 3;
+ case 3:
+ _i++;
+ return [3 /*break*/, 1];
+ case 4:
+ additionalSortOrder = 0;
+ _a = 0, additionalConfig_1 = additionalConfig;
+ _b.label = 5;
+ case 5:
+ if (!(_a < additionalConfig_1.length)) return [3 /*break*/, 8];
+ item = additionalConfig_1[_a];
+ return [4 /*yield*/, db_1.default.insert(menu_v2_1.menuTreeNodes).values({
+ domain: domain,
+ parentId: null,
+ nodeType: 'additional',
+ titleKo: getTranslation(item.titleKey, 'ko'),
+ titleEn: getTranslation(item.titleKey, 'en'),
+ descriptionKo: item.descriptionKey ? getTranslation(item.descriptionKey, 'ko') : null,
+ descriptionEn: item.descriptionKey ? getTranslation(item.descriptionKey, 'en') : null,
+ menuPath: item.href,
+ sortOrder: additionalSortOrder++,
+ isActive: true,
+ })];
+ case 6:
+ _b.sent();
+ _b.label = 7;
+ case 7:
+ _a++;
+ return [3 /*break*/, 5];
+ case 8: return [2 /*return*/];
+ }
+ });
+ });
+}
+// CLI에서 직접 실행 가능하도록
+if (require.main === module) {
+ seedMenuTree()
+ .then(function () {
+ console.log('Seed completed successfully');
+ process.exit(0);
+ })
+ .catch(function (error) {
+ console.error('Seed failed:', error);
+ process.exit(1);
+ });
+}
diff --git a/db/seeds/menu-v2-seed.ts b/db/seeds/menu-v2-seed.ts
new file mode 100644
index 00000000..0c6b310d
--- /dev/null
+++ b/db/seeds/menu-v2-seed.ts
@@ -0,0 +1,145 @@
+// db/seeds/menu-v2-seed.ts
+import { mainNav, additionalNav, mainNavVendor, additionalNavVendor, MenuSection, MenuItem } from "@/config/menuConfig";
+import koMenu from '@/i18n/locales/ko/menu.json';
+import enMenu from '@/i18n/locales/en/menu.json';
+import db from "@/db/db";
+import { menuTreeNodes } from "@/db/schema/menu-v2";
+import type { MenuDomain } from "@/lib/menu-v2/types";
+
+type TranslationObject = { [key: string]: string | TranslationObject };
+
+// 중첩 키로 번역 값 가져오기
+function getTranslation(key: string, locale: 'ko' | 'en'): string {
+ const translations: TranslationObject = locale === 'ko' ? koMenu : enMenu;
+ const keys = key.split('.');
+ let value: string | TranslationObject | undefined = translations;
+
+ for (const k of keys) {
+ if (typeof value === 'object' && value !== null) {
+ value = value[k];
+ } else {
+ return key;
+ }
+ if (value === undefined) return key;
+ }
+
+ return typeof value === 'string' ? value : key;
+}
+
+export async function seedMenuTree() {
+ console.log('🌱 Starting menu tree seeding...');
+
+ // 기존 데이터 삭제
+ await db.delete(menuTreeNodes);
+ console.log('✅ Cleared existing menu tree data');
+
+ // evcp 도메인 seed
+ await seedDomainMenus('evcp', mainNav, additionalNav);
+ console.log('✅ Seeded evcp menu tree');
+
+ // partners 도메인 seed
+ await seedDomainMenus('partners', mainNavVendor, additionalNavVendor);
+ console.log('✅ Seeded partners menu tree');
+
+ console.log('🎉 Menu tree seeding completed!');
+}
+
+async function seedDomainMenus(
+ domain: MenuDomain,
+ navConfig: MenuSection[],
+ additionalConfig: MenuItem[]
+) {
+ // 최상위 sortOrder (메뉴그룹과 최상위 메뉴 모두 같은 레벨에서 정렬)
+ let topLevelSortOrder = 0;
+
+ // 메인 네비게이션 (메뉴그룹 → 그룹 → 메뉴)
+ for (const section of navConfig) {
+ // 1단계: 메뉴그룹 생성
+ const [menuGroup] = await db.insert(menuTreeNodes).values({
+ domain,
+ parentId: null,
+ nodeType: 'menu_group',
+ titleKo: getTranslation(section.titleKey, 'ko'),
+ titleEn: getTranslation(section.titleKey, 'en'),
+ sortOrder: topLevelSortOrder++,
+ isActive: true,
+ }).returning();
+
+ // groupKey별로 그룹화
+ const groupedItems = new Map<string, MenuItem[]>();
+ section.items.forEach(item => {
+ const groupKey = item.groupKey || '__default__';
+ if (!groupedItems.has(groupKey)) {
+ groupedItems.set(groupKey, []);
+ }
+ groupedItems.get(groupKey)!.push(item);
+ });
+
+ let groupSortOrder = 0;
+ for (const [groupKey, items] of groupedItems) {
+ let parentId = menuGroup.id;
+
+ // groupKey가 있으면 2단계 그룹 생성
+ if (groupKey !== '__default__') {
+ const [group] = await db.insert(menuTreeNodes).values({
+ domain,
+ parentId: menuGroup.id,
+ nodeType: 'group',
+ titleKo: getTranslation(groupKey, 'ko'),
+ titleEn: getTranslation(groupKey, 'en'),
+ sortOrder: groupSortOrder++,
+ isActive: true,
+ }).returning();
+ parentId = group.id;
+ }
+
+ // 3단계: 메뉴 생성
+ let menuSortOrder = 0;
+ for (const item of items) {
+ await db.insert(menuTreeNodes).values({
+ domain,
+ parentId,
+ nodeType: 'menu',
+ titleKo: getTranslation(item.titleKey, 'ko'),
+ titleEn: getTranslation(item.titleKey, 'en'),
+ descriptionKo: item.descriptionKey ? getTranslation(item.descriptionKey, 'ko') : null,
+ descriptionEn: item.descriptionKey ? getTranslation(item.descriptionKey, 'en') : null,
+ menuPath: item.href,
+ icon: item.icon || null,
+ sortOrder: menuSortOrder++,
+ isActive: true,
+ });
+ }
+ }
+ }
+
+ // 최상위 단일 링크 메뉴 (기존 additional)
+ // nodeType을 'menu'로 설정하고 parentId를 null로 유지
+ for (const item of additionalConfig) {
+ await db.insert(menuTreeNodes).values({
+ domain,
+ parentId: null,
+ nodeType: 'menu', // 'additional' 대신 'menu' 사용
+ titleKo: getTranslation(item.titleKey, 'ko'),
+ titleEn: getTranslation(item.titleKey, 'en'),
+ descriptionKo: item.descriptionKey ? getTranslation(item.descriptionKey, 'ko') : null,
+ descriptionEn: item.descriptionKey ? getTranslation(item.descriptionKey, 'en') : null,
+ menuPath: item.href,
+ sortOrder: topLevelSortOrder++, // 메뉴그룹 다음 순서
+ isActive: true,
+ });
+ }
+}
+
+// CLI에서 직접 실행 가능하도록
+if (require.main === module) {
+ seedMenuTree()
+ .then(() => {
+ console.log('Seed completed successfully');
+ process.exit(0);
+ })
+ .catch((error) => {
+ console.error('Seed failed:', error);
+ process.exit(1);
+ });
+}
diff --git a/hooks/use-visible-menu-tree.ts b/hooks/use-visible-menu-tree.ts
new file mode 100644
index 00000000..bc7f1f73
--- /dev/null
+++ b/hooks/use-visible-menu-tree.ts
@@ -0,0 +1,49 @@
+"use client";
+
+import { useState, useEffect, useCallback } from "react";
+import { getVisibleMenuTree } from "@/lib/menu-v2/permission-service";
+import type { MenuDomain, MenuTreeNode, MenuTreeActiveResult } from "@/lib/menu-v2/types";
+
+interface UseVisibleMenuTreeResult extends MenuTreeActiveResult {
+ isLoading: boolean;
+ error: Error | null;
+ refetch: () => Promise<void>;
+}
+
+/**
+ * Hook to fetch user's visible menu tree (filtered by permissions)
+ * Tree contains both menu groups (dropdowns) and top-level menus (single links)
+ */
+export function useVisibleMenuTree(domain: MenuDomain): UseVisibleMenuTreeResult {
+ const [tree, setTree] = useState<MenuTreeNode[]>([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState<Error | null>(null);
+
+ const fetchMenuTree = useCallback(async () => {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ // Call server action directly
+ const result = await getVisibleMenuTree(domain);
+ setTree(result.tree);
+ } catch (err) {
+ console.error("Error fetching visible menu tree:", err);
+ setError(err instanceof Error ? err : new Error("Unknown error"));
+ setTree([]);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [domain]);
+
+ useEffect(() => {
+ fetchMenuTree();
+ }, [fetchMenuTree]);
+
+ return {
+ tree,
+ isLoading,
+ error,
+ refetch: fetchMenuTree,
+ };
+}
diff --git a/i18n/locales/en/dolce.json b/i18n/locales/en/dolce.json
index c198086d..ba3c7d4f 100644
--- a/i18n/locales/en/dolce.json
+++ b/i18n/locales/en/dolce.json
@@ -33,6 +33,7 @@
"drawingNo": "Drawing No",
"drawingName": "Drawing Name",
"discipline": "Discipline",
+ "detailDwgCnt": "Detail Count",
"manager": "Manager",
"category": "Category",
"appDwgPlanDate": "Approval Dwg Plan Date",
diff --git a/i18n/locales/en/menu.json b/i18n/locales/en/menu.json
index bee0a946..e4835c37 100644
--- a/i18n/locales/en/menu.json
+++ b/i18n/locales/en/menu.json
@@ -94,8 +94,9 @@
"tbe": "TBE",
"tbe_desc": "Technical Bid Evaluation",
"itb": "RFQ Creation",
- "itb_desc": "Create RFQ before PR Issue"
-
+ "itb_desc": "Create RFQ before PR Issue",
+ "vendor_progress": "Vendor Progress",
+ "vendor_progress_desc": "Vendor EDP input progress"
},
"vendor_management": {
"title": "Vendor",
diff --git a/i18n/locales/ko/dolce.json b/i18n/locales/ko/dolce.json
index 4390a416..10748b76 100644
--- a/i18n/locales/ko/dolce.json
+++ b/i18n/locales/ko/dolce.json
@@ -33,6 +33,7 @@
"drawingNo": "도면번호",
"drawingName": "도면명",
"discipline": "설계공종",
+ "detailDwgCnt": "상세도면 수",
"manager": "담당자명",
"category": "구분",
"appDwgPlanDate": "승인도면 예정일",
diff --git a/lib/approval/handlers-registry.ts b/lib/approval/handlers-registry.ts
index beb6b971..235c9b7b 100644
--- a/lib/approval/handlers-registry.ts
+++ b/lib/approval/handlers-registry.ts
@@ -40,9 +40,10 @@ export async function initializeApprovalHandlers() {
// 벤더 가입 승인 핸들러 등록 (결재 승인 후 실행될 함수 approveVendorWithMDGInternal)
registerActionHandler('vendor_approval', approveVendorWithMDGInternal);
- // 5. 계약 승인 핸들러
- // const { approveContractInternal } = await import('@/lib/contract/handlers');
- // registerActionHandler('contract_approval', approveContractInternal);
+ // 5. 일반계약 승인 핸들러
+ const { approveContractInternal } = await import('@/lib/general-contracts/handlers');
+ // 일반계약 승인 핸들러 등록 (결재 승인 후 실행될 함수 approveContractInternal)
+ registerActionHandler('general_contract_approval', approveContractInternal);
// 6. RFQ 발송 핸들러 (첨부파일이 있는 경우)
const { sendRfqWithApprovalInternal } = await import('@/lib/rfq-last/approval-handlers');
diff --git a/lib/approval/templates/일반계약 결재.html b/lib/approval/templates/일반계약 결재.html
new file mode 100644
index 00000000..99389030
--- /dev/null
+++ b/lib/approval/templates/일반계약 결재.html
@@ -0,0 +1,3024 @@
+<div
+
+ style="
+
+ max-width: 1000px;
+
+ margin: 0 auto;
+
+ font-family: 'Malgun Gothic', 'Segoe UI', sans-serif;
+
+ font-size: 14px;
+
+ color: #333;
+
+ line-height: 1.5;
+
+ border: 1px solid #666; /* 전체적인 테두리 추가 */
+
+ "
+
+>
+
+ <!-- 1. 제목 및 안내 문구 -->
+
+ <table
+
+ style="
+
+ width: 100%;
+
+ border-collapse: collapse;
+
+ margin-bottom: 0px;
+
+ border-bottom: 2px solid #000;
+
+ "
+
+ >
+
+ <thead>
+
+ <tr>
+
+ <th
+
+ style="
+
+ background-color: #fff;
+
+ color: #000;
+
+ padding: 15px;
+
+ text-align: center;
+
+ font-size: 20px;
+
+ font-weight: 700;
+
+ "
+
+ >
+
+ 계약 체결 진행 품의 요청서 (구매성)
+
+ </th>
+
+ </tr>
+
+ <tr>
+
+ <td
+
+ style="
+
+ padding: 5px 15px;
+
+ text-align: right;
+
+ font-size: 12px;
+
+ color: #666;
+
+ border-bottom: 1px solid #ccc;
+
+ "
+
+ >
+
+ *결재 완료 후 계약 체결을 진행할 수 있습니다.
+
+ <br />
+
+ * 본 계약은 계약 갱신이 불필요하여 만료 알림이 설정되지 않았습니다.
+
+ </td>
+
+ </tr>
+
+ </thead>
+
+ </table>
+
+
+
+ <!-- 2. 계약 기본 정보 -->
+
+ <table
+
+ style="
+
+ width: 100%;
+
+ border-collapse: collapse;
+
+ margin-bottom: 15px;
+
+ "
+
+ >
+
+ <thead>
+
+ <tr>
+
+ <th
+
+ colspan="6"
+
+ style="
+
+ background-color: #333;
+
+ color: #fff;
+
+ padding: 10px;
+
+ text-align: left;
+
+ font-size: 15px;
+
+ font-weight: 600;
+
+ border-bottom: 1px solid #666;
+
+ "
+
+ >
+
+ ■ 계약 기본 정보
+
+ </th>
+
+ </tr>
+
+ </thead>
+
+ <tbody>
+
+ <!-- 1행 -->
+
+ <tr>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ width: 15%;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 계약번호
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ width: 20%;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{계약번호}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ width: 15%;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 계약명
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ width: 20%;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{계약명}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ width: 15%;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 계약체결방식
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ width: 15%;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{계약체결방식}}
+
+ </td>
+
+ </tr>
+
+ <!-- 2행 -->
+
+ <tr>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 계약종류
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{계약종류}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 구매담당자
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{구매담당자}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 업체선정방식
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{업체선정방식}}
+
+ </td>
+
+ </tr>
+
+ <!-- 3행 -->
+
+ <tr>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 입찰번호
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{입찰번호}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 입찰명
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{입찰명}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 계약기간
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{계약기간}}
+
+ </td>
+
+ </tr>
+
+ <!-- 4행 -->
+
+ <tr>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 계약일자
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{계약일자}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 매입 부가가치세
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{매입_부가가치세}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 계약 담당자
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{계약_담당자}}
+
+ </td>
+
+ </tr>
+
+ <!-- 5행 -->
+
+ <tr>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 계약부서
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{계약부서}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 계약 금액
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{계약금액}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ SHI 지급조건
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{SHI_지급조건}}
+
+ </td>
+
+ </tr>
+
+ <!-- 6행 -->
+
+ <tr>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ SHI 인도조건
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{SHI_인도조건}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ SHI 인도조건(옵션)
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{SHI_인도조건_옵션}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 선적지
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{선적지}}
+
+ </td>
+
+ </tr>
+
+ <!-- 7행 -->
+
+ <tr>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 하역지
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{하역지}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 사외업체 야드 투입 여부
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{사외업체_야드_투입여부}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 프로젝트
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{프로젝트}}
+
+ </td>
+
+ </tr>
+
+ <!-- 8행 -->
+
+ <tr>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 직종
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{직종}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 재하도 협력사
+
+ </td>
+
+ <td
+
+ colspan="3"
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{재하도_협력사}}
+
+ </td>
+
+ </tr>
+
+ <!-- 9행: 계약 내용 -->
+
+ <tr>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 계약 내용
+
+ </td>
+
+ <td
+
+ colspan="5"
+
+ style="
+
+ padding: 8px 10px;
+
+ height: 80px;
+
+ border: 1px solid #ccc;
+
+ vertical-align: top;
+
+ "
+
+ >
+
+ {{계약내용}}
+
+ </td>
+
+ </tr>
+
+ </tbody>
+
+ </table>
+
+
+
+ <!-- 3. 계약 협력사 및 담당자 정보 -->
+
+ <table
+
+ style="
+
+ width: 100%;
+
+ border-collapse: collapse;
+
+ margin-bottom: 15px;
+
+ "
+
+ >
+
+ <thead>
+
+ <tr>
+
+ <th
+
+ colspan="6"
+
+ style="
+
+ background-color: #333;
+
+ color: #fff;
+
+ padding: 10px;
+
+ text-align: left;
+
+ font-size: 15px;
+
+ font-weight: 600;
+
+ border-bottom: 1px solid #666;
+
+ "
+
+ >
+
+ ■ 계약 협력사 및 담당자 정보
+
+ </th>
+
+ </tr>
+
+ <tr>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 8px 10px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 15%;
+
+ "
+
+ >
+
+ 협력사 코드
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 8px 10px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 25%;
+
+ "
+
+ >
+
+ 협력사명
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 8px 10px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 15%;
+
+ "
+
+ >
+
+ 담당자
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 8px 10px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 15%;
+
+ "
+
+ >
+
+ 전화번호
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 8px 10px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 20%;
+
+ "
+
+ >
+
+ 이메일
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 8px 10px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 10%;
+
+ "
+
+ >
+
+ 비고
+
+ </th>
+
+ </tr>
+
+ </thead>
+
+ <tbody>
+
+ <!-- 데이터 행 (반복 영역) -->
+
+ <tr>
+
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{협력사코드}}</td>
+
+ <td style="padding: 8px 10px; border: 1px solid #ccc;">{{협력사명}}</td>
+
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{협력사_담당자}}</td>
+
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{전화번호}}</td>
+
+ <td style="padding: 8px 10px; border: 1px solid #ccc;">{{이메일}}</td>
+
+ <td style="padding: 8px 10px; border: 1px solid #ccc;">{{비고}}</td>
+
+ </tr>
+
+ <!-- /데이터 행 -->
+
+ </tbody>
+
+ </table>
+
+
+
+ <!-- 4. 계약 대상 자재 정보 -->
+
+ <table
+
+ style="
+
+ width: 100%;
+
+ border-collapse: collapse;
+
+ margin-bottom: 15px;
+
+ "
+
+ >
+
+ <thead>
+
+ <tr>
+
+ <th
+
+ colspan="15"
+
+ style="
+
+ background-color: #333;
+
+ color: #fff;
+
+ padding: 10px;
+
+ text-align: left;
+
+ font-size: 15px;
+
+ font-weight: 600;
+
+ border-bottom: 1px solid #666;
+
+ "
+
+ >
+
+ ■ 계약 대상 자재 정보 (총 {{대상_자재_수}}건 - 결재본문 내 표시 자재는 100건 이하로 제한되어 있습니다)
+
+ </th>
+
+ </tr>
+
+ <tr style="font-size: 12px;">
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 5%;
+
+ "
+
+ >
+
+ 순번
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 6%;
+
+ "
+
+ >
+
+ 플랜트
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 6%;
+
+ "
+
+ >
+
+ 프로젝트
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 8%;
+
+ "
+
+ >
+
+ 자재그룹
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 10%;
+
+ "
+
+ >
+
+ 자재그룹명
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 10%;
+
+ "
+
+ >
+
+ 자재번호
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 15%;
+
+ "
+
+ >
+
+ 자재상세
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 5%;
+
+ "
+
+ >
+
+ 연간단가 여부
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 5%;
+
+ "
+
+ >
+
+ 수량
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 5%;
+
+ "
+
+ >
+
+ 구매단위
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 10%;
+
+ "
+
+ >
+
+ 계약단가
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 5%;
+
+ "
+
+ >
+
+ 수량단위
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 5%;
+
+ "
+
+ >
+
+ 총중량
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 5%;
+
+ "
+
+ >
+
+ 중량단위
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 10%;
+
+ "
+
+ >
+
+ 계약금액
+
+ </th>
+
+ </tr>
+
+ </thead>
+
+ <tbody>
+
+ <!-- 데이터 행 (반복 영역) -->
+
+ <tr style="font-size: 12px;">
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">1</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{플랜트_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{프로젝트_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{자재그룹_1}}</td>
+
+ <td style="padding: 6px 4px; border: 1px solid #ccc;">{{자재그룹명_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{자재번호_1}}</td>
+
+ <td style="padding: 6px 4px; border: 1px solid #ccc;">{{자재상세_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{연간단가여부_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{수량_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{구매단위_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{계약단가_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{수량단위_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{총중량_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{중량단위_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc; font-weight: 600;">{{계약금액_1}}</td>
+
+ </tr>
+
+ <tr style="font-size: 12px;">
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">2</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{플랜트_2}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{프로젝트_2}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{자재그룹_2}}</td>
+
+ <td style="padding: 6px 4px; border: 1px solid #ccc;">{{자재그룹명_2}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{자재번호_2}}</td>
+
+ <td style="padding: 6px 4px; border: 1px solid #ccc;">{{자재상세_2}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{연간단가여부_2}}</td>
+
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{수량_2}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{구매단위_2}}</td>
+
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{계약단가_2}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{수량단위_2}}</td>
+
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{총중량_2}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{중량단위_2}}</td>
+
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc; font-weight: 600;">{{계약금액_2}}</td>
+
+ </tr>
+
+ <!-- /데이터 행 -->
+
+ <tr>
+
+ <td colspan="14" style="background-color: #f5f5f5; padding: 8px 10px; text-align: center; font-weight: 700; border: 1px solid #ccc;">총 계약 금액</td>
+
+ <td style="padding: 8px 10px; text-align: right; font-weight: 700; border: 1px solid #ccc;">{{총_계약금액}}</td>
+
+ </tr>
+
+ </tbody>
+
+ </table>
+
+
+
+ <!-- 5. 보증 내용 -->
+
+ <!-- <table
+
+ style="
+
+ width: 100%;
+
+ border-collapse: collapse;
+
+ margin-bottom: 15px;
+
+ "
+
+ >
+
+ <thead>
+
+ <tr>
+
+ <th
+
+ colspan="10"
+
+ style="
+
+ background-color: #333;
+
+ color: #fff;
+
+ padding: 10px;
+
+ text-align: left;
+
+ font-size: 15px;
+
+ font-weight: 600;
+
+ border-bottom: 1px solid #666;
+
+ "
+
+ >
+
+ ■ 보증 내용
+
+ </th>
+
+ </tr>
+
+ <tr style="font-size: 12px;">
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 10%;
+
+ "
+
+ >
+
+ 구분
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 5%;
+
+ "
+
+ >
+
+ 차수
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 15%;
+
+ "
+
+ >
+
+ 증권번호
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 7%;
+
+ "
+
+ >
+
+ 보증금율(%)
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 13%;
+
+ "
+
+ >
+
+ 보증 금액
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 10%;
+
+ "
+
+ >
+
+ 보증 기간
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 10%;
+
+ "
+
+ >
+
+ 보증기간 시작일
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 10%;
+
+ "
+
+ >
+
+ 보증기간 종료일
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 15%;
+
+ "
+
+ >
+
+ 발행 기관
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 5%;
+
+ "
+
+ >
+
+ 발행<br>비고<br>지
+
+ </th>
+
+ </tr>
+
+ </thead>
+
+ <tbody>
+
+
+
+ <tr>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ 계약보증
+
+ </td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{계약보증_차수_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{계약보증_증권번호_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{계약보증_보증금율_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{계약보증_보증금액_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{계약보증_보증기간_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{계약보증_시작일_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{계약보증_종료일_1}}</td>
+
+ <td style="padding: 6px 4px; border: 1px solid #ccc;">{{계약보증_발행기관_1}}</td>
+
+ <td style="padding: 6px 4px; border: 1px solid #ccc;">{{계약보증_비고_1}}</td>
+
+ </tr>
+
+
+
+ <tr>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ 지급보증
+
+ </td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{지급보증_차수_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{지급보증_증권번호_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{지급보증_보증금율_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{지급보증_보증금액_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{지급보증_보증기간_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{지급보증_시작일_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{지급보증_종료일_1}}</td>
+
+ <td style="padding: 6px 4px; border: 1px solid #ccc;">{{지급보증_발행기관_1}}</td>
+
+ <td style="padding: 6px 4px; border: 1px solid #ccc;">{{지급보증_비고_1}}</td>
+
+ </tr>
+
+
+
+ <tr>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ 하자보증
+
+ </td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{하자보증_차수_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{하자보증_증권번호_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{하자보증_보증금율_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{하자보증_보증금액_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{하자보증_보증기간_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{하자보증_시작일_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{하자보증_종료일_1}}</td>
+
+ <td style="padding: 6px 4px; border: 1px solid #ccc;">{{하자보증_발행기관_1}}</td>
+
+ <td style="padding: 6px 4px; border: 1px solid #ccc;">{{하자보증_비고_1}}</td>
+
+ </tr>
+
+ </tbody>
+
+ </table>
+
+-->
+
+
+
+ <!-- 6. 하도급 자율점검 Check List -->
+
+ <table
+
+ style="
+
+ width: 100%;
+
+ border-collapse: collapse;
+
+ margin-bottom: 15px;
+
+ "
+
+ >
+
+ <thead>
+
+ <tr>
+
+ <th
+
+ colspan="12"
+
+ style="
+
+ background-color: #333;
+
+ color: #fff;
+
+ padding: 10px;
+
+ text-align: left;
+
+ font-size: 15px;
+
+ font-weight: 600;
+
+ border-bottom: 1px solid #666;
+
+ "
+
+ >
+
+ ■ 하도급 자율점검 Check List
+
+ </th>
+
+ </tr>
+
+ <!-- 헤더 1행: 계약 시 -->
+
+ <tr style="font-size: 12px;">
+
+ <th
+
+ colspan="12"
+
+ style="
+
+ background-color: #d9d9d9;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ 계약 시 [계약체결 단계]
+
+ </th>
+
+ </tr>
+
+ <!-- 헤더 2행 ~ 4행 (복합 구조) -->
+
+ <tr style="font-size: 12px;">
+
+ <!-- 작업 前 서면발급 -->
+
+ <th
+
+ rowspan="3"
+
+ style="
+
+ background-color: #d9d9d9;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 5%;
+
+ "
+
+ >
+
+ 작업 前<br>서면발급
+
+ </th>
+
+ <!-- 1. 계약서면 발급 -->
+
+ <th
+
+ colspan="6"
+
+ style="
+
+ background-color: #d9d9d9;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 30%;
+
+ "
+
+ >
+
+ 1. 계약서면 발급
+
+ </th>
+
+ <!-- 2. 부당 하도급 대금 결정 행위 -->
+
+ <th
+
+ rowspan="3"
+
+ style="
+
+ background-color: #d9d9d9;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 10%;
+
+ "
+
+ >
+
+ 2. 부당하도급대<br>금 결정 행위<br>(대금결정방법)
+
+ </th>
+
+ <!-- 점검 결과 -->
+
+ <th
+
+ rowspan="3"
+
+ style="
+
+ background-color: #d9d9d9;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 10%;
+
+ "
+
+ >
+
+ 점검결과<br>"준수"<br>"위반"<br>"위반의심"
+
+ </th>
+
+ <!-- 위반/위반의심 시 작성 항목 -->
+
+ <th
+
+ colspan="3"
+
+ rowspan="2"
+
+ style="
+
+ background-color: #d9d9d9;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 45%;
+
+ "
+
+ >
+
+ 위반/위반의심 시 a~c 작성 欄
+
+ </th>
+
+ </tr>
+
+ <tr style="font-size: 12px;">
+
+ <!-- 6대 법정 기재사항 -->
+
+ <th
+
+ colspan="6"
+
+ style="
+
+ background-color: #d9d9d9;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ 6대 법정 기재사항 명기 여부
+
+ </th>
+
+ </tr>
+
+ <tr style="font-size: 12px;">
+
+ <!-- 1~6 항목 -->
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ ①위탁일자<br>/위탁내용
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ ②인도시기<br>/장소
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ ③검사방법<br>/시기
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ ④대금지급<br>방법/기일
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ ⑤원재료지급<br>방법/기일
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ ⑥원재료가격변동<br>에 따른 대금조정 등
+
+ </th>
+
+ <!-- a, b, c -->
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ a. 귀책부서
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ b. 원인
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ c. 대책
+
+ </th>
+
+ </tr>
+
+ </thead>
+
+ <tbody>
+
+ <!-- 데이터 행 -->
+
+ <tr style="font-size: 12px;">
+
+ <td
+
+ style="
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{작업전_서면발급_체크}}
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{기재사항_1}}
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{기재사항_2}}
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{기재사항_3}}
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{기재사항_4}}
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{기재사항_5}}
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{기재사항_6}}
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{부당대금_결정}}
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{점검결과}}
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{귀책부서}}
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{원인}}
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{대책}}
+
+ </td>
+
+ </tr>
+
+ </tbody>
+
+ </table>
+
+</div>
+
diff --git a/lib/basic-contract/cpvw-service.ts b/lib/basic-contract/cpvw-service.ts
new file mode 100644
index 00000000..6d249002
--- /dev/null
+++ b/lib/basic-contract/cpvw-service.ts
@@ -0,0 +1,236 @@
+"use server"
+
+import { oracleKnex } from '@/lib/oracle-db/db'
+
+// CPVW_WAB_QUST_LIST_VIEW 테이블 데이터 타입 (실제 테이블 구조에 맞게 조정 필요)
+export interface CPVWWabQustListView {
+ [key: string]: string | number | Date | null | undefined
+}
+
+// 테스트 환경용 폴백 데이터 (실제 CPVW_WAB_QUST_LIST_VIEW 테이블 구조에 맞춤)
+const FALLBACK_TEST_DATA: CPVWWabQustListView[] = [
+ {
+ REG_NO: '1030',
+ INQ_TP: 'OC',
+ INQ_TP_DSC: '해외계약',
+ TIT: 'Contrack of Sale',
+ REQ_DGR: '2',
+ REQR_NM: '김원식',
+ REQ_DT: '20130829',
+ REVIEW_TERM_DT: '20130902',
+ RVWR_NM: '김미정',
+ CNFMR_NM: '안한진',
+ APPR_NM: '염정훈',
+ PRGS_STAT: 'E',
+ PRGS_STAT_DSC: '검토중',
+ REGR_DPTCD: 'D602058000',
+ REGR_DEPTNM: '구매1팀(사외계약)'
+ },
+ {
+ REG_NO: '1076',
+ INQ_TP: 'IC',
+ INQ_TP_DSC: '국내계약',
+ TIT: 'CAISSON PIPE 복관 계약서 검토 요청件',
+ REQ_DGR: '1',
+ REQR_NM: '서권환',
+ REQ_DT: '20130821',
+ REVIEW_TERM_DT: '20130826',
+ RVWR_NM: '이택준',
+ CNFMR_NM: '이택준',
+ APPR_NM: '전상용',
+ PRGS_STAT: 'E',
+ PRGS_STAT_DSC: '완료',
+ REGR_DPTCD: 'D602058000',
+ REGR_DEPTNM: '구매1팀(사외계약)'
+ },
+ {
+ REG_NO: '1100',
+ INQ_TP: 'IC',
+ INQ_TP_DSC: '국내계약',
+ TIT: '(7102) HVAC 작업계약',
+ REQ_DGR: '1',
+ REQR_NM: '신동동',
+ REQ_DT: '20130826',
+ REVIEW_TERM_DT: '20130829',
+ RVWR_NM: '이두리',
+ CNFMR_NM: '이두리',
+ APPR_NM: '전상용',
+ PRGS_STAT: 'E',
+ PRGS_STAT_DSC: '완료',
+ REGR_DPTCD: 'D602058000',
+ REGR_DEPTNM: '구매1팀(사외계약)'
+ },
+ {
+ REG_NO: '1105',
+ INQ_TP: 'IC',
+ INQ_TP_DSC: '국내계약',
+ TIT: 'Plate 가공계약서 검토 요청건',
+ REQ_DGR: '1',
+ REQR_NM: '서권환',
+ REQ_DT: '20130826',
+ REVIEW_TERM_DT: '20130829',
+ RVWR_NM: '백영국',
+ CNFMR_NM: '백영국',
+ APPR_NM: '전상용',
+ PRGS_STAT: 'E',
+ PRGS_STAT_DSC: '완료',
+ REGR_DPTCD: 'D602058000',
+ REGR_DEPTNM: '구매1팀(사외계약)'
+ },
+ {
+ REG_NO: '1106',
+ INQ_TP: 'IC',
+ INQ_TP_DSC: '국내계약',
+ TIT: 'SHELL FLNG, V-BRACE 제작 계약서 검토件',
+ REQ_DGR: '1',
+ REQR_NM: '성기승',
+ REQ_DT: '20130826',
+ REVIEW_TERM_DT: '20130830',
+ RVWR_NM: '이두리',
+ CNFMR_NM: '이두리',
+ APPR_NM: '전상용',
+ PRGS_STAT: 'E',
+ PRGS_STAT_DSC: '완료',
+ REGR_DPTCD: 'D602058000',
+ REGR_DEPTNM: '구매1팀(사외계약)'
+ }
+]
+
+const normalizeOracleRows = (rows: Array<Record<string, unknown>>): CPVWWabQustListView[] => {
+ return rows.map((item) => {
+ const convertedItem: CPVWWabQustListView = {}
+ for (const [key, value] of Object.entries(item)) {
+ if (value instanceof Date) {
+ convertedItem[key] = value
+ } else if (value === null) {
+ convertedItem[key] = null
+ } else {
+ convertedItem[key] = String(value)
+ }
+ }
+ return convertedItem
+ })
+}
+
+/**
+ * CPVW_WAB_QUST_LIST_VIEW 테이블 전체 조회
+ * @returns 테이블 데이터 배열
+ */
+export async function getCPVWWabQustListViewData(): Promise<{
+ success: boolean
+ data: CPVWWabQustListView[]
+ error?: string
+ isUsingFallback?: boolean
+}> {
+ try {
+ console.log('📋 [getCPVWWabQustListViewData] CPVW_WAB_QUST_LIST_VIEW 테이블 조회 시작...')
+
+ const result = await oracleKnex.raw(`
+ SELECT *
+ FROM CPVW_WAB_QUST_LIST_VIEW
+ WHERE ROWNUM < 100
+ ORDER BY 1
+ `)
+
+ // Oracle raw query의 결과는 rows 배열에 들어있음
+ const rows = (result.rows || result) as Array<Record<string, unknown>>
+
+ console.log(`✅ [getCPVWWabQustListViewData] 조회 성공 - ${rows.length}건`)
+
+ // 데이터 타입 변환 (필요에 따라 조정)
+ const cleanedResult = normalizeOracleRows(rows)
+
+ return {
+ success: true,
+ data: cleanedResult,
+ isUsingFallback: false
+ }
+ } catch (error) {
+ console.error('❌ [getCPVWWabQustListViewData] 오류:', error)
+ console.log('🔄 [getCPVWWabQustListViewData] 폴백 테스트 데이터 사용')
+ return {
+ success: true,
+ data: FALLBACK_TEST_DATA,
+ isUsingFallback: true
+ }
+ }
+}
+
+export async function getCPVWWabQustListViewByRegNo(regNo: string): Promise<{
+ success: boolean
+ data?: CPVWWabQustListView
+ error?: string
+ isUsingFallback?: boolean
+}> {
+ if (!regNo) {
+ return {
+ success: false,
+ error: 'REG_NO는 필수입니다.'
+ }
+ }
+
+ try {
+ console.log(`[getCPVWWabQustListViewByRegNo] REG_NO=${regNo} 조회`)
+ const result = await oracleKnex.raw(
+ `
+ SELECT *
+ FROM CPVW_WAB_QUST_LIST_VIEW
+ WHERE REG_NO = :regNo
+ `,
+ { regNo }
+ )
+
+ const rows = (result.rows || result) as Array<Record<string, unknown>>
+ const cleanedResult = normalizeOracleRows(rows)
+
+ if (cleanedResult.length === 0) {
+ // 데이터가 없을 때 폴백 테스트 데이터에서 찾기
+ console.log(`[getCPVWWabQustListViewByRegNo] 데이터 없음, 폴백 테스트 데이터에서 검색: REG_NO=${regNo}`)
+ const fallbackData = FALLBACK_TEST_DATA.find(item =>
+ String(item.REG_NO) === String(regNo)
+ )
+
+ if (fallbackData) {
+ console.log(`[getCPVWWabQustListViewByRegNo] 폴백 테스트 데이터에서 찾음: REG_NO=${regNo}`)
+ return {
+ success: true,
+ data: fallbackData,
+ isUsingFallback: true
+ }
+ }
+
+ return {
+ success: false,
+ error: '해당 REG_NO에 대한 데이터가 없습니다.'
+ }
+ }
+
+ return {
+ success: true,
+ data: cleanedResult[0],
+ isUsingFallback: false
+ }
+ } catch (error) {
+ console.error('[getCPVWWabQustListViewByRegNo] 오류:', error)
+ console.log(`[getCPVWWabQustListViewByRegNo] 폴백 테스트 데이터에서 검색: REG_NO=${regNo}`)
+
+ // 오류 발생 시 폴백 테스트 데이터에서 찾기
+ const fallbackData = FALLBACK_TEST_DATA.find(item =>
+ String(item.REG_NO) === String(regNo)
+ )
+
+ if (fallbackData) {
+ console.log(`[getCPVWWabQustListViewByRegNo] 폴백 테스트 데이터에서 찾음: REG_NO=${regNo}`)
+ return {
+ success: true,
+ data: fallbackData,
+ isUsingFallback: true
+ }
+ }
+
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'REG_NO 조회 중 오류가 발생했습니다.'
+ }
+ }
+}
diff --git a/lib/basic-contract/service.ts b/lib/basic-contract/service.ts
index 6f4e5d53..12278c54 100644
--- a/lib/basic-contract/service.ts
+++ b/lib/basic-contract/service.ts
@@ -2862,6 +2862,10 @@ export async function requestLegalReviewAction(
}
}
+// ⚠️ SSLVW(법무관리시스템) PRGS_STAT_DSC 문자열을 그대로 저장하는 함수입니다.
+// - 상태 텍스트 및 완료 여부는 외부 시스템에 의존하므로 신뢰도가 100%는 아니고,
+// - 여기에서 관리하는 값들은 UI 표시/참고용으로만 사용해야 합니다.
+// - 최종 승인 차단 등 핵심 비즈니스 로직에서는 SSLVW 쪽 완료 시간을 직접 신뢰하지 않습니다.
const persistLegalReviewStatus = async ({
contractId,
regNo,
@@ -2904,6 +2908,121 @@ const persistLegalReviewStatus = async ({
}
/**
+ * 준법문의 요청 서버 액션
+ */
+export async function requestComplianceInquiryAction(
+ contractIds: number[]
+): Promise<{ success: boolean; message: string }> {
+ const session = await getServerSession(authOptions)
+
+ if (!session?.user) {
+ return {
+ success: false,
+ message: "로그인이 필요합니다."
+ }
+ }
+
+ // 계약서 정보 조회
+ const contracts = await db
+ .select({
+ id: basicContractView.id,
+ complianceReviewRequestedAt: basicContractView.complianceReviewRequestedAt,
+ })
+ .from(basicContractView)
+ .where(inArray(basicContractView.id, contractIds))
+
+ if (contracts.length === 0) {
+ return {
+ success: false,
+ message: "선택된 계약서를 찾을 수 없습니다."
+ }
+ }
+
+ // 준법문의 요청 가능한 계약서 필터링 (이미 요청되지 않은 것만)
+ const eligibleContracts = contracts.filter(contract =>
+ !contract.complianceReviewRequestedAt
+ )
+
+ if (eligibleContracts.length === 0) {
+ return {
+ success: false,
+ message: "준법문의 요청 가능한 계약서가 없습니다."
+ }
+ }
+
+ const currentDate = new Date()
+
+ // 트랜잭션으로 처리
+ await db.transaction(async (tx) => {
+ for (const contract of eligibleContracts) {
+ await tx
+ .update(basicContract)
+ .set({
+ complianceReviewRequestedAt: currentDate,
+ updatedAt: currentDate,
+ })
+ .where(eq(basicContract.id, contract.id))
+ }
+ })
+
+ revalidateTag("basic-contracts")
+
+ return {
+ success: true,
+ message: `${eligibleContracts.length}건의 준법문의 요청이 완료되었습니다.`
+ }
+}
+
+/**
+ * 준법문의 상태 저장 (준법문의 전용 필드 사용)
+ */
+const persistComplianceReviewStatus = async ({
+ contractId,
+ regNo,
+ progressStatus,
+}: {
+ contractId: number
+ regNo: string
+ progressStatus: string
+}) => {
+ const now = new Date()
+
+ // 완료 상태 확인 (법무검토와 동일한 패턴)
+ // ⚠️ CPVW PRGS_STAT_DSC 문자열을 기반으로 한 best-effort 휴리스틱입니다.
+ // - 외부 시스템의 상태 텍스트에 의존하므로 신뢰도가 100%는 아니고,
+ // - 여기에서 설정하는 완료 시간(complianceReviewCompletedAt)은 UI 표시용으로만 사용해야 합니다.
+ // - 버튼 활성화, 서버 액션 차단, 필터 조건 등 핵심 비즈니스 로직에서는
+ // 이 값을 신뢰하지 않도록 합니다.
+ // 완료 상태 확인 (법무검토와 동일한 패턴)
+ const isCompleted = progressStatus && (
+ progressStatus.includes('완료') ||
+ progressStatus.includes('승인') ||
+ progressStatus.includes('종료')
+ )
+
+ await db.transaction(async (tx) => {
+ // 준법문의 상태 업데이트 (준법문의 전용 필드 사용)
+ const updateData: any = {
+ complianceReviewRegNo: regNo,
+ complianceReviewProgressStatus: progressStatus,
+ updatedAt: now,
+ }
+
+ // 완료 상태인 경우 완료일 설정
+ if (isCompleted) {
+ updateData.complianceReviewCompletedAt = now
+ }
+
+ await tx
+ .update(basicContract)
+ .set(updateData)
+ .where(eq(basicContract.id, contractId))
+ })
+
+ revalidateTag("basic-contracts")
+}
+
+/**
* SSLVW 데이터로부터 법무검토 상태 업데이트
* @param sslvwData 선택된 SSLVW 데이터 배열
* @param selectedContractIds 선택된 계약서 ID 배열
@@ -3033,6 +3152,137 @@ export async function updateLegalReviewStatusFromSSLVW(
}
}
+/**
+ * CPVW 데이터로부터 준법문의 상태 업데이트
+ * @param cpvwData 선택된 CPVW 데이터 배열
+ * @param selectedContractIds 선택된 계약서 ID 배열
+ * @returns 성공 여부 및 메시지
+ */
+export async function updateComplianceReviewStatusFromCPVW(
+ cpvwData: Array<{ REG_NO?: string; reg_no?: string; PRGS_STAT_DSC?: string; prgs_stat_dsc?: string; [key: string]: any }>,
+ selectedContractIds: number[]
+): Promise<{ success: boolean; message: string; updatedCount: number; errors: string[] }> {
+ try {
+ console.log(`[updateComplianceReviewStatusFromCPVW] CPVW 데이터로부터 준법문의 상태 업데이트 시작`)
+
+ if (!cpvwData || cpvwData.length === 0) {
+ return {
+ success: false,
+ message: 'CPVW 데이터가 없습니다.',
+ updatedCount: 0,
+ errors: []
+ }
+ }
+
+ if (!selectedContractIds || selectedContractIds.length === 0) {
+ return {
+ success: false,
+ message: '선택된 계약서가 없습니다.',
+ updatedCount: 0,
+ errors: []
+ }
+ }
+
+ if (selectedContractIds.length !== 1) {
+ return {
+ success: false,
+ message: '한 개의 계약서만 선택해 주세요.',
+ updatedCount: 0,
+ errors: []
+ }
+ }
+
+ if (cpvwData.length !== 1) {
+ return {
+ success: false,
+ message: '준법문의 시스템 데이터도 한 건만 선택해 주세요.',
+ updatedCount: 0,
+ errors: []
+ }
+ }
+
+ const contractId = selectedContractIds[0]
+ const cpvwItem = cpvwData[0]
+ const regNo = String(
+ cpvwItem.REG_NO ??
+ cpvwItem.reg_no ??
+ cpvwItem.RegNo ??
+ ''
+ ).trim()
+ const progressStatus = String(
+ cpvwItem.PRGS_STAT_DSC ??
+ cpvwItem.prgs_stat_dsc ??
+ cpvwItem.PrgsStatDsc ??
+ ''
+ ).trim()
+
+ if (!regNo) {
+ return {
+ success: false,
+ message: 'REG_NO 값을 찾을 수 없습니다.',
+ updatedCount: 0,
+ errors: []
+ }
+ }
+
+ if (!progressStatus) {
+ return {
+ success: false,
+ message: 'PRGS_STAT_DSC 값을 찾을 수 없습니다.',
+ updatedCount: 0,
+ errors: []
+ }
+ }
+
+ const contract = await db
+ .select({
+ id: basicContract.id,
+ complianceReviewRegNo: basicContract.complianceReviewRegNo,
+ })
+ .from(basicContract)
+ .where(eq(basicContract.id, contractId))
+ .limit(1)
+
+ if (!contract[0]) {
+ return {
+ success: false,
+ message: `계약서(${contractId})를 찾을 수 없습니다.`,
+ updatedCount: 0,
+ errors: []
+ }
+ }
+
+ if (contract[0].complianceReviewRegNo && contract[0].complianceReviewRegNo !== regNo) {
+ console.warn(`[updateComplianceReviewStatusFromCPVW] REG_NO가 변경됩니다: ${contract[0].complianceReviewRegNo} -> ${regNo}`)
+ }
+
+ // 준법문의 상태 업데이트
+ await persistComplianceReviewStatus({
+ contractId,
+ regNo,
+ progressStatus,
+ })
+
+ console.log(`[updateComplianceReviewStatusFromCPVW] 완료: 계약서 ${contractId}, REG_NO ${regNo}, 상태 ${progressStatus}`)
+
+ return {
+ success: true,
+ message: '준법문의 상태가 업데이트되었습니다.',
+ updatedCount: 1,
+ errors: []
+ }
+
+ } catch (error) {
+ console.error('[updateComplianceReviewStatusFromCPVW] 오류:', error)
+ return {
+ success: false,
+ message: '준법문의 상태 업데이트 중 오류가 발생했습니다.',
+ updatedCount: 0,
+ errors: [error instanceof Error ? error.message : '알 수 없는 오류']
+ }
+ }
+}
+
export async function refreshLegalReviewStatusFromOracle(contractId: number): Promise<{
success: boolean
message: string
@@ -3274,12 +3524,9 @@ export async function processBuyerSignatureAction(
}
}
- if (contractData.legalReviewRequestedAt && !contractData.legalReviewCompletedAt) {
- return {
- success: false,
- message: "법무검토가 완료되지 않았습니다."
- }
- }
+ // ⚠️ 법무검토 완료 여부는 SSLVW 상태/시간에 의존하므로
+ // 여기서는 legalReviewCompletedAt 기반으로 최종승인을 막지 않습니다.
+ // (법무 상태는 UI에서 참고 정보로만 사용)
// 파일 저장 로직 (기존 파일 덮어쓰기)
const saveResult = await saveBuffer({
@@ -3373,9 +3620,9 @@ export async function prepareFinalApprovalAction(
if (contract.completedAt !== null || !contract.signedFilePath) {
return false
}
- if (contract.legalReviewRequestedAt && !contract.legalReviewCompletedAt) {
- return false
- }
+ // ⚠️ 법무검토 완료 여부는 SSLVW 상태/시간에 의존하므로
+ // 여기서는 legalReviewCompletedAt 기반으로 필터링하지 않습니다.
+ // (법무 상태는 UI에서 참고 정보로만 사용)
return true
})
@@ -3949,6 +4196,8 @@ export async function saveGtcDocumentAction({
buyerSignedAt: null,
legalReviewRequestedAt: null,
legalReviewCompletedAt: null,
+ complianceReviewRequestedAt: null,
+ complianceReviewCompletedAt: null,
updatedAt: new Date()
})
.where(eq(basicContract.id, documentId))
diff --git a/lib/basic-contract/sslvw-service.ts b/lib/basic-contract/sslvw-service.ts
index 38ecb67d..08b43f82 100644
--- a/lib/basic-contract/sslvw-service.ts
+++ b/lib/basic-contract/sslvw-service.ts
@@ -10,18 +10,89 @@ export interface SSLVWPurInqReq {
// 테스트 환경용 폴백 데이터
const FALLBACK_TEST_DATA: SSLVWPurInqReq[] = [
{
- id: 1,
- request_number: 'REQ001',
- status: 'PENDING',
- created_date: new Date('2025-01-01'),
- description: '테스트 요청 1'
+ REG_NO: '1030',
+ INQ_TP: 'OC',
+ INQ_TP_DSC: '해외계약',
+ TIT: 'Contrack of Sale',
+ REQ_DGR: '2',
+ REQR_NM: '김원식',
+ REQ_DT: '20130829',
+ REVIEW_TERM_DT: '20130902',
+ RVWR_NM: '김미정',
+ CNFMR_NM: '안한진',
+ APPR_NM: '염정훈',
+ PRGS_STAT: 'E',
+ PRGS_STAT_DSC: '검토중이라고',
+ REGR_DPTCD: 'D602058000',
+ REGR_DEPTNM: '구매1팀(사외계약)'
},
{
- id: 2,
- request_number: 'REQ002',
- status: 'APPROVED',
- created_date: new Date('2025-01-02'),
- description: '테스트 요청 2'
+ REG_NO: '1076',
+ INQ_TP: 'IC',
+ INQ_TP_DSC: '국내계약',
+ TIT: 'CAISSON PIPE 복관 계약서 검토 요청件',
+ REQ_DGR: '1',
+ REQR_NM: '서권환',
+ REQ_DT: '20130821',
+ REVIEW_TERM_DT: '20130826',
+ RVWR_NM: '이택준',
+ CNFMR_NM: '이택준',
+ APPR_NM: '전상용',
+ PRGS_STAT: 'E',
+ PRGS_STAT_DSC: '완료',
+ REGR_DPTCD: 'D602058000',
+ REGR_DEPTNM: '구매1팀(사외계약)'
+ },
+ {
+ REG_NO: '1100',
+ INQ_TP: 'IC',
+ INQ_TP_DSC: '국내계약',
+ TIT: '(7102) HVAC 작업계약',
+ REQ_DGR: '1',
+ REQR_NM: '신동동',
+ REQ_DT: '20130826',
+ REVIEW_TERM_DT: '20130829',
+ RVWR_NM: '이두리',
+ CNFMR_NM: '이두리',
+ APPR_NM: '전상용',
+ PRGS_STAT: 'E',
+ PRGS_STAT_DSC: '완료',
+ REGR_DPTCD: 'D602058000',
+ REGR_DEPTNM: '구매1팀(사외계약)'
+ },
+ {
+ REG_NO: '1105',
+ INQ_TP: 'IC',
+ INQ_TP_DSC: '국내계약',
+ TIT: 'Plate 가공계약서 검토 요청건',
+ REQ_DGR: '1',
+ REQR_NM: '서권환',
+ REQ_DT: '20130826',
+ REVIEW_TERM_DT: '20130829',
+ RVWR_NM: '백영국',
+ CNFMR_NM: '백영국',
+ APPR_NM: '전상용',
+ PRGS_STAT: 'E',
+ PRGS_STAT_DSC: '완료',
+ REGR_DPTCD: 'D602058000',
+ REGR_DEPTNM: '구매1팀(사외계약)'
+ },
+ {
+ REG_NO: '1106',
+ INQ_TP: 'IC',
+ INQ_TP_DSC: '국내계약',
+ TIT: 'SHELL FLNG, V-BRACE 제작 계약서 검토件',
+ REQ_DGR: '1',
+ REQR_NM: '성기승',
+ REQ_DT: '20130826',
+ REVIEW_TERM_DT: '20130830',
+ RVWR_NM: '이두리',
+ CNFMR_NM: '이두리',
+ APPR_NM: '전상용',
+ PRGS_STAT: 'E',
+ PRGS_STAT_DSC: '완료',
+ REGR_DPTCD: 'D602058000',
+ REGR_DEPTNM: '구매1팀(사외계약)'
}
]
@@ -89,6 +160,7 @@ export async function getSSLVWPurInqReqByRegNo(regNo: string): Promise<{
success: boolean
data?: SSLVWPurInqReq
error?: string
+ isUsingFallback?: boolean
}> {
if (!regNo) {
return {
@@ -112,6 +184,21 @@ export async function getSSLVWPurInqReqByRegNo(regNo: string): Promise<{
const cleanedResult = normalizeOracleRows(rows)
if (cleanedResult.length === 0) {
+ // 데이터가 없을 때 폴백 테스트 데이터에서 찾기
+ console.log(`[getSSLVWPurInqReqByRegNo] 데이터 없음, 폴백 테스트 데이터에서 검색: REG_NO=${regNo}`)
+ const fallbackData = FALLBACK_TEST_DATA.find(item =>
+ String(item.REG_NO) === String(regNo)
+ )
+
+ if (fallbackData) {
+ console.log(`[getSSLVWPurInqReqByRegNo] 폴백 테스트 데이터에서 찾음: REG_NO=${regNo}`)
+ return {
+ success: true,
+ data: fallbackData,
+ isUsingFallback: true
+ }
+ }
+
return {
success: false,
error: '해당 REG_NO에 대한 데이터가 없습니다.'
@@ -120,10 +207,27 @@ export async function getSSLVWPurInqReqByRegNo(regNo: string): Promise<{
return {
success: true,
- data: cleanedResult[0]
+ data: cleanedResult[0],
+ isUsingFallback: false
}
} catch (error) {
console.error('[getSSLVWPurInqReqByRegNo] 오류:', error)
+ console.log(`[getSSLVWPurInqReqByRegNo] 폴백 테스트 데이터에서 검색: REG_NO=${regNo}`)
+
+ // 오류 발생 시 폴백 테스트 데이터에서 찾기
+ const fallbackData = FALLBACK_TEST_DATA.find(item =>
+ String(item.REG_NO) === String(regNo)
+ )
+
+ if (fallbackData) {
+ console.log(`[getSSLVWPurInqReqByRegNo] 폴백 테스트 데이터에서 찾음: REG_NO=${regNo}`)
+ return {
+ success: true,
+ data: fallbackData,
+ isUsingFallback: true
+ }
+ }
+
return {
success: false,
error: error instanceof Error ? error.message : 'REG_NO 조회 중 오류가 발생했습니다.'
diff --git a/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx b/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx
index 575582cf..77e36bc7 100644
--- a/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx
+++ b/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx
@@ -18,9 +18,10 @@ import {
DialogTitle,
} from "@/components/ui/dialog"
import { Badge } from "@/components/ui/badge"
-import { prepareFinalApprovalAction, quickFinalApprovalAction, resendContractsAction, updateLegalReviewStatusFromSSLVW } from "../service"
+import { prepareFinalApprovalAction, quickFinalApprovalAction, resendContractsAction, updateLegalReviewStatusFromSSLVW, updateComplianceReviewStatusFromCPVW, requestComplianceInquiryAction } from "../service"
import { BasicContractSignDialog } from "../vendor-table/basic-contract-sign-dialog"
import { SSLVWPurInqReqDialog } from "@/components/common/legal/sslvw-pur-inq-req-dialog"
+import { CPVWWabQustListViewDialog } from "@/components/common/legal/cpvw-wab-qust-list-view-dialog"
import { prepareRedFlagResolutionApproval, requestRedFlagResolution } from "@/lib/compliance/red-flag-resolution"
import { useRouter } from "next/navigation"
import { useSession } from "next-auth/react"
@@ -81,47 +82,17 @@ export function BasicContractDetailTableToolbarActions({
if (contract.completedAt !== null || !contract.signedFilePath) {
return false;
}
- if (contract.legalReviewRequestedAt && !contract.legalReviewCompletedAt) {
- return false;
- }
+ // ⚠️ 법무/준법문의 완료 여부는 SSLVW/CPVW 상태 및 완료 시간에 의존하므로,
+ // 여기서는 legalReviewCompletedAt / complianceReviewCompletedAt 기반으로
+ // 최종 승인 버튼을 막지 않습니다. (상태/시간은 UI 참고용으로만 사용)
return true;
});
- // 법무검토 요청 가능 여부
- // 1. 협의 완료됨 (negotiationCompletedAt 있음) OR
- // 2. 협의 없음 (코멘트 없음, hasComments: false)
- // 협의 중 (negotiationCompletedAt 없고 코멘트 있음)은 불가
- const canRequestLegalReview = hasSelectedRows && selectedRows.some(row => {
- const contract = row.original;
- // 이미 법무검토 요청된 계약서는 제외
- if (contract.legalReviewRequestedAt) {
- return false;
- }
- // 이미 최종승인 완료된 계약서는 제외
- if (contract.completedAt) {
- return false;
- }
-
- // 협의 완료된 경우 → 가능
- if (contract.negotiationCompletedAt) {
- return true;
- }
-
- // 협의 완료되지 않은 경우
- // GTC 템플릿인 경우 코멘트 존재 여부 확인
- if (contract.templateName?.includes('GTC')) {
- const contractGtcData = gtcData[contract.id];
- // 코멘트가 없으면 가능 (협의 없음)
- if (contractGtcData && !contractGtcData.hasComments) {
- return true;
- }
- // 코멘트가 있으면 불가 (협의 중)
- return false;
- }
-
- // GTC가 아닌 경우는 협의 완료 여부만 확인
- return false;
- });
+ // 법무검토 요청 가능 여부: 준법서약 템플릿이 아닐 때 항상 활성화
+ const canRequestLegalReview = !isComplianceTemplate;
+
+ // 준법문의 버튼 활성화 가능 여부: 준법서약 템플릿일 때 항상 활성화
+ const canRequestComplianceInquiry = isComplianceTemplate;
// 필터링된 계약서들 계산
const resendContracts = selectedRows.map(row => row.original)
@@ -394,6 +365,47 @@ export function BasicContractDetailTableToolbarActions({
}
}
+ // CPVW 데이터 선택 확인 핸들러
+ const handleCPVWConfirm = async (selectedCPVWData: any[]) => {
+ if (!selectedCPVWData || selectedCPVWData.length === 0) {
+ toast.error("선택된 데이터가 없습니다.")
+ return
+ }
+
+ if (selectedRows.length !== 1) {
+ toast.error("계약서 한 건을 선택해주세요.")
+ return
+ }
+
+ try {
+ setLoading(true)
+
+ // 선택된 계약서 ID들 추출
+ const selectedContractIds = selectedRows.map(row => row.original.id)
+
+ // 서버 액션 호출
+ const result = await updateComplianceReviewStatusFromCPVW(selectedCPVWData, selectedContractIds)
+
+ if (result.success) {
+ toast.success(result.message)
+ router.refresh()
+ table.toggleAllPageRowsSelected(false)
+ } else {
+ toast.error(result.message)
+ }
+
+ if (result.errors && result.errors.length > 0) {
+ toast.warning(`일부 처리 실패: ${result.errors.join(', ')}`)
+ }
+
+ } catch (error) {
+ console.error('CPVW 확인 처리 실패:', error)
+ toast.error('준법문의 상태 업데이트 중 오류가 발생했습니다.')
+ } finally {
+ setLoading(false)
+ }
+ }
+
// 빠른 승인 (서명 없이)
const confirmQuickApproval = async () => {
setLoading(true)
@@ -541,11 +553,34 @@ export function BasicContractDetailTableToolbarActions({
const complianceInquiryUrl = 'http://60.101.207.55/Inquiry/Write/InquiryWrite.aspx'
// 법무검토 요청 / 준법문의
- const handleRequestLegalReview = () => {
+ const handleRequestLegalReview = async () => {
if (isComplianceTemplate) {
+ // 준법문의: 선택된 계약서가 있으면 요청일 기록 후 외부 URL 열기, 없으면 URL만 열기
+ const selectedContractIds = selectedRows.map(row => row.original.id)
+
+ if (selectedContractIds.length > 0) {
+ try {
+ setLoading(true)
+ const result = await requestComplianceInquiryAction(selectedContractIds)
+ if (result.success) {
+ toast.success(result.message)
+ router.refresh()
+ } else {
+ toast.error(result.message)
+ }
+ } catch (error) {
+ console.error('준법문의 요청 처리 실패:', error)
+ toast.error('준법문의 요청 중 오류가 발생했습니다.')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ // 선택된 계약서가 있든 없든 URL은 항상 열기
window.open(complianceInquiryUrl, '_blank', 'noopener,noreferrer')
return
}
+ // 법무검토 요청: 선택된 행이 없어도 다이얼로그 열기
setLegalReviewDialog(true)
}
@@ -617,31 +652,64 @@ export function BasicContractDetailTableToolbarActions({
</span>
</Button>
- {/* 법무검토 버튼 (SSLVW 데이터 조회) */}
- <SSLVWPurInqReqDialog
- onConfirm={handleSSLVWConfirm}
- requireSingleSelection
- triggerDisabled={selectedRows.length !== 1 || loading}
- triggerTitle={
- selectedRows.length !== 1
- ? "계약서 한 건을 선택해주세요"
- : undefined
- }
- />
+ {/* 법무검토 버튼 (SSLVW 데이터 조회) - 준법서약 템플릿이 아닐 때만 표시 */}
+ {!isComplianceTemplate && (
+ <SSLVWPurInqReqDialog
+ onConfirm={handleSSLVWConfirm}
+ requireSingleSelection
+ triggerDisabled={selectedRows.length !== 1 || loading}
+ triggerTitle={
+ selectedRows.length !== 1
+ ? "계약서 한 건을 선택해주세요"
+ : undefined
+ }
+ />
+ )}
+
+ {/* 준법문의 요청 데이터 조회 버튼 (준법서약 템플릿만) */}
+ {isComplianceTemplate && (
+ <CPVWWabQustListViewDialog
+ onConfirm={handleCPVWConfirm}
+ requireSingleSelection
+ triggerDisabled={selectedRows.length !== 1 || loading}
+ triggerTitle={
+ selectedRows.length !== 1
+ ? "계약서 한 건을 선택해주세요"
+ : undefined
+ }
+ />
+ )}
{/* 법무검토 요청 / 준법문의 버튼 */}
- <Button
- variant="outline"
- size="sm"
- onClick={handleRequestLegalReview}
- className="gap-2"
- title={isComplianceTemplate ? "준법문의 링크로 이동" : "법무검토 요청 링크 선택"}
- >
- <FileText className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">
- {isComplianceTemplate ? "준법문의" : "법무검토 요청"}
- </span>
- </Button>
+ {isComplianceTemplate ? (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleRequestLegalReview}
+ className="gap-2"
+ disabled={loading}
+ title="준법문의 링크로 이동"
+ >
+ <FileText className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">
+ 준법문의
+ </span>
+ </Button>
+ ) : (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleRequestLegalReview}
+ className="gap-2"
+ disabled={loading}
+ title="법무검토 요청 링크 선택"
+ >
+ <FileText className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">
+ 법무검토 요청
+ </span>
+ </Button>
+ )}
{/* 최종승인 버튼 */}
<Button
diff --git a/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx b/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx
index aab808b8..de6ba1a9 100644
--- a/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx
+++ b/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx
@@ -553,8 +553,8 @@ export function getDetailColumns({
minSize: 130,
},
- // 법무검토 상태
- {
+ // 법무검토 상태 (준법서약 템플릿이 아닐 때만 표시)
+ ...(!isComplianceTemplate ? [{
accessorKey: "legalReviewStatus",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="법무검토 상태" />
@@ -571,7 +571,30 @@ export function getDetailColumns({
return <div className="text-sm text-gray-400">-</div>
},
minSize: 140,
+ }] : []),
+
+ // 준법문의 상태 (준법서약 템플릿일 때만 표시)
+ ...(isComplianceTemplate ? [{
+ accessorKey: "complianceReviewStatus",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="준법문의 상태" />
+ ),
+ cell: ({ row }) => {
+ const status = row.getValue("complianceReviewStatus") as string | null
+
+ // PRGS_STAT_DSC 연동값 우선 표시
+ if (status) {
+ return <div className="text-sm text-gray-800">{status}</div>
+ }
+
+ // 동기화된 값이 없으면 빈 값 처리
+ return <div className="text-sm text-gray-400">-</div>
+ },
+ minSize: 140,
},
+ // Red Flag 컬럼들 (준법서약 템플릿일 때만 표시)
+ redFlagColumn,
+ redFlagResolutionColumn] : []),
// 계약완료일
{
@@ -659,17 +682,5 @@ export function getDetailColumns({
actionsColumn,
]
- // 준법서약 템플릿인 경우 Red Flag 컬럼과 해제 컬럼을 법무검토 상태 뒤에 추가
- if (isComplianceTemplate) {
- const legalReviewStatusIndex = baseColumns.findIndex((col) => {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- return (col as any).accessorKey === 'legalReviewStatus'
- })
-
- if (legalReviewStatusIndex !== -1) {
- baseColumns.splice(legalReviewStatusIndex + 1, 0, redFlagColumn, redFlagResolutionColumn)
- }
- }
-
return baseColumns
} \ No newline at end of file
diff --git a/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx b/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx
index cface6b3..c6fe1cdd 100644
--- a/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx
+++ b/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx
@@ -241,6 +241,7 @@ type RedFlagResolutionState = {
<BasicContractDetailTableToolbarActions
table={table}
gtcData={gtcData}
+ agreementCommentData={agreementCommentData}
redFlagData={redFlagData}
redFlagResolutionData={redFlagResolutionData}
isComplianceTemplate={isComplianceTemplate}
diff --git a/lib/bidding/actions.ts b/lib/bidding/actions.ts
index 4e7da36c..64dc3aa8 100644
--- a/lib/bidding/actions.ts
+++ b/lib/bidding/actions.ts
@@ -20,7 +20,7 @@ import { createPurchaseOrder } from "@/lib/soap/ecc/send/create-po-bidding"
import { getCurrentSAPDate } from "@/lib/soap/utils"
import { generateContractNumber } from "@/lib/general-contracts/service"
import { saveFile } from "@/lib/file-stroage"
-
+import { checkAndSaveChemicalSubstancesForBidding } from "./service"
// TO Contract
export async function transmitToContract(biddingId: number, userId: number) {
try {
@@ -125,6 +125,11 @@ export async function transmitToContract(biddingId: number, userId: number) {
const contractNumber = await generateContractNumber(safeUserId, biddingData.contractType)
console.log('Generated contractNumber:', contractNumber)
+ // 연동제 여부 변환 (boolean -> Y/N)
+ const interlockingSystem = biddingCondition?.isPriceAdjustmentApplicable
+ ? 'Y'
+ : (biddingCondition?.isPriceAdjustmentApplicable === false ? 'N' : null)
+
// general-contract 생성 (발주비율 계산된 최종 금액 사용)
const contractResult = await db.insert(generalContracts).values({
contractNumber,
@@ -141,10 +146,13 @@ export async function transmitToContract(biddingId: number, userId: number) {
currency: biddingData.currency || 'KRW',
// 계약 조건 정보 추가
paymentTerm: biddingCondition?.paymentTerms || null,
+ paymentDelivery: biddingCondition?.paymentTerms || null, // 지급조건 (납품 지급조건)
taxType: biddingCondition?.taxConditions || 'V0',
deliveryTerm: biddingCondition?.incoterms || 'FOB',
shippingLocation: biddingCondition?.shippingPort || null,
dischargeLocation: biddingCondition?.destinationPort || null,
+ contractDeliveryDate: biddingCondition?.contractDeliveryDate || null, // 계약납기일
+ interlockingSystem: interlockingSystem, // 연동제 여부
registeredById: userId,
lastUpdatedById: userId,
}).returning({ id: generalContracts.id })
@@ -644,7 +652,7 @@ export async function cancelDisposalAction(
}
// 사용자 이름 조회 헬퍼 함수
-async function getUserNameById(userId: string): Promise<string> {
+export async function getUserNameById(userId: string): Promise<string> {
try {
const user = await db
.select({ name: users.name })
@@ -730,6 +738,26 @@ export async function openBiddingAction(biddingId: number) {
})
.where(eq(biddings.id, biddingId))
+ // 4. 화학물질 조회 실행 (비동기로 실행해서 개찰 성능에 영향 없도록)
+ try {
+ // 개찰 트랜잭션이 완료된 후 화학물질 조회 시작
+ setImmediate(async () => {
+ try {
+ const result = await checkAndSaveChemicalSubstancesForBidding(biddingId)
+ if (result.success) {
+ console.log(`입찰 ${biddingId} 화학물질 조회 완료: ${result.results.filter(r => r.success).length}/${result.results.length}개 업체`)
+ } else {
+ console.error(`입찰 ${biddingId} 화학물질 조회 실패:`, result.message)
+ }
+ } catch (error) {
+ console.error(`입찰 ${biddingId} 화학물질 조회 중 오류:`, error)
+ }
+ })
+ } catch (error) {
+ // 화학물질 조회 실패해도 개찰은 성공으로 처리
+ console.error('화학물질 조회 시작 실패:', error)
+ }
+
return { success: true, message: isDeadlinePassed ? '개찰이 완료되었습니다.' : '조기개찰이 완료되었습니다.' }
})
diff --git a/lib/bidding/approval-actions.ts b/lib/bidding/approval-actions.ts
index 3d07d49c..b4f6f297 100644
--- a/lib/bidding/approval-actions.ts
+++ b/lib/bidding/approval-actions.ts
@@ -81,6 +81,7 @@ export async function prepareBiddingApprovalData(data: {
projectName: biddings.projectName,
itemName: biddings.itemName,
biddingType: biddings.biddingType,
+ awardCount: biddings.awardCount,
bidPicName: biddings.bidPicName,
supplyPicName: biddings.supplyPicName,
submissionStartDate: biddings.submissionStartDate,
@@ -166,6 +167,7 @@ export async function prepareBiddingApprovalData(data: {
...bidding,
projectName: bidding.projectName || undefined,
itemName: bidding.itemName || undefined,
+ awardCount: bidding.awardCount || undefined,
bidPicName: bidding.bidPicName || undefined,
supplyPicName: bidding.supplyPicName || undefined,
targetPrice: bidding.targetPrice ? Number(bidding.targetPrice) : undefined,
@@ -264,12 +266,14 @@ export async function requestBiddingInvitationWithApproval(data: {
const { default: db } = await import('@/db/db');
const { biddings, biddingCompanies, prItemsForBidding } = await import('@/db/schema');
const { eq } = await import('drizzle-orm');
-
+ const { getUserNameById } = await import('@/lib/bidding/actions');
+ const userName = await getUserNameById(data.currentUser.id.toString());
+
await db
.update(biddings)
.set({
status: 'approval_pending', // 결재 진행중 상태
- updatedBy: String(data.currentUser.id), // id를 string으로 변환
+ updatedBy: userName,
updatedAt: new Date()
})
.where(eq(biddings.id, data.biddingId));
@@ -463,6 +467,7 @@ export async function requestBiddingClosureWithApproval(data: {
const { default: db } = await import('@/db/db');
const { biddings } = await import('@/db/schema');
const { eq } = await import('drizzle-orm');
+ const { getUserNameById } = await import('@/lib/bidding/actions');
// 유찰상태인지 확인
const biddingResult = await db
@@ -485,12 +490,12 @@ export async function requestBiddingClosureWithApproval(data: {
// 3. 입찰 상태를 결재 진행중으로 변경
debugLog('[BiddingClosureApproval] 입찰 상태 변경 시작');
-
+ const userName = await getUserNameById(data.currentUser.id.toString());
await db
.update(biddings)
.set({
status: 'approval_pending', // 폐찰 결재 진행중 상태
- updatedBy: Number(data.currentUser.id),
+ updatedBy: userName,
updatedAt: new Date()
})
.where(eq(biddings.id, data.biddingId));
@@ -691,12 +696,13 @@ export async function requestBiddingAwardWithApproval(data: {
const { default: db } = await import('@/db/db');
const { biddings } = await import('@/db/schema');
const { eq } = await import('drizzle-orm');
-
+ const { getUserNameById } = await import('@/lib/bidding/actions');
+ const userName = await getUserNameById(data.currentUser.id.toString());
await db
.update(biddings)
.set({
status: 'approval_pending', // 낙찰 결재 진행중 상태
- updatedBy: Number(data.currentUser.id),
+ updatedBy: userName,
updatedAt: new Date()
})
.where(eq(biddings.id, data.biddingId));
diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts
index f52ecb1e..eec3f253 100644
--- a/lib/bidding/detail/service.ts
+++ b/lib/bidding/detail/service.ts
@@ -3,7 +3,7 @@
import db from '@/db/db'
import { biddings, prItemsForBidding, biddingDocuments, biddingCompanies, vendors, companyPrItemBids, companyConditionResponses, vendorSelectionResults, priceAdjustmentForms, users, vendorContacts } from '@/db/schema'
import { specificationMeetings, biddingCompaniesContacts } from '@/db/schema/bidding'
-import { eq, and, sql, desc, ne, asc } from 'drizzle-orm'
+import { eq, and, sql, desc, ne, asc, inArray } from 'drizzle-orm'
import { revalidatePath, revalidateTag } from 'next/cache'
import { unstable_cache } from "@/lib/unstable-cache";
import { sendEmail } from '@/lib/mail/sendEmail'
@@ -30,43 +30,113 @@ async function getUserNameById(userId: string): Promise<string> {
// 데이터 조회 함수들
export interface BiddingDetailData {
bidding: Awaited<ReturnType<typeof getBiddingById>>
- quotationDetails: QuotationDetails | null
+ quotationDetails: null
quotationVendors: QuotationVendor[]
- prItems: Awaited<ReturnType<typeof getPRItemsForBidding>>
+ prItems: Awaited<ReturnType<typeof getPrItemsForBidding>>
}
// getBiddingById 함수 임포트 (기존 함수 재사용)
import { getBiddingById, updateBiddingProjectInfo } from '@/lib/bidding/service'
+import { getPrItemsForBidding } from '../pre-quote/service'
-// Promise.all을 사용하여 모든 데이터를 병렬로 조회 (캐시 적용)
+// Bidding Detail Data 조회 (캐시 제거, 로직 단순화)
export async function getBiddingDetailData(biddingId: number): Promise<BiddingDetailData> {
- return unstable_cache(
- async () => {
- const [
- bidding,
- quotationDetails,
- quotationVendors,
- prItems
- ] = await Promise.all([
- getBiddingById(biddingId),
- getQuotationDetails(biddingId),
- getQuotationVendors(biddingId),
- getPRItemsForBidding(biddingId)
- ])
+ try {
+ // 1. 입찰 정보 조회
+ const bidding = await getBiddingById(biddingId)
- return {
- bidding,
- quotationDetails,
- quotationVendors,
- prItems
+ // 2. 입찰 품목 조회 (pre-quote service 함수 재사용)
+ const prItems = await getPrItemsForBidding(biddingId)
+
+ // 3. 본입찰 제출 업체 조회 (bidding_submitted 상태)
+ const vendorsData = await db
+ .select({
+ id: biddingCompanies.id,
+ biddingId: biddingCompanies.biddingId,
+ vendorId: biddingCompanies.companyId,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ vendorEmail: vendors.email,
+ quotationAmount: biddingCompanies.finalQuoteAmount,
+ currency: sql<string>`'KRW'`,
+ submissionDate: biddingCompanies.finalQuoteSubmittedAt,
+ isWinner: biddingCompanies.isWinner,
+ awardRatio: biddingCompanies.awardRatio,
+ isBiddingParticipated: biddingCompanies.isBiddingParticipated,
+ invitationStatus: biddingCompanies.invitationStatus,
+ // 연동제 관련 필드
+ isPriceAdjustmentApplicableQuestion: biddingCompanies.isPriceAdjustmentApplicableQuestion,
+ priceAdjustmentResponse: companyConditionResponses.priceAdjustmentResponse, // 벤더가 응답한 연동제 적용 여부
+ shiPriceAdjustmentApplied: biddingCompanies.shiPriceAdjustmentApplied,
+ priceAdjustmentNote: biddingCompanies.priceAdjustmentNote,
+ hasChemicalSubstance: biddingCompanies.hasChemicalSubstance,
+ // Contact info from biddingCompaniesContacts
+ contactPerson: biddingCompaniesContacts.contactName,
+ contactEmail: biddingCompaniesContacts.contactEmail,
+ contactPhone: biddingCompaniesContacts.contactNumber,
+ })
+ .from(biddingCompanies)
+ .innerJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+ .leftJoin(biddingCompaniesContacts, and(
+ eq(biddingCompaniesContacts.biddingId, biddingId),
+ eq(biddingCompaniesContacts.vendorId, biddingCompanies.companyId)
+ ))
+ .leftJoin(companyConditionResponses, and(
+ eq(companyConditionResponses.biddingCompanyId, biddingCompanies.id),
+ eq(companyConditionResponses.isPreQuote, false) // 본입찰 데이터만
+ ))
+ .where(and(
+ eq(biddingCompanies.biddingId, biddingId),
+ eq(biddingCompanies.isBiddingParticipated, true)
+ ))
+ .orderBy(desc(biddingCompanies.finalQuoteAmount))
+
+ // 중복 제거 (업체당 여러 담당자가 있을 경우 첫 번째만 사용하거나 처리)
+ // 여기서는 간단히 메모리에서 중복 제거 (biddingCompanyId 기준)
+ const uniqueVendors = vendorsData.reduce((acc, curr) => {
+ if (!acc.find(v => v.id === curr.id)) {
+ acc.push({
+ id: curr.id,
+ biddingId: curr.biddingId,
+ vendorId: curr.vendorId,
+ vendorName: curr.vendorName || `Vendor ${curr.vendorId}`,
+ vendorCode: curr.vendorCode || '',
+ vendorEmail: curr.vendorEmail || '',
+ contactPerson: curr.contactPerson || '',
+ contactEmail: curr.contactEmail || '',
+ contactPhone: curr.contactPhone || '',
+ quotationAmount: Number(curr.quotationAmount) || 0,
+ currency: curr.currency,
+ submissionDate: curr.submissionDate ? (curr.submissionDate instanceof Date ? curr.submissionDate.toISOString().split('T')[0] : String(curr.submissionDate).split('T')[0]) : '',
+ isWinner: curr.isWinner,
+ awardRatio: curr.awardRatio ? Number(curr.awardRatio) : null,
+ isBiddingParticipated: curr.isBiddingParticipated,
+ invitationStatus: curr.invitationStatus,
+ // 연동제 관련 필드
+ isPriceAdjustmentApplicableQuestion: curr.isPriceAdjustmentApplicableQuestion,
+ priceAdjustmentResponse: curr.priceAdjustmentResponse, // 벤더가 응답한 연동제 적용 여부
+ shiPriceAdjustmentApplied: curr.shiPriceAdjustmentApplied,
+ priceAdjustmentNote: curr.priceAdjustmentNote,
+ hasChemicalSubstance: curr.hasChemicalSubstance,
+ documents: [],
+ })
}
- },
- [`bidding-detail-data-${biddingId}`],
- {
- tags: [`bidding-${biddingId}`, 'bidding-detail', 'quotation-vendors', 'pr-items']
+ return acc
+ }, [] as QuotationVendor[])
+
+ return {
+ bidding,
+ quotationDetails: null,
+ quotationVendors: uniqueVendors,
+ prItems
}
- )()
+ } catch (error) {
+ console.error('Failed to get bidding detail data:', error)
+ throw error
+ }
}
+
+// QuotationDetails Interface (Keeping it for type safety if needed elsewhere, or remove if safe)
export interface QuotationDetails {
biddingId: number
estimatedPrice: number // 예상액
@@ -94,6 +164,12 @@ export interface QuotationVendor {
awardRatio: number | null // 발주비율
isBiddingParticipated: boolean | null // 본입찰 참여여부
invitationStatus: 'pending' | 'pre_quote_sent' | 'pre_quote_accepted' | 'pre_quote_declined' | 'pre_quote_submitted' | 'bidding_sent' | 'bidding_accepted' | 'bidding_declined' | 'bidding_cancelled' | 'bidding_submitted'
+ // 연동제 관련 필드
+ isPriceAdjustmentApplicableQuestion: boolean | null // SHI가 요청한 연동제 요청 여부
+ priceAdjustmentResponse: boolean | null // 벤더가 응답한 연동제 적용 여부 (companyConditionResponses.priceAdjustmentResponse)
+ shiPriceAdjustmentApplied: boolean | null // SHI 연동제 적용여부
+ priceAdjustmentNote: string | null // 연동제 Note
+ hasChemicalSubstance: boolean | null // 화학물질여부
documents: Array<{
id: number
fileName: string
@@ -103,66 +179,6 @@ export interface QuotationVendor {
}>
}
-// 견적 시스템에서 내정가 및 관련 정보를 가져오는 함수 (캐시 적용)
-export async function getQuotationDetails(biddingId: number): Promise<QuotationDetails | null> {
- return unstable_cache(
- async () => {
- try {
- // bidding_companies 테이블에서 견적 데이터를 집계
- const quotationStats = await db
- .select({
- biddingId: biddingCompanies.biddingId,
- estimatedPrice: sql<number>`AVG(${biddingCompanies.finalQuoteAmount})`.as('estimated_price'),
- lowestQuote: sql<number>`MIN(${biddingCompanies.finalQuoteAmount})`.as('lowest_quote'),
- averageQuote: sql<number>`AVG(${biddingCompanies.finalQuoteAmount})`.as('average_quote'),
- targetPrice: sql<number>`AVG(${biddings.targetPrice})`.as('target_price'),
- quotationCount: sql<number>`COUNT(*)`.as('quotation_count'),
- lastUpdated: sql<string>`MAX(${biddingCompanies.updatedAt})`.as('last_updated')
- })
- .from(biddingCompanies)
- .leftJoin(biddings, eq(biddingCompanies.biddingId, biddings.id))
- .where(and(
- eq(biddingCompanies.biddingId, biddingId),
- sql`${biddingCompanies.finalQuoteAmount} IS NOT NULL`
- ))
- .groupBy(biddingCompanies.biddingId)
- .limit(1)
-
- if (quotationStats.length === 0) {
- return {
- biddingId,
- estimatedPrice: 0,
- lowestQuote: 0,
- averageQuote: 0,
- targetPrice: 0,
- quotationCount: 0,
- lastUpdated: new Date().toISOString()
- }
- }
-
- const stat = quotationStats[0]
-
- return {
- biddingId,
- estimatedPrice: Number(stat.estimatedPrice) || 0,
- lowestQuote: Number(stat.lowestQuote) || 0,
- averageQuote: Number(stat.averageQuote) || 0,
- targetPrice: Number(stat.targetPrice) || 0,
- quotationCount: Number(stat.quotationCount) || 0,
- lastUpdated: stat.lastUpdated || new Date().toISOString()
- }
- } catch (error) {
- console.error('Failed to get quotation details:', error)
- return null
- }
- },
- [`quotation-details-${biddingId}`],
- {
- tags: [`bidding-${biddingId}`, 'quotation-details']
- }
- )()
-}
-
// bidding_companies 테이블을 메인으로 vendors 테이블을 조인하여 협력업체 정보 조회
export async function getBiddingCompaniesData(biddingId: number) {
try {
@@ -281,7 +297,7 @@ export async function getAllBiddingCompanies(biddingId: number) {
}
}
-// prItemsForBidding 테이블에서 품목 정보 조회 (캐시 미적용, always fresh)
+// prItemsForBidding 테이블에서 품목 정보 조회 (deprecated - import from pre-quote/service)
export async function getPRItemsForBidding(biddingId: number) {
try {
const items = await db
@@ -297,70 +313,9 @@ export async function getPRItemsForBidding(biddingId: number) {
}
}
-// 견적 시스템에서 협력업체 정보를 가져오는 함수 (캐시 적용)
-export async function getQuotationVendors(biddingId: number): Promise<QuotationVendor[]> {
- return unstable_cache(
- async () => {
- try {
- // bidding_companies 테이블을 메인으로 vendors를 조인하여 협력업체 정보 조회
- const vendorsData = await db
- .select({
- id: biddingCompanies.id,
- biddingId: biddingCompanies.biddingId,
- vendorId: biddingCompanies.companyId,
- vendorName: vendors.vendorName,
- vendorCode: vendors.vendorCode,
- vendorEmail: vendors.email, // 벤더의 기본 이메일
- contactPerson: biddingCompanies.contactPerson,
- contactEmail: biddingCompanies.contactEmail,
- contactPhone: biddingCompanies.contactPhone,
- quotationAmount: biddingCompanies.finalQuoteAmount,
- currency: sql<string>`'KRW'`,
- submissionDate: biddingCompanies.finalQuoteSubmittedAt,
- isWinner: biddingCompanies.isWinner,
- // awardRatio: sql<number>`CASE WHEN ${biddingCompanies.isWinner} THEN 100 ELSE 0 END`,
- awardRatio: biddingCompanies.awardRatio,
- isBiddingParticipated: biddingCompanies.isBiddingParticipated,
- invitationStatus: biddingCompanies.invitationStatus,
- })
- .from(biddingCompanies)
- .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
- .where(and(
- eq(biddingCompanies.biddingId, biddingId),
- eq(biddingCompanies.isPreQuoteSelected, true) // 본입찰 선정된 업체만 조회
- ))
- .orderBy(desc(biddingCompanies.finalQuoteAmount))
+// 견적 시스템에서 협력업체 정보를 가져오는 함수 (Deprecated - integrated into getBiddingDetailData)
+// export async function getQuotationVendors(biddingId: number): Promise<QuotationVendor[]> { ... }
- return vendorsData.map(vendor => ({
- id: vendor.id,
- biddingId: vendor.biddingId,
- vendorId: vendor.vendorId,
- vendorName: vendor.vendorName || `Vendor ${vendor.vendorId}`,
- vendorCode: vendor.vendorCode || '',
- vendorEmail: vendor.vendorEmail || '', // 벤더의 기본 이메일
- contactPerson: vendor.contactPerson || '',
- contactEmail: vendor.contactEmail || '',
- contactPhone: vendor.contactPhone || '',
- quotationAmount: Number(vendor.quotationAmount) || 0,
- currency: vendor.currency,
- submissionDate: vendor.submissionDate ? (vendor.submissionDate instanceof Date ? vendor.submissionDate.toISOString().split('T')[0] : String(vendor.submissionDate).split('T')[0]) : '',
- isWinner: vendor.isWinner,
- awardRatio: vendor.awardRatio ? Number(vendor.awardRatio) : null,
- isBiddingParticipated: vendor.isBiddingParticipated,
- invitationStatus: vendor.invitationStatus,
- documents: [], // 빈 배열로 초기화
- }))
- } catch (error) {
- console.error('Failed to get quotation vendors:', error)
- return []
- }
- },
- [`quotation-vendors-${biddingId}`],
- {
- tags: [`bidding-${biddingId}`, 'quotation-vendors']
- }
- )()
-}
// 사전견적 데이터 조회 (내정가 산정용)
export async function getPreQuoteData(biddingId: number) {
@@ -898,11 +853,59 @@ export async function registerBidding(biddingId: number, userId: string) {
await db.transaction(async (tx) => {
debugLog('registerBidding: Transaction started')
- // 1. 입찰 상태를 오픈으로 변경
+
+ // 0. 입찰서 제출기간 계산 (입력값 절대 기준)
+ const { submissionStartOffset, submissionDurationDays, submissionStartDate, submissionEndDate } = bidding
+
+ let calculatedStartDate = bidding.submissionStartDate
+ let calculatedEndDate = bidding.submissionEndDate
+
+ if (submissionStartOffset !== null && submissionDurationDays !== null) {
+ // DB에 저장된 시간을 숫자 그대로 가져옴 (예: 10:00 저장 → 10 반환)
+ const startTime = submissionStartDate
+ ? { hours: submissionStartDate.getUTCHours(), minutes: submissionStartDate.getUTCMinutes() }
+ : { hours: 9, minutes: 0 }
+ const endTime = submissionEndDate
+ ? { hours: submissionEndDate.getUTCHours(), minutes: submissionEndDate.getUTCMinutes() }
+ : { hours: 18, minutes: 0 }
+
+ // 서버의 오늘 날짜(년/월/일)를 그대로 사용해 00:00 UTC 시점 생성
+ const now = new Date()
+ const baseDate = new Date(Date.UTC(
+ now.getFullYear(),
+ now.getMonth(),
+ now.getDate(),
+ 0, 0, 0
+ ))
+
+ // 시작일 = baseDate + offset일 + 입력 시간(숫자 그대로)
+ const tempStartDate = new Date(baseDate)
+ tempStartDate.setUTCDate(tempStartDate.getUTCDate() + submissionStartOffset)
+ tempStartDate.setUTCHours(startTime.hours, startTime.minutes, 0, 0)
+
+ // 마감일 = 시작일 날짜만 기준 + duration일 + 입력 마감 시간
+ const tempEndDate = new Date(tempStartDate)
+ tempEndDate.setUTCHours(0, 0, 0, 0)
+ tempEndDate.setUTCDate(tempEndDate.getUTCDate() + submissionDurationDays)
+ tempEndDate.setUTCHours(endTime.hours, endTime.minutes, 0, 0)
+
+ calculatedStartDate = tempStartDate
+ calculatedEndDate = tempEndDate
+
+ debugLog('registerBidding: Submission dates calculated (Input Value Based)', {
+ baseDate: baseDate.toISOString(),
+ calculatedStartDate: calculatedStartDate.toISOString(),
+ calculatedEndDate: calculatedEndDate.toISOString(),
+ })
+ }
+
+ // 1. 입찰 상태를 오픈으로 변경 + 제출기간 업데이트
await tx
.update(biddings)
.set({
status: 'bidding_opened',
+ submissionStartDate: calculatedStartDate,
+ submissionEndDate: calculatedEndDate,
updatedBy: userName,
updatedAt: new Date()
})
@@ -1368,10 +1371,14 @@ export async function getAwardedCompanies(biddingId: number) {
companyId: biddingCompanies.companyId,
companyName: vendors.vendorName,
finalQuoteAmount: biddingCompanies.finalQuoteAmount,
- awardRatio: biddingCompanies.awardRatio
+ awardRatio: biddingCompanies.awardRatio,
+ vendorCode: vendors.vendorCode,
+ companySize: vendors.businessSize,
+ targetPrice: biddings.targetPrice
})
.from(biddingCompanies)
.leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+ .leftJoin(biddings, eq(biddingCompanies.biddingId, biddings.id))
.where(and(
eq(biddingCompanies.biddingId, biddingId),
eq(biddingCompanies.isWinner, true)
@@ -1381,7 +1388,10 @@ export async function getAwardedCompanies(biddingId: number) {
companyId: company.companyId,
companyName: company.companyName,
finalQuoteAmount: parseFloat(company.finalQuoteAmount?.toString() || '0'),
- awardRatio: parseFloat(company.awardRatio?.toString() || '0')
+ awardRatio: parseFloat(company.awardRatio?.toString() || '0'),
+ vendorCode: company.vendorCode,
+ companySize: company.companySize,
+ targetPrice: company.targetPrice ? parseFloat(company.targetPrice.toString()) : 0
}))
} catch (error) {
console.error('Failed to get awarded companies:', error)
@@ -1410,7 +1420,7 @@ async function updateBiddingAmounts(biddingId: number) {
.set({
targetPrice: totalTargetAmount.toString(),
budget: totalBudgetAmount.toString(),
- finalBidPrice: totalActualAmount.toString(),
+ actualPrice: totalActualAmount.toString(),
updatedAt: new Date()
})
.where(eq(biddings.id, biddingId))
@@ -1693,7 +1703,7 @@ export interface PartnersBiddingListItem {
biddingNumber: string
originalBiddingNumber: string | null // 원입찰번호
revision: number | null
- projectName: string
+ projectName: string | null
itemName: string
title: string
contractType: string
@@ -1782,9 +1792,9 @@ export async function getBiddingListForPartners(companyId: number): Promise<Part
// 계산된 필드 추가
const resultWithCalculatedFields = result.map(item => ({
...item,
- respondedAt: item.respondedAt ? (item.respondedAt instanceof Date ? item.respondedAt.toISOString() : item.respondedAt.toString()) : null,
+ respondedAt: item.respondedAt ? (item.respondedAt instanceof Date ? item.respondedAt.toISOString() : String(item.respondedAt)) : null,
finalQuoteAmount: item.finalQuoteAmount ? Number(item.finalQuoteAmount) : null, // string을 number로 변환
- finalQuoteSubmittedAt: item.finalQuoteSubmittedAt ? (item.finalQuoteSubmittedAt instanceof Date ? item.finalQuoteSubmittedAt.toISOString() : item.finalQuoteSubmittedAt.toString()) : null,
+ finalQuoteSubmittedAt: item.finalQuoteSubmittedAt ? (item.finalQuoteSubmittedAt instanceof Date ? item.finalQuoteSubmittedAt.toISOString() : String(item.finalQuoteSubmittedAt)) : null,
responseDeadline: item.submissionStartDate
? new Date(item.submissionStartDate.getTime() - 3 * 24 * 60 * 60 * 1000) // 3일 전
: null,
@@ -1825,7 +1835,6 @@ export async function getBiddingDetailsForPartners(biddingId: number, companyId:
biddingRegistrationDate: biddings.biddingRegistrationDate,
submissionStartDate: biddings.submissionStartDate,
submissionEndDate: biddings.submissionEndDate,
- evaluationDate: biddings.evaluationDate,
// 가격 정보
currency: biddings.currency,
@@ -2596,101 +2605,72 @@ export async function getBiddingDocumentsForPartners(biddingId: number) {
// 입찰가 비교 분석 함수들
// =================================================
-// 벤더별 입찰가 정보 조회 (캐시 적용)
+// 벤더별 입찰가 정보 조회 (최적화 및 간소화됨)
export async function getVendorPricesForBidding(biddingId: number) {
- return unstable_cache(
- async () => {
- try {
- // 각 회사의 입찰가 정보를 조회 - 본입찰 참여 업체들
- const vendorPrices = await db
- .select({
- companyId: biddingCompanies.companyId,
- companyName: vendors.vendorName,
- biddingCompanyId: biddingCompanies.id,
- currency: sql<string>`'KRW'`, // 기본값 KRW
- finalQuoteAmount: biddingCompanies.finalQuoteAmount,
- isBiddingParticipated: biddingCompanies.isBiddingParticipated,
- })
- .from(biddingCompanies)
- .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
- .where(and(
- eq(biddingCompanies.biddingId, biddingId),
- eq(biddingCompanies.isBiddingParticipated, true), // 본입찰 참여 업체만
- sql`${biddingCompanies.finalQuoteAmount} IS NOT NULL` // 입찰가를 제출한 업체만
- ))
+ try {
+ // 1. 본입찰 참여 업체들 조회
+ const participatingVendors = await db
+ .select({
+ companyId: biddingCompanies.companyId,
+ companyName: vendors.vendorName,
+ biddingCompanyId: biddingCompanies.id,
+ currency: sql<string>`'KRW'`, // 기본값 KRW
+ finalQuoteAmount: biddingCompanies.finalQuoteAmount,
+ isBiddingParticipated: biddingCompanies.isBiddingParticipated,
+ })
+ .from(biddingCompanies)
+ .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+ .where(and(
+ eq(biddingCompanies.biddingId, biddingId),
+ eq(biddingCompanies.isBiddingParticipated, true) // 본입찰 참여 업체만
+ ))
- console.log(`Found ${vendorPrices.length} vendors for bidding ${biddingId}`)
+ if (participatingVendors.length === 0) {
+ return []
+ }
- const result: any[] = []
+ const biddingCompanyIds = participatingVendors.map(v => v.biddingCompanyId)
- for (const vendor of vendorPrices) {
- try {
- // 해당 회사의 품목별 입찰가 조회 (본입찰 데이터)
- const itemPrices = await db
- .select({
- prItemId: companyPrItemBids.prItemId,
- itemName: prItemsForBidding.itemInfo, // itemInfo 사용
- itemNumber: prItemsForBidding.itemNumber, // itemNumber도 포함
- quantity: prItemsForBidding.quantity,
- quantityUnit: prItemsForBidding.quantityUnit,
- weight: prItemsForBidding.totalWeight, // totalWeight 사용
- weightUnit: prItemsForBidding.weightUnit,
- unitPrice: companyPrItemBids.bidUnitPrice,
- amount: companyPrItemBids.bidAmount,
- proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate,
- })
- .from(companyPrItemBids)
- .leftJoin(prItemsForBidding, eq(companyPrItemBids.prItemId, prItemsForBidding.id))
- .where(and(
- eq(companyPrItemBids.biddingCompanyId, vendor.biddingCompanyId),
- eq(companyPrItemBids.isPreQuote, false) // 본입찰 데이터만
- ))
- .orderBy(prItemsForBidding.id)
-
- console.log(`Vendor ${vendor.companyName}: Found ${itemPrices.length} item prices`)
-
- // 총 금액은 biddingCompanies.finalQuoteAmount 사용
- const totalAmount = parseFloat(vendor.finalQuoteAmount || '0')
-
- result.push({
- companyId: vendor.companyId,
- companyName: vendor.companyName || `Vendor ${vendor.companyId}`,
- biddingCompanyId: vendor.biddingCompanyId,
- totalAmount,
- currency: vendor.currency,
- itemPrices: itemPrices.map(item => ({
- prItemId: item.prItemId,
- itemName: item.itemName || item.itemNumber || `Item ${item.prItemId}`,
- quantity: parseFloat(item.quantity || '0'),
- quantityUnit: item.quantityUnit || 'ea',
- weight: item.weight ? parseFloat(item.weight) : null,
- weightUnit: item.weightUnit,
- unitPrice: parseFloat(item.unitPrice || '0'),
- amount: parseFloat(item.amount || '0'),
- proposedDeliveryDate: item.proposedDeliveryDate ?
- (typeof item.proposedDeliveryDate === 'string'
- ? item.proposedDeliveryDate
- : item.proposedDeliveryDate.toISOString().split('T')[0])
- : null,
- }))
- })
- } catch (vendorError) {
- console.error(`Error processing vendor ${vendor.companyId}:`, vendorError)
- // 벤더 처리 중 에러가 발생해도 다른 벤더들은 계속 처리
- }
- }
+ // 2. 해당 업체들의 입찰 품목 조회 (한 번의 쿼리로 최적화)
+ // 필요한 필드만 조회: prItemId, bidUnitPrice, bidAmount
+ const allItemBids = await db
+ .select({
+ biddingCompanyId: companyPrItemBids.biddingCompanyId,
+ prItemId: companyPrItemBids.prItemId,
+ bidUnitPrice: companyPrItemBids.bidUnitPrice,
+ bidAmount: companyPrItemBids.bidAmount,
+ })
+ .from(companyPrItemBids)
+ .where(and(
+ inArray(companyPrItemBids.biddingCompanyId, biddingCompanyIds),
+ eq(companyPrItemBids.isPreQuote, false) // 본입찰 데이터만
+ ))
- return result
- } catch (error) {
- console.error('Failed to get vendor prices for bidding:', error)
- return []
+ // 3. 업체별로 데이터 매핑
+ const result = participatingVendors.map(vendor => {
+ const vendorItems = allItemBids.filter(item => item.biddingCompanyId === vendor.biddingCompanyId)
+
+ const totalAmount = parseFloat(vendor.finalQuoteAmount || '0')
+
+ return {
+ companyId: vendor.companyId,
+ companyName: vendor.companyName || `Vendor ${vendor.companyId}`,
+ biddingCompanyId: vendor.biddingCompanyId,
+ totalAmount,
+ currency: vendor.currency,
+ itemPrices: vendorItems.map(item => ({
+ prItemId: item.prItemId,
+ unitPrice: parseFloat(item.bidUnitPrice || '0'),
+ amount: parseFloat(item.bidAmount || '0'),
+ }))
}
- },
- [`bidding-vendor-prices-${biddingId}`],
- {
- tags: [`bidding-${biddingId}`, 'quotation-vendors', 'pr-items']
- }
- )()
+ })
+
+ return result
+ } catch (error) {
+ console.error('Failed to get vendor prices for bidding:', error)
+ return []
+ }
}
// 사양설명회 참여 여부 업데이트
@@ -2720,3 +2700,35 @@ export async function setSpecificationMeetingParticipation(biddingCompanyId: num
return { success: false, error: '사양설명회 참여상태 업데이트에 실패했습니다.' }
}
}
+
+// 연동제 정보 업데이트
+export async function updatePriceAdjustmentInfo(params: {
+ biddingCompanyId: number
+ shiPriceAdjustmentApplied: boolean | null
+ priceAdjustmentNote: string | null
+ hasChemicalSubstance: boolean | null
+}): Promise<{ success: boolean; error?: string }> {
+ try {
+ const result = await db.update(biddingCompanies)
+ .set({
+ shiPriceAdjustmentApplied: params.shiPriceAdjustmentApplied,
+ priceAdjustmentNote: params.priceAdjustmentNote,
+ hasChemicalSubstance: params.hasChemicalSubstance,
+ updatedAt: new Date(),
+ })
+ .where(eq(biddingCompanies.id, params.biddingCompanyId))
+ .returning({ biddingId: biddingCompanies.biddingId })
+
+ if (result.length > 0) {
+ const biddingId = result[0].biddingId
+ revalidateTag(`bidding-${biddingId}`)
+ revalidateTag('quotation-vendors')
+ revalidatePath(`/evcp/bid/${biddingId}`)
+ }
+
+ return { success: true }
+ } catch (error) {
+ console.error('Failed to update price adjustment info:', error)
+ return { success: false, error: '연동제 정보 업데이트에 실패했습니다.' }
+ }
+}
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx
index 5368b287..05c1a93d 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx
@@ -31,6 +31,7 @@ interface GetVendorColumnsProps {
}
export function getBiddingDetailVendorColumns({
+ onViewPriceAdjustment,
onViewItemDetails,
onSendBidding,
onUpdateParticipation,
@@ -239,6 +240,83 @@ export function getBiddingDetailVendorColumns({
),
},
{
+ accessorKey: 'priceAdjustmentResponse',
+ header: '연동제 응답',
+ cell: ({ row }) => {
+ const vendor = row.original
+ const response = vendor.priceAdjustmentResponse
+
+ // 버튼 형태로 표시, 클릭 시 상세 다이얼로그 열기
+ const getBadgeVariant = () => {
+ if (response === null || response === undefined) return 'outline'
+ return response ? 'default' : 'secondary'
+ }
+
+ const getBadgeClass = () => {
+ if (response === true) return 'bg-green-600 hover:bg-green-700 cursor-pointer'
+ if (response === false) return 'hover:bg-gray-300 cursor-pointer'
+ return ''
+ }
+
+ const getLabel = () => {
+ if (response === null || response === undefined) return '해당없음'
+ return response ? '예' : '아니오'
+ }
+
+ return (
+ <Badge
+ variant={getBadgeVariant()}
+ className={getBadgeClass()}
+ onClick={() => onViewPriceAdjustment?.(vendor)}
+ >
+ {getLabel()}
+ </Badge>
+ )
+ },
+ },
+ {
+ accessorKey: 'shiPriceAdjustmentApplied',
+ header: 'SHI연동제적용',
+ cell: ({ row }) => {
+ const applied = row.original.shiPriceAdjustmentApplied
+ if (applied === null || applied === undefined) {
+ return <Badge variant="outline">미정</Badge>
+ }
+ return (
+ <Badge variant={applied ? 'default' : 'secondary'} className={applied ? 'bg-green-600' : ''}>
+ {applied ? '적용' : '미적용'}
+ </Badge>
+ )
+ },
+ },
+ {
+ accessorKey: 'priceAdjustmentNote',
+ header: '연동제 Note',
+ cell: ({ row }) => {
+ const note = row.original.priceAdjustmentNote
+ return (
+ <div className="text-sm max-w-[150px] truncate" title={note || ''}>
+ {note || '-'}
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: 'hasChemicalSubstance',
+ header: '화학물질',
+ cell: ({ row }) => {
+ const hasChemical = row.original.hasChemicalSubstance
+ if (hasChemical === null || hasChemical === undefined) {
+ return <Badge variant="outline">미정</Badge>
+ }
+ return (
+ <Badge variant={hasChemical ? 'destructive' : 'secondary'}>
+ {hasChemical ? '해당' : '해당없음'}
+ </Badge>
+ )
+ },
+ },
+ {
id: 'actions',
header: '작업',
cell: ({ row }) => {
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx
index fffac0c1..407cc51c 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx
@@ -10,9 +10,9 @@ import { BiddingDetailVendorToolbarActions } from './bidding-detail-vendor-toolb
import { BiddingDetailVendorEditDialog } from './bidding-detail-vendor-edit-dialog'
import { BiddingAwardDialog } from './bidding-award-dialog'
import { getBiddingDetailVendorColumns } from './bidding-detail-vendor-columns'
-import { QuotationVendor, getPriceAdjustmentFormByBiddingCompanyId } from '@/lib/bidding/detail/service'
+import { QuotationVendor } from '@/lib/bidding/detail/service'
import { Bidding } from '@/db/schema'
-import { PriceAdjustmentDialog } from '@/components/bidding/price-adjustment-dialog'
+import { VendorPriceAdjustmentViewDialog } from './vendor-price-adjustment-view-dialog'
import { QuotationHistoryDialog } from './quotation-history-dialog'
import { ApprovalPreviewDialog } from '@/lib/approval/approval-preview-dialog'
import { ApplicationReasonDialog } from '@/lib/rfq-last/vendor/application-reason-dialog'
@@ -27,6 +27,7 @@ interface BiddingDetailVendorTableContentProps {
onOpenSelectionReasonDialog: () => void
onViewItemDetails?: (vendor: QuotationVendor) => void
onViewQuotationHistory?: (vendor: QuotationVendor) => void
+ readOnly?: boolean
}
const filterFields: DataTableFilterField<QuotationVendor>[] = [
@@ -86,7 +87,8 @@ export function BiddingDetailVendorTableContent({
vendors,
onRefresh,
onViewItemDetails,
- onViewQuotationHistory
+ onViewQuotationHistory,
+ readOnly = false
}: BiddingDetailVendorTableContentProps) {
const { data: session } = useSession()
const { toast } = useToast()
@@ -96,8 +98,7 @@ export function BiddingDetailVendorTableContent({
const [selectedVendor, setSelectedVendor] = React.useState<QuotationVendor | null>(null)
const [isAwardDialogOpen, setIsAwardDialogOpen] = React.useState(false)
const [isAwardRatioDialogOpen, setIsAwardRatioDialogOpen] = React.useState(false)
- const [priceAdjustmentData, setPriceAdjustmentData] = React.useState<any>(null)
- const [isPriceAdjustmentDialogOpen, setIsPriceAdjustmentDialogOpen] = React.useState(false)
+ const [isVendorPriceAdjustmentDialogOpen, setIsVendorPriceAdjustmentDialogOpen] = React.useState(false)
const [quotationHistoryData, setQuotationHistoryData] = React.useState<any>(null)
const [isQuotationHistoryDialogOpen, setIsQuotationHistoryDialogOpen] = React.useState(false)
const [approvalPreviewData, setApprovalPreviewData] = React.useState<{
@@ -114,28 +115,9 @@ export function BiddingDetailVendorTableContent({
} | null>(null)
const [isApprovalPreviewDialogOpen, setIsApprovalPreviewDialogOpen] = React.useState(false)
- const handleViewPriceAdjustment = async (vendor: QuotationVendor) => {
- try {
- const priceAdjustmentForm = await getPriceAdjustmentFormByBiddingCompanyId(vendor.id)
- if (priceAdjustmentForm) {
- setPriceAdjustmentData(priceAdjustmentForm)
- setSelectedVendor(vendor)
- setIsPriceAdjustmentDialogOpen(true)
- } else {
- toast({
- title: '연동제 정보 없음',
- description: '해당 업체의 연동제 정보가 없습니다.',
- variant: 'default',
- })
- }
- } catch (error) {
- console.error('Failed to load price adjustment form:', error)
- toast({
- title: '오류',
- description: '연동제 정보를 불러오는데 실패했습니다.',
- variant: 'destructive',
- })
- }
+ const handleViewPriceAdjustment = (vendor: QuotationVendor) => {
+ setSelectedVendor(vendor)
+ setIsVendorPriceAdjustmentDialogOpen(true)
}
const handleViewQuotationHistory = async (vendor: QuotationVendor) => {
@@ -269,6 +251,7 @@ export function BiddingDetailVendorTableContent({
onSuccess={onRefresh}
winnerVendor={vendors.find(v => v.awardRatio === 100)}
singleSelectedVendor={singleSelectedVendor}
+ readOnly={readOnly}
/>
</DataTableAdvancedToolbar>
</DataTable>
@@ -296,11 +279,12 @@ export function BiddingDetailVendorTableContent({
}}
/>
- <PriceAdjustmentDialog
- open={isPriceAdjustmentDialogOpen}
- onOpenChange={setIsPriceAdjustmentDialogOpen}
- data={priceAdjustmentData}
+ <VendorPriceAdjustmentViewDialog
+ open={isVendorPriceAdjustmentDialogOpen}
+ onOpenChange={setIsVendorPriceAdjustmentDialogOpen}
vendorName={selectedVendor?.vendorName || ''}
+ priceAdjustmentResponse={selectedVendor?.priceAdjustmentResponse ?? null}
+ biddingCompanyId={selectedVendor?.id || 0}
/>
<QuotationHistoryDialog
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
index 8df29289..e934a5fe 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
@@ -5,13 +5,14 @@ import { useRouter } from "next/navigation"
import { useTransition } from "react"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
-import { Plus, Send, RotateCcw, XCircle, Trophy, FileText, DollarSign, RotateCw } from "lucide-react"
+import { Plus, Send, RotateCcw, XCircle, Trophy, FileText, DollarSign, RotateCw, Link2 } from "lucide-react"
import { registerBidding, markAsDisposal, cancelAwardRatio } from "@/lib/bidding/detail/service"
import { sendBiddingBasicContracts, getSelectedVendorsForBidding } from "@/lib/bidding/pre-quote/service"
import { increaseRoundOrRebid } from "@/lib/bidding/service"
import { BiddingDetailVendorCreateDialog } from "../../../../components/bidding/manage/bidding-detail-vendor-create-dialog"
import { BiddingDocumentUploadDialog } from "./bidding-document-upload-dialog"
+import { PriceAdjustmentDialog } from "./price-adjustment-dialog"
import { Bidding } from "@/db/schema"
import { useToast } from "@/hooks/use-toast"
import { QuotationVendor } from "@/lib/bidding/detail/service"
@@ -25,6 +26,7 @@ interface BiddingDetailVendorToolbarActionsProps {
onSuccess: () => void
winnerVendor?: QuotationVendor | null // 100% 낙찰된 벤더
singleSelectedVendor?: QuotationVendor | null // single select된 벤더
+ readOnly?: boolean
}
export function BiddingDetailVendorToolbarActions({
@@ -35,7 +37,8 @@ export function BiddingDetailVendorToolbarActions({
onOpenAwardRatioDialog,
onSuccess,
winnerVendor,
- singleSelectedVendor
+ singleSelectedVendor,
+ readOnly = false
}: BiddingDetailVendorToolbarActionsProps) {
const router = useRouter()
const { toast } = useToast()
@@ -47,6 +50,7 @@ export function BiddingDetailVendorToolbarActions({
const [selectedVendors, setSelectedVendors] = React.useState<any[]>([])
const [isRoundIncreaseDialogOpen, setIsRoundIncreaseDialogOpen] = React.useState(false)
const [isCancelAwardDialogOpen, setIsCancelAwardDialogOpen] = React.useState(false)
+ const [isPriceAdjustmentDialogOpen, setIsPriceAdjustmentDialogOpen] = React.useState(false)
// 본입찰 초대 다이얼로그가 열릴 때 선정된 업체들 조회
React.useEffect(() => {
@@ -82,53 +86,6 @@ export function BiddingDetailVendorToolbarActions({
setIsBiddingInvitationDialogOpen(true)
}
- // const handleBiddingInvitationSend = async (data: any) => {
- // try {
- // // 1. 기본계약 발송
- // const contractResult = await sendBiddingBasicContracts(
- // biddingId,
- // data.vendors,
- // data.generatedPdfs,
- // data.message
- // )
-
- // if (!contractResult.success) {
- // toast({
- // title: '기본계약 발송 실패',
- // description: contractResult.error,
- // variant: 'destructive',
- // })
- // return
- // }
-
- // // 2. 입찰 등록 진행
- // const registerResult = await registerBidding(bidding.id, userId)
-
- // if (registerResult.success) {
- // toast({
- // title: '본입찰 초대 완료',
- // description: '기본계약 발송 및 본입찰 초대가 완료되었습니다.',
- // })
- // setIsBiddingInvitationDialogOpen(false)
- // router.refresh()
- // onSuccess()
- // } else {
- // toast({
- // title: '오류',
- // description: registerResult.error,
- // variant: 'destructive',
- // })
- // }
- // } catch (error) {
- // console.error('본입찰 초대 실패:', error)
- // toast({
- // title: '오류',
- // description: '본입찰 초대에 실패했습니다.',
- // variant: 'destructive',
- // })
- // }
- // }
-
// 선정된 업체들 조회 (서버 액션 함수 사용)
const getSelectedVendors = async () => {
try {
@@ -165,27 +122,6 @@ export function BiddingDetailVendorToolbarActions({
})
}
- const handleRoundIncrease = () => {
- startTransition(async () => {
- const result = await increaseRoundOrRebid(bidding.id, userId, 'round_increase')
-
- if (result.success) {
- toast({
- title: "성공",
- description: result.message,
- })
- router.push(`/evcp/bid`)
- onSuccess()
- } else {
- toast({
- title: "오류",
- description: result.error || "차수증가 중 오류가 발생했습니다.",
- variant: 'destructive',
- })
- }
- })
- }
-
const handleCancelAward = () => {
if (!winnerVendor) return
@@ -218,8 +154,12 @@ export function BiddingDetailVendorToolbarActions({
title: "성공",
description: '차수증가가 완료되었습니다.',
})
- router.push(`/evcp/bid`)
- onSuccess()
+ if (result.biddingId) {
+ router.push(`/evcp/bid/${result.biddingId}/info`)
+ } else {
+ router.push(`/evcp/bid`)
+ }
+ // onSuccess()
} else {
toast({
title: "오류",
@@ -233,69 +173,87 @@ export function BiddingDetailVendorToolbarActions({
return (
<>
<div className="flex items-center gap-2">
- {/* 상태별 액션 버튼 */}
- {/* 차수증가: 입찰공고 또는 입찰평가중 상태에서만 */}
- {(bidding.status === 'evaluation_of_bidding' || bidding.status === 'bidding_opened') && (
- <Button
- variant="outline"
- size="sm"
- onClick={() => setIsRoundIncreaseDialogOpen(true)}
- disabled={isPending}
- >
- <RotateCw className="mr-2 h-4 w-4" />
- 차수증가
- </Button>
- )}
-
- {/* 발주비율 산정: single select 시에만 활성화 */}
- {(bidding.status === 'evaluation_of_bidding') && (
- <Button
- variant="outline"
- size="sm"
- onClick={onOpenAwardRatioDialog}
- disabled={!singleSelectedVendor || isPending || singleSelectedVendor.isBiddingParticipated !== true}
- >
- <DollarSign className="mr-2 h-4 w-4" />
- 발주비율 산정
- </Button>
- )}
-
- {/* 유찰/낙찰: 입찰공고 또는 입찰평가중 상태에서만 */}
- {(bidding.status === 'bidding_opened' || bidding.status === 'evaluation_of_bidding') && (
+ {/* 상태별 액션 버튼 - 읽기 전용이 아닐 때만 표시 */}
+ {!readOnly && (
<>
- <Button
- variant="destructive"
- size="sm"
- onClick={handleMarkAsDisposal}
- disabled={isPending}
- >
- <XCircle className="mr-2 h-4 w-4" />
- 유찰
- </Button>
- <Button
- variant="default"
- size="sm"
- onClick={onOpenAwardDialog}
- disabled={isPending}
- >
- <Trophy className="mr-2 h-4 w-4" />
- 낙찰
- </Button>
+ {/* 차수증가: 입찰공고 또는 입찰평가중 상태에서만 */}
+ {(bidding.status === 'evaluation_of_bidding' || bidding.status === 'bidding_opened') && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setIsRoundIncreaseDialogOpen(true)}
+ disabled={isPending}
+ >
+ <RotateCw className="mr-2 h-4 w-4" />
+ 차수증가
+ </Button>
+ )}
+
+ {/* 발주비율 산정: single select 시에만 활성화 */}
+ {(bidding.status === 'evaluation_of_bidding') && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={onOpenAwardRatioDialog}
+ disabled={!singleSelectedVendor || isPending || singleSelectedVendor.isBiddingParticipated !== true}
+ >
+ <DollarSign className="mr-2 h-4 w-4" />
+ 발주비율 산정
+ </Button>
+ )}
+
+ {/* 연동제 적용여부: single select 시에만 활성화 */}
+ {(bidding.status === 'evaluation_of_bidding') && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setIsPriceAdjustmentDialogOpen(true)}
+ disabled={!singleSelectedVendor || isPending || singleSelectedVendor.isBiddingParticipated !== true}
+ >
+ <Link2 className="mr-2 h-4 w-4" />
+ 연동제 적용
+ </Button>
+ )}
+
+ {/* 유찰/낙찰: 입찰공고 또는 입찰평가중 상태에서만 */}
+ {(bidding.status === 'bidding_opened' || bidding.status === 'evaluation_of_bidding') && (
+ <>
+ <Button
+ variant="destructive"
+ size="sm"
+ onClick={handleMarkAsDisposal}
+ disabled={isPending}
+ >
+ <XCircle className="mr-2 h-4 w-4" />
+ 유찰
+ </Button>
+ <Button
+ variant="default"
+ size="sm"
+ onClick={onOpenAwardDialog}
+ disabled={isPending}
+ >
+ <Trophy className="mr-2 h-4 w-4" />
+ 낙찰
+ </Button>
+ </>
+ )}
+
+ {/* 발주비율 취소: 100% 낙찰된 벤더가 있는 경우 */}
+ {winnerVendor && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setIsCancelAwardDialogOpen(true)}
+ disabled={isPending}
+ >
+ <RotateCcw className="mr-2 h-4 w-4" />
+ 발주비율 취소
+ </Button>
+ )}
</>
)}
- {/* 발주비율 취소: 100% 낙찰된 벤더가 있는 경우 */}
- {winnerVendor && (
- <Button
- variant="outline"
- size="sm"
- onClick={() => setIsCancelAwardDialogOpen(true)}
- disabled={isPending}
- >
- <RotateCcw className="mr-2 h-4 w-4" />
- 발주비율 취소
- </Button>
- )}
{/* 구분선 */}
{(bidding.status === 'bidding_generated' ||
bidding.status === 'bidding_disposal') && (
@@ -392,6 +350,14 @@ export function BiddingDetailVendorToolbarActions({
</DialogContent>
</Dialog>
+ {/* 연동제 적용여부 다이얼로그 */}
+ <PriceAdjustmentDialog
+ open={isPriceAdjustmentDialogOpen}
+ onOpenChange={setIsPriceAdjustmentDialogOpen}
+ vendor={singleSelectedVendor || null}
+ onSuccess={onSuccess}
+ />
+
</>
)
}
diff --git a/lib/bidding/detail/table/price-adjustment-dialog.tsx b/lib/bidding/detail/table/price-adjustment-dialog.tsx
new file mode 100644
index 00000000..96a3af0c
--- /dev/null
+++ b/lib/bidding/detail/table/price-adjustment-dialog.tsx
@@ -0,0 +1,195 @@
+"use client"
+
+import * as React from "react"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Label } from "@/components/ui/label"
+import { Textarea } from "@/components/ui/textarea"
+import { Switch } from "@/components/ui/switch"
+import { useToast } from "@/hooks/use-toast"
+import { updatePriceAdjustmentInfo } from "@/lib/bidding/detail/service"
+import { QuotationVendor } from "@/lib/bidding/detail/service"
+import { Loader2 } from "lucide-react"
+
+interface PriceAdjustmentDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ vendor: QuotationVendor | null
+ onSuccess: () => void
+}
+
+export function PriceAdjustmentDialog({
+ open,
+ onOpenChange,
+ vendor,
+ onSuccess,
+}: PriceAdjustmentDialogProps) {
+ const { toast } = useToast()
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+
+ // 폼 상태
+ const [shiPriceAdjustmentApplied, setSHIPriceAdjustmentApplied] = React.useState<boolean | null>(null)
+ const [priceAdjustmentNote, setPriceAdjustmentNote] = React.useState("")
+ const [hasChemicalSubstance, setHasChemicalSubstance] = React.useState<boolean | null>(null)
+
+ // 다이얼로그가 열릴 때 벤더 정보로 폼 초기화
+ React.useEffect(() => {
+ if (open && vendor) {
+ setSHIPriceAdjustmentApplied(vendor.shiPriceAdjustmentApplied ?? null)
+ setPriceAdjustmentNote(vendor.priceAdjustmentNote || "")
+ setHasChemicalSubstance(vendor.hasChemicalSubstance ?? null)
+ }
+ }, [open, vendor])
+
+ const handleSubmit = async () => {
+ if (!vendor) return
+
+ setIsSubmitting(true)
+ try {
+ const result = await updatePriceAdjustmentInfo({
+ biddingCompanyId: vendor.id,
+ shiPriceAdjustmentApplied,
+ priceAdjustmentNote: priceAdjustmentNote || null,
+ hasChemicalSubstance,
+ })
+
+ if (result.success) {
+ toast({
+ title: "저장 완료",
+ description: "연동제 정보가 저장되었습니다.",
+ })
+ onOpenChange(false)
+ onSuccess()
+ } else {
+ toast({
+ title: "오류",
+ description: result.error || "저장 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ }
+ } catch (error) {
+ console.error("연동제 정보 저장 오류:", error)
+ toast({
+ title: "오류",
+ description: "저장 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ if (!vendor) return null
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[500px]">
+ <DialogHeader>
+ <DialogTitle>연동제 적용 설정</DialogTitle>
+ <DialogDescription>
+ <span className="font-semibold text-primary">{vendor.vendorName}</span> 업체의 연동제 적용 여부를 설정합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-6 py-4">
+ {/* 업체가 제출한 연동제 요청 여부 (읽기 전용) */}
+ {/* <div className="flex flex-row items-center justify-between rounded-lg border p-4 bg-muted/50">
+ <div className="space-y-0.5">
+ <Label className="text-base">업체 연동제 요청</Label>
+ <p className="text-sm text-muted-foreground">
+ 업체가 제출한 연동제 적용 요청 여부입니다.
+ </p>
+ </div>
+ <span className={`font-medium ${vendor.isPriceAdjustmentApplicableQuestion ? 'text-green-600' : 'text-gray-500'}`}>
+ {vendor.isPriceAdjustmentApplicableQuestion === null ? '미정' : vendor.isPriceAdjustmentApplicableQuestion ? '예' : '아니오'}
+ </span>
+ </div> */}
+
+ {/* SHI 연동제 적용여부 */}
+ <div className="flex flex-row items-center justify-between rounded-lg border p-4">
+ <div className="space-y-0.5">
+ <Label className="text-base">SHI 연동제 적용</Label>
+ <p className="text-sm text-muted-foreground">
+ 해당 업체에 연동제를 적용할지 결정합니다.
+ </p>
+ </div>
+ <div className="flex items-center gap-3">
+ <span className={`text-sm ${shiPriceAdjustmentApplied === false ? 'font-medium' : 'text-muted-foreground'}`}>
+ 미적용
+ </span>
+ <Switch
+ checked={shiPriceAdjustmentApplied === true}
+ onCheckedChange={(checked) => setSHIPriceAdjustmentApplied(checked)}
+ />
+ <span className={`text-sm ${shiPriceAdjustmentApplied === true ? 'font-medium' : 'text-muted-foreground'}`}>
+ 적용
+ </span>
+ </div>
+ </div>
+
+ {/* 연동제 Note */}
+ <div className="space-y-2">
+ <Label htmlFor="price-adjustment-note">연동제 Note</Label>
+ <Textarea
+ id="price-adjustment-note"
+ placeholder="연동제 관련 추가 사항을 입력하세요"
+ value={priceAdjustmentNote}
+ onChange={(e) => setPriceAdjustmentNote(e.target.value)}
+ rows={4}
+ />
+ </div>
+
+ {/* 화학물질 여부 */}
+ {/* <div className="flex flex-row items-center justify-between rounded-lg border p-4">
+ <div className="space-y-0.5">
+ <Label className="text-base">화학물질 해당여부</Label>
+ <p className="text-sm text-muted-foreground">
+ 해당 업체가 화학물질 취급 대상인지 여부입니다.
+ </p>
+ </div>
+ <div className="flex items-center gap-3">
+ <span className={`text-sm ${hasChemicalSubstance === false ? 'font-medium' : 'text-muted-foreground'}`}>
+ 해당없음
+ </span>
+ <Switch
+ checked={hasChemicalSubstance === true}
+ onCheckedChange={(checked) => setHasChemicalSubstance(checked)}
+ />
+ <span className={`text-sm ${hasChemicalSubstance === true ? 'font-medium text-red-600' : 'text-muted-foreground'}`}>
+ 해당
+ </span>
+ </div>
+ </div> */}
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ <Button onClick={handleSubmit} disabled={isSubmitting}>
+ {isSubmitting ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ 저장 중...
+ </>
+ ) : (
+ "저장"
+ )}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
+
diff --git a/lib/bidding/detail/table/vendor-price-adjustment-view-dialog.tsx b/lib/bidding/detail/table/vendor-price-adjustment-view-dialog.tsx
new file mode 100644
index 00000000..f31caf5e
--- /dev/null
+++ b/lib/bidding/detail/table/vendor-price-adjustment-view-dialog.tsx
@@ -0,0 +1,324 @@
+'use client'
+
+import React from 'react'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { Badge } from '@/components/ui/badge'
+import { Separator } from '@/components/ui/separator'
+import { format } from 'date-fns'
+import { ko } from 'date-fns/locale'
+import { Loader2 } from 'lucide-react'
+
+interface PriceAdjustmentData {
+ id: number
+ itemName?: string | null
+ adjustmentReflectionPoint?: string | null
+ majorApplicableRawMaterial?: string | null
+ adjustmentFormula?: string | null
+ rawMaterialPriceIndex?: string | null
+ referenceDate?: Date | string | null
+ comparisonDate?: Date | string | null
+ adjustmentRatio?: string | null
+ notes?: string | null
+ adjustmentConditions?: string | null
+ majorNonApplicableRawMaterial?: string | null
+ adjustmentPeriod?: string | null
+ contractorWriter?: string | null
+ adjustmentDate?: Date | string | null
+ nonApplicableReason?: string | null
+ createdAt: Date | string
+ updatedAt: Date | string
+}
+
+interface VendorPriceAdjustmentViewDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ vendorName: string
+ priceAdjustmentResponse: boolean | null // 벤더가 응답한 연동제 적용 여부
+ biddingCompanyId: number
+}
+
+export function VendorPriceAdjustmentViewDialog({
+ open,
+ onOpenChange,
+ vendorName,
+ priceAdjustmentResponse,
+ biddingCompanyId,
+}: VendorPriceAdjustmentViewDialogProps) {
+ const [data, setData] = React.useState<PriceAdjustmentData | null>(null)
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [error, setError] = React.useState<string | null>(null)
+
+ // 다이얼로그가 열릴 때 데이터 로드
+ React.useEffect(() => {
+ if (open && biddingCompanyId) {
+ loadPriceAdjustmentData()
+ }
+ }, [open, biddingCompanyId])
+
+ const loadPriceAdjustmentData = async () => {
+ setIsLoading(true)
+ setError(null)
+ try {
+ // 서버에서 연동제 폼 데이터 조회
+ const { getPriceAdjustmentFormByBiddingCompanyId } = await import('@/lib/bidding/detail/service')
+ const formData = await getPriceAdjustmentFormByBiddingCompanyId(biddingCompanyId)
+ setData(formData)
+ } catch (err) {
+ console.error('Failed to load price adjustment data:', err)
+ setError('연동제 정보를 불러오는데 실패했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // 날짜 포맷팅 헬퍼
+ const formatDateValue = (date: Date | string | null | undefined) => {
+ if (!date) return '-'
+ try {
+ const dateObj = typeof date === 'string' ? new Date(date) : date
+ return format(dateObj, 'yyyy-MM-dd', { locale: ko })
+ } catch {
+ return '-'
+ }
+ }
+
+ // 연동제 적용 여부 판단
+ const isApplied = priceAdjustmentResponse === true
+ const isNotApplied = priceAdjustmentResponse === false
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <span>하도급대금등 연동표</span>
+ <Badge variant="secondary">{vendorName}</Badge>
+ {isApplied && (
+ <Badge variant="default" className="bg-green-600 hover:bg-green-700">
+ 연동제 적용
+ </Badge>
+ )}
+ {isNotApplied && (
+ <Badge variant="outline" className="border-red-500 text-red-600">
+ 연동제 미적용
+ </Badge>
+ )}
+ {priceAdjustmentResponse === null && (
+ <Badge variant="outline">해당없음</Badge>
+ )}
+ </DialogTitle>
+ <DialogDescription>
+ 협력업체가 제출한 연동제 적용 정보입니다.
+ {isApplied && " (연동제 적용)"}
+ {isNotApplied && " (연동제 미적용)"}
+ </DialogDescription>
+ </DialogHeader>
+
+ {isLoading ? (
+ <div className="flex items-center justify-center py-12">
+ <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
+ <span className="ml-2 text-muted-foreground">연동제 정보를 불러오는 중...</span>
+ </div>
+ ) : error ? (
+ <div className="py-8 text-center text-red-600">{error}</div>
+ ) : !data && priceAdjustmentResponse !== null ? (
+ <div className="py-8 text-center text-muted-foreground">연동제 상세 정보가 없습니다.</div>
+ ) : priceAdjustmentResponse === null ? (
+ <div className="py-8 text-center text-muted-foreground">해당 업체는 연동제 관련 응답을 하지 않았습니다.</div>
+ ) : (
+ <div className="space-y-6">
+ {/* 기본 정보 */}
+ <div>
+ <h3 className="text-sm font-medium text-gray-900 mb-3">기본 정보</h3>
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <label className="text-xs text-gray-500">물품등의 명칭</label>
+ <p className="text-sm font-medium">{data?.itemName || '-'}</p>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">연동제 적용 여부</label>
+ <div className="mt-1">
+ {isApplied && (
+ <Badge variant="default" className="bg-green-600 hover:bg-green-700">
+ 예 (연동제 적용)
+ </Badge>
+ )}
+ {isNotApplied && (
+ <Badge variant="outline" className="border-red-500 text-red-600">
+ 아니오 (연동제 미적용)
+ </Badge>
+ )}
+ </div>
+ </div>
+ {isApplied && (
+ <div>
+ <label className="text-xs text-gray-500">조정대금 반영시점</label>
+ <p className="text-sm font-medium">{data?.adjustmentReflectionPoint || '-'}</p>
+ </div>
+ )}
+ </div>
+ </div>
+
+ <Separator />
+
+ {/* 원재료 정보 */}
+ <div>
+ <h3 className="text-sm font-medium text-gray-900 mb-3">원재료 정보</h3>
+ <div className="space-y-4">
+ {isApplied && (
+ <div>
+ <label className="text-xs text-gray-500">연동대상 주요 원재료</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data?.majorApplicableRawMaterial || '-'}
+ </p>
+ </div>
+ )}
+ {isNotApplied && (
+ <>
+ <div>
+ <label className="text-xs text-gray-500">연동 미적용 주요 원재료</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data?.majorNonApplicableRawMaterial || '-'}
+ </p>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">연동 미적용 사유</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data?.nonApplicableReason || '-'}
+ </p>
+ </div>
+ </>
+ )}
+ </div>
+ </div>
+
+ {isApplied && data && (
+ <>
+ <Separator />
+
+ {/* 연동 공식 및 지표 */}
+ <div>
+ <h3 className="text-sm font-medium text-gray-900 mb-3">연동 공식 및 지표</h3>
+ <div className="space-y-4">
+ <div>
+ <label className="text-xs text-gray-500">하도급대금등 연동 산식</label>
+ <div className="p-3 bg-gray-50 rounded-md">
+ <p className="text-sm font-mono whitespace-pre-wrap">
+ {data.adjustmentFormula || '-'}
+ </p>
+ </div>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">원재료 가격 기준지표</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data.rawMaterialPriceIndex || '-'}
+ </p>
+ </div>
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <label className="text-xs text-gray-500">원재료 기준 가격의 변동률 산정을 위한 기준시점</label>
+ <p className="text-sm font-medium">{data.referenceDate ? formatDateValue(data.referenceDate) : '-'}</p>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">원재료 기준 가격의 변동률 산정을 위한 비교시점</label>
+ <p className="text-sm font-medium">{data.comparisonDate ? formatDateValue(data.comparisonDate) : '-'}</p>
+ </div>
+ </div>
+ {data.adjustmentRatio && (
+ <div>
+ <label className="text-xs text-gray-500">반영비율</label>
+ <p className="text-sm font-medium">
+ {data.adjustmentRatio}%
+ </p>
+ </div>
+ )}
+ </div>
+ </div>
+
+ <Separator />
+
+ {/* 조정 조건 및 기타 */}
+ <div>
+ <h3 className="text-sm font-medium text-gray-900 mb-3">조정 조건 및 기타</h3>
+ <div className="space-y-4">
+ <div>
+ <label className="text-xs text-gray-500">조정요건</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data.adjustmentConditions || '-'}
+ </p>
+ </div>
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <label className="text-xs text-gray-500">조정주기</label>
+ <p className="text-sm font-medium">{data.adjustmentPeriod || '-'}</p>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">조정일</label>
+ <p className="text-sm font-medium">{data.adjustmentDate ? formatDateValue(data.adjustmentDate) : '-'}</p>
+ </div>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">수탁기업(협력사)작성자</label>
+ <p className="text-sm font-medium">{data.contractorWriter || '-'}</p>
+ </div>
+ {data.notes && (
+ <div>
+ <label className="text-xs text-gray-500">기타사항</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data.notes}
+ </p>
+ </div>
+ )}
+ </div>
+ </div>
+ </>
+ )}
+
+ {isNotApplied && data && (
+ <>
+ <Separator />
+ <div>
+ <h3 className="text-sm font-medium text-gray-900 mb-3">작성자 정보</h3>
+ <div>
+ <label className="text-xs text-gray-500">수탁기업(협력사) 작성자</label>
+ <p className="text-sm font-medium">{data.contractorWriter || '-'}</p>
+ </div>
+ </div>
+ </>
+ )}
+
+ {data && (
+ <>
+ <Separator />
+
+ {/* 메타 정보 */}
+ <div className="text-xs text-gray-500 space-y-1">
+ <p>작성일: {formatDateValue(data.createdAt)}</p>
+ <p>수정일: {formatDateValue(data.updatedAt)}</p>
+ </div>
+ </>
+ )}
+
+ <Separator />
+
+ {/* 참고 경고문 */}
+ <div className="text-xs text-red-600 space-y-2 bg-red-50 p-3 rounded-md border border-red-200">
+ <p className="font-medium">※ 참고사항</p>
+ <div className="space-y-1">
+ <p>• 납품대금의 10% 이상을 차지하는 주요 원재료가 있는 경우 모든 주요 원재료에 대해서 적용 또는 미적용에 대한 연동표를 작성해야 한다.</p>
+ <p>• 납품대급연동표를 허위로 작성하거나 근거자료를 허위로 제출할 경우 본 계약이 체결되지 않을 수 있으며, 본 계약이 체결되었더라도 계약의 전부 또는 일부를 해제 또는 해지할 수 있다.</p>
+ </div>
+ </div>
+ </div>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+}
+
diff --git a/lib/bidding/handlers.ts b/lib/bidding/handlers.ts
index 11955a39..b422118d 100644
--- a/lib/bidding/handlers.ts
+++ b/lib/bidding/handlers.ts
@@ -10,6 +10,96 @@
import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils';
/**
+ * 결재 완료 시점을 기준으로 입찰서 제출기간 계산 및 업데이트
+ *
+ * 계산 로직:
+ * - baseDate = 결재완료일 날짜만 (00:00:00)
+ * - 시작일 = baseDate + submissionStartOffset일 + submissionStartDate의 시:분
+ * - 마감일 = 시작일(날짜만) + submissionDurationDays일 + submissionEndDate의 시:분
+ */
+async function calculateAndUpdateSubmissionDates(biddingId: number) {
+ const { default: db } = await import('@/db/db');
+ const { biddings } = await import('@/db/schema');
+ const { eq } = await import('drizzle-orm');
+
+ // 현재 입찰 정보 조회
+ const biddingInfo = await db
+ .select({
+ submissionStartOffset: biddings.submissionStartOffset,
+ submissionDurationDays: biddings.submissionDurationDays,
+ submissionStartDate: biddings.submissionStartDate, // 시간만 저장된 상태 (1970-01-01 HH:MM:00)
+ submissionEndDate: biddings.submissionEndDate, // 시간만 저장된 상태 (1970-01-01 HH:MM:00)
+ })
+ .from(biddings)
+ .where(eq(biddings.id, biddingId))
+ .limit(1);
+
+ if (biddingInfo.length === 0) {
+ debugError('[calculateAndUpdateSubmissionDates] 입찰 정보를 찾을 수 없음', { biddingId });
+ throw new Error('입찰 정보를 찾을 수 없습니다.');
+ }
+
+ const { submissionStartOffset, submissionDurationDays, submissionStartDate, submissionEndDate } = biddingInfo[0];
+
+ // 필수 값 검증
+ if (submissionStartOffset === null || submissionDurationDays === null) {
+ debugError('[calculateAndUpdateSubmissionDates] 오프셋 값이 설정되지 않음', { submissionStartOffset, submissionDurationDays });
+ throw new Error('입찰서 제출기간 오프셋이 설정되지 않았습니다.');
+ }
+
+ // 시간 추출 (기본값: 시작 09:00, 마감 18:00)
+ const startTime = submissionStartDate
+ ? { hours: submissionStartDate.getUTCHours(), minutes: submissionStartDate.getUTCMinutes() }
+ : { hours: 9, minutes: 0 };
+ const endTime = submissionEndDate
+ ? { hours: submissionEndDate.getUTCHours(), minutes: submissionEndDate.getUTCMinutes() }
+ : { hours: 18, minutes: 0 };
+
+ // 1. baseDate = 결재완료일 날짜만 (KST 기준 00:00:00)
+ const now = new Date();
+ const baseDate = new Date(now);
+ // KST 기준으로 날짜만 추출 (시간은 00:00:00)
+ baseDate.setHours(0, 0, 0, 0);
+
+ // 2. 시작일 = baseDate + offset일 + 시작시간
+ const calculatedStartDate = new Date(baseDate);
+ calculatedStartDate.setDate(calculatedStartDate.getDate() + submissionStartOffset);
+ calculatedStartDate.setHours(startTime.hours, startTime.minutes, 0, 0);
+
+ // 3. 마감일 = 시작일(날짜만) + duration일 + 마감시간
+ const calculatedEndDate = new Date(calculatedStartDate);
+ calculatedEndDate.setHours(0, 0, 0, 0); // 시작일의 날짜만
+ calculatedEndDate.setDate(calculatedEndDate.getDate() + submissionDurationDays);
+ calculatedEndDate.setHours(endTime.hours, endTime.minutes, 0, 0);
+
+ debugLog('[calculateAndUpdateSubmissionDates] 입찰서 제출기간 계산 완료', {
+ biddingId,
+ baseDate: baseDate.toISOString(),
+ submissionStartOffset,
+ submissionDurationDays,
+ startTime,
+ endTime,
+ calculatedStartDate: calculatedStartDate.toISOString(),
+ calculatedEndDate: calculatedEndDate.toISOString(),
+ });
+
+ // DB 업데이트
+ await db
+ .update(biddings)
+ .set({
+ submissionStartDate: calculatedStartDate,
+ submissionEndDate: calculatedEndDate,
+ updatedAt: new Date(),
+ })
+ .where(eq(biddings.id, biddingId));
+
+ return {
+ startDate: calculatedStartDate,
+ endDate: calculatedEndDate,
+ };
+}
+
+/**
* 입찰초대 핸들러 (결재 승인 후 실행됨)
*
* ✅ Internal 함수: 결재 워크플로우에서 자동 호출됨 (직접 호출 금지)
@@ -52,7 +142,7 @@ export async function requestBiddingInvitationInternal(payload: {
try {
// 1. 기본계약 발송
const { sendBiddingBasicContracts } = await import('@/lib/bidding/pre-quote/service');
-
+
const vendorDataForContract = payload.vendors.map(vendor => ({
vendorId: vendor.vendorId,
vendorName: vendor.vendorName,
@@ -86,7 +176,7 @@ export async function requestBiddingInvitationInternal(payload: {
debugLog('[BiddingInvitationHandler] 기본계약 발송 완료');
- // 2. 입찰 등록 진행 (상태를 bidding_opened로 변경)
+ // 2. 입찰 등록 진행 (상태를 bidding_opened로 변경, 입찰서 제출기간 자동 계산)
const { registerBidding } = await import('@/lib/bidding/detail/service');
const registerResult = await registerBidding(payload.biddingId, payload.currentUserId.toString());
@@ -127,6 +217,7 @@ export async function mapBiddingInvitationToTemplateVariables(payload: {
biddingNumber: string;
projectName?: string;
itemName?: string;
+ awardCount: string;
biddingType: string;
bidPicName?: string;
supplyPicName?: string;
@@ -181,7 +272,7 @@ export async function mapBiddingInvitationToTemplateVariables(payload: {
const { bidding, biddingItems, vendors, message, specificationMeeting, requestedAt } = payload;
// 제목
- const title = bidding.title || '입찰';
+ const title = bidding.title || '';
// 입찰명
const biddingTitle = bidding.title || '';
@@ -190,7 +281,7 @@ export async function mapBiddingInvitationToTemplateVariables(payload: {
const biddingNumber = bidding.biddingNumber || '';
// 낙찰업체수
- const winnerCount = '1'; // 기본값, 실제로는 bidding 설정에서 가져와야 함
+ const awardCount = bidding.awardCount || '';
// 계약구분
const contractType = bidding.biddingType || '';
@@ -199,7 +290,7 @@ export async function mapBiddingInvitationToTemplateVariables(payload: {
const prNumber = '';
// 예산
- const budget = bidding.targetPrice ? bidding.targetPrice.toLocaleString() : '';
+ const budget = bidding.budget ? bidding.budget.toLocaleString() : '';
// 내정가
const targetPrice = bidding.targetPrice ? bidding.targetPrice.toLocaleString() : '';
@@ -219,9 +310,6 @@ export async function mapBiddingInvitationToTemplateVariables(payload: {
// 입찰 공고문
const biddingNotice = message || '';
- // 입찰담당자 (중복이지만 템플릿에 맞춤)
- const biddingManagerDup = bidding.bidPicName || bidding.supplyPicName || '';
-
// 협력사 정보들
const vendorVariables: Record<string, string> = {};
vendors.forEach((vendor, index) => {
@@ -237,8 +325,6 @@ export async function mapBiddingInvitationToTemplateVariables(payload: {
const hasSpecMeeting = bidding.hasSpecificationMeeting ? '예' : '아니오';
const specMeetingStart = bidding.submissionStartDate ? new Date(bidding.submissionStartDate).toISOString().slice(0, 16).replace('T', ' ') : '';
const specMeetingEnd = bidding.submissionEndDate ? new Date(bidding.submissionEndDate).toISOString().slice(0, 16).replace('T', ' ') : '';
- const specMeetingStartDup = specMeetingStart;
- const specMeetingEndDup = specMeetingEnd;
// 입찰서제출기간 정보
const submissionPeriodExecution = '예'; // 입찰 기간이 있으므로 예
@@ -272,7 +358,7 @@ export async function mapBiddingInvitationToTemplateVariables(payload: {
제목: title,
입찰명: biddingTitle,
입찰번호: biddingNumber,
- 낙찰업체수: winnerCount,
+ 낙찰업체수: awardCount,
계약구분: contractType,
'P/R번호': prNumber,
예산: budget,
@@ -426,12 +512,13 @@ export async function requestBiddingClosureInternal(payload: {
const { default: db } = await import('@/db/db');
const { biddings } = await import('@/db/schema');
const { eq } = await import('drizzle-orm');
-
+ const { getUserNameById } = await import('@/lib/bidding/actions');
+ const userName = await getUserNameById(payload.currentUserId.toString());
await db
.update(biddings)
.set({
status: 'bid_closure',
- updatedBy: payload.currentUserId.toString(),
+ updatedBy: userName,
updatedAt: new Date(),
remarks: payload.description, // 폐찰 사유를 remarks에 저장
})
@@ -618,6 +705,15 @@ export async function mapBiddingAwardToTemplateVariables(payload: {
biddingId: number;
selectionReason: string;
requestedAt: Date;
+ awardedCompanies?: Array<{
+ companyId: number;
+ companyName: string | null;
+ finalQuoteAmount: number;
+ awardRatio: number;
+ vendorCode?: string | null;
+ companySize?: string | null;
+ targetPrice?: number | null;
+ }>;
}): Promise<Record<string, string>> {
const { biddingId, selectionReason, requestedAt } = payload;
@@ -637,6 +733,7 @@ export async function mapBiddingAwardToTemplateVariables(payload: {
biddingType: biddings.biddingType,
bidPicName: biddings.bidPicName,
supplyPicName: biddings.supplyPicName,
+ budget: biddings.budget,
targetPrice: biddings.targetPrice,
awardCount: biddings.awardCount,
})
@@ -652,8 +749,11 @@ export async function mapBiddingAwardToTemplateVariables(payload: {
const bidding = biddingInfo[0];
// 2. 낙찰된 업체 정보 조회
- const { getAwardedCompanies } = await import('@/lib/bidding/detail/service');
- const awardedCompanies = await getAwardedCompanies(biddingId);
+ let awardedCompanies = payload.awardedCompanies;
+ if (!awardedCompanies) {
+ const { getAwardedCompanies } = await import('@/lib/bidding/detail/service');
+ awardedCompanies = await getAwardedCompanies(biddingId);
+ }
// 3. 입찰 대상 자재 정보 조회
const biddingItemsInfo = await db
@@ -684,7 +784,7 @@ export async function mapBiddingAwardToTemplateVariables(payload: {
const biddingNumber = bidding.biddingNumber || '';
const winnerCount = (bidding.awardCount === 'single' ? 1 : bidding.awardCount === 'multiple' ? 2 : 1).toString();
const contractType = bidding.biddingType || '';
- const budget = bidding.targetPrice ? bidding.targetPrice.toLocaleString() : '';
+ const budget = bidding.budget ? bidding.budget.toLocaleString() : '';
const targetPrice = bidding.targetPrice ? bidding.targetPrice.toLocaleString() : '';
const biddingManager = bidding.bidPicName || bidding.supplyPicName || '';
const biddingOverview = bidding.itemName || '';
diff --git a/lib/bidding/list/biddings-page-header.tsx b/lib/bidding/list/biddings-page-header.tsx
index 0be2172b..227a917b 100644
--- a/lib/bidding/list/biddings-page-header.tsx
+++ b/lib/bidding/list/biddings-page-header.tsx
@@ -4,7 +4,11 @@ import { Button } from "@/components/ui/button"
import { Plus, FileText, TrendingUp } from "lucide-react"
import { useRouter } from "next/navigation"
import { InformationButton } from "@/components/information/information-button"
-export function BiddingsPageHeader() {
+import { useTranslation } from "@/i18n/client"
+
+export function BiddingsPageHeader(props: {lng: string}) {
+ const {lng} = props
+ const {t} = useTranslation(lng, 'menu')
const router = useRouter()
return (
@@ -12,11 +16,11 @@ export function BiddingsPageHeader() {
{/* 좌측: 제목과 설명 */}
<div className="space-y-1">
<div className="flex items-center gap-2">
- <h2 className="text-3xl font-bold tracking-tight">입찰 목록 관리</h2>
+ <h2 className="text-3xl font-bold tracking-tight">{t('menu.procurement.bid_management')}</h2>
<InformationButton pagePath="evcp/bid" />
</div>
<p className="text-muted-foreground">
- 입찰 공고를 생성하고 진행 상황을 관리할 수 있습니다.
+ {t('menu.procurement.bid_management_desc')}
</p>
</div>
diff --git a/lib/bidding/list/biddings-table-columns.tsx b/lib/bidding/list/biddings-table-columns.tsx
index 62d4dbe7..602bcbb9 100644
--- a/lib/bidding/list/biddings-table-columns.tsx
+++ b/lib/bidding/list/biddings-table-columns.tsx
@@ -257,21 +257,40 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef
id: "submissionPeriod",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰서제출기간" />,
cell: ({ row }) => {
+ const status = row.original.status
+
+ // 입찰생성 또는 결재진행중 상태일 때는 특별 메시지 표시
+ if (status === 'bidding_generated') {
+ return (
+ <div className="text-xs text-orange-600 font-medium">
+ 입찰 등록중입니다
+ </div>
+ )
+ }
+
+ if (status === 'approval_pending') {
+ return (
+ <div className="text-xs text-blue-600 font-medium">
+ 결재 진행중입니다
+ </div>
+ )
+ }
+
const startDate = row.original.submissionStartDate
const endDate = row.original.submissionEndDate
-
+
if (!startDate || !endDate) return <span className="text-muted-foreground">-</span>
-
+
const startObj = new Date(startDate)
const endObj = new Date(endDate)
- // UI 표시용 KST 변환
- const formatKst = (d: Date) => new Date(d.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ')
-
+ // 입력값 기반: 저장된 UTC 값 그대로 표시 (타임존 가감 없음)
+ const formatValue = (d: Date) => d.toISOString().slice(0, 16).replace('T', ' ')
+
return (
<div className="text-xs">
<div>
- {formatKst(startObj)} ~ {formatKst(endObj)}
+ {formatValue(startObj)} ~ {formatValue(endObj)}
</div>
</div>
)
diff --git a/lib/bidding/list/biddings-table-toolbar-actions.tsx b/lib/bidding/list/biddings-table-toolbar-actions.tsx
index 33368218..b0007c8c 100644
--- a/lib/bidding/list/biddings-table-toolbar-actions.tsx
+++ b/lib/bidding/list/biddings-table-toolbar-actions.tsx
@@ -7,7 +7,7 @@ import {
} from "lucide-react"
import { toast } from "sonner"
import { useSession } from "next-auth/react"
-import { exportTableToExcel } from "@/lib/export"
+import { exportBiddingsToExcel } from "./export-biddings-to-excel"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
@@ -92,6 +92,23 @@ export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActio
return selectedBiddings.length === 1 && selectedBiddings[0].status === 'bidding_generated'
}, [selectedBiddings])
+ // Excel 내보내기 핸들러
+ const handleExport = React.useCallback(async () => {
+ try {
+ setIsExporting(true)
+ await exportBiddingsToExcel(table, {
+ filename: "입찰목록",
+ onlySelected: false,
+ })
+ toast.success("Excel 파일이 다운로드되었습니다.")
+ } catch (error) {
+ console.error("Excel export error:", error)
+ toast.error("Excel 내보내기 중 오류가 발생했습니다.")
+ } finally {
+ setIsExporting(false)
+ }
+ }, [table])
+
return (
<>
<div className="flex items-center gap-2">
@@ -100,6 +117,17 @@ export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActio
// 성공 시 테이블 새로고침 등 추가 작업
// window.location.reload()
}} />
+ {/* Excel 내보내기 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleExport}
+ disabled={isExporting}
+ className="gap-2"
+ >
+ <FileSpreadsheet className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">{isExporting ? "내보내는 중..." : "Excel 내보내기"}</span>
+ </Button>
{/* 전송하기 (업체선정 완료된 입찰만) */}
<Button
variant="default"
@@ -112,20 +140,16 @@ export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActio
<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>
-
-
-
+ <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>
{/* 전송 다이얼로그 */}
diff --git a/lib/bidding/list/export-biddings-to-excel.ts b/lib/bidding/list/export-biddings-to-excel.ts
new file mode 100644
index 00000000..64d98399
--- /dev/null
+++ b/lib/bidding/list/export-biddings-to-excel.ts
@@ -0,0 +1,209 @@
+import { type Table } from "@tanstack/react-table"
+import ExcelJS from "exceljs"
+import { BiddingListItem } from "@/db/schema"
+import {
+ biddingStatusLabels,
+ contractTypeLabels,
+ biddingTypeLabels,
+} from "@/db/schema"
+import { formatDate } from "@/lib/utils"
+
+// BiddingListItem 확장 타입 (manager 정보 포함)
+type BiddingListItemWithManagerCode = BiddingListItem & {
+ bidPicName?: string | null
+ supplyPicName?: string | null
+}
+
+/**
+ * 입찰 목록을 Excel로 내보내기
+ * - 계약구분, 진행상태, 입찰유형은 라벨(명칭)로 변환
+ * - 입찰서 제출기간은 submissionStartDate, submissionEndDate 기준
+ * - 등록일시는 년, 월, 일 형식
+ */
+export async function exportBiddingsToExcel(
+ table: Table<BiddingListItemWithManagerCode>,
+ {
+ filename = "입찰목록",
+ onlySelected = false,
+ }: {
+ filename?: string
+ onlySelected?: boolean
+ } = {}
+): Promise<void> {
+ // 테이블에서 실제 사용 중인 leaf columns 가져오기
+ const allColumns = table.getAllLeafColumns()
+
+ // select, actions 컬럼 제외
+ const columns = allColumns.filter(
+ (col) => !["select", "actions"].includes(col.id)
+ )
+
+ // 헤더 행 생성 (excelHeader 사용)
+ const headerRow = columns.map((col) => {
+ const excelHeader = (col.columnDef.meta as any)?.excelHeader
+ return typeof excelHeader === "string" ? excelHeader : col.id
+ })
+
+ // 데이터 행 생성
+ const rowModel = onlySelected
+ ? table.getFilteredSelectedRowModel()
+ : table.getRowModel()
+
+ const dataRows = rowModel.rows.map((row) => {
+ const original = row.original
+ return columns.map((col) => {
+ const colId = col.id
+ let value: any
+
+ // 특별 처리 필요한 컬럼들
+ switch (colId) {
+ case "contractType":
+ // 계약구분: 라벨로 변환
+ value = contractTypeLabels[original.contractType as keyof typeof contractTypeLabels] || original.contractType
+ break
+
+ case "status":
+ // 진행상태: 라벨로 변환
+ value = biddingStatusLabels[original.status as keyof typeof biddingStatusLabels] || original.status
+ break
+
+ case "biddingType":
+ // 입찰유형: 라벨로 변환
+ value = biddingTypeLabels[original.biddingType as keyof typeof biddingTypeLabels] || original.biddingType
+ break
+
+ case "submissionPeriod":
+ // 입찰서 제출기간: submissionStartDate, submissionEndDate 기준
+ const startDate = original.submissionStartDate
+ const endDate = original.submissionEndDate
+
+ if (!startDate || !endDate) {
+ value = "-"
+ } else {
+ const startObj = new Date(startDate)
+ const endObj = new Date(endDate)
+
+ // 입력값 기반: 저장된 UTC 값을 그대로 표시 (타임존 가감 없음)
+ const formatValue = (d: Date) => d.toISOString().slice(0, 16).replace('T', ' ')
+
+ value = `${formatValue(startObj)} ~ ${formatValue(endObj)}`
+ }
+ break
+
+ case "updatedAt":
+ // 등록일시: 년, 월, 일 형식만
+ if (original.updatedAt) {
+ value = formatDate(original.updatedAt, "KR")
+ } else {
+ value = "-"
+ }
+ break
+
+ case "biddingRegistrationDate":
+ // 입찰등록일: 년, 월, 일 형식만
+ if (original.biddingRegistrationDate) {
+ value = formatDate(original.biddingRegistrationDate, "KR")
+ } else {
+ value = "-"
+ }
+ break
+
+ case "projectName":
+ // 프로젝트: 코드와 이름 조합
+ const code = original.projectCode
+ const name = original.projectName
+ value = code && name ? `${code} (${name})` : (code || name || "-")
+ break
+
+ case "hasSpecificationMeeting":
+ // 사양설명회: Yes/No
+ value = original.hasSpecificationMeeting ? "Yes" : "No"
+ break
+
+ default:
+ // 기본값: row.getValue 사용
+ value = row.getValue(colId)
+
+ // null/undefined 처리
+ if (value == null) {
+ value = ""
+ }
+
+ // 객체인 경우 JSON 문자열로 변환
+ if (typeof value === "object") {
+ value = JSON.stringify(value)
+ }
+ break
+ }
+
+ return value
+ })
+ })
+
+ // 최종 sheetData
+ const sheetData = [headerRow, ...dataRows]
+
+ // ExcelJS로 파일 생성 및 다운로드
+ await createAndDownloadExcel(sheetData, columns.length, filename)
+}
+
+/**
+ * Excel 파일 생성 및 다운로드
+ */
+async function createAndDownloadExcel(
+ sheetData: any[][],
+ columnCount: number,
+ filename: string
+): Promise<void> {
+ // ExcelJS 워크북/시트 생성
+ const workbook = new ExcelJS.Workbook()
+ const worksheet = workbook.addWorksheet("Sheet1")
+
+ // 칼럼별 최대 길이 추적
+ const maxColumnLengths = Array(columnCount).fill(0)
+ sheetData.forEach((row) => {
+ row.forEach((cellValue, colIdx) => {
+ const cellText = cellValue?.toString() ?? ""
+ if (cellText.length > maxColumnLengths[colIdx]) {
+ maxColumnLengths[colIdx] = cellText.length
+ }
+ })
+ })
+
+ // 시트에 데이터 추가 + 헤더 스타일
+ sheetData.forEach((arr, idx) => {
+ const row = worksheet.addRow(arr)
+
+ // 헤더 스타일 적용 (첫 번째 행)
+ if (idx === 0) {
+ row.font = { bold: true }
+ row.alignment = { horizontal: "center" }
+ row.eachCell((cell) => {
+ cell.fill = {
+ type: "pattern",
+ pattern: "solid",
+ fgColor: { argb: "FFCCCCCC" },
+ }
+ })
+ }
+ })
+
+ // 칼럼 너비 자동 조정
+ maxColumnLengths.forEach((len, idx) => {
+ // 최소 너비 10, +2 여백
+ worksheet.getColumn(idx + 1).width = Math.max(len + 2, 10)
+ })
+
+ // 최종 파일 다운로드
+ const buffer = await workbook.xlsx.writeBuffer()
+ const blob = new Blob([buffer], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ })
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement("a")
+ link.href = url
+ link.download = `${filename}.xlsx`
+ link.click()
+ URL.revokeObjectURL(url)
+}
+
diff --git a/lib/bidding/manage/export-bidding-items-to-excel.ts b/lib/bidding/manage/export-bidding-items-to-excel.ts
new file mode 100644
index 00000000..814648a7
--- /dev/null
+++ b/lib/bidding/manage/export-bidding-items-to-excel.ts
@@ -0,0 +1,161 @@
+import ExcelJS from "exceljs"
+import { PRItemInfo } from "@/components/bidding/manage/bidding-items-editor"
+import { getProjectCodesByIds } from "./project-utils"
+
+/**
+ * 입찰품목 목록을 Excel로 내보내기
+ */
+export async function exportBiddingItemsToExcel(
+ items: PRItemInfo[],
+ {
+ filename = "입찰품목목록",
+ }: {
+ filename?: string
+ } = {}
+): Promise<void> {
+ // 프로젝트 ID 목록 수집
+ const projectIds = items
+ .map((item) => item.projectId)
+ .filter((id): id is number => id != null && id > 0)
+
+ // 프로젝트 코드 맵 조회
+ const projectCodeMap = await getProjectCodesByIds(projectIds)
+
+ // 헤더 정의
+ const headers = [
+ "프로젝트코드",
+ "프로젝트명",
+ "자재그룹코드",
+ "자재그룹명",
+ "자재코드",
+ "자재명",
+ "수량",
+ "수량단위",
+ "중량",
+ "중량단위",
+ "납품요청일",
+ "가격단위",
+ "구매단위",
+ "자재순중량",
+ "내정단가",
+ "내정금액",
+ "내정통화",
+ "예산금액",
+ "예산통화",
+ "실적금액",
+ "실적통화",
+ "WBS코드",
+ "WBS명",
+ "코스트센터코드",
+ "코스트센터명",
+ "GL계정코드",
+ "GL계정명",
+ "PR번호",
+ ]
+
+ // 데이터 행 생성
+ const dataRows = items.map((item) => {
+ // 프로젝트 코드 조회
+ const projectCode = item.projectId
+ ? projectCodeMap.get(item.projectId) || ""
+ : ""
+
+ return [
+ projectCode,
+ item.projectInfo || "",
+ item.materialGroupNumber || "",
+ item.materialGroupInfo || "",
+ item.materialNumber || "",
+ item.materialInfo || "",
+ item.quantity || "",
+ item.quantityUnit || "",
+ item.totalWeight || "",
+ item.weightUnit || "",
+ item.requestedDeliveryDate || "",
+ item.priceUnit || "",
+ item.purchaseUnit || "",
+ item.materialWeight || "",
+ item.targetUnitPrice || "",
+ item.targetAmount || "",
+ item.targetCurrency || "KRW",
+ item.budgetAmount || "",
+ item.budgetCurrency || "KRW",
+ item.actualAmount || "",
+ item.actualCurrency || "KRW",
+ item.wbsCode || "",
+ item.wbsName || "",
+ item.costCenterCode || "",
+ item.costCenterName || "",
+ item.glAccountCode || "",
+ item.glAccountName || "",
+ item.prNumber || "",
+ ]
+ })
+
+ // 최종 sheetData
+ const sheetData = [headers, ...dataRows]
+
+ // ExcelJS로 파일 생성 및 다운로드
+ await createAndDownloadExcel(sheetData, headers.length, filename)
+}
+
+/**
+ * Excel 파일 생성 및 다운로드
+ */
+async function createAndDownloadExcel(
+ sheetData: any[][],
+ columnCount: number,
+ filename: string
+): Promise<void> {
+ // ExcelJS 워크북/시트 생성
+ const workbook = new ExcelJS.Workbook()
+ const worksheet = workbook.addWorksheet("Sheet1")
+
+ // 칼럼별 최대 길이 추적
+ const maxColumnLengths = Array(columnCount).fill(0)
+ sheetData.forEach((row) => {
+ row.forEach((cellValue, colIdx) => {
+ const cellText = cellValue?.toString() ?? ""
+ if (cellText.length > maxColumnLengths[colIdx]) {
+ maxColumnLengths[colIdx] = cellText.length
+ }
+ })
+ })
+
+ // 시트에 데이터 추가 + 헤더 스타일
+ sheetData.forEach((arr, idx) => {
+ const row = worksheet.addRow(arr)
+
+ // 헤더 스타일 적용 (첫 번째 행)
+ if (idx === 0) {
+ row.font = { bold: true }
+ row.alignment = { horizontal: "center" }
+ row.eachCell((cell) => {
+ cell.fill = {
+ type: "pattern",
+ pattern: "solid",
+ fgColor: { argb: "FFCCCCCC" },
+ }
+ })
+ }
+ })
+
+ // 칼럼 너비 자동 조정
+ maxColumnLengths.forEach((len, idx) => {
+ // 최소 너비 10, +2 여백
+ worksheet.getColumn(idx + 1).width = Math.max(len + 2, 10)
+ })
+
+ // 최종 파일 다운로드
+ const buffer = await workbook.xlsx.writeBuffer()
+ const blob = new Blob([buffer], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ })
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement("a")
+ link.href = url
+ link.download = `${filename}.xlsx`
+ link.click()
+ URL.revokeObjectURL(url)
+}
+
diff --git a/lib/bidding/manage/import-bidding-items-from-excel.ts b/lib/bidding/manage/import-bidding-items-from-excel.ts
new file mode 100644
index 00000000..fe5b17a9
--- /dev/null
+++ b/lib/bidding/manage/import-bidding-items-from-excel.ts
@@ -0,0 +1,273 @@
+import ExcelJS from "exceljs"
+import { PRItemInfo } from "@/components/bidding/manage/bidding-items-editor"
+import { getProjectIdByCodeAndName } from "./project-utils"
+import { decryptWithServerAction } from "@/components/drm/drmUtils"
+
+export interface ImportBiddingItemsResult {
+ success: boolean
+ items: PRItemInfo[]
+ errors: string[]
+}
+
+/**
+ * Excel 파일에서 입찰품목 데이터 파싱
+ */
+export async function importBiddingItemsFromExcel(
+ file: File
+): Promise<ImportBiddingItemsResult> {
+ const errors: string[] = []
+ const items: PRItemInfo[] = []
+
+ try {
+ const workbook = new ExcelJS.Workbook()
+ // DRM 해제 후 ArrayBuffer 획득 (DRM 서버 미연결 시 원본 반환)
+ const arrayBuffer = await decryptWithServerAction(file)
+ await workbook.xlsx.load(arrayBuffer)
+
+ const worksheet = workbook.worksheets[0]
+ if (!worksheet) {
+ return {
+ success: false,
+ items: [],
+ errors: ["Excel 파일에 시트가 없습니다."],
+ }
+ }
+
+ // 헤더 행 읽기 (첫 번째 행)
+ const headerRow = worksheet.getRow(1)
+ const headerValues = headerRow.values as ExcelJS.CellValue[]
+
+ // 헤더 매핑 생성
+ const headerMap: Record<string, number> = {}
+ const expectedHeaders = [
+ "프로젝트코드",
+ "프로젝트명",
+ "자재그룹코드",
+ "자재그룹명",
+ "자재코드",
+ "자재명",
+ "수량",
+ "수량단위",
+ "중량",
+ "중량단위",
+ "납품요청일",
+ "가격단위",
+ "구매단위",
+ "자재순중량",
+ "내정단가",
+ "내정금액",
+ "내정통화",
+ "예산금액",
+ "예산통화",
+ "실적금액",
+ "실적통화",
+ "WBS코드",
+ "WBS명",
+ "코스트센터코드",
+ "코스트센터명",
+ "GL계정코드",
+ "GL계정명",
+ "PR번호",
+ ]
+
+ // 헤더 인덱스 매핑
+ for (let i = 1; i < headerValues.length; i++) {
+ const headerValue = String(headerValues[i] || "").trim()
+ if (headerValue && expectedHeaders.includes(headerValue)) {
+ headerMap[headerValue] = i
+ }
+ }
+
+ // 필수 헤더 확인
+ const requiredHeaders = ["자재그룹코드", "자재그룹명"]
+ const missingHeaders = requiredHeaders.filter(
+ (h) => !headerMap[h]
+ )
+ if (missingHeaders.length > 0) {
+ errors.push(
+ `필수 컬럼이 없습니다: ${missingHeaders.join(", ")}`
+ )
+ }
+
+ // 데이터 행 읽기 (2번째 행부터)
+ for (let rowIndex = 2; rowIndex <= worksheet.rowCount; rowIndex++) {
+ const row = worksheet.getRow(rowIndex)
+ const rowValues = row.values as ExcelJS.CellValue[]
+
+ // 빈 행 건너뛰기
+ if (rowValues.every((val) => !val || String(val).trim() === "")) {
+ continue
+ }
+
+ // 셀 값 추출 헬퍼
+ const getCellValue = (headerName: string): string => {
+ const colIndex = headerMap[headerName]
+ if (!colIndex) return ""
+ const value = rowValues[colIndex]
+ if (value == null) return ""
+
+ // ExcelJS 객체 처리
+ if (typeof value === "object" && "text" in value) {
+ return String((value as any).text || "")
+ }
+
+ // 날짜 처리
+ if (value instanceof Date) {
+ return value.toISOString().split("T")[0]
+ }
+
+ return String(value).trim()
+ }
+
+ // 필수값 검증
+ const materialGroupNumber = getCellValue("자재그룹코드")
+ const materialGroupInfo = getCellValue("자재그룹명")
+
+ if (!materialGroupNumber || !materialGroupInfo) {
+ errors.push(
+ `${rowIndex}번 행: 자재그룹코드와 자재그룹명은 필수입니다.`
+ )
+ continue
+ }
+
+ // 수량 또는 중량 검증
+ const quantity = getCellValue("수량")
+ const totalWeight = getCellValue("중량")
+ const quantityUnit = getCellValue("수량단위")
+ const weightUnit = getCellValue("중량단위")
+
+ if (!quantity && !totalWeight) {
+ errors.push(
+ `${rowIndex}번 행: 수량 또는 중량 중 하나는 필수입니다.`
+ )
+ continue
+ }
+
+ if (quantity && !quantityUnit) {
+ errors.push(
+ `${rowIndex}번 행: 수량이 있으면 수량단위가 필수입니다.`
+ )
+ continue
+ }
+
+ if (totalWeight && !weightUnit) {
+ errors.push(
+ `${rowIndex}번 행: 중량이 있으면 중량단위가 필수입니다.`
+ )
+ continue
+ }
+
+ // 납품요청일 검증
+ const requestedDeliveryDate = getCellValue("납품요청일")
+ if (!requestedDeliveryDate) {
+ errors.push(
+ `${rowIndex}번 행: 납품요청일은 필수입니다.`
+ )
+ continue
+ }
+
+ // 날짜 형식 검증
+ const dateRegex = /^\d{4}-\d{2}-\d{2}$/
+ if (requestedDeliveryDate && !dateRegex.test(requestedDeliveryDate)) {
+ errors.push(
+ `${rowIndex}번 행: 납품요청일 형식이 올바르지 않습니다. (YYYY-MM-DD 형식)`
+ )
+ continue
+ }
+
+ // 내정단가 검증 (필수)
+ const targetUnitPrice = getCellValue("내정단가")
+ if (!targetUnitPrice || parseFloat(targetUnitPrice.replace(/,/g, "")) <= 0) {
+ errors.push(
+ `${rowIndex}번 행: 내정단가는 필수이며 0보다 커야 합니다.`
+ )
+ continue
+ }
+
+ // 숫자 값 정리 (콤마 제거)
+ const cleanNumber = (value: string): string => {
+ return value.replace(/,/g, "").trim()
+ }
+
+ // 프로젝트 ID 조회 (프로젝트코드와 프로젝트명으로)
+ const projectCode = getCellValue("프로젝트코드")
+ const projectName = getCellValue("프로젝트명")
+ let projectId: number | null = null
+
+ if (projectCode && projectName) {
+ projectId = await getProjectIdByCodeAndName(projectCode, projectName)
+ if (!projectId) {
+ errors.push(
+ `${rowIndex}번 행: 프로젝트코드 "${projectCode}"와 프로젝트명 "${projectName}"에 해당하는 프로젝트를 찾을 수 없습니다.`
+ )
+ // 프로젝트를 찾지 못해도 계속 진행 (경고만 표시)
+ }
+ }
+
+ // PRItemInfo 객체 생성
+ const item: PRItemInfo = {
+ id: -(rowIndex - 1), // 임시 ID (음수)
+ prNumber: getCellValue("PR번호") || null,
+ projectId: projectId,
+ projectInfo: projectName || null,
+ shi: null,
+ quantity: quantity ? cleanNumber(quantity) : null,
+ quantityUnit: quantityUnit || null,
+ totalWeight: totalWeight ? cleanNumber(totalWeight) : null,
+ weightUnit: weightUnit || null,
+ materialDescription: null,
+ hasSpecDocument: false,
+ requestedDeliveryDate: requestedDeliveryDate || null,
+ isRepresentative: false,
+ annualUnitPrice: null,
+ currency: "KRW",
+ materialGroupNumber: materialGroupNumber || null,
+ materialGroupInfo: materialGroupInfo || null,
+ materialNumber: getCellValue("자재코드") || null,
+ materialInfo: getCellValue("자재명") || null,
+ priceUnit: getCellValue("가격단위") || "1",
+ purchaseUnit: getCellValue("구매단위") || "EA",
+ materialWeight: getCellValue("자재순중량") || null,
+ wbsCode: getCellValue("WBS코드") || null,
+ wbsName: getCellValue("WBS명") || null,
+ costCenterCode: getCellValue("코스트센터코드") || null,
+ costCenterName: getCellValue("코스트센터명") || null,
+ glAccountCode: getCellValue("GL계정코드") || null,
+ glAccountName: getCellValue("GL계정명") || null,
+ targetUnitPrice: cleanNumber(targetUnitPrice) || null,
+ targetAmount: getCellValue("내정금액")
+ ? cleanNumber(getCellValue("내정금액"))
+ : null,
+ targetCurrency: getCellValue("내정통화") || "KRW",
+ budgetAmount: getCellValue("예산금액")
+ ? cleanNumber(getCellValue("예산금액"))
+ : null,
+ budgetCurrency: getCellValue("예산통화") || "KRW",
+ actualAmount: getCellValue("실적금액")
+ ? cleanNumber(getCellValue("실적금액"))
+ : null,
+ actualCurrency: getCellValue("실적통화") || "KRW",
+ }
+
+ items.push(item)
+ }
+
+ return {
+ success: errors.length === 0,
+ items,
+ errors,
+ }
+ } catch (error) {
+ console.error("Excel import error:", error)
+ return {
+ success: false,
+ items: [],
+ errors: [
+ error instanceof Error
+ ? error.message
+ : "Excel 파일 파싱 중 오류가 발생했습니다.",
+ ],
+ }
+ }
+}
+
diff --git a/lib/bidding/manage/project-utils.ts b/lib/bidding/manage/project-utils.ts
new file mode 100644
index 00000000..92744695
--- /dev/null
+++ b/lib/bidding/manage/project-utils.ts
@@ -0,0 +1,87 @@
+'use server'
+
+import db from '@/db/db'
+import { projects } from '@/db/schema'
+import { eq, and, inArray } from 'drizzle-orm'
+
+/**
+ * 프로젝트 ID로 프로젝트 코드 조회
+ */
+export async function getProjectCodeById(projectId: number): Promise<string | null> {
+ try {
+ const result = await db
+ .select({ code: projects.code })
+ .from(projects)
+ .where(eq(projects.id, projectId))
+ .limit(1)
+
+ return result[0]?.code || null
+ } catch (error) {
+ console.error('Failed to get project code by id:', error)
+ return null
+ }
+}
+
+/**
+ * 프로젝트 코드와 이름으로 프로젝트 ID 조회
+ */
+export async function getProjectIdByCodeAndName(
+ projectCode: string,
+ projectName: string
+): Promise<number | null> {
+ try {
+ if (!projectCode || !projectName) {
+ return null
+ }
+
+ const result = await db
+ .select({ id: projects.id })
+ .from(projects)
+ .where(
+ and(
+ eq(projects.code, projectCode.trim()),
+ eq(projects.name, projectName.trim())
+ )
+ )
+ .limit(1)
+
+ return result[0]?.id || null
+ } catch (error) {
+ console.error('Failed to get project id by code and name:', error)
+ return null
+ }
+}
+
+/**
+ * 여러 프로젝트 ID로 프로젝트 코드 맵 조회 (성능 최적화)
+ */
+export async function getProjectCodesByIds(
+ projectIds: number[]
+): Promise<Map<number, string>> {
+ try {
+ if (projectIds.length === 0) {
+ return new Map()
+ }
+
+ const uniqueIds = [...new Set(projectIds.filter(id => id != null))]
+ if (uniqueIds.length === 0) {
+ return new Map()
+ }
+
+ const result = await db
+ .select({ id: projects.id, code: projects.code })
+ .from(projects)
+ .where(inArray(projects.id, uniqueIds))
+
+ const map = new Map<number, string>()
+ result.forEach((project) => {
+ map.set(project.id, project.code)
+ })
+
+ return map
+ } catch (error) {
+ console.error('Failed to get project codes by ids:', error)
+ return new Map()
+ }
+}
+
diff --git a/lib/bidding/pre-quote/service.ts b/lib/bidding/pre-quote/service.ts
index 08cb0e2c..6fef228c 100644
--- a/lib/bidding/pre-quote/service.ts
+++ b/lib/bidding/pre-quote/service.ts
@@ -49,16 +49,6 @@ interface UpdateBiddingCompanyInput {
isAttendingMeeting?: boolean
}
-interface PrItemQuotation {
- prItemId: number
- bidUnitPrice: number
- bidAmount: number
- proposedDeliveryDate?: string
- technicalSpecification?: string
-}
-
-
-
// 사전견적용 업체 추가 - biddingCompanies와 company_condition_responses 레코드 생성
export async function createBiddingCompany(input: CreateBiddingCompanyInput) {
try {
@@ -201,16 +191,6 @@ export async function deleteBiddingCompany(id: number) {
}
-// 선택된 업체들에게 사전견적 초대 발송
-interface CompanyWithContacts {
- id: number
- companyId: number
- companyName: string
- selectedMainEmail: string
- additionalEmails: string[]
-}
-
-
// PR 아이템 조회 (입찰에 포함된 품목들)
export async function getPrItemsForBidding(biddingId: number, companyId?: number) {
try {
@@ -253,12 +233,11 @@ export async function getPrItemsForBidding(biddingId: number, companyId?: number
selectFields.bidAmount = companyPrItemBids.bidAmount
selectFields.proposedDeliveryDate = companyPrItemBids.proposedDeliveryDate
selectFields.technicalSpecification = companyPrItemBids.technicalSpecification
- }
-
- let query = db.select(selectFields).from(prItemsForBidding)
+ selectFields.currency = companyPrItemBids.currency
- if (companyId) {
- query = query
+ return await db
+ .select(selectFields)
+ .from(prItemsForBidding)
.leftJoin(biddingCompanies, and(
eq(biddingCompanies.biddingId, biddingId),
eq(biddingCompanies.companyId, companyId)
@@ -266,13 +245,16 @@ export async function getPrItemsForBidding(biddingId: number, companyId?: number
.leftJoin(companyPrItemBids, and(
eq(companyPrItemBids.prItemId, prItemsForBidding.id),
eq(companyPrItemBids.biddingCompanyId, biddingCompanies.id)
- )) as any
+ ))
+ .where(eq(prItemsForBidding.biddingId, biddingId))
+ .orderBy(prItemsForBidding.id)
+ } else {
+ return await db
+ .select(selectFields)
+ .from(prItemsForBidding)
+ .where(eq(prItemsForBidding.biddingId, biddingId))
+ .orderBy(prItemsForBidding.id)
}
-
- query = query.where(eq(prItemsForBidding.biddingId, biddingId)).orderBy(prItemsForBidding.id) as any
-
- const prItems = await query
- return prItems
} catch (error) {
console.error('Failed to get PR items for bidding:', error)
return []
@@ -877,8 +859,8 @@ export async function getSelectedVendorsForBidding(biddingId: number) {
interface CreatePreQuoteRfqInput {
rfqType: string;
rfqTitle: string;
- dueDate: Date;
- picUserId: number;
+ dueDate?: Date;
+ picUserId: number | string | undefined;
projectId?: number;
remark?: string;
biddingNumber?: string;
@@ -893,6 +875,8 @@ interface CreatePreQuoteRfqInput {
remark?: string;
materialCode?: string;
materialName?: string;
+ totalWeight?: number | string | null; // 중량 추가
+ weightUnit?: string | null; // 중량단위 추가
}>;
biddingConditions?: {
paymentTerms?: string | null
@@ -994,6 +978,10 @@ export async function createPreQuoteRfqAction(input: CreatePreQuoteRfqInput) {
quantity: item.quantity, // 수량
uom: item.uom, // 단위
+ // 중량 정보
+ grossWeight: item.totalWeight ? (typeof item.totalWeight === 'string' ? parseFloat(item.totalWeight) : item.totalWeight) : null,
+ gwUom: item.weightUnit || null,
+
majorYn: index === 0, // 첫 번째 아이템을 주요 아이템으로 설정
remark: item.remark || null, // 비고
}));
diff --git a/lib/bidding/receive/biddings-receive-columns.tsx b/lib/bidding/receive/biddings-receive-columns.tsx
index 9650574a..6847d9d5 100644
--- a/lib/bidding/receive/biddings-receive-columns.tsx
+++ b/lib/bidding/receive/biddings-receive-columns.tsx
@@ -1,404 +1,404 @@
-"use client"
-
-import * as React from "react"
-import { type ColumnDef } from "@tanstack/react-table"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
-import {
- Eye, Calendar, Users, CheckCircle, XCircle, Clock, AlertTriangle
-} from "lucide-react"
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
-import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-import {
- biddingStatusLabels,
- contractTypeLabels,
-} from "@/db/schema"
-import { formatDate } from "@/lib/utils"
-import { DataTableRowAction } from "@/types/table"
-
-type BiddingReceiveItem = {
- id: number
- biddingNumber: string
- originalBiddingNumber: string | null
- title: string
- status: string
- contractType: string
- prNumber: string | null
- submissionStartDate: Date | null
- submissionEndDate: Date | null
- bidPicName: string | null
- supplyPicName: string | null
- createdBy: string | null
- createdAt: Date | null
- updatedAt: Date | null
-
- // 참여 현황
- participantExpected: number
- participantParticipated: number
- participantDeclined: number
- participantPending: number
-
- // 개찰 정보
- openedAt: Date | null
- openedBy: string | null
-}
-
-interface GetColumnsProps {
- setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<BiddingReceiveItem> | null>>
- onParticipantClick?: (biddingId: number, participantType: 'expected' | 'participated' | 'declined' | 'pending') => void
-}
-
-// 상태별 배지 색상
-const getStatusBadgeVariant = (status: string) => {
- switch (status) {
- case 'received_quotation':
- return 'secondary'
- case 'bidding_opened':
- return 'default'
- case 'bidding_closed':
- return 'outline'
- default:
- return 'outline'
- }
-}
-
-// 금액 포맷팅
-const formatCurrency = (amount: string | number | null, currency = 'KRW') => {
- if (!amount) return '-'
-
- const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount
- if (isNaN(numAmount)) return '-'
-
- return new Intl.NumberFormat('ko-KR', {
- style: 'currency',
- currency: currency,
- minimumFractionDigits: 0,
- maximumFractionDigits: 0,
- }).format(numAmount)
-}
-
-export function getBiddingsReceiveColumns({ setRowAction, onParticipantClick }: GetColumnsProps): ColumnDef<BiddingReceiveItem>[] {
-
- return [
- // ░░░ 선택 ░░░
- {
- id: "select",
- header: "",
- cell: ({ row }) => (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={(value) => {
- // single select 모드에서는 다른 행들의 선택을 해제
- row.toggleSelected(!!value)
- }}
- aria-label="행 선택"
- />
- ),
- size: 50,
- enableSorting: false,
- enableHiding: false,
- },
-
- // ░░░ 입찰번호 ░░░
- {
- accessorKey: "biddingNumber",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰번호" />,
- cell: ({ row }) => (
- <div className="font-mono text-sm">
- {row.original.biddingNumber}
- </div>
- ),
- size: 120,
- meta: { excelHeader: "입찰번호" },
- },
-
- // ░░░ 입찰명 ░░░
- {
- accessorKey: "title",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰명" />,
- cell: ({ row }) => (
- <div className="truncate max-w-[200px]" title={row.original.title}>
- {/* <Button
- variant="link"
- className="p-0 h-auto text-left justify-start font-bold underline"
- onClick={() => setRowAction({ row, type: "view" })}
- >
- <div className="whitespace-pre-line">
- {row.original.title}
- </div>
- </Button> */}
- {row.original.title}
- </div>
- ),
- size: 200,
- meta: { excelHeader: "입찰명" },
- },
-
- // ░░░ 원입찰번호 ░░░
- {
- accessorKey: "originalBiddingNumber",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="원입찰번호" />,
- cell: ({ row }) => (
- <div className="font-mono text-sm">
- {row.original.originalBiddingNumber || '-'}
- </div>
- ),
- size: 120,
- meta: { excelHeader: "원입찰번호" },
- },
-
- // ░░░ 진행상태 ░░░
- {
- accessorKey: "status",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="진행상태" />,
- cell: ({ row }) => (
- <Badge variant={getStatusBadgeVariant(row.original.status)}>
- {biddingStatusLabels[row.original.status]}
- </Badge>
- ),
- size: 120,
- meta: { excelHeader: "진행상태" },
- },
-
- // ░░░ 계약구분 ░░░
- {
- accessorKey: "contractType",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약구분" />,
- cell: ({ row }) => (
- <Badge variant="outline">
- {contractTypeLabels[row.original.contractType]}
- </Badge>
- ),
- size: 100,
- meta: { excelHeader: "계약구분" },
- },
-
- // ░░░ 입찰서제출기간 ░░░
- {
- id: "submissionPeriod",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰서제출기간" />,
- cell: ({ row }) => {
- const startDate = row.original.submissionStartDate
- const endDate = row.original.submissionEndDate
-
- if (!startDate || !endDate) return <span className="text-muted-foreground">-</span>
-
- const startObj = new Date(startDate)
- const endObj = new Date(endDate)
-
- // UI 표시용 KST 변환
- const formatKst = (d: Date) => new Date(d.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ')
-
- return (
- <div className="text-xs">
- <div>
- {formatKst(startObj)} ~ {formatKst(endObj)}
- </div>
- </div>
- )
- },
- size: 140,
- meta: { excelHeader: "입찰서제출기간" },
- },
-
- // ░░░ P/R번호 ░░░
- {
- accessorKey: "prNumber",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="P/R번호" />,
- cell: ({ row }) => (
- <span className="font-mono text-sm">{row.original.prNumber || '-'}</span>
- ),
- size: 100,
- meta: { excelHeader: "P/R번호" },
- },
-
- // ░░░ 입찰담당자 ░░░
- {
- accessorKey: "bidPicName",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰담당자" />,
- cell: ({ row }) => {
- const bidPic = row.original.bidPicName
- const supplyPic = row.original.supplyPicName
-
- const displayName = bidPic || supplyPic || "-"
- return <span className="text-sm">{displayName}</span>
- },
- size: 100,
- meta: { excelHeader: "입찰담당자" },
- },
-
- // ░░░ 참여예정협력사 ░░░
- {
- id: "participantExpected",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여예정협력사" />,
- cell: ({ row }) => (
- <Button
- variant="ghost"
- size="sm"
- className="h-auto p-1 hover:bg-blue-50"
- onClick={() => onParticipantClick?.(row.original.id, 'expected')}
- disabled={row.original.participantExpected === 0}
- >
- <div className="flex items-center gap-1">
- <Users className="h-4 w-4 text-blue-500" />
- <span className="text-sm font-medium">{row.original.participantExpected}</span>
- </div>
- </Button>
- ),
- size: 100,
- meta: { excelHeader: "참여예정협력사" },
- },
-
- // ░░░ 참여협력사 ░░░
- {
- id: "participantParticipated",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여협력사" />,
- cell: ({ row }) => (
- <Button
- variant="ghost"
- size="sm"
- className="h-auto p-1 hover:bg-green-50"
- onClick={() => onParticipantClick?.(row.original.id, 'participated')}
- disabled={row.original.participantParticipated === 0}
- >
- <div className="flex items-center gap-1">
- <CheckCircle className="h-4 w-4 text-green-500" />
- <span className="text-sm font-medium">{row.original.participantParticipated}</span>
- </div>
- </Button>
- ),
- size: 100,
- meta: { excelHeader: "참여협력사" },
- },
-
- // ░░░ 포기협력사 ░░░
- {
- id: "participantDeclined",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="포기협력사" />,
- cell: ({ row }) => (
- <Button
- variant="ghost"
- size="sm"
- className="h-auto p-1 hover:bg-red-50"
- onClick={() => onParticipantClick?.(row.original.id, 'declined')}
- disabled={row.original.participantDeclined === 0}
- >
- <div className="flex items-center gap-1">
- <XCircle className="h-4 w-4 text-red-500" />
- <span className="text-sm font-medium">{row.original.participantDeclined}</span>
- </div>
- </Button>
- ),
- size: 100,
- meta: { excelHeader: "포기협력사" },
- },
-
- // ░░░ 미제출협력사 ░░░
- {
- id: "participantPending",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="미제출협력사" />,
- cell: ({ row }) => (
- <Button
- variant="ghost"
- size="sm"
- className="h-auto p-1 hover:bg-yellow-50"
- onClick={() => onParticipantClick?.(row.original.id, 'pending')}
- disabled={row.original.participantPending === 0}
- >
- <div className="flex items-center gap-1">
- <Clock className="h-4 w-4 text-yellow-500" />
- <span className="text-sm font-medium">{row.original.participantPending}</span>
- </div>
- </Button>
- ),
- size: 100,
- meta: { excelHeader: "미제출협력사" },
- },
-
- // ░░░ 개찰자명 ░░░
- {
- id: "openedBy",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="개찰자명" />,
- cell: ({ row }) => {
- const openedBy = row.original.openedBy
- return <span className="text-sm">{openedBy || '-'}</span>
- },
- size: 100,
- meta: { excelHeader: "개찰자명" },
- },
-
- // ░░░ 개찰일 ░░░
- {
- id: "openedAt",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="개찰일" />,
- cell: ({ row }) => {
- const openedAt = row.original.openedAt
- return <span className="text-sm">{openedAt ? formatDate(openedAt, "KR") : '-'}</span>
- },
- size: 100,
- meta: { excelHeader: "개찰일" },
- },
-
- // ░░░ 등록자 ░░░
- {
- accessorKey: "createdBy",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="등록자" />,
- cell: ({ row }) => (
- <span className="text-sm">{row.original.createdBy || '-'}</span>
- ),
- size: 100,
- meta: { excelHeader: "등록자" },
- },
-
- // ░░░ 등록일시 ░░░
- {
- accessorKey: "createdAt",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="등록일시" />,
- cell: ({ row }) => (
- <span className="text-sm">{row.original.createdAt ? formatDate(row.original.createdAt, "KR") : '-'}</span>
- ),
- size: 100,
- meta: { excelHeader: "등록일시" },
- },
-
- // ═══════════════════════════════════════════════════════════════
- // 액션
- // ═══════════════════════════════════════════════════════════════
- // {
- // id: "actions",
- // header: "액션",
- // cell: ({ row }) => (
- // <DropdownMenu>
- // <DropdownMenuTrigger asChild>
- // <Button variant="ghost" className="h-8 w-8 p-0">
- // <span className="sr-only">메뉴 열기</span>
- // <AlertTriangle className="h-4 w-4" />
- // </Button>
- // </DropdownMenuTrigger>
- // <DropdownMenuContent align="end">
- // <DropdownMenuItem onClick={() => setRowAction({ row, type: "view" })}>
- // <Eye className="mr-2 h-4 w-4" />
- // 상세보기
- // </DropdownMenuItem>
- // </DropdownMenuContent>
- // </DropdownMenu>
- // ),
- // size: 50,
- // enableSorting: false,
- // enableHiding: false,
- // },
- ]
-}
+"use client"
+
+import * as React from "react"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ Eye, Calendar, Users, CheckCircle, XCircle, Clock, AlertTriangle
+} from "lucide-react"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import {
+ biddingStatusLabels,
+ contractTypeLabels,
+} from "@/db/schema"
+import { formatDate } from "@/lib/utils"
+import { DataTableRowAction } from "@/types/table"
+
+type BiddingReceiveItem = {
+ id: number
+ biddingNumber: string
+ originalBiddingNumber: string | null
+ title: string
+ status: string
+ contractType: string
+ prNumber: string | null
+ submissionStartDate: Date | null
+ submissionEndDate: Date | null
+ bidPicName: string | null
+ supplyPicName: string | null
+ createdBy: string | null
+ createdAt: Date | null
+ updatedAt: Date | null
+
+ // 참여 현황
+ participantExpected: number
+ participantParticipated: number
+ participantDeclined: number
+ participantPending: number
+
+ // 개찰 정보
+ openedAt: Date | null
+ openedBy: string | null
+}
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<BiddingReceiveItem> | null>>
+ onParticipantClick?: (biddingId: number, participantType: 'expected' | 'participated' | 'declined' | 'pending') => void
+}
+
+// 상태별 배지 색상
+const getStatusBadgeVariant = (status: string) => {
+ switch (status) {
+ case 'received_quotation':
+ return 'secondary'
+ case 'bidding_opened':
+ return 'default'
+ case 'bidding_closed':
+ return 'outline'
+ default:
+ return 'outline'
+ }
+}
+
+// 금액 포맷팅
+const formatCurrency = (amount: string | number | null, currency = 'KRW') => {
+ if (!amount) return '-'
+
+ const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount
+ if (isNaN(numAmount)) return '-'
+
+ return new Intl.NumberFormat('ko-KR', {
+ style: 'currency',
+ currency: currency,
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 0,
+ }).format(numAmount)
+}
+
+export function getBiddingsReceiveColumns({ setRowAction, onParticipantClick }: GetColumnsProps): ColumnDef<BiddingReceiveItem>[] {
+
+ return [
+ // ░░░ 선택 ░░░
+ {
+ id: "select",
+ header: "",
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => {
+ // single select 모드에서는 다른 행들의 선택을 해제
+ row.toggleSelected(!!value)
+ }}
+ aria-label="행 선택"
+ />
+ ),
+ size: 50,
+ enableSorting: false,
+ enableHiding: false,
+ },
+
+ // ░░░ 입찰번호 ░░░
+ {
+ accessorKey: "biddingNumber",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰번호" />,
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">
+ {row.original.biddingNumber}
+ </div>
+ ),
+ size: 120,
+ meta: { excelHeader: "입찰번호" },
+ },
+
+ // ░░░ 입찰명 ░░░
+ {
+ accessorKey: "title",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰명" />,
+ cell: ({ row }) => (
+ <div className="truncate max-w-[200px]" title={row.original.title}>
+ {/* <Button
+ variant="link"
+ className="p-0 h-auto text-left justify-start font-bold underline"
+ onClick={() => setRowAction({ row, type: "view" })}
+ >
+ <div className="whitespace-pre-line">
+ {row.original.title}
+ </div>
+ </Button> */}
+ {row.original.title}
+ </div>
+ ),
+ size: 200,
+ meta: { excelHeader: "입찰명" },
+ },
+
+ // ░░░ 원입찰번호 ░░░
+ {
+ accessorKey: "originalBiddingNumber",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="원입찰번호" />,
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">
+ {row.original.originalBiddingNumber || '-'}
+ </div>
+ ),
+ size: 120,
+ meta: { excelHeader: "원입찰번호" },
+ },
+
+ // ░░░ 진행상태 ░░░
+ {
+ accessorKey: "status",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="진행상태" />,
+ cell: ({ row }) => (
+ <Badge variant={getStatusBadgeVariant(row.original.status)}>
+ {biddingStatusLabels[row.original.status]}
+ </Badge>
+ ),
+ size: 120,
+ meta: { excelHeader: "진행상태" },
+ },
+
+ // ░░░ 계약구분 ░░░
+ {
+ accessorKey: "contractType",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약구분" />,
+ cell: ({ row }) => (
+ <Badge variant="outline">
+ {contractTypeLabels[row.original.contractType]}
+ </Badge>
+ ),
+ size: 100,
+ meta: { excelHeader: "계약구분" },
+ },
+
+ // ░░░ 입찰서제출기간 ░░░
+ {
+ id: "submissionPeriod",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰서제출기간" />,
+ cell: ({ row }) => {
+ const startDate = row.original.submissionStartDate
+ const endDate = row.original.submissionEndDate
+
+ if (!startDate || !endDate) return <span className="text-muted-foreground">-</span>
+
+ const startObj = new Date(startDate)
+ const endObj = new Date(endDate)
+
+ // 입력값 기반: 저장된 UTC 값 그대로 표시 (타임존 가감 없음)
+ const formatValue = (d: Date) => d.toISOString().slice(0, 16).replace('T', ' ')
+
+ return (
+ <div className="text-xs">
+ <div>
+ {formatValue(startObj)} ~ {formatValue(endObj)}
+ </div>
+ </div>
+ )
+ },
+ size: 140,
+ meta: { excelHeader: "입찰서제출기간" },
+ },
+
+ // ░░░ P/R번호 ░░░
+ {
+ accessorKey: "prNumber",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="P/R번호" />,
+ cell: ({ row }) => (
+ <span className="font-mono text-sm">{row.original.prNumber || '-'}</span>
+ ),
+ size: 100,
+ meta: { excelHeader: "P/R번호" },
+ },
+
+ // ░░░ 입찰담당자 ░░░
+ {
+ accessorKey: "bidPicName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰담당자" />,
+ cell: ({ row }) => {
+ const bidPic = row.original.bidPicName
+ const supplyPic = row.original.supplyPicName
+
+ const displayName = bidPic || supplyPic || "-"
+ return <span className="text-sm">{displayName}</span>
+ },
+ size: 100,
+ meta: { excelHeader: "입찰담당자" },
+ },
+
+ // ░░░ 참여예정협력사 ░░░
+ {
+ id: "participantExpected",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여예정협력사" />,
+ cell: ({ row }) => (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-auto p-1 hover:bg-blue-50"
+ onClick={() => onParticipantClick?.(row.original.id, 'expected')}
+ disabled={row.original.participantExpected === 0}
+ >
+ <div className="flex items-center gap-1">
+ <Users className="h-4 w-4 text-blue-500" />
+ <span className="text-sm font-medium">{row.original.participantExpected}</span>
+ </div>
+ </Button>
+ ),
+ size: 100,
+ meta: { excelHeader: "참여예정협력사" },
+ },
+
+ // ░░░ 참여협력사 ░░░
+ {
+ id: "participantParticipated",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여협력사" />,
+ cell: ({ row }) => (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-auto p-1 hover:bg-green-50"
+ onClick={() => onParticipantClick?.(row.original.id, 'participated')}
+ disabled={row.original.participantParticipated === 0}
+ >
+ <div className="flex items-center gap-1">
+ <CheckCircle className="h-4 w-4 text-green-500" />
+ <span className="text-sm font-medium">{row.original.participantParticipated}</span>
+ </div>
+ </Button>
+ ),
+ size: 100,
+ meta: { excelHeader: "참여협력사" },
+ },
+
+ // ░░░ 포기협력사 ░░░
+ {
+ id: "participantDeclined",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="포기협력사" />,
+ cell: ({ row }) => (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-auto p-1 hover:bg-red-50"
+ onClick={() => onParticipantClick?.(row.original.id, 'declined')}
+ disabled={row.original.participantDeclined === 0}
+ >
+ <div className="flex items-center gap-1">
+ <XCircle className="h-4 w-4 text-red-500" />
+ <span className="text-sm font-medium">{row.original.participantDeclined}</span>
+ </div>
+ </Button>
+ ),
+ size: 100,
+ meta: { excelHeader: "포기협력사" },
+ },
+
+ // ░░░ 미제출협력사 ░░░
+ {
+ id: "participantPending",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="미제출협력사" />,
+ cell: ({ row }) => (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-auto p-1 hover:bg-yellow-50"
+ onClick={() => onParticipantClick?.(row.original.id, 'pending')}
+ disabled={row.original.participantPending === 0}
+ >
+ <div className="flex items-center gap-1">
+ <Clock className="h-4 w-4 text-yellow-500" />
+ <span className="text-sm font-medium">{row.original.participantPending}</span>
+ </div>
+ </Button>
+ ),
+ size: 100,
+ meta: { excelHeader: "미제출협력사" },
+ },
+
+ // ░░░ 개찰자명 ░░░
+ {
+ id: "openedBy",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="개찰자명" />,
+ cell: ({ row }) => {
+ const openedBy = row.original.openedBy
+ return <span className="text-sm">{openedBy || '-'}</span>
+ },
+ size: 100,
+ meta: { excelHeader: "개찰자명" },
+ },
+
+ // ░░░ 개찰일 ░░░
+ {
+ id: "openedAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="개찰일" />,
+ cell: ({ row }) => {
+ const openedAt = row.original.openedAt
+ return <span className="text-sm">{openedAt ? formatDate(openedAt, "KR") : '-'}</span>
+ },
+ size: 100,
+ meta: { excelHeader: "개찰일" },
+ },
+
+ // ░░░ 등록자 ░░░
+ {
+ accessorKey: "createdBy",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="등록자" />,
+ cell: ({ row }) => (
+ <span className="text-sm">{row.original.createdBy || '-'}</span>
+ ),
+ size: 100,
+ meta: { excelHeader: "등록자" },
+ },
+
+ // ░░░ 등록일시 ░░░
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="등록일시" />,
+ cell: ({ row }) => (
+ <span className="text-sm">{row.original.createdAt ? formatDate(row.original.createdAt, "KR") : '-'}</span>
+ ),
+ size: 100,
+ meta: { excelHeader: "등록일시" },
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // 액션
+ // ═══════════════════════════════════════════════════════════════
+ // {
+ // id: "actions",
+ // header: "액션",
+ // cell: ({ row }) => (
+ // <DropdownMenu>
+ // <DropdownMenuTrigger asChild>
+ // <Button variant="ghost" className="h-8 w-8 p-0">
+ // <span className="sr-only">메뉴 열기</span>
+ // <AlertTriangle className="h-4 w-4" />
+ // </Button>
+ // </DropdownMenuTrigger>
+ // <DropdownMenuContent align="end">
+ // <DropdownMenuItem onClick={() => setRowAction({ row, type: "view" })}>
+ // <Eye className="mr-2 h-4 w-4" />
+ // 상세보기
+ // </DropdownMenuItem>
+ // </DropdownMenuContent>
+ // </DropdownMenu>
+ // ),
+ // size: 50,
+ // enableSorting: false,
+ // enableHiding: false,
+ // },
+ ]
+}
diff --git a/lib/bidding/receive/biddings-receive-table.tsx b/lib/bidding/receive/biddings-receive-table.tsx
index 2b141d5e..6a48fa79 100644
--- a/lib/bidding/receive/biddings-receive-table.tsx
+++ b/lib/bidding/receive/biddings-receive-table.tsx
@@ -1,296 +1,297 @@
-"use client"
-
-import * as React from "react"
-import { useRouter } from "next/navigation"
-import { useSession } from "next-auth/react"
-import type {
- DataTableAdvancedFilterField,
- DataTableFilterField,
- DataTableRowAction,
-} from "@/types/table"
-import { Button } from "@/components/ui/button"
-import { Loader2 } from "lucide-react"
-import { toast } from "sonner"
-
-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 { getBiddingsReceiveColumns } from "./biddings-receive-columns"
-import { getBiddingsForReceive } from "@/lib/bidding/service"
-import {
- biddingStatusLabels,
- contractTypeLabels,
-} from "@/db/schema"
-// import { SpecificationMeetingDialog, PrDocumentsDialog } from "../list/bidding-detail-dialogs"
-import { openBiddingAction } from "@/lib/bidding/actions"
-import { BiddingParticipantsDialog } from "@/components/bidding/receive/bidding-participants-dialog"
-import { getAllBiddingCompanies } from "@/lib/bidding/detail/service"
-
-type BiddingReceiveItem = {
- id: number
- biddingNumber: string
- originalBiddingNumber: string | null
- title: string
- status: string
- contractType: string
- prNumber: string | null
- submissionStartDate: Date | null
- submissionEndDate: Date | null
- bidPicName: string | null
- supplyPicName: string | null
- createdBy: string | null
- createdAt: Date | null
- updatedAt: Date | null
-
- // 참여 현황
- participantExpected: number
- participantParticipated: number
- participantDeclined: number
- participantPending: number
- participantFinalSubmitted: number
-
- // 개찰 정보
- openedAt: Date | null
- openedBy: string | null
-}
-
-interface BiddingsReceiveTableProps {
- promises: Promise<
- [
- Awaited<ReturnType<typeof getBiddingsForReceive>>
- ]
- >
-}
-
-export function BiddingsReceiveTable({ promises }: BiddingsReceiveTableProps) {
- const [biddingsResult] = React.use(promises)
-
- // biddingsResult에서 data와 pageCount 추출
- const { data, pageCount } = biddingsResult
-
- const [isCompact, setIsCompact] = React.useState<boolean>(false)
- // const [specMeetingDialogOpen, setSpecMeetingDialogOpen] = React.useState(false)
- // const [prDocumentsDialogOpen, setPrDocumentsDialogOpen] = React.useState(false)
- const [selectedBidding, setSelectedBidding] = React.useState<BiddingReceiveItem | null>(null)
-
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<BiddingReceiveItem> | null>(null)
- const [isOpeningBidding, setIsOpeningBidding] = React.useState(false)
-
- // 협력사 다이얼로그 관련 상태
- const [participantsDialogOpen, setParticipantsDialogOpen] = React.useState(false)
- const [selectedParticipantType, setSelectedParticipantType] = React.useState<'expected' | 'participated' | 'declined' | 'pending' | null>(null)
- const [selectedBiddingId, setSelectedBiddingId] = React.useState<number | null>(null)
- const [participantCompanies, setParticipantCompanies] = React.useState<any[]>([])
- const [isLoadingParticipants, setIsLoadingParticipants] = React.useState(false)
-
- const router = useRouter()
- const { data: session } = useSession()
-
- // 협력사 클릭 핸들러
- const handleParticipantClick = React.useCallback(async (biddingId: number, participantType: 'expected' | 'participated' | 'declined' | 'pending') => {
- setSelectedBiddingId(biddingId)
- setSelectedParticipantType(participantType)
- setIsLoadingParticipants(true)
- setParticipantsDialogOpen(true)
-
- try {
- // 협력사 데이터 로드 (모든 초대된 협력사)
- const companies = await getAllBiddingCompanies(biddingId)
-
- console.log('Loaded companies:', companies)
-
- // 필터링 없이 모든 데이터 그대로 표시
- // invitationStatus가 그대로 다이얼로그에 표시됨
- setParticipantCompanies(companies)
- } catch (error) {
- console.error('Failed to load participant companies:', error)
- toast.error('협력사 목록을 불러오는데 실패했습니다.')
- setParticipantCompanies([])
- } finally {
- setIsLoadingParticipants(false)
- }
- }, [])
-
- const columns = React.useMemo(
- () => getBiddingsReceiveColumns({ setRowAction, onParticipantClick: handleParticipantClick }),
- [setRowAction, handleParticipantClick]
- )
-
- // rowAction 변경 감지하여 해당 다이얼로그 열기
- React.useEffect(() => {
- if (rowAction) {
- setSelectedBidding(rowAction.row.original)
-
- switch (rowAction.type) {
- case "view":
- // 상세 페이지로 이동
- router.push(`/evcp/bid/${rowAction.row.original.id}`)
- break
- default:
- break
- }
- }
- }, [rowAction, router])
-
- const filterFields: DataTableFilterField<BiddingReceiveItem>[] = [
- {
- id: "biddingNumber",
- label: "입찰번호",
- placeholder: "입찰번호를 입력하세요",
- },
- {
- id: "prNumber",
- label: "P/R번호",
- placeholder: "P/R번호를 입력하세요",
- },
- {
- id: "title",
- label: "입찰명",
- placeholder: "입찰명을 입력하세요",
- },
- ]
-
- const advancedFilterFields: DataTableAdvancedFilterField<BiddingReceiveItem>[] = [
- { id: "title", label: "입찰명", type: "text" },
- { id: "biddingNumber", label: "입찰번호", type: "text" },
- { id: "bidPicName", label: "입찰담당자", type: "text" },
- {
- id: "status",
- label: "진행상태",
- type: "multi-select",
- options: Object.entries(biddingStatusLabels).map(([value, label]) => ({
- label,
- value,
- })),
- },
- {
- id: "contractType",
- label: "계약구분",
- type: "select",
- options: Object.entries(contractTypeLabels).map(([value, label]) => ({
- label,
- value,
- })),
- },
- { id: "createdAt", label: "등록일", type: "date" },
- { id: "submissionStartDate", label: "제출시작일", type: "date" },
- { id: "submissionEndDate", label: "제출마감일", type: "date" },
- ]
-
- const { table } = useDataTable({
- data,
- columns,
- pageCount,
- filterFields,
- enablePinning: true,
- enableAdvancedFilter: true,
- enableRowSelection: true,
- initialState: {
- sorting: [{ id: "createdAt", desc: true }],
- columnPinning: { right: ["actions"] },
- },
- getRowId: (originalRow) => String(originalRow.id),
- shallow: false,
- clearOnDefault: true,
- })
-
- const handleCompactChange = React.useCallback((compact: boolean) => {
- setIsCompact(compact)
- }, [])
-
-
- // 선택된 행 가져오기
- const selectedRows = table.getFilteredSelectedRowModel().rows
- const selectedBiddingForAction = selectedRows.length > 0 ? selectedRows[0].original : null
-
- // 개찰 가능 여부 확인
- const canOpen = React.useMemo(() => {
- if (!selectedBiddingForAction) return false
-
- const now = new Date()
- const submissionEndDate = selectedBiddingForAction.submissionEndDate ? new Date(selectedBiddingForAction.submissionEndDate) : null
-
- // 1. 입찰 마감일이 지났으면 무조건 가능
- if (submissionEndDate && now > submissionEndDate) return true
-
- // 2. 입찰 기간 내 조기개찰 조건 확인
- // - 미제출 협력사가 0이어야 함 (참여예정 = 최종제출 + 포기)
- const participatedOrDeclined = selectedBiddingForAction.participantFinalSubmitted + selectedBiddingForAction.participantDeclined
- const isEarlyOpenPossible = participatedOrDeclined === selectedBiddingForAction.participantExpected
-
- return isEarlyOpenPossible
- }, [selectedBiddingForAction])
-
- const handleOpenBidding = React.useCallback(async () => {
- if (!selectedBiddingForAction) return
-
- setIsOpeningBidding(true)
- try {
- const result = await openBiddingAction(selectedBiddingForAction.id)
- if (result.success) {
- toast.success("개찰이 완료되었습니다.")
- // 데이터 리프레시
- window.location.reload()
- } else {
- toast.error(result.message || "개찰에 실패했습니다.")
- }
- } catch (error) {
- toast.error("개찰 중 오류가 발생했습니다.")
- } finally {
- setIsOpeningBidding(false)
- }
- }, [selectedBiddingForAction])
-
- return (
- <>
- <DataTable
- table={table}
- compact={isCompact}
- >
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- shallow={false}
- enableCompactToggle={true}
- compactStorageKey="biddingsReceiveTableCompact"
- onCompactChange={handleCompactChange}
- >
- <div className="flex items-center gap-2">
- <Button
- variant="outline"
- size="sm"
- onClick={handleOpenBidding}
- disabled={!selectedBiddingForAction || !canOpen || isOpeningBidding}
- >
- {isOpeningBidding && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
- 개찰
- </Button>
- </div>
- </DataTableAdvancedToolbar>
- </DataTable>
-
- {/* 사양설명회 다이얼로그 */}
- {/* <SpecificationMeetingDialog
- open={specMeetingDialogOpen}
- onOpenChange={handleSpecMeetingDialogClose}
- bidding={selectedBidding}
- /> */}
-
- {/* PR 문서 다이얼로그 */}
- {/* <PrDocumentsDialog
- open={prDocumentsDialogOpen}
- onOpenChange={handlePrDocumentsDialogClose}
- bidding={selectedBidding}
- /> */}
-
- {/* 참여 협력사 다이얼로그 */}
- <BiddingParticipantsDialog
- open={participantsDialogOpen}
- onOpenChange={setParticipantsDialogOpen}
- biddingId={selectedBiddingId}
- participantType={selectedParticipantType}
- companies={participantCompanies}
- />
- </>
- )
-}
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import { useSession } from "next-auth/react"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+import { Button } from "@/components/ui/button"
+import { Loader2 } from "lucide-react"
+import { toast } from "sonner"
+
+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 { getBiddingsReceiveColumns } from "./biddings-receive-columns"
+import { getBiddingsForReceive } from "@/lib/bidding/service"
+import {
+ biddingStatusLabels,
+ contractTypeLabels,
+} from "@/db/schema"
+// import { SpecificationMeetingDialog, PrDocumentsDialog } from "../list/bidding-detail-dialogs"
+import { openBiddingAction } from "@/lib/bidding/actions"
+import { BiddingParticipantsDialog } from "@/components/bidding/receive/bidding-participants-dialog"
+import { getAllBiddingCompanies } from "@/lib/bidding/detail/service"
+
+type BiddingReceiveItem = {
+ id: number
+ biddingNumber: string
+ originalBiddingNumber: string | null
+ title: string
+ status: string
+ contractType: string
+ prNumber: string | null
+ submissionStartDate: Date | null
+ submissionEndDate: Date | null
+ bidPicName: string | null
+ supplyPicName: string | null
+ createdBy: string | null
+ createdAt: Date | null
+ updatedAt: Date | null
+
+ // 참여 현황
+ participantExpected: number
+ participantParticipated: number
+ participantDeclined: number
+ participantPending: number
+ participantFinalSubmitted: number
+
+ // 개찰 정보
+ openedAt: Date | null
+ openedBy: string | null
+}
+
+interface BiddingsReceiveTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getBiddingsForReceive>>
+ ]
+ >
+}
+
+export function BiddingsReceiveTable({ promises }: BiddingsReceiveTableProps) {
+ const [biddingsResult] = React.use(promises)
+
+ // biddingsResult에서 data와 pageCount 추출
+ const { data, pageCount } = biddingsResult
+
+ const [isCompact, setIsCompact] = React.useState<boolean>(false)
+ // const [specMeetingDialogOpen, setSpecMeetingDialogOpen] = React.useState(false)
+ // const [prDocumentsDialogOpen, setPrDocumentsDialogOpen] = React.useState(false)
+ const [selectedBidding, setSelectedBidding] = React.useState<BiddingReceiveItem | null>(null)
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<BiddingReceiveItem> | null>(null)
+ const [isOpeningBidding, setIsOpeningBidding] = React.useState(false)
+
+ // 협력사 다이얼로그 관련 상태
+ const [participantsDialogOpen, setParticipantsDialogOpen] = React.useState(false)
+ const [selectedParticipantType, setSelectedParticipantType] = React.useState<'expected' | 'participated' | 'declined' | 'pending' | null>(null)
+ const [selectedBiddingId, setSelectedBiddingId] = React.useState<number | null>(null)
+ const [participantCompanies, setParticipantCompanies] = React.useState<any[]>([])
+ const [isLoadingParticipants, setIsLoadingParticipants] = React.useState(false)
+
+ const router = useRouter()
+ const { data: session } = useSession()
+
+ // 협력사 클릭 핸들러
+ const handleParticipantClick = React.useCallback(async (biddingId: number, participantType: 'expected' | 'participated' | 'declined' | 'pending') => {
+ setSelectedBiddingId(biddingId)
+ setSelectedParticipantType(participantType)
+ setIsLoadingParticipants(true)
+ setParticipantsDialogOpen(true)
+
+ try {
+ // 협력사 데이터 로드 (모든 초대된 협력사)
+ const companies = await getAllBiddingCompanies(biddingId)
+
+ console.log('Loaded companies:', companies)
+
+ // 필터링 없이 모든 데이터 그대로 표시
+ // invitationStatus가 그대로 다이얼로그에 표시됨
+ setParticipantCompanies(companies)
+ } catch (error) {
+ console.error('Failed to load participant companies:', error)
+ toast.error('협력사 목록을 불러오는데 실패했습니다.')
+ setParticipantCompanies([])
+ } finally {
+ setIsLoadingParticipants(false)
+ }
+ }, [])
+
+ const columns = React.useMemo(
+ () => getBiddingsReceiveColumns({ setRowAction, onParticipantClick: handleParticipantClick }),
+ [setRowAction, handleParticipantClick]
+ )
+
+ // rowAction 변경 감지하여 해당 다이얼로그 열기
+ React.useEffect(() => {
+ if (rowAction) {
+ setSelectedBidding(rowAction.row.original)
+
+ switch (rowAction.type) {
+ case "view":
+ // 상세 페이지로 이동
+ router.push(`/evcp/bid/${rowAction.row.original.id}`)
+ break
+ default:
+ break
+ }
+ }
+ }, [rowAction, router])
+
+ const filterFields: DataTableFilterField<BiddingReceiveItem>[] = [
+ {
+ id: "biddingNumber",
+ label: "입찰번호",
+ placeholder: "입찰번호를 입력하세요",
+ },
+ {
+ id: "prNumber",
+ label: "P/R번호",
+ placeholder: "P/R번호를 입력하세요",
+ },
+ {
+ id: "title",
+ label: "입찰명",
+ placeholder: "입찰명을 입력하세요",
+ },
+ ]
+
+ const advancedFilterFields: DataTableAdvancedFilterField<BiddingReceiveItem>[] = [
+ { id: "title", label: "입찰명", type: "text" },
+ { id: "biddingNumber", label: "입찰번호", type: "text" },
+ { id: "bidPicName", label: "입찰담당자", type: "text" },
+ {
+ id: "status",
+ label: "진행상태",
+ type: "multi-select",
+ options: Object.entries(biddingStatusLabels).map(([value, label]) => ({
+ label,
+ value,
+ })),
+ },
+ {
+ id: "contractType",
+ label: "계약구분",
+ type: "select",
+ options: Object.entries(contractTypeLabels).map(([value, label]) => ({
+ label,
+ value,
+ })),
+ },
+ { id: "createdAt", label: "등록일", type: "date" },
+ { id: "submissionStartDate", label: "제출시작일", type: "date" },
+ { id: "submissionEndDate", label: "제출마감일", type: "date" },
+ ]
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ enableRowSelection: true,
+ enableMultiRowSelection: false, // 단일 선택만 가능
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ const handleCompactChange = React.useCallback((compact: boolean) => {
+ setIsCompact(compact)
+ }, [])
+
+
+ // 선택된 행 가져오기
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ const selectedBiddingForAction = selectedRows.length > 0 ? selectedRows[0].original : null
+
+ // 개찰 가능 여부 확인
+ const canOpen = React.useMemo(() => {
+ if (!selectedBiddingForAction) return false
+
+ const now = new Date()
+ const submissionEndDate = selectedBiddingForAction.submissionEndDate ? new Date(selectedBiddingForAction.submissionEndDate) : null
+
+ // 1. 입찰 마감일이 지났으면 무조건 가능
+ if (submissionEndDate && now > submissionEndDate) return true
+
+ // 2. 입찰 기간 내 조기개찰 조건 확인
+ // - 미제출 협력사가 0이어야 함 (참여예정 = 최종제출 + 포기)
+ const participatedOrDeclined = selectedBiddingForAction.participantFinalSubmitted + selectedBiddingForAction.participantDeclined
+ const isEarlyOpenPossible = participatedOrDeclined === selectedBiddingForAction.participantExpected
+
+ return isEarlyOpenPossible
+ }, [selectedBiddingForAction])
+
+ const handleOpenBidding = React.useCallback(async () => {
+ if (!selectedBiddingForAction) return
+
+ setIsOpeningBidding(true)
+ try {
+ const result = await openBiddingAction(selectedBiddingForAction.id)
+ if (result.success) {
+ toast.success("개찰이 완료되었습니다.")
+ // 데이터 리프레시
+ window.location.reload()
+ } else {
+ toast.error(result.message || "개찰에 실패했습니다.")
+ }
+ } catch (error) {
+ toast.error("개찰 중 오류가 발생했습니다.")
+ } finally {
+ setIsOpeningBidding(false)
+ }
+ }, [selectedBiddingForAction])
+
+ return (
+ <>
+ <DataTable
+ table={table}
+ compact={isCompact}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ enableCompactToggle={true}
+ compactStorageKey="biddingsReceiveTableCompact"
+ onCompactChange={handleCompactChange}
+ >
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleOpenBidding}
+ disabled={!selectedBiddingForAction || !canOpen || isOpeningBidding}
+ >
+ {isOpeningBidding && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ 개찰
+ </Button>
+ </div>
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ {/* 사양설명회 다이얼로그 */}
+ {/* <SpecificationMeetingDialog
+ open={specMeetingDialogOpen}
+ onOpenChange={handleSpecMeetingDialogClose}
+ bidding={selectedBidding}
+ /> */}
+
+ {/* PR 문서 다이얼로그 */}
+ {/* <PrDocumentsDialog
+ open={prDocumentsDialogOpen}
+ onOpenChange={handlePrDocumentsDialogClose}
+ bidding={selectedBidding}
+ /> */}
+
+ {/* 참여 협력사 다이얼로그 */}
+ <BiddingParticipantsDialog
+ open={participantsDialogOpen}
+ onOpenChange={setParticipantsDialogOpen}
+ biddingId={selectedBiddingId}
+ participantType={selectedParticipantType}
+ companies={participantCompanies}
+ />
+ </>
+ )
+}
diff --git a/lib/bidding/selection/actions.ts b/lib/bidding/selection/actions.ts
index f19fbe6d..06dcbea1 100644
--- a/lib/bidding/selection/actions.ts
+++ b/lib/bidding/selection/actions.ts
@@ -131,6 +131,75 @@ export async function saveSelectionResult(data: SaveSelectionResultData) {
}
}
+// 선정결과 조회
+export async function getSelectionResult(biddingId: number) {
+ try {
+ // 선정결과 조회 (selectedCompanyId가 null인 레코드)
+ const allResults = await db
+ .select()
+ .from(vendorSelectionResults)
+ .where(eq(vendorSelectionResults.biddingId, biddingId))
+
+ // @ts-ignore
+ const existingResult = allResults.filter((result: any) => result.selectedCompanyId === null).slice(0, 1)
+
+ if (existingResult.length === 0) {
+ return {
+ success: true,
+ data: {
+ summary: '',
+ attachments: []
+ }
+ }
+ }
+
+ const result = existingResult[0]
+
+ // 첨부파일 조회
+ const documents = await db
+ .select({
+ id: biddingDocuments.id,
+ fileName: biddingDocuments.fileName,
+ originalFileName: biddingDocuments.originalFileName,
+ fileSize: biddingDocuments.fileSize,
+ mimeType: biddingDocuments.mimeType,
+ filePath: biddingDocuments.filePath,
+ uploadedAt: biddingDocuments.uploadedAt
+ })
+ .from(biddingDocuments)
+ .where(and(
+ eq(biddingDocuments.biddingId, biddingId),
+ eq(biddingDocuments.documentType, 'selection_result')
+ ))
+
+ return {
+ success: true,
+ data: {
+ summary: result.evaluationSummary || '',
+ attachments: documents.map(doc => ({
+ id: doc.id,
+ fileName: doc.fileName || doc.originalFileName || '',
+ originalFileName: doc.originalFileName || '',
+ fileSize: doc.fileSize || 0,
+ mimeType: doc.mimeType || '',
+ filePath: doc.filePath || '',
+ uploadedAt: doc.uploadedAt
+ }))
+ }
+ }
+ } catch (error) {
+ console.error('Failed to get selection result:', error)
+ return {
+ success: false,
+ error: '선정결과 조회 중 오류가 발생했습니다.',
+ data: {
+ summary: '',
+ attachments: []
+ }
+ }
+ }
+}
+
// 견적 히스토리 조회
export async function getQuotationHistory(biddingId: number, vendorId: number) {
try {
@@ -168,12 +237,14 @@ export async function getQuotationHistory(biddingId: number, vendorId: number) {
.where(eq(biddings.originalBiddingNumber, baseNumber))
.orderBy(biddings.createdAt)
- // 각 bidding에 대한 벤더의 견적 정보 조회
+ // 각 bidding에 대한 벤더의 견적 정보 및 상세 아이템 조회
const historyPromises = relatedBiddings.map(async (bidding) => {
+ // 1. 견적 헤더 정보 조회 (ID 포함)
const biddingCompanyData = await db
.select({
+ id: biddingCompanies.id,
finalQuoteAmount: biddingCompanies.finalQuoteAmount,
- responseSubmittedAt: biddingCompanies.responseSubmittedAt,
+ responseSubmittedAt: biddingCompanies.finalQuoteSubmittedAt,
isFinalSubmission: biddingCompanies.isFinalSubmission
})
.from(biddingCompanies)
@@ -187,84 +258,72 @@ export async function getQuotationHistory(biddingId: number, vendorId: number) {
return null
}
- return {
- biddingId: bidding.id,
- biddingNumber: bidding.biddingNumber,
- finalQuoteAmount: biddingCompanyData[0].finalQuoteAmount,
- responseSubmittedAt: biddingCompanyData[0].responseSubmittedAt,
- isFinalSubmission: biddingCompanyData[0].isFinalSubmission,
- targetPrice: bidding.targetPrice,
- currency: bidding.currency
- }
- })
-
- const historyData = (await Promise.all(historyPromises)).filter(Boolean)
-
- // biddingNumber의 suffix를 기준으로 정렬 (-01, -02, -03 등)
- const sortedHistory = historyData.sort((a, b) => {
- const aSuffix = a!.biddingNumber.split('-')[1] ? parseInt(a!.biddingNumber.split('-')[1]) : 0
- const bSuffix = b!.biddingNumber.split('-')[1] ? parseInt(b!.biddingNumber.split('-')[1]) : 0
- return aSuffix - bSuffix
- })
-
- // PR 항목 정보 조회 (현재 bidding 기준)
- const prItems = await db
- .select({
- id: prItemsForBidding.id,
- itemNumber: prItemsForBidding.itemNumber,
- itemInfo: prItemsForBidding.itemInfo,
- quantity: prItemsForBidding.quantity,
- quantityUnit: prItemsForBidding.quantityUnit,
- requestedDeliveryDate: prItemsForBidding.requestedDeliveryDate
- })
- .from(prItemsForBidding)
- .where(eq(prItemsForBidding.biddingId, biddingId))
-
- // 각 히스토리 항목에 대한 PR 아이템 견적 조회
- const history = await Promise.all(sortedHistory.map(async (item, index) => {
- // 각 bidding에 대한 PR 아이템 견적 조회
+ // 2. 아이템별 견적 및 상세 정보 조회 (Join 사용)
const prItemBids = await db
.select({
- prItemId: companyPrItemBids.prItemId,
+ // 견적 정보
bidUnitPrice: companyPrItemBids.bidUnitPrice,
bidAmount: companyPrItemBids.bidAmount,
- proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate
+ proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate,
+ // 아이템 상세 정보
+ prItemId: prItemsForBidding.id,
+ itemNumber: prItemsForBidding.itemNumber,
+ itemInfo: prItemsForBidding.itemInfo,
+ quantity: prItemsForBidding.quantity,
+ quantityUnit: prItemsForBidding.quantityUnit,
+ requestedDeliveryDate: prItemsForBidding.requestedDeliveryDate
})
.from(companyPrItemBids)
- .where(and(
- eq(companyPrItemBids.biddingId, item!.biddingId),
- eq(companyPrItemBids.companyId, vendorId)
- ))
+ .innerJoin(prItemsForBidding, eq(companyPrItemBids.prItemId, prItemsForBidding.id))
+ .where(eq(companyPrItemBids.biddingCompanyId, biddingCompanyData[0].id))
- const targetPrice = item!.targetPrice ? parseFloat(item!.targetPrice.toString()) : null
- const totalAmount = parseFloat(item!.finalQuoteAmount.toString())
+ // 아이템 매핑
+ const items = prItemBids.map(bid => ({
+ itemCode: bid.itemNumber || `ITEM${bid.prItemId}`,
+ itemName: bid.itemInfo || '품목 정보 없음',
+ quantity: bid.quantity ? parseFloat(bid.quantity.toString()) : 0,
+ unit: bid.quantityUnit || 'EA',
+ unitPrice: bid.bidUnitPrice ? parseFloat(bid.bidUnitPrice.toString()) : 0,
+ totalPrice: bid.bidAmount ? parseFloat(bid.bidAmount.toString()) : 0,
+ deliveryDate: bid.proposedDeliveryDate
+ ? new Date(bid.proposedDeliveryDate)
+ : bid.requestedDeliveryDate
+ ? new Date(bid.requestedDeliveryDate)
+ : new Date()
+ }))
+
+ const targetPrice = bidding.targetPrice ? parseFloat(bidding.targetPrice.toString()) : null
+ const totalAmount = parseFloat(biddingCompanyData[0].finalQuoteAmount.toString())
const vsTargetPrice = targetPrice && targetPrice > 0
? ((totalAmount - targetPrice) / targetPrice) * 100
: 0
- const items = prItemBids.map(bid => {
- const prItem = prItems.find(p => p.id === bid.prItemId)
- return {
- itemCode: prItem?.itemNumber || `ITEM${bid.prItemId}`,
- itemName: prItem?.itemInfo || '품목 정보 없음',
- quantity: prItem?.quantity || 0,
- unit: prItem?.quantityUnit || 'EA',
- unitPrice: parseFloat(bid.bidUnitPrice.toString()),
- totalPrice: parseFloat(bid.bidAmount.toString()),
- deliveryDate: bid.proposedDeliveryDate ? new Date(bid.proposedDeliveryDate) : prItem?.requestedDeliveryDate ? new Date(prItem.requestedDeliveryDate) : new Date()
- }
- })
-
return {
- id: item!.biddingId,
- round: index + 1, // 1차, 2차, 3차...
- submittedAt: new Date(item!.responseSubmittedAt),
+ biddingId: bidding.id,
+ biddingNumber: bidding.biddingNumber,
+ submittedAt: new Date(biddingCompanyData[0].responseSubmittedAt),
totalAmount,
- currency: item!.currency || 'KRW',
+ currency: bidding.currency || 'KRW',
vsTargetPrice: parseFloat(vsTargetPrice.toFixed(2)),
items
}
+ })
+
+ const historyData = (await Promise.all(historyPromises)).filter(Boolean)
+
+ // biddingNumber의 suffix를 기준으로 정렬 (-01, -02, -03 등)
+ const sortedHistory = historyData.sort((a, b) => {
+ const aSuffix = a!.biddingNumber.split('-')[1] ? parseInt(a!.biddingNumber.split('-')[1]) : 0
+ const bSuffix = b!.biddingNumber.split('-')[1] ? parseInt(b!.biddingNumber.split('-')[1]) : 0
+ return aSuffix - bSuffix
+ })
+
+ // 회차 정보 추가
+ const history = sortedHistory.map((item, index) => ({
+ id: item!.biddingId,
+ round: index + 1, // 1차, 2차, 3차...
+ ...item!
}))
return {
diff --git a/lib/bidding/selection/bidding-info-card.tsx b/lib/bidding/selection/bidding-info-card.tsx
index 8864e7db..5904bf65 100644
--- a/lib/bidding/selection/bidding-info-card.tsx
+++ b/lib/bidding/selection/bidding-info-card.tsx
@@ -5,6 +5,18 @@ import { Badge } from '@/components/ui/badge'
// import { formatDate } from '@/lib/utils'
import { biddingStatusLabels, contractTypeLabels } from '@/db/schema'
+// 입찰유형 라벨 맵 추가
+const biddingTypeLabels: Record<string, string> = {
+ equipment: '기자재',
+ construction: '공사',
+ service: '용역',
+ lease: '임차',
+ transport: '운송',
+ waste: '폐기물',
+ sale: '매각',
+ other: '기타(직접입력)',
+}
+
interface BiddingInfoCardProps {
bidding: Bidding
}
@@ -56,7 +68,7 @@ export function BiddingInfoCard({ bidding }: BiddingInfoCardProps) {
입찰유형
</label>
<div className="text-sm font-medium">
- {bidding.isPublic ? '공개입찰' : '비공개입찰'}
+ {biddingTypeLabels[bidding.biddingType as keyof typeof biddingTypeLabels] || bidding.biddingType || '-'}
</div>
</div>
diff --git a/lib/bidding/selection/bidding-item-table.tsx b/lib/bidding/selection/bidding-item-table.tsx
new file mode 100644
index 00000000..aa2b34ec
--- /dev/null
+++ b/lib/bidding/selection/bidding-item-table.tsx
@@ -0,0 +1,205 @@
+'use client'
+
+import * as React from 'react'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { getBiddingSelectionItemsAndPrices } from '@/lib/bidding/service'
+import { formatNumber } from '@/lib/utils'
+import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
+
+interface BiddingItemTableProps {
+ biddingId: number
+}
+
+export function BiddingItemTable({ biddingId }: BiddingItemTableProps) {
+ const [data, setData] = React.useState<{
+ prItems: any[]
+ vendorPrices: any[]
+ }>({ prItems: [], vendorPrices: [] })
+ const [loading, setLoading] = React.useState(true)
+
+ React.useEffect(() => {
+ let isMounted = true
+
+ const loadData = async () => {
+ try {
+ setLoading(true)
+ const { prItems, vendorPrices } = await getBiddingSelectionItemsAndPrices(biddingId)
+
+ if (isMounted) {
+ console.log('prItems', prItems)
+ console.log('vendorPrices', vendorPrices)
+ setData({ prItems, vendorPrices })
+ }
+ } catch (error) {
+ console.error('Failed to load bidding items:', error)
+ } finally {
+ if (isMounted) {
+ setLoading(false)
+ }
+ }
+ }
+
+ loadData()
+
+ return () => {
+ isMounted = false
+ }
+ }, [biddingId])
+
+ // Memoize calculations
+ const totals = React.useMemo(() => {
+ const { prItems } = data
+ return {
+ quantity: prItems.reduce((sum, item) => sum + Number(item.quantity || 0), 0),
+ weight: prItems.reduce((sum, item) => sum + Number(item.totalWeight || 0), 0),
+ targetAmount: prItems.reduce((sum, item) => sum + Number(item.targetAmount || 0), 0)
+ }
+ }, [data.prItems])
+
+ const vendorTotals = React.useMemo(() => {
+ const { vendorPrices } = data
+ return vendorPrices.map(vendor => {
+ const total = vendor.itemPrices.reduce((sum: number, item: any) => sum + Number(item.amount || 0), 0)
+ return {
+ companyId: vendor.companyId,
+ totalAmount: total
+ }
+ })
+ }, [data.vendorPrices])
+
+ if (loading) {
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle>응찰품목</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="flex items-center justify-center py-8">
+ <div className="text-sm text-muted-foreground">로딩 중...</div>
+ </div>
+ </CardContent>
+ </Card>
+ )
+ }
+
+ const { prItems, vendorPrices } = data
+
+
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle>응찰품목</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <ScrollArea className="w-full whitespace-nowrap rounded-md border">
+ <div className="w-max min-w-full">
+ <table className="w-full caption-bottom text-sm">
+ <thead className="[&_tr]:border-b">
+ {/* Header Row 1: Base Info + Vendor Groups */}
+ <tr className="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>자재번호</th>
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>자재내역</th>
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>자재내역상세</th>
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>구매단위</th>
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>수량</th>
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>단위</th>
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>총중량</th>
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>중량단위</th>
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>내정단가</th>
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>내정액</th>
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>통화</th>
+
+ {vendorPrices.map((vendor) => (
+ <th key={vendor.companyId} colSpan={4} className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r bg-muted/20">
+ {vendor.companyName}
+ </th>
+ ))}
+ </tr>
+ {/* Header Row 2: Vendor Sub-columns */}
+ <tr className="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
+ {vendorPrices.map((vendor) => (
+ <React.Fragment key={vendor.companyId}>
+ <th className="h-10 px-2 text-center align-middle font-medium text-muted-foreground border-r bg-muted/10">단가</th>
+ <th className="h-10 px-2 text-center align-middle font-medium text-muted-foreground border-r bg-muted/10">총액</th>
+ <th className="h-10 px-2 text-center align-middle font-medium text-muted-foreground border-r bg-muted/10">통화</th>
+ <th className="h-10 px-2 text-center align-middle font-medium text-muted-foreground border-r bg-muted/10">내정액(%)</th>
+ </React.Fragment>
+ ))}
+ </tr>
+ </thead>
+ <tbody className="[&_tr:last-child]:border-0">
+ {/* Summary Row */}
+ <tr className="border-b transition-colors hover:bg-muted/50 bg-muted/30 font-semibold">
+ <td className="p-4 align-middle text-center border-r" colSpan={4}>합계</td>
+ <td className="p-4 align-middle text-right border-r">{formatNumber(totals.quantity)}</td>
+ <td className="p-4 align-middle text-center border-r">-</td>
+ <td className="p-4 align-middle text-right border-r">{formatNumber(totals.weight)}</td>
+ <td className="p-4 align-middle text-center border-r">-</td>
+ <td className="p-4 align-middle text-center border-r">-</td>
+ <td className="p-4 align-middle text-right border-r">{formatNumber(totals.targetAmount)}</td>
+ <td className="p-4 align-middle text-center border-r">KRW</td>
+
+ {vendorPrices.map((vendor) => {
+ const vTotal = vendorTotals.find(t => t.companyId === vendor.companyId)?.totalAmount || 0
+ const ratio = totals.targetAmount > 0 ? (vTotal / totals.targetAmount) * 100 : 0
+ return (
+ <React.Fragment key={vendor.companyId}>
+ <td className="p-4 align-middle text-center border-r">-</td>
+ <td className="p-4 align-middle text-right border-r">{formatNumber(vTotal)}</td>
+ <td className="p-4 align-middle text-center border-r">{vendor.currency}</td>
+ <td className="p-4 align-middle text-right border-r">{formatNumber(ratio, 0)}%</td>
+ </React.Fragment>
+ )
+ })}
+ </tr>
+
+ {/* Data Rows */}
+ {prItems.map((item) => (
+ <tr key={item.id} className="border-b transition-colors hover:bg-muted/50">
+ <td className="p-4 align-middle border-r">{item.materialNumber}</td>
+ <td className="p-4 align-middle border-r min-w-[150px]">{item.materialInfo}</td>
+ <td className="p-4 align-middle border-r min-w-[150px]">{item.specification}</td>
+ <td className="p-4 align-middle text-center border-r">{item.purchaseUnit}</td>
+ <td className="p-4 align-middle text-right border-r">{formatNumber(item.quantity)}</td>
+ <td className="p-4 align-middle text-center border-r">{item.quantityUnit}</td>
+ <td className="p-4 align-middle text-right border-r">{formatNumber(item.totalWeight)}</td>
+ <td className="p-4 align-middle text-center border-r">{item.weightUnit}</td>
+ <td className="p-4 align-middle text-right border-r">{formatNumber(item.targetUnitPrice)}</td>
+ <td className="p-4 align-middle text-right border-r">{formatNumber(item.targetAmount)}</td>
+ <td className="p-4 align-middle text-center border-r">{item.currency}</td>
+
+ {vendorPrices.map((vendor) => {
+ const bidItem = vendor.itemPrices.find((p: any) => p.prItemId === item.id)
+ const bidAmount = bidItem ? bidItem.amount : 0
+ const targetAmt = Number(item.targetAmount || 0)
+ const ratio = targetAmt > 0 && bidAmount > 0 ? (bidAmount / targetAmt) * 100 : 0
+
+ return (
+ <React.Fragment key={vendor.companyId}>
+ <td className="p-4 align-middle text-right border-r bg-muted/5">
+ {bidItem ? formatNumber(bidItem.unitPrice) : '-'}
+ </td>
+ <td className="p-4 align-middle text-right border-r bg-muted/5">
+ {bidItem ? formatNumber(bidItem.amount) : '-'}
+ </td>
+ <td className="p-4 align-middle text-center border-r bg-muted/5">
+ {vendor.currency}
+ </td>
+ <td className="p-4 align-middle text-right border-r bg-muted/5">
+ {bidItem && ratio > 0 ? `${formatNumber(ratio, 0)}%` : '-'}
+ </td>
+ </React.Fragment>
+ )
+ })}
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ <ScrollBar orientation="horizontal" />
+ </ScrollArea>
+ </CardContent>
+ </Card>
+ )
+}
+
diff --git a/lib/bidding/selection/bidding-selection-detail-content.tsx b/lib/bidding/selection/bidding-selection-detail-content.tsx
index 45d5d402..887498dc 100644
--- a/lib/bidding/selection/bidding-selection-detail-content.tsx
+++ b/lib/bidding/selection/bidding-selection-detail-content.tsx
@@ -5,6 +5,7 @@ import { Bidding } from '@/db/schema'
import { BiddingInfoCard } from './bidding-info-card'
import { SelectionResultForm } from './selection-result-form'
import { VendorSelectionTable } from './vendor-selection-table'
+import { BiddingItemTable } from './bidding-item-table'
interface BiddingSelectionDetailContentProps {
biddingId: number
@@ -17,6 +18,9 @@ export function BiddingSelectionDetailContent({
}: BiddingSelectionDetailContentProps) {
const [refreshKey, setRefreshKey] = React.useState(0)
+ // 입찰평가중 상태가 아니면 읽기 전용
+ const isReadOnly = bidding.status !== 'evaluation_of_bidding'
+
const handleRefresh = React.useCallback(() => {
setRefreshKey(prev => prev + 1)
}, [])
@@ -27,7 +31,7 @@ export function BiddingSelectionDetailContent({
<BiddingInfoCard bidding={bidding} />
{/* 선정결과 폼 */}
- <SelectionResultForm biddingId={biddingId} onSuccess={handleRefresh} />
+ <SelectionResultForm biddingId={biddingId} onSuccess={handleRefresh} readOnly={isReadOnly} />
{/* 업체선정 테이블 */}
<VendorSelectionTable
@@ -35,7 +39,12 @@ export function BiddingSelectionDetailContent({
biddingId={biddingId}
bidding={bidding}
onRefresh={handleRefresh}
+ readOnly={isReadOnly}
/>
+
+ {/* 응찰품목 테이블 */}
+ <BiddingItemTable biddingId={biddingId} />
+
</div>
)
}
diff --git a/lib/bidding/selection/biddings-selection-columns.tsx b/lib/bidding/selection/biddings-selection-columns.tsx
index 87c489e3..030fc05b 100644
--- a/lib/bidding/selection/biddings-selection-columns.tsx
+++ b/lib/bidding/selection/biddings-selection-columns.tsx
@@ -177,14 +177,13 @@ export function getBiddingsSelectionColumns({ setRowAction }: GetColumnsProps):
const startObj = new Date(startDate)
const endObj = new Date(endDate)
- // 비교로직만 유지, 색상표기/마감뱃지 제거
- // UI 표시용 KST 변환
- const formatKst = (d: Date) => new Date(d.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ')
+ // 입력값 기반: 저장된 UTC 값 그대로 표시 (타임존 가감 없음)
+ const formatValue = (d: Date) => d.toISOString().slice(0, 16).replace('T', ' ')
return (
<div className="text-xs">
<div>
- {formatKst(startObj)} ~ {formatKst(endObj)}
+ {formatValue(startObj)} ~ {formatValue(endObj)}
</div>
</div>
)
diff --git a/lib/bidding/selection/biddings-selection-table.tsx b/lib/bidding/selection/biddings-selection-table.tsx
index c3990e7b..41225531 100644
--- a/lib/bidding/selection/biddings-selection-table.tsx
+++ b/lib/bidding/selection/biddings-selection-table.tsx
@@ -84,13 +84,13 @@ export function BiddingsSelectionTable({ promises }: BiddingsSelectionTableProps
switch (rowAction.type) {
case "view":
// 상세 페이지로 이동
- // 입찰평가중일때만 상세보기 가능
- if (rowAction.row.original.status === 'evaluation_of_bidding') {
+ // 입찰평가중, 업체선정, 차수증가, 재입찰 상태일 때 상세보기 가능
+ if (['evaluation_of_bidding', 'vendor_selected', 'round_increase', 'rebidding'].includes(rowAction.row.original.status)) {
router.push(`/evcp/bid-selection/${rowAction.row.original.id}/detail`)
} else {
toast({
title: '접근 제한',
- description: '입찰평가중이 아닙니다.',
+ description: '상세보기가 불가능한 상태입니다.',
variant: 'destructive',
})
}
diff --git a/lib/bidding/selection/selection-result-form.tsx b/lib/bidding/selection/selection-result-form.tsx
index 54687cc9..af6b8d43 100644
--- a/lib/bidding/selection/selection-result-form.tsx
+++ b/lib/bidding/selection/selection-result-form.tsx
@@ -9,8 +9,8 @@ import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { useToast } from '@/hooks/use-toast'
-import { saveSelectionResult } from './actions'
-import { Loader2, Save, FileText } from 'lucide-react'
+import { saveSelectionResult, getSelectionResult } from './actions'
+import { Loader2, Save, FileText, Download, X } from 'lucide-react'
import { Dropzone, DropzoneZone, DropzoneUploadIcon, DropzoneTitle, DropzoneDescription, DropzoneInput } from '@/components/ui/dropzone'
const selectionResultSchema = z.object({
@@ -22,12 +22,25 @@ type SelectionResultFormData = z.infer<typeof selectionResultSchema>
interface SelectionResultFormProps {
biddingId: number
onSuccess: () => void
+ readOnly?: boolean
}
-export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFormProps) {
+interface AttachmentInfo {
+ id: number
+ fileName: string
+ originalFileName: string
+ fileSize: number
+ mimeType: string
+ filePath: string
+ uploadedAt: Date | null
+}
+
+export function SelectionResultForm({ biddingId, onSuccess, readOnly = false }: SelectionResultFormProps) {
const { toast } = useToast()
const [isSubmitting, setIsSubmitting] = React.useState(false)
+ const [isLoading, setIsLoading] = React.useState(true)
const [attachmentFiles, setAttachmentFiles] = React.useState<File[]>([])
+ const [existingAttachments, setExistingAttachments] = React.useState<AttachmentInfo[]>([])
const form = useForm<SelectionResultFormData>({
resolver: zodResolver(selectionResultSchema),
@@ -36,10 +49,53 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor
},
})
+ // 기존 선정결과 로드
+ React.useEffect(() => {
+ const loadSelectionResult = async () => {
+ setIsLoading(true)
+ try {
+ const result = await getSelectionResult(biddingId)
+ if (result.success && result.data) {
+ form.reset({
+ summary: result.data.summary || '',
+ })
+ if (result.data.attachments) {
+ setExistingAttachments(result.data.attachments)
+ }
+ }
+ } catch (error) {
+ console.error('Failed to load selection result:', error)
+ toast({
+ title: '로드 실패',
+ description: '선정결과를 불러오는데 실패했습니다.',
+ variant: 'destructive',
+ })
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ loadSelectionResult()
+ }, [biddingId, form, toast])
+
const removeAttachmentFile = (index: number) => {
setAttachmentFiles(prev => prev.filter((_, i) => i !== index))
}
+ const removeExistingAttachment = (id: number) => {
+ setExistingAttachments(prev => prev.filter(att => att.id !== id))
+ }
+
+ const downloadAttachment = (filePath: string, fileName: string) => {
+ // 파일 다운로드 (filePath가 절대 경로인 경우)
+ if (filePath.startsWith('http') || filePath.startsWith('/')) {
+ window.open(filePath, '_blank')
+ } else {
+ // 상대 경로인 경우
+ window.open(`/api/files/${filePath}`, '_blank')
+ }
+ }
+
const onSubmit = async (data: SelectionResultFormData) => {
setIsSubmitting(true)
try {
@@ -74,6 +130,22 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor
}
}
+ if (isLoading) {
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle>선정결과</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="flex items-center justify-center py-8">
+ <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
+ <span className="ml-2 text-sm text-muted-foreground">로딩 중...</span>
+ </div>
+ </CardContent>
+ </Card>
+ )
+ }
+
return (
<Card>
<CardHeader>
@@ -94,6 +166,7 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor
placeholder="선정결과에 대한 요약을 입력해주세요..."
className="min-h-[120px]"
{...field}
+ disabled={readOnly}
/>
</FormControl>
<FormMessage />
@@ -104,35 +177,83 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor
{/* 첨부파일 */}
<div className="space-y-4">
<FormLabel>첨부파일</FormLabel>
- <Dropzone
- maxSize={10 * 1024 * 1024} // 10MB
- onDropAccepted={(files) => {
- const newFiles = Array.from(files)
- setAttachmentFiles(prev => [...prev, ...newFiles])
- }}
- onDropRejected={() => {
- toast({
- title: "파일 업로드 거부",
- description: "파일 크기 및 형식을 확인해주세요.",
- variant: "destructive",
- })
- }}
- >
- <DropzoneZone>
- <DropzoneUploadIcon className="mx-auto h-12 w-12 text-muted-foreground" />
- <DropzoneTitle className="text-lg font-medium">
- 파일을 드래그하거나 클릭하여 업로드
- </DropzoneTitle>
- <DropzoneDescription className="text-sm text-muted-foreground">
- PDF, Word, Excel, 이미지 파일 (최대 10MB)
- </DropzoneDescription>
- </DropzoneZone>
- <DropzoneInput />
- </Dropzone>
+
+ {/* 기존 첨부파일 */}
+ {existingAttachments.length > 0 && (
+ <div className="space-y-2">
+ <h4 className="text-sm font-medium">기존 첨부파일</h4>
+ <div className="space-y-2">
+ {existingAttachments.map((attachment) => (
+ <div
+ key={attachment.id}
+ className="flex items-center justify-between p-3 bg-muted rounded-lg"
+ >
+ <div className="flex items-center gap-3">
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ <div>
+ <p className="text-sm font-medium">{attachment.originalFileName || attachment.fileName}</p>
+ <p className="text-xs text-muted-foreground">
+ {(attachment.fileSize / 1024 / 1024).toFixed(2)} MB
+ </p>
+ </div>
+ </div>
+ <div className="flex items-center gap-2">
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => downloadAttachment(attachment.filePath, attachment.originalFileName || attachment.fileName)}
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ {!readOnly && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => removeExistingAttachment(attachment.id)}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {!readOnly && (
+ <Dropzone
+ maxSize={10 * 1024 * 1024} // 10MB
+ onDropAccepted={(files) => {
+ const newFiles = Array.from(files)
+ setAttachmentFiles(prev => [...prev, ...newFiles])
+ }}
+ onDropRejected={() => {
+ toast({
+ title: "파일 업로드 거부",
+ description: "파일 크기 및 형식을 확인해주세요.",
+ variant: "destructive",
+ })
+ }}
+ >
+ <DropzoneZone>
+ <DropzoneUploadIcon className="mx-auto h-12 w-12 text-muted-foreground" />
+ <DropzoneTitle className="text-lg font-medium">
+ 파일을 드래그하거나 클릭하여 업로드
+ </DropzoneTitle>
+ <DropzoneDescription className="text-sm text-muted-foreground">
+ PDF, Word, Excel, 이미지 파일 (최대 10MB)
+ </DropzoneDescription>
+ </DropzoneZone>
+ <DropzoneInput />
+ </Dropzone>
+ )}
{attachmentFiles.length > 0 && (
<div className="space-y-2">
- <h4 className="text-sm font-medium">업로드된 파일</h4>
+ <h4 className="text-sm font-medium">새로 추가할 파일</h4>
<div className="space-y-2">
{attachmentFiles.map((file, index) => (
<div
@@ -148,14 +269,16 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor
</p>
</div>
</div>
- <Button
- type="button"
- variant="ghost"
- size="sm"
- onClick={() => removeAttachmentFile(index)}
- >
- 제거
- </Button>
+ {!readOnly && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => removeAttachmentFile(index)}
+ >
+ 제거
+ </Button>
+ )}
</div>
))}
</div>
@@ -164,13 +287,15 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor
</div>
{/* 저장 버튼 */}
- <div className="flex justify-end">
- <Button type="submit" disabled={isSubmitting}>
- {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
- <Save className="mr-2 h-4 w-4" />
- 저장
- </Button>
- </div>
+ {!readOnly && (
+ <div className="flex justify-end">
+ <Button type="submit" disabled={isSubmitting}>
+ {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ <Save className="mr-2 h-4 w-4" />
+ 저장
+ </Button>
+ </div>
+ )}
</form>
</Form>
</CardContent>
diff --git a/lib/bidding/selection/vendor-selection-table.tsx b/lib/bidding/selection/vendor-selection-table.tsx
index 8570b5b6..40f13ec1 100644
--- a/lib/bidding/selection/vendor-selection-table.tsx
+++ b/lib/bidding/selection/vendor-selection-table.tsx
@@ -10,9 +10,10 @@ interface VendorSelectionTableProps {
biddingId: number
bidding: Bidding
onRefresh: () => void
+ readOnly?: boolean
}
-export function VendorSelectionTable({ biddingId, bidding, onRefresh }: VendorSelectionTableProps) {
+export function VendorSelectionTable({ biddingId, bidding, onRefresh, readOnly = false }: VendorSelectionTableProps) {
const [vendors, setVendors] = React.useState<any[]>([])
const [loading, setLoading] = React.useState(true)
@@ -59,6 +60,7 @@ export function VendorSelectionTable({ biddingId, bidding, onRefresh }: VendorSe
vendors={vendors}
onRefresh={onRefresh}
onOpenSelectionReasonDialog={() => {}}
+ readOnly={readOnly}
/>
</CardContent>
</Card>
diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts
index a658ee6a..ed20ad0c 100644
--- a/lib/bidding/service.ts
+++ b/lib/bidding/service.ts
@@ -18,6 +18,7 @@ import {
vendorContacts,
vendors
} from '@/db/schema'
+import { companyConditionResponses } from '@/db/schema/bidding'
import {
eq,
desc,
@@ -39,8 +40,11 @@ import {
import { revalidatePath } from 'next/cache'
import { filterColumns } from '@/lib/filter-columns'
import { GetBiddingsSchema, CreateBiddingSchema } from './validation'
-import { saveFile } from '../file-stroage'
-
+import { saveFile, saveBuffer } from '../file-stroage'
+import { decryptBufferWithServerAction } from '@/components/drm/drmUtils'
+import { getVendorPricesForBidding } from './detail/service'
+import { getPrItemsForBidding } from './pre-quote/service'
+import { checkChemicalSubstance, checkMultipleChemicalSubstances, type ChemicalSubstanceResult } from '@/lib/soap/ecc/send/chemical-substance-check'
// 사용자 이메일로 사용자 코드 조회
@@ -59,6 +63,27 @@ export async function getUserCodeByEmail(email: string): Promise<string | null>
}
}
+// 사용자 ID로 상세 정보 조회 (이름, 코드 등)
+export async function getUserDetails(userId: number) {
+ try {
+ const user = await db
+ .select({
+ id: users.id,
+ name: users.name,
+ userCode: users.userCode,
+ employeeNumber: users.employeeNumber
+ })
+ .from(users)
+ .where(eq(users.id, userId))
+ .limit(1)
+
+ return user[0] || null
+ } catch (error) {
+ console.error('Failed to get user details:', error)
+ return null
+ }
+}
+
// userId를 user.name으로 변환하는 유틸리티 함수
async function getUserNameById(userId: string): Promise<string> {
try {
@@ -419,9 +444,10 @@ export async function getBiddings(input: GetBiddingsSchema) {
// 메타 정보
remarks: biddings.remarks,
updatedAt: biddings.updatedAt,
- updatedBy: biddings.updatedBy,
+ updatedBy: users.name,
})
.from(biddings)
+ .leftJoin(users, sql`${biddings.updatedBy} = ${users.id}::varchar`)
.where(finalWhere)
.orderBy(...orderByColumns)
.limit(input.perPage)
@@ -846,7 +872,7 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
.insert(biddings)
.values({
biddingNumber,
- originalBiddingNumber: null, // 원입찰번호는 초기 생성이므로 아직 없음
+ originalBiddingNumber: biddingNumber.split('-')[0],
revision: input.revision || 0,
// 프로젝트 정보 (PR 아이템에서 설정됨)
@@ -872,7 +898,6 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
biddingRegistrationDate: new Date(),
submissionStartDate: parseDate(input.submissionStartDate),
submissionEndDate: parseDate(input.submissionEndDate),
- evaluationDate: parseDate(input.evaluationDate),
hasSpecificationMeeting: input.hasSpecificationMeeting || false,
hasPrDocument: input.hasPrDocument || false,
@@ -911,6 +936,7 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
await tx.insert(biddingNoticeTemplate).values({
biddingId,
title: input.title + ' 입찰공고',
+ type: input.noticeType || 'standard',
content: input.content || standardContent,
isTemplate: false,
})
@@ -1721,7 +1747,6 @@ export async function updateBiddingBasicInfo(
contractEndDate?: string
submissionStartDate?: string
submissionEndDate?: string
- evaluationDate?: string
hasSpecificationMeeting?: boolean
hasPrDocument?: boolean
currency?: string
@@ -1779,9 +1804,23 @@ export async function updateBiddingBasicInfo(
// 정의된 필드들만 업데이트
if (updates.title !== undefined) updateData.title = updates.title
if (updates.description !== undefined) updateData.description = updates.description
- if (updates.content !== undefined) updateData.content = updates.content
+ // content는 bidding 테이블에 컬럼이 없음, notice content는 별도로 저장해야 함
+ // if (updates.content !== undefined) updateData.content = updates.content
if (updates.noticeType !== undefined) updateData.noticeType = updates.noticeType
if (updates.contractType !== undefined) updateData.contractType = updates.contractType
+
+ // 입찰공고 내용 저장
+ if (updates.content !== undefined) {
+ try {
+ await saveBiddingNotice(biddingId, {
+ title: (updates.title || '') + ' 입찰공고', // 제목이 없으면 기존 제목을 가져오거나 해야하는데, 여기서는 업데이트된 제목 사용
+ content: updates.content
+ })
+ } catch (e) {
+ console.error('Failed to save bidding notice content:', e)
+ // 공고 저장 실패는 전체 업데이트 실패로 처리하지 않음 (로그만 남김)
+ }
+ }
if (updates.biddingType !== undefined) updateData.biddingType = updates.biddingType
if (updates.biddingTypeCustom !== undefined) updateData.biddingTypeCustom = updates.biddingTypeCustom
if (updates.awardCount !== undefined) updateData.awardCount = updates.awardCount
@@ -1793,7 +1832,6 @@ export async function updateBiddingBasicInfo(
if (updates.contractEndDate !== undefined) updateData.contractEndDate = parseDate(updates.contractEndDate)
if (updates.submissionStartDate !== undefined) updateData.submissionStartDate = parseDate(updates.submissionStartDate)
if (updates.submissionEndDate !== undefined) updateData.submissionEndDate = parseDate(updates.submissionEndDate)
- if (updates.evaluationDate !== undefined) updateData.evaluationDate = parseDate(updates.evaluationDate)
if (updates.hasSpecificationMeeting !== undefined) updateData.hasSpecificationMeeting = updates.hasSpecificationMeeting
if (updates.hasPrDocument !== undefined) updateData.hasPrDocument = updates.hasPrDocument
if (updates.currency !== undefined) updateData.currency = updates.currency
@@ -1877,12 +1915,14 @@ export async function updateBiddingBasicInfo(
}
}
-// 입찰 일정 업데이트
+// 입찰 일정 업데이트 (오프셋 기반)
export async function updateBiddingSchedule(
biddingId: number,
schedule: {
- submissionStartDate?: string
- submissionEndDate?: string
+ submissionStartOffset?: number // 시작일 오프셋 (결재 후 n일)
+ submissionStartTime?: string // 시작 시간 (HH:MM)
+ submissionDurationDays?: number // 기간 (시작일 + n일)
+ submissionEndTime?: string // 마감 시간 (HH:MM)
remarks?: string
isUrgent?: boolean
hasSpecificationMeeting?: boolean
@@ -1913,14 +1953,28 @@ export async function updateBiddingSchedule(
return new Date(`${dateStr}:00+09:00`)
}
+ // 시간 문자열(HH:MM)을 임시 timestamp로 변환 (1970-01-01 HH:MM:00 UTC)
+ // 결재 완료 시 실제 날짜로 계산됨
+ const timeToTimestamp = (timeStr?: string): Date | null => {
+ if (!timeStr) return null
+ const [hours, minutes] = timeStr.split(':').map(Number)
+ const date = new Date(0) // 1970-01-01 00:00:00 UTC
+ date.setUTCHours(hours, minutes, 0, 0)
+ return date
+ }
+
return await db.transaction(async (tx) => {
const updateData: any = {
updatedAt: new Date(),
updatedBy: userName,
}
- if (schedule.submissionStartDate !== undefined) updateData.submissionStartDate = parseDate(schedule.submissionStartDate) || null
- if (schedule.submissionEndDate !== undefined) updateData.submissionEndDate = parseDate(schedule.submissionEndDate) || null
+ // 오프셋 기반 필드 저장
+ if (schedule.submissionStartOffset !== undefined) updateData.submissionStartOffset = schedule.submissionStartOffset
+ if (schedule.submissionDurationDays !== undefined) updateData.submissionDurationDays = schedule.submissionDurationDays
+ // 시간은 timestamp 필드에 임시 저장 (1970-01-01 HH:MM:00)
+ if (schedule.submissionStartTime !== undefined) updateData.submissionStartDate = timeToTimestamp(schedule.submissionStartTime)
+ if (schedule.submissionEndTime !== undefined) updateData.submissionEndDate = timeToTimestamp(schedule.submissionEndTime)
if (schedule.remarks !== undefined) updateData.remarks = schedule.remarks
if (schedule.isUrgent !== undefined) updateData.isUrgent = schedule.isUrgent
if (schedule.hasSpecificationMeeting !== undefined) updateData.hasSpecificationMeeting = schedule.hasSpecificationMeeting
@@ -2196,7 +2250,7 @@ export async function updateBiddingProjectInfo(biddingId: number) {
}
// 입찰의 PR 아이템 금액 합산하여 bidding 업데이트
-async function updateBiddingAmounts(biddingId: number) {
+export async function updateBiddingAmounts(biddingId: number) {
try {
// 해당 bidding의 모든 PR 아이템들의 금액 합계 계산
const amounts = await db
@@ -2214,9 +2268,9 @@ async function updateBiddingAmounts(biddingId: number) {
await db
.update(biddings)
.set({
- targetPrice: totalTargetAmount,
- budget: totalBudgetAmount,
- finalBidPrice: totalActualAmount,
+ targetPrice: String(totalTargetAmount),
+ budget: String(totalBudgetAmount),
+ finalBidPrice: String(totalActualAmount),
updatedAt: new Date()
})
.where(eq(biddings.id, biddingId))
@@ -2511,6 +2565,119 @@ export async function deleteBiddingCompanyContact(contactId: number) {
}
}
+// 입찰담당자별 입찰 업체 조회
+export async function getBiddingCompaniesByBidPicId(bidPicId: number) {
+ try {
+ const companies = await db
+ .select({
+ biddingId: biddings.id,
+ biddingNumber: biddings.biddingNumber,
+ biddingTitle: biddings.title,
+ companyId: biddingCompanies.companyId,
+ vendorCode: vendors.vendorCode,
+ vendorName: vendors.vendorName,
+ updatedAt: biddings.updatedAt,
+ })
+ .from(biddings)
+ .innerJoin(biddingCompanies, eq(biddings.id, biddingCompanies.biddingId))
+ .innerJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+ .where(eq(biddings.bidPicId, bidPicId))
+ .orderBy(desc(biddings.updatedAt))
+
+ return {
+ success: true,
+ data: companies
+ }
+ } catch (error) {
+ console.error('Failed to get bidding companies by bidPicId:', error)
+ return {
+ success: false,
+ error: '입찰 업체 조회에 실패했습니다.',
+ data: []
+ }
+ }
+}
+
+// 입찰 업체를 현재 입찰에 추가 (담당자 정보 포함)
+export async function addBiddingCompanyFromOtherBidding(
+ targetBiddingId: number,
+ sourceBiddingId: number,
+ companyId: number,
+ contacts?: Array<{
+ contactName: string
+ contactEmail: string
+ contactNumber?: string
+ }>
+) {
+ try {
+ return await db.transaction(async (tx) => {
+ // 중복 체크
+ const existingCompany = await tx
+ .select()
+ .from(biddingCompanies)
+ .where(
+ and(
+ eq(biddingCompanies.biddingId, targetBiddingId),
+ eq(biddingCompanies.companyId, companyId)
+ )
+ )
+ .limit(1)
+
+ if (existingCompany.length > 0) {
+ return {
+ success: false,
+ error: '이미 등록된 업체입니다.'
+ }
+ }
+
+ // 1. biddingCompanies 레코드 생성
+ const [biddingCompanyResult] = await tx
+ .insert(biddingCompanies)
+ .values({
+ biddingId: targetBiddingId,
+ companyId: companyId,
+ invitationStatus: 'pending',
+ invitedAt: new Date(),
+ })
+ .returning({ id: biddingCompanies.id })
+
+ if (!biddingCompanyResult) {
+ throw new Error('업체 추가에 실패했습니다.')
+ }
+
+ // 2. 담당자 정보 추가
+ if (contacts && contacts.length > 0) {
+ await tx.insert(biddingCompaniesContacts).values(
+ contacts.map(contact => ({
+ biddingId: targetBiddingId,
+ vendorId: companyId,
+ contactName: contact.contactName,
+ contactEmail: contact.contactEmail,
+ contactNumber: contact.contactNumber || null,
+ }))
+ )
+ }
+
+ // 3. company_condition_responses 레코드 생성
+ await tx.insert(companyConditionResponses).values({
+ biddingCompanyId: biddingCompanyResult.id,
+ })
+
+ return {
+ success: true,
+ message: '업체가 성공적으로 추가되었습니다.',
+ data: { id: biddingCompanyResult.id }
+ }
+ })
+ } catch (error) {
+ console.error('Failed to add bidding company from other bidding:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '업체 추가에 실패했습니다.'
+ }
+ }
+}
+
export async function updateBiddingConditions(
biddingId: number,
updates: {
@@ -2758,10 +2925,13 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u
// 2. 입찰번호 생성 (타입에 따라 다르게 처리)
let newBiddingNumber: string
+ let originalBiddingNumber: string
if (type === 'rebidding') {
// 재입찰: 완전히 새로운 입찰번호 생성
newBiddingNumber = await generateBiddingNumber(existingBidding.contractType, userId, tx)
+ // 재입찰시에도 원입찰번호는 새로 생성된 입찰번호로 셋팅
+ originalBiddingNumber = newBiddingNumber.split('-')[0]
} else {
// 차수증가: 기존 입찰번호에서 차수 증가
const currentBiddingNumber = existingBidding.biddingNumber
@@ -2771,16 +2941,18 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u
let currentRound = match ? parseInt(match[1]) : 1
if (currentRound >= 3) {
- // -03 이상이면 새로운 번호 생성
+ // -03 이상이면 재입찰이며, 새로운 번호 생성
newBiddingNumber = await generateBiddingNumber(existingBidding.contractType, userId, tx)
+ // 새로 생성한 입찰번호를 원입찰번호로 셋팅
+ originalBiddingNumber = newBiddingNumber.split('-')[0]
} else {
// -02까지는 차수만 증가
const baseNumber = currentBiddingNumber.split('-')[0]
newBiddingNumber = `${baseNumber}-${String(currentRound + 1).padStart(2, '0')}`
+ // 차수증가의 경우에도 원입찰번호는 새로 생성한 입찰번호로 셋팅
+ originalBiddingNumber = newBiddingNumber.split('-')[0]
}
}
- //원입찰번호는 -0n 제외하고 저장
- const originalBiddingNumber = existingBidding.biddingNumber.split('-')[0]
// 3. 새로운 입찰 생성 (기존 정보 복제)
const [newBidding] = await tx
@@ -2793,13 +2965,15 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u
// 기본 정보 복제
projectName: existingBidding.projectName,
+ projectCode: existingBidding.projectCode, // 프로젝트 코드 복제
itemName: existingBidding.itemName,
title: existingBidding.title,
description: existingBidding.description,
// 계약 정보 복제
contractType: existingBidding.contractType,
- biddingType: existingBidding.biddingType,
+ noticeType: existingBidding.noticeType, // 공고타입 복제
+ biddingType: existingBidding.biddingType, // 구매유형 복제
awardCount: existingBidding.awardCount,
contractStartDate: existingBidding.contractStartDate,
contractEndDate: existingBidding.contractEndDate,
@@ -2809,7 +2983,6 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u
biddingRegistrationDate: new Date(),
submissionStartDate: null,
submissionEndDate: null,
- evaluationDate: null,
// 사양설명회
hasSpecificationMeeting: existingBidding.hasSpecificationMeeting,
@@ -2819,6 +2992,7 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u
budget: existingBidding.budget,
targetPrice: existingBidding.targetPrice,
targetPriceCalculationCriteria: existingBidding.targetPriceCalculationCriteria,
+ actualPrice: existingBidding.actualPrice,
finalBidPrice: null, // 최종입찰가는 초기화
// PR 정보 복제
@@ -2832,6 +3006,7 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u
// 구매조직
purchasingOrganization: existingBidding.purchasingOrganization,
+ plant: existingBidding.plant,
// 담당자 정보 복제
bidPicId: existingBidding.bidPicId,
@@ -3074,8 +3249,6 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u
.from(biddingDocuments)
.where(and(
eq(biddingDocuments.biddingId, biddingId),
- // PR 아이템에 연결된 첨부파일은 제외 (SHI용과 협력업체용만 복제)
- isNull(biddingDocuments.prItemId),
// SHI용(evaluation_doc) 또는 협력업체용(company_proposal) 문서만 복제
or(
eq(biddingDocuments.documentType, 'evaluation_doc'),
@@ -3086,32 +3259,34 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u
if (existingDocuments.length > 0) {
for (const doc of existingDocuments) {
try {
- // 기존 파일을 Buffer로 읽어서 File 객체 생성
- const { readFileSync, existsSync } = await import('fs')
+ // 기존 파일 경로 확인 및 Buffer로 읽기
+ const { readFile, access, constants } = await import('fs/promises')
const { join } = await import('path')
+ // 파일 경로 정규화
const oldFilePath = doc.filePath.startsWith('/uploads/')
? join(process.cwd(), 'public', doc.filePath)
+ : doc.filePath.startsWith('/')
+ ? join(process.cwd(), 'public', doc.filePath)
: doc.filePath
- if (!existsSync(oldFilePath)) {
- console.warn(`원본 파일이 존재하지 않음: ${oldFilePath}`)
+ // 파일 존재 여부 확인
+ try {
+ await access(oldFilePath, constants.R_OK)
+ } catch {
+ console.warn(`원본 파일이 존재하지 않거나 읽을 수 없음: ${oldFilePath}`)
continue
}
- // 파일 내용을 읽어서 Buffer 생성
- const fileBuffer = readFileSync(oldFilePath)
-
- // Buffer를 File 객체로 변환 (브라우저 File API 시뮬레이션)
- const file = new File([fileBuffer], doc.fileName, {
- type: doc.mimeType || 'application/octet-stream'
- })
+ // 파일 내용을 Buffer로 읽기
+ const fileBuffer = await readFile(oldFilePath)
- // saveFile을 사용하여 새 파일 저장
- const saveResult = await saveFile({
- file,
+ // saveBuffer를 사용하여 새 파일 저장 (File 객체 변환 없이 직접 저장)
+ const saveResult = await saveBuffer({
+ buffer: fileBuffer,
+ fileName: doc.fileName,
directory: `biddings/${newBidding.id}/attachments/${doc.documentType === 'evaluation_doc' ? 'shi' : 'vendor'}`,
- originalName: `copied_${Date.now()}_${doc.fileName}`,
+ originalName: doc.originalFileName || doc.fileName,
userId: userName
})
@@ -3145,9 +3320,10 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u
}
}
- revalidatePath('/bidding')
- revalidatePath(`/bidding/${biddingId}`) // 기존 입찰 페이지도 갱신
- revalidatePath(`/bidding/${newBidding.id}`)
+ revalidatePath('/bid-receive')
+ revalidatePath('/evcp/bid-receive')
+ revalidatePath('/evcp/bid')
+ revalidatePath(`/bid-receive/${biddingId}`) // 기존 입찰 페이지도 갱신
return {
success: true,
@@ -3436,9 +3612,10 @@ export async function getBiddingsForSelection(input: GetBiddingsSchema) {
// 'bidding_opened', 'bidding_closed', 'evaluation_of_bidding', 'vendor_selected' 상태만 조회
basicConditions.push(
or(
- eq(biddings.status, 'bidding_closed'),
eq(biddings.status, 'evaluation_of_bidding'),
- eq(biddings.status, 'vendor_selected')
+ eq(biddings.status, 'vendor_selected'),
+ eq(biddings.status, 'round_increase'),
+ eq(biddings.status, 'rebidding'),
)!
)
@@ -3704,7 +3881,7 @@ export async function getBiddingsForFailure(input: GetBiddingsSchema) {
// 유찰 정보 (업데이트 일시를 유찰일로 사용)
disposalDate: biddings.updatedAt, // 유찰일
disposalUpdatedAt: biddings.updatedAt, // 폐찰수정일
- disposalUpdatedBy: biddings.updatedBy, // 폐찰수정자
+ disposalUpdatedBy: users.name, // 폐찰수정자
// 폐찰 정보
closureReason: biddings.description, // 폐찰사유
@@ -3719,9 +3896,10 @@ export async function getBiddingsForFailure(input: GetBiddingsSchema) {
createdBy: biddings.createdBy,
createdAt: biddings.createdAt,
updatedAt: biddings.updatedAt,
- updatedBy: biddings.updatedBy,
+ updatedBy: users.name,
})
.from(biddings)
+ .leftJoin(users, sql`${biddings.updatedBy} = ${users.id}::varchar`)
.leftJoin(biddingDocuments, and(
eq(biddingDocuments.biddingId, biddings.id),
eq(biddingDocuments.documentType, 'evaluation_doc'), // 폐찰 문서
@@ -3791,4 +3969,378 @@ export async function getBiddingsForFailure(input: GetBiddingsSchema) {
console.error("Error in getBiddingsForFailure:", err)
return { data: [], pageCount: 0, total: 0 }
}
-} \ No newline at end of file
+}
+
+
+export async function getBiddingSelectionItemsAndPrices(biddingId: number) {
+ try {
+ const [prItems, vendorPrices] = await Promise.all([
+ getPrItemsForBidding(biddingId),
+ getVendorPricesForBidding(biddingId)
+ ])
+
+ return {
+ prItems,
+ vendorPrices
+ }
+ } catch (error) {
+ console.error('Failed to get bidding selection items and prices:', error)
+ throw error
+ }
+}
+
+// ========================================
+// 화학물질 조회 및 저장 관련 함수들
+// ========================================
+
+/**
+ * 입찰 참여업체의 화학물질 정보를 조회하고 DB에 저장
+ */
+// export async function checkAndSaveChemicalSubstanceForBiddingCompany(biddingCompanyId: number) {
+// try {
+// // 입찰 참여업체 정보 조회 (벤더 정보 포함)
+// const biddingCompanyInfo = await db
+// .select({
+// id: biddingCompanies.id,
+// biddingId: biddingCompanies.biddingId,
+// companyId: biddingCompanies.companyId,
+// hasChemicalSubstance: biddingCompanies.hasChemicalSubstance,
+// vendors: {
+// vendorCode: vendors.vendorCode
+// }
+// })
+// .from(biddingCompanies)
+// .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+// .where(eq(biddingCompanies.id, biddingCompanyId))
+// .limit(1)
+
+// if (!biddingCompanyInfo[0]) {
+// throw new Error(`입찰 참여업체를 찾을 수 없습니다: ${biddingCompanyId}`)
+// }
+
+// const companyInfo = biddingCompanyInfo[0]
+
+// // 이미 화학물질 검사가 완료된 경우 스킵
+// if (companyInfo.hasChemicalSubstance !== null && companyInfo.hasChemicalSubstance !== undefined) {
+// console.log(`이미 화학물질 검사가 완료된 입찰 참여업체: ${biddingCompanyId}`)
+// return {
+// success: true,
+// message: '이미 화학물질 검사가 완료되었습니다.',
+// hasChemicalSubstance: companyInfo.hasChemicalSubstance
+// }
+// }
+
+// // 벤더 코드가 없는 경우 스킵
+// if (!companyInfo.vendors?.vendorCode) {
+// console.log(`벤더 코드가 없는 입찰 참여업체: ${biddingCompanyId}`)
+// return {
+// success: false,
+// message: '벤더 코드가 없습니다.'
+// }
+// }
+
+// // 입찰의 PR 아이템들 조회 (자재번호 있는 것만)
+// const prItems = await db
+// .select({
+// id: prItemsForBidding.id,
+// materialNumber: prItemsForBidding.materialNumber
+// })
+// .from(prItemsForBidding)
+// .where(and(
+// eq(prItemsForBidding.biddingId, companyInfo.biddingId),
+// isNotNull(prItemsForBidding.materialNumber),
+// sql`${prItemsForBidding.materialNumber} != ''`
+// ))
+
+// if (prItems.length === 0) {
+// console.log(`자재번호가 있는 PR 아이템이 없는 입찰: ${companyInfo.biddingId}`)
+// return {
+// success: false,
+// message: '조회할 자재가 없습니다.'
+// }
+// }
+
+// // 각 자재에 대해 화학물질 조회
+// let hasAnyChemicalSubstance = false
+// const results: Array<{ materialNumber: string; hasChemicalSubstance: boolean; message: string }> = []
+
+// for (const prItem of prItems) {
+// try {
+// const checkResult = await checkChemicalSubstance({
+// bukrs: 'H100', // 회사코드는 H100 고정
+// werks: 'PM11', // WERKS는 PM11 고정
+// lifnr: companyInfo.vendors.vendorCode,
+// matnr: prItem.materialNumber!
+// })
+
+// if (checkResult.success) {
+// const itemHasChemical = checkResult.hasChemicalSubstance || false
+// hasAnyChemicalSubstance = hasAnyChemicalSubstance || itemHasChemical
+
+// results.push({
+// materialNumber: prItem.materialNumber!,
+// hasChemicalSubstance: itemHasChemical,
+// message: checkResult.message || '조회 성공'
+// })
+// } else {
+// results.push({
+// materialNumber: prItem.materialNumber!,
+// hasChemicalSubstance: false,
+// message: checkResult.message || '조회 실패'
+// })
+// }
+
+// // API 호출 간 지연
+// await new Promise(resolve => setTimeout(resolve, 500))
+
+// } catch (error) {
+// results.push({
+// materialNumber: prItem.materialNumber!,
+// hasChemicalSubstance: false,
+// message: error instanceof Error ? error.message : 'Unknown error'
+// })
+// }
+// }
+
+// // 하나라도 Y(Y=true)이면 true, 모두 N(false)이면 false
+// const finalHasChemicalSubstance = hasAnyChemicalSubstance
+
+// // DB에 결과 저장
+// await db
+// .update(biddingCompanies)
+// .set({
+// hasChemicalSubstance: finalHasChemicalSubstance,
+// updatedAt: new Date()
+// })
+// .where(eq(biddingCompanies.id, biddingCompanyId))
+
+// console.log(`화학물질 정보 저장 완료: 입찰 참여업체 ${biddingCompanyId}, 화학물질 ${finalHasChemicalSubstance ? '있음' : '없음'} (${results.filter(r => r.hasChemicalSubstance).length}/${results.length})`)
+
+// return {
+// success: true,
+// message: `화학물질 조회 및 저장이 완료되었습니다. (${results.filter(r => r.hasChemicalSubstance).length}/${results.length}개 자재에 화학물질 있음)`,
+// hasChemicalSubstance: finalHasChemicalSubstance,
+// results
+// }
+
+// } catch (error) {
+// console.error(`화학물질 조회 실패 (입찰 참여업체 ${biddingCompanyId}):`, error)
+// return {
+// success: false,
+// message: error instanceof Error ? error.message : 'Unknown error',
+// hasChemicalSubstance: null,
+// results: []
+// }
+// }
+// }
+
+/**
+ * 입찰의 모든 참여업체에 대한 화학물질 정보를 일괄 조회하고 저장
+ */
+export async function checkAndSaveChemicalSubstancesForBidding(biddingId: number) {
+ try {
+ const [biddingInfo] = await db
+ .select({
+ id: biddings.id,
+ ANFNR: biddings.ANFNR,
+ plant: biddings.plant,
+ })
+ .from(biddings)
+ .where(eq(biddings.id, biddingId))
+ .limit(1)
+
+ if (!biddingInfo) {
+ return {
+ success: false,
+ message: '입찰 정보를 찾을 수 없습니다.',
+ results: []
+ }
+ }
+
+ if (!biddingInfo.ANFNR) {
+ return {
+ success: true,
+ message: 'SAP PR 연동 입찰이 아니므로 화학물질 검사를 건너뜁니다.',
+ results: []
+ }
+ }
+
+ const biddingWerks = biddingInfo.plant?.trim()
+ if (!biddingWerks) {
+ return {
+ success: false,
+ message: '입찰의 플랜트(WERKS) 정보가 없어 화학물질 검사를 진행할 수 없습니다.',
+ results: []
+ }
+ }
+
+ // 입찰의 모든 참여업체 조회 (벤더 코드 있는 것만)
+ const biddingCompaniesList = await db
+ .select({
+ id: biddingCompanies.id,
+ companyId: biddingCompanies.companyId,
+ hasChemicalSubstance: biddingCompanies.hasChemicalSubstance,
+ vendors: {
+ vendorCode: vendors.vendorCode,
+ vendorName: vendors.vendorName
+ }
+ })
+ .from(biddingCompanies)
+ .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+ .where(and(
+ eq(biddingCompanies.biddingId, biddingId),
+ isNotNull(vendors.vendorCode),
+ sql`${vendors.vendorCode} != ''`
+ ))
+
+ if (biddingCompaniesList.length === 0) {
+ return {
+ success: true,
+ message: '벤더 코드가 있는 참여업체가 없습니다.',
+ results: []
+ }
+ }
+
+ // 입찰의 PR 아이템들 조회 (자재번호 있는 것만)
+ const prItems = await db
+ .select({
+ materialNumber: prItemsForBidding.materialNumber
+ })
+ .from(prItemsForBidding)
+ .where(and(
+ eq(prItemsForBidding.biddingId, biddingId),
+ isNotNull(prItemsForBidding.materialNumber),
+ sql`${prItemsForBidding.materialNumber} != ''`
+ ))
+
+ if (prItems.length === 0) {
+ return {
+ success: false,
+ message: '조회할 자재가 없습니다.',
+ results: []
+ }
+ }
+
+ const materialNumbers = prItems.map(item => item.materialNumber!).filter(Boolean)
+
+ // 각 참여업체에 대해 화학물질 조회
+ const results: Array<{
+ biddingCompanyId: number;
+ vendorCode: string;
+ vendorName: string;
+ success: boolean;
+ hasChemicalSubstance?: boolean;
+ message: string;
+ materialResults?: Array<{ materialNumber: string; hasChemicalSubstance: boolean; message: string }>;
+ }> = []
+
+ for (const biddingCompany of biddingCompaniesList) {
+ try {
+ // 이미 검사가 완료된 경우 스킵
+ if (biddingCompany.hasChemicalSubstance !== null && biddingCompany.hasChemicalSubstance !== undefined) {
+ results.push({
+ biddingCompanyId: biddingCompany.id,
+ vendorCode: biddingCompany.vendors!.vendorCode!,
+ vendorName: biddingCompany.vendors!.vendorName || '',
+ success: true,
+ hasChemicalSubstance: biddingCompany.hasChemicalSubstance,
+ message: '이미 검사가 완료되었습니다.'
+ })
+ continue
+ }
+
+ // 각 자재에 대해 화학물질 조회
+ let hasAnyChemicalSubstance = false
+ const materialResults: Array<{ materialNumber: string; hasChemicalSubstance: boolean; message: string }> = []
+
+ for (const materialNumber of materialNumbers) {
+ try {
+ const checkResult = await checkChemicalSubstance({
+ bukrs: 'H100', // 회사코드는 H100 고정
+ werks: biddingWerks,
+ lifnr: biddingCompany.vendors!.vendorCode!,
+ matnr: materialNumber
+ })
+
+ if (checkResult.success) {
+ const itemHasChemical = checkResult.hasChemicalSubstance || false
+ hasAnyChemicalSubstance = hasAnyChemicalSubstance || itemHasChemical
+
+ materialResults.push({
+ materialNumber,
+ hasChemicalSubstance: itemHasChemical,
+ message: checkResult.message || '조회 성공'
+ })
+ } else {
+ materialResults.push({
+ materialNumber,
+ hasChemicalSubstance: false,
+ message: checkResult.message || '조회 실패'
+ })
+ }
+
+ // API 호출 간 지연
+ await new Promise(resolve => setTimeout(resolve, 500))
+
+ } catch (error) {
+ materialResults.push({
+ materialNumber,
+ hasChemicalSubstance: false,
+ message: error instanceof Error ? error.message : 'Unknown error'
+ })
+ }
+ }
+
+ // 하나라도 Y이면 true, 모두 N이면 false
+ const finalHasChemicalSubstance = hasAnyChemicalSubstance
+
+ // DB에 결과 저장
+ await db
+ .update(biddingCompanies)
+ .set({
+ hasChemicalSubstance: finalHasChemicalSubstance,
+ updatedAt: new Date()
+ })
+ .where(eq(biddingCompanies.id, biddingCompany.id))
+
+ results.push({
+ biddingCompanyId: biddingCompany.id,
+ vendorCode: biddingCompany.vendors!.vendorCode!,
+ vendorName: biddingCompany.vendors!.vendorName || '',
+ success: true,
+ hasChemicalSubstance: finalHasChemicalSubstance,
+ message: `조회 완료 (${materialResults.filter(r => r.hasChemicalSubstance).length}/${materialResults.length}개 자재에 화학물질 있음)`,
+ materialResults
+ })
+
+ } catch (error) {
+ results.push({
+ biddingCompanyId: biddingCompany.id,
+ vendorCode: biddingCompany.vendors!.vendorCode!,
+ vendorName: biddingCompany.vendors!.vendorName || '',
+ success: false,
+ message: error instanceof Error ? error.message : 'Unknown error'
+ })
+ }
+ }
+
+ const successCount = results.filter(r => r.success).length
+ const totalCount = results.length
+
+ console.log(`입찰 ${biddingId} 화학물질 일괄 조회 완료: ${successCount}/${totalCount} 성공`)
+
+ return {
+ success: true,
+ message: `화학물질 일괄 조회 완료: ${successCount}/${totalCount} 성공`,
+ results
+ }
+
+ } catch (error) {
+ console.error(`입찰 화학물질 일괄 조회 실패 (${biddingId}):`, error)
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : 'Unknown error',
+ results: []
+ }
+ }
+}
diff --git a/lib/bidding/validation.ts b/lib/bidding/validation.ts
index 73c2fe21..3254ae7e 100644
--- a/lib/bidding/validation.ts
+++ b/lib/bidding/validation.ts
@@ -99,7 +99,6 @@ export const createBiddingSchema = z.object({
submissionEndDate: z.string().optional(),
- evaluationDate: z.string().optional(),
// 회의 및 문서
hasSpecificationMeeting: z.boolean().default(false),
@@ -220,7 +219,6 @@ export const createBiddingSchema = z.object({
submissionStartDate: z.string().optional(),
submissionEndDate: z.string().optional(),
- evaluationDate: z.string().optional(),
hasSpecificationMeeting: z.boolean().optional(),
hasPrDocument: z.boolean().optional(),
diff --git a/lib/bidding/vendor/components/pr-items-pricing-table.tsx b/lib/bidding/vendor/components/pr-items-pricing-table.tsx
index 7dd8384e..6910e360 100644
--- a/lib/bidding/vendor/components/pr-items-pricing-table.tsx
+++ b/lib/bidding/vendor/components/pr-items-pricing-table.tsx
@@ -4,7 +4,17 @@ import * as React from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
-
+import { Button } from '@/components/ui/button'
+import { Calendar } from '@/components/ui/calendar'
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import {
Table,
@@ -16,10 +26,12 @@ import {
} from '@/components/ui/table'
import {
Package,
-
Download,
- Calculator
+ Calculator,
+ CalendarIcon
} from 'lucide-react'
+import { format } from 'date-fns'
+import { cn } from '@/lib/utils'
import { formatDate } from '@/lib/utils'
import { downloadFile, formatFileSize, getFileInfo } from '@/lib/file-download'
import { getSpecDocumentsForPrItem } from '../../pre-quote/service'
@@ -186,6 +198,8 @@ export function PrItemsPricingTable({
}: PrItemsPricingTableProps) {
const [quotations, setQuotations] = React.useState<PrItemQuotation[]>([])
const [specDocuments, setSpecDocuments] = React.useState<Record<number, SpecDocument[]>>({})
+ const [showBulkDateDialog, setShowBulkDateDialog] = React.useState(false)
+ const [bulkDeliveryDate, setBulkDeliveryDate] = React.useState<Date | undefined>(undefined)
// 초기 견적 데이터 설정 및 SPEC 문서 로드
React.useEffect(() => {
@@ -279,6 +293,21 @@ export function PrItemsPricingTable({
onTotalAmountChange(totalAmount)
}
+ // 일괄 납기일 적용
+ const applyBulkDeliveryDate = () => {
+ if (bulkDeliveryDate && quotations.length > 0) {
+ const formattedDate = format(bulkDeliveryDate, 'yyyy-MM-dd')
+ const updatedQuotations = quotations.map(q => ({
+ ...q,
+ proposedDeliveryDate: formattedDate
+ }))
+
+ setQuotations(updatedQuotations)
+ onQuotationsChange(updatedQuotations)
+ setShowBulkDateDialog(false)
+ setBulkDeliveryDate(undefined)
+ }
+ }
// 통화 포맷팅
const formatCurrency = (amount: number) => {
@@ -292,12 +321,26 @@ export function PrItemsPricingTable({
const totalAmount = quotations.reduce((sum, q) => sum + q.bidAmount, 0)
return (
+ <>
<Card>
<CardHeader>
- <CardTitle className="flex items-center gap-2">
- <Package className="w-5 h-5" />
- 품목별 입찰 작성
- </CardTitle>
+ <div className="flex items-center justify-between">
+ <CardTitle className="flex items-center gap-2">
+ <Package className="w-5 h-5" />
+ 품목별 입찰 작성
+ </CardTitle>
+ {!readOnly && (
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={() => setShowBulkDateDialog(true)}
+ >
+ <CalendarIcon className="h-4 w-4 mr-1" />
+ 전체 납품예정일 설정
+ </Button>
+ )}
+ </div>
</CardHeader>
<CardContent>
<div className="space-y-4">
@@ -382,18 +425,14 @@ export function PrItemsPricingTable({
</span>
) : (
<Input
- type="number"
- inputMode="decimal"
- min={0}
- pattern="^(0|[1-9][0-9]*)(\.[0-9]+)?$"
- value={quotation.bidUnitPrice === 0 ? '' : quotation.bidUnitPrice}
+ type="text"
+ inputMode="numeric"
+ value={quotation.bidUnitPrice === 0 ? '' : quotation.bidUnitPrice.toLocaleString()}
onChange={(e) => {
- let value = e.target.value
- if (/^0[0-9]+/.test(value)) {
- value = value.replace(/^0+/, '')
- if (value === '') value = '0'
- }
- const numericValue = parseFloat(value)
+ // 콤마 제거 및 숫자만 허용
+ const value = e.target.value.replace(/,/g, '').replace(/[^0-9]/g, '')
+ const numericValue = Number(value)
+
updateQuotation(
item.id,
'bidUnitPrice',
@@ -471,5 +510,73 @@ export function PrItemsPricingTable({
</div>
</CardContent>
</Card>
+
+ {/* 일괄 납품예정일 설정 다이얼로그 */}
+ <Dialog open={showBulkDateDialog} onOpenChange={setShowBulkDateDialog}>
+ <DialogContent className="sm:max-w-md">
+ <DialogHeader>
+ <DialogTitle>전체 납품예정일 설정</DialogTitle>
+ <DialogDescription>
+ 모든 PR 아이템에 동일한 납품예정일을 적용합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ <div className="space-y-2">
+ <Label>납품예정일 선택</Label>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ className={cn(
+ "w-full justify-start text-left font-normal",
+ !bulkDeliveryDate && "text-muted-foreground"
+ )}
+ >
+ <CalendarIcon className="mr-2 h-4 w-4" />
+ {bulkDeliveryDate ? format(bulkDeliveryDate, "yyyy-MM-dd") : "날짜 선택"}
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={bulkDeliveryDate}
+ onSelect={setBulkDeliveryDate}
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ </div>
+
+ <div className="bg-muted/50 rounded-lg p-3">
+ <p className="text-sm text-muted-foreground">
+ 선택된 날짜가 <strong>{prItems.length}개</strong>의 모든 PR 아이템에 적용됩니다.
+ 기존에 설정된 납품예정일은 모두 교체됩니다.
+ </p>
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => {
+ setShowBulkDateDialog(false)
+ setBulkDeliveryDate(undefined)
+ }}
+ >
+ 취소
+ </Button>
+ <Button
+ type="button"
+ onClick={applyBulkDeliveryDate}
+ disabled={!bulkDeliveryDate}
+ >
+ 전체 적용
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </>
)
}
diff --git a/lib/bidding/vendor/export-partners-biddings-to-excel.ts b/lib/bidding/vendor/export-partners-biddings-to-excel.ts
new file mode 100644
index 00000000..e1d985fe
--- /dev/null
+++ b/lib/bidding/vendor/export-partners-biddings-to-excel.ts
@@ -0,0 +1,275 @@
+import { type Table } from "@tanstack/react-table"
+import ExcelJS from "exceljs"
+import { PartnersBiddingListItem } from '../detail/service'
+import {
+ biddingStatusLabels,
+ contractTypeLabels,
+} from "@/db/schema"
+import { formatDate } from "@/lib/utils"
+
+/**
+ * Partners 입찰 목록을 Excel로 내보내기
+ * - 계약구분, 진행상태는 라벨(명칭)로 변환
+ * - 입찰기간은 submissionStartDate, submissionEndDate 기준
+ * - 날짜는 적절한 형식으로 변환
+ */
+export async function exportPartnersBiddingsToExcel(
+ table: Table<PartnersBiddingListItem>,
+ {
+ filename = "협력업체입찰목록",
+ onlySelected = false,
+ }: {
+ filename?: string
+ onlySelected?: boolean
+ } = {}
+): Promise<void> {
+ // 테이블에서 실제 사용 중인 leaf columns 가져오기
+ const allColumns = table.getAllLeafColumns()
+
+ // select, actions, attachments 컬럼 제외
+ const columns = allColumns.filter(
+ (col) => !["select", "actions", "attachments"].includes(col.id)
+ )
+
+ // 헤더 매핑 (컬럼 id -> Excel 헤더명)
+ const headerMap: Record<string, string> = {
+ biddingNumber: "입찰 No.",
+ status: "입찰상태",
+ isUrgent: "긴급여부",
+ title: "입찰명",
+ isAttendingMeeting: "사양설명회",
+ isBiddingParticipated: "입찰 참여의사",
+ biddingSubmissionStatus: "입찰 제출여부",
+ contractType: "계약구분",
+ submissionStartDate: "입찰기간",
+ contractStartDate: "계약기간",
+ bidPicName: "입찰담당자",
+ supplyPicName: "조달담당자",
+ updatedAt: "최종수정일",
+ }
+
+ // 헤더 행 생성
+ const headerRow = columns.map((col) => {
+ return headerMap[col.id] || col.id
+ })
+
+ // 데이터 행 생성
+ const rowModel = onlySelected
+ ? table.getFilteredSelectedRowModel()
+ : table.getRowModel()
+
+ const dataRows = rowModel.rows.map((row) => {
+ const original = row.original
+ return columns.map((col) => {
+ const colId = col.id
+ let value: any
+
+ // 특별 처리 필요한 컬럼들
+ switch (colId) {
+ case "contractType":
+ // 계약구분: 라벨로 변환
+ value = contractTypeLabels[original.contractType as keyof typeof contractTypeLabels] || original.contractType
+ break
+
+ case "status":
+ // 입찰상태: 라벨로 변환
+ value = biddingStatusLabels[original.status as keyof typeof biddingStatusLabels] || original.status
+ break
+
+ case "isUrgent":
+ // 긴급여부: Yes/No
+ value = original.isUrgent ? "긴급" : "일반"
+ break
+
+ case "isAttendingMeeting":
+ // 사양설명회: 참석/불참/미결정
+ if (original.isAttendingMeeting === null) {
+ value = "해당없음"
+ } else {
+ value = original.isAttendingMeeting ? "참석" : "불참"
+ }
+ break
+
+ case "isBiddingParticipated":
+ // 입찰 참여의사: 참여/불참/미결정
+ if (original.isBiddingParticipated === null) {
+ value = "미결정"
+ } else {
+ value = original.isBiddingParticipated ? "참여" : "불참"
+ }
+ break
+
+ case "biddingSubmissionStatus":
+ // 입찰 제출여부: 최종제출/제출/미제출
+ const finalQuoteAmount = original.finalQuoteAmount
+ const isFinalSubmission = original.isFinalSubmission
+
+ if (!finalQuoteAmount) {
+ value = "미제출"
+ } else if (isFinalSubmission) {
+ value = "최종제출"
+ } else {
+ value = "제출"
+ }
+ break
+
+ case "submissionStartDate":
+ // 입찰기간: submissionStartDate, submissionEndDate 기준
+ const startDate = original.submissionStartDate
+ const endDate = original.submissionEndDate
+
+ if (!startDate || !endDate) {
+ value = "-"
+ } else {
+ const startObj = new Date(startDate)
+ const endObj = new Date(endDate)
+
+ // 입력값 기반: 저장된 UTC 값을 그대로 표시 (타임존 가감 없음)
+ const formatValue = (d: Date) => d.toISOString().slice(0, 16).replace('T', ' ')
+
+ value = `${formatValue(startObj)} ~ ${formatValue(endObj)}`
+ }
+ break
+
+ // case "preQuoteDeadline":
+ // // 사전견적 마감일: 날짜 형식
+ // if (!original.preQuoteDeadline) {
+ // value = "-"
+ // } else {
+ // const deadline = new Date(original.preQuoteDeadline)
+ // value = deadline.toISOString().slice(0, 16).replace('T', ' ')
+ // }
+ // break
+
+ case "contractStartDate":
+ // 계약기간: contractStartDate, contractEndDate 기준
+ const contractStart = original.contractStartDate
+ const contractEnd = original.contractEndDate
+
+ if (!contractStart || !contractEnd) {
+ value = "-"
+ } else {
+ const startObj = new Date(contractStart)
+ const endObj = new Date(contractEnd)
+ value = `${formatDate(startObj, "KR")} ~ ${formatDate(endObj, "KR")}`
+ }
+ break
+
+ case "bidPicName":
+ // 입찰담당자: bidPicName
+ value = original.bidPicName || "-"
+ break
+
+ case "supplyPicName":
+ // 조달담당자: supplyPicName
+ value = original.supplyPicName || "-"
+ break
+
+ case "updatedAt":
+ // 최종수정일: 날짜 시간 형식
+ if (original.updatedAt) {
+ const updated = new Date(original.updatedAt)
+ value = updated.toISOString().slice(0, 16).replace('T', ' ')
+ } else {
+ value = "-"
+ }
+ break
+
+ case "biddingNumber":
+ // 입찰번호: 원입찰번호 포함
+ const biddingNumber = original.biddingNumber
+ const originalBiddingNumber = original.originalBiddingNumber
+ if (originalBiddingNumber) {
+ value = `${biddingNumber} (원: ${originalBiddingNumber})`
+ } else {
+ value = biddingNumber
+ }
+ break
+
+ default:
+ // 기본값: row.getValue 사용
+ value = row.getValue(colId)
+
+ // null/undefined 처리
+ if (value == null) {
+ value = ""
+ }
+
+ // 객체인 경우 JSON 문자열로 변환
+ if (typeof value === "object") {
+ value = JSON.stringify(value)
+ }
+ break
+ }
+
+ return value
+ })
+ })
+
+ // 최종 sheetData
+ const sheetData = [headerRow, ...dataRows]
+
+ // ExcelJS로 파일 생성 및 다운로드
+ await createAndDownloadExcel(sheetData, columns.length, filename)
+}
+
+/**
+ * Excel 파일 생성 및 다운로드
+ */
+async function createAndDownloadExcel(
+ sheetData: any[][],
+ columnCount: number,
+ filename: string
+): Promise<void> {
+ // ExcelJS 워크북/시트 생성
+ const workbook = new ExcelJS.Workbook()
+ const worksheet = workbook.addWorksheet("Sheet1")
+
+ // 칼럼별 최대 길이 추적
+ const maxColumnLengths = Array(columnCount).fill(0)
+ sheetData.forEach((row) => {
+ row.forEach((cellValue, colIdx) => {
+ const cellText = cellValue?.toString() ?? ""
+ if (cellText.length > maxColumnLengths[colIdx]) {
+ maxColumnLengths[colIdx] = cellText.length
+ }
+ })
+ })
+
+ // 시트에 데이터 추가 + 헤더 스타일
+ sheetData.forEach((arr, idx) => {
+ const row = worksheet.addRow(arr)
+
+ // 헤더 스타일 적용 (첫 번째 행)
+ if (idx === 0) {
+ row.font = { bold: true }
+ row.alignment = { horizontal: "center" }
+ row.eachCell((cell) => {
+ cell.fill = {
+ type: "pattern",
+ pattern: "solid",
+ fgColor: { argb: "FFCCCCCC" },
+ }
+ })
+ }
+ })
+
+ // 칼럼 너비 자동 조정
+ maxColumnLengths.forEach((len, idx) => {
+ // 최소 너비 10, +2 여백
+ worksheet.getColumn(idx + 1).width = Math.max(len + 2, 10)
+ })
+
+ // 최종 파일 다운로드
+ const buffer = await workbook.xlsx.writeBuffer()
+ const blob = new Blob([buffer], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ })
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement("a")
+ link.href = url
+ link.download = `${filename}.xlsx`
+ link.click()
+ URL.revokeObjectURL(url)
+}
+
diff --git a/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx b/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx
index d0ef97f1..8d6cb82d 100644
--- a/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx
+++ b/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx
@@ -37,7 +37,6 @@ interface PartnersSpecificationMeetingDialogProps {
title: string
preQuoteDate: string | null
biddingRegistrationDate: string | null
- evaluationDate: string | null
hasSpecificationMeeting?: boolean // 사양설명회 여부 추가
} | null
biddingCompanyId: number
diff --git a/lib/bidding/vendor/partners-bidding-detail.tsx b/lib/bidding/vendor/partners-bidding-detail.tsx
index bf76de62..bf33cef5 100644
--- a/lib/bidding/vendor/partners-bidding-detail.tsx
+++ b/lib/bidding/vendor/partners-bidding-detail.tsx
@@ -75,7 +75,6 @@ interface BiddingDetail {
biddingRegistrationDate: Date | string | null
submissionStartDate: Date | string | null
submissionEndDate: Date | string | null
- evaluationDate: Date | string | null
currency: string
budget: number | null
targetPrice: number | null
@@ -869,7 +868,8 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
const timeLeft = deadline.getTime() - now.getTime()
const daysLeft = Math.floor(timeLeft / (1000 * 60 * 60 * 24))
const hoursLeft = Math.floor((timeLeft % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
- const kstDeadline = new Date(deadline.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ')
+ // 입력값 기반: 저장된 UTC 값을 그대로 표시
+ const displayDeadline = deadline.toISOString().slice(0, 16).replace('T', ' ')
return (
<div className={`p-3 rounded-lg border-2 ${
@@ -884,7 +884,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
<Calendar className="w-5 h-5" />
<span className="font-medium">제출 마감일:</span>
<span className="text-lg font-semibold">
- {kstDeadline}
+ {displayDeadline}
</span>
</div>
{isExpired ? (
@@ -921,17 +921,13 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
<span className="font-medium">입찰서 제출기간:</span> {(() => {
const start = new Date(biddingDetail.submissionStartDate!)
const end = new Date(biddingDetail.submissionEndDate!)
- const kstStart = new Date(start.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ')
- const kstEnd = new Date(end.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ')
- return `${kstStart} ~ ${kstEnd}`
+ const displayStart = start.toISOString().slice(0, 16).replace('T', ' ')
+ const displayEnd = end.toISOString().slice(0, 16).replace('T', ' ')
+ return `${displayStart} ~ ${displayEnd}`
})()}
</div>
)}
- {biddingDetail.evaluationDate && (
- <div>
- <span className="font-medium">평가일:</span> {format(new Date(biddingDetail.evaluationDate), "yyyy-MM-dd HH:mm")}
- </div>
- )}
+
</div>
</div>
</CardContent>
diff --git a/lib/bidding/vendor/partners-bidding-list-columns.tsx b/lib/bidding/vendor/partners-bidding-list-columns.tsx
index a122e87b..09c3caad 100644
--- a/lib/bidding/vendor/partners-bidding-list-columns.tsx
+++ b/lib/bidding/vendor/partners-bidding-list-columns.tsx
@@ -285,7 +285,7 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL
cell: ({ row }) => {
const isAttending = row.original.isAttendingMeeting
if (isAttending === null) {
- return <div className="text-muted-foreground text-center">-</div>
+ return <div className="text-muted-foreground text-center">해당없음</div>
}
return isAttending ? (
<CheckCircle className="h-5 w-5 text-green-600 mx-auto" />
@@ -352,45 +352,45 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL
const startObj = new Date(startDate)
const endObj = new Date(endDate)
- // UI 표시용 KST 변환
- const formatKst = (d: Date) => new Date(d.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ')
+ // 입력값 기반: 저장된 UTC 값 그대로 표시 (타임존 가감 없음)
+ const formatValue = (d: Date) => d.toISOString().slice(0, 16).replace('T', ' ')
return (
<div className="text-sm">
- <div>{formatKst(startObj)}</div>
+ <div>{formatValue(startObj)}</div>
<div className="text-muted-foreground">~</div>
- <div>{formatKst(endObj)}</div>
+ <div>{formatValue(endObj)}</div>
</div>
)
},
}),
// 사전견적 마감일
- columnHelper.accessor('preQuoteDeadline', {
- header: '사전견적 마감일',
- cell: ({ row }) => {
- const deadline = row.original.preQuoteDeadline
- if (!deadline) {
- return <div className="text-muted-foreground">-</div>
- }
+ // columnHelper.accessor('preQuoteDeadline', {
+ // header: '사전견적 마감일',
+ // cell: ({ row }) => {
+ // const deadline = row.original.preQuoteDeadline
+ // if (!deadline) {
+ // return <div className="text-muted-foreground">-</div>
+ // }
- const now = new Date()
- const deadlineDate = new Date(deadline)
- const isExpired = deadlineDate < now
+ // const now = new Date()
+ // const deadlineDate = new Date(deadline)
+ // const isExpired = deadlineDate < now
- return (
- <div className={`text-sm flex items-center gap-1 ${isExpired ? 'text-red-600' : ''}`}>
- <Calendar className="w-4 h-4" />
- <span>{format(new Date(deadline), "yyyy-MM-dd HH:mm")}</span>
- {isExpired && (
- <Badge variant="destructive" className="text-xs">
- 마감
- </Badge>
- )}
- </div>
- )
- },
- }),
+ // return (
+ // <div className={`text-sm flex items-center gap-1 ${isExpired ? 'text-red-600' : ''}`}>
+ // <Calendar className="w-4 h-4" />
+ // <span>{format(new Date(deadline), "yyyy-MM-dd HH:mm")}</span>
+ // {isExpired && (
+ // <Badge variant="destructive" className="text-xs">
+ // 마감
+ // </Badge>
+ // )}
+ // </div>
+ // )
+ // },
+ // }),
// 계약기간
columnHelper.accessor('contractStartDate', {
diff --git a/lib/bidding/vendor/partners-bidding-list.tsx b/lib/bidding/vendor/partners-bidding-list.tsx
index 0f68ed68..f1cb0bdc 100644
--- a/lib/bidding/vendor/partners-bidding-list.tsx
+++ b/lib/bidding/vendor/partners-bidding-list.tsx
@@ -181,7 +181,6 @@ export function PartnersBiddingList({ promises }: PartnersBiddingListProps) {
title: rowAction.row.original.title,
preQuoteDate: null,
biddingRegistrationDate: rowAction.row.original.submissionStartDate?.toISOString() || null,
- evaluationDate: null,
hasSpecificationMeeting: rowAction.row.original.hasSpecificationMeeting || false,
} : null}
biddingCompanyId={rowAction?.row.original?.biddingCompanyId || 0}
diff --git a/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx b/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx
index 87b1367e..9a2f026c 100644
--- a/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx
+++ b/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx
@@ -2,10 +2,12 @@
import * as React from "react"
import { type Table } from "@tanstack/react-table"
-import { Users} from "lucide-react"
+import { Users, FileSpreadsheet } from "lucide-react"
+import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { PartnersBiddingListItem } from '../detail/service'
+import { exportPartnersBiddingsToExcel } from './export-partners-biddings-to-excel'
interface PartnersBiddingToolbarActionsProps {
table: Table<PartnersBiddingListItem>
@@ -20,6 +22,8 @@ export function PartnersBiddingToolbarActions({
const selectedRows = table.getFilteredSelectedRowModel().rows
const selectedBidding = selectedRows.length === 1 ? selectedRows[0].original : null
+ const [isExporting, setIsExporting] = React.useState(false)
+
const handleSpecificationMeetingClick = () => {
if (selectedBidding && setRowAction) {
setRowAction({
@@ -29,8 +33,36 @@ export function PartnersBiddingToolbarActions({
}
}
+ // Excel 내보내기 핸들러
+ const handleExport = React.useCallback(async () => {
+ try {
+ setIsExporting(true)
+ await exportPartnersBiddingsToExcel(table, {
+ filename: "협력업체입찰목록",
+ onlySelected: false,
+ })
+ toast.success("Excel 파일이 다운로드되었습니다.")
+ } catch (error) {
+ console.error("Excel export error:", error)
+ toast.error("Excel 내보내기 중 오류가 발생했습니다.")
+ } finally {
+ setIsExporting(false)
+ }
+ }, [table])
+
return (
<div className="flex items-center gap-2">
+ {/* Excel 내보내기 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleExport}
+ disabled={isExporting}
+ className="gap-2"
+ >
+ <FileSpreadsheet className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">{isExporting ? "내보내는 중..." : "Excel 내보내기"}</span>
+ </Button>
<Button
variant="outline"
size="sm"
diff --git a/lib/dolce/actions.ts b/lib/dolce/actions.ts
index 501c6cb0..fe246956 100644
--- a/lib/dolce/actions.ts
+++ b/lib/dolce/actions.ts
@@ -20,6 +20,7 @@ export interface DwgReceiptItem {
CreateUserENM: string | null;
CreateUserId: string | null;
CreateUserNo: string;
+ DetailDwgCNT: number;
Discipline: string;
DrawingKind: string;
DrawingMoveGbn: string;
@@ -44,6 +45,7 @@ export interface GttDwgReceiptItem {
CreateUserENM: string;
CreateUserId: string;
CreateUserNo: string;
+ DetailDwgCNT: number;
DGbn: string | null;
DegreeGbn: string | null;
DeptGbn: string | null;
@@ -946,7 +948,7 @@ export async function prepareB4DetailDrawingsV2(params: {
DrawingRevNo: revNo,
Category: category,
Receiver: null,
- Manager: drawingInfo.Manager || "970043",
+ Manager: drawingInfo.ManagerNo,
RegisterDesc: "",
UploadId: uploadId,
RegCompanyCode: vendorCode,
@@ -1188,7 +1190,7 @@ export async function bulkUploadB4FilesV2(
DrawingRevNo: revNo,
Category: category,
Receiver: null,
- Manager: drawingInfo.Manager || "970043",
+ Manager: drawingInfo.ManagerNo,
RegisterDesc: registerDesc,
UploadId: uploadId,
RegCompanyCode: vendorCode,
diff --git a/lib/dolce/dialogs/add-and-modify-detail-drawing-dialog.tsx b/lib/dolce/dialogs/add-and-modify-detail-drawing-dialog.tsx
index d4318b90..2d2532d7 100644
--- a/lib/dolce/dialogs/add-and-modify-detail-drawing-dialog.tsx
+++ b/lib/dolce/dialogs/add-and-modify-detail-drawing-dialog.tsx
@@ -272,7 +272,7 @@ export function AddAndModifyDetailDrawingDialog({
DrawingRevNo: drawingUsage === "CMT" ? null : revision,
Category: "TS", // To SHI (벤더가 SHI에게 제출)
Receiver: null,
- Manager: "",
+ Manager: drawing.ManagerNo,
RegisterDesc: comment,
UploadId: uploadId,
RegCompanyCode: vendorCode,
diff --git a/lib/dolce/table/drawing-list-columns.tsx b/lib/dolce/table/drawing-list-columns.tsx
index 30b71d8d..6fe1b3e2 100644
--- a/lib/dolce/table/drawing-list-columns.tsx
+++ b/lib/dolce/table/drawing-list-columns.tsx
@@ -28,12 +28,21 @@ export function drawingListColumns(lng: string, t: any): ColumnDef<DwgReceiptIte
minSize: 120,
},
{
+ accessorKey: "DetailDwgCNT",
+ header: t("drawingList.columns.detailDwgCnt"),
+ minSize: 100,
+ cell: ({ row }) => {
+ const count = row.getValue("DetailDwgCNT") as number;
+ return <div className="text-center">{count || 0}</div>;
+ },
+ },
+ {
accessorKey: "Manager",
header: t("drawingList.columns.manager"),
minSize: 200,
cell: ({ row }) => {
const managerENM = row.original.ManagerENM;
- const manager = row.getValue("Manager");
+ const manager = row.getValue("Manager") as string;
return <div>{managerENM || manager}</div>;
},
},
diff --git a/lib/dolce/table/gtt-drawing-list-columns.tsx b/lib/dolce/table/gtt-drawing-list-columns.tsx
index 94d4d7d1..c76fcba0 100644
--- a/lib/dolce/table/gtt-drawing-list-columns.tsx
+++ b/lib/dolce/table/gtt-drawing-list-columns.tsx
@@ -41,12 +41,21 @@ export function createGttDrawingListColumns({
minSize: 80,
},
{
+ accessorKey: "DetailDwgCNT",
+ header: t("drawingList.columns.detailDwgCnt"),
+ minSize: 100,
+ cell: ({ row }) => {
+ const count = row.getValue("DetailDwgCNT") as number;
+ return <div className="text-center">{count || 0}</div>;
+ },
+ },
+ {
accessorKey: "Manager",
header: t("drawingList.columns.manager"),
minSize: 200,
cell: ({ row }) => {
const managerENM = row.original.ManagerENM;
- const manager = row.getValue("Manager");
+ const manager = row.getValue("Manager") as string;
return <div>{managerENM || manager}</div>;
},
},
@@ -56,7 +65,7 @@ export function createGttDrawingListColumns({
minSize: 120,
cell: ({ row }) => {
- const drawingMoveGbn = row.getValue("DrawingMoveGbn");
+ const drawingMoveGbn = row.getValue("DrawingMoveGbn") as string;
let type = "";
if (drawingMoveGbn == "도면입수") {
diff --git a/lib/forms-plant/services.ts b/lib/forms-plant/services.ts
index 3f50bd47..64d353de 100644
--- a/lib/forms-plant/services.ts
+++ b/lib/forms-plant/services.ts
@@ -21,7 +21,7 @@ import {
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";
+import { unstable_cache ,unstable_noStore } from "next/cache";
import { revalidateTag } from "next/cache";
import { getErrorMessage } from "../handle-error";
import { DataTableColumnJSON } from "@/components/form-data/form-data-table-columns";
@@ -234,9 +234,10 @@ export async function getEditableFieldsByTag(
* 그리고 이 로직 전체를 unstable_cache로 감싸 캐싱.
*/
export async function getFormData(formCode: string, projectCode: string, packageCode:string) {
+ unstable_noStore();
try {
- console.log(formCode,projectCode, packageCode)
+ // console.log(formCode,projectCode, packageCode)
const project = await db.query.projects.findFirst({
where: eq(projects.code, projectCode),
@@ -329,83 +330,84 @@ export async function getFormData(formCode: string, projectCode: string, package
console.error(`[getFormData] Cache operation failed:`, cacheError);
// Fallback logic (기존과 동일하게 editableFieldsMap 추가)
- try {
- console.log(`[getFormData] Fallback DB query for (${formCode}, ${packageCode})`);
-
- const project = await db.query.projects.findFirst({
- where: eq(projects.code, projectCode),
- columns: {
- id: true
- }
- });
-
- const projectId = project.id;
-
- const metaRows = await db
- .select()
- .from(formMetas)
- .where(
- and(
- eq(formMetas.formCode, formCode),
- eq(formMetas.projectId, projectId)
- )
- )
- .orderBy(desc(formMetas.updatedAt))
- .limit(1);
-
- const meta = metaRows[0] ?? null;
- if (!meta) {
- console.warn(`[getFormData] Fallback: No form meta found for formCode: ${formCode} and projectId: ${projectId}`);
- return { columns: null, data: [], editableFieldsMap: new Map() };
- }
-
- const entryRows = await db
- .select()
- .from(formEntriesPlant)
- .where(
- and(
- eq(formEntriesPlant.formCode, formCode),
- eq(formEntriesPlant.projectCode, projectCode),
- eq(formEntriesPlant.packageCode, packageCode)
- )
- )
- .orderBy(desc(formEntriesPlant.updatedAt))
- .limit(1);
-
- const entry = entryRows[0] ?? null;
-
- let columns = meta.columns as DataTableColumnJSON[];
- const excludeKeys = ['BF_TAG_NO', 'TAG_TYPE_ID', 'PIC_NO'];
- columns = columns.filter(col => !excludeKeys.includes(col.key));
-
- columns.forEach((col) => {
- if (!col.displayLabel) {
- if (col.uom) {
- col.displayLabel = `${col.label} (${col.uom})`;
- } else {
- col.displayLabel = col.label;
- }
- }
- });
-
- let data: Array<Record<string, any>> = [];
- if (entry) {
- if (Array.isArray(entry.data)) {
- data = entry.data;
- } else {
- console.warn("formEntries data was not an array. Using empty array (fallback).");
- }
- }
-
- // Fallback에서도 편집 가능 필드 정보 계산
- const editableFieldsMap = await getEditableFieldsByTag(projectCode, packageCode, projectId);
-
- return { columns, data, projectId, editableFieldsMap };
- } catch (dbError) {
- console.error(`[getFormData] Fallback DB query failed:`, dbError);
- return { columns: null, data: [], editableFieldsMap: new Map() };
- }
- }
+ // try {
+ // console.log(`[getFormData] Fallback DB query for (${formCode}, ${packageCode})`);
+
+ // const project = await db.query.projects.findFirst({
+ // where: eq(projects.code, projectCode),
+ // columns: {
+ // id: true
+ // }
+ // });
+
+ // const projectId = project.id;
+
+ // const metaRows = await db
+ // .select()
+ // .from(formMetas)
+ // .where(
+ // and(
+ // eq(formMetas.formCode, formCode),
+ // eq(formMetas.projectId, projectId)
+ // )
+ // )
+ // .orderBy(desc(formMetas.updatedAt))
+ // .limit(1);
+
+ // const meta = metaRows[0] ?? null;
+ // if (!meta) {
+ // console.warn(`[getFormData] Fallback: No form meta found for formCode: ${formCode} and projectId: ${projectId}`);
+ // return { columns: null, data: [], editableFieldsMap: new Map() };
+ // }
+
+ // const entryRows = await db
+ // .select()
+ // .from(formEntriesPlant)
+ // .where(
+ // and(
+ // eq(formEntriesPlant.formCode, formCode),
+ // eq(formEntriesPlant.projectCode, projectCode),
+ // eq(formEntriesPlant.packageCode, packageCode)
+ // )
+ // )
+ // .orderBy(desc(formEntriesPlant.updatedAt))
+ // .limit(1);
+
+ // const entry = entryRows[0] ?? null;
+
+ // let columns = meta.columns as DataTableColumnJSON[];
+ // const excludeKeys = ['BF_TAG_NO', 'TAG_TYPE_ID', 'PIC_NO'];
+ // columns = columns.filter(col => !excludeKeys.includes(col.key));
+
+ // columns.forEach((col) => {
+ // if (!col.displayLabel) {
+ // if (col.uom) {
+ // col.displayLabel = `${col.label} (${col.uom})`;
+ // } else {
+ // col.displayLabel = col.label;
+ // }
+ // }
+ // });
+
+ // let data: Array<Record<string, any>> = [];
+ // if (entry) {
+ // if (Array.isArray(entry.data)) {
+ // data = entry.data;
+ // } else {
+ // console.warn("formEntries data was not an array. Using empty array (fallback).");
+ // }
+ // }
+
+ // // Fallback에서도 편집 가능 필드 정보 계산
+ // const editableFieldsMap = await getEditableFieldsByTag(projectCode, packageCode, projectId);
+
+ // return { columns, data, projectId, editableFieldsMap };
+ // } catch (dbError) {
+ // console.error(`[getFormData] Fallback DB query failed:`, dbError);
+ // return { columns: null, data: [], editableFieldsMap: new Map() };
+ // }
+ // }
+}
}
/**
* contractId와 formCode(itemCode)를 사용하여 contractItemId를 찾는 서버 액션
@@ -1052,6 +1054,7 @@ type GetReportFileList = (
}>;
export const getFormId: GetReportFileList = async (projectCode, packageCode, formCode, mode) => {
+ unstable_noStore();
const result: { formId: number } = {
formId: 0,
};
diff --git a/lib/general-contracts/approval-actions.ts b/lib/general-contracts/approval-actions.ts
new file mode 100644
index 00000000..e75d6cd6
--- /dev/null
+++ b/lib/general-contracts/approval-actions.ts
@@ -0,0 +1,136 @@
+/**
+ * 일반계약 관련 결재 서버 액션
+ *
+ * 사용자가 UI에서 호출하는 함수들
+ * ApprovalSubmissionSaga를 사용하여 결재 프로세스를 시작
+ */
+
+'use server';
+
+import { ApprovalSubmissionSaga } from '@/lib/approval';
+import { mapContractToApprovalTemplateVariables } from './approval-template-variables';
+import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils';
+import db from '@/db/db';
+import { eq } from 'drizzle-orm';
+import { generalContracts } from '@/db/schema/generalContract';
+import { users } from '@/db/schema';
+
+interface ContractSummary {
+ basicInfo: Record<string, unknown>;
+ items: Record<string, unknown>[];
+ subcontractChecklist: Record<string, unknown> | null;
+ storageInfo?: Record<string, unknown>[];
+ pdfPath?: string;
+ basicContractPdfs?: Array<{ key: string; buffer: number[]; fileName: string }>;
+}
+
+/**
+ * 결재를 거쳐 일반계약 승인 요청을 처리하는 서버 액션
+ *
+ * 사용법 (클라이언트 컴포넌트에서):
+ * ```typescript
+ * const result = await requestContractApprovalWithApproval({
+ * contractId: 123,
+ * contractSummary: summaryData,
+ * currentUser: { id: 1, epId: 'EP001', email: 'user@example.com' },
+ * approvers: ['EP002', 'EP003'],
+ * title: '계약 체결 진행 품의 요청서'
+ * });
+ *
+ * if (result.status === 'pending_approval') {
+ * console.log('결재 ID:', result.approvalId);
+ * }
+ * ```
+ */
+export async function requestContractApprovalWithApproval(data: {
+ contractId: number;
+ contractSummary: ContractSummary;
+ currentUser: { id: number; epId: string | null; email?: string };
+ approvers?: string[]; // Knox EP ID 배열 (결재선)
+ title?: string; // 결재 제목 (선택사항, 미지정 시 자동 생성)
+}) {
+ debugLog('[ContractApproval] 일반계약 승인 요청 결재 서버 액션 시작', {
+ contractId: data.contractId,
+ contractNumber: data.contractSummary.basicInfo?.contractNumber,
+ contractName: data.contractSummary.basicInfo?.name,
+ userId: data.currentUser.id,
+ hasEpId: !!data.currentUser.epId,
+ });
+
+ // 입력 검증
+ if (!data.currentUser.epId) {
+ debugError('[ContractApproval] Knox EP ID 없음');
+ throw new Error('Knox EP ID가 필요합니다');
+ }
+
+ if (!data.contractId) {
+ debugError('[ContractApproval] 계약 ID 없음');
+ throw new Error('계약 ID가 필요합니다');
+ }
+
+ // 1. 유저의 nonsapUserId 조회 (Cronjob 환경을 위해)
+ debugLog('[ContractApproval] nonsapUserId 조회');
+ const userResult = await db.query.users.findFirst({
+ where: eq(users.id, data.currentUser.id),
+ columns: { nonsapUserId: true }
+ });
+ const nonsapUserId = userResult?.nonsapUserId || null;
+ debugLog('[ContractApproval] nonsapUserId 조회 완료', { nonsapUserId });
+
+ // 2. 템플릿 변수 매핑
+ debugLog('[ContractApproval] 템플릿 변수 매핑 시작');
+ const variables = await mapContractToApprovalTemplateVariables(data.contractSummary);
+ debugLog('[ContractApproval] 템플릿 변수 매핑 완료', {
+ variableKeys: Object.keys(variables),
+ });
+
+ // 3. 결재 워크플로우 시작 (Saga 패턴)
+ debugLog('[ContractApproval] ApprovalSubmissionSaga 생성');
+ const saga = new ApprovalSubmissionSaga(
+ // actionType: 핸들러를 찾을 때 사용할 키
+ 'general_contract_approval',
+
+ // actionPayload: 결재 승인 후 핸들러에 전달될 데이터
+ {
+ contractId: data.contractId,
+ contractSummary: data.contractSummary,
+ currentUser: {
+ id: data.currentUser.id,
+ email: data.currentUser.email,
+ nonsapUserId: nonsapUserId,
+ },
+ },
+
+ // approvalConfig: 결재 상신 정보 (템플릿 포함)
+ {
+ title: data.title || `계약 체결 진행 품의 요청서 - ${data.contractSummary.basicInfo?.contractNumber || data.contractId}`,
+ description: `${data.contractSummary.basicInfo?.name || '일반계약'} 계약 체결 진행 품의 요청`,
+ templateName: '일반계약 결재', // 한국어 템플릿명
+ variables, // 치환할 변수들
+ approvers: data.approvers,
+ currentUser: data.currentUser,
+ }
+ );
+
+ debugLog('[ContractApproval] Saga 실행 시작');
+ const result = await saga.execute();
+
+ // 4. 결재 상신 성공 시 상태를 'approval_in_progress'로 변경
+ if (result.status === 'pending_approval') {
+ debugLog('[ContractApproval] 상태를 approval_in_progress로 변경');
+ await db.update(generalContracts)
+ .set({
+ status: 'approval_in_progress',
+ lastUpdatedAt: new Date()
+ })
+ .where(eq(generalContracts.id, data.contractId));
+ }
+
+ debugSuccess('[ContractApproval] 결재 워크플로우 완료', {
+ approvalId: result.approvalId,
+ status: result.status,
+ });
+
+ return result;
+}
+
diff --git a/lib/general-contracts/approval-template-variables.ts b/lib/general-contracts/approval-template-variables.ts
new file mode 100644
index 00000000..710e6101
--- /dev/null
+++ b/lib/general-contracts/approval-template-variables.ts
@@ -0,0 +1,345 @@
+/**
+ * 일반계약 결재 템플릿 변수 매핑 함수
+ *
+ * 제공된 HTML 템플릿의 변수명에 맞춰 매핑
+ */
+
+'use server';
+
+import { format } from 'date-fns';
+
+interface ContractSummary {
+ basicInfo: Record<string, unknown>;
+ items: Record<string, unknown>[];
+ subcontractChecklist: Record<string, unknown> | null;
+ storageInfo?: Record<string, unknown>[];
+}
+
+/**
+ * 일반계약 데이터를 결재 템플릿 변수로 매핑
+ *
+ * @param contractSummary - 계약 요약 정보
+ * @returns 템플릿 변수 객체 (Record<string, string>)
+ */
+export async function mapContractToApprovalTemplateVariables(
+ contractSummary: ContractSummary
+): Promise<Record<string, string>> {
+ const { basicInfo, items, subcontractChecklist } = contractSummary;
+
+ // 날짜 포맷팅 헬퍼
+ const formatDate = (date: any) => {
+ if (!date) return '';
+ try {
+ const d = new Date(date);
+ if (isNaN(d.getTime())) return String(date);
+ return format(d, 'yyyy-MM-dd');
+ } 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 contractPeriod = basicInfo.startDate && basicInfo.endDate
+ ? `${formatDate(basicInfo.startDate)} ~ ${formatDate(basicInfo.endDate)}`
+ : '';
+
+ // 계약체결방식
+ const contractExecutionMethod = basicInfo.executionMethod || '';
+
+ // 계약종류
+ const contractType = basicInfo.type || '';
+
+ // 업체선정방식
+ const vendorSelectionMethod = basicInfo.contractSourceType || '';
+
+ // 매입 부가가치세
+ const taxType = basicInfo.taxType || '';
+
+ // SHI 지급조건
+ const paymentTerm = basicInfo.paymentTerm || '';
+
+ // SHI 인도조건
+ const deliveryTerm = basicInfo.deliveryTerm || '';
+ const deliveryType = basicInfo.deliveryType || '';
+
+ // 사외업체 야드 투입 여부
+ const externalYardEntry = basicInfo.externalYardEntry === 'Y' ? '예' : '아니오';
+
+ // 직종
+ const workType = basicInfo.workType || '';
+
+ // 재하도 협력사
+ const subcontractVendor = basicInfo.subcontractVendorName || '';
+
+ // 계약 내용
+ const contractContent = basicInfo.notes || basicInfo.name || '';
+
+ // 계약성립조건
+ 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) {
+ console.warn('계약성립조건 파싱 실패:', e);
+ }
+ }
+
+ // 계약해지조건
+ 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) {
+ console.warn('계약해지조건 파싱 실패:', e);
+ }
+ }
+
+ // 협력사 정보
+ const vendorCode = basicInfo.vendorCode || '';
+ const vendorName = basicInfo.vendorName || '';
+ const vendorContactPerson = basicInfo.vendorContactPerson || '';
+ const vendorPhone = basicInfo.vendorPhone || '';
+ const vendorEmail = basicInfo.vendorEmail || '';
+ const vendorNote = '';
+
+ // 자재 정보 (최대 100건)
+ const materialItems = items.slice(0, 100);
+ const materialCount = items.length;
+
+ // 보증 정보
+ const guarantees: Array<{
+ type: string;
+ order: number;
+ bondNumber: string;
+ rate: string;
+ amount: string;
+ period: string;
+ startDate: string;
+ endDate: string;
+ issuer: string;
+ }> = [];
+
+ // // 계약보증 (첫 번째 항목만 사용)
+ // if (basicInfo.contractBond) {
+ // const bond = typeof basicInfo.contractBond === 'string'
+ // ? JSON.parse(basicInfo.contractBond)
+ // : basicInfo.contractBond;
+
+ // if (bond && Array.isArray(bond) && bond.length > 0) {
+ // const b = bond[0];
+ // guarantees.push({
+ // type: '계약보증',
+ // order: 1,
+ // bondNumber: b.bondNumber || '',
+ // rate: b.rate ? `${b.rate}%` : '',
+ // amount: formatCurrency(b.amount),
+ // period: b.period || '',
+ // startDate: formatDate(b.startDate),
+ // endDate: formatDate(b.endDate),
+ // issuer: b.issuer || '',
+ // });
+ // }
+ // }
+
+ // // 지급보증 (첫 번째 항목만 사용)
+ // if (basicInfo.paymentBond) {
+ // const bond = typeof basicInfo.paymentBond === 'string'
+ // ? JSON.parse(basicInfo.paymentBond)
+ // : basicInfo.paymentBond;
+
+ // if (bond && Array.isArray(bond) && bond.length > 0) {
+ // const b = bond[0];
+ // guarantees.push({
+ // type: '지급보증',
+ // order: 1,
+ // bondNumber: b.bondNumber || '',
+ // rate: b.rate ? `${b.rate}%` : '',
+ // amount: formatCurrency(b.amount),
+ // period: b.period || '',
+ // startDate: formatDate(b.startDate),
+ // endDate: formatDate(b.endDate),
+ // issuer: b.issuer || '',
+ // });
+ // }
+ // }
+
+ // // 하자보증 (첫 번째 항목만 사용)
+ // if (basicInfo.defectBond) {
+ // const bond = typeof basicInfo.defectBond === 'string'
+ // ? JSON.parse(basicInfo.defectBond)
+ // : basicInfo.defectBond;
+
+ // if (bond && Array.isArray(bond) && bond.length > 0) {
+ // const b = bond[0];
+ // guarantees.push({
+ // type: '하자보증',
+ // order: 1,
+ // bondNumber: b.bondNumber || '',
+ // rate: b.rate ? `${b.rate}%` : '',
+ // amount: formatCurrency(b.amount),
+ // period: b.period || '',
+ // startDate: formatDate(b.startDate),
+ // endDate: formatDate(b.endDate),
+ // issuer: b.issuer || '',
+ // });
+ // }
+ // }
+
+ // // 보증 전체 비고
+ // const guaranteeNote = basicInfo.guaranteeNote || '';
+
+
+ // 총 계약 금액 계산
+ const totalContractAmount = items.reduce((sum, item) => {
+ const amount = Number(item.contractAmount || item.totalLineAmount || 0);
+ return sum + (isNaN(amount) ? 0 : amount);
+ }, 0);
+
+ // 변수 매핑
+ const variables: Record<string, string> = {
+ // 계약 기본 정보
+ '계약번호': String(basicInfo.contractNumber || ''),
+ '계약명': String(basicInfo.name || basicInfo.contractName || ''),
+ '계약체결방식': String(contractExecutionMethod),
+ '계약종류': String(contractType),
+ '구매담당자': String(basicInfo.managerName || basicInfo.registeredByName || ''),
+ '업체선정방식': String(vendorSelectionMethod),
+ '입찰번호': String(basicInfo.linkedBidNumber || ''),
+ '입찰명': String(basicInfo.linkedBidName || ''),
+ '계약기간': contractPeriod,
+ '계약일자': formatDate(basicInfo.registeredAt || basicInfo.createdAt),
+ '매입_부가가치세': String(taxType),
+ '계약_담당자': String(basicInfo.managerName || basicInfo.registeredByName || ''),
+ '계약부서': String(basicInfo.departmentName || ''),
+ '계약금액': formatCurrency(basicInfo.contractAmount),
+ 'SHI_지급조건': String(paymentTerm),
+ 'SHI_인도조건': String(deliveryTerm),
+ 'SHI_인도조건_옵션': String(deliveryType),
+ '선적지': String(basicInfo.shippingLocation || ''),
+ '하역지': String(basicInfo.dischargeLocation || ''),
+ '사외업체_야드_투입여부': externalYardEntry,
+ '프로젝트': String(basicInfo.projectName || basicInfo.projectCode || ''),
+ '직종': String(workType),
+ '재하도_협력사': String(subcontractVendor),
+ '계약내용': String(contractContent),
+ '계약성립조건': establishmentConditionsText,
+ '계약해지조건': terminationConditionsText,
+
+ // 협력사 정보
+ '협력사코드': String(vendorCode),
+ '협력사명': String(vendorName),
+ '협력사_담당자': String(vendorContactPerson),
+ '전화번호': String(vendorPhone),
+ '이메일': String(vendorEmail),
+ '비고': String(vendorNote),
+
+ // 자재 정보
+ '대상_자재_수': String(materialCount),
+ };
+
+ // 자재 정보 변수 (최대 100건)
+ materialItems.forEach((item, index) => {
+ const idx = index + 1;
+ variables[`플랜트_${idx}`] = String(item.plant || '');
+ variables[`프로젝트_${idx}`] = String(item.projectName || item.projectCode || '');
+ variables[`자재그룹_${idx}`] = String(item.itemGroup || item.itemCode || '');
+ variables[`자재그룹명_${idx}`] = String(item.itemGroupName || '');
+ variables[`자재번호_${idx}`] = String(item.itemCode || '');
+ variables[`자재상세_${idx}`] = String(item.itemInfo || item.description || '');
+ variables[`연간단가여부_${idx}`] = String(item.isAnnualPrice ? '예' : '아니오');
+ variables[`수량_${idx}`] = formatCurrency(item.quantity);
+ variables[`구매단위_${idx}`] = String(item.quantityUnit || '');
+ variables[`계약단가_${idx}`] = formatCurrency(item.contractUnitPrice || item.unitPrice);
+ variables[`수량단위_${idx}`] = String(item.quantityUnit || '');
+ variables[`총중량_${idx}`] = formatCurrency(item.totalWeight);
+ variables[`중량단위_${idx}`] = String(item.weightUnit || '');
+ variables[`계약금액_${idx}`] = formatCurrency(item.contractAmount || item.totalLineAmount);
+ });
+
+ // 총 계약 금액
+ variables['총_계약금액'] = formatCurrency(totalContractAmount);
+
+ // // 보증 정보 변수 (첫 번째 항목만 사용)
+ // const contractGuarantee = guarantees.find(g => g.type === '계약보증');
+ // if (contractGuarantee) {
+ // variables['계약보증_차수_1'] = String(contractGuarantee.order);
+ // variables['계약보증_증권번호_1'] = String(contractGuarantee.bondNumber || '');
+ // variables['계약보증_보증금율_1'] = String(contractGuarantee.rate || '');
+ // variables['계약보증_보증금액_1'] = String(contractGuarantee.amount || '');
+ // variables['계약보증_보증기간_1'] = String(contractGuarantee.period || '');
+ // variables['계약보증_시작일_1'] = String(contractGuarantee.startDate || '');
+ // variables['계약보증_종료일_1'] = String(contractGuarantee.endDate || '');
+ // variables['계약보증_발행기관_1'] = String(contractGuarantee.issuer || '');
+ // variables['계약보증_비고_1'] = String(guaranteeNote); // 기존 보증 전체 비고를 계약보증 비고로 사용
+ // }
+
+ // const paymentGuarantee = guarantees.find(g => g.type === '지급보증');
+ // if (paymentGuarantee) {
+ // variables['지급보증_차수_1'] = String(paymentGuarantee.order);
+ // variables['지급보증_증권번호_1'] = String(paymentGuarantee.bondNumber || '');
+ // variables['지급보증_보증금율_1'] = String(paymentGuarantee.rate || '');
+ // variables['지급보증_보증금액_1'] = String(paymentGuarantee.amount || '');
+ // variables['지급보증_보증기간_1'] = String(paymentGuarantee.period || '');
+ // variables['지급보증_시작일_1'] = String(paymentGuarantee.startDate || '');
+ // variables['지급보증_종료일_1'] = String(paymentGuarantee.endDate || '');
+ // variables['지급보증_발행기관_1'] = String(paymentGuarantee.issuer || '');
+ // variables['지급보증_비고_1'] = String(guaranteeNote); // 기존 보증 전체 비고를 지급보증 비고로 사용
+ // }
+
+ // const defectGuarantee = guarantees.find(g => g.type === '하자보증');
+ // if (defectGuarantee) {
+ // variables['하자보증_차수_1'] = String(defectGuarantee.order);
+ // variables['하자보증_증권번호_1'] = String(defectGuarantee.bondNumber || '');
+ // variables['하자보증_보증금율_1'] = String(defectGuarantee.rate || '');
+ // variables['하자보증_보증금액_1'] = String(defectGuarantee.amount || '');
+ // variables['하자보증_보증기간_1'] = String(defectGuarantee.period || '');
+ // variables['하자보증_시작일_1'] = String(defectGuarantee.startDate || '');
+ // variables['하자보증_종료일_1'] = String(defectGuarantee.endDate || '');
+ // variables['하자보증_발행기관_1'] = String(defectGuarantee.issuer || '');
+ // variables['하자보증_비고_1'] = String(guaranteeNote); // 기존 보증 전체 비고를 하자보증 비고로 사용
+ // }
+
+ // 하도급 체크리스트 변수 (새로운 템플릿 구조에 맞춤)
+ if (subcontractChecklist) {
+ variables['작업전_서면발급_체크'] = String(subcontractChecklist.workDocumentIssuedCheck || subcontractChecklist.workDocumentIssued || '');
+ variables['기재사항_1'] = String(subcontractChecklist.legalItem1 || subcontractChecklist.sixLegalItems1 || '');
+ variables['기재사항_2'] = String(subcontractChecklist.legalItem2 || subcontractChecklist.sixLegalItems2 || '');
+ variables['기재사항_3'] = String(subcontractChecklist.legalItem3 || subcontractChecklist.sixLegalItems3 || '');
+ variables['기재사항_4'] = String(subcontractChecklist.legalItem4 || subcontractChecklist.sixLegalItems4 || '');
+ variables['기재사항_5'] = String(subcontractChecklist.legalItem5 || subcontractChecklist.sixLegalItems5 || '');
+ variables['기재사항_6'] = String(subcontractChecklist.legalItem6 || subcontractChecklist.sixLegalItems6 || '');
+ variables['부당대금_결정'] = String(subcontractChecklist.unfairPriceDecision || subcontractChecklist.unfairSubcontractPrice || '');
+ variables['점검결과'] = String(subcontractChecklist.inspectionResult || subcontractChecklist.overallResult || '');
+ variables['귀책부서'] = String(subcontractChecklist.responsibleDepartment || subcontractChecklist.overallDepartment || '');
+ variables['원인'] = String(subcontractChecklist.cause || subcontractChecklist.overallCause || '');
+ variables['대책'] = String(subcontractChecklist.countermeasure || subcontractChecklist.overallMeasure || '');
+ }
+
+ return variables;
+}
+
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 46251c71..db0901cb 100644
--- a/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx
+++ b/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx
@@ -1,1068 +1,1200 @@
-'use client'
-
-import React, { useState, useEffect } from 'react'
-import { useSession } from 'next-auth/react'
-import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
-import { Button } from '@/components/ui/button'
-import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
-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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
-import { Input } from '@/components/ui/input'
-import { toast } from 'sonner'
-import {
- FileText,
- Upload,
- Eye,
- Send,
- CheckCircle,
- Download,
- AlertCircle
-} from 'lucide-react'
-import { ContractDocuments } from './general-contract-documents'
-import { getActiveContractTemplates } from '@/lib/bidding/service'
-import { type BasicContractTemplate } from '@/db/schema'
-import {
- getBasicInfo,
- getContractItems,
- getSubcontractChecklist,
- uploadContractApprovalFile,
- sendContractApprovalRequest,
- getContractById,
- getContractTemplateByContractType,
- getStorageInfo
-} from '../service'
-import { mapContractDataToTemplateVariables } from '../utils'
-
-interface ContractApprovalRequestDialogProps {
- contract: Record<string, unknown>
- open: boolean
- onOpenChange: (open: boolean) => void
-}
-
-interface ContractSummary {
- basicInfo: Record<string, unknown>
- items: Record<string, unknown>[]
- subcontractChecklist: Record<string, unknown> | null
- storageInfo?: Record<string, unknown>[]
-}
-
-export function ContractApprovalRequestDialog({
- contract,
- open,
- onOpenChange
-}: ContractApprovalRequestDialogProps) {
- const { data: session } = useSession()
- const [currentStep, setCurrentStep] = useState(1)
- const [contractSummary, setContractSummary] = useState<ContractSummary | null>(null)
- const [uploadedFile, setUploadedFile] = useState<File | null>(null)
- const [generatedPdfUrl, setGeneratedPdfUrl] = useState<string | null>(null)
- const [generatedPdfBuffer, setGeneratedPdfBuffer] = useState<Uint8Array | null>(null)
- const [isLoading, setIsLoading] = useState(false)
- const [pdfViewerInstance, setPdfViewerInstance] = useState<any>(null)
- const [isPdfPreviewVisible, setIsPdfPreviewVisible] = useState(false)
-
- // 기본계약 관련 상태
- const [selectedBasicContracts, setSelectedBasicContracts] = useState<Array<{
- type: string;
- templateName: string;
- checked: boolean;
- }>>([])
- const [isLoadingBasicContracts, setIsLoadingBasicContracts] = useState(false)
-
- const contractId = contract.id as number
- const userId = session?.user?.id || ''
-
-
- // 기본계약 생성 함수 (최종 전송 시점에 호출)
- const generateBasicContractPdf = async (
- vendorId: number,
- contractType: string,
- templateName: string
- ): Promise<{ buffer: number[], fileName: string }> => {
- try {
- // 1. 템플릿 데이터 준비 (서버 액션 호출)
- const prepareResponse = await fetch("/api/contracts/prepare-template", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- templateName,
- vendorId,
- }),
- });
-
- if (!prepareResponse.ok) {
- const errorText = await prepareResponse.text();
- throw new Error(`템플릿 준비 실패 (${prepareResponse.status}): ${errorText}`);
- }
-
- const { template, templateData } = await prepareResponse.json();
-
- // 2. 템플릿 파일 다운로드
- const templateResponse = await fetch("/api/contracts/get-template", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ templatePath: template.filePath }),
- });
-
- const templateBlob = await templateResponse.blob();
- const templateFile = new window.File([templateBlob], "template.docx", {
- type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
- });
-
- // 3. PDFTron WebViewer로 PDF 변환
- const { default: WebViewer } = await import("@pdftron/webviewer");
-
- const tempDiv = document.createElement('div');
- tempDiv.style.display = 'none';
- document.body.appendChild(tempDiv);
-
- try {
- const instance = await WebViewer(
- {
- path: "/pdftronWeb",
- licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
- fullAPI: true,
- enableOfficeEditing: true,
- },
- tempDiv
- );
-
- const { Core } = instance;
- const { createDocument } = Core;
-
- const templateDoc = await createDocument(templateFile, {
- filename: templateFile.name,
- extension: 'docx',
- });
-
- // 변수 치환 적용
- await templateDoc.applyTemplateValues(templateData);
- await new Promise(resolve => setTimeout(resolve, 3000));
-
- const fileData = await templateDoc.getFileData();
- const pdfBuffer = await Core.officeToPDFBuffer(fileData, { extension: 'docx' });
-
- const fileName = `${contractType}_${contractSummary?.basicInfo?.vendorCode || vendorId}_${Date.now()}.pdf`;
-
- instance.UI.dispose();
- return {
- buffer: Array.from(pdfBuffer),
- fileName
- };
-
- } finally {
- if (tempDiv.parentNode) {
- document.body.removeChild(tempDiv);
- }
- }
-
- } catch (error) {
- console.error(`기본계약 PDF 생성 실패 (${contractType}):`, error);
- throw error;
- }
- };
-
- // 기본계약 생성 및 선택 초기화
- const initializeBasicContracts = React.useCallback(async () => {
- if (!contractSummary?.basicInfo) return;
-
- setIsLoadingBasicContracts(true);
- try {
- // 기본적으로 사용할 수 있는 계약서 타입들
- const availableContracts: Array<{
- type: string;
- templateName: string;
- checked: boolean;
- }> = [
- { type: "NDA", templateName: "비밀", checked: false },
- { type: "General_GTC", templateName: "General GTC", checked: false },
- { type: "기술자료", templateName: "기술", checked: false }
- ];
-
- // 프로젝트 코드가 있으면 Project GTC도 추가
- if (contractSummary.basicInfo.projectCode) {
- availableContracts.push({
- type: "Project_GTC",
- templateName: contractSummary.basicInfo.projectCode as string,
- checked: false
- });
- }
-
- setSelectedBasicContracts(availableContracts);
- } catch (error) {
- console.error('기본계약 초기화 실패:', error);
- toast.error('기본계약 초기화에 실패했습니다.');
- } finally {
- setIsLoadingBasicContracts(false);
- }
- }, [contractSummary]);
-
- // 기본계약 선택 토글
- const toggleBasicContract = (type: string) => {
- setSelectedBasicContracts(prev =>
- prev.map(contract =>
- contract.type === type
- ? { ...contract, checked: !contract.checked }
- : contract
- )
- );
- };
-
-
- // 1단계: 계약 현황 수집
- const collectContractSummary = React.useCallback(async () => {
- setIsLoading(true)
- try {
- // 각 컴포넌트에서 활성화된 데이터만 수집
- const summary: ContractSummary = {
- basicInfo: {},
- items: [],
- subcontractChecklist: null
- }
-
- // Basic Info 확인 (항상 활성화)
- try {
- const basicInfoData = await getBasicInfo(contractId)
- if (basicInfoData && basicInfoData.success) {
- summary.basicInfo = basicInfoData.data || {}
- }
- // externalYardEntry 정보도 추가로 가져오기
- const contractData = await getContractById(contractId)
- if (contractData) {
- summary.basicInfo = {
- ...summary.basicInfo,
- externalYardEntry: contractData.externalYardEntry || 'N'
- }
- }
- } catch {
- console.log('Basic Info 데이터 없음')
- }
-
- // 품목 정보 확인
- try {
- const itemsData = await getContractItems(contractId)
- if (itemsData && itemsData.length > 0) {
- summary.items = itemsData
- }
- } catch {
- console.log('품목 정보 데이터 없음')
- }
-
- try {
- // Subcontract Checklist 확인
- const subcontractData = await getSubcontractChecklist(contractId)
- if (subcontractData && subcontractData.success && subcontractData.enabled) {
- summary.subcontractChecklist = subcontractData.data
- }
- } 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)
- setContractSummary(summary)
- } catch (error) {
- console.error('Error collecting contract summary:', error)
- toast.error('계약 정보를 수집하는 중 오류가 발생했습니다.')
- } finally {
- setIsLoading(false)
- }
- }, [contractId])
-
- // 3단계: PDF 생성 및 미리보기 (PDFTron 사용) - 템플릿 자동 로드
- const generatePdf = async () => {
- if (!contractSummary) {
- toast.error('계약 정보가 필요합니다.')
- return
- }
-
- setIsLoading(true)
- try {
- // 1. 계약 유형에 맞는 템플릿 조회
- const contractType = contractSummary.basicInfo.contractType as string
- const templateResult = await getContractTemplateByContractType(contractType)
-
- if (!templateResult.success || !templateResult.template) {
- throw new Error(templateResult.error || '템플릿을 찾을 수 없습니다.')
- }
-
- const template = templateResult.template
-
- // 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에 추가하지 않음)
- const tempDiv = document.createElement('div')
- tempDiv.style.display = 'none'
- document.body.appendChild(tempDiv)
-
- const instance = await WebViewer(
- {
- path: "/pdftronWeb",
- licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
- fullAPI: true,
- },
- tempDiv
- )
-
- try {
- const { Core } = instance
- const { createDocument } = Core
-
- // 템플릿 문서 생성 및 변수 치환
- const templateDoc = await createDocument(templateFile, {
- filename: templateFile.name,
- extension: 'docx',
- })
-
- // 템플릿 변수 매핑
- const mappedTemplateData = mapContractDataToTemplateVariables(contractSummary)
-
- console.log("🔄 변수 치환 시작:", mappedTemplateData)
- await templateDoc.applyTemplateValues(mappedTemplateData as any)
- console.log("✅ 변수 치환 완료")
-
- // PDF 변환
- const fileData = await templateDoc.getFileData()
- const pdfBuffer = await (Core as any).officeToPDFBuffer(fileData, { extension: 'docx' })
-
- 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가 생성되었습니다.')
-
- } finally {
- // 임시 WebViewer 정리
- instance.UI.dispose()
- document.body.removeChild(tempDiv)
- }
-
- } catch (error: any) {
- console.error('❌ PDF 생성 실패:', error)
- const errorMessage = error instanceof Error ? error.message : (error?.message || '알 수 없는 오류')
- toast.error(`PDF 생성 중 오류가 발생했습니다: ${errorMessage}`)
- } finally {
- setIsLoading(false)
- }
- }
-
- // PDF 미리보기 기능
- const openPdfPreview = async () => {
- if (!generatedPdfBuffer) {
- toast.error('생성된 PDF가 없습니다.')
- return
- }
-
- setIsLoading(true)
- try {
- // @ts-ignore
- const WebViewer = await import("@pdftron/webviewer").then(({ default: WebViewer }) => WebViewer)
-
- // 기존 인스턴스가 있다면 정리
- if (pdfViewerInstance) {
- console.log("🔄 기존 WebViewer 인스턴스 정리")
- try {
- pdfViewerInstance.UI.dispose()
- } catch (error) {
- console.warn('기존 WebViewer 정리 중 오류:', error)
- }
- setPdfViewerInstance(null)
- }
-
- // 미리보기용 컨테이너 확인
- let previewDiv = document.getElementById('pdf-preview-container')
- if (!previewDiv) {
- console.log("🔄 컨테이너 생성")
- previewDiv = document.createElement('div')
- previewDiv.id = 'pdf-preview-container'
- previewDiv.className = 'w-full h-full'
- previewDiv.style.width = '100%'
- previewDiv.style.height = '100%'
-
- // 실제 컨테이너에 추가
- const actualContainer = document.querySelector('[data-pdf-container]')
- if (actualContainer) {
- actualContainer.appendChild(previewDiv)
- }
- }
-
- console.log("🔄 WebViewer 인스턴스 생성 시작")
-
- // WebViewer 인스턴스 생성 (문서 없이)
- const instance = await Promise.race([
- WebViewer(
- {
- path: "/pdftronWeb",
- licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
- fullAPI: true,
- },
- previewDiv
- ),
- new Promise((_, reject) =>
- setTimeout(() => reject(new Error('WebViewer 초기화 타임아웃')), 30000)
- )
- ])
-
- console.log("🔄 WebViewer 인스턴스 생성 완료")
- setPdfViewerInstance(instance)
-
- // PDF 버퍼를 Blob으로 변환
- 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 as any).Core
-
- // 문서 로드 이벤트 대기
- await new Promise((resolve, reject) => {
- const timeout = setTimeout(() => {
- reject(new Error('문서 로드 타임아웃'))
- }, 20000)
-
- const onDocumentLoaded = () => {
- clearTimeout(timeout)
- documentViewer.removeEventListener('documentLoaded', onDocumentLoaded)
- documentViewer.removeEventListener('documentError', onDocumentError)
- console.log("🔄 문서 로드 완료")
- resolve(true)
- }
-
- const onDocumentError = (error: any) => {
- clearTimeout(timeout)
- documentViewer.removeEventListener('documentLoaded', onDocumentLoaded)
- documentViewer.removeEventListener('documentError', onDocumentError)
- console.error('문서 로드 오류:', error)
- reject(error)
- }
-
- documentViewer.addEventListener('documentLoaded', onDocumentLoaded)
- documentViewer.addEventListener('documentError', onDocumentError)
-
- // 문서 로드 시작
- documentViewer.loadDocument(pdfUrl)
- })
-
- setIsPdfPreviewVisible(true)
- toast.success('PDF 미리보기가 준비되었습니다.')
-
- } catch (error) {
- console.error('PDF 미리보기 실패:', error)
- toast.error(`PDF 미리보기 중 오류가 발생했습니다: ${error.message}`)
- } finally {
- setIsLoading(false)
- }
- }
-
- // PDF 다운로드 기능
- const downloadPdf = () => {
- if (!generatedPdfBuffer) {
- toast.error('다운로드할 PDF가 없습니다.')
- return
- }
-
- const pdfBlob = new Blob([generatedPdfBuffer as any], { type: 'application/pdf' })
- const pdfUrl = URL.createObjectURL(pdfBlob)
-
- const link = document.createElement('a')
- link.href = pdfUrl
- link.download = `contract_${contractId}_${Date.now()}.pdf`
- document.body.appendChild(link)
- link.click()
- document.body.removeChild(link)
-
- URL.revokeObjectURL(pdfUrl)
- toast.success('PDF가 다운로드되었습니다.')
- }
-
- // PDF 미리보기 닫기
- const closePdfPreview = () => {
- console.log("🔄 PDF 미리보기 닫기 시작")
- if (pdfViewerInstance) {
- try {
- console.log("🔄 WebViewer 인스턴스 정리")
- pdfViewerInstance.UI.dispose()
- } catch (error) {
- console.warn('WebViewer 정리 중 오류:', error)
- }
- setPdfViewerInstance(null)
- }
-
- // 컨테이너 정리
- const previewDiv = document.getElementById('pdf-preview-container')
- if (previewDiv) {
- try {
- previewDiv.innerHTML = ''
- } catch (error) {
- console.warn('컨테이너 정리 중 오류:', error)
- }
- }
-
- setIsPdfPreviewVisible(false)
- console.log("🔄 PDF 미리보기 닫기 완료")
- }
-
- // 최종 전송
- const handleFinalSubmit = async () => {
- if (!generatedPdfUrl || !contractSummary || !generatedPdfBuffer) {
- toast.error('생성된 PDF가 필요합니다.')
- return
- }
-
- if (!userId) {
- toast.error('로그인이 필요합니다.')
- return
- }
-
- setIsLoading(true)
- try {
- // 기본계약서 생성 (최종 전송 시점에)
- let generatedBasicContractPdfs: Array<{ key: string; buffer: number[]; fileName: string }> = [];
-
- const contractsToGenerate = selectedBasicContracts.filter(c => c.checked);
- if (contractsToGenerate.length > 0) {
- // vendorId 조회
- let vendorId: number | undefined;
- try {
- const basicInfoData = await getBasicInfo(contractId);
- if (basicInfoData && basicInfoData.success && basicInfoData.data) {
- vendorId = basicInfoData.data.vendorId;
- }
- } catch (error) {
- console.error('vendorId 조회 실패:', error);
- }
-
- if (vendorId) {
- toast.info('기본계약서를 생성하는 중입니다...');
-
- for (const contract of contractsToGenerate) {
- try {
- const pdf = await generateBasicContractPdf(vendorId, contract.type, contract.templateName);
- generatedBasicContractPdfs.push({
- key: `${vendorId}_${contract.type}_${contract.templateName}`,
- ...pdf
- });
- } catch (error) {
- console.error(`${contract.type} 계약서 생성 실패:`, error);
- // 개별 실패는 전체를 중단하지 않음
- }
- }
-
- if (generatedBasicContractPdfs.length > 0) {
- toast.success(`${generatedBasicContractPdfs.length}개의 기본계약서가 생성되었습니다.`);
- }
- }
- }
-
- // 서버액션을 사용하여 계약승인요청 전송
- const result = await sendContractApprovalRequest(
- contractSummary,
- generatedPdfBuffer,
- 'contractDocument',
- userId,
- generatedBasicContractPdfs
- )
-
- if (result.success) {
- toast.success('계약승인요청이 전송되었습니다.')
- onOpenChange(false)
- } else {
- // 서버에서 이미 처리된 에러 메시지 표시
- toast.error(result.error || '계약승인요청 전송 실패')
- return
- }
- } catch (error: any) {
- console.error('Error submitting approval request:', error)
-
- // 데이터베이스 중복 키 오류 처리
- if (error.message && error.message.includes('duplicate key value violates unique constraint')) {
- toast.error('이미 존재하는 계약번호입니다. 다른 계약번호를 사용해주세요.')
- return
- }
-
- // 다른 오류에 대한 일반적인 처리
- toast.error('계약승인요청 전송 중 오류가 발생했습니다.')
- } finally {
- setIsLoading(false)
- }
- }
-
- // 다이얼로그가 열릴 때 1단계 데이터 수집
- useEffect(() => {
- if (open && currentStep === 1) {
- collectContractSummary()
- }
- }, [open, currentStep, collectContractSummary])
-
- // 계약 요약이 준비되면 기본계약 초기화
- useEffect(() => {
- if (contractSummary && currentStep === 2) {
- const loadBasicContracts = async () => {
- await initializeBasicContracts()
- }
- loadBasicContracts()
- }
- }, [contractSummary, currentStep, initializeBasicContracts])
-
- // 다이얼로그가 닫힐 때 PDF 뷰어 정리
- useEffect(() => {
- if (!open) {
- closePdfPreview()
- }
- }, [open])
-
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <FileText className="h-5 w-5" />
- 계약승인요청
- </DialogTitle>
- </DialogHeader>
-
- <Tabs value={currentStep.toString()} className="w-full">
- <TabsList className="grid w-full grid-cols-3">
- <TabsTrigger value="1" disabled={currentStep < 1}>
- 1. 계약 현황 정리
- </TabsTrigger>
- <TabsTrigger value="2" disabled={currentStep < 2}>
- 2. 기본계약 체크
- </TabsTrigger>
- <TabsTrigger value="3" disabled={currentStep < 3}>
- 3. PDF 미리보기
- </TabsTrigger>
- </TabsList>
-
- {/* 1단계: 계약 현황 정리 */}
- <TabsContent value="1" className="space-y-4">
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- <CheckCircle className="h-5 w-5 text-green-600" />
- 작성된 계약 현황
- </CardTitle>
- </CardHeader>
- <CardContent className="space-y-4">
- {isLoading ? (
- <div className="text-center py-4">
- <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
- <p className="mt-2 text-sm text-muted-foreground">계약 정보를 수집하는 중...</p>
- </div>
- ) : (
- <div className="space-y-4">
- {/* 기본 정보 (필수) */}
- <div className="border rounded-lg p-4">
- <div className="flex items-center gap-2 mb-3">
- <CheckCircle className="h-4 w-4 text-green-600" />
- <Label className="font-medium">기본 정보</Label>
- <Badge variant="secondary">필수</Badge>
- </div>
- <div className="grid grid-cols-2 gap-4 text-sm">
- <div>
- <span className="font-medium">계약번호:</span> {String(contractSummary?.basicInfo?.contractNumber || '')}
- </div>
- <div>
- <span className="font-medium">계약명:</span> {String(contractSummary?.basicInfo?.contractName || '')}
- </div>
- <div>
- <span className="font-medium">벤더:</span> {String(contractSummary?.basicInfo?.vendorName || '')}
- </div>
- <div>
- <span className="font-medium">프로젝트:</span> {String(contractSummary?.basicInfo?.projectName || '')}
- </div>
- <div>
- <span className="font-medium">계약유형:</span> {String(contractSummary?.basicInfo?.contractType || '')}
- </div>
- <div>
- <span className="font-medium">계약상태:</span> {String(contractSummary?.basicInfo?.contractStatus || '')}
- </div>
- <div>
- <span className="font-medium">계약금액:</span> {String(contractSummary?.basicInfo?.contractAmount || '')} {String(contractSummary?.basicInfo?.currency || '')}
- </div>
- <div>
- <span className="font-medium">계약기간:</span> {String(contractSummary?.basicInfo?.startDate || '')} ~ {String(contractSummary?.basicInfo?.endDate || '')}
- </div>
- <div>
- <span className="font-medium">사양서 유형:</span> {String(contractSummary?.basicInfo?.specificationType || '')}
- </div>
- <div>
- <span className="font-medium">단가 유형:</span> {String(contractSummary?.basicInfo?.unitPriceType || '')}
- </div>
- <div>
- <span className="font-medium">연결 PO번호:</span> {String(contractSummary?.basicInfo?.linkedPoNumber || '')}
- </div>
- <div>
- <span className="font-medium">연결 입찰번호:</span> {String(contractSummary?.basicInfo?.linkedBidNumber || '')}
- </div>
- </div>
- </div>
-
- {/* 지급/인도 조건 */}
- <div className="border rounded-lg p-4">
- <div className="flex items-center gap-2 mb-3">
- <CheckCircle className="h-4 w-4 text-green-600" />
- <Label className="font-medium">지급/인도 조건</Label>
- <Badge variant="secondary">필수</Badge>
- </div>
- <div className="grid grid-cols-2 gap-4 text-sm">
- <div>
- <span className="font-medium">지급조건:</span> {String(contractSummary?.basicInfo?.paymentTerm || '')}
- </div>
- <div>
- <span className="font-medium">세금 유형:</span> {String(contractSummary?.basicInfo?.taxType || '')}
- </div>
- <div>
- <span className="font-medium">인도조건:</span> {String(contractSummary?.basicInfo?.deliveryTerm || '')}
- </div>
- <div>
- <span className="font-medium">인도유형:</span> {String(contractSummary?.basicInfo?.deliveryType || '')}
- </div>
- <div>
- <span className="font-medium">선적지:</span> {String(contractSummary?.basicInfo?.shippingLocation || '')}
- </div>
- <div>
- <span className="font-medium">하역지:</span> {String(contractSummary?.basicInfo?.dischargeLocation || '')}
- </div>
- <div>
- <span className="font-medium">계약납기:</span> {String(contractSummary?.basicInfo?.contractDeliveryDate || '')}
- </div>
- <div>
- <span className="font-medium">위약금:</span> {contractSummary?.basicInfo?.liquidatedDamages ? '적용' : '미적용'}
- </div>
- </div>
- </div>
-
- {/* 추가 조건 */}
- <div className="border rounded-lg p-4">
- <div className="flex items-center gap-2 mb-3">
- <CheckCircle className="h-4 w-4 text-green-600" />
- <Label className="font-medium">추가 조건</Label>
- <Badge variant="secondary">필수</Badge>
- </div>
- <div className="grid grid-cols-2 gap-4 text-sm">
- <div>
- <span className="font-medium">연동제 정보:</span> {String(contractSummary?.basicInfo?.interlockingSystem || '')}
- </div>
- <div>
- <span className="font-medium">계약성립조건:</span>
- {contractSummary?.basicInfo?.contractEstablishmentConditions &&
- Object.entries(contractSummary.basicInfo.contractEstablishmentConditions as Record<string, unknown>)
- .filter(([, value]) => value === true)
- .map(([key]) => key)
- .join(', ') || '없음'}
- </div>
- <div>
- <span className="font-medium">계약해지조건:</span>
- {contractSummary?.basicInfo?.contractTerminationConditions &&
- Object.entries(contractSummary.basicInfo.contractTerminationConditions as Record<string, unknown>)
- .filter(([, value]) => value === true)
- .map(([key]) => key)
- .join(', ') || '없음'}
- </div>
- </div>
- </div>
-
- {/* 품목 정보 */}
- <div className="border rounded-lg p-4">
- <div className="flex items-center gap-2 mb-3">
- <Checkbox
- id="items-enabled"
- checked={contractSummary?.items && contractSummary.items.length > 0}
- disabled
- />
- <Label htmlFor="items-enabled" className="font-medium">품목 정보</Label>
- <Badge variant="outline">선택</Badge>
- </div>
- {contractSummary?.items && contractSummary.items.length > 0 ? (
- <div className="space-y-2">
- <p className="text-sm text-muted-foreground">
- 총 {contractSummary.items.length}개 품목이 입력되어 있습니다.
- </p>
- <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">{String(item.itemInfo || item.description || `품목 ${index + 1}`)}</div>
- <div className="text-muted-foreground">
- 수량: {String(item.quantity || 0)} | 단가: {String(item.contractUnitPrice || item.unitPrice || 0)}
- </div>
- </div>
- ))}
- {contractSummary.items.length > 3 && (
- <div className="text-xs text-muted-foreground text-center">
- ... 외 {contractSummary.items.length - 3}개 품목
- </div>
- )}
- </div>
- </div>
- ) : (
- <p className="text-sm text-muted-foreground">
- 품목 정보가 입력되지 않았습니다.
- </p>
- )}
- </div>
-
- {/* 하도급 체크리스트 */}
- <div className="border rounded-lg p-4">
- <div className="flex items-center gap-2 mb-3">
- <Checkbox
- id="subcontract-enabled"
- checked={!!contractSummary?.subcontractChecklist}
- disabled
- />
- <Label htmlFor="subcontract-enabled" className="font-medium">
- 하도급 체크리스트
- </Label>
- <Badge variant="outline">선택</Badge>
- </div>
- <p className="text-sm text-muted-foreground">
- {contractSummary?.subcontractChecklist
- ? '정보가 입력되어 있습니다.'
- : '정보가 입력되지 않았습니다.'}
- </p>
- </div>
- </div>
- )}
- </CardContent>
- </Card>
-
- <div className="flex justify-end">
- <Button
- onClick={() => setCurrentStep(2)}
- disabled={isLoading}
- >
- 다음 단계
- </Button>
- </div>
- </TabsContent>
-
- {/* 2단계: 기본계약 체크 */}
- <TabsContent value="2" className="space-y-4">
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- <FileText className="h-5 w-5 text-blue-600" />
- 기본계약서 선택
- </CardTitle>
- <p className="text-sm text-muted-foreground">
- 벤더에게 발송할 기본계약서를 선택해주세요. (템플릿이 있는 계약서만 선택 가능합니다.)
- </p>
- </CardHeader>
- <CardContent className="space-y-4">
- {isLoadingBasicContracts ? (
- <div className="text-center py-8">
- <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
- <p className="mt-2 text-sm text-muted-foreground">기본계약 템플릿을 불러오는 중...</p>
- </div>
- ) : (
- <div className="space-y-4">
- {selectedBasicContracts.length > 0 ? (
- <div className="space-y-3">
- <div className="flex items-center justify-between">
- <h4 className="font-medium">필요한 기본계약서</h4>
- <Badge variant="outline">
- {selectedBasicContracts.filter(c => c.checked).length}개 선택됨
- </Badge>
- </div>
-
- <div className="grid gap-3">
- {selectedBasicContracts.map((contract) => (
- <div
- key={contract.type}
- className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50"
- >
- <div className="flex items-center gap-3">
- <Checkbox
- id={`contract-${contract.type}`}
- checked={contract.checked}
- onCheckedChange={() => toggleBasicContract(contract.type)}
- />
- <div>
- <Label
- htmlFor={`contract-${contract.type}`}
- className="font-medium cursor-pointer"
- >
- {contract.type}
- </Label>
- <p className="text-sm text-muted-foreground">
- 템플릿: {contract.templateName}
- </p>
- </div>
- </div>
- <Badge
- variant="secondary"
- className="text-xs"
- >
- {contract.checked ? "선택됨" : "미선택"}
- </Badge>
- </div>
- ))}
- </div>
-
- </div>
- ) : (
- <div className="text-center py-8 text-muted-foreground">
- <FileText className="h-12 w-12 mx-auto mb-4 opacity-50" />
- <p>기본계약서 목록을 불러올 수 없습니다.</p>
- <p className="text-sm">잠시 후 다시 시도해주세요.</p>
- </div>
- )}
-
- </div>
- )}
- </CardContent>
- </Card>
-
- <div className="flex justify-between">
- <Button variant="outline" onClick={() => setCurrentStep(1)}>
- 이전 단계
- </Button>
- <Button
- onClick={() => setCurrentStep(3)}
- disabled={isLoadingBasicContracts}
- >
- 다음 단계
- </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>
- </CardHeader>
- <CardContent className="space-y-4">
- {!generatedPdfUrl ? (
- <div className="text-center py-8">
- <Button onClick={generatePdf} disabled={isLoading}>
- {isLoading ? 'PDF 생성 중...' : 'PDF 생성하기'}
- </Button>
- </div>
- ) : (
- <div className="space-y-4">
- <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">PDF 생성 완료</span>
- </div>
- </div>
-
- <div className="border rounded-lg p-4">
- <div className="flex items-center justify-between mb-4">
- <h4 className="font-medium">생성된 PDF</h4>
- <div className="flex gap-2">
- <Button
- variant="outline"
- size="sm"
- onClick={downloadPdf}
- disabled={isLoading}
- >
- <Download className="h-4 w-4 mr-2" />
- 다운로드
- </Button>
- <Button
- variant="outline"
- size="sm"
- onClick={openPdfPreview}
- disabled={isLoading}
- >
- <Eye className="h-4 w-4 mr-2" />
- 미리보기
- </Button>
- </div>
- </div>
-
- {/* PDF 미리보기 영역 */}
- <div className="border rounded-lg h-96 bg-gray-50 relative" data-pdf-container>
- {isPdfPreviewVisible ? (
- <>
- <div className="absolute top-2 right-2 z-10">
- <Button
- variant="outline"
- size="sm"
- onClick={closePdfPreview}
- className="bg-white/90 hover:bg-white"
- >
- ✕ 닫기
- </Button>
- </div>
- <div id="pdf-preview-container" className="w-full h-full" />
- </>
- ) : (
- <div className="flex items-center justify-center h-full">
- <div className="text-center text-muted-foreground">
- <FileText className="h-12 w-12 mx-auto mb-2" />
- <p>미리보기 버튼을 클릭하여 PDF를 확인하세요</p>
- </div>
- </div>
- )}
- </div>
- </div>
- </div>
- )}
- </CardContent>
- </Card>
-
- <div className="flex justify-between">
- <Button variant="outline" onClick={() => setCurrentStep(2)}>
- 이전 단계
- </Button>
- <Button
- onClick={handleFinalSubmit}
- disabled={!generatedPdfUrl || isLoading}
- className="bg-green-600 hover:bg-green-700"
- >
- <Send className="h-4 w-4 mr-2" />
- {isLoading ? '전송 중...' : '최종 전송'}
- </Button>
- </div>
- </TabsContent>
- </Tabs>
- </DialogContent>
- </Dialog>
+'use client'
+
+import React, { useState, useEffect } from 'react'
+import { useSession } from 'next-auth/react'
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
+import { Button } from '@/components/ui/button'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import { Input } from '@/components/ui/input'
+import { toast } from 'sonner'
+import {
+ FileText,
+ Upload,
+ Eye,
+ Send,
+ CheckCircle,
+ Download,
+ AlertCircle
+} from 'lucide-react'
+import { ContractDocuments } from './general-contract-documents'
+import { getActiveContractTemplates } from '@/lib/bidding/service'
+import { type BasicContractTemplate } from '@/db/schema'
+import {
+ getBasicInfo,
+ getContractItems,
+ getSubcontractChecklist,
+ uploadContractApprovalFile,
+ sendContractApprovalRequest,
+ getContractById,
+ getContractTemplateByContractType,
+ getStorageInfo
+} from '../service'
+import { mapContractDataToTemplateVariables } from '../utils'
+import { ApprovalPreviewDialog } from '@/lib/approval/client'
+import { requestContractApprovalWithApproval } from '../approval-actions'
+import { mapContractToApprovalTemplateVariables } from '../approval-template-variables'
+
+interface ContractApprovalRequestDialogProps {
+ contract: Record<string, unknown>
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+interface ContractSummary {
+ basicInfo: Record<string, unknown>
+ items: Record<string, unknown>[]
+ subcontractChecklist: Record<string, unknown> | null
+ storageInfo?: Record<string, unknown>[]
+ pdfPath?: string
+ basicContractPdfs?: Array<{ key: string; buffer: number[]; fileName: string }>
+}
+
+export function ContractApprovalRequestDialog({
+ contract,
+ open,
+ onOpenChange
+}: ContractApprovalRequestDialogProps) {
+ const { data: session } = useSession()
+ const [currentStep, setCurrentStep] = useState(1)
+ const [contractSummary, setContractSummary] = useState<ContractSummary | null>(null)
+ const [uploadedFile, setUploadedFile] = useState<File | null>(null)
+ const [generatedPdfUrl, setGeneratedPdfUrl] = useState<string | null>(null)
+ const [generatedPdfBuffer, setGeneratedPdfBuffer] = useState<Uint8Array | null>(null)
+ const [isLoading, setIsLoading] = useState(false)
+ const [pdfViewerInstance, setPdfViewerInstance] = useState<any>(null)
+ const [isPdfPreviewVisible, setIsPdfPreviewVisible] = useState(false)
+
+ // 기본계약 관련 상태
+ const [selectedBasicContracts, setSelectedBasicContracts] = useState<Array<{
+ type: string;
+ templateName: string;
+ checked: boolean;
+ }>>([])
+ const [isLoadingBasicContracts, setIsLoadingBasicContracts] = useState(false)
+
+ // 결재 관련 상태
+ const [approvalDialogOpen, setApprovalDialogOpen] = useState(false)
+ const [approvalVariables, setApprovalVariables] = useState<Record<string, string>>({})
+ const [savedPdfPath, setSavedPdfPath] = useState<string | null>(null)
+ const [savedBasicContractPdfs, setSavedBasicContractPdfs] = useState<Array<{ key: string; buffer: number[]; fileName: string }>>([])
+
+ const contractId = contract.id as number
+ const userId = session?.user?.id || ''
+
+
+ // 기본계약 생성 함수 (최종 전송 시점에 호출)
+ const generateBasicContractPdf = async (
+ vendorId: number,
+ contractType: string,
+ templateName: string
+ ): Promise<{ buffer: number[], fileName: string }> => {
+ try {
+ // 1. 템플릿 데이터 준비 (서버 액션 호출)
+ const prepareResponse = await fetch("/api/contracts/prepare-template", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ templateName,
+ vendorId,
+ }),
+ });
+
+ if (!prepareResponse.ok) {
+ const errorText = await prepareResponse.text();
+ throw new Error(`템플릿 준비 실패 (${prepareResponse.status}): ${errorText}`);
+ }
+
+ const { template, templateData } = await prepareResponse.json();
+
+ // 2. 템플릿 파일 다운로드
+ const templateResponse = await fetch("/api/contracts/get-template", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ templatePath: template.filePath }),
+ });
+
+ const templateBlob = await templateResponse.blob();
+ const templateFile = new window.File([templateBlob], "template.docx", {
+ type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
+ });
+
+ // 3. PDFTron WebViewer로 PDF 변환
+ const { default: WebViewer } = await import("@pdftron/webviewer");
+
+ const tempDiv = document.createElement('div');
+ tempDiv.style.display = 'none';
+ document.body.appendChild(tempDiv);
+
+ try {
+ const instance = await WebViewer(
+ {
+ path: "/pdftronWeb",
+ licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
+ fullAPI: true,
+ enableOfficeEditing: true,
+ },
+ tempDiv
+ );
+
+ const { Core } = instance;
+ const { createDocument } = Core;
+
+ const templateDoc = await createDocument(templateFile, {
+ filename: templateFile.name,
+ extension: 'docx',
+ });
+
+ // 변수 치환 적용
+ await templateDoc.applyTemplateValues(templateData);
+ await new Promise(resolve => setTimeout(resolve, 3000));
+
+ const fileData = await templateDoc.getFileData();
+ const pdfBuffer = await (Core as any).officeToPDFBuffer(fileData, { extension: 'docx' });
+
+ const fileName = `${contractType}_${contractSummary?.basicInfo?.vendorCode || vendorId}_${Date.now()}.pdf`;
+
+ instance.UI.dispose();
+ return {
+ buffer: Array.from(pdfBuffer),
+ fileName
+ };
+
+ } finally {
+ if (tempDiv.parentNode) {
+ document.body.removeChild(tempDiv);
+ }
+ }
+
+ } catch (error) {
+ console.error(`기본계약 PDF 생성 실패 (${contractType}):`, error);
+ throw error;
+ }
+ };
+
+ // 기본계약 생성 및 선택 초기화
+ const initializeBasicContracts = React.useCallback(async () => {
+ if (!contractSummary?.basicInfo) return;
+
+ setIsLoadingBasicContracts(true);
+ try {
+ // 기본적으로 사용할 수 있는 계약서 타입들
+ const availableContracts: Array<{
+ type: string;
+ templateName: string;
+ checked: boolean;
+ }> = [
+ { type: "NDA", templateName: "비밀", checked: false },
+ { type: "General_GTC", templateName: "General GTC", checked: false },
+ { type: "기술자료", templateName: "기술", checked: false }
+ ];
+
+ // 프로젝트 코드가 있으면 Project GTC도 추가
+ if (contractSummary.basicInfo.projectCode) {
+ availableContracts.push({
+ type: "Project_GTC",
+ templateName: contractSummary.basicInfo.projectCode as string,
+ checked: false
+ });
+ }
+
+ setSelectedBasicContracts(availableContracts);
+ } catch (error) {
+ console.error('기본계약 초기화 실패:', error);
+ toast.error('기본계약 초기화에 실패했습니다.');
+ } finally {
+ setIsLoadingBasicContracts(false);
+ }
+ }, [contractSummary]);
+
+ // 기본계약 선택 토글
+ const toggleBasicContract = (type: string) => {
+ setSelectedBasicContracts(prev =>
+ prev.map(contract =>
+ contract.type === type
+ ? { ...contract, checked: !contract.checked }
+ : contract
+ )
+ );
+ };
+
+
+ // 1단계: 계약 현황 수집
+ const collectContractSummary = React.useCallback(async () => {
+ setIsLoading(true)
+ try {
+ // 각 컴포넌트에서 활성화된 데이터만 수집
+ const summary: ContractSummary = {
+ basicInfo: {},
+ items: [],
+ subcontractChecklist: null
+ }
+
+ // Basic Info 확인 (항상 활성화)
+ try {
+ const basicInfoData = await getBasicInfo(contractId)
+ if (basicInfoData && basicInfoData.success) {
+ summary.basicInfo = basicInfoData.data || {}
+ }
+ // externalYardEntry 정보도 추가로 가져오기
+ const contractData = await getContractById(contractId)
+ if (contractData) {
+ summary.basicInfo = {
+ ...summary.basicInfo,
+ externalYardEntry: contractData.externalYardEntry || 'N'
+ }
+ }
+ } catch {
+ console.log('Basic Info 데이터 없음')
+ }
+
+ // 품목 정보 확인
+ try {
+ const itemsData = await getContractItems(contractId)
+ if (itemsData && itemsData.length > 0) {
+ summary.items = itemsData
+ }
+ } catch {
+ console.log('품목 정보 데이터 없음')
+ }
+
+ try {
+ // Subcontract Checklist 확인
+ const subcontractData = await getSubcontractChecklist(contractId)
+ if (subcontractData && subcontractData.success && subcontractData.enabled) {
+ summary.subcontractChecklist = subcontractData.data
+ }
+ } 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)
+ setContractSummary(summary)
+ } catch (error) {
+ console.error('Error collecting contract summary:', error)
+ toast.error('계약 정보를 수집하는 중 오류가 발생했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }, [contractId])
+
+ // 3단계: PDF 생성 및 미리보기 (PDFTron 사용) - 템플릿 자동 로드
+ const generatePdf = async () => {
+ if (!contractSummary) {
+ toast.error('계약 정보가 필요합니다.')
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ // 1. 계약 유형에 맞는 템플릿 조회
+ const contractType = contractSummary.basicInfo.contractType as string
+ const templateResult = await getContractTemplateByContractType(contractType)
+
+ if (!templateResult.success || !templateResult.template) {
+ throw new Error(templateResult.error || '템플릿을 찾을 수 없습니다.')
+ }
+
+ const template = templateResult.template
+
+ // 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에 추가하지 않음)
+ const tempDiv = document.createElement('div')
+ tempDiv.style.display = 'none'
+ document.body.appendChild(tempDiv)
+
+ const instance = await WebViewer(
+ {
+ path: "/pdftronWeb",
+ licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
+ fullAPI: true,
+ },
+ tempDiv
+ )
+
+ try {
+ const { Core } = instance
+ const { createDocument } = Core
+
+ // 템플릿 문서 생성 및 변수 치환
+ const templateDoc = await createDocument(templateFile, {
+ filename: templateFile.name,
+ extension: 'docx',
+ })
+
+ // 템플릿 변수 매핑
+ const mappedTemplateData = mapContractDataToTemplateVariables(contractSummary)
+
+ console.log("🔄 변수 치환 시작:", mappedTemplateData)
+ await templateDoc.applyTemplateValues(mappedTemplateData as any)
+ console.log("✅ 변수 치환 완료")
+
+ // PDF 변환
+ const fileData = await templateDoc.getFileData()
+ const pdfBuffer = await (Core as any).officeToPDFBuffer(fileData, { extension: 'docx' })
+
+ 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가 생성되었습니다.')
+
+ } finally {
+ // 임시 WebViewer 정리
+ instance.UI.dispose()
+ document.body.removeChild(tempDiv)
+ }
+
+ } catch (error: any) {
+ console.error('❌ PDF 생성 실패:', error)
+ const errorMessage = error instanceof Error ? error.message : (error?.message || '알 수 없는 오류')
+ toast.error(`PDF 생성 중 오류가 발생했습니다: ${errorMessage}`)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // PDF 미리보기 기능
+ const openPdfPreview = async () => {
+ if (!generatedPdfBuffer) {
+ toast.error('생성된 PDF가 없습니다.')
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ // @ts-ignore
+ const WebViewer = await import("@pdftron/webviewer").then(({ default: WebViewer }) => WebViewer)
+
+ // 기존 인스턴스가 있다면 정리
+ if (pdfViewerInstance) {
+ console.log("🔄 기존 WebViewer 인스턴스 정리")
+ try {
+ pdfViewerInstance.UI.dispose()
+ } catch (error) {
+ console.warn('기존 WebViewer 정리 중 오류:', error)
+ }
+ setPdfViewerInstance(null)
+ }
+
+ // 미리보기용 컨테이너 확인
+ let previewDiv = document.getElementById('pdf-preview-container')
+ if (!previewDiv) {
+ console.log("🔄 컨테이너 생성")
+ previewDiv = document.createElement('div')
+ previewDiv.id = 'pdf-preview-container'
+ previewDiv.className = 'w-full h-full'
+ previewDiv.style.width = '100%'
+ previewDiv.style.height = '100%'
+
+ // 실제 컨테이너에 추가
+ const actualContainer = document.querySelector('[data-pdf-container]')
+ if (actualContainer) {
+ actualContainer.appendChild(previewDiv)
+ }
+ }
+
+ console.log("🔄 WebViewer 인스턴스 생성 시작")
+
+ // WebViewer 인스턴스 생성 (문서 없이)
+ const instance = await Promise.race([
+ WebViewer(
+ {
+ path: "/pdftronWeb",
+ licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
+ fullAPI: true,
+ },
+ previewDiv
+ ),
+ new Promise((_, reject) =>
+ setTimeout(() => reject(new Error('WebViewer 초기화 타임아웃')), 30000)
+ )
+ ])
+
+ console.log("🔄 WebViewer 인스턴스 생성 완료")
+ setPdfViewerInstance(instance)
+
+ // PDF 버퍼를 Blob으로 변환
+ 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 as any).Core
+
+ // 문서 로드 이벤트 대기
+ await new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ reject(new Error('문서 로드 타임아웃'))
+ }, 20000)
+
+ const onDocumentLoaded = () => {
+ clearTimeout(timeout)
+ documentViewer.removeEventListener('documentLoaded', onDocumentLoaded)
+ documentViewer.removeEventListener('documentError', onDocumentError)
+ console.log("🔄 문서 로드 완료")
+ resolve(true)
+ }
+
+ const onDocumentError = (error: any) => {
+ clearTimeout(timeout)
+ documentViewer.removeEventListener('documentLoaded', onDocumentLoaded)
+ documentViewer.removeEventListener('documentError', onDocumentError)
+ console.error('문서 로드 오류:', error)
+ reject(error)
+ }
+
+ documentViewer.addEventListener('documentLoaded', onDocumentLoaded)
+ documentViewer.addEventListener('documentError', onDocumentError)
+
+ // 문서 로드 시작
+ documentViewer.loadDocument(pdfUrl)
+ })
+
+ setIsPdfPreviewVisible(true)
+ toast.success('PDF 미리보기가 준비되었습니다.')
+
+ } catch (error) {
+ console.error('PDF 미리보기 실패:', error)
+ toast.error(`PDF 미리보기 중 오류가 발생했습니다: ${error.message}`)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // PDF 다운로드 기능
+ const downloadPdf = () => {
+ if (!generatedPdfBuffer) {
+ toast.error('다운로드할 PDF가 없습니다.')
+ return
+ }
+
+ const pdfBlob = new Blob([generatedPdfBuffer as any], { type: 'application/pdf' })
+ const pdfUrl = URL.createObjectURL(pdfBlob)
+
+ const link = document.createElement('a')
+ link.href = pdfUrl
+ link.download = `contract_${contractId}_${Date.now()}.pdf`
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
+
+ URL.revokeObjectURL(pdfUrl)
+ toast.success('PDF가 다운로드되었습니다.')
+ }
+
+ // PDF 미리보기 닫기
+ const closePdfPreview = () => {
+ console.log("🔄 PDF 미리보기 닫기 시작")
+ if (pdfViewerInstance) {
+ try {
+ console.log("🔄 WebViewer 인스턴스 정리")
+ pdfViewerInstance.UI.dispose()
+ } catch (error) {
+ console.warn('WebViewer 정리 중 오류:', error)
+ }
+ setPdfViewerInstance(null)
+ }
+
+ // 컨테이너 정리
+ const previewDiv = document.getElementById('pdf-preview-container')
+ if (previewDiv) {
+ try {
+ previewDiv.innerHTML = ''
+ } catch (error) {
+ console.warn('컨테이너 정리 중 오류:', error)
+ }
+ }
+
+ setIsPdfPreviewVisible(false)
+ console.log("🔄 PDF 미리보기 닫기 완료")
+ }
+
+ // PDF를 서버에 저장하는 함수 (API route 사용)
+ const savePdfToServer = async (pdfBuffer: Uint8Array, fileName: string): Promise<string | null> => {
+ try {
+ // PDF 버퍼를 Blob으로 변환
+ const pdfBlob = new Blob([pdfBuffer], { type: 'application/pdf' });
+
+ // FormData 생성
+ const formData = new FormData();
+ formData.append('file', pdfBlob, fileName);
+ formData.append('contractId', String(contractId));
+
+ // API route로 업로드
+ const response = await fetch('/api/general-contracts/upload-pdf', {
+ method: 'POST',
+ body: formData,
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || 'PDF 파일 저장에 실패했습니다.');
+ }
+
+ const result = await response.json();
+
+ if (!result.success) {
+ throw new Error(result.error || 'PDF 파일 저장에 실패했습니다.');
+ }
+
+ return result.filePath;
+ } catch (error) {
+ console.error('PDF 저장 실패:', error);
+ return null;
+ }
+ };
+
+ // 최종 전송 - 결재 프로세스 시작
+ const handleFinalSubmit = async () => {
+ if (!generatedPdfUrl || !contractSummary || !generatedPdfBuffer) {
+ toast.error('생성된 PDF가 필요합니다.')
+ return
+ }
+
+ if (!userId) {
+ toast.error('로그인이 필요합니다.')
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ // 기본계약서 생성 (최종 전송 시점에)
+ let generatedBasicContractPdfs: Array<{ key: string; buffer: number[]; fileName: string }> = [];
+
+ const contractsToGenerate = selectedBasicContracts.filter(c => c.checked);
+ if (contractsToGenerate.length > 0) {
+ // vendorId 조회
+ let vendorId: number | undefined;
+ try {
+ const basicInfoData = await getBasicInfo(contractId);
+ if (basicInfoData && basicInfoData.success && basicInfoData.data) {
+ vendorId = basicInfoData.data.vendorId;
+ }
+ } catch (error) {
+ console.error('vendorId 조회 실패:', error);
+ }
+
+ if (vendorId) {
+ toast.info('기본계약서를 생성하는 중입니다...');
+
+ for (const contract of contractsToGenerate) {
+ try {
+ const pdf = await generateBasicContractPdf(vendorId, contract.type, contract.templateName);
+ generatedBasicContractPdfs.push({
+ key: `${vendorId}_${contract.type}_${contract.templateName}`,
+ ...pdf
+ });
+ } catch (error) {
+ console.error(`${contract.type} 계약서 생성 실패:`, error);
+ // 개별 실패는 전체를 중단하지 않음
+ }
+ }
+
+ if (generatedBasicContractPdfs.length > 0) {
+ toast.success(`${generatedBasicContractPdfs.length}개의 기본계약서가 생성되었습니다.`);
+ }
+ }
+ }
+
+ // PDF를 서버에 저장
+ toast.info('PDF를 서버에 저장하는 중입니다...');
+ const pdfPath = await savePdfToServer(
+ generatedPdfBuffer,
+ `contract_${contractId}_${Date.now()}.pdf`
+ );
+
+ if (!pdfPath) {
+ toast.error('PDF 저장에 실패했습니다.');
+ return;
+ }
+
+ setSavedPdfPath(pdfPath);
+ setSavedBasicContractPdfs(generatedBasicContractPdfs);
+
+ // 결재 템플릿 변수 매핑
+ const approvalVars = await mapContractToApprovalTemplateVariables(contractSummary);
+ setApprovalVariables(approvalVars);
+
+ // 계약승인요청 dialog close
+ onOpenChange(false);
+
+ // 결재 템플릿 dialog open
+ setApprovalDialogOpen(true);
+ } catch (error: any) {
+ console.error('Error preparing approval:', error);
+ toast.error('결재 준비 중 오류가 발생했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // 결재 등록 처리
+ const handleApprovalSubmit = async (data: {
+ approvers: string[];
+ title: string;
+ attachments?: File[];
+ }) => {
+ if (!contractSummary || !savedPdfPath) {
+ toast.error('계약 정보가 필요합니다.')
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ const result = await requestContractApprovalWithApproval({
+ contractId,
+ contractSummary: {
+ ...contractSummary,
+ // PDF 경로를 contractSummary에 추가
+ pdfPath: savedPdfPath || undefined,
+ basicContractPdfs: savedBasicContractPdfs.length > 0 ? savedBasicContractPdfs : undefined,
+ } as ContractSummary,
+ currentUser: {
+ id: Number(userId),
+ epId: session?.user?.epId || null,
+ email: session?.user?.email || undefined,
+ },
+ approvers: data.approvers,
+ title: data.title,
+ });
+
+ if (result.status === 'pending_approval') {
+ toast.success('결재가 등록되었습니다.')
+ setApprovalDialogOpen(false);
+ } else {
+ toast.error('결재 등록에 실패했습니다.')
+ }
+ } catch (error: any) {
+ console.error('Error submitting approval:', error);
+ toast.error(`결재 등록 중 오류가 발생했습니다: ${error.message || '알 수 없는 오류'}`);
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // 다이얼로그가 열릴 때 1단계 데이터 수집
+ useEffect(() => {
+ if (open && currentStep === 1) {
+ collectContractSummary()
+ }
+ }, [open, currentStep, collectContractSummary])
+
+ // 계약 요약이 준비되면 기본계약 초기화
+ useEffect(() => {
+ if (contractSummary && currentStep === 2) {
+ const loadBasicContracts = async () => {
+ await initializeBasicContracts()
+ }
+ loadBasicContracts()
+ }
+ }, [contractSummary, currentStep, initializeBasicContracts])
+
+ // 다이얼로그가 닫힐 때 PDF 뷰어 정리
+ useEffect(() => {
+ if (!open) {
+ closePdfPreview()
+ }
+ }, [open])
+
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <FileText className="h-5 w-5" />
+ 계약승인요청
+ </DialogTitle>
+ </DialogHeader>
+
+ <Tabs value={currentStep.toString()} className="w-full">
+ <TabsList className="grid w-full grid-cols-3">
+ <TabsTrigger value="1" disabled={currentStep < 1}>
+ 1. 계약 현황 정리
+ </TabsTrigger>
+ <TabsTrigger value="2" disabled={currentStep < 2}>
+ 2. 기본계약 체크
+ </TabsTrigger>
+ <TabsTrigger value="3" disabled={currentStep < 3}>
+ 3. PDF 미리보기
+ </TabsTrigger>
+ </TabsList>
+
+ {/* 1단계: 계약 현황 정리 */}
+ <TabsContent value="1" className="space-y-4">
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <CheckCircle className="h-5 w-5 text-green-600" />
+ 작성된 계약 현황
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {isLoading ? (
+ <div className="text-center py-4">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
+ <p className="mt-2 text-sm text-muted-foreground">계약 정보를 수집하는 중...</p>
+ </div>
+ ) : (
+ <div className="space-y-4">
+ {/* 기본 정보 (필수) */}
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center gap-2 mb-3">
+ <CheckCircle className="h-4 w-4 text-green-600" />
+ <Label className="font-medium">기본 정보</Label>
+ <Badge variant="secondary">필수</Badge>
+ </div>
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div>
+ <span className="font-medium">계약번호:</span> {String(contractSummary?.basicInfo?.contractNumber || '')}
+ </div>
+ <div>
+ <span className="font-medium">계약명:</span> {String(contractSummary?.basicInfo?.contractName || '')}
+ </div>
+ <div>
+ <span className="font-medium">벤더:</span> {String(contractSummary?.basicInfo?.vendorName || '')}
+ </div>
+ <div>
+ <span className="font-medium">프로젝트:</span> {String(contractSummary?.basicInfo?.projectName || '')}
+ </div>
+ <div>
+ <span className="font-medium">계약유형:</span> {String(contractSummary?.basicInfo?.contractType || '')}
+ </div>
+ <div>
+ <span className="font-medium">계약상태:</span> {String(contractSummary?.basicInfo?.contractStatus || '')}
+ </div>
+ <div>
+ <span className="font-medium">계약금액:</span> {String(contractSummary?.basicInfo?.contractAmount || '')} {String(contractSummary?.basicInfo?.currency || '')}
+ </div>
+ <div>
+ <span className="font-medium">계약기간:</span> {String(contractSummary?.basicInfo?.startDate || '')} ~ {String(contractSummary?.basicInfo?.endDate || '')}
+ </div>
+ <div>
+ <span className="font-medium">사양서 유형:</span> {String(contractSummary?.basicInfo?.specificationType || '')}
+ </div>
+ <div>
+ <span className="font-medium">단가 유형:</span> {String(contractSummary?.basicInfo?.unitPriceType || '')}
+ </div>
+ <div>
+ <span className="font-medium">연결 PO번호:</span> {String(contractSummary?.basicInfo?.linkedPoNumber || '')}
+ </div>
+ <div>
+ <span className="font-medium">연결 입찰번호:</span> {String(contractSummary?.basicInfo?.linkedBidNumber || '')}
+ </div>
+ </div>
+ </div>
+
+ {/* 지급/인도 조건 */}
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center gap-2 mb-3">
+ <CheckCircle className="h-4 w-4 text-green-600" />
+ <Label className="font-medium">지급/인도 조건</Label>
+ <Badge variant="secondary">필수</Badge>
+ </div>
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div>
+ <span className="font-medium">지급조건:</span> {String(contractSummary?.basicInfo?.paymentTerm || '')}
+ </div>
+ <div>
+ <span className="font-medium">세금 유형:</span> {String(contractSummary?.basicInfo?.taxType || '')}
+ </div>
+ <div>
+ <span className="font-medium">인도조건:</span> {String(contractSummary?.basicInfo?.deliveryTerm || '')}
+ </div>
+ <div>
+ <span className="font-medium">인도유형:</span> {String(contractSummary?.basicInfo?.deliveryType || '')}
+ </div>
+ <div>
+ <span className="font-medium">선적지:</span> {String(contractSummary?.basicInfo?.shippingLocation || '')}
+ </div>
+ <div>
+ <span className="font-medium">하역지:</span> {String(contractSummary?.basicInfo?.dischargeLocation || '')}
+ </div>
+ <div>
+ <span className="font-medium">계약납기:</span> {String(contractSummary?.basicInfo?.contractDeliveryDate || '')}
+ </div>
+ <div>
+ <span className="font-medium">위약금:</span> {contractSummary?.basicInfo?.liquidatedDamages ? '적용' : '미적용'}
+ </div>
+ </div>
+ </div>
+
+ {/* 추가 조건 */}
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center gap-2 mb-3">
+ <CheckCircle className="h-4 w-4 text-green-600" />
+ <Label className="font-medium">추가 조건</Label>
+ <Badge variant="secondary">필수</Badge>
+ </div>
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div>
+ <span className="font-medium">연동제 정보:</span> {String(contractSummary?.basicInfo?.interlockingSystem || '')}
+ </div>
+ <div>
+ <span className="font-medium">계약성립조건:</span>
+ {contractSummary?.basicInfo?.contractEstablishmentConditions ? (() => {
+ const conditions = Object.entries(contractSummary.basicInfo.contractEstablishmentConditions as Record<string, boolean>)
+ .filter(([, value]) => value === true)
+ .map(([key]) => {
+ const conditionMap: Record<string, string> = {
+ 'ownerApproval': '정규업체 등록(실사 포함) 시',
+ 'regularVendorRegistration': '프로젝트 수주 시',
+ 'shipOwnerApproval': '선주 승인 시',
+ 'other': '기타'
+ };
+ return conditionMap[key] || key;
+ });
+ return conditions.length > 0 ? conditions.join(', ') : '없음';
+ })() : '없음'}
+ </div>
+ <div>
+ <span className="font-medium">계약해지조건:</span>
+ {contractSummary?.basicInfo?.contractTerminationConditions ? (() => {
+ const conditions = Object.entries(contractSummary.basicInfo.contractTerminationConditions as Record<string, boolean>)
+ .filter(([, value]) => value === true)
+ .map(([key]) => {
+ const conditionMap: Record<string, string> = {
+ 'standardTermination': '표준 계약해지조건',
+ 'projectNotAwarded': '프로젝트 미수주 시',
+ 'other': '기타'
+ };
+ return conditionMap[key] || key;
+ });
+ return conditions.length > 0 ? conditions.join(', ') : '없음';
+ })() : '없음'}
+ </div>
+ </div>
+ </div>
+
+ {/* 품목 정보 */}
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center gap-2 mb-3">
+ <Checkbox
+ id="items-enabled"
+ checked={contractSummary?.items && contractSummary.items.length > 0}
+ disabled
+ />
+ <Label htmlFor="items-enabled" className="font-medium">품목 정보</Label>
+ <Badge variant="outline">선택</Badge>
+ </div>
+ {contractSummary?.items && contractSummary.items.length > 0 ? (
+ <div className="space-y-2">
+ <p className="text-sm text-muted-foreground">
+ 총 {contractSummary.items.length}개 품목이 입력되어 있습니다.
+ </p>
+ <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">{String(item.itemInfo || item.description || `품목 ${index + 1}`)}</div>
+ <div className="text-muted-foreground">
+ 수량: {String(item.quantity || 0)} | 단가: {String(item.contractUnitPrice || item.unitPrice || 0)}
+ </div>
+ </div>
+ ))}
+ {contractSummary.items.length > 3 && (
+ <div className="text-xs text-muted-foreground text-center">
+ ... 외 {contractSummary.items.length - 3}개 품목
+ </div>
+ )}
+ </div>
+ </div>
+ ) : (
+ <p className="text-sm text-muted-foreground">
+ 품목 정보가 입력되지 않았습니다.
+ </p>
+ )}
+ </div>
+
+ {/* 하도급 체크리스트 */}
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center gap-2 mb-3">
+ <Checkbox
+ id="subcontract-enabled"
+ checked={!!contractSummary?.subcontractChecklist}
+ disabled
+ />
+ <Label htmlFor="subcontract-enabled" className="font-medium">
+ 하도급 체크리스트
+ </Label>
+ <Badge variant="outline">선택</Badge>
+ </div>
+ <p className="text-sm text-muted-foreground">
+ {contractSummary?.subcontractChecklist
+ ? '정보가 입력되어 있습니다.'
+ : '정보가 입력되지 않았습니다.'}
+ </p>
+ </div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ <div className="flex justify-end">
+ <Button
+ onClick={() => setCurrentStep(2)}
+ disabled={isLoading}
+ >
+ 다음 단계
+ </Button>
+ </div>
+ </TabsContent>
+
+ {/* 2단계: 기본계약 체크 */}
+ <TabsContent value="2" className="space-y-4">
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <FileText className="h-5 w-5 text-blue-600" />
+ 기본계약서 선택
+ </CardTitle>
+ <p className="text-sm text-muted-foreground">
+ 벤더에게 발송할 기본계약서를 선택해주세요. (템플릿이 있는 계약서만 선택 가능합니다.)
+ </p>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {isLoadingBasicContracts ? (
+ <div className="text-center py-8">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
+ <p className="mt-2 text-sm text-muted-foreground">기본계약 템플릿을 불러오는 중...</p>
+ </div>
+ ) : (
+ <div className="space-y-4">
+ {selectedBasicContracts.length > 0 ? (
+ <div className="space-y-3">
+ <div className="flex items-center justify-between">
+ <h4 className="font-medium">필요한 기본계약서</h4>
+ <Badge variant="outline">
+ {selectedBasicContracts.filter(c => c.checked).length}개 선택됨
+ </Badge>
+ </div>
+
+ <div className="grid gap-3">
+ {selectedBasicContracts.map((contract) => (
+ <div
+ key={contract.type}
+ className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50"
+ >
+ <div className="flex items-center gap-3">
+ <Checkbox
+ id={`contract-${contract.type}`}
+ checked={contract.checked}
+ onCheckedChange={() => toggleBasicContract(contract.type)}
+ />
+ <div>
+ <Label
+ htmlFor={`contract-${contract.type}`}
+ className="font-medium cursor-pointer"
+ >
+ {contract.type}
+ </Label>
+ <p className="text-sm text-muted-foreground">
+ 템플릿: {contract.templateName}
+ </p>
+ </div>
+ </div>
+ <Badge
+ variant="secondary"
+ className="text-xs"
+ >
+ {contract.checked ? "선택됨" : "미선택"}
+ </Badge>
+ </div>
+ ))}
+ </div>
+
+ </div>
+ ) : (
+ <div className="text-center py-8 text-muted-foreground">
+ <FileText className="h-12 w-12 mx-auto mb-4 opacity-50" />
+ <p>기본계약서 목록을 불러올 수 없습니다.</p>
+ <p className="text-sm">잠시 후 다시 시도해주세요.</p>
+ </div>
+ )}
+
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ <div className="flex justify-between">
+ <Button variant="outline" onClick={() => setCurrentStep(1)}>
+ 이전 단계
+ </Button>
+ <Button
+ onClick={() => setCurrentStep(3)}
+ disabled={isLoadingBasicContracts}
+ >
+ 다음 단계
+ </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>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {!generatedPdfUrl ? (
+ <div className="text-center py-8">
+ <Button onClick={generatePdf} disabled={isLoading}>
+ {isLoading ? 'PDF 생성 중...' : 'PDF 생성하기'}
+ </Button>
+ </div>
+ ) : (
+ <div className="space-y-4">
+ <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">PDF 생성 완료</span>
+ </div>
+ </div>
+
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center justify-between mb-4">
+ <h4 className="font-medium">생성된 PDF</h4>
+ <div className="flex gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={downloadPdf}
+ disabled={isLoading}
+ >
+ <Download className="h-4 w-4 mr-2" />
+ 다운로드
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={openPdfPreview}
+ disabled={isLoading}
+ >
+ <Eye className="h-4 w-4 mr-2" />
+ 미리보기
+ </Button>
+ </div>
+ </div>
+
+ {/* PDF 미리보기 영역 */}
+ <div className="border rounded-lg h-96 bg-gray-50 relative" data-pdf-container>
+ {isPdfPreviewVisible ? (
+ <>
+ <div className="absolute top-2 right-2 z-10">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={closePdfPreview}
+ className="bg-white/90 hover:bg-white"
+ >
+ ✕ 닫기
+ </Button>
+ </div>
+ <div id="pdf-preview-container" className="w-full h-full" />
+ </>
+ ) : (
+ <div className="flex items-center justify-center h-full">
+ <div className="text-center text-muted-foreground">
+ <FileText className="h-12 w-12 mx-auto mb-2" />
+ <p>미리보기 버튼을 클릭하여 PDF를 확인하세요</p>
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ <div className="flex justify-between">
+ <Button variant="outline" onClick={() => setCurrentStep(2)}>
+ 이전 단계
+ </Button>
+ <Button
+ onClick={handleFinalSubmit}
+ disabled={!generatedPdfUrl || isLoading}
+ className="bg-green-600 hover:bg-green-700"
+ >
+ <Send className="h-4 w-4 mr-2" />
+ {isLoading ? '전송 중...' : '최종 전송'}
+ </Button>
+ </div>
+ </TabsContent>
+ </Tabs>
+ </DialogContent>
+
+ {/* 결재 미리보기 Dialog */}
+ {session?.user && session.user.epId && contractSummary && (
+ <ApprovalPreviewDialog
+ open={approvalDialogOpen}
+ onOpenChange={(open) => {
+ setApprovalDialogOpen(open);
+ if (!open) {
+ setApprovalVariables({});
+ setSavedPdfPath(null);
+ setSavedBasicContractPdfs([]);
+ }
+ }}
+ templateName="일반계약 결재"
+ variables={approvalVariables}
+ title={`계약 체결 진행 품의 요청서 - ${contractSummary.basicInfo?.contractNumber || contractId}`}
+ currentUser={{
+ id: Number(session.user.id),
+ epId: session.user.epId,
+ name: session.user.name || undefined,
+ email: session.user.email || undefined,
+ }}
+ onConfirm={handleApprovalSubmit}
+ enableAttachments={false}
+ />
+ )}
+ </Dialog>
)} \ No newline at end of file
diff --git a/lib/general-contracts/detail/general-contract-basic-info.tsx b/lib/general-contracts/detail/general-contract-basic-info.tsx
index b0378912..d7533d2e 100644
--- a/lib/general-contracts/detail/general-contract-basic-info.tsx
+++ b/lib/general-contracts/detail/general-contract-basic-info.tsx
@@ -8,7 +8,21 @@ import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button'
-import { Save, LoaderIcon } from 'lucide-react'
+import { Save, LoaderIcon, Check, ChevronsUpDown } from 'lucide-react'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover'
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from '@/components/ui/command'
+import { cn } from '@/lib/utils'
import { updateContractBasicInfo, getContractBasicInfo } from '../service'
import { toast } from 'sonner'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
@@ -140,19 +154,28 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
// paymentDelivery에서 퍼센트와 타입 분리
const paymentDeliveryValue = contractData?.paymentDelivery || ''
+ console.log(paymentDeliveryValue,"paymentDeliveryValue")
let paymentDeliveryType = ''
let paymentDeliveryPercentValue = ''
- if (paymentDeliveryValue.includes('%')) {
+ // "60일 이내" 또는 "추가조건"은 그대로 사용
+ if (paymentDeliveryValue === '납품완료일로부터 60일 이내 지급' || paymentDeliveryValue === '추가조건') {
+ paymentDeliveryType = paymentDeliveryValue
+ } else if (paymentDeliveryValue.includes('%')) {
+ // 퍼센트가 포함된 경우 (예: "10% L/C")
const match = paymentDeliveryValue.match(/(\d+)%\s*(.+)/)
if (match) {
paymentDeliveryPercentValue = match[1]
paymentDeliveryType = match[2]
+ } else {
+ paymentDeliveryType = paymentDeliveryValue
}
} else {
+ // 일반 지급조건 코드 (예: "P008")
paymentDeliveryType = paymentDeliveryValue
}
-
+ console.log(paymentDeliveryType,"paymentDeliveryType")
+ console.log(paymentDeliveryPercentValue,"paymentDeliveryPercentValue")
setPaymentDeliveryPercent(paymentDeliveryPercentValue)
// 합의계약(AD, AW)인 경우 인도조건 기본값 설정
@@ -309,6 +332,7 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
loadShippingPlaces();
loadDestinationPlaces();
}, [loadPaymentTerms, loadIncoterms, loadShippingPlaces, loadDestinationPlaces]);
+
const handleSaveContractInfo = async () => {
if (!userId) {
toast.error('사용자 정보를 찾을 수 없습니다.')
@@ -342,12 +366,29 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
return
}
- // paymentDelivery와 paymentDeliveryPercent 합쳐서 저장
+ // paymentDelivery 저장 로직
+ // 1. "60일 이내" 또는 "추가조건"은 그대로 저장
+ // 2. L/C 또는 T/T이고 퍼센트가 있으면 "퍼센트% 코드" 형식으로 저장
+ // 3. 그 외의 경우는 그대로 저장
+ let paymentDeliveryToSave = formData.paymentDelivery
+
+ if (
+ formData.paymentDelivery !== '납품완료일로부터 60일 이내 지급' &&
+ formData.paymentDelivery !== '추가조건' &&
+ (formData.paymentDelivery === 'L/C' || formData.paymentDelivery === 'T/T') &&
+ paymentDeliveryPercent
+ ) {
+ paymentDeliveryToSave = `${paymentDeliveryPercent}% ${formData.paymentDelivery}`
+ }
+ console.log(paymentDeliveryToSave,"paymentDeliveryToSave")
+
const dataToSave = {
...formData,
- paymentDelivery: (formData.paymentDelivery === 'L/C' || formData.paymentDelivery === 'T/T') && paymentDeliveryPercent
- ? `${paymentDeliveryPercent}% ${formData.paymentDelivery}`
- : formData.paymentDelivery
+ paymentDelivery: paymentDeliveryToSave,
+ // 추가조건 선택 시에만 추가 텍스트 저장, 그 외에는 빈 문자열 또는 undefined
+ paymentDeliveryAdditionalText: formData.paymentDelivery === '추가조건'
+ ? (formData.paymentDeliveryAdditionalText || '')
+ : ''
}
await updateContractBasicInfo(contractId, dataToSave, userId as number)
@@ -1026,20 +1067,100 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
<div className="space-y-2">
<div className="space-y-1">
<Label htmlFor="paymentDelivery" className="text-xs">지급조건 *</Label>
- <Select value={formData.paymentDelivery} onValueChange={(value) => setFormData(prev => ({ ...prev, paymentDelivery: value }))}>
- <SelectTrigger className={`h-8 text-xs ${errors.paymentDelivery ? 'border-red-500' : ''}`}>
- <SelectValue placeholder="선택" />
- </SelectTrigger>
- <SelectContent>
- {paymentTermsOptions.map((term) => (
- <SelectItem key={term.code} value={term.code} className="text-xs">
- {term.code}
- </SelectItem>
- ))}
- <SelectItem value="납품완료일로부터 60일 이내 지급" className="text-xs">60일 이내</SelectItem>
- <SelectItem value="추가조건" className="text-xs">추가조건</SelectItem>
- </SelectContent>
- </Select>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "w-full justify-between h-8 text-xs",
+ !formData.paymentDelivery && "text-muted-foreground",
+ errors.paymentDelivery && "border-red-500"
+ )}
+ >
+ {formData.paymentDelivery
+ ? (() => {
+ // 1. paymentTermsOptions에서 찾기
+ const foundOption = paymentTermsOptions.find((option) => option.code === formData.paymentDelivery)
+ if (foundOption) {
+ return `${foundOption.code} ${foundOption.description ? `(${foundOption.description})` : ''}`
+ }
+ // 2. 특수 케이스 처리
+ if (formData.paymentDelivery === '납품완료일로부터 60일 이내 지급') {
+ return '60일 이내'
+ }
+ if (formData.paymentDelivery === '추가조건') {
+ return '추가조건'
+ }
+ // 3. 그 외의 경우 원본 값 표시 (로드된 값이지만 옵션에 없는 경우)
+ return formData.paymentDelivery
+ })()
+ : "지급조건 선택"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="지급조건 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {paymentTermsOptions.map((option) => (
+ <CommandItem
+ key={option.code}
+ value={`${option.code} ${option.description || ''}`}
+ onSelect={() => {
+ setFormData(prev => ({ ...prev, paymentDelivery: option.code }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ option.code === formData.paymentDelivery
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {option.code} {option.description && `(${option.description})`}
+ </CommandItem>
+ ))}
+ <CommandItem
+ value="납품완료일로부터 60일 이내 지급"
+ onSelect={() => {
+ setFormData(prev => ({ ...prev, paymentDelivery: '납품완료일로부터 60일 이내 지급' }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ formData.paymentDelivery === '납품완료일로부터 60일 이내 지급'
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ 60일 이내
+ </CommandItem>
+ <CommandItem
+ value="추가조건"
+ onSelect={() => {
+ setFormData(prev => ({ ...prev, paymentDelivery: '추가조건' }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ formData.paymentDelivery === '추가조건'
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ 추가조건
+ </CommandItem>
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
{formData.paymentDelivery === '추가조건' && (
<Input
type="text"
@@ -1152,53 +1273,59 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
</div>
</div>
- {/* 지불조건 -> 세금조건 (지불조건 삭제됨) */}
+ {/*세금조건*/}
<div className="space-y-2">
<Label className="text-sm font-medium">세금조건</Label>
<div className="space-y-2">
- {/* 지불조건 필드 삭제됨
- <div className="space-y-1">
- <Label htmlFor="paymentTerm" className="text-xs">지불조건 *</Label>
- <Select
- value={formData.paymentTerm}
- onValueChange={(value) => setFormData(prev => ({ ...prev, paymentTerm: value }))}
- >
- <SelectTrigger className={`h-8 text-xs ${errors.paymentTerm ? 'border-red-500' : ''}`}>
- <SelectValue placeholder="선택" />
- </SelectTrigger>
- <SelectContent>
- {paymentTermsOptions.length > 0 ? (
- paymentTermsOptions.map((option) => (
- <SelectItem key={option.code} value={option.code} className="text-xs">
- {option.code}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled className="text-xs">
- 로딩중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
- </div>
- */}
<div className="space-y-1">
<Label htmlFor="taxType" className="text-xs">세금조건 *</Label>
- <Select
- value={formData.taxType}
- onValueChange={(value) => setFormData(prev => ({ ...prev, taxType: value }))}
- >
- <SelectTrigger className={`h-8 text-xs ${errors.taxType ? 'border-red-500' : ''}`}>
- <SelectValue placeholder="선택" />
- </SelectTrigger>
- <SelectContent>
- {TAX_CONDITIONS.map((condition) => (
- <SelectItem key={condition.code} value={condition.code} className="text-xs">
- {condition.name}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "w-full justify-between h-8 text-xs",
+ !formData.taxType && "text-muted-foreground",
+ errors.taxType && "border-red-500"
+ )}
+ >
+ {formData.taxType
+ ? TAX_CONDITIONS.find((condition) => condition.code === formData.taxType)?.name
+ : "세금조건 선택"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="세금조건 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {TAX_CONDITIONS.map((condition) => (
+ <CommandItem
+ key={condition.code}
+ value={`${condition.code} ${condition.name}`}
+ onSelect={() => {
+ setFormData(prev => ({ ...prev, taxType: condition.code }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ condition.code === formData.taxType
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {condition.name}
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
</div>
</div>
</div>
@@ -1266,79 +1393,178 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
{/* 인도조건 */}
<div className="space-y-2">
<Label htmlFor="deliveryTerm" className="text-xs">인도조건</Label>
- <Select
- value={formData.deliveryTerm}
- onValueChange={(value) => setFormData(prev => ({ ...prev, deliveryTerm: value }))}
- >
- <SelectTrigger className="h-8 text-xs">
- <SelectValue placeholder="선택" />
- </SelectTrigger>
- <SelectContent>
- {incotermsOptions.length > 0 ? (
- incotermsOptions.map((option) => (
- <SelectItem key={option.code} value={option.code} className="text-xs">
- {option.code}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled className="text-xs">
- 로딩중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "w-full justify-between h-8 text-xs",
+ !formData.deliveryTerm && "text-muted-foreground"
+ )}
+ >
+ {formData.deliveryTerm
+ ? incotermsOptions.find((option) => option.code === formData.deliveryTerm)
+ ? `${incotermsOptions.find((option) => option.code === formData.deliveryTerm)?.code} ${incotermsOptions.find((option) => option.code === formData.deliveryTerm)?.description ? `(${incotermsOptions.find((option) => option.code === formData.deliveryTerm)?.description})` : ''}`
+ : formData.deliveryTerm
+ : "인코텀즈 선택"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="인코텀즈 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {incotermsOptions.length > 0 ? (
+ incotermsOptions.map((option) => (
+ <CommandItem
+ key={option.code}
+ value={`${option.code} ${option.description || ''}`}
+ onSelect={() => {
+ setFormData(prev => ({ ...prev, deliveryTerm: option.code }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ option.code === formData.deliveryTerm
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {option.code} {option.description && `(${option.description})`}
+ </CommandItem>
+ ))
+ ) : (
+ <CommandItem value="loading" disabled>
+ 로딩중...
+ </CommandItem>
+ )}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
</div>
{/* 선적지 */}
<div className="space-y-2">
<Label htmlFor="shippingLocation" className="text-xs">선적지</Label>
- <Select
- value={formData.shippingLocation}
- onValueChange={(value) => setFormData(prev => ({ ...prev, shippingLocation: value }))}
- >
- <SelectTrigger className="h-8 text-xs">
- <SelectValue placeholder="선택" />
- </SelectTrigger>
- <SelectContent>
- {shippingPlaces.length > 0 ? (
- shippingPlaces.map((place) => (
- <SelectItem key={place.code} value={place.code} className="text-xs">
- {place.code}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled className="text-xs">
- 로딩중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "w-full justify-between h-8 text-xs",
+ !formData.shippingLocation && "text-muted-foreground"
+ )}
+ >
+ {formData.shippingLocation
+ ? shippingPlaces.find((place) => place.code === formData.shippingLocation)
+ ? `${shippingPlaces.find((place) => place.code === formData.shippingLocation)?.code} ${shippingPlaces.find((place) => place.code === formData.shippingLocation)?.description ? `(${shippingPlaces.find((place) => place.code === formData.shippingLocation)?.description})` : ''}`
+ : formData.shippingLocation
+ : "선적지 선택"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="선적지 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {shippingPlaces.length > 0 ? (
+ shippingPlaces.map((place) => (
+ <CommandItem
+ key={place.code}
+ value={`${place.code} ${place.description || ''}`}
+ onSelect={() => {
+ setFormData(prev => ({ ...prev, shippingLocation: place.code }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ place.code === formData.shippingLocation
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {place.code} {place.description && `(${place.description})`}
+ </CommandItem>
+ ))
+ ) : (
+ <CommandItem value="loading" disabled>
+ 로딩중...
+ </CommandItem>
+ )}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
</div>
{/* 하역지 */}
<div className="space-y-2">
<Label htmlFor="dischargeLocation" className="text-xs">하역지</Label>
- <Select
- value={formData.dischargeLocation}
- onValueChange={(value) => setFormData(prev => ({ ...prev, dischargeLocation: value }))}
- >
- <SelectTrigger className="h-8 text-xs">
- <SelectValue placeholder="선택" />
- </SelectTrigger>
- <SelectContent>
- {destinationPlaces.length > 0 ? (
- destinationPlaces.map((place) => (
- <SelectItem key={place.code} value={place.code} className="text-xs">
- {place.code}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled className="text-xs">
- 로딩중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "w-full justify-between h-8 text-xs",
+ !formData.dischargeLocation && "text-muted-foreground"
+ )}
+ >
+ {formData.dischargeLocation
+ ? destinationPlaces.find((place) => place.code === formData.dischargeLocation)
+ ? `${destinationPlaces.find((place) => place.code === formData.dischargeLocation)?.code} ${destinationPlaces.find((place) => place.code === formData.dischargeLocation)?.description ? `(${destinationPlaces.find((place) => place.code === formData.dischargeLocation)?.description})` : ''}`
+ : formData.dischargeLocation
+ : "하역지 선택"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="하역지 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {destinationPlaces.length > 0 ? (
+ destinationPlaces.map((place) => (
+ <CommandItem
+ key={place.code}
+ value={`${place.code} ${place.description || ''}`}
+ onSelect={() => {
+ setFormData(prev => ({ ...prev, dischargeLocation: place.code }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ place.code === formData.dischargeLocation
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {place.code} {place.description && `(${place.description})`}
+ </CommandItem>
+ ))
+ ) : (
+ <CommandItem value="loading" disabled>
+ 로딩중...
+ </CommandItem>
+ )}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
</div>
{/* 계약납기일 */}
diff --git a/lib/general-contracts/detail/general-contract-items-table.tsx b/lib/general-contracts/detail/general-contract-items-table.tsx
index 15e5c926..be174417 100644
--- a/lib/general-contracts/detail/general-contract-items-table.tsx
+++ b/lib/general-contracts/detail/general-contract-items-table.tsx
@@ -30,6 +30,9 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
import { ProjectSelector } from '@/components/ProjectSelector'
import { MaterialGroupSelectorDialogSingle } from '@/components/common/material/material-group-selector-dialog-single'
import { MaterialSearchItem } from '@/lib/material/material-group-service'
+import { ProcurementItemSelectorDialogSingle } from '@/components/common/selectors/procurement-item/procurement-item-selector-dialog-single'
+import { ProcurementSearchItem } from '@/components/common/selectors/procurement-item/procurement-item-service'
+import { cn } from '@/lib/utils'
interface ContractItem {
id?: number
@@ -41,12 +44,12 @@ interface ContractItem {
materialGroupCode?: string
materialGroupDescription?: string
specification: string
- quantity: number
+ quantity: number | string // number | string으로 변경하여 입력 중 포맷팅 지원
quantityUnit: string
- totalWeight: number
+ totalWeight: number | string // number | string으로 변경하여 입력 중 포맷팅 지원
weightUnit: string
contractDeliveryDate: string
- contractUnitPrice: number
+ contractUnitPrice: number | string // number | string으로 변경하여 입력 중 포맷팅 지원
contractAmount: number
contractCurrency: string
isSelected?: boolean
@@ -103,6 +106,34 @@ export function ContractItemsTable({
contractUnitPrice: ''
})
+ // 천단위 콤마 포맷팅 헬퍼 함수들
+ const formatNumberWithCommas = (value: string | number | null | undefined): string => {
+ if (value === null || value === undefined || value === '') return ''
+ const str = value.toString()
+ const parts = str.split('.')
+ const integerPart = parts[0].replace(/,/g, '')
+
+ // 정수부가 비어있거나 '-' 만 있는 경우 처리
+ if (integerPart === '' || integerPart === '-') {
+ return str
+ }
+
+ const num = parseFloat(integerPart)
+ if (isNaN(num)) return str
+
+ const formattedInt = num.toLocaleString()
+
+ if (parts.length > 1) {
+ return `${formattedInt}.${parts[1]}`
+ }
+
+ return formattedInt
+ }
+
+ const parseNumberFromCommas = (value: string): string => {
+ return value.replace(/,/g, '')
+ }
+
// 초기 데이터 로드
React.useEffect(() => {
const loadItems = async () => {
@@ -123,6 +154,8 @@ export function ContractItemsTable({
}
}
+ // number 타입을 string으로 변환하지 않고 일단 그대로 둠 (렌더링 시 포맷팅)
+ // 단, 입력 중 편의를 위해 string이 들어올 수 있으므로 ContractItem 타입 변경함
return {
id: item.id,
projectId: item.projectId || null,
@@ -172,11 +205,20 @@ export function ContractItemsTable({
// validation 체크
const errors: string[] = []
- for (let index = 0; index < localItems.length; index++) {
- const item = localItems[index]
- if (!item.itemCode) errors.push(`${index + 1}번째 품목의 품목코드`)
+ // 저장 시 number로 변환된 데이터 준비
+ const itemsToSave = localItems.map(item => ({
+ ...item,
+ quantity: parseFloat(item.quantity.toString().replace(/,/g, '')) || 0,
+ totalWeight: parseFloat(item.totalWeight.toString().replace(/,/g, '')) || 0,
+ contractUnitPrice: parseFloat(item.contractUnitPrice.toString().replace(/,/g, '')) || 0,
+ contractAmount: parseFloat(item.contractAmount.toString().replace(/,/g, '')) || 0,
+ }));
+
+ for (let index = 0; index < itemsToSave.length; index++) {
+ const item = itemsToSave[index]
+ // if (!item.itemCode) errors.push(`${index + 1}번째 품목의 품목코드`)
if (!item.itemInfo) errors.push(`${index + 1}번째 품목의 Item 정보`)
- if (!item.quantity || item.quantity <= 0) errors.push(`${index + 1}번째 품목의 수량`)
+ // if (!item.quantity || item.quantity <= 0) errors.push(`${index + 1}번째 품목의 수량`)
if (!item.contractUnitPrice || item.contractUnitPrice <= 0) errors.push(`${index + 1}번째 품목의 단가`)
if (!item.contractDeliveryDate) errors.push(`${index + 1}번째 품목의 납기일`)
}
@@ -186,7 +228,7 @@ export function ContractItemsTable({
return
}
- await updateContractItems(contractId, localItems as any)
+ await updateContractItems(contractId, itemsToSave as any)
toast.success('품목정보가 저장되었습니다.')
} catch (error) {
console.error('Error saving contract items:', error)
@@ -197,9 +239,18 @@ export function ContractItemsTable({
}
// 총 금액 계산
- const totalAmount = localItems.reduce((sum, item) => sum + item.contractAmount, 0)
- const totalQuantity = localItems.reduce((sum, item) => sum + item.quantity, 0)
- const totalUnitPrice = localItems.reduce((sum, item) => sum + item.contractUnitPrice, 0)
+ const totalAmount = localItems.reduce((sum, item) => {
+ const amount = parseFloat(item.contractAmount.toString().replace(/,/g, '')) || 0
+ return sum + amount
+ }, 0)
+ const totalQuantity = localItems.reduce((sum, item) => {
+ const quantity = parseFloat(item.quantity.toString().replace(/,/g, '')) || 0
+ return sum + quantity
+ }, 0)
+ const totalUnitPrice = localItems.reduce((sum, item) => {
+ const unitPrice = parseFloat(item.contractUnitPrice.toString().replace(/,/g, '')) || 0
+ return sum + unitPrice
+ }, 0)
const amountDifference = availableBudget - totalAmount
const budgetRatio = availableBudget > 0 ? (totalAmount / availableBudget) * 100 : 0
@@ -211,12 +262,14 @@ export function ContractItemsTable({
// 아이템 업데이트
const updateItem = (index: number, field: keyof ContractItem, value: string | number | boolean | undefined) => {
const updatedItems = [...localItems]
- updatedItems[index] = { ...updatedItems[index], [field]: value }
+ const updatedItem = { ...updatedItems[index], [field]: value }
+ updatedItems[index] = updatedItem
// 단가나 수량이 변경되면 금액 자동 계산
if (field === 'contractUnitPrice' || field === 'quantity') {
- const item = updatedItems[index]
- updatedItems[index].contractAmount = item.contractUnitPrice * item.quantity
+ const quantity = parseFloat(updatedItem.quantity.toString().replace(/,/g, '')) || 0
+ const unitPrice = parseFloat(updatedItem.contractUnitPrice.toString().replace(/,/g, '')) || 0
+ updatedItem.contractAmount = unitPrice * quantity
}
setLocalItems(updatedItems)
@@ -271,6 +324,34 @@ export function ContractItemsTable({
onItemsChange(updatedItems)
}
+ // 1회성 품목 선택 시 행 추가
+ const handleOneTimeItemSelect = (item: ProcurementSearchItem | null) => {
+ if (!item) return
+
+ const newItem: ContractItem = {
+ projectId: null,
+ itemCode: item.itemCode,
+ itemInfo: item.itemName,
+ materialGroupCode: '',
+ materialGroupDescription: '',
+ specification: item.specification || '',
+ quantity: 0,
+ quantityUnit: item.unit || 'EA',
+ totalWeight: 0,
+ weightUnit: 'KG',
+ contractDeliveryDate: '',
+ contractUnitPrice: 0,
+ contractAmount: 0,
+ contractCurrency: 'KRW',
+ isSelected: false
+ }
+
+ const updatedItems = [...localItems, newItem]
+ setLocalItems(updatedItems)
+ onItemsChange(updatedItems)
+ toast.success('1회성 품목이 추가되었습니다.')
+ }
+
// 일괄입력 적용
const applyBatchInput = () => {
if (localItems.length === 0) {
@@ -296,7 +377,8 @@ export function ContractItemsTable({
if (batchInputData.contractUnitPrice) {
updatedItem.contractUnitPrice = parseFloat(batchInputData.contractUnitPrice) || 0
// 단가가 변경되면 계약금액도 재계산
- updatedItem.contractAmount = updatedItem.contractUnitPrice * updatedItem.quantity
+ const quantity = parseFloat(updatedItem.quantity.toString().replace(/,/g, '')) || 0
+ updatedItem.contractAmount = (parseFloat(batchInputData.contractUnitPrice) || 0) * quantity
}
return updatedItem
@@ -382,6 +464,17 @@ export function ContractItemsTable({
<Plus className="w-4 h-4" />
행 추가
</Button>
+ <ProcurementItemSelectorDialogSingle
+ triggerLabel="1회성 품목 추가"
+ triggerVariant="outline"
+ triggerSize="sm"
+ selectedProcurementItem={null}
+ onProcurementItemSelect={handleOneTimeItemSelect}
+ title="1회성 품목 선택"
+ description="추가할 1회성 품목을 선택해주세요."
+ showConfirmButtons={false}
+ disabled={!isEnabled || readOnly}
+ />
<Dialog open={showBatchInputDialog} onOpenChange={setShowBatchInputDialog}>
<DialogTrigger asChild>
<Button
@@ -671,14 +764,23 @@ export function ContractItemsTable({
)}
</TableCell> */}
<TableCell className="px-3 py-3">
+ {readOnly ? (
+ <span className="text-sm text-right">{item.quantity.toLocaleString()}</span>
+ ) : (
<Input
- type="number"
- value={item.quantity}
- onChange={(e) => updateItem(index, 'quantity', parseFloat(e.target.value) || 0)}
+ type="text"
+ value={formatNumberWithCommas(item.quantity)}
+ onChange={(e) => {
+ const val = parseNumberFromCommas(e.target.value)
+ if (val === '' || /^-?\d*\.?\d*$/.test(val)) {
+ updateItem(index, 'quantity', val)
+ }
+ }}
className="h-8 text-sm text-right"
placeholder="0"
- disabled={!isEnabled}
+ disabled={!isEnabled || isQuantityDisabled}
/>
+ )}
</TableCell>
<TableCell className="px-3 py-3">
{readOnly ? (
@@ -707,9 +809,14 @@ export function ContractItemsTable({
<span className="text-sm text-right">{item.totalWeight.toLocaleString()}</span>
) : (
<Input
- type="number"
- value={item.totalWeight}
- onChange={(e) => updateItem(index, 'totalWeight', parseFloat(e.target.value) || 0)}
+ type="text"
+ value={formatNumberWithCommas(item.totalWeight)}
+ onChange={(e) => {
+ const val = parseNumberFromCommas(e.target.value)
+ if (val === '' || /^-?\d*\.?\d*$/.test(val)) {
+ updateItem(index, 'totalWeight', val)
+ }
+ }}
className="h-8 text-sm text-right"
placeholder="0"
disabled={!isEnabled || isQuantityDisabled}
@@ -756,9 +863,14 @@ export function ContractItemsTable({
<span className="text-sm text-right">{item.contractUnitPrice.toLocaleString()}</span>
) : (
<Input
- type="number"
- value={item.contractUnitPrice}
- onChange={(e) => updateItem(index, 'contractUnitPrice', parseFloat(e.target.value) || 0)}
+ type="text"
+ value={formatNumberWithCommas(item.contractUnitPrice)}
+ onChange={(e) => {
+ const val = parseNumberFromCommas(e.target.value)
+ if (val === '' || /^-?\d*\.?\d*$/.test(val)) {
+ updateItem(index, 'contractUnitPrice', val)
+ }
+ }}
className="h-8 text-sm text-right"
placeholder="0"
disabled={!isEnabled}
diff --git a/lib/general-contracts/handlers.ts b/lib/general-contracts/handlers.ts
new file mode 100644
index 00000000..029fb9cd
--- /dev/null
+++ b/lib/general-contracts/handlers.ts
@@ -0,0 +1,157 @@
+/**
+ * 일반계약 관련 결재 액션 핸들러
+ *
+ * 실제 비즈니스 로직만 포함 (결재 로직은 approval-workflow에서 처리)
+ */
+
+'use server';
+
+import { sendContractApprovalRequest } from './service';
+import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils';
+import db from '@/db/db';
+import { eq } from 'drizzle-orm';
+import { generalContracts } from '@/db/schema/generalContract';
+
+interface ContractSummary {
+ basicInfo: Record<string, unknown>;
+ items: Record<string, unknown>[];
+ subcontractChecklist: Record<string, unknown> | null;
+ storageInfo?: Record<string, unknown>[];
+}
+
+/**
+ * 일반계약 승인 핸들러 (결재 승인 후 계약승인요청 전송 실행)
+ *
+ * 결재 승인 후 자동으로 계약승인요청을 전송함
+ * 이 함수는 직접 호출하지 않고, 결재 워크플로우에서 자동으로 호출됨
+ *
+ * @param payload - withApproval()에서 전달한 actionPayload
+ */
+export async function approveContractInternal(payload: {
+ contractId: number;
+ contractSummary: ContractSummary;
+ currentUser?: {
+ id: string | number;
+ name?: string | null;
+ email?: string | null;
+ nonsapUserId?: string | null;
+ };
+}) {
+ debugLog('[ContractApprovalHandler] 일반계약 승인 핸들러 시작', {
+ contractId: payload.contractId,
+ contractNumber: payload.contractSummary.basicInfo?.contractNumber,
+ contractName: payload.contractSummary.basicInfo?.name,
+ hasCurrentUser: !!payload.currentUser,
+ });
+
+ try {
+ // 1. 계약 정보 확인
+ const [contract] = await db
+ .select()
+ .from(generalContracts)
+ .where(eq(generalContracts.id, payload.contractId))
+ .limit(1);
+
+ if (!contract) {
+ throw new Error('계약을 찾을 수 없습니다.');
+ }
+
+ // 2. 계약승인요청 전송
+ debugLog('[ContractApprovalHandler] sendContractApprovalRequest 호출');
+
+ // PDF 경로에서 PDF 버퍼 읽기
+ const pdfPath = (payload.contractSummary as any).pdfPath;
+ if (!pdfPath) {
+ throw new Error('PDF 경로가 없습니다.');
+ }
+
+ // PDF 파일 읽기
+ const fs = await import('fs/promises');
+ const path = await import('path');
+
+ const nasPath = process.env.NAS_PATH || "/evcp_nas";
+ const isProduction = process.env.NODE_ENV === "production";
+ const baseDir = isProduction ? nasPath : path.join(process.cwd(), "public");
+
+ // publicPath에서 실제 파일 경로로 변환
+ const actualPath = pdfPath.startsWith('/')
+ ? path.join(baseDir, pdfPath)
+ : path.join(baseDir, 'generalContracts', pdfPath);
+
+ let pdfBuffer: Uint8Array;
+ try {
+ const fileBuffer = await fs.readFile(actualPath);
+ pdfBuffer = new Uint8Array(fileBuffer);
+ } catch (error) {
+ debugError('[ContractApprovalHandler] PDF 파일 읽기 실패', error);
+ throw new Error('PDF 파일을 읽을 수 없습니다.');
+ }
+
+ // 기본계약서는 클라이언트에서 이미 생성되었을 것으로 가정
+ const generatedBasicContracts: Array<{ key: string; buffer: number[]; fileName: string }> =
+ (payload.contractSummary as any).basicContractPdfs || [];
+
+ const userId = payload.currentUser?.id
+ ? String(payload.currentUser.id)
+ : String(contract.registeredById);
+
+ const result = await sendContractApprovalRequest(
+ payload.contractSummary,
+ pdfBuffer,
+ 'contractDocument',
+ userId,
+ generatedBasicContracts
+ );
+
+ if (!result.success) {
+ debugError('[ContractApprovalHandler] 계약승인요청 전송 실패', result.error);
+
+ // 전송 실패 시 상태를 원래대로 되돌림
+ await db.update(generalContracts)
+ .set({
+ status: 'Draft',
+ lastUpdatedAt: new Date()
+ })
+ .where(eq(generalContracts.id, payload.contractId));
+
+ throw new Error(result.error || '계약승인요청 전송에 실패했습니다.');
+ }
+
+ // 3. 전송 성공 시 상태를 'Contract Accept Request'로 변경
+ debugLog('[ContractApprovalHandler] 계약승인요청 전송 성공, 상태를 Contract Accept Request로 변경');
+ await db.update(generalContracts)
+ .set({
+ status: 'Contract Accept Request',
+ lastUpdatedAt: new Date()
+ })
+ .where(eq(generalContracts.id, payload.contractId));
+
+ debugSuccess('[ContractApprovalHandler] 일반계약 승인 완료', {
+ contractId: payload.contractId,
+ result: result
+ });
+
+ return {
+ success: true,
+ message: '계약승인요청이 전송되었습니다.',
+ result: result
+ };
+ } catch (error) {
+ debugError('[ContractApprovalHandler] 일반계약 승인 중 에러', error);
+
+ // 에러 발생 시 상태를 원래대로 되돌림
+ try {
+ await db.update(generalContracts)
+ .set({
+ status: 'Draft',
+ lastUpdatedAt: new Date()
+ })
+ .where(eq(generalContracts.id, payload.contractId));
+ } catch (updateError) {
+ debugError('[ContractApprovalHandler] 상태 업데이트 실패', updateError);
+ }
+
+ throw error;
+ }
+}
+
diff --git a/lib/general-contracts/service.ts b/lib/general-contracts/service.ts
index 3f3dc8de..b803d2d4 100644
--- a/lib/general-contracts/service.ts
+++ b/lib/general-contracts/service.ts
@@ -504,7 +504,7 @@ export async function updateContractBasicInfo(id: number, data: Record<string, u
linkedBidNumber,
notes,
paymentBeforeDelivery, // JSON 필드
- paymentDelivery: convertToNumberOrNull(paymentDelivery),
+ paymentDelivery,
paymentAfterDelivery, // JSON 필드
paymentTerm,
taxType,
@@ -525,7 +525,7 @@ export async function updateContractBasicInfo(id: number, data: Record<string, u
lastUpdatedAt: new Date(),
lastUpdatedById: userId,
}
-
+ console.log(updateData.paymentDelivery,"updateData.paymentDelivery")
// DB에 업데이트 실행
const [updatedContract] = await db
.update(generalContracts)
@@ -533,14 +533,9 @@ export async function updateContractBasicInfo(id: number, data: Record<string, u
.where(eq(generalContracts.id, id))
.returning()
- // 계약명 I/F 로직 (39번 화면으로의 I/F)
- // TODO: 39번 화면의 정확한 API 엔드포인트나 함수명 확인 필요
- // if (data.name) {
- // await syncContractNameToScreen39(id, data.name as string)
- // }
revalidatePath('/general-contracts')
- revalidatePath(`/general-contracts/detail/${id}`)
+ revalidatePath(`/general-contracts/${id}`)
return updatedContract
} catch (error) {
console.error('Error updating contract basic info:', error)
@@ -1391,7 +1386,7 @@ export async function sendContractApprovalRequest(
signerStatus: 'PENDING',
})
- // 사외업체 야드투입이 'Y'인 경우 안전담당자 자동 지정
+ // 사외업체 야드투입이 'Y'인 경우 안전담당자 자동 지정 - 수정필요 12/05
if (contractSummary.basicInfo?.externalYardEntry === 'Y') {
try {
// 안전담당자 역할을 가진 사용자 조회 (역할명에 '안전' 또는 'safety' 포함)
diff --git a/lib/information/service.ts b/lib/information/service.ts
index 02efe616..39e810e4 100644
--- a/lib/information/service.ts
+++ b/lib/information/service.ts
@@ -3,7 +3,7 @@
import { getErrorMessage } from "@/lib/handle-error"
import { desc, or, eq } from "drizzle-orm"
import db from "@/db/db"
-import { pageInformation, menuAssignments, users } from "@/db/schema"
+import { pageInformation, menuTreeNodes, users } from "@/db/schema"
import { saveDRMFile } from "@/lib/file-stroage"
import { decryptWithServerAction } from "@/components/drm/drmUtils"
@@ -144,27 +144,27 @@ export async function checkInformationEditPermission(pagePath: string, userId: s
pagePath // 원본 경로 정확한 매칭
]
- // menu_assignments에서 해당 pagePath와 매칭되는 메뉴 찾기
- const menuAssignment = await db
+ // menu_tree_nodes에서 해당 pagePath와 매칭되는 메뉴 찾기
+ const menuNode = await db
.select()
- .from(menuAssignments)
+ .from(menuTreeNodes)
.where(
or(
- ...menuPathQueries.map(path => eq(menuAssignments.menuPath, path))
+ ...menuPathQueries.map(path => eq(menuTreeNodes.menuPath, path))
)
)
.limit(1)
- if (menuAssignment.length === 0) {
+ if (menuNode.length === 0) {
// 매칭되는 메뉴가 없으면 권한 없음
return false
}
- const assignment = menuAssignment[0]
+ const node = menuNode[0]
const userIdNumber = parseInt(userId)
// 현재 사용자가 manager1 또는 manager2인지 확인
- return assignment.manager1Id === userIdNumber || assignment.manager2Id === userIdNumber
+ return node.manager1Id === userIdNumber || node.manager2Id === userIdNumber
} catch (error) {
console.error("Failed to check information edit permission:", error)
return false
@@ -176,17 +176,21 @@ export async function getEditPermissionDirect(pagePath: string, userId: string)
return await checkInformationEditPermission(pagePath, userId)
}
-// menu_assignments 기반으로 page_information 동기화
+// menu_tree_nodes 기반으로 page_information 동기화
export async function syncInformationFromMenuAssignments() {
try {
- // menu_assignments에서 모든 메뉴 가져오기
- const menuItems = await db.select().from(menuAssignments);
+ // menu_tree_nodes에서 메뉴 타입 노드만 가져오기 (menuPath가 있는 것)
+ const menuItems = await db.select()
+ .from(menuTreeNodes)
+ .where(eq(menuTreeNodes.nodeType, 'menu'));
let processedCount = 0;
// upsert를 사용하여 각 메뉴 항목 처리
for (const menu of menuItems) {
try {
+ if (!menu.menuPath) continue;
+
// 맨 앞의 / 제거하여 pagePath 정규화
const normalizedPagePath = menu.menuPath.startsWith('/')
? menu.menuPath.slice(1)
@@ -195,14 +199,14 @@ export async function syncInformationFromMenuAssignments() {
await db.insert(pageInformation)
.values({
pagePath: normalizedPagePath,
- pageName: menu.menuTitle,
+ pageName: menu.titleKo,
informationContent: "",
isActive: true // 기본값으로 활성화
})
.onConflictDoUpdate({
target: pageInformation.pagePath,
set: {
- pageName: menu.menuTitle,
+ pageName: menu.titleKo,
updatedAt: new Date()
}
});
@@ -213,8 +217,6 @@ export async function syncInformationFromMenuAssignments() {
}
}
- // 캐시 무효화 제거됨
-
return {
success: true,
message: `페이지 정보 동기화 완료: ${processedCount}개 처리됨`
diff --git a/lib/items-tech/table/add-items-dialog.tsx b/lib/items-tech/table/add-items-dialog.tsx
index 01a072da..a4c644b6 100644
--- a/lib/items-tech/table/add-items-dialog.tsx
+++ b/lib/items-tech/table/add-items-dialog.tsx
@@ -34,7 +34,7 @@ import {
} from "@/components/ui/select"
import { toast } from "sonner"
-import { createShipbuildingItem, createOffshoreTopItem, createOffshoreHullItem } from "../service"
+import { createShipbuildingItem, createOffshoreTopItem, createOffshoreHullItem, getShipTypes } from "../service"
import { ItemType } from "./delete-items-dialog"
// 조선 공종 유형 정의
@@ -88,6 +88,8 @@ export function AddItemDialog({ itemType }: AddItemDialogProps) {
const router = useRouter()
const [open, setOpen] = React.useState(false)
const [isAddPending, startAddTransition] = React.useTransition()
+ const [shipTypeOptions, setShipTypeOptions] = React.useState<string[]>([])
+ const [isShipTypeLoading, setIsShipTypeLoading] = React.useState(false)
// 기본값 설정
const getDefaultValues = () => {
@@ -97,7 +99,7 @@ export function AddItemDialog({ itemType }: AddItemDialogProps) {
}
if (itemType === 'shipbuilding') {
- defaults.shipTypes = "OPTION"
+ defaults.shipTypes = ""
} else {
defaults.itemList = ""
defaults.subItemList = ""
@@ -124,6 +126,42 @@ export function AddItemDialog({ itemType }: AddItemDialogProps) {
defaultValues: getDefaultValues(),
})
+ // shipTypes 목록 로드 (조선 아이템 생성 시)
+ React.useEffect(() => {
+ if (itemType !== 'shipbuilding' || !open) return
+
+ let isMounted = true
+ const loadShipTypes = async () => {
+ try {
+ setIsShipTypeLoading(true)
+ const { data, error } = await getShipTypes()
+ if (!isMounted) return
+ if (error) {
+ toast.error("선종 목록을 불러오지 못했습니다")
+ return
+ }
+ const options = (data || []).filter((v): v is string => Boolean(v))
+ setShipTypeOptions(options)
+ // 기본값 자동 설정
+ if (options.length > 0 && !form.getValues("shipTypes")) {
+ form.setValue("shipTypes", options[0])
+ }
+ } catch (err) {
+ console.error("shipTypes load error:", err)
+ if (isMounted) {
+ toast.error("선종 목록 로드 중 오류가 발생했습니다")
+ }
+ } finally {
+ if (isMounted) setIsShipTypeLoading(false)
+ }
+ }
+
+ loadShipTypes()
+ return () => {
+ isMounted = false
+ }
+ }, [itemType, open, form])
+
const onSubmit = async (data: ItemFormValues) => {
startAddTransition(async () => {
try {
@@ -276,7 +314,28 @@ export function AddItemDialog({ itemType }: AddItemDialogProps) {
<FormItem>
<FormLabel>선종 <span style={{ color: 'red' }}>*</span></FormLabel>
<FormControl>
- <Input placeholder="선종을 입력하세요" {...field} />
+ <Select
+ onValueChange={field.onChange}
+ value={field.value ?? ""}
+ disabled={isShipTypeLoading || shipTypeOptions.length === 0}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder={isShipTypeLoading ? "불러오는 중..." : "선종을 선택하세요"} />
+ </SelectTrigger>
+ <SelectContent>
+ {shipTypeOptions.length === 0 ? (
+ <div className="px-3 py-2 text-sm text-muted-foreground">
+ {isShipTypeLoading ? "불러오는 중..." : "선종 없음"}
+ </div>
+ ) : (
+ shipTypeOptions.map((type) => (
+ <SelectItem key={type} value={type}>
+ {type}
+ </SelectItem>
+ ))
+ )}
+ </SelectContent>
+ </Select>
</FormControl>
<FormMessage />
</FormItem>
diff --git a/lib/menu-v2/components/add-node-dialog.tsx b/lib/menu-v2/components/add-node-dialog.tsx
new file mode 100644
index 00000000..b6762820
--- /dev/null
+++ b/lib/menu-v2/components/add-node-dialog.tsx
@@ -0,0 +1,186 @@
+"use client";
+
+import { useForm } from "react-hook-form";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import type {
+ MenuDomain,
+ CreateMenuGroupInput,
+ CreateGroupInput,
+ CreateTopLevelMenuInput
+} from "../types";
+
+type DialogType = "menu_group" | "group" | "top_level_menu";
+
+interface AddNodeDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ type: DialogType;
+ domain: MenuDomain;
+ parentId?: number; // group 생성 시 필요
+ onSave: (data: CreateMenuGroupInput | CreateGroupInput | CreateTopLevelMenuInput) => Promise<void>;
+}
+
+interface FormData {
+ titleKo: string;
+ titleEn: string;
+ menuPath: string;
+}
+
+export function AddNodeDialog({
+ open,
+ onOpenChange,
+ type,
+ domain,
+ parentId,
+ onSave,
+}: AddNodeDialogProps) {
+ const {
+ register,
+ handleSubmit,
+ reset,
+ formState: { isSubmitting, errors },
+ } = useForm<FormData>({
+ defaultValues: {
+ titleKo: "",
+ titleEn: "",
+ menuPath: "",
+ },
+ });
+
+ const getTitle = () => {
+ switch (type) {
+ case "menu_group":
+ return "Add Menu Group";
+ case "group":
+ return "Add Group";
+ case "top_level_menu":
+ return "Add Top-Level Menu";
+ default:
+ return "Add";
+ }
+ };
+
+ const getDescription = () => {
+ switch (type) {
+ case "menu_group":
+ return "A dropdown trigger displayed in the header navigation.";
+ case "group":
+ return "Groups menus within a menu group.";
+ case "top_level_menu":
+ return "A single link displayed in the header navigation.";
+ default:
+ return "";
+ }
+ };
+
+ const onSubmit = async (data: FormData) => {
+ let saveData: CreateMenuGroupInput | CreateGroupInput | CreateTopLevelMenuInput;
+
+ if (type === "menu_group") {
+ saveData = {
+ titleKo: data.titleKo,
+ titleEn: data.titleEn || undefined,
+ };
+ } else if (type === "group" && parentId) {
+ saveData = {
+ parentId,
+ titleKo: data.titleKo,
+ titleEn: data.titleEn || undefined,
+ };
+ } else if (type === "top_level_menu") {
+ saveData = {
+ titleKo: data.titleKo,
+ titleEn: data.titleEn || undefined,
+ menuPath: data.menuPath,
+ };
+ } else {
+ return;
+ }
+
+ await onSave(saveData);
+ reset();
+ onOpenChange(false);
+ };
+
+ const handleClose = () => {
+ reset();
+ onOpenChange(false);
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={handleClose}>
+ <DialogContent className="max-w-md">
+ <DialogHeader>
+ <DialogTitle>{getTitle()}</DialogTitle>
+ <DialogDescription>{getDescription()}</DialogDescription>
+ </DialogHeader>
+
+ <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
+ <div className="grid gap-4">
+ {/* Korean Name */}
+ <div className="grid gap-2">
+ <Label htmlFor="titleKo">Name (Korean) *</Label>
+ <Input
+ id="titleKo"
+ {...register("titleKo", { required: "Name is required" })}
+ placeholder="Master Data"
+ />
+ {errors.titleKo && (
+ <p className="text-xs text-destructive">{errors.titleKo.message}</p>
+ )}
+ </div>
+
+ {/* English Name */}
+ <div className="grid gap-2">
+ <Label htmlFor="titleEn">Name (English)</Label>
+ <Input
+ id="titleEn"
+ {...register("titleEn")}
+ placeholder="Master Data"
+ />
+ </div>
+
+ {/* Menu Path for Top-Level Menu */}
+ {type === "top_level_menu" && (
+ <div className="grid gap-2">
+ <Label htmlFor="menuPath">Menu Path *</Label>
+ <Input
+ id="menuPath"
+ {...register("menuPath", {
+ required: type === "top_level_menu" ? "Path is required" : false
+ })}
+ placeholder={`/${domain}/dashboard`}
+ />
+ {errors.menuPath && (
+ <p className="text-xs text-destructive">{errors.menuPath.message}</p>
+ )}
+ <p className="text-xs text-muted-foreground">
+ e.g., /{domain}/report, /{domain}/faq
+ </p>
+ </div>
+ )}
+ </div>
+
+ <DialogFooter>
+ <Button type="button" variant="outline" onClick={handleClose}>
+ Cancel
+ </Button>
+ <Button type="submit" disabled={isSubmitting}>
+ {isSubmitting ? "Creating..." : "Create"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/lib/menu-v2/components/domain-tabs.tsx b/lib/menu-v2/components/domain-tabs.tsx
new file mode 100644
index 00000000..e52fa80b
--- /dev/null
+++ b/lib/menu-v2/components/domain-tabs.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import type { MenuDomain } from "../types";
+
+interface DomainTabsProps {
+ value: MenuDomain;
+ onChange: (domain: MenuDomain) => void;
+}
+
+export function DomainTabs({ value, onChange }: DomainTabsProps) {
+ return (
+ <Tabs value={value} onValueChange={(v) => onChange(v as MenuDomain)}>
+ <TabsList>
+ <TabsTrigger value="evcp">
+ EVCP (Internal)
+ </TabsTrigger>
+ <TabsTrigger value="partners">
+ Partners (Vendors)
+ </TabsTrigger>
+ </TabsList>
+ </Tabs>
+ );
+}
+
diff --git a/lib/menu-v2/components/edit-node-dialog.tsx b/lib/menu-v2/components/edit-node-dialog.tsx
new file mode 100644
index 00000000..9631a611
--- /dev/null
+++ b/lib/menu-v2/components/edit-node-dialog.tsx
@@ -0,0 +1,215 @@
+"use client";
+
+import { useEffect } from "react";
+import { useForm } from "react-hook-form";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { Switch } from "@/components/ui/switch";
+import type { MenuTreeNode, UpdateNodeInput } from "../types";
+
+interface EditNodeDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ node: MenuTreeNode | null;
+ onSave: (nodeId: number, data: UpdateNodeInput) => Promise<void>;
+}
+
+interface FormData {
+ titleKo: string;
+ titleEn: string;
+ descriptionKo: string;
+ descriptionEn: string;
+ scrId: string;
+ isActive: boolean;
+}
+
+export function EditNodeDialog({
+ open,
+ onOpenChange,
+ node,
+ onSave,
+}: EditNodeDialogProps) {
+ const {
+ register,
+ handleSubmit,
+ reset,
+ setValue,
+ watch,
+ formState: { isSubmitting },
+ } = useForm<FormData>({
+ defaultValues: {
+ titleKo: "",
+ titleEn: "",
+ descriptionKo: "",
+ descriptionEn: "",
+ scrId: "",
+ isActive: true,
+ },
+ });
+
+ const isActive = watch("isActive");
+
+ useEffect(() => {
+ if (node) {
+ reset({
+ titleKo: node.titleKo,
+ titleEn: node.titleEn || "",
+ descriptionKo: node.descriptionKo || "",
+ descriptionEn: node.descriptionEn || "",
+ scrId: node.scrId || "",
+ isActive: node.isActive,
+ });
+ }
+ }, [node, reset]);
+
+ const onSubmit = async (data: FormData) => {
+ if (!node) return;
+
+ await onSave(node.id, {
+ titleKo: data.titleKo,
+ titleEn: data.titleEn || undefined,
+ descriptionKo: data.descriptionKo || undefined,
+ descriptionEn: data.descriptionEn || undefined,
+ scrId: data.scrId || undefined,
+ isActive: data.isActive,
+ });
+
+ onOpenChange(false);
+ };
+
+ const getTypeLabel = () => {
+ switch (node?.nodeType) {
+ case "menu_group":
+ return "Menu Group";
+ case "group":
+ return "Group";
+ case "menu":
+ return "Menu";
+ case "additional":
+ return "Additional Menu";
+ default:
+ return "Node";
+ }
+ };
+
+ const showMenuFields = node?.nodeType === "menu" || node?.nodeType === "additional";
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-lg">
+ <DialogHeader>
+ <DialogTitle>Edit {getTypeLabel()}</DialogTitle>
+ <DialogDescription>
+ {node?.menuPath && (
+ <span className="text-xs text-muted-foreground">{node.menuPath}</span>
+ )}
+ </DialogDescription>
+ </DialogHeader>
+
+ <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
+ <div className="grid gap-4">
+ {/* Korean Name */}
+ <div className="grid gap-2">
+ <Label htmlFor="titleKo">Name (Korean) *</Label>
+ <Input
+ id="titleKo"
+ {...register("titleKo", { required: true })}
+ placeholder="Project List"
+ />
+ </div>
+
+ {/* English Name */}
+ <div className="grid gap-2">
+ <Label htmlFor="titleEn">Name (English)</Label>
+ <Input
+ id="titleEn"
+ {...register("titleEn")}
+ placeholder="Project List"
+ />
+ </div>
+
+ {/* Korean Description */}
+ {showMenuFields && (
+ <div className="grid gap-2">
+ <Label htmlFor="descriptionKo">Description (Korean)</Label>
+ <Textarea
+ id="descriptionKo"
+ {...register("descriptionKo")}
+ placeholder="Project list from MDG (C)"
+ rows={2}
+ />
+ </div>
+ )}
+
+ {/* English Description */}
+ {showMenuFields && (
+ <div className="grid gap-2">
+ <Label htmlFor="descriptionEn">Description (English)</Label>
+ <Textarea
+ id="descriptionEn"
+ {...register("descriptionEn")}
+ placeholder="Project list from MDG (C)"
+ rows={2}
+ />
+ </div>
+ )}
+
+ {/* Permission SCR_ID */}
+ {showMenuFields && (
+ <div className="grid gap-2">
+ <Label htmlFor="scrId">Permission SCR_ID (EVCP only)</Label>
+ <Input
+ id="scrId"
+ {...register("scrId")}
+ placeholder="SCR_001"
+ />
+ <p className="text-xs text-muted-foreground">
+ Linked with Oracle DB SCR_ID. If empty, auto-matched by URL.
+ </p>
+ </div>
+ )}
+
+ {/* Active Status */}
+ <div className="flex items-center justify-between">
+ <div className="space-y-0.5">
+ <Label htmlFor="isActive">Show in Menu</Label>
+ <p className="text-xs text-muted-foreground">
+ When disabled, hidden from the navigation menu.
+ </p>
+ </div>
+ <Switch
+ id="isActive"
+ checked={isActive}
+ onCheckedChange={(checked) => setValue("isActive", checked)}
+ />
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ >
+ Cancel
+ </Button>
+ <Button type="submit" disabled={isSubmitting}>
+ {isSubmitting ? "Saving..." : "Save"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </DialogContent>
+ </Dialog>
+ );
+}
+
diff --git a/lib/menu-v2/components/menu-tree-manager.tsx b/lib/menu-v2/components/menu-tree-manager.tsx
new file mode 100644
index 00000000..337eaee4
--- /dev/null
+++ b/lib/menu-v2/components/menu-tree-manager.tsx
@@ -0,0 +1,364 @@
+"use client";
+
+import { useState, useEffect, useCallback, useTransition } from "react";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { RefreshCw, Plus, Loader2 } from "lucide-react";
+import { DomainTabs } from "./domain-tabs";
+import { MenuTree } from "./menu-tree";
+import { EditNodeDialog } from "./edit-node-dialog";
+import { AddNodeDialog } from "./add-node-dialog";
+import { MoveToDialog } from "./move-to-dialog";
+import { UnassignedMenusPanel } from "./unassigned-menus-panel";
+import {
+ getMenuTreeForAdmin,
+ createMenuGroup,
+ createGroup,
+ createTopLevelMenu,
+ updateNode,
+ moveNodeUp,
+ moveNodeDown,
+ moveNodeToParent,
+ getAvailableParents,
+ assignMenuToGroup,
+ activateAsTopLevelMenu,
+ syncDiscoveredMenus,
+} from "../service";
+import type {
+ MenuDomain,
+ MenuTreeNode,
+ MenuTreeAdminResult,
+ UpdateNodeInput,
+ CreateMenuGroupInput,
+ CreateGroupInput,
+ CreateTopLevelMenuInput,
+} from "../types";
+
+interface MenuTreeManagerProps {
+ initialDomain?: MenuDomain;
+}
+
+export function MenuTreeManager({ initialDomain = "evcp" }: MenuTreeManagerProps) {
+ const [domain, setDomain] = useState<MenuDomain>(initialDomain);
+ const [data, setData] = useState<MenuTreeAdminResult | null>(null);
+ const [isInitialLoading, setIsInitialLoading] = useState(true);
+ const [isPending, startTransition] = useTransition();
+
+ // Dialog states
+ const [editDialogOpen, setEditDialogOpen] = useState(false);
+ const [editingNode, setEditingNode] = useState<MenuTreeNode | null>(null);
+ const [addDialogOpen, setAddDialogOpen] = useState(false);
+ const [addDialogType, setAddDialogType] = useState<"menu_group" | "group" | "top_level_menu">("menu_group");
+ const [addGroupParentId, setAddGroupParentId] = useState<number | undefined>(undefined);
+
+ // Move dialog state
+ const [moveDialogOpen, setMoveDialogOpen] = useState(false);
+ const [movingNode, setMovingNode] = useState<MenuTreeNode | null>(null);
+ const [availableParents, setAvailableParents] = useState<{ id: number | null; title: string; depth: number }[]>([]);
+
+ // Tree expansion state
+ const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
+
+ // Load data using server action
+ const loadData = useCallback(async (isRefresh = false) => {
+ if (!isRefresh) {
+ setIsInitialLoading(true);
+ }
+ try {
+ const result = await getMenuTreeForAdmin(domain);
+ setData(result);
+ } catch (error) {
+ console.error("Error loading menu tree:", error);
+ toast.error("Failed to load menu tree");
+ } finally {
+ setIsInitialLoading(false);
+ }
+ }, [domain]);
+
+ useEffect(() => {
+ setExpandedIds(new Set());
+ loadData();
+ }, [loadData]);
+
+ const handleSync = async () => {
+ startTransition(async () => {
+ try {
+ const result = await syncDiscoveredMenus(domain);
+ toast.success(`Sync complete: ${result.added} menus added`);
+ loadData(true);
+ } catch (error) {
+ console.error("Error syncing menus:", error);
+ toast.error("Failed to sync menus");
+ }
+ });
+ };
+
+ const handleEdit = (node: MenuTreeNode) => {
+ setEditingNode(node);
+ setEditDialogOpen(true);
+ };
+
+ const handleSaveEdit = async (nodeId: number, input: UpdateNodeInput) => {
+ startTransition(async () => {
+ try {
+ await updateNode(nodeId, input);
+ toast.success("Saved successfully");
+ loadData(true);
+ } catch (error) {
+ console.error("Error updating node:", error);
+ toast.error("Failed to save");
+ }
+ });
+ };
+
+ // Move up (within same parent)
+ const handleMoveUp = async (nodeId: number) => {
+ startTransition(async () => {
+ try {
+ await moveNodeUp(nodeId);
+ loadData(true);
+ } catch (error) {
+ console.error("Error moving node up:", error);
+ toast.error("Failed to move");
+ }
+ });
+ };
+
+ // Move down (within same parent)
+ const handleMoveDown = async (nodeId: number) => {
+ startTransition(async () => {
+ try {
+ await moveNodeDown(nodeId);
+ loadData(true);
+ } catch (error) {
+ console.error("Error moving node down:", error);
+ toast.error("Failed to move");
+ }
+ });
+ };
+
+ // Open move to dialog
+ const handleOpenMoveDialog = async (node: MenuTreeNode) => {
+ setMovingNode(node);
+ try {
+ const parents = await getAvailableParents(node.id, domain, node.nodeType);
+ setAvailableParents(parents);
+ setMoveDialogOpen(true);
+ } catch (error) {
+ console.error("Error loading available parents:", error);
+ toast.error("Failed to load move options");
+ }
+ };
+
+ // Execute move to different parent
+ const handleMoveTo = async (newParentId: number | null) => {
+ if (!movingNode) return;
+ startTransition(async () => {
+ try {
+ await moveNodeToParent(movingNode.id, newParentId);
+ toast.success("Moved successfully");
+ setMoveDialogOpen(false);
+ setMovingNode(null);
+ loadData(true);
+ } catch (error) {
+ console.error("Error moving node:", error);
+ toast.error("Failed to move");
+ }
+ });
+ };
+
+ const handleAddMenuGroup = () => {
+ setAddDialogType("menu_group");
+ setAddGroupParentId(undefined);
+ setAddDialogOpen(true);
+ };
+
+ const handleAddGroup = (parentId: number) => {
+ setAddDialogType("group");
+ setAddGroupParentId(parentId);
+ setAddDialogOpen(true);
+ };
+
+ const handleAddTopLevelMenu = () => {
+ setAddDialogType("top_level_menu");
+ setAddGroupParentId(undefined);
+ setAddDialogOpen(true);
+ };
+
+ const handleSaveAdd = async (
+ input: CreateMenuGroupInput | CreateGroupInput | CreateTopLevelMenuInput
+ ) => {
+ startTransition(async () => {
+ try {
+ if (addDialogType === "menu_group") {
+ await createMenuGroup(domain, input as CreateMenuGroupInput);
+ } else if (addDialogType === "group") {
+ await createGroup(domain, input as CreateGroupInput);
+ } else if (addDialogType === "top_level_menu") {
+ await createTopLevelMenu(domain, input as CreateTopLevelMenuInput);
+ }
+ toast.success("Created successfully");
+ loadData(true);
+ } catch (error) {
+ console.error("Error creating node:", error);
+ toast.error("Failed to create");
+ }
+ });
+ };
+
+ const handleAssign = async (menuId: number, groupId: number) => {
+ startTransition(async () => {
+ try {
+ await assignMenuToGroup(menuId, groupId);
+ toast.success("Assigned successfully");
+ loadData(true);
+ } catch (error) {
+ console.error("Error assigning menu:", error);
+ toast.error("Failed to assign");
+ }
+ });
+ };
+
+ const handleActivateAsTopLevel = async (menuId: number) => {
+ startTransition(async () => {
+ try {
+ await activateAsTopLevelMenu(menuId);
+ toast.success("Activated as top-level menu");
+ loadData(true);
+ } catch (error) {
+ console.error("Error activating as top level:", error);
+ toast.error("Failed to activate");
+ }
+ });
+ };
+
+ // Build list of available groups for assignment
+ const getAvailableGroups = () => {
+ if (!data) return [];
+
+ const groups: { id: number; title: string; parentTitle?: string }[] = [];
+
+ for (const node of data.tree) {
+ if (node.nodeType !== 'menu_group') continue;
+
+ groups.push({ id: node.id, title: node.titleKo });
+
+ if (node.children) {
+ for (const child of node.children) {
+ if (child.nodeType === "group") {
+ groups.push({
+ id: child.id,
+ title: child.titleKo,
+ parentTitle: node.titleKo,
+ });
+ }
+ }
+ }
+ }
+
+ return groups;
+ };
+
+ if (isInitialLoading) {
+ return (
+ <div className="flex items-center justify-center h-96">
+ <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
+ </div>
+ );
+ }
+
+ return (
+ <div className="space-y-6">
+ {/* Header */}
+ <div className="flex items-center justify-between">
+ <DomainTabs value={domain} onChange={setDomain} />
+ <div className="flex items-center gap-2">
+ {/* [jh] I've commented this button.. */}
+ {/* <Button variant="outline" size="sm" onClick={handleSync} disabled={isPending}>
+ <RefreshCw className={`mr-2 h-4 w-4 ${isPending ? "animate-spin" : ""}`} />
+ Sync Pages
+ </Button> */}
+ <Button variant="outline" size="sm" onClick={handleAddTopLevelMenu} disabled={isPending}>
+ <Plus className="mr-2 h-4 w-4" />
+ Add Top-Level Menu
+ </Button>
+ <Button size="sm" onClick={handleAddMenuGroup} disabled={isPending}>
+ <Plus className="mr-2 h-4 w-4" />
+ Add Menu Group
+ </Button>
+ </div>
+ </div>
+
+ {/* Main Content */}
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
+ {/* Menu Tree */}
+ <div className="lg:col-span-2">
+ <Card>
+ <CardHeader>
+ <CardTitle>{domain === "evcp" ? "EVCP" : "Partners"} Menu Structure</CardTitle>
+ <CardDescription>
+ Use arrow buttons to reorder, or click Move To to change parent.
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ {data?.tree && data.tree.length > 0 ? (
+ <MenuTree
+ nodes={data.tree}
+ onEdit={handleEdit}
+ onMoveUp={handleMoveUp}
+ onMoveDown={handleMoveDown}
+ onMoveTo={handleOpenMoveDialog}
+ onAddGroup={handleAddGroup}
+ expandedIds={expandedIds}
+ onExpandedIdsChange={setExpandedIds}
+ isPending={isPending}
+ />
+ ) : (
+ <p className="text-sm text-muted-foreground text-center py-8">
+ No menus. Add one using the buttons above.
+ </p>
+ )}
+ </CardContent>
+ </Card>
+ </div>
+
+ {/* Unassigned Menus */}
+ <div className="lg:col-span-1">
+ <UnassignedMenusPanel
+ menus={data?.unassigned || []}
+ onAssign={handleAssign}
+ onActivateAsTopLevel={handleActivateAsTopLevel}
+ onEdit={handleEdit}
+ availableGroups={getAvailableGroups()}
+ />
+ </div>
+ </div>
+
+ {/* Dialogs */}
+ <EditNodeDialog
+ open={editDialogOpen}
+ onOpenChange={setEditDialogOpen}
+ node={editingNode}
+ onSave={handleSaveEdit}
+ />
+
+ <AddNodeDialog
+ open={addDialogOpen}
+ onOpenChange={setAddDialogOpen}
+ type={addDialogType}
+ domain={domain}
+ parentId={addGroupParentId}
+ onSave={handleSaveAdd}
+ />
+
+ <MoveToDialog
+ open={moveDialogOpen}
+ onOpenChange={setMoveDialogOpen}
+ node={movingNode}
+ availableParents={availableParents}
+ onMove={handleMoveTo}
+ />
+ </div>
+ );
+}
diff --git a/lib/menu-v2/components/menu-tree.tsx b/lib/menu-v2/components/menu-tree.tsx
new file mode 100644
index 00000000..7d3ab077
--- /dev/null
+++ b/lib/menu-v2/components/menu-tree.tsx
@@ -0,0 +1,282 @@
+"use client";
+
+import { useCallback } from "react";
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import {
+ ChevronRight,
+ ChevronDown,
+ ChevronUp,
+ Folder,
+ FolderOpen,
+ File,
+ Pencil,
+ Plus,
+ ArrowUpDown,
+ EyeOff,
+} from "lucide-react";
+import type { MenuTreeNode } from "../types";
+
+interface MenuTreeProps {
+ nodes: MenuTreeNode[];
+ onEdit: (node: MenuTreeNode) => void;
+ onMoveUp: (nodeId: number) => void;
+ onMoveDown: (nodeId: number) => void;
+ onMoveTo: (node: MenuTreeNode) => void;
+ onAddGroup: (parentId: number) => void;
+ expandedIds: Set<number>;
+ onExpandedIdsChange: (ids: Set<number>) => void;
+ isPending?: boolean;
+}
+
+interface TreeItemProps {
+ node: MenuTreeNode;
+ depth: number;
+ isFirst: boolean;
+ isLast: boolean;
+ onEdit: (node: MenuTreeNode) => void;
+ onMoveUp: (nodeId: number) => void;
+ onMoveDown: (nodeId: number) => void;
+ onMoveTo: (node: MenuTreeNode) => void;
+ onAddGroup: (parentId: number) => void;
+ isExpanded: boolean;
+ onToggleExpand: () => void;
+ isPending?: boolean;
+}
+
+function TreeItem({
+ node,
+ depth,
+ isFirst,
+ isLast,
+ onEdit,
+ onMoveUp,
+ onMoveDown,
+ onMoveTo,
+ onAddGroup,
+ isExpanded,
+ onToggleExpand,
+ isPending,
+}: TreeItemProps) {
+ const isMenuGroup = node.nodeType === "menu_group";
+ const isGroup = node.nodeType === "group";
+ const isMenu = node.nodeType === "menu";
+ const isTopLevel = node.parentId === null;
+ const hasChildren = node.children && node.children.length > 0;
+ const isExpandable = isMenuGroup || isGroup;
+
+ // Move To is disabled for:
+ // - menu_group (always at top level, cannot be moved)
+ // - top-level menu (parentId === null, can only reorder with up/down)
+ const canMoveTo = !isMenuGroup && !isTopLevel;
+
+ const getIcon = () => {
+ if (isMenuGroup || isGroup) {
+ return isExpanded ? (
+ <FolderOpen className="h-4 w-4 text-amber-500" />
+ ) : (
+ <Folder className="h-4 w-4 text-amber-500" />
+ );
+ }
+ return <File className="h-4 w-4 text-slate-500" />;
+ };
+
+ const getTypeLabel = () => {
+ switch (node.nodeType) {
+ case "menu_group": return "Menu Group";
+ case "group": return "Group";
+ case "menu": return "Menu";
+ default: return "";
+ }
+ };
+
+ return (
+ <div
+ className={cn(
+ "flex items-center gap-2 px-2 py-1.5 rounded-md border bg-background hover:bg-accent/50 transition-colors",
+ !node.isActive && "opacity-50 bg-muted/30 border-dashed"
+ )}
+ style={{ marginLeft: depth * 24 }}
+ >
+ {/* Expand/Collapse */}
+ {isExpandable ? (
+ <button
+ onClick={onToggleExpand}
+ className="p-0.5 hover:bg-accent rounded shrink-0"
+ >
+ {isExpanded ? (
+ <ChevronDown className="h-4 w-4" />
+ ) : (
+ <ChevronRight className="h-4 w-4" />
+ )}
+ </button>
+ ) : (
+ <div className="w-5 shrink-0" />
+ )}
+
+ {/* Icon */}
+ {getIcon()}
+
+ {/* Title */}
+ <span className={cn(
+ "flex-1 text-sm font-medium truncate min-w-0",
+ !node.isActive && "line-through text-muted-foreground"
+ )}>
+ {node.titleKo}
+ {node.titleEn && (
+ <span className="text-muted-foreground font-normal"> [{node.titleEn}]</span>
+ )}
+ </span>
+
+ {/* Hidden indicator */}
+ {!node.isActive && (
+ <EyeOff className="h-3.5 w-3.5 text-muted-foreground shrink-0" title="Hidden" />
+ )}
+
+ {/* Path (for menus) */}
+ {isMenu && node.menuPath && (
+ <span className="text-xs text-muted-foreground truncate max-w-[150px] shrink-0">
+ {node.menuPath}
+ </span>
+ )}
+
+ {/* Type Badge */}
+ <Badge variant={node.isActive ? "default" : "secondary"} className="text-xs shrink-0">
+ {getTypeLabel()}
+ </Badge>
+
+ {/* Active indicator */}
+ <div
+ className={cn(
+ "w-2 h-2 rounded-full shrink-0",
+ node.isActive ? "bg-green-500" : "bg-gray-400"
+ )}
+ title={node.isActive ? "Visible" : "Hidden"}
+ />
+
+ {/* Actions */}
+ <div className="flex items-center gap-1 shrink-0">
+ {/* Move Up */}
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-7 w-7"
+ onClick={() => onMoveUp(node.id)}
+ disabled={isFirst || isPending}
+ title="Move Up"
+ >
+ <ChevronUp className="h-4 w-4" />
+ </Button>
+
+ {/* Move Down */}
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-7 w-7"
+ onClick={() => onMoveDown(node.id)}
+ disabled={isLast || isPending}
+ title="Move Down"
+ >
+ <ChevronDown className="h-4 w-4" />
+ </Button>
+
+ {/* Move To (different parent) - disabled for top level nodes */}
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-7 w-7"
+ onClick={() => onMoveTo(node)}
+ disabled={!canMoveTo || isPending}
+ title={canMoveTo ? "Move To..." : "Cannot move top-level items"}
+ >
+ <ArrowUpDown className="h-4 w-4" />
+ </Button>
+
+ {/* Edit */}
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-7 w-7"
+ onClick={() => onEdit(node)}
+ disabled={isPending}
+ title="Edit"
+ >
+ <Pencil className="h-4 w-4" />
+ </Button>
+
+ {/* Add Sub-Group (for menu groups only) */}
+ {isMenuGroup && (
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-7 w-7"
+ onClick={() => onAddGroup(node.id)}
+ disabled={isPending}
+ title="Add Sub-Group"
+ >
+ <Plus className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ </div>
+ );
+}
+
+export function MenuTree({
+ nodes,
+ onEdit,
+ onMoveUp,
+ onMoveDown,
+ onMoveTo,
+ onAddGroup,
+ expandedIds,
+ onExpandedIdsChange,
+ isPending,
+}: MenuTreeProps) {
+ const toggleExpand = useCallback((nodeId: number) => {
+ const next = new Set(expandedIds);
+ if (next.has(nodeId)) {
+ next.delete(nodeId);
+ } else {
+ next.add(nodeId);
+ }
+ onExpandedIdsChange(next);
+ }, [expandedIds, onExpandedIdsChange]);
+
+ const renderTree = (nodeList: MenuTreeNode[], depth: number) => {
+ return nodeList.map((node, index) => {
+ const isExpanded = expandedIds.has(node.id);
+ const isExpandable = node.nodeType === "menu_group" || node.nodeType === "group";
+ const hasChildren = node.children && node.children.length > 0;
+
+ return (
+ <div key={node.id} className="space-y-1">
+ <TreeItem
+ node={node}
+ depth={depth}
+ isFirst={index === 0}
+ isLast={index === nodeList.length - 1}
+ onEdit={onEdit}
+ onMoveUp={onMoveUp}
+ onMoveDown={onMoveDown}
+ onMoveTo={onMoveTo}
+ onAddGroup={onAddGroup}
+ isExpanded={isExpanded}
+ onToggleExpand={() => toggleExpand(node.id)}
+ isPending={isPending}
+ />
+ {isExpandable && isExpanded && hasChildren && (
+ <div className="space-y-1">
+ {renderTree(node.children!, depth + 1)}
+ </div>
+ )}
+ </div>
+ );
+ });
+ };
+
+ return <div className="space-y-1">{renderTree(nodes, 0)}</div>;
+}
+
+
diff --git a/lib/menu-v2/components/move-to-dialog.tsx b/lib/menu-v2/components/move-to-dialog.tsx
new file mode 100644
index 00000000..7253708b
--- /dev/null
+++ b/lib/menu-v2/components/move-to-dialog.tsx
@@ -0,0 +1,87 @@
+"use client";
+
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { cn } from "@/lib/utils";
+import { Folder, FolderOpen, Home } from "lucide-react";
+import type { MenuTreeNode } from "../types";
+
+interface MoveToDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ node: MenuTreeNode | null;
+ availableParents: { id: number | null; title: string; depth: number }[];
+ onMove: (newParentId: number | null) => void;
+}
+
+export function MoveToDialog({
+ open,
+ onOpenChange,
+ node,
+ availableParents,
+ onMove,
+}: MoveToDialogProps) {
+ if (!node) return null;
+
+ const isCurrent = (parentId: number | null) => node.parentId === parentId;
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-md">
+ <DialogHeader>
+ <DialogTitle>Move To</DialogTitle>
+ <DialogDescription>
+ Select a new location for &quot;{node.titleKo}&quot;
+ </DialogDescription>
+ </DialogHeader>
+
+ <ScrollArea className="max-h-[400px]">
+ <div className="space-y-0.5 p-1">
+ {availableParents.map((parent) => (
+ <Button
+ key={parent.id ?? 'root'}
+ variant={isCurrent(parent.id) ? "secondary" : "ghost"}
+ className={cn(
+ "w-full justify-start h-auto py-2 text-sm",
+ parent.depth === 0 && "font-medium",
+ parent.depth === 1 && "font-medium",
+ parent.depth === 2 && "text-muted-foreground"
+ )}
+ style={{ paddingLeft: parent.depth * 20 + 8 }}
+ onClick={() => onMove(parent.id)}
+ disabled={isCurrent(parent.id)}
+ >
+ {parent.id === null ? (
+ <Home className="mr-2 h-4 w-4 text-blue-500 shrink-0" />
+ ) : parent.depth === 1 ? (
+ <FolderOpen className="mr-2 h-4 w-4 text-amber-500 shrink-0" />
+ ) : (
+ <Folder className="mr-2 h-4 w-4 text-amber-400 shrink-0" />
+ )}
+ <span className="truncate">{parent.title}</span>
+ {isCurrent(parent.id) && (
+ <span className="ml-auto text-xs text-muted-foreground shrink-0">(current)</span>
+ )}
+ </Button>
+ ))}
+ </div>
+ </ScrollArea>
+
+ <div className="flex justify-end">
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ Cancel
+ </Button>
+ </div>
+ </DialogContent>
+ </Dialog>
+ );
+}
+
+
diff --git a/lib/menu-v2/components/unassigned-menus-panel.tsx b/lib/menu-v2/components/unassigned-menus-panel.tsx
new file mode 100644
index 00000000..2c914f2a
--- /dev/null
+++ b/lib/menu-v2/components/unassigned-menus-panel.tsx
@@ -0,0 +1,178 @@
+"use client";
+
+import { useState } from "react";
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Badge } from "@/components/ui/badge";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Search, FileQuestion, ArrowRight, Pencil, Link } from "lucide-react";
+import type { MenuTreeNode } from "../types";
+
+interface UnassignedMenusPanelProps {
+ menus: MenuTreeNode[];
+ onAssign: (menuId: number, groupId: number) => void;
+ onActivateAsTopLevel: (menuId: number) => void;
+ onEdit: (menu: MenuTreeNode) => void;
+ availableGroups: { id: number; title: string; parentTitle?: string }[];
+}
+
+export function UnassignedMenusPanel({
+ menus,
+ onAssign,
+ onActivateAsTopLevel,
+ onEdit,
+ availableGroups,
+}: UnassignedMenusPanelProps) {
+ const [searchTerm, setSearchTerm] = useState("");
+ const [selectedMenu, setSelectedMenu] = useState<number | null>(null);
+
+ const filteredMenus = menus.filter(
+ (menu) =>
+ menu.titleKo.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ menu.menuPath?.toLowerCase().includes(searchTerm.toLowerCase())
+ );
+
+ return (
+ <Card className="h-full">
+ <CardHeader className="pb-3">
+ <CardTitle className="text-base flex items-center gap-2">
+ <FileQuestion className="h-4 w-4" />
+ Unassigned Menus ({menus.length})
+ </CardTitle>
+ <CardDescription>
+ Assign to a group or activate as a top-level link.
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-3">
+ {/* Search */}
+ <div className="relative">
+ <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
+ <Input
+ placeholder="Search..."
+ value={searchTerm}
+ onChange={(e) => setSearchTerm(e.target.value)}
+ className="pl-8"
+ />
+ </div>
+
+ {/* Menu List */}
+ <ScrollArea className="h-[400px]">
+ <div className="space-y-2">
+ {filteredMenus.length === 0 ? (
+ <p className="text-sm text-muted-foreground text-center py-4">
+ {searchTerm ? "No results found." : "No unassigned menus."}
+ </p>
+ ) : (
+ filteredMenus.map((menu) => (
+ <div
+ key={menu.id}
+ className={cn(
+ "p-3 rounded-md border bg-background hover:bg-accent/50 transition-colors",
+ selectedMenu === menu.id && "ring-2 ring-primary"
+ )}
+ >
+ <div className="flex items-start justify-between gap-2">
+ <div className="flex-1 min-w-0">
+ <div className="flex items-center gap-2">
+ <span className="font-medium text-sm">{menu.titleKo}</span>
+ <Badge variant="secondary" className="text-xs">
+ Inactive
+ </Badge>
+ </div>
+ <p className="text-xs text-muted-foreground truncate mt-1">
+ {menu.menuPath}
+ </p>
+ </div>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-7 w-7 shrink-0"
+ onClick={() => onEdit(menu)}
+ >
+ <Pencil className="h-3.5 w-3.5" />
+ </Button>
+ </div>
+
+ {/* Group Selection (expanded) */}
+ {selectedMenu === menu.id ? (
+ <div className="mt-3 pt-3 border-t space-y-2">
+ {/* Activate as Top-Level */}
+ <div>
+ <p className="text-xs text-muted-foreground mb-2">
+ Activate as top-level link:
+ </p>
+ <Button
+ variant="default"
+ size="sm"
+ className="text-xs h-7"
+ onClick={() => {
+ onActivateAsTopLevel(menu.id);
+ setSelectedMenu(null);
+ }}
+ >
+ <Link className="mr-1 h-3 w-3" />
+ Activate as Top-Level
+ </Button>
+ </div>
+
+ {/* Assign to Group */}
+ {availableGroups.length > 0 && (
+ <div>
+ <p className="text-xs text-muted-foreground mb-2">
+ Or assign to group:
+ </p>
+ <div className="flex flex-wrap gap-1">
+ {availableGroups.map((group) => (
+ <Button
+ key={group.id}
+ variant="outline"
+ size="sm"
+ className="text-xs h-7"
+ onClick={() => {
+ onAssign(menu.id, group.id);
+ setSelectedMenu(null);
+ }}
+ >
+ {group.parentTitle && (
+ <span className="text-muted-foreground mr-1">
+ {group.parentTitle} &gt;
+ </span>
+ )}
+ {group.title}
+ <ArrowRight className="ml-1 h-3 w-3" />
+ </Button>
+ ))}
+ </div>
+ </div>
+ )}
+
+ <Button
+ variant="ghost"
+ size="sm"
+ className="text-xs"
+ onClick={() => setSelectedMenu(null)}
+ >
+ Cancel
+ </Button>
+ </div>
+ ) : (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="mt-2 text-xs w-full"
+ onClick={() => setSelectedMenu(menu.id)}
+ >
+ Assign / Activate
+ </Button>
+ )}
+ </div>
+ ))
+ )}
+ </div>
+ </ScrollArea>
+ </CardContent>
+ </Card>
+ );
+}
diff --git a/lib/menu-v2/permission-service.ts b/lib/menu-v2/permission-service.ts
new file mode 100644
index 00000000..e495ba23
--- /dev/null
+++ b/lib/menu-v2/permission-service.ts
@@ -0,0 +1,186 @@
+'use server';
+
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/app/api/auth/[...nextauth]/route";
+import { getAllScreens, getAuthsByScreenId, getUserRoles, type ScreenEvcp, type RoleRelEvcp } from "@/lib/nonsap/db";
+import { getActiveMenuTree } from "./service";
+import type { MenuDomain, MenuTreeNode, MenuTreeActiveResult } from "./types";
+import db from "@/db/db";
+import { users } from "@/db/schema/users";
+import { eq } from "drizzle-orm";
+
+/**
+ * Oracle 권한 체크 스킵 여부 확인
+ * SKIP_ORACLE_PERMISSION_CHECK=true인 경우 Oracle DB 권한 체크를 건너뜀
+ */
+function shouldSkipOraclePermissionCheck(): boolean {
+ return process.env.SKIP_ORACLE_PERMISSION_CHECK === 'true';
+}
+
+/**
+ * 사용자 ID로 employeeNumber 조회
+ */
+async function getEmployeeNumberByUserId(userId: number): Promise<string | null> {
+ const [user] = await db.select({ employeeNumber: users.employeeNumber })
+ .from(users)
+ .where(eq(users.id, userId))
+ .limit(1);
+
+ return user?.employeeNumber || null;
+}
+
+/**
+ * Get menu tree filtered by user permissions
+ *
+ * @param domain - Domain (evcp | partners)
+ * @param userId - Optional user ID. If not provided, gets from session.
+ *
+ * Environment variable SKIP_ORACLE_PERMISSION_CHECK=true skips Oracle permission check
+ */
+export async function getVisibleMenuTree(
+ domain: MenuDomain,
+ userId?: number
+): Promise<MenuTreeActiveResult> {
+ const { tree: menuTree } = await getActiveMenuTree(domain);
+
+ // Partners domain uses its own permission system (not implemented)
+ if (domain === 'partners') {
+ return { tree: menuTree };
+ }
+
+ // Skip Oracle permission check in development
+ if (shouldSkipOraclePermissionCheck()) {
+ return { tree: menuTree };
+ }
+
+ // Get userId from session if not provided
+ let effectiveUserId = userId;
+ if (!effectiveUserId) {
+ const session = await getServerSession(authOptions);
+ effectiveUserId = session?.user?.id ? parseInt(session.user.id, 10) : undefined;
+ }
+
+ if (!effectiveUserId) {
+ return { tree: menuTree };
+ }
+
+ // Get employeeNumber from userId
+ const empNo = await getEmployeeNumberByUserId(effectiveUserId);
+ if (!empNo) {
+ return { tree: menuTree };
+ }
+
+ let screens: ScreenEvcp[];
+ let userRoles: RoleRelEvcp[];
+
+ try {
+ [screens, userRoles] = await Promise.all([
+ getAllScreens(),
+ getUserRoles(empNo)
+ ]);
+ } catch (error) {
+ // Oracle DB 연결 실패 시 전체 메뉴 반환 (에러로 인한 접근 차단 방지)
+ console.error('[menu-v2] Oracle permission check failed, returning all menus:', error);
+ return { tree: menuTree };
+ }
+
+ const userRoleIds = new Set(userRoles.map(r => r.ROLE_ID));
+ const screenMap = new Map<string, ScreenEvcp>(screens.map(s => [s.SCR_URL, s]));
+
+ // 메뉴 필터링 (최상위 menu, menu_group, group 모두 처리)
+ async function filterByPermission(nodes: MenuTreeNode[]): Promise<MenuTreeNode[]> {
+ const result: MenuTreeNode[] = [];
+
+ for (const node of nodes) {
+ // 메뉴 노드 (최상위 단일 링크 또는 하위 메뉴)
+ if (node.nodeType === 'menu' && node.menuPath) {
+ const screen = screenMap.get(node.menuPath);
+
+ // 화면 정보가 없거나 SCRT_CHK_YN === 'N' 이면 표시
+ if (!screen || screen.SCRT_CHK_YN === 'N') {
+ result.push(node);
+ continue;
+ }
+
+ // SCRT_CHK_YN === 'Y' 이면 권한 체크
+ if (screen.SCRT_CHK_YN === 'Y') {
+ const scrIdToCheck = node.scrId || screen.SCR_ID;
+ const auths = await getAuthsByScreenId(scrIdToCheck);
+
+ const hasAccess = auths.some(auth => {
+ if (auth.ACSR_GB_CD === 'U' && auth.ACSR_ID === empNo) return true;
+ if (auth.ACSR_GB_CD === 'R' && userRoleIds.has(auth.ACSR_ID)) return true;
+ return false;
+ });
+
+ if (hasAccess) result.push(node);
+ }
+ }
+ // 메뉴그룹 또는 그룹 (자식 필터링 후 자식이 있으면 포함)
+ else if (node.nodeType === 'menu_group' || node.nodeType === 'group') {
+ const filteredChildren = await filterByPermission(node.children || []);
+ if (filteredChildren.length > 0) {
+ result.push({ ...node, children: filteredChildren });
+ }
+ }
+ }
+
+ return result;
+ }
+
+ const filteredTree = await filterByPermission(menuTree);
+
+ return { tree: filteredTree };
+}
+
+/**
+ * 특정 메뉴 경로에 대한 접근 권한 확인
+ *
+ * 환경변수 SKIP_ORACLE_PERMISSION_CHECK=true인 경우 항상 true 반환
+ */
+export async function checkMenuAccess(
+ menuPath: string,
+ userId: number
+): Promise<boolean> {
+ // Oracle 권한 체크 스킵 설정된 경우
+ if (shouldSkipOraclePermissionCheck()) {
+ return true;
+ }
+
+ const empNo = await getEmployeeNumberByUserId(userId);
+ if (!empNo) return false;
+
+ try {
+ const screens = await getAllScreens();
+ const screen = screens.find(s => s.SCR_URL === menuPath);
+
+ // 등록되지 않은 화면 또는 권한 체크가 필요 없는 화면
+ if (!screen || screen.SCRT_CHK_YN === 'N') {
+ return true;
+ }
+
+ // 삭제된 화면
+ if (screen.DEL_YN === 'Y') {
+ return false;
+ }
+
+ // 권한 체크
+ const [auths, userRoles] = await Promise.all([
+ getAuthsByScreenId(screen.SCR_ID),
+ getUserRoles(empNo)
+ ]);
+
+ const userRoleIds = new Set(userRoles.map(r => r.ROLE_ID));
+
+ return auths.some(auth => {
+ if (auth.ACSR_GB_CD === 'U' && auth.ACSR_ID === empNo) return true;
+ if (auth.ACSR_GB_CD === 'R' && userRoleIds.has(auth.ACSR_ID)) return true;
+ return false;
+ });
+ } catch (error) {
+ // Oracle DB 연결 실패 시 접근 허용 (에러로 인한 차단 방지)
+ console.error('[menu-v2] Oracle permission check failed for path:', menuPath, error);
+ return true;
+ }
+}
+
diff --git a/lib/menu-v2/service.ts b/lib/menu-v2/service.ts
new file mode 100644
index 00000000..39ca144a
--- /dev/null
+++ b/lib/menu-v2/service.ts
@@ -0,0 +1,605 @@
+'use server';
+
+import fs from 'fs';
+import path from 'path';
+import db from "@/db/db";
+import { menuTreeNodes } from "@/db/schema/menu-v2";
+import { eq, and, asc, inArray, isNull } from "drizzle-orm";
+import { revalidatePath } from "next/cache";
+import type {
+ MenuDomain,
+ MenuTreeNode,
+ MenuTreeAdminResult,
+ MenuTreeActiveResult,
+ CreateMenuGroupInput,
+ CreateGroupInput,
+ UpdateNodeInput,
+ ReorderNodeInput,
+ DiscoveredMenu
+} from "./types";
+import { DOMAIN_APP_PATHS } from "./types";
+
+// 도메인별 전체 트리 조회 (관리 화면용)
+export async function getMenuTreeForAdmin(domain: MenuDomain): Promise<MenuTreeAdminResult> {
+ const nodes = await db.select()
+ .from(menuTreeNodes)
+ .where(eq(menuTreeNodes.domain, domain))
+ .orderBy(asc(menuTreeNodes.sortOrder));
+
+ // 트리에 포함될 노드들:
+ // - menu_group (최상위 드롭다운)
+ // - group (드롭다운 내 그룹)
+ // - 배정된 menu (parentId !== null)
+ // - 최상위 menu (parentId === null, isActive === true) - 단일 링크
+ const treeNodes = nodes.filter(n =>
+ n.nodeType === 'menu_group' ||
+ n.nodeType === 'group' ||
+ (n.nodeType === 'menu' && n.parentId !== null) ||
+ (n.nodeType === 'menu' && n.parentId === null && n.isActive)
+ ) as MenuTreeNode[];
+
+ const tree = buildTree(treeNodes);
+
+ // 미배정 메뉴 (parentId가 null이고 isActive가 false인 menu)
+ const unassigned = nodes.filter(n =>
+ n.nodeType === 'menu' && n.parentId === null && !n.isActive
+ ) as MenuTreeNode[];
+
+ return { tree, unassigned };
+}
+
+// 도메인별 활성 트리 조회 (헤더용)
+export async function getActiveMenuTree(domain: MenuDomain): Promise<MenuTreeActiveResult> {
+ const nodes = await db.select()
+ .from(menuTreeNodes)
+ .where(and(
+ eq(menuTreeNodes.domain, domain),
+ eq(menuTreeNodes.isActive, true)
+ ))
+ .orderBy(asc(menuTreeNodes.sortOrder));
+
+ // 트리에 포함될 노드들:
+ // - menu_group (최상위 드롭다운)
+ // - group (드롭다운 내 그룹)
+ // - 배정된 menu (parentId !== null)
+ // - 최상위 menu (parentId === null) - 단일 링크
+ const treeNodes = nodes.filter(n =>
+ n.nodeType === 'menu_group' ||
+ n.nodeType === 'group' ||
+ n.nodeType === 'menu'
+ ) as MenuTreeNode[];
+
+ const tree = buildTree(treeNodes);
+
+ return { tree };
+}
+
+// 메뉴그룹 생성 (드롭다운)
+export async function createMenuGroup(domain: MenuDomain, data: CreateMenuGroupInput) {
+ const [result] = await db.insert(menuTreeNodes).values({
+ domain,
+ parentId: null,
+ nodeType: 'menu_group',
+ titleKo: data.titleKo,
+ titleEn: data.titleEn,
+ sortOrder: data.sortOrder ?? 0,
+ isActive: true,
+ }).returning();
+
+ revalidatePath('/evcp/menu-v2');
+ return result;
+}
+
+// 그룹 생성 (메뉴그룹 하위)
+export async function createGroup(domain: MenuDomain, data: CreateGroupInput) {
+ const [result] = await db.insert(menuTreeNodes).values({
+ domain,
+ parentId: data.parentId,
+ nodeType: 'group',
+ titleKo: data.titleKo,
+ titleEn: data.titleEn,
+ sortOrder: data.sortOrder ?? 0,
+ isActive: true,
+ }).returning();
+
+ revalidatePath('/evcp/menu-v2');
+ return result;
+}
+
+// 최상위 메뉴 생성 (단일 링크 - 기존 additional 역할)
+export async function createTopLevelMenu(domain: MenuDomain, data: {
+ titleKo: string;
+ titleEn?: string;
+ menuPath: string;
+ sortOrder?: number;
+}) {
+ const [result] = await db.insert(menuTreeNodes).values({
+ domain,
+ parentId: null,
+ nodeType: 'menu',
+ titleKo: data.titleKo,
+ titleEn: data.titleEn,
+ menuPath: data.menuPath,
+ sortOrder: data.sortOrder ?? 0,
+ isActive: true,
+ }).returning();
+
+ revalidatePath('/evcp/menu-v2');
+ return result;
+}
+
+// 노드 이동 (드래그앤드롭)
+export async function moveNode(nodeId: number, newParentId: number | null, newSortOrder: number) {
+ await db.update(menuTreeNodes)
+ .set({
+ parentId: newParentId,
+ sortOrder: newSortOrder,
+ updatedAt: new Date()
+ })
+ .where(eq(menuTreeNodes.id, nodeId));
+
+ revalidatePath('/evcp/menu-v2');
+}
+
+// 노드 수정
+export async function updateNode(nodeId: number, data: UpdateNodeInput) {
+ await db.update(menuTreeNodes)
+ .set({ ...data, updatedAt: new Date() })
+ .where(eq(menuTreeNodes.id, nodeId));
+
+ revalidatePath('/evcp/menu-v2');
+}
+
+// 노드 삭제
+export async function deleteNode(nodeId: number) {
+ const [node] = await db.select().from(menuTreeNodes).where(eq(menuTreeNodes.id, nodeId)).limit(1);
+
+ if (!node) return;
+
+ if (node.nodeType === 'menu') {
+ // 최상위 메뉴(parentId === null)는 직접 삭제 가능
+ // 하위 메뉴(parentId !== null)는 미배정으로
+ if (node.parentId === null) {
+ await db.delete(menuTreeNodes).where(eq(menuTreeNodes.id, nodeId));
+ } else {
+ await db.update(menuTreeNodes)
+ .set({ parentId: null, isActive: false, updatedAt: new Date() })
+ .where(eq(menuTreeNodes.id, nodeId));
+ }
+ } else {
+ // 메뉴그룹/그룹 삭제 시, 하위 메뉴는 미배정으로
+ const children = await db.select({ id: menuTreeNodes.id, nodeType: menuTreeNodes.nodeType })
+ .from(menuTreeNodes)
+ .where(eq(menuTreeNodes.parentId, nodeId));
+
+ for (const child of children) {
+ if (child.nodeType === 'menu') {
+ // 메뉴는 미배정으로
+ await db.update(menuTreeNodes)
+ .set({ parentId: null, isActive: false, updatedAt: new Date() })
+ .where(eq(menuTreeNodes.id, child.id));
+ } else if (child.nodeType === 'group') {
+ // 그룹의 하위 메뉴도 미배정으로
+ await db.update(menuTreeNodes)
+ .set({ parentId: null, isActive: false, updatedAt: new Date() })
+ .where(eq(menuTreeNodes.parentId, child.id));
+
+ // 그룹 삭제
+ await db.delete(menuTreeNodes).where(eq(menuTreeNodes.id, child.id));
+ }
+ }
+
+ // 본 노드 삭제
+ await db.delete(menuTreeNodes).where(eq(menuTreeNodes.id, nodeId));
+ }
+
+ revalidatePath('/evcp/menu-v2');
+}
+
+// 순서 일괄 변경
+export async function reorderNodes(updates: ReorderNodeInput[]) {
+ for (const { id, sortOrder } of updates) {
+ await db.update(menuTreeNodes)
+ .set({ sortOrder, updatedAt: new Date() })
+ .where(eq(menuTreeNodes.id, id));
+ }
+ revalidatePath('/evcp/menu-v2');
+}
+
+// 미배정 메뉴를 특정 그룹에 배정
+export async function assignMenuToGroup(menuId: number, groupId: number) {
+ await db.update(menuTreeNodes)
+ .set({
+ parentId: groupId,
+ isActive: true,
+ updatedAt: new Date()
+ })
+ .where(eq(menuTreeNodes.id, menuId));
+
+ revalidatePath('/evcp/menu-v2');
+}
+
+// 미배정 메뉴를 최상위 메뉴로 활성화
+export async function activateAsTopLevelMenu(menuId: number) {
+ await db.update(menuTreeNodes)
+ .set({
+ parentId: null,
+ isActive: true,
+ updatedAt: new Date()
+ })
+ .where(eq(menuTreeNodes.id, menuId));
+
+ revalidatePath('/evcp/menu-v2');
+}
+
+// 단일 노드 조회
+export async function getNodeById(nodeId: number): Promise<MenuTreeNode | null> {
+ const [node] = await db.select()
+ .from(menuTreeNodes)
+ .where(eq(menuTreeNodes.id, nodeId))
+ .limit(1);
+
+ return node as MenuTreeNode | null;
+}
+
+// Helper: Convert flat list to tree
+function buildTree(nodes: MenuTreeNode[]): MenuTreeNode[] {
+ const nodeMap = new Map<number, MenuTreeNode>();
+ const roots: MenuTreeNode[] = [];
+
+ nodes.forEach(node => {
+ nodeMap.set(node.id, { ...node, children: [] });
+ });
+
+ nodes.forEach(node => {
+ const current = nodeMap.get(node.id)!;
+ if (node.parentId === null) {
+ roots.push(current);
+ } else {
+ const parent = nodeMap.get(node.parentId);
+ if (parent) {
+ if (!parent.children) parent.children = [];
+ parent.children.push(current);
+ }
+ }
+ });
+
+ const sortChildren = (nodes: MenuTreeNode[]) => {
+ nodes.sort((a, b) => a.sortOrder - b.sortOrder);
+ nodes.forEach(node => {
+ if (node.children?.length) {
+ sortChildren(node.children);
+ }
+ });
+ };
+ sortChildren(roots);
+
+ return roots;
+}
+
+// ============================================
+// Menu Discovery & Sync (Server Actions)
+// ============================================
+
+const DYNAMIC_SEGMENT_PATTERN = /^\[.+\]$/;
+
+/**
+ * Discover pages from app router for a specific domain
+ */
+function discoverMenusFromAppRouter(domain: MenuDomain): DiscoveredMenu[] {
+ const { appDir, basePath } = DOMAIN_APP_PATHS[domain];
+ const menus: DiscoveredMenu[] = [];
+
+ function scanDirectory(dir: string, currentPath: string[], routeGroup: string) {
+ const absoluteDir = path.resolve(process.cwd(), dir);
+
+ if (!fs.existsSync(absoluteDir)) return;
+
+ const entries = fs.readdirSync(absoluteDir, { withFileTypes: true });
+
+ for (const entry of entries) {
+ const fullPath = path.join(absoluteDir, entry.name);
+
+ if (entry.isDirectory()) {
+ if (entry.name.startsWith('(') && entry.name.endsWith(')')) {
+ scanDirectory(fullPath, currentPath, entry.name);
+ }
+ else if (DYNAMIC_SEGMENT_PATTERN.test(entry.name)) {
+ continue;
+ }
+ else {
+ scanDirectory(fullPath, [...currentPath, entry.name], routeGroup);
+ }
+ }
+ else if (entry.name === 'page.tsx') {
+ const menuPath = basePath + (currentPath.length > 0 ? '/' + currentPath.join('/') : '');
+ menus.push({
+ domain,
+ menuPath,
+ pageFilePath: fullPath,
+ routeGroup
+ });
+ }
+ }
+ }
+
+ scanDirectory(appDir, [], '');
+ return menus;
+}
+
+/**
+ * Sync discovered menus for a specific domain
+ */
+export async function syncDiscoveredMenus(domain: MenuDomain): Promise<{ added: number; removed: number }> {
+ const discovered = discoverMenusFromAppRouter(domain);
+
+ const existing = await db.select({
+ id: menuTreeNodes.id,
+ menuPath: menuTreeNodes.menuPath
+ })
+ .from(menuTreeNodes)
+ .where(and(
+ eq(menuTreeNodes.domain, domain),
+ inArray(menuTreeNodes.nodeType, ['menu', 'additional'])
+ ));
+
+ const existingPaths = new Set(existing.map(e => e.menuPath).filter(Boolean));
+
+ const newMenus = discovered.filter(d => !existingPaths.has(d.menuPath));
+ let added = 0;
+
+ for (const menu of newMenus) {
+ const pathSegments = menu.menuPath.split('/').filter(Boolean);
+ const lastSegment = pathSegments[pathSegments.length - 1] || 'unknown';
+
+ await db.insert(menuTreeNodes).values({
+ domain,
+ parentId: null,
+ nodeType: 'menu',
+ sortOrder: 0,
+ titleKo: lastSegment,
+ titleEn: lastSegment,
+ menuPath: menu.menuPath,
+ isActive: false,
+ });
+ added++;
+ }
+
+ revalidatePath('/evcp/menu-v2');
+ return { added, removed: 0 };
+}
+
+/**
+ * Sync all domains
+ */
+export async function syncAllDomains(): Promise<Record<MenuDomain, { added: number; removed: number }>> {
+ const [evcp, partners] = await Promise.all([
+ syncDiscoveredMenus('evcp'),
+ syncDiscoveredMenus('partners')
+ ]);
+ return { evcp, partners };
+}
+
+/**
+ * Get discovered menus without syncing
+ */
+export async function getDiscoveredMenus(): Promise<Record<MenuDomain, DiscoveredMenu[]>> {
+ return {
+ evcp: discoverMenusFromAppRouter('evcp'),
+ partners: discoverMenusFromAppRouter('partners')
+ };
+}
+
+// ============================================
+// Move Node Helpers
+// ============================================
+
+/**
+ * Move node up within same parent (decrease sort order)
+ */
+export async function moveNodeUp(nodeId: number): Promise<void> {
+ const [node] = await db.select()
+ .from(menuTreeNodes)
+ .where(eq(menuTreeNodes.id, nodeId))
+ .limit(1);
+
+ if (!node) return;
+
+ // Get siblings (nodes with same parent)
+ const siblings = await db.select()
+ .from(menuTreeNodes)
+ .where(node.parentId === null
+ ? and(eq(menuTreeNodes.domain, node.domain), isNull(menuTreeNodes.parentId))
+ : eq(menuTreeNodes.parentId, node.parentId)
+ )
+ .orderBy(asc(menuTreeNodes.sortOrder));
+
+ // Find current index
+ const currentIndex = siblings.findIndex(s => s.id === nodeId);
+ if (currentIndex <= 0) return; // Already at top
+
+ // Swap sort orders with previous node
+ const prevNode = siblings[currentIndex - 1];
+ const prevSortOrder = prevNode.sortOrder;
+ const currentSortOrder = node.sortOrder;
+
+ // If sort orders are the same, assign unique values
+ if (prevSortOrder === currentSortOrder) {
+ await db.update(menuTreeNodes)
+ .set({ sortOrder: currentIndex - 1, updatedAt: new Date() })
+ .where(eq(menuTreeNodes.id, nodeId));
+ await db.update(menuTreeNodes)
+ .set({ sortOrder: currentIndex, updatedAt: new Date() })
+ .where(eq(menuTreeNodes.id, prevNode.id));
+ } else {
+ await db.update(menuTreeNodes)
+ .set({ sortOrder: prevSortOrder, updatedAt: new Date() })
+ .where(eq(menuTreeNodes.id, nodeId));
+ await db.update(menuTreeNodes)
+ .set({ sortOrder: currentSortOrder, updatedAt: new Date() })
+ .where(eq(menuTreeNodes.id, prevNode.id));
+ }
+
+ revalidatePath('/evcp/menu-v2');
+}
+
+/**
+ * Move node down within same parent (increase sort order)
+ */
+export async function moveNodeDown(nodeId: number): Promise<void> {
+ const [node] = await db.select()
+ .from(menuTreeNodes)
+ .where(eq(menuTreeNodes.id, nodeId))
+ .limit(1);
+
+ if (!node) return;
+
+ // Get siblings (nodes with same parent)
+ const siblings = await db.select()
+ .from(menuTreeNodes)
+ .where(node.parentId === null
+ ? and(eq(menuTreeNodes.domain, node.domain), isNull(menuTreeNodes.parentId))
+ : eq(menuTreeNodes.parentId, node.parentId)
+ )
+ .orderBy(asc(menuTreeNodes.sortOrder));
+
+ // Find current index
+ const currentIndex = siblings.findIndex(s => s.id === nodeId);
+ if (currentIndex >= siblings.length - 1) return; // Already at bottom
+
+ // Swap sort orders with next node
+ const nextNode = siblings[currentIndex + 1];
+ const nextSortOrder = nextNode.sortOrder;
+ const currentSortOrder = node.sortOrder;
+
+ // If sort orders are the same, assign unique values
+ if (nextSortOrder === currentSortOrder) {
+ await db.update(menuTreeNodes)
+ .set({ sortOrder: currentIndex + 1, updatedAt: new Date() })
+ .where(eq(menuTreeNodes.id, nodeId));
+ await db.update(menuTreeNodes)
+ .set({ sortOrder: currentIndex, updatedAt: new Date() })
+ .where(eq(menuTreeNodes.id, nextNode.id));
+ } else {
+ await db.update(menuTreeNodes)
+ .set({ sortOrder: nextSortOrder, updatedAt: new Date() })
+ .where(eq(menuTreeNodes.id, nodeId));
+ await db.update(menuTreeNodes)
+ .set({ sortOrder: currentSortOrder, updatedAt: new Date() })
+ .where(eq(menuTreeNodes.id, nextNode.id));
+ }
+
+ revalidatePath('/evcp/menu-v2');
+}
+
+/**
+ * Move node to a different parent
+ */
+export async function moveNodeToParent(nodeId: number, newParentId: number | null): Promise<void> {
+ const [node] = await db.select()
+ .from(menuTreeNodes)
+ .where(eq(menuTreeNodes.id, nodeId))
+ .limit(1);
+
+ if (!node) return;
+
+ // Get max sort order in new parent
+ const siblings = await db.select({ sortOrder: menuTreeNodes.sortOrder })
+ .from(menuTreeNodes)
+ .where(newParentId === null
+ ? and(eq(menuTreeNodes.domain, node.domain), isNull(menuTreeNodes.parentId))
+ : eq(menuTreeNodes.parentId, newParentId)
+ )
+ .orderBy(asc(menuTreeNodes.sortOrder));
+
+ const maxSortOrder = siblings.length > 0 ? Math.max(...siblings.map(s => s.sortOrder)) + 1 : 0;
+
+ await db.update(menuTreeNodes)
+ .set({
+ parentId: newParentId,
+ sortOrder: maxSortOrder,
+ updatedAt: new Date()
+ })
+ .where(eq(menuTreeNodes.id, nodeId));
+
+ revalidatePath('/evcp/menu-v2');
+}
+
+/**
+ * Get all possible parent targets for a node (for Move To dialog)
+ * Returns items in tree order (same as Menu Structure display)
+ *
+ * Rules:
+ * - menu_group: Cannot be moved (always at top level)
+ * - group: Can only move to menu_group (not to root or other groups)
+ * - menu: Can move to root, menu_group, or group
+ */
+export async function getAvailableParents(
+ nodeId: number,
+ domain: MenuDomain,
+ nodeType: string
+): Promise<{ id: number | null; title: string; depth: number }[]> {
+ const nodes = await db.select()
+ .from(menuTreeNodes)
+ .where(and(
+ eq(menuTreeNodes.domain, domain),
+ inArray(menuTreeNodes.nodeType, ['menu_group', 'group'])
+ ))
+ .orderBy(asc(menuTreeNodes.sortOrder));
+
+ const result: { id: number | null; title: string; depth: number }[] = [];
+
+ // For menu nodes, allow moving to root (as top-level menu)
+ if (nodeType === 'menu') {
+ result.push({ id: null, title: 'Top Level (Root)', depth: 0 });
+ }
+
+ // Build tree structure
+ const nodeMap = new Map(nodes.map(n => [n.id, n]));
+ const menuGroups = nodes.filter(n => n.parentId === null && n.nodeType === 'menu_group');
+
+ // Helper to check if node is descendant of nodeId (prevent circular reference)
+ const isDescendantOf = (checkNode: typeof nodes[0], ancestorId: number): boolean => {
+ let parent = checkNode.parentId;
+ while (parent !== null) {
+ if (parent === ancestorId) return true;
+ const parentNode = nodeMap.get(parent);
+ parent = parentNode?.parentId ?? null;
+ }
+ return false;
+ };
+
+ // Traverse tree in order (menu_group -> its children groups)
+ for (const menuGroup of menuGroups) {
+ // Skip if it's the node being moved or its descendant
+ if (menuGroup.id === nodeId || isDescendantOf(menuGroup, nodeId)) continue;
+
+ // Add menu_group
+ result.push({
+ id: menuGroup.id,
+ title: menuGroup.titleKo,
+ depth: 1
+ });
+
+ // For group nodes, only menu_groups are valid targets (skip children)
+ if (nodeType === 'group') continue;
+
+ // Add children groups (sorted by sortOrder)
+ const childGroups = nodes
+ .filter(n => n.parentId === menuGroup.id && n.nodeType === 'group')
+ .sort((a, b) => a.sortOrder - b.sortOrder);
+
+ for (const group of childGroups) {
+ // Skip if it's the node being moved or its descendant
+ if (group.id === nodeId || isDescendantOf(group, nodeId)) continue;
+
+ result.push({
+ id: group.id,
+ title: group.titleKo,
+ depth: 2
+ });
+ }
+ }
+
+ return result;
+}
diff --git a/lib/menu-v2/types.ts b/lib/menu-v2/types.ts
new file mode 100644
index 00000000..1be8a4fe
--- /dev/null
+++ b/lib/menu-v2/types.ts
@@ -0,0 +1,103 @@
+// lib/menu-v2/types.ts
+
+export type NodeType = 'menu_group' | 'group' | 'menu' | 'additional';
+export type MenuDomain = 'evcp' | 'partners';
+
+export interface MenuTreeNode {
+ id: number;
+ domain: MenuDomain;
+ parentId: number | null;
+ nodeType: NodeType;
+ sortOrder: number;
+ titleKo: string;
+ titleEn: string | null;
+ descriptionKo: string | null;
+ descriptionEn: string | null;
+ menuPath: string | null;
+ icon: string | null;
+ scrId: string | null;
+ isActive: boolean;
+ manager1Id: number | null;
+ manager2Id: number | null;
+ createdAt: Date;
+ updatedAt: Date;
+ // 조회 시 추가되는 필드
+ children?: MenuTreeNode[];
+}
+
+export interface DiscoveredMenu {
+ domain: MenuDomain;
+ menuPath: string;
+ pageFilePath: string;
+ routeGroup: string;
+}
+
+// 도메인별 앱 라우터 경로 설정
+export const DOMAIN_APP_PATHS: Record<MenuDomain, {
+ appDir: string;
+ basePath: string;
+}> = {
+ evcp: {
+ appDir: 'app/[lng]/evcp/(evcp)',
+ basePath: '/evcp'
+ },
+ partners: {
+ appDir: 'app/[lng]/partners',
+ basePath: '/partners'
+ }
+};
+
+// 관리자용 트리 조회 결과 타입
+// tree: 메뉴그룹(드롭다운) + 최상위 메뉴(단일 링크) 통합
+export interface MenuTreeAdminResult {
+ tree: MenuTreeNode[];
+ unassigned: MenuTreeNode[];
+}
+
+// 헤더용 트리 조회 결과 타입
+// tree: 메뉴그룹(드롭다운) + 최상위 메뉴(단일 링크) 통합
+export interface MenuTreeActiveResult {
+ tree: MenuTreeNode[];
+}
+
+// 노드 생성 타입
+export interface CreateMenuGroupInput {
+ titleKo: string;
+ titleEn?: string;
+ sortOrder?: number;
+}
+
+export interface CreateGroupInput {
+ parentId: number;
+ titleKo: string;
+ titleEn?: string;
+ sortOrder?: number;
+}
+
+// 최상위 메뉴 생성 (단일 링크)
+export interface CreateTopLevelMenuInput {
+ titleKo: string;
+ titleEn?: string;
+ menuPath: string;
+ sortOrder?: number;
+}
+
+// 노드 업데이트 타입
+export interface UpdateNodeInput {
+ titleKo?: string;
+ titleEn?: string;
+ descriptionKo?: string;
+ descriptionEn?: string;
+ isActive?: boolean;
+ scrId?: string;
+ icon?: string;
+ manager1Id?: number | null;
+ manager2Id?: number | null;
+}
+
+// 순서 변경 타입
+export interface ReorderNodeInput {
+ id: number;
+ sortOrder: number;
+}
+
diff --git a/lib/procurement-items/service.ts b/lib/procurement-items/service.ts
index b62eb8df..c91959a9 100644
--- a/lib/procurement-items/service.ts
+++ b/lib/procurement-items/service.ts
@@ -255,8 +255,19 @@ export async function searchProcurementItems(query: string): Promise<{ itemCode:
unstable_noStore()
try {
+ // 검색어가 없으면 상위 50개 반환
if (!query || query.trim().length < 1) {
- return []
+ const results = await db
+ .select({
+ itemCode: procurementItems.itemCode,
+ itemName: procurementItems.itemName,
+ })
+ .from(procurementItems)
+ .where(eq(procurementItems.isActive, 'Y'))
+ .limit(50)
+ .orderBy(asc(procurementItems.itemCode))
+
+ return results
}
const searchQuery = `%${query.trim()}%`
@@ -277,7 +288,7 @@ export async function searchProcurementItems(query: string): Promise<{ itemCode:
eq(procurementItems.isActive, 'Y')
)
)
- .limit(20)
+ .limit(50)
.orderBy(asc(procurementItems.itemCode))
return results
diff --git a/lib/sedp/get-tags-plant.ts b/lib/sedp/get-tags-plant.ts
index be0e398b..f804ebe9 100644
--- a/lib/sedp/get-tags-plant.ts
+++ b/lib/sedp/get-tags-plant.ts
@@ -3,651 +3,578 @@ 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 { eq, and } from "drizzle-orm";
+import { revalidatePath } from "next/cache";
import { getSEDPToken } from "./sedp-token";
-/**
- * 태그 가져오기 서비스 함수
- * contractItemId(packageId)를 기반으로 외부 시스템에서 태그 데이터를 가져와 DB에 저장
- * TAG_IDX를 기준으로 태그를 식별합니다.
- *
- * @param projectCode 계약 아이템 ID (contractItemId)
- * @param packageCode 계약 아이템 ID (contractItemId)
- * @param progressCallback 진행 상황을 보고하기 위한 콜백 함수
- * @returns 처리 결과 정보 (처리된 태그 수, 오류 목록 등)
- */
+const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api';
+
+// ============ 타입 정의 ============
+interface newRegister {
+ PROJ_NO: string;
+ MAP_ID: string;
+ EP_ID: string;
+ DESC: string;
+ CATEGORY: string;
+ BYPASS: boolean;
+ REG_TYPE_ID: string;
+ TOOL_ID: string;
+ TOOL_TYPE: string;
+ SCOPES: string[];
+ MAP_CLS: {
+ TOOL_ATT_NAME: string;
+ ITEMS: any[];
+ };
+ MAP_ATT: any[];
+ MAP_TMPLS: string[];
+ CRTER_NO: string;
+ CRTE_DTM: string;
+ CHGER_NO: string;
+ _id: string;
+}
+
+interface Register {
+ PROJ_NO: string;
+ TYPE_ID: string;
+ EP_ID: string;
+ DESC: string;
+ REMARK: string | null;
+ NEW_TAG_YN: boolean;
+ ALL_TAG_YN: boolean;
+ VND_YN: boolean;
+ SEQ: number;
+ CMPLX_YN: boolean;
+ CMPL_SETT: any | null;
+ MAP_ATT: any[];
+ MAP_CLS_ID: string[];
+ MAP_OPER: any | null;
+ LNK_ATT: any[];
+ JOIN_TABLS: any[];
+ DELETED: boolean;
+ CRTER_NO: string;
+ CRTE_DTM: string;
+ CHGER_NO: string | null;
+ CHGE_DTM: string | null;
+ _id: string;
+}
+
+interface FormInfo {
+ formCode: string;
+ formName: string;
+ im: boolean;
+ eng: boolean;
+}
+
+// ============ API 호출 함수들 ============
+
+async function getNewRegisters(projectCode: string): Promise<newRegister[]> {
+ const apiKey = await getSEDPToken();
+
+ 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}`);
+ }
+
+ const data = await response.json();
+ const registers: newRegister[] = Array.isArray(data) ? data : [data];
+
+ console.log(`[getNewRegisters] 프로젝트 ${projectCode}에서 ${registers.length}개의 레지스터를 가져왔습니다.`);
+ return registers;
+}
+
+async function getRegisters(projectCode: string): Promise<Register[]> {
+ const apiKey = await getSEDPToken();
+
+ const response = await fetch(
+ `${SEDP_API_BASE_URL}/Register/Get`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'accept': '*/*',
+ 'ApiKey': apiKey,
+ 'ProjectNo': projectCode
+ },
+ body: JSON.stringify({
+ ProjectNo: projectCode,
+ ContainDeleted: false
+ })
+ }
+ );
+
+ if (!response.ok) {
+ throw new Error(`레지스터 요청 실패: ${response.status} ${response.statusText}`);
+ }
+
+ const data = await response.json();
+ const registers: Register[] = Array.isArray(data) ? data : [data];
+
+ console.log(`[getRegisters] 프로젝트 ${projectCode}에서 ${registers.length}개의 레지스터를 가져왔습니다.`);
+ return registers;
+}
+
+async function fetchTagDataFromSEDP(projectCode: string, formCode: string): Promise<any> {
+ const apiKey = await getSEDPToken();
+
+ 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 요청 실패: ${response.status} ${response.statusText} - ${errorText}`);
+ }
+
+ return await response.json();
+}
+
+async function getRegisterDetail(projectCode: string, formCode: string): Promise<Register | null> {
+ const apiKey = await getSEDPToken();
+
+ const response = 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: formCode,
+ ContainDeleted: false
+ })
+ }
+ );
+
+ if (!response.ok) {
+ console.error(`Register detail 요청 실패: ${formCode}`);
+ return null;
+ }
+
+ return await response.json();
+}
+
+// ============ 메인 함수 ============
+
export async function importTagsFromSEDP(
projectCode: string,
packageCode: string,
- progressCallback?: (progress: number) => void,
- mode?: string
+ progressCallback?: (progress: number) => void
): Promise<{
processedCount: number;
excludedCount: number;
totalEntries: number;
errors?: string[];
}> {
+ const allErrors: string[] = [];
+ let totalProcessedCount = 0;
+ let totalExcludedCount = 0;
+ let totalEntriesCount = 0;
+
try {
- // 진행 상황 보고
if (progressCallback) progressCallback(5);
+ // Step 1: 프로젝트 ID 조회
const project = await db.query.projects.findFirst({
where: eq(projects.code, projectCode),
- columns: {
- id: true
- }
+ columns: { id: true }
});
+ if (!project) {
+ throw new Error(`Project not found: ${projectCode}`);
+ }
+ const projectId = project.id;
+
+ if (progressCallback) progressCallback(10);
- // 프로젝트 ID 획득
- const projectId = project?.id;
+ // Step 2: 두 API 동시 호출
+ const [newRegisters, registers] = await Promise.all([
+ getNewRegisters(projectCode),
+ getRegisters(projectCode)
+ ]);
- // 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 (progressCallback) progressCallback(20);
+
+ // ======== 서브클래스 매핑을 위한 태그 클래스 로드 ========
+ const allTagClasses = await db.query.tagClasses.findMany({
+ where: eq(tagClasses.projectId, projectId)
});
- if (!item) {
- throw new Error(`Item with ID ${item?.id} not found`);
+ // 클래스 코드로 빠른 조회를 위한 Map
+ const tagClassByCode = new Map(allTagClasses.map(tc => [tc.code, tc]));
+
+ // 서브클래스 코드로 부모 클래스 찾기 위한 Map
+ const parentBySubclassCode = new Map<string, typeof allTagClasses[0]>();
+ for (const tc of allTagClasses) {
+ if (tc.subclasses && Array.isArray(tc.subclasses)) {
+ for (const sub of tc.subclasses as { id: string; desc: string }[]) {
+ parentBySubclassCode.set(sub.id, tc);
+ }
+ }
}
- const itemCode = item.itemCode;
+ console.log(`[importTagsFromSEDP] 태그 클래스 ${allTagClasses.length}개 로드, 서브클래스 매핑 ${parentBySubclassCode.size}개 생성`);
+ // ======== 서브클래스 매핑 준비 완료 ========
- // 진행 상황 보고
- if (progressCallback) progressCallback(10);
+ // Step 3: packageCode에 해당하는 폼 정보 추출
+ const formsToProcess: FormInfo[] = [];
- // 기본 매핑 검색 - 모든 모드에서 사용
- 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}`);
+ // Register 정보를 Map으로 변환 (TYPE_ID로 빠른 조회)
+ const registerMap = new Map<string, Register>();
+ for (const reg of registers) {
+ registerMap.set(reg.TYPE_ID, reg);
}
- // 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");
+ // newRegisters에서 packageCode가 SCOPES에 포함된 것 필터링
+ for (const newReg of newRegisters) {
+ if (newReg.SCOPES && newReg.SCOPES.includes(packageCode)) {
+ const formCode = newReg.REG_TYPE_ID;
+ const formName = newReg.DESC;
+
+ // Register에서 EP_ID 확인하여 im/eng 결정
+ const register = registerMap.get(formCode);
+ const isIM = register?.EP_ID === "IMEP";
+
+ formsToProcess.push({
+ formCode,
+ formName,
+ im: isIM,
+ eng: !isIM
+ });
}
}
- // 매핑이 없는 경우 모드에 따라 다른 오류 메시지 사용
- 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 (formsToProcess.length === 0) {
+ throw new Error(`No forms found for packageCode: ${packageCode}`);
}
-
- // 진행 상황 보고
- 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;
- }
- }
+ console.log(`[importTagsFromSEDP] ${formsToProcess.length}개의 폼을 처리합니다.`);
- // 진행 상황 보고 - 매핑별 진행률 조정
- if (progressCallback) {
- const baseProgress = 15;
- const mappingProgress = Math.floor(15 * (mappingIndex + 1) / mappings.length);
- progressCallback(baseProgress + mappingProgress);
- }
+ if (progressCallback) progressCallback(25);
- // Step 4: Find the form ID
- const form = await db.query.formsPlant.findFirst({
- where: and(
- eq(formsPlant.projectCode, projectCode),
- eq(formsPlant.formCode, formCode),
- eq(formsPlant.packageCode, packageCode)
- )
- });
-
- 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;
- }
- }
- }
+ // Step 4: 각 폼에 대해 처리
+ for (let i = 0; i < formsToProcess.length; i++) {
+ const formInfo = formsToProcess[i];
+ const { formCode, formName, im, eng } = formInfo;
- const insertResult = await db.insert(formsPlant)
- .values(insertValues)
- .onConflictDoUpdate({
- target: [formsPlant.projectCode, formsPlant.formCode],
- set: {
- packageCode: insertValues.packageCode,
- formName: insertValues.formName,
- eng: insertValues.eng ?? false,
- im: insertValues.im ?? false,
- updatedAt: new Date()
- }
- })
- .returning({ id: formsPlant.id });
+ try {
+ // 진행률 계산
+ const baseProgress = 25;
+ const progressPerForm = 70 / formsToProcess.length;
- 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));
+ // Step 4-1: formsPlant upsert
+ const existingForm = await db.query.formsPlant.findFirst({
+ where: and(
+ eq(formsPlant.projectCode, projectCode),
+ eq(formsPlant.packageCode, packageCode),
+ eq(formsPlant.formCode, formCode)
+ )
+ });
+
+ let formId: number;
+
+ if (existingForm) {
+ // 기존 폼 업데이트
+ await db.update(formsPlant)
+ .set({
+ formName,
+ im,
+ eng,
+ updatedAt: new Date()
+ })
+ .where(eq(formsPlant.id, existingForm.id));
+
+ formId = existingForm.id;
+ console.log(`[formsPlant] Updated form: ${formCode}`);
+ } else {
+ // 새 폼 생성
+ const insertResult = await db.insert(formsPlant)
+ .values({
+ projectCode,
+ packageCode,
+ formCode,
+ formName,
+ im,
+ eng
+ })
+ .returning({ id: formsPlant.id });
- console.log(`Updated form ${formId} with ${mode} mode enabled`);
- }
+ formId = insertResult[0].id;
+ console.log(`[formsPlant] Created form: ${formCode}`);
}
- }
-
- // 진행 상황 보고 - 매핑별 진행률 조정
- 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);
+ progressCallback(baseProgress + progressPerForm * (i + 0.2));
}
- // 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
+ // Step 4-2: SEDP에서 태그 데이터 가져오기
+ const tagData = await fetchTagDataFromSEDP(projectCode, formCode);
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; // 다음 매핑으로 진행
+ console.log(`[importTagsFromSEDP] No tag data for formCode: ${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 SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api';
- const apiKey = await getSEDPToken();
-
- 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
+ totalEntriesCount += tagEntries.length;
+
+ if (progressCallback) {
+ progressCallback(baseProgress + progressPerForm * (i + 0.4));
}
-
- const registerDetail: Register = await registerResponse.json()
+
+ // Step 4-3: Register detail에서 허용된 ATT_ID 추출
+ const registerDetail = await getRegisterDetail(projectCode, formCode);
+ const allowedAttIds = new Set<string>();
- // ✅ MAP_ATT에서 허용된 ATT_ID 목록 추출
- const allowedAttIds = new Set<string>()
- if (Array.isArray(registerDetail.MAP_ATT)) {
+ if (registerDetail?.MAP_ATT && Array.isArray(registerDetail.MAP_ATT)) {
for (const mapAttr of registerDetail.MAP_ATT) {
if (mapAttr.ATT_ID) {
- allowedAttIds.add(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);
- }
+ // Step 4-4: 태그 처리
+ const newTagsForFormEntry: Array<Record<string, any>> = [];
+ let processedCount = 0;
+ let excludedCount = 0;
- continue; // 이 항목은 건너뜀
- }
+ for (const entry of tagEntries) {
+ // TAG_IDX 없으면 제외
+ if (!entry.TAG_IDX) {
+ excludedCount++;
+ continue;
+ }
+
+ // TAG_TYPE_ID 없으면 제외
+ if (!entry.TAG_TYPE_ID || entry.TAG_TYPE_ID === "") {
+ excludedCount++;
+ 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)
- }
+ // attributes 추출 (허용된 ATT_ID만)
+ const attributes: Record<string, string> = {};
+ if (Array.isArray(entry.ATTRIBUTES)) {
+ for (const attr of entry.ATTRIBUTES) {
+ 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; // 이 항목은 건너뜀
+ // tagType 조회
+ const tagType = await db.query.tagTypes.findFirst({
+ where: and(
+ eq(tagTypes.code, entry.TAG_TYPE_ID),
+ eq(tagTypes.projectId, projectId)
+ )
+ });
+
+ // ======== 클래스 및 서브클래스 결정 로직 ========
+ let classLabel: string;
+ let subclassValue: string | null = null;
+ let tagClassId: number | null = null;
+
+ // 1. 먼저 CLS_ID로 직접 tagClass 찾기
+ const tagClass = tagClassByCode.get(entry.CLS_ID);
+
+ if (tagClass) {
+ // 직접 찾은 경우 - 이게 메인 클래스
+ classLabel = tagClass.label || entry.CLS_ID;
+ tagClassId = tagClass.id;
+ } else {
+ // 2. 서브클래스인지 확인 (부모 클래스의 subclasses 배열에 있는지)
+ const parentClass = parentBySubclassCode.get(entry.CLS_ID);
+
+ if (parentClass) {
+ // 서브클래스인 경우
+ classLabel = parentClass.label || parentClass.code;
+ subclassValue = entry.CLS_ID;
+ tagClassId = parentClass.id;
+
+ console.log(`[importTagsFromSEDP] 서브클래스 발견: ${entry.CLS_ID} -> 부모: ${parentClass.code}`);
+ } else {
+ // 어디에도 없는 경우 - 원본 값 사용
+ classLabel = entry.CLS_ID;
+ console.log(`[importTagsFromSEDP] 클래스를 찾을 수 없음: ${entry.CLS_ID}`);
}
-
- // 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,
+ }
+ // ======== 클래스/서브클래스 결정 완료 ========
+
+ // tagsPlant upsert (subclass 필드 추가)
+ await db.insert(tagsPlant).values({
+ projectCode,
+ packageCode,
+ formId,
+ tagIdx: entry.TAG_IDX,
+ tagNo: entry.TAG_NO || entry.TAG_IDX,
+ tagType: tagType?.description || entry.TAG_TYPE_ID,
+ tagClassId: tagClassId,
+ class: classLabel,
+ subclass: subclassValue,
+ description: entry.TAG_DESC,
+ attributes,
+ }).onConflictDoUpdate({
+ target: [tagsPlant.projectCode, tagsPlant.packageCode, tagsPlant.tagIdx],
+ set: {
+ formId,
tagNo: entry.TAG_NO || entry.TAG_IDX,
tagType: tagType?.description || entry.TAG_TYPE_ID,
- tagClassId: tagClass?.id,
- class: tagClass?.label || entry.CLS_ID,
+ tagClassId: tagClassId,
+ class: classLabel,
+ subclass: subclassValue,
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;
- }
+ attributes,
+ updatedAt: new Date()
+ }
+ });
+
+ // formEntriesPlant용 데이터 준비
+ const tagDataForFormEntry: Record<string, any> = {
+ TAG_IDX: entry.TAG_IDX,
+ TAG_NO: entry.TAG_NO || entry.TAG_IDX,
+ TAG_DESC: entry.TAG_DESC || null,
+ status: "From S-EDP",
+ source: "S-EDP"
+ };
+
+ // ATTRIBUTES 추가
+ 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);
+ newTagsForFormEntry.push(tagDataForFormEntry);
+ processedCount++;
+ }
- processedCount++;
- totalProcessedCount++;
+ totalProcessedCount += processedCount;
+ totalExcludedCount += excludedCount;
- // 주기적으로 진행 상황 보고
- 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');
- }
+ if (progressCallback) {
+ progressCallback(baseProgress + progressPerForm * (i + 0.8));
}
- // Step 7: formEntries 업데이트 - TAG_IDX 기준으로 변경
+ // Step 4-5: formEntriesPlant upsert
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;
- }
+ const existingEntry = await db.query.formEntriesPlant.findFirst({
+ where: and(
+ eq(formEntriesPlant.formCode, formCode),
+ eq(formEntriesPlant.projectCode, projectCode),
+ eq(formEntriesPlant.packageCode, packageCode)
+ )
+ });
+
+ if (existingEntry) {
+ // 기존 데이터 병합
+ let existingData: Array<Record<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)
- );
+ const existingTagIdxs = new Set(
+ existingData.map(item => item.TAG_IDX).filter(Boolean)
+ );
- // 중복되지 않은 새 태그들만 필터링 (변경: TAG_NO → TAG_IDX)
- const newUniqueTagsData = newTagsForFormEntry.filter(
- tagData => !existingTagIdxs.has(tagData.TAG_IDX)
+ // 기존 데이터 업데이트 + 새 데이터 추가
+ const updatedData = existingData.map(existingItem => {
+ const newData = newTagsForFormEntry.find(
+ n => n.TAG_IDX === existingItem.TAG_IDX
);
+ return newData ? { ...existingItem, ...newData } : existingItem;
+ });
- // 기존 태그들의 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];
+ const newUniqueData = newTagsForFormEntry.filter(
+ n => !existingTagIdxs.has(n.TAG_IDX)
+ );
- await db
- .update(formEntriesPlant)
- .set({
- data: finalData,
- updatedAt: new Date()
- })
- .where(eq(formEntriesPlant.id, existingEntry.id));
+ await db.update(formEntriesPlant)
+ .set({
+ data: [...updatedData, ...newUniqueData],
+ 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}`);
- }
+ console.log(`[formEntriesPlant] Updated: ${formCode} (${newUniqueData.length} new, ${updatedData.length} updated)`);
+ } else {
+ // 새로 생성
+ await db.insert(formEntriesPlant).values({
+ formCode,
+ projectCode,
+ packageCode,
+ data: newTagsForFormEntry,
+ createdAt: new Date(),
+ updatedAt: new Date()
+ });
- // 캐시 무효화
- // 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}`);
+ console.log(`[formEntriesPlant] Created: ${formCode} (${newTagsForFormEntry.length} tags)`);
}
}
+ if (progressCallback) {
+ progressCallback(baseProgress + progressPerForm * (i + 1));
+ }
+
} catch (error: any) {
- console.error(`Error processing mapping for formCode ${formCode}:`, error);
- allErrors.push(`Error with formCode ${formCode}: ${error.message || 'Unknown error'}`);
+ console.error(`Error processing form ${formCode}:`, error);
+ allErrors.push(`Form ${formCode}: ${error.message}`);
}
}
- // 모든 매핑 처리 완료 - 진행률 100%
- if (progressCallback) {
- progressCallback(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/soap/ecc/mapper/bidding-and-pr-mapper.ts b/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts
index 9bdd238d..4cdaf90d 100644
--- a/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts
+++ b/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts
@@ -32,6 +32,7 @@ import {
parseSAPDateToString,
findSpecificationByMATNR,
} from './common-mapper-utils';
+import { updateBiddingAmounts } from '@/lib/bidding/service';
// Note: POS 파일은 온디맨드 방식으로 다운로드됩니다.
// 자동 동기화 관련 import는 제거되었습니다.
@@ -255,11 +256,14 @@ export async function mapECCBiddingHeaderToBidding(
// prNumber: 대표 PR의 BANFN 또는 타겟 PR의 ZREQ_FN 값
prNumber = representativeItem?.BANFN || targetItem.ZREQ_FN || null;
}
+
+ // 원입찰번호(originalBiddingNumber)에 생성된 biddingNumber에서 '-'로 split해서 앞부분을 사용
+ const originalBiddingNumber = biddingNumber ? biddingNumber.split('-')[0] : (eccHeader.ANFNR || null);
// 매핑
const mappedData: BiddingData = {
biddingNumber, // 생성된 Bidding 코드
- originalBiddingNumber: eccHeader.ANFNR || null, // 원입찰번호
+ originalBiddingNumber, // 원입찰번호에 생성된 biddingnumber split 결과 사용
revision: 0, // 기본 리비전 0 (I/F 해서 가져온 건 보낸 적 없으므로 0 고정)
projectName, // 타겟 PR Item의 PSPID로 찾은 프로젝트 이름
itemName, // 타겟 PR Item 정보로 조회한 자재명/내역
@@ -278,7 +282,6 @@ export async function mapECCBiddingHeaderToBidding(
biddingRegistrationDate: new Date().toISOString(), // 입찰등록일 I/F 시점 등록(1120 이시원 프로 요청)
submissionStartDate: null,
submissionEndDate: null,
- evaluationDate: null,
// 사양설명회
hasSpecificationMeeting: false, // 기본값 처리하고, 입찰관리상세에서 사용자가 관리
@@ -293,6 +296,7 @@ export async function mapECCBiddingHeaderToBidding(
// PR 정보
prNumber, // 첫번째 PR의 ZREQ_FN 값
hasPrDocument: false, // PR문서는 POS를 말하는 것으로 보임.
+ plant: eccHeader.WERKS || null, // 플랜트 코드(WERKS)
// 상태 및 설정
status: 'bidding_generated', // 입찰생성 상태
@@ -497,6 +501,20 @@ export async function mapAndSaveECCBiddingData(
};
});
+ // 새로 생성된 Bidding들에 대해 금액 집계 업데이트 (PR 아이템의 금액 정보를 Bidding 헤더에 반영)
+ if (result.insertedBiddings && result.insertedBiddings.length > 0) {
+ debugLog('Bidding 금액 집계 업데이트 시작', { count: result.insertedBiddings.length });
+ await Promise.all(
+ result.insertedBiddings.map(async (bidding) => {
+ try {
+ await updateBiddingAmounts(bidding.id);
+ } catch (err) {
+ debugError(`Bidding ${bidding.biddingNumber} 금액 업데이트 실패`, err);
+ }
+ })
+ );
+ }
+
debugSuccess('ECC Bidding 데이터 일괄 처리 완료', {
processedCount: result.processedCount,
});
diff --git a/lib/soap/ecc/send/chemical-substance-check.ts b/lib/soap/ecc/send/chemical-substance-check.ts
new file mode 100644
index 00000000..b5c4cc25
--- /dev/null
+++ b/lib/soap/ecc/send/chemical-substance-check.ts
@@ -0,0 +1,449 @@
+'use server'
+
+import { sendSoapXml } from "@/lib/soap/sender";
+import type { SoapSendConfig, SoapLogInfo, SoapSendResult } from "@/lib/soap/types";
+
+// ECC 화학물질 조회 엔드포인트 (WSDL에 명시된 인터페이스 사용)
+const ECC_CHEMICAL_SUBSTANCE_ENDPOINT = "http://shii8dvddb01.hec.serp.shi.samsung.net:50000/sap/xi/engine?type=entry&version=3.0&Sender.Service=P2038_Q&Interface=http%3A%2F%2Fshi.samsung.co.kr%2FP2_MM%2FMMM%5E[P2MM_INTERFACE_NAME]";
+
+// 화학물질 조회 요청 데이터 타입
+export interface ChemicalSubstanceCheckRequest {
+ T_LIST: Array<{
+ BUKRS: string; // Company Code (M, CHAR 4)
+ WERKS: string; // Plant (M, CHAR 4)
+ LIFNR: string; // Vendor's account number (M, CHAR 10)
+ MATNR: string; // Material Number (M, CHAR 18)
+ }>;
+}
+
+// 화학물질 조회 응답 데이터 타입
+export interface ChemicalSubstanceCheckResponse {
+ T_LIST: Array<{
+ QINSPST: string; // Y/N (화학물질 여부)
+ SGTXT: string; // Text (상세 메시지)
+ }>;
+}
+
+// 화학물질 조회 결과 타입 (DB 저장용)
+export interface ChemicalSubstanceResult {
+ bukrs: string;
+ werks: string;
+ lifnr: string;
+ matnr: string;
+ hasChemicalSubstance: boolean;
+ message: string;
+ checkedAt: Date;
+}
+
+// SOAP Body Content 생성 함수
+function createChemicalSubstanceCheckSoapBodyContent(data: ChemicalSubstanceCheckRequest): Record<string, unknown> {
+ return {
+ 'p1:MT_[INTERFACE_NAME]_S': { // 실제 인터페이스명으로 변경 필요
+ 'T_LIST': data.T_LIST
+ }
+ };
+}
+
+// 화학물질 조회 데이터 검증 함수
+function validateChemicalSubstanceCheckData(data: ChemicalSubstanceCheckRequest): { isValid: boolean; errors: string[] } {
+ const errors: string[] = [];
+
+ // T_LIST 배열 검증
+ if (!data.T_LIST || !Array.isArray(data.T_LIST) || data.T_LIST.length === 0) {
+ errors.push('T_LIST는 필수이며 최소 1개 이상의 데이터가 있어야 합니다.');
+ } else {
+ data.T_LIST.forEach((item, index) => {
+ // 필수 필드 검증
+ if (!item.BUKRS || typeof item.BUKRS !== 'string' || item.BUKRS.trim() === '') {
+ errors.push(`T_LIST[${index}].BUKRS은 필수입니다.`);
+ } else if (item.BUKRS.length > 4) {
+ errors.push(`T_LIST[${index}].BUKRS은 4자를 초과할 수 없습니다.`);
+ }
+
+ if (!item.WERKS || typeof item.WERKS !== 'string' || item.WERKS.trim() === '') {
+ errors.push(`T_LIST[${index}].WERKS는 필수입니다.`);
+ } else if (item.WERKS.length > 4) {
+ errors.push(`T_LIST[${index}].WERKS는 4자를 초과할 수 없습니다.`);
+ }
+
+ if (!item.LIFNR || typeof item.LIFNR !== 'string' || item.LIFNR.trim() === '') {
+ errors.push(`T_LIST[${index}].LIFNR은 필수입니다.`);
+ } else if (item.LIFNR.length > 10) {
+ errors.push(`T_LIST[${index}].LIFNR은 10자를 초과할 수 없습니다.`);
+ }
+
+ if (!item.MATNR || typeof item.MATNR !== 'string' || item.MATNR.trim() === '') {
+ errors.push(`T_LIST[${index}].MATNR은 필수입니다.`);
+ } else if (item.MATNR.length > 18) {
+ errors.push(`T_LIST[${index}].MATNR은 18자를 초과할 수 없습니다.`);
+ }
+ });
+ }
+
+ return {
+ isValid: errors.length === 0,
+ errors
+ };
+}
+
+// ECC로 화학물질 조회 SOAP XML 전송하는 함수
+async function sendChemicalSubstanceCheckToECC(data: ChemicalSubstanceCheckRequest): Promise<SoapSendResult> {
+ try {
+ // 데이터 검증
+ const validation = validateChemicalSubstanceCheckData(data);
+ if (!validation.isValid) {
+ return {
+ success: false,
+ message: `데이터 검증 실패: ${validation.errors.join(', ')}`
+ };
+ }
+
+ // SOAP Body Content 생성
+ const soapBodyContent = createChemicalSubstanceCheckSoapBodyContent(data);
+
+ // SOAP 전송 설정
+ const config: SoapSendConfig = {
+ endpoint: ECC_CHEMICAL_SUBSTANCE_ENDPOINT,
+ envelope: soapBodyContent,
+ soapAction: 'http://sap.com/xi/WebService/soap1.1',
+ timeout: 30000, // 화학물질 조회는 30초 타임아웃
+ retryCount: 3,
+ retryDelay: 1000,
+ namespace: 'http://shi.samsung.co.kr/P2_MM/MMM', // ECC MM 모듈 네임스페이스
+ prefix: 'p1' // WSDL에서 사용하는 p1 접두사
+ };
+
+ // 로그 정보
+ const logInfo: SoapLogInfo = {
+ direction: 'OUTBOUND',
+ system: 'S-ERP ECC',
+ interface: 'IF_ECC_EVCP_CHEMICAL_SUBSTANCE_CHECK'
+ };
+
+ const materials = data.T_LIST.map(item => `${item.BUKRS}/${item.WERKS}/${item.LIFNR}/${item.MATNR}`).join(', ');
+ console.log(`📤 화학물질 조회 요청 전송 시작 - Materials: ${materials}`);
+ console.log(`🔍 조회 대상 물질 ${data.T_LIST.length}개`);
+
+ // SOAP XML 전송
+ const result = await sendSoapXml(config, logInfo);
+
+ if (result.success) {
+ console.log(`✅ 화학물질 조회 요청 전송 성공 - Materials: ${materials}`);
+ } else {
+ console.error(`❌ 화학물질 조회 요청 전송 실패 - Materials: ${materials}, 오류: ${result.message}`);
+ }
+
+ return result;
+
+ } catch (error) {
+ console.error('❌ 화학물질 조회 전송 중 오류 발생:', error);
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : 'Unknown error'
+ };
+ }
+}
+
+// ========================================
+// 메인 화학물질 조회 서버 액션 함수들
+// ========================================
+
+// 단일 화학물질 조회 요청 처리
+export async function checkChemicalSubstance(params: {
+ bukrs: string;
+ werks: string;
+ lifnr: string;
+ matnr: string;
+}): Promise<{
+ success: boolean;
+ message: string;
+ hasChemicalSubstance?: boolean;
+ responseData?: string;
+ statusCode?: number;
+ headers?: Record<string, string>;
+ endpoint?: string;
+ requestXml?: string;
+ material?: string;
+}> {
+ try {
+ console.log(`🚀 화학물질 조회 요청 시작 - Material: ${params.bukrs}/${params.werks}/${params.lifnr}/${params.matnr}`);
+
+ const requestData: ChemicalSubstanceCheckRequest = {
+ T_LIST: [{
+ BUKRS: params.bukrs,
+ WERKS: params.werks,
+ LIFNR: params.lifnr,
+ MATNR: params.matnr
+ }]
+ };
+
+ const result = await sendChemicalSubstanceCheckToECC(requestData);
+
+ let hasChemicalSubstance: boolean | undefined;
+ let message = result.message;
+
+ if (result.success && result.responseText) {
+ try {
+ // 응답 파싱 로직 (실제 응답 구조에 따라 조정 필요)
+ // QINSPST = 'Y' 이면 화학물질 있음, 'N'이면 없음
+ const responseData = JSON.parse(result.responseText);
+ if (responseData?.T_LIST?.[0]) {
+ const item = responseData.T_LIST[0];
+ hasChemicalSubstance = item.QINSPST === 'Y';
+ message = item.SGTXT || result.message;
+ }
+ } catch (parseError) {
+ console.warn('응답 데이터 파싱 실패:', parseError);
+ }
+ }
+
+ return {
+ success: result.success,
+ message,
+ hasChemicalSubstance,
+ responseData: result.responseText,
+ statusCode: result.statusCode,
+ headers: result.headers,
+ endpoint: result.endpoint,
+ requestXml: result.requestXml,
+ material: `${params.bukrs}/${params.werks}/${params.lifnr}/${params.matnr}`
+ };
+
+ } catch (error) {
+ console.error('❌ 화학물질 조회 요청 처리 실패:', error);
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : 'Unknown error'
+ };
+ }
+}
+
+// 여러 물질 배치 화학물질 조회 요청 처리
+export async function checkMultipleChemicalSubstances(items: Array<{
+ bukrs: string;
+ werks: string;
+ lifnr: string;
+ matnr: string;
+}>): Promise<{
+ success: boolean;
+ message: string;
+ results?: Array<{
+ material: string;
+ hasChemicalSubstance?: boolean;
+ success: boolean;
+ error?: string;
+ message?: string;
+ }>;
+}> {
+ try {
+ console.log(`🚀 배치 화학물질 조회 요청 시작: ${items.length}개`);
+
+ const requestData: ChemicalSubstanceCheckRequest = {
+ T_LIST: items.map(item => ({
+ BUKRS: item.bukrs,
+ WERKS: item.werks,
+ LIFNR: item.lifnr,
+ MATNR: item.matnr
+ }))
+ };
+
+ const result = await sendChemicalSubstanceCheckToECC(requestData);
+
+ let results: Array<{
+ material: string;
+ hasChemicalSubstance?: boolean;
+ success: boolean;
+ error?: string;
+ message?: string;
+ }> | undefined;
+
+ if (result.success && result.responseText) {
+ try {
+ const responseData = JSON.parse(result.responseText);
+ if (responseData?.T_LIST && Array.isArray(responseData.T_LIST)) {
+ results = responseData.T_LIST.map((item: any, index: number) => {
+ const originalItem = items[index];
+ const material = `${originalItem.bukrs}/${originalItem.werks}/${originalItem.lifnr}/${originalItem.matnr}`;
+
+ return {
+ material,
+ hasChemicalSubstance: item.QINSPST === 'Y',
+ success: true,
+ message: item.SGTXT
+ };
+ });
+ }
+ } catch (parseError) {
+ console.warn('배치 응답 데이터 파싱 실패:', parseError);
+ // 파싱 실패시 전체 실패로 처리
+ results = items.map(item => ({
+ material: `${item.bukrs}/${item.werks}/${item.lifnr}/${item.matnr}`,
+ success: false,
+ error: '응답 데이터 파싱 실패'
+ }));
+ }
+ } else {
+ // 전송 실패시 모든 항목 실패로 처리
+ results = items.map(item => ({
+ material: `${item.bukrs}/${item.werks}/${item.lifnr}/${item.matnr}`,
+ success: false,
+ error: result.message
+ }));
+ }
+
+ const successCount = results?.filter(r => r.success).length || 0;
+ const failCount = (results?.length || 0) - successCount;
+
+ console.log(`🎉 배치 화학물질 조회 완료: 성공 ${successCount}개, 실패 ${failCount}개`);
+
+ return {
+ success: result.success,
+ message: result.success
+ ? `배치 화학물질 조회 성공: ${successCount}개`
+ : `배치 화학물질 조회 실패: ${result.message}`,
+ results
+ };
+
+ } catch (error) {
+ console.error('❌ 배치 화학물질 조회 중 전체 오류 발생:', error);
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : 'Unknown error'
+ };
+ }
+}
+
+// 개별 처리 방식의 배치 화학물질 조회 (각각 따로 전송)
+export async function checkMultipleChemicalSubstancesIndividually(items: Array<{
+ bukrs: string;
+ werks: string;
+ lifnr: string;
+ matnr: string;
+}>): Promise<{
+ success: boolean;
+ message: string;
+ results?: Array<{
+ material: string;
+ hasChemicalSubstance?: boolean;
+ success: boolean;
+ error?: string;
+ message?: string;
+ }>;
+}> {
+ try {
+ console.log(`🚀 개별 화학물질 조회 요청 시작: ${items.length}개`);
+
+ const results: Array<{
+ material: string;
+ hasChemicalSubstance?: boolean;
+ success: boolean;
+ error?: string;
+ message?: string;
+ }> = [];
+
+ for (const item of items) {
+ try {
+ const material = `${item.bukrs}/${item.werks}/${item.lifnr}/${item.matnr}`;
+ console.log(`📤 화학물질 조회 처리 중: ${material}`);
+
+ const checkResult = await checkChemicalSubstance(item);
+
+ results.push({
+ material,
+ hasChemicalSubstance: checkResult.hasChemicalSubstance,
+ success: checkResult.success,
+ error: checkResult.success ? undefined : checkResult.message,
+ message: checkResult.message
+ });
+
+ // 개별 처리간 지연 (시스템 부하 방지)
+ if (items.length > 1) {
+ await new Promise(resolve => setTimeout(resolve, 500));
+ }
+
+ } catch (error) {
+ const material = `${item.bukrs}/${item.werks}/${item.lifnr}/${item.matnr}`;
+ console.error(`❌ 화학물질 조회 처리 실패: ${material}`, error);
+ results.push({
+ material,
+ success: false,
+ error: error instanceof Error ? error.message : 'Unknown error'
+ });
+ }
+ }
+
+ const successCount = results.filter(r => r.success).length;
+ const failCount = results.length - successCount;
+
+ console.log(`🎉 개별 화학물질 조회 완료: 성공 ${successCount}개, 실패 ${failCount}개`);
+
+ return {
+ success: failCount === 0,
+ message: `개별 화학물질 조회 완료: 성공 ${successCount}개, 실패 ${failCount}개`,
+ results
+ };
+
+ } catch (error) {
+ console.error('❌ 개별 화학물질 조회 중 전체 오류 발생:', error);
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : 'Unknown error'
+ };
+ }
+}
+
+// 테스트용 화학물질 조회 함수 (샘플 데이터 포함)
+export async function checkTestChemicalSubstance(): Promise<{
+ success: boolean;
+ message: string;
+ hasChemicalSubstance?: boolean;
+ responseData?: string;
+ testData?: ChemicalSubstanceCheckRequest;
+}> {
+ try {
+ console.log('🧪 테스트용 화학물질 조회 시작');
+
+ // 테스트용 샘플 데이터 생성
+ const testData: ChemicalSubstanceCheckRequest = {
+ T_LIST: [{
+ BUKRS: '1000',
+ WERKS: '1000',
+ LIFNR: 'TEST_VENDOR',
+ MATNR: 'TEST_MATERIAL'
+ }]
+ };
+
+ const result = await sendChemicalSubstanceCheckToECC(testData);
+
+ let hasChemicalSubstance: boolean | undefined;
+ let message = result.message;
+
+ if (result.success && result.responseText) {
+ try {
+ const responseData = JSON.parse(result.responseText);
+ if (responseData?.T_LIST?.[0]) {
+ const item = responseData.T_LIST[0];
+ hasChemicalSubstance = item.QINSPST === 'Y';
+ message = item.SGTXT || result.message;
+ }
+ } catch (parseError) {
+ console.warn('테스트 응답 데이터 파싱 실패:', parseError);
+ }
+ }
+
+ return {
+ success: result.success,
+ message,
+ hasChemicalSubstance,
+ responseData: result.responseText,
+ testData
+ };
+
+ } catch (error) {
+ console.error('❌ 테스트 화학물질 조회 실패:', error);
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : 'Unknown error'
+ };
+ }
+}
diff --git a/lib/tags-plant/queries.ts b/lib/tags-plant/queries.ts
index a0d28b1e..c7ad43e0 100644
--- a/lib/tags-plant/queries.ts
+++ b/lib/tags-plant/queries.ts
@@ -5,6 +5,7 @@ import db from "@/db/db"
import { tagsPlant } from "@/db/schema/vendorData"
import { eq, and } from "drizzle-orm"
+import { revalidateTag, unstable_noStore } from "next/cache";
/**
* 모든 태그 가져오기 (클라이언트 렌더링용)
@@ -13,6 +14,7 @@ export async function getAllTagsPlant(
projectCode: string,
packageCode: string
) {
+ unstable_noStore();
try {
const tags = await db
.select()
diff --git a/lib/tags-plant/service.ts b/lib/tags-plant/service.ts
index 9e9d9ebf..27cc207b 100644
--- a/lib/tags-plant/service.ts
+++ b/lib/tags-plant/service.ts
@@ -25,6 +25,14 @@ interface CreatedOrExistingForm {
isNewlyCreated: boolean;
}
+interface FormInfo {
+ formCode: string;
+ formName: string;
+ im: boolean;
+ eng: boolean;
+}
+
+
/**
* 16진수 24자리 고유 식별자 생성
* @returns 24자리 16진수 문자열 (예: "a1b2c3d4e5f6789012345678")
@@ -280,6 +288,7 @@ export async function createTag(
tagIdx: generatedTagIdx, // 🆕 생성된 16진수 24자리 추가
tagNo: validated.data.tagNo,
class: validated.data.class,
+ subclass: validated.data.subclass,
tagType: validated.data.tagType,
description: validated.data.description ?? null,
})
@@ -1790,13 +1799,11 @@ export async function getIMForms(
return existingForms
}
- // 2. DB에 없으면 SEDP API에서 가져오기
+ // 2. DB에 없으면 두 API 동시 호출
const apiKey = await getSEDPToken()
- // 2-1. GetByToolID로 레지스터 매핑 정보 가져오기
- const mappingResponse = await fetch(
- `${SEDP_API_BASE_URL}/AdapterDataMapping/GetByToolID`,
- {
+ const [newRegistersResponse, registersResponse] = await Promise.all([
+ fetch(`${SEDP_API_BASE_URL}/AdapterDataMapping/GetByToolID`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -1808,95 +1815,94 @@ export async function getIMForms(
ProjectNo: projectCode,
TOOL_ID: "eVCP"
})
- }
- )
+ }),
+ fetch(`${SEDP_API_BASE_URL}/Register/Get`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'accept': '*/*',
+ 'ApiKey': apiKey,
+ 'ProjectNo': projectCode
+ },
+ body: JSON.stringify({
+ ProjectNo: projectCode,
+ ContainDeleted: false
+ })
+ })
+ ])
- if (!mappingResponse.ok) {
- throw new Error(
- `레지스터 매핑 요청 실패: ${mappingResponse.status} ${mappingResponse.statusText}`
- )
+ if (!newRegistersResponse.ok) {
+ throw new Error(`새 레지스터 요청 실패: ${newRegistersResponse.status}`)
}
- const mappingData = await mappingResponse.json()
- const registers: NewRegister[] = Array.isArray(mappingData)
- ? mappingData
- : [mappingData]
+ if (!registersResponse.ok) {
+ throw new Error(`레지스터 요청 실패: ${registersResponse.status}`)
+ }
- // 2-2. packageCode가 SCOPES에 포함된 레지스터 필터링
- const matchingRegisters = registers.filter(register =>
- register.SCOPES.includes(packageCode)
- )
+ const newRegistersData = await newRegistersResponse.json()
+ const registersData = await registersResponse.json()
- if (matchingRegisters.length === 0) {
- console.log(`패키지 ${packageCode}에 해당하는 레지스터가 없습니다.`)
- return []
+ const newRegisters: NewRegister[] = Array.isArray(newRegistersData)
+ ? newRegistersData
+ : [newRegistersData]
+
+ const registers: RegisterDetail[] = Array.isArray(registersData)
+ ? registersData
+ : [registersData]
+
+ // 3. Register를 Map으로 변환 (TYPE_ID로 빠른 조회)
+ const registerMap = new Map<string, RegisterDetail>()
+ for (const reg of registers) {
+ registerMap.set(reg.TYPE_ID, reg)
}
- // 2-3. 각 레지스터의 상세 정보 가져오기
+ // 4. packageCode가 SCOPES에 포함되고, EP_ID가 "IMEP"인 것만 필터링
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()
+ for (const newReg of newRegisters) {
+ // packageCode가 SCOPES에 없으면 스킵
+ if (!newReg.SCOPES || !newReg.SCOPES.includes(packageCode)) {
+ continue
+ }
- // DELETED가 true이거나 DESC가 없으면 스킵
- if (detail.DELETED || !detail.DESC) {
- continue
- }
+ const formCode = newReg.REG_TYPE_ID
+ const register = registerMap.get(formCode)
- formInfos.push({
- formCode: detail.TYPE_ID,
- formName: detail.DESC
- })
+ // Register에서 EP_ID가 "IMEP"가 아니면 스킵 (IM 폼만 처리)
+ if (!register || register.EP_ID !== "IMEP") {
+ continue
+ }
- // 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
- )
+ // DELETED면 스킵
+ if (register.DELETED) {
continue
}
+
+ const formName = newReg.DESC || register.DESC || formCode
+
+ formInfos.push({
+ formCode,
+ formName
+ })
+
+ formsToInsert.push({
+ projectCode,
+ packageCode,
+ formCode,
+ formName,
+ eng: false,
+ im: true
+ })
}
- // 2-4. DB에 저장
+ // 5. DB에 저장
if (formsToInsert.length > 0) {
- await db.insert(formsPlant).values(formsToInsert).onConflictDoNothing()
- console.log(`${formsToInsert.length}개의 IM 폼을 DB에 저장했습니다.`)
+ await db.insert(formsPlant)
+ .values(formsToInsert)
+ .onConflictDoNothing()
+
+ console.log(`[getIMForms] ${formsToInsert.length}개의 IM 폼을 DB에 저장했습니다.`)
}
return formInfos
diff --git a/lib/tags-plant/table/add-tag-dialog.tsx b/lib/tags-plant/table/add-tag-dialog.tsx
index de5d2bf8..1bfb0703 100644
--- a/lib/tags-plant/table/add-tag-dialog.tsx
+++ b/lib/tags-plant/table/add-tag-dialog.tsx
@@ -329,7 +329,7 @@ export function AddTagDialog({ projectCode, packageCode }: AddTagDialogProps) {
const tagData: CreateTagSchema = {
tagType: data.tagType,
class: data.class,
- // subclass: data.subclass, // 서브클래스 정보 추가
+ subclass: data.subclass, // 서브클래스 정보 추가
tagNo: row.tagNo,
description: row.description,
...Object.fromEntries(
diff --git a/lib/tags-plant/table/tag-table-column.tsx b/lib/tags-plant/table/tag-table-column.tsx
index 80c25464..30bdacc3 100644
--- a/lib/tags-plant/table/tag-table-column.tsx
+++ b/lib/tags-plant/table/tag-table-column.tsx
@@ -82,14 +82,27 @@ export function getColumns({
minSize: 150,
size: 240,
},
- {
+ {
accessorKey: "class",
header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Tag Class" />
+ <DataTableColumnHeaderSimple column={column} title="Class" />
),
cell: ({ row }) => <div>{row.getValue("class")}</div>,
meta: {
- excelHeader: "Tag Class"
+ excelHeader: "Class"
+ },
+ enableResizing: true,
+ minSize: 100,
+ size: 150,
+ },
+ {
+ accessorKey: "subclass",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Item Class" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("subclass")}</div>,
+ meta: {
+ excelHeader: "Item Class"
},
enableResizing: true,
minSize: 100,
diff --git a/lib/tags-plant/table/tag-table.tsx b/lib/tags-plant/table/tag-table.tsx
index 2fdcd5fc..70bfc4e4 100644
--- a/lib/tags-plant/table/tag-table.tsx
+++ b/lib/tags-plant/table/tag-table.tsx
@@ -78,6 +78,9 @@ export function TagsTable({
const [isLoading, setIsLoading] = React.useState(true)
const [rowAction, setRowAction] = React.useState<DataTableRowAction<Tag> | null>(null)
+
+ console.log(tableData,"tableData")
+
// 선택된 행 관리
const [selectedRowsData, setSelectedRowsData] = React.useState<Tag[]>([])
const [clearSelection, setClearSelection] = React.useState(false)
diff --git a/lib/tech-vendors/possible-items/connect-item-vendor-dialog.tsx b/lib/tech-vendors/possible-items/connect-item-vendor-dialog.tsx
new file mode 100644
index 00000000..bd53b3cc
--- /dev/null
+++ b/lib/tech-vendors/possible-items/connect-item-vendor-dialog.tsx
@@ -0,0 +1,406 @@
+"use client";
+
+import * as React from "react";
+import { Search, X } from "lucide-react";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Badge } from "@/components/ui/badge";
+import {
+ getItemsForVendorMapping,
+ getConnectableVendorsForItem,
+ connectItemWithVendors,
+} from "../service";
+
+type ItemType = "SHIP" | "TOP" | "HULL";
+
+interface ItemData {
+ id: number;
+ itemCode: string | null;
+ itemList: string | null;
+ workType: string | null;
+ shipTypes?: string | null;
+ subItemList?: string | null;
+ itemType: ItemType;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+interface VendorData {
+ id: number;
+ vendorName: string;
+ email: string | null;
+ techVendorType: string;
+ status: string;
+}
+
+interface ConnectItemVendorDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onConnected?: () => void;
+}
+
+export function ConnectItemVendorDialog({
+ open,
+ onOpenChange,
+ onConnected,
+}: ConnectItemVendorDialogProps) {
+ const [items, setItems] = React.useState<ItemData[]>([]);
+ const [filteredItems, setFilteredItems] = React.useState<ItemData[]>([]);
+ const [itemSearch, setItemSearch] = React.useState("");
+ const [selectedItem, setSelectedItem] = React.useState<ItemData | null>(null);
+
+ const [vendors, setVendors] = React.useState<VendorData[]>([]);
+ const [filteredVendors, setFilteredVendors] = React.useState<VendorData[]>([]);
+ const [vendorSearch, setVendorSearch] = React.useState("");
+ const [selectedVendorIds, setSelectedVendorIds] = React.useState<number[]>([]);
+
+ const [isLoadingItems, setIsLoadingItems] = React.useState(false);
+ const [isLoadingVendors, setIsLoadingVendors] = React.useState(false);
+ const [isSubmitting, setIsSubmitting] = React.useState(false);
+
+ // 다이얼로그가 열릴 때 전체 아이템 목록 로드
+ React.useEffect(() => {
+ if (open) {
+ loadItems();
+ }
+ }, [open]);
+
+ // 아이템 검색 필터링
+ React.useEffect(() => {
+ if (!itemSearch) {
+ setFilteredItems(items);
+ return;
+ }
+
+ const lowered = itemSearch.toLowerCase();
+ const filtered = items.filter((item) =>
+ [item.itemCode, item.itemList, item.workType, item.shipTypes, item.subItemList]
+ .filter(Boolean)
+ .some((value) => value?.toLowerCase().includes(lowered))
+ );
+ setFilteredItems(filtered);
+ }, [items, itemSearch]);
+
+ // 벤더 검색 필터링
+ React.useEffect(() => {
+ if (!vendorSearch) {
+ setFilteredVendors(vendors);
+ return;
+ }
+
+ const lowered = vendorSearch.toLowerCase();
+ const filtered = vendors.filter((vendor) =>
+ [vendor.vendorName, vendor.email, vendor.techVendorType, vendor.status]
+ .filter(Boolean)
+ .some((value) => value?.toLowerCase().includes(lowered))
+ );
+ setFilteredVendors(filtered);
+ }, [vendors, vendorSearch]);
+
+ // 특정 아이템 선택 시 연결 가능한 벤더 목록 로드
+ React.useEffect(() => {
+ if (!selectedItem) {
+ setVendors([]);
+ setFilteredVendors([]);
+ setSelectedVendorIds([]);
+ return;
+ }
+ loadVendors(selectedItem);
+ }, [selectedItem]);
+
+ const loadItems = async () => {
+ setIsLoadingItems(true);
+ try {
+ const result = await getItemsForVendorMapping();
+ if (result.error) {
+ throw new Error(result.error);
+ }
+ const validItems = (result.data as ItemData[]).filter((item) => item.itemCode != null);
+ setItems(validItems);
+ } catch (error) {
+ console.error("Failed to load items for mapping:", error);
+ toast.error("아이템 목록을 불러오는데 실패했습니다.");
+ } finally {
+ setIsLoadingItems(false);
+ }
+ };
+
+ const loadVendors = async (item: ItemData) => {
+ setIsLoadingVendors(true);
+ try {
+ const result = await getConnectableVendorsForItem(item.id, item.itemType);
+ if (result.error) {
+ throw new Error(result.error);
+ }
+ setVendors(result.data as VendorData[]);
+ } catch (error) {
+ console.error("Failed to load vendors for item:", error);
+ toast.error("연결 가능한 벤더 목록을 불러오는데 실패했습니다.");
+ } finally {
+ setIsLoadingVendors(false);
+ }
+ };
+
+ const handleItemSelect = (item: ItemData) => {
+ if (!item.itemCode) return;
+ setSelectedItem(item);
+ };
+
+ const handleVendorToggle = (vendorId: number) => {
+ setSelectedVendorIds((prev) =>
+ prev.includes(vendorId)
+ ? prev.filter((id) => id !== vendorId)
+ : [...prev, vendorId]
+ );
+ };
+
+ const handleSubmit = async () => {
+ if (!selectedItem || selectedVendorIds.length === 0) return;
+
+ setIsSubmitting(true);
+ try {
+ const result = await connectItemWithVendors({
+ itemId: selectedItem.id,
+ itemType: selectedItem.itemType,
+ vendorIds: selectedVendorIds,
+ });
+
+ if (!result.success) {
+ throw new Error(result.error || "연결에 실패했습니다.");
+ }
+
+ const successCount = result.successCount || 0;
+ const skippedCount = result.skipped?.length || 0;
+
+ toast.success(
+ `${successCount}개 벤더와 연결되었습니다${
+ skippedCount > 0 ? ` (${skippedCount}개 중복 제외)` : ""
+ }`
+ );
+
+ onConnected?.();
+ handleClose();
+ } catch (error) {
+ console.error("Failed to connect item with vendors:", error);
+ toast.error(error instanceof Error ? error.message : "연결 중 오류가 발생했습니다.");
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const handleClose = () => {
+ onOpenChange(false);
+ setTimeout(() => {
+ setItemSearch("");
+ setVendorSearch("");
+ setSelectedItem(null);
+ setSelectedVendorIds([]);
+ setItems([]);
+ setFilteredItems([]);
+ setVendors([]);
+ setFilteredVendors([]);
+ }, 200);
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-5xl max-h-[90vh] flex flex-col">
+ <DialogHeader>
+ <DialogTitle>아이템 기준 벤더 연결</DialogTitle>
+ <DialogDescription>
+ 연결할 아이템을 먼저 선택한 후, 해당 아이템과 연결할 벤더를 선택하세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 flex-1 min-h-0">
+ {/* 아이템 선택 영역 */}
+ <div className="flex flex-col space-y-3">
+ <div className="space-y-2">
+ <Label htmlFor="item-search">아이템 검색</Label>
+ <div className="relative">
+ <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
+ <Input
+ id="item-search"
+ placeholder="아이템코드, 아이템리스트, 공종, 선종 검색..."
+ value={itemSearch}
+ onChange={(e) => setItemSearch(e.target.value)}
+ className="pl-10"
+ />
+ </div>
+ </div>
+
+ {selectedItem && (
+ <div className="space-y-2">
+ <Label>선택된 아이템</Label>
+ <div className="flex flex-wrap gap-1 p-2 border rounded-md bg-muted/50">
+ <Badge variant="default" className="text-xs">
+ {[selectedItem.itemType, selectedItem.itemCode, selectedItem.shipTypes]
+ .filter(Boolean)
+ .join("-")}
+ <X
+ className="ml-1 h-3 w-3 cursor-pointer"
+ onClick={(e) => {
+ e.stopPropagation();
+ setSelectedItem(null);
+ }}
+ />
+ </Badge>
+ </div>
+ </div>
+ )}
+
+ <div className="flex-1 min-h-0 overflow-hidden">
+ <div className="max-h-96 overflow-y-auto border rounded-lg bg-gray-50 p-2 h-full">
+ {isLoadingItems ? (
+ <div className="text-center py-4">아이템 로딩 중...</div>
+ ) : filteredItems.length === 0 ? (
+ <div className="text-center py-4 text-muted-foreground">
+ 아이템이 없습니다.
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {filteredItems.map((item) => {
+ if (!item.itemCode) return null;
+ const isSelected = selectedItem?.id === item.id && selectedItem.itemType === item.itemType;
+ const itemKey = `${item.itemType}-${item.id}-${item.itemCode}${item.shipTypes ? `-${item.shipTypes}` : ""}`;
+ return (
+ <div
+ key={`item-${itemKey}`}
+ className={`p-3 bg-white border rounded-lg cursor-pointer transition-colors ${
+ isSelected
+ ? "bg-primary/10 border-primary hover:bg-primary/20"
+ : "hover:bg-gray-50"
+ }`}
+ onClick={() => handleItemSelect(item)}
+ >
+ <div className="font-medium">
+ {[`[${item.itemType}]`, item.itemCode, item.shipTypes]
+ .filter(Boolean)
+ .join(" ")}
+ </div>
+ <div className="text-sm text-muted-foreground">
+ {item.itemList || "-"}
+ </div>
+ <div className="flex flex-wrap gap-2 mt-1 text-xs">
+ <span>공종: {item.workType || "-"}</span>
+ {item.shipTypes && <span>선종: {item.shipTypes}</span>}
+ {item.subItemList && <span>서브아이템: {item.subItemList}</span>}
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+
+ {/* 벤더 선택 영역 */}
+ <div className="flex flex-col space-y-3">
+ <div className="space-y-2">
+ <Label htmlFor="vendor-search">벤더 검색</Label>
+ <div className="relative">
+ <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
+ <Input
+ id="vendor-search"
+ placeholder="벤더명, 이메일, 벤더타입, 상태로 검색..."
+ value={vendorSearch}
+ onChange={(e) => setVendorSearch(e.target.value)}
+ className="pl-10"
+ disabled={!selectedItem}
+ />
+ </div>
+ </div>
+
+ {selectedVendorIds.length > 0 && (
+ <div className="space-y-2">
+ <Label>선택된 벤더 ({selectedVendorIds.length}개)</Label>
+ <div className="flex flex-wrap gap-1 p-2 border rounded-md bg-muted/50 max-h-20 overflow-y-auto">
+ {vendors
+ .filter((vendor) => selectedVendorIds.includes(vendor.id))
+ .map((vendor) => (
+ <Badge key={`selected-vendor-${vendor.id}`} variant="default" className="text-xs">
+ {vendor.vendorName}
+ <X
+ className="ml-1 h-3 w-3 cursor-pointer"
+ onClick={(e) => {
+ e.stopPropagation();
+ handleVendorToggle(vendor.id);
+ }}
+ />
+ </Badge>
+ ))}
+ </div>
+ </div>
+ )}
+
+ <div className="flex-1 min-h-0 overflow-hidden">
+ <div className="max-h-96 overflow-y-auto border rounded-lg bg-gray-50 p-2 h-full">
+ {!selectedItem ? (
+ <div className="text-center py-4 text-muted-foreground">
+ 아이템을 먼저 선택해주세요.
+ </div>
+ ) : isLoadingVendors ? (
+ <div className="text-center py-4">벤더 로딩 중...</div>
+ ) : filteredVendors.length === 0 ? (
+ <div className="text-center py-4 text-muted-foreground">
+ 연결 가능한 벤더가 없습니다.
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {filteredVendors.map((vendor) => {
+ const isSelected = selectedVendorIds.includes(vendor.id);
+ return (
+ <div
+ key={`vendor-${vendor.id}`}
+ className={`p-3 bg-white border rounded-lg cursor-pointer transition-colors ${
+ isSelected
+ ? "bg-primary/10 border-primary hover:bg-primary/20"
+ : "hover:bg-gray-50"
+ }`}
+ onClick={() => handleVendorToggle(vendor.id)}
+ >
+ <div className="font-medium">{vendor.vendorName}</div>
+ <div className="text-sm text-muted-foreground">
+ {vendor.email || "-"}
+ </div>
+ <div className="flex flex-wrap gap-2 mt-1 text-xs">
+ <span>타입: {vendor.techVendorType || "-"}</span>
+ <span>상태: {vendor.status || "-"}</span>
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div className="flex justify-end gap-2 pt-4 border-t">
+ <Button variant="outline" onClick={handleClose}>
+ 취소
+ </Button>
+ <Button
+ onClick={handleSubmit}
+ disabled={!selectedItem || selectedVendorIds.length === 0 || isSubmitting}
+ >
+ {isSubmitting ? "연결 중..." : `연결 (${selectedVendorIds.length})`}
+ </Button>
+ </div>
+ </DialogContent>
+ </Dialog>
+ );
+}
+
diff --git a/lib/tech-vendors/possible-items/possible-items-table.tsx b/lib/tech-vendors/possible-items/possible-items-table.tsx
index 100ef04a..226cddf7 100644
--- a/lib/tech-vendors/possible-items/possible-items-table.tsx
+++ b/lib/tech-vendors/possible-items/possible-items-table.tsx
@@ -37,7 +37,8 @@ import { getTechVendorPossibleItems } from "../../tech-vendor-possible-items/ser
import { deleteTechVendorPossibleItem, getTechVendorDetailById } from "../service"
import type { TechVendorPossibleItem } from "../validations"
import { PossibleItemsTableToolbarActions } from "./possible-items-toolbar-actions"
-import { AddItemDialog } from "./add-item-dialog" // 주석처리
+import { AddItemDialog } from "./add-item-dialog"
+import { ConnectItemVendorDialog } from "./connect-item-vendor-dialog"
interface TechVendorPossibleItemsTableProps {
promises: Promise<
@@ -55,7 +56,8 @@ export function TechVendorPossibleItemsTable({
// Suspense로 받아온 데이터
const [{ data, pageCount }] = React.use(promises)
const [rowAction, setRowAction] = React.useState<DataTableRowAction<TechVendorPossibleItem> | null>(null)
- const [showAddDialog, setShowAddDialog] = React.useState(false) // 주석처리
+ const [showAddDialog, setShowAddDialog] = React.useState(false)
+ const [showConnectDialog, setShowConnectDialog] = React.useState(false)
const [showDeleteAlert, setShowDeleteAlert] = React.useState(false)
const [isDeleting, setIsDeleting] = React.useState(false)
@@ -189,7 +191,8 @@ export function TechVendorPossibleItemsTable({
<PossibleItemsTableToolbarActions
table={table}
vendorId={vendorId}
- onAdd={() => setShowAddDialog(true)} // 주석처리
+ onAdd={() => setShowAddDialog(true)}
+ onConnect={() => setShowConnectDialog(true)}
onRefresh={() => {
// 페이지 새로고침을 위한 콜백
window.location.reload()
@@ -199,13 +202,20 @@ export function TechVendorPossibleItemsTable({
</DataTableAdvancedToolbar>
</DataTable>
- {/* Add Item Dialog */}
+ {/* Add Item Dialog (벤더 기준) */}
<AddItemDialog
open={showAddDialog}
onOpenChange={setShowAddDialog}
vendorId={vendorId}
/>
+ {/* Item -> Vendor Connect Dialog (아이템 기준) */}
+ <ConnectItemVendorDialog
+ open={showConnectDialog}
+ onOpenChange={setShowConnectDialog}
+ onConnected={() => window.location.reload()}
+ />
+
{/* Vendor Items Dialog */}
<Dialog open={showItemsDialog} onOpenChange={setShowItemsDialog}>
<DialogContent className="max-w-2xl max-h-[80vh]">
diff --git a/lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx b/lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx
index 49a673ff..428f4ce5 100644
--- a/lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx
+++ b/lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx
@@ -39,14 +39,16 @@ import {
interface PossibleItemsTableToolbarActionsProps {
table: Table<TechVendorPossibleItem>
vendorId: number
- onAdd: () => void // 주석처리
+ onAdd: () => void
+ onConnect: () => void
onRefresh?: () => void // 데이터 새로고침 콜백
}
export function PossibleItemsTableToolbarActions({
table,
vendorId,
- onAdd, // 주석처리
+ onAdd,
+ onConnect,
onRefresh,
}: PossibleItemsTableToolbarActionsProps) {
const [showDeleteAlert, setShowDeleteAlert] = React.useState(false)
@@ -362,7 +364,16 @@ export function PossibleItemsTableToolbarActions({
onClick={onAdd}
>
<Plus className="mr-2 h-4 w-4" />
- 아이템 연결
+ 기존 아이템 연결
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={onConnect}
+ >
+ <Plus className="mr-2 h-4 w-4" />
+ 아이템-벤더 연결
</Button>
{selectedRows.length > 0 && (
diff --git a/lib/tech-vendors/service.ts b/lib/tech-vendors/service.ts
index 940e59ce..5290b6a0 100644
--- a/lib/tech-vendors/service.ts
+++ b/lib/tech-vendors/service.ts
@@ -703,6 +703,267 @@ export interface ItemDropdownOption {
subItemList: string | null;
}
+export interface ItemForVendorMapping {
+ id: number;
+ itemCode: string | null;
+ itemList: string | null;
+ workType: string | null;
+ shipTypes?: string | null;
+ subItemList?: string | null;
+ itemType: "SHIP" | "TOP" | "HULL";
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+export interface VendorForItemMapping {
+ id: number;
+ vendorName: string;
+ email: string | null;
+ techVendorType: string;
+ status: string;
+}
+
+const itemTypeToVendorType: Record<"SHIP" | "TOP" | "HULL", string> = {
+ SHIP: "조선",
+ TOP: "해양TOP",
+ HULL: "해양HULL",
+};
+
+function parseVendorTypes(value: string | string[] | null) {
+ if (!value) return [] as string[];
+ if (Array.isArray(value)) {
+ return value
+ .map((type) => type.trim())
+ .filter((type) => type.length > 0);
+ }
+ return value
+ .split(",")
+ .map((type) => type.trim())
+ .filter((type) => type.length > 0);
+}
+
+/**
+ * 아이템 기준으로 벤더 매핑 시 사용할 전체 아이템 목록 조회
+ * 벤더에 관계없이 전 타입을 모두 가져온다.
+ */
+export async function getItemsForVendorMapping() {
+ return unstable_cache(
+ async () => {
+ try {
+ const items: ItemForVendorMapping[] = [];
+
+ const shipbuildingItems = await db
+ .select({
+ id: itemShipbuilding.id,
+ createdAt: itemShipbuilding.createdAt,
+ updatedAt: itemShipbuilding.updatedAt,
+ itemCode: itemShipbuilding.itemCode,
+ itemList: itemShipbuilding.itemList,
+ workType: itemShipbuilding.workType,
+ shipTypes: itemShipbuilding.shipTypes,
+ })
+ .from(itemShipbuilding)
+ .orderBy(asc(itemShipbuilding.itemCode));
+
+ items.push(
+ ...shipbuildingItems
+ .filter((item) => item.itemCode != null)
+ .map((item) => ({
+ ...item,
+ itemType: "SHIP" as const,
+ }))
+ );
+
+ const offshoreTopItems = await db
+ .select({
+ id: itemOffshoreTop.id,
+ createdAt: itemOffshoreTop.createdAt,
+ updatedAt: itemOffshoreTop.updatedAt,
+ itemCode: itemOffshoreTop.itemCode,
+ itemList: itemOffshoreTop.itemList,
+ workType: itemOffshoreTop.workType,
+ subItemList: itemOffshoreTop.subItemList,
+ })
+ .from(itemOffshoreTop)
+ .orderBy(asc(itemOffshoreTop.itemCode));
+
+ items.push(
+ ...offshoreTopItems
+ .filter((item) => item.itemCode != null)
+ .map((item) => ({
+ ...item,
+ itemType: "TOP" as const,
+ }))
+ );
+
+ const offshoreHullItems = await db
+ .select({
+ id: itemOffshoreHull.id,
+ createdAt: itemOffshoreHull.createdAt,
+ updatedAt: itemOffshoreHull.updatedAt,
+ itemCode: itemOffshoreHull.itemCode,
+ itemList: itemOffshoreHull.itemList,
+ workType: itemOffshoreHull.workType,
+ subItemList: itemOffshoreHull.subItemList,
+ })
+ .from(itemOffshoreHull)
+ .orderBy(asc(itemOffshoreHull.itemCode));
+
+ items.push(
+ ...offshoreHullItems
+ .filter((item) => item.itemCode != null)
+ .map((item) => ({
+ ...item,
+ itemType: "HULL" as const,
+ }))
+ );
+
+ return { data: items, error: null };
+ } catch (err) {
+ console.error("Failed to fetch items for vendor mapping:", err);
+ return {
+ data: [],
+ error: "아이템 목록을 불러오는데 실패했습니다.",
+ };
+ }
+ },
+ ["items-for-vendor-mapping"],
+ {
+ revalidate: 3600,
+ tags: ["items"],
+ }
+ )();
+}
+
+/**
+ * 특정 아이템에 연결 가능한 벤더 목록을 조회
+ * - 이미 연결된 벤더는 제외
+ * - 아이템 타입과 벤더 타입(조선/해양TOP/해양HULL) 매칭
+ */
+export async function getConnectableVendorsForItem(
+ itemId: number,
+ itemType: "SHIP" | "TOP" | "HULL"
+) {
+ unstable_noStore();
+
+ try {
+ // 1) 이미 연결된 벤더 ID 조회
+ const existingVendors = await db
+ .select({ vendorId: techVendorPossibleItems.vendorId })
+ .from(techVendorPossibleItems)
+ .where(
+ itemType === "SHIP"
+ ? eq(techVendorPossibleItems.shipbuildingItemId, itemId)
+ : itemType === "TOP"
+ ? eq(techVendorPossibleItems.offshoreTopItemId, itemId)
+ : eq(techVendorPossibleItems.offshoreHullItemId, itemId)
+ );
+
+ const existingVendorIds = existingVendors.map((row) => row.vendorId);
+
+ // 2) 모든 벤더 조회 후 타입 매칭 + 중복 제외
+ const vendorRows = await db
+ .select({
+ id: techVendors.id,
+ vendorName: techVendors.vendorName,
+ email: techVendors.email,
+ techVendorType: techVendors.techVendorType,
+ status: techVendors.status,
+ })
+ .from(techVendors);
+
+ const targetType = itemTypeToVendorType[itemType];
+
+ const availableVendors: VendorForItemMapping[] = vendorRows
+ .map((vendor) => ({
+ ...vendor,
+ vendorTypes: parseVendorTypes(vendor.techVendorType),
+ }))
+ .filter(
+ (vendor) =>
+ vendor.vendorTypes.includes(targetType) &&
+ !existingVendorIds.includes(vendor.id)
+ )
+ .map(({ vendorTypes, ...rest }) => rest);
+
+ return { data: availableVendors, error: null };
+ } catch (err) {
+ console.error("Failed to fetch connectable vendors:", err);
+ return { data: [], error: "연결 가능한 벤더 조회에 실패했습니다." };
+ }
+}
+
+/**
+ * 선택한 아이템을 여러 벤더와 연결
+ * - 중복 연결은 건너뜀
+ */
+export async function connectItemWithVendors(input: {
+ itemId: number;
+ itemType: "SHIP" | "TOP" | "HULL";
+ vendorIds: number[];
+}) {
+ unstable_noStore();
+
+ if (!input.vendorIds || input.vendorIds.length === 0) {
+ return { success: false, error: "연결할 벤더를 선택해주세요." };
+ }
+
+ try {
+ let successCount = 0;
+ const skipped: number[] = [];
+
+ await db.transaction(async (tx) => {
+ for (const vendorId of input.vendorIds) {
+ const whereConditions = [eq(techVendorPossibleItems.vendorId, vendorId)];
+
+ if (input.itemType === "SHIP") {
+ whereConditions.push(eq(techVendorPossibleItems.shipbuildingItemId, input.itemId));
+ } else if (input.itemType === "TOP") {
+ whereConditions.push(eq(techVendorPossibleItems.offshoreTopItemId, input.itemId));
+ } else {
+ whereConditions.push(eq(techVendorPossibleItems.offshoreHullItemId, input.itemId));
+ }
+
+ const existing = await tx.query.techVendorPossibleItems.findFirst({
+ where: and(...whereConditions),
+ });
+
+ if (existing) {
+ skipped.push(vendorId);
+ continue;
+ }
+
+ const insertData: {
+ vendorId: number;
+ shipbuildingItemId?: number;
+ offshoreTopItemId?: number;
+ offshoreHullItemId?: number;
+ } = { vendorId };
+
+ if (input.itemType === "SHIP") {
+ insertData.shipbuildingItemId = input.itemId;
+ } else if (input.itemType === "TOP") {
+ insertData.offshoreTopItemId = input.itemId;
+ } else {
+ insertData.offshoreHullItemId = input.itemId;
+ }
+
+ await tx.insert(techVendorPossibleItems).values(insertData);
+ successCount += 1;
+ }
+ });
+
+ input.vendorIds.forEach((vendorId) => {
+ revalidateTag(`tech-vendor-possible-items-${vendorId}`);
+ });
+
+ return { success: true, successCount, skipped };
+ } catch (err) {
+ console.error("Failed to connect item with vendors:", err);
+ return { success: false, error: getErrorMessage(err) };
+ }
+}
+
/**
* Vendor Item 추가 시 사용할 아이템 목록 조회 (전체 목록 반환)
* 아이템 코드, 이름, 설명만 간소화해서 반환
diff --git a/lib/techsales-rfq/repository.ts b/lib/techsales-rfq/repository.ts
index e6138651..61072d3f 100644
--- a/lib/techsales-rfq/repository.ts
+++ b/lib/techsales-rfq/repository.ts
@@ -94,6 +94,7 @@ export async function selectTechSalesRfqsWithJoin(
// 담당자 및 비고
picCode: techSalesRfqs.picCode,
+ hideProjectInfoForVendors: techSalesRfqs.hideProjectInfoForVendors,
remark: techSalesRfqs.remark,
cancelReason: techSalesRfqs.cancelReason,
description: techSalesRfqs.description,
diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts
index cf4d02e2..8ce41cba 100644
--- a/lib/techsales-rfq/service.ts
+++ b/lib/techsales-rfq/service.ts
@@ -557,6 +557,7 @@ export async function sendTechSalesRfqToVendors(input: {
email?: string | null;
epId?: string | null;
};
+ hideProjectInfoForVendors?: boolean;
}) {
unstable_noStore();
try {
@@ -573,6 +574,7 @@ export async function sendTechSalesRfqToVendors(input: {
materialCode: true,
description: true,
rfqType: true,
+ hideProjectInfoForVendors: true,
},
with: {
biddingProject: true,
@@ -604,6 +606,23 @@ export async function sendTechSalesRfqToVendors(input: {
}
const isResend = rfq.status === TECH_SALES_RFQ_STATUSES.RFQ_SENT;
+ const effectiveHideProjectInfo =
+ typeof input.hideProjectInfoForVendors === "boolean"
+ ? input.hideProjectInfoForVendors
+ : rfq.hideProjectInfoForVendors ?? false;
+
+ if (
+ typeof input.hideProjectInfoForVendors === "boolean" &&
+ input.hideProjectInfoForVendors !== rfq.hideProjectInfoForVendors
+ ) {
+ await db
+ .update(techSalesRfqs)
+ .set({
+ hideProjectInfoForVendors: input.hideProjectInfoForVendors,
+ updatedAt: new Date(),
+ })
+ .where(eq(techSalesRfqs.id, input.rfqId));
+ }
// 현재 사용자 정보 조회
const sender = await db.query.users.findFirst({
@@ -728,6 +747,9 @@ export async function sendTechSalesRfqToVendors(input: {
const rfqItemsResult = await getTechSalesRfqItems(rfq.id);
const rfqItems = rfqItemsResult.data || [];
+ const projectNameForVendor = effectiveHideProjectInfo ? "" : rfq.biddingProject?.projNm || "";
+ const projectCodeForVendor = effectiveHideProjectInfo ? "" : rfq.biddingProject?.pspid || "";
+
// 이메일 컨텍스트 구성
const emailContext = {
language: language,
@@ -735,8 +757,8 @@ export async function sendTechSalesRfqToVendors(input: {
id: rfq.id,
code: rfq.rfqCode,
title: rfqItems.length > 0 ? rfqItems.map(item => item.itemList).join(', ') : '',
- projectCode: rfq.biddingProject?.pspid || '',
- projectName: rfq.biddingProject?.projNm || '',
+ projectCode: projectCodeForVendor,
+ projectName: projectNameForVendor,
description: rfq.remark || '',
dueDate: rfq.dueDate ? formatDate(rfq.dueDate, "KR") : 'N/A',
materialCode: rfq.materialCode || '',
@@ -990,6 +1012,7 @@ export async function getTechSalesVendorQuotation(quotationId: number) {
projMsrm: quotation.projMsrm,
ptypeNm: quotation.ptypeNm,
} : null,
+ hideProjectInfoForVendors: quotation.hideProjectInfoForVendors ?? false,
},
// 벤더 정보
@@ -1414,6 +1437,7 @@ export async function getVendorQuotations(input: {
dueDate: techSalesRfqs.dueDate,
rfqStatus: techSalesRfqs.status,
description: techSalesRfqs.description,
+ hideProjectInfoForVendors: techSalesRfqs.hideProjectInfoForVendors,
// 프로젝트 정보 (직접 조인)
projNm: biddingProjects.projNm,
// 아이템 개수
@@ -3662,7 +3686,8 @@ export async function getTechSalesVendorQuotationAttachments(quotationId: number
updatedAt: techSalesVendorQuotationAttachments.updatedAt,
})
.from(techSalesVendorQuotationAttachments)
- .where(eq(techSalesVendorQuotationAttachments.quotationId, quotationId))
+ .where(and(eq(techSalesVendorQuotationAttachments.quotationId, quotationId),
+ eq(techSalesVendorQuotationAttachments.isVendorUpload, true)))
.orderBy(desc(techSalesVendorQuotationAttachments.createdAt));
return { data: attachments };
@@ -3680,6 +3705,172 @@ export async function getTechSalesVendorQuotationAttachments(quotationId: number
}
/**
+ * 기술영업 RFQ 기준 벤더 견적서 요약 목록 조회 (eml 첨부 전용)
+ */
+export async function getTechSalesVendorQuotationsForRfq(rfqId: number) {
+ unstable_noStore();
+ try {
+ const quotations = await db
+ .select({
+ id: techSalesVendorQuotations.id,
+ vendorId: techSalesVendorQuotations.vendorId,
+ vendorName: techVendors.vendorName,
+ vendorCode: techVendors.vendorCode,
+ quotationVersion: techSalesVendorQuotations.quotationVersion,
+ status: techSalesVendorQuotations.status,
+ })
+ .from(techSalesVendorQuotations)
+ .leftJoin(techVendors, eq(techSalesVendorQuotations.vendorId, techVendors.id))
+ .where(eq(techSalesVendorQuotations.rfqId, rfqId))
+ .orderBy(
+ asc(techVendors.vendorName),
+ asc(techSalesVendorQuotations.id)
+ );
+
+ return { data: quotations, error: null };
+ } catch (error) {
+ console.error("기술영업 RFQ 벤더 견적서 목록 조회 오류:", error);
+ return { data: [], error: getErrorMessage(error) };
+ }
+}
+
+/**
+ * 기술영업 벤더 견적서 eml 첨부파일 조회 (isVendorUpload = false)
+ */
+export async function getTechSalesVendorQuotationEmlAttachments(quotationId: number) {
+ unstable_noStore();
+ try {
+ const attachments = await db
+ .select({
+ id: techSalesVendorQuotationAttachments.id,
+ quotationId: techSalesVendorQuotationAttachments.quotationId,
+ revisionId: techSalesVendorQuotationAttachments.revisionId,
+ fileName: techSalesVendorQuotationAttachments.fileName,
+ originalFileName: techSalesVendorQuotationAttachments.originalFileName,
+ fileSize: techSalesVendorQuotationAttachments.fileSize,
+ fileType: techSalesVendorQuotationAttachments.fileType,
+ filePath: techSalesVendorQuotationAttachments.filePath,
+ description: techSalesVendorQuotationAttachments.description,
+ uploadedBy: techSalesVendorQuotationAttachments.uploadedBy,
+ vendorId: techSalesVendorQuotationAttachments.vendorId,
+ isVendorUpload: techSalesVendorQuotationAttachments.isVendorUpload,
+ createdAt: techSalesVendorQuotationAttachments.createdAt,
+ updatedAt: techSalesVendorQuotationAttachments.updatedAt,
+ })
+ .from(techSalesVendorQuotationAttachments)
+ .where(
+ and(
+ eq(techSalesVendorQuotationAttachments.quotationId, quotationId),
+ eq(techSalesVendorQuotationAttachments.isVendorUpload, false)
+ )
+ )
+ .orderBy(desc(techSalesVendorQuotationAttachments.createdAt));
+
+ return { data: attachments, error: null };
+ } catch (error) {
+ console.error("기술영업 벤더 견적서 eml 첨부파일 조회 오류:", error);
+ return { data: [], error: getErrorMessage(error) };
+ }
+}
+
+/**
+ * 기술영업 벤더 견적서 eml 첨부파일 업로드/삭제 처리
+ * - isVendorUpload = false 로 저장 (메일 등 별도 전달 문서 보관용)
+ */
+export async function processTechSalesVendorQuotationEmlAttachments(params: {
+ quotationId: number;
+ newFiles?: { file: File; description?: string }[];
+ deleteAttachmentIds?: number[];
+ uploadedBy: number;
+ revisionId?: number;
+}) {
+ unstable_noStore();
+ const { quotationId, newFiles = [], deleteAttachmentIds = [], uploadedBy, revisionId } = params;
+
+ try {
+ // 견적서 확인
+ const quotation = await db.query.techSalesVendorQuotations.findFirst({
+ where: eq(techSalesVendorQuotations.id, quotationId),
+ columns: { id: true, rfqId: true, quotationVersion: true },
+ });
+
+ if (!quotation) {
+ return { data: null, error: "견적서를 찾을 수 없습니다." };
+ }
+
+ const targetRevisionId = revisionId ?? quotation.quotationVersion ?? 0;
+
+ await db.transaction(async (tx) => {
+ // 삭제 처리 (벤더 업로드 파일은 삭제하지 않음)
+ if (deleteAttachmentIds.length > 0) {
+ const deletable = await tx.query.techSalesVendorQuotationAttachments.findMany({
+ where: inArray(techSalesVendorQuotationAttachments.id, deleteAttachmentIds),
+ });
+
+ for (const attachment of deletable) {
+ if (attachment.isVendorUpload) {
+ throw new Error("벤더가 업로드한 파일은 여기서 삭제할 수 없습니다.");
+ }
+
+ await tx
+ .delete(techSalesVendorQuotationAttachments)
+ .where(eq(techSalesVendorQuotationAttachments.id, attachment.id));
+
+ try {
+ deleteFile(attachment.filePath);
+ } catch (fileError) {
+ console.warn("eml 첨부파일 삭제 중 파일 시스템 오류:", fileError);
+ }
+ }
+ }
+
+ // 업로드 처리
+ if (newFiles.length > 0) {
+ for (const { file, description } of newFiles) {
+ const saveResult = await saveFile({
+ file,
+ directory: `techsales-quotations/${quotationId}/eml`,
+ userId: uploadedBy.toString(),
+ });
+
+ if (!saveResult.success) {
+ throw new Error(saveResult.error || "파일 저장에 실패했습니다.");
+ }
+
+ await tx.insert(techSalesVendorQuotationAttachments).values({
+ quotationId,
+ revisionId: targetRevisionId,
+ fileName: saveResult.fileName!,
+ originalFileName: saveResult.originalName || file.name,
+ fileSize: file.size,
+ fileType: file.type || undefined,
+ filePath: saveResult.publicPath!,
+ description: description || null,
+ uploadedBy,
+ isVendorUpload: false,
+ });
+ }
+ }
+ });
+
+ // 캐시 무효화
+ revalidateTag(`quotation-${quotationId}`);
+ revalidateTag("quotation-attachments");
+ revalidateTag("techSalesVendorQuotations");
+ if (quotation.rfqId) {
+ revalidateTag(`techSalesRfq-${quotation.rfqId}`);
+ }
+ revalidateTag("techSalesRfqs");
+
+ const refreshed = await getTechSalesVendorQuotationEmlAttachments(quotationId);
+ return { data: refreshed.data, error: refreshed.error };
+ } catch (error) {
+ console.error("기술영업 벤더 견적서 eml 첨부파일 처리 오류:", error);
+ return { data: null, error: getErrorMessage(error) };
+ }
+}
+
+/**
* 특정 리비전의 견적서 첨부파일 조회
*/
export async function getTechSalesVendorQuotationAttachmentsByRevision(quotationId: number, revisionId: number) {
diff --git a/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx
index fe9befe5..d3a12385 100644
--- a/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx
+++ b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx
@@ -5,7 +5,7 @@ import type { ColumnDef, Row } from "@tanstack/react-table";
import { formatDate } from "@/lib/utils"
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
import { Checkbox } from "@/components/ui/checkbox";
-import { MessageCircle, MoreHorizontal, Trash2, Paperclip, Users } from "lucide-react";
+import { MessageCircle, MoreHorizontal, Trash2, Paperclip, Users, Mail } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
@@ -79,6 +79,7 @@ interface GetColumnsProps<TData> {
onQuotationClick?: (quotationId: number) => void; // 견적 클릭 핸들러
openQuotationAttachmentsSheet?: (quotationId: number, quotationInfo: QuotationInfo) => void; // 견적서 첨부파일 sheet 열기
openContactsDialog?: (quotationId: number, vendorName?: string) => void; // 담당자 조회 다이얼로그 열기
+ openEmlAttachmentsSheet?: (quotationId: number, quotationInfo: QuotationInfo) => void; // eml 첨부파일 sheet 열기
}
export function getRfqDetailColumns({
@@ -86,7 +87,8 @@ export function getRfqDetailColumns({
unreadMessages = {},
onQuotationClick,
openQuotationAttachmentsSheet,
- openContactsDialog
+ openContactsDialog,
+ openEmlAttachmentsSheet
}: GetColumnsProps<RfqDetailView>): ColumnDef<RfqDetailView>[] {
return [
{
@@ -351,6 +353,42 @@ export function getRfqDetailColumns({
size: 80,
},
{
+ id: "emlAttachments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="eml 첨부" />
+ ),
+ cell: ({ row }) => {
+ const quotation = row.original;
+ const handleClick = () => {
+ if (!openEmlAttachmentsSheet) return;
+ openEmlAttachmentsSheet(quotation.id, {
+ id: quotation.id,
+ quotationCode: quotation.quotationCode || null,
+ vendorName: quotation.vendorName || undefined,
+ rfqCode: quotation.rfqCode || undefined,
+ });
+ };
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label="eml 첨부파일 관리"
+ title="eml 첨부파일 관리"
+ >
+ <Mail className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ </Button>
+ );
+ },
+ meta: {
+ excelHeader: "eml 첨부"
+ },
+ enableResizing: false,
+ size: 80,
+ },
+ {
id: "contacts",
header: "담당자",
cell: ({ row }) => {
diff --git a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
index 72f03dc3..d8ced6f8 100644
--- a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
+++ b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
@@ -19,6 +19,7 @@ import { VendorCommunicationDrawer } from "./vendor-communication-drawer"
import { DeleteVendorDialog } from "./delete-vendors-dialog"
import { QuotationHistoryDialog } from "@/lib/techsales-rfq/table/detail-table/quotation-history-dialog"
import { TechSalesQuotationAttachmentsSheet, type QuotationAttachment } from "../tech-sales-quotation-attachments-sheet"
+import { TechSalesVendorEmlAttachmentsSheet, type VendorEmlAttachment } from "../tech-sales-vendor-eml-attachments-sheet"
import type { QuotationInfo } from "./rfq-detail-column"
import { VendorContactSelectionDialog } from "./vendor-contact-selection-dialog"
import { QuotationContactsViewDialog } from "./quotation-contacts-view-dialog"
@@ -89,6 +90,12 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
const [quotationAttachments, setQuotationAttachments] = useState<QuotationAttachment[]>([])
const [isLoadingAttachments, setIsLoadingAttachments] = useState(false)
+ // eml 첨부파일 sheet 상태 관리
+ const [emlAttachmentsSheetOpen, setEmlAttachmentsSheetOpen] = useState(false)
+ const [selectedQuotationForEml, setSelectedQuotationForEml] = useState<QuotationInfo | null>(null)
+ const [emlAttachments, setEmlAttachments] = useState<VendorEmlAttachment[]>([])
+ const [isLoadingEmlAttachments, setIsLoadingEmlAttachments] = useState(false)
+
// 벤더 contact 선택 다이얼로그 상태 관리
const [contactSelectionDialogOpen, setContactSelectionDialogOpen] = useState(false)
@@ -250,7 +257,7 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
contactId: number;
contactEmail: string;
contactName: string;
- }>) => {
+ }>, options?: { hideProjectInfoForVendors?: boolean }) => {
if (!selectedRfqId) {
toast.error("선택된 RFQ가 없습니다.");
return;
@@ -294,6 +301,7 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
name: session.data.user.name || undefined,
email: session.data.user.email || undefined,
},
+ hideProjectInfoForVendors: options?.hideProjectInfoForVendors,
});
if (result.success) {
@@ -463,6 +471,31 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
}
}, [])
+ // eml 첨부파일 sheet 열기 핸들러
+ const handleOpenEmlAttachmentsSheet = useCallback(async (quotationId: number, quotationInfo: QuotationInfo) => {
+ try {
+ setIsLoadingEmlAttachments(true)
+ setSelectedQuotationForEml(quotationInfo)
+ setEmlAttachmentsSheetOpen(true)
+
+ const { getTechSalesVendorQuotationEmlAttachments } = await import("@/lib/techsales-rfq/service")
+ const result = await getTechSalesVendorQuotationEmlAttachments(quotationId)
+
+ if (result.error) {
+ toast.error(result.error)
+ setEmlAttachments([])
+ } else {
+ setEmlAttachments(result.data || [])
+ }
+ } catch (error) {
+ console.error("eml 첨부파일 조회 오류:", error)
+ toast.error("eml 첨부파일을 불러오는 중 오류가 발생했습니다.")
+ setEmlAttachments([])
+ } finally {
+ setIsLoadingEmlAttachments(false)
+ }
+ }, [])
+
// 담당자 조회 다이얼로그 열기 함수
const handleOpenContactsDialog = useCallback((quotationId: number, vendorName?: string) => {
setSelectedQuotationForContacts({ id: quotationId, vendorName })
@@ -554,8 +587,9 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
unreadMessages,
onQuotationClick: handleOpenHistoryDialog,
openQuotationAttachmentsSheet: handleOpenQuotationAttachmentsSheet,
- openContactsDialog: handleOpenContactsDialog
- }), [unreadMessages, handleOpenHistoryDialog, handleOpenQuotationAttachmentsSheet, handleOpenContactsDialog])
+ openContactsDialog: handleOpenContactsDialog,
+ openEmlAttachmentsSheet: handleOpenEmlAttachmentsSheet
+ }), [unreadMessages, handleOpenHistoryDialog, handleOpenQuotationAttachmentsSheet, handleOpenContactsDialog, handleOpenEmlAttachmentsSheet])
// 필터 필드 정의 (메모이제이션)
const advancedFilterFields = useMemo(
@@ -928,6 +962,16 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
isLoading={isLoadingAttachments}
/>
+ {/* eml 첨부파일 Sheet */}
+ <TechSalesVendorEmlAttachmentsSheet
+ open={emlAttachmentsSheetOpen}
+ onOpenChange={setEmlAttachmentsSheetOpen}
+ quotation={selectedQuotationForEml}
+ attachments={emlAttachments}
+ isLoading={isLoadingEmlAttachments}
+ onAttachmentsChange={setEmlAttachments}
+ />
+
{/* 벤더 contact 선택 다이얼로그 */}
<VendorContactSelectionDialog
open={contactSelectionDialogOpen}
diff --git a/lib/techsales-rfq/table/detail-table/vendor-contact-selection-dialog.tsx b/lib/techsales-rfq/table/detail-table/vendor-contact-selection-dialog.tsx
index d83394bb..8daa9be7 100644
--- a/lib/techsales-rfq/table/detail-table/vendor-contact-selection-dialog.tsx
+++ b/lib/techsales-rfq/table/detail-table/vendor-contact-selection-dialog.tsx
@@ -49,7 +49,10 @@ interface VendorContactSelectionDialogProps {
onOpenChange: (open: boolean) => void
vendorIds: number[]
rfqId?: number // RFQ ID 추가
- onSendRfq: (selectedContacts: SelectedContact[]) => Promise<void>
+ onSendRfq: (
+ selectedContacts: SelectedContact[],
+ options: { hideProjectInfoForVendors: boolean }
+ ) => Promise<void>
}
export function VendorContactSelectionDialog({
@@ -63,6 +66,7 @@ export function VendorContactSelectionDialog({
const [selectedContacts, setSelectedContacts] = useState<SelectedContact[]>([])
const [isLoading, setIsLoading] = useState(false)
const [isSending, setIsSending] = useState(false)
+ const [hideProjectInfoForVendors, setHideProjectInfoForVendors] = useState(false)
// 벤더 contact 정보 조회
useEffect(() => {
@@ -77,6 +81,7 @@ export function VendorContactSelectionDialog({
setVendorsWithContacts({})
setSelectedContacts([])
setIsLoading(false)
+ setHideProjectInfoForVendors(false)
}
}, [open])
@@ -177,7 +182,7 @@ export function VendorContactSelectionDialog({
try {
setIsSending(true)
- await onSendRfq(selectedContacts)
+ await onSendRfq(selectedContacts, { hideProjectInfoForVendors })
onOpenChange(false)
} catch (error) {
console.error("RFQ 발송 오류:", error)
@@ -328,8 +333,17 @@ export function VendorContactSelectionDialog({
<DialogFooter>
<div className="flex items-center justify-between w-full">
- <div className="text-sm text-muted-foreground">
- 총 {selectedContacts.length}명의 연락처가 선택됨
+ <div className="flex flex-col gap-2">
+ <div className="text-sm text-muted-foreground">
+ 총 {selectedContacts.length}명의 연락처가 선택됨
+ </div>
+ <label className="flex items-center gap-2 text-sm">
+ <Checkbox
+ checked={hideProjectInfoForVendors}
+ onCheckedChange={(checked) => setHideProjectInfoForVendors(!!checked)}
+ />
+ 벤더 화면에서 프로젝트명/선주명을 숨기기
+ </label>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
diff --git a/lib/techsales-rfq/table/tech-sales-vendor-eml-attachments-sheet.tsx b/lib/techsales-rfq/table/tech-sales-vendor-eml-attachments-sheet.tsx
new file mode 100644
index 00000000..2b6f6753
--- /dev/null
+++ b/lib/techsales-rfq/table/tech-sales-vendor-eml-attachments-sheet.tsx
@@ -0,0 +1,348 @@
+"use client"
+
+import * as React from "react"
+import {
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+ SheetDescription,
+ SheetFooter,
+ SheetClose,
+} from "@/components/ui/sheet"
+import { Button } from "@/components/ui/button"
+import {
+ Dropzone,
+ DropzoneDescription,
+ DropzoneInput,
+ DropzoneTitle,
+ DropzoneUploadIcon,
+ DropzoneZone,
+} from "@/components/ui/dropzone"
+import {
+ FileList,
+ FileListAction,
+ FileListDescription,
+ FileListHeader,
+ FileListIcon,
+ FileListInfo,
+ FileListItem,
+ FileListName,
+} from "@/components/ui/file-list"
+import { Badge } from "@/components/ui/badge"
+import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel } from "@/components/ui/form"
+import { toast } from "sonner"
+import { Download, Loader, Trash2, X } from "lucide-react"
+import prettyBytes from "pretty-bytes"
+import { useSession } from "next-auth/react"
+import { useForm } from "react-hook-form"
+import { formatDate } from "@/lib/utils"
+import {
+ getTechSalesVendorQuotationEmlAttachments,
+ processTechSalesVendorQuotationEmlAttachments,
+} from "@/lib/techsales-rfq/service"
+
+const MAX_FILE_SIZE = 6e8 // 600MB
+
+export interface VendorEmlAttachment {
+ id: number
+ quotationId: number
+ revisionId: number
+ fileName: string
+ originalFileName: string
+ fileSize: number
+ fileType: string | null
+ filePath: string
+ description: string | null
+ uploadedBy: number | null
+ vendorId: number | null
+ isVendorUpload: boolean
+ createdAt: Date
+ updatedAt: Date
+}
+
+interface QuotationInfo {
+ id: number
+ quotationCode: string | null
+ vendorName?: string
+ rfqCode?: string
+}
+
+interface TechSalesVendorEmlAttachmentsSheetProps extends React.ComponentPropsWithRef<typeof Sheet> {
+ quotation: QuotationInfo | null
+ attachments: VendorEmlAttachment[]
+ onAttachmentsChange?: (attachments: VendorEmlAttachment[]) => void
+ isLoading?: boolean
+}
+
+export function TechSalesVendorEmlAttachmentsSheet({
+ quotation,
+ attachments,
+ onAttachmentsChange,
+ isLoading = false,
+ ...props
+}: TechSalesVendorEmlAttachmentsSheetProps) {
+ const session = useSession()
+ const [isPending, setIsPending] = React.useState(false)
+ const [existing, setExisting] = React.useState<VendorEmlAttachment[]>(attachments)
+ const [newUploads, setNewUploads] = React.useState<File[]>([])
+ const [deleteIds, setDeleteIds] = React.useState<number[]>([])
+
+ const form = useForm({
+ defaultValues: {
+ dummy: true,
+ },
+ })
+
+ // sync when parent changes
+ React.useEffect(() => {
+ setExisting(attachments)
+ setNewUploads([])
+ setDeleteIds([])
+ }, [attachments])
+
+ const handleDownloadClick = React.useCallback(async (attachment: VendorEmlAttachment) => {
+ try {
+ const { downloadFile } = await import("@/lib/file-download")
+ await downloadFile(attachment.filePath, attachment.originalFileName || attachment.fileName, {
+ showToast: true,
+ onError: (error) => {
+ console.error("다운로드 오류:", error)
+ toast.error(error)
+ },
+ })
+ } catch (error) {
+ console.error("다운로드 오류:", error)
+ toast.error("파일 다운로드 중 오류가 발생했습니다.")
+ }
+ }, [])
+
+ const handleDropAccepted = React.useCallback((accepted: File[]) => {
+ setNewUploads((prev) => [...prev, ...accepted])
+ }, [])
+
+ const handleDropRejected = React.useCallback(() => {
+ toast.error("파일 크기가 너무 크거나 지원하지 않는 형식입니다.")
+ }, [])
+
+ const handleRemoveExisting = React.useCallback((id: number) => {
+ setDeleteIds((prev) => (prev.includes(id) ? prev : [...prev, id]))
+ setExisting((prev) => prev.filter((att) => att.id !== id))
+ }, [])
+
+ const handleRemoveNewUpload = React.useCallback((index: number) => {
+ setNewUploads((prev) => prev.filter((_, i) => i !== index))
+ }, [])
+
+ const handleSubmit = async () => {
+ if (!quotation) {
+ toast.error("견적 정보를 찾을 수 없습니다.")
+ return
+ }
+
+ const userId = Number(session.data?.user.id || 0)
+ if (!userId) {
+ toast.error("로그인 정보를 확인해주세요.")
+ return
+ }
+
+ setIsPending(true)
+ try {
+ const result = await processTechSalesVendorQuotationEmlAttachments({
+ quotationId: quotation.id,
+ newFiles: newUploads.map((file) => ({ file })),
+ deleteAttachmentIds: deleteIds,
+ uploadedBy: userId,
+ })
+
+ if (result.error) {
+ toast.error(result.error)
+ return
+ }
+
+ const refreshed =
+ result.data ||
+ (await getTechSalesVendorQuotationEmlAttachments(quotation.id)).data ||
+ []
+
+ setExisting(refreshed)
+ setNewUploads([])
+ setDeleteIds([])
+ onAttachmentsChange?.(refreshed)
+ toast.success("Eml 첨부파일이 저장되었습니다.")
+ props.onOpenChange?.(false)
+ } catch (error) {
+ console.error("eml 첨부파일 저장 오류:", error)
+ toast.error("eml 첨부파일 저장 중 오류가 발생했습니다.")
+ } finally {
+ setIsPending(false)
+ }
+ }
+
+ const totalNewSize = newUploads.reduce((acc, f) => acc + f.size, 0)
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>eml 첨부파일</SheetTitle>
+ <SheetDescription>
+ <div className="space-y-1">
+ {quotation?.vendorName && <div>벤더: {quotation.vendorName}</div>}
+ {quotation?.rfqCode && <div>RFQ: {quotation.rfqCode}</div>}
+ </div>
+ </SheetDescription>
+ </SheetHeader>
+
+ <Form {...form}>
+ <form onSubmit={(e) => e.preventDefault()} className="flex flex-1 flex-col gap-6">
+ {/* 기존 첨부 */}
+ <div className="grid gap-4">
+ <h6 className="font-semibold leading-none tracking-tight">
+ 기존 첨부파일 ({existing.length}개)
+ </h6>
+ {isLoading ? (
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <Loader className="h-4 w-4 animate-spin" />
+ 로딩 중...
+ </div>
+ ) : existing.length === 0 ? (
+ <div className="text-sm text-muted-foreground">첨부파일이 없습니다.</div>
+ ) : (
+ existing.map((att) => (
+ <div
+ key={att.id}
+ className="flex items-start justify-between p-3 border rounded-md gap-3"
+ >
+ <div className="flex-1 min-w-0">
+ <div className="flex items-center gap-2 mb-1 flex-wrap">
+ <p className="text-sm font-medium break-words leading-tight">
+ {att.originalFileName || att.fileName}
+ </p>
+ <Badge variant="outline" className="text-xs shrink-0">
+ rev {att.revisionId}
+ </Badge>
+ </div>
+ <p className="text-xs text-muted-foreground">
+ {prettyBytes(att.fileSize)} • {formatDate(att.createdAt, "KR")}
+ </p>
+ {att.description && (
+ <p className="text-xs text-muted-foreground mt-1 break-words">
+ {att.description}
+ </p>
+ )}
+ </div>
+
+ <div className="flex items-center gap-1 shrink-0">
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-8 w-8"
+ type="button"
+ onClick={() => handleDownloadClick(att)}
+ title="다운로드"
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-8 w-8"
+ type="button"
+ onClick={() => handleRemoveExisting(att.id)}
+ title="삭제"
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ ))
+ )}
+ </div>
+
+ {/* 새 업로드 */}
+ <Dropzone
+ maxSize={MAX_FILE_SIZE}
+ onDropAccepted={handleDropAccepted}
+ onDropRejected={handleDropRejected}
+ >
+ {({ maxSize }) => (
+ <FormField
+ control={form.control}
+ name="dummy"
+ render={() => (
+ <FormItem>
+ <FormLabel>새 eml 파일 업로드</FormLabel>
+ <DropzoneZone className="flex justify-center">
+ <FormControl>
+ <DropzoneInput />
+ </FormControl>
+ <div className="flex items-center gap-6">
+ <DropzoneUploadIcon />
+ <div className="grid gap-0.5">
+ <DropzoneTitle>파일을 드래그하거나 클릭하세요</DropzoneTitle>
+ <DropzoneDescription>
+ 최대 크기: {maxSize ? prettyBytes(maxSize) : "600MB"}
+ </DropzoneDescription>
+ </div>
+ </div>
+ </DropzoneZone>
+ <FormDescription>복수 파일 업로드 가능</FormDescription>
+ </FormItem>
+ )}
+ />
+ )}
+ </Dropzone>
+
+ {newUploads.length > 0 && (
+ <div className="grid gap-3">
+ <div className="flex items-center justify-between">
+ <h6 className="font-semibold leading-none tracking-tight">
+ 새 파일 ({newUploads.length}개)
+ </h6>
+ <span className="text-xs text-muted-foreground">
+ 총 용량 {prettyBytes(totalNewSize)}
+ </span>
+ </div>
+ <FileList>
+ {newUploads.map((file, idx) => (
+ <FileListItem key={`${file.name}-${idx}`}>
+ <FileListHeader>
+ <FileListIcon />
+ <FileListInfo>
+ <FileListName>{file.name}</FileListName>
+ <FileListDescription>{prettyBytes(file.size)}</FileListDescription>
+ </FileListInfo>
+ <FileListAction onClick={() => handleRemoveNewUpload(idx)}>
+ <X />
+ <span className="sr-only">제거</span>
+ </FileListAction>
+ </FileListHeader>
+ </FileListItem>
+ ))}
+ </FileList>
+ </div>
+ )}
+
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ 닫기
+ </Button>
+ </SheetClose>
+ <Button
+ type="button"
+ onClick={handleSubmit}
+ disabled={isPending || (!newUploads.length && deleteIds.length === 0)}
+ >
+ {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />}
+ {isPending ? "저장 중..." : "저장"}
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+}
+
diff --git a/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx b/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx
index 8a45f529..31e87330 100644
--- a/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx
+++ b/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx
@@ -16,6 +16,7 @@ interface ProjectInfoTabProps {
dueDate: Date | null
status: string | null
remark: string | null
+ hideProjectInfoForVendors?: boolean
biddingProject?: {
id: number
pspid: string | null
@@ -110,7 +111,9 @@ export function ProjectInfoTab({ quotation }: ProjectInfoTabProps) {
<CardHeader>
<CardTitle className="flex items-center gap-2">
프로젝트 기본 정보
- <Badge variant="outline">{rfq.biddingProject.pspid || "N/A"}</Badge>
+ <Badge variant="outline">
+ {rfq.hideProjectInfoForVendors ? "비공개" : (rfq.biddingProject.pspid || "N/A")}
+ </Badge>
</CardTitle>
<CardDescription>
연결된 프로젝트의 기본 정보
@@ -120,11 +123,15 @@ export function ProjectInfoTab({ quotation }: ProjectInfoTabProps) {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<div className="text-sm font-medium text-muted-foreground">프로젝트 ID</div>
- <div className="text-sm">{rfq.biddingProject.pspid || "N/A"}</div>
+ <div className="text-sm">
+ {rfq.hideProjectInfoForVendors ? "비공개" : (rfq.biddingProject.pspid || "N/A")}
+ </div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-muted-foreground">프로젝트명</div>
- <div className="text-sm">{rfq.biddingProject.projNm || "N/A"}</div>
+ <div className="text-sm">
+ {rfq.hideProjectInfoForVendors ? "비공개" : (rfq.biddingProject.projNm || "N/A")}
+ </div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-muted-foreground">프로젝트 섹터</div>
diff --git a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx
index aabe7a64..97f21be2 100644
--- a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx
+++ b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx
@@ -27,6 +27,7 @@ interface QuotationWithRfqCode extends TechSalesVendorQuotations {
materialCode?: string;
dueDate?: Date;
rfqStatus?: string;
+ hideProjectInfoForVendors?: boolean;
// 아이템 정보
itemName?: string;
@@ -258,17 +259,19 @@ export function getColumns({ router, openAttachmentsSheet, openItemsDialog, open
),
cell: ({ row }) => {
const projNm = row.getValue("projNm") as string;
+ const hideProjectInfo = row.original.hideProjectInfoForVendors === true;
+ const displayValue = hideProjectInfo ? "비공개" : projNm || "N/A";
return (
<div className="min-w-48 max-w-64">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="truncate block text-sm">
- {projNm || "N/A"}
+ {displayValue}
</span>
</TooltipTrigger>
<TooltipContent>
- <p className="max-w-xs">{projNm || "N/A"}</p>
+ <p className="max-w-xs">{displayValue}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
diff --git a/lib/vendor-document-list/plant/document-stage-dialogs.tsx b/lib/vendor-document-list/plant/document-stage-dialogs.tsx
index d4e0ff33..b6cf6d7a 100644
--- a/lib/vendor-document-list/plant/document-stage-dialogs.tsx
+++ b/lib/vendor-document-list/plant/document-stage-dialogs.tsx
@@ -44,7 +44,8 @@ import {
updateDocument,
deleteDocuments,
updateStage,
- getDocumentClassOptionsByContract
+ getDocumentClassOptionsByContract,
+ checkDuplicateDocuments
} from "./document-stages-service"
import { type Row } from "@tanstack/react-table"
@@ -127,6 +128,14 @@ export function AddDocumentDialog({
const [cpyTypeConfigs, setCpyTypeConfigs] = React.useState<any[]>([])
const [cpyComboBoxOptions, setCpyComboBoxOptions] = React.useState<Record<number, any[]>>({})
+ // Duplicate check states
+ const [duplicateWarning, setDuplicateWarning] = React.useState<{
+ isDuplicate: boolean
+ type?: 'SHI_DOC_NO' | 'OWN_DOC_NO' | 'BOTH'
+ message?: string
+ }>({ isDuplicate: false })
+ const [isCheckingDuplicate, setIsCheckingDuplicate] = React.useState(false)
+
// Initialize react-hook-form
const form = useForm<DocumentFormValues>({
resolver: zodResolver(documentFormSchema),
@@ -167,6 +176,7 @@ export function AddDocumentDialog({
setShiComboBoxOptions({})
setCpyComboBoxOptions({})
setDocumentClassOptions([])
+ setDuplicateWarning({ isDuplicate: false })
}
}, [open])
@@ -359,6 +369,59 @@ export function AddDocumentDialog({
return preview && preview !== '' && !preview.includes('[value]')
}
+ // Real-time duplicate check with debounce
+ const checkDuplicateDebounced = React.useMemo(() => {
+ let timeoutId: NodeJS.Timeout | null = null
+
+ return (shiDocNo: string, cpyDocNo: string) => {
+ if (timeoutId) {
+ clearTimeout(timeoutId)
+ }
+
+ timeoutId = setTimeout(async () => {
+ // Skip if both are empty or incomplete
+ if ((!shiDocNo || shiDocNo.includes('[value]')) &&
+ (!cpyDocNo || cpyDocNo.includes('[value]'))) {
+ setDuplicateWarning({ isDuplicate: false })
+ return
+ }
+
+ setIsCheckingDuplicate(true)
+ try {
+ const result = await checkDuplicateDocuments(
+ contractId,
+ shiDocNo && !shiDocNo.includes('[value]') ? shiDocNo : undefined,
+ cpyDocNo && !cpyDocNo.includes('[value]') ? cpyDocNo : undefined
+ )
+
+ if (result.isDuplicate) {
+ setDuplicateWarning({
+ isDuplicate: true,
+ type: result.duplicateType,
+ message: result.message
+ })
+ } else {
+ setDuplicateWarning({ isDuplicate: false })
+ }
+ } catch (error) {
+ console.error('Duplicate check error:', error)
+ } finally {
+ setIsCheckingDuplicate(false)
+ }
+ }, 500) // 500ms debounce
+ }
+ }, [contractId])
+
+ // Trigger duplicate check when document numbers change
+ React.useEffect(() => {
+ const shiPreview = generateShiPreview()
+ const cpyPreview = generateCpyPreview()
+
+ if (shiPreview || cpyPreview) {
+ checkDuplicateDebounced(shiPreview, cpyPreview)
+ }
+ }, [shiFieldValues, cpyFieldValues])
+
const onSubmit = async (data: DocumentFormValues) => {
// Validate that at least one document number is configured and complete
if (shiType && !isShiComplete()) {
@@ -520,6 +583,24 @@ export function AddDocumentDialog({
<form onSubmit={form.handleSubmit(onSubmit)} className="flex-1 flex flex-col min-h-0">
<div className="flex-1 overflow-y-auto pr-2 space-y-4">
+ {/* Duplicate Warning Alert */}
+ {duplicateWarning.isDuplicate && (
+ <Alert variant="destructive" className="border-red-300 bg-red-50 dark:bg-red-950/50">
+ <AlertTriangle className="h-4 w-4" />
+ <AlertDescription className="font-medium">
+ {duplicateWarning.message}
+ </AlertDescription>
+ </Alert>
+ )}
+
+ {/* Checking Duplicate Indicator */}
+ {isCheckingDuplicate && (
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <Loader2 className="h-4 w-4 animate-spin" />
+ Checking for duplicates...
+ </div>
+ )}
+
{/* SHI Document Number Card */}
{shiType && (
<Card className="border-blue-200 dark:border-blue-800">
@@ -719,7 +800,9 @@ export function AddDocumentDialog({
form.formState.isSubmitting ||
!hasAvailableTypes ||
(shiType && !isShiComplete()) ||
- (cpyType && !isCpyComplete())
+ (cpyType && !isCpyComplete()) ||
+ duplicateWarning.isDuplicate ||
+ isCheckingDuplicate
}
>
{form.formState.isSubmitting ? (
diff --git a/lib/vendor-document-list/plant/document-stages-service.ts b/lib/vendor-document-list/plant/document-stages-service.ts
index ed4099b3..cf19eb41 100644
--- a/lib/vendor-document-list/plant/document-stages-service.ts
+++ b/lib/vendor-document-list/plant/document-stages-service.ts
@@ -878,6 +878,127 @@ interface CreateDocumentData {
vendorDocNumber?: string
}
+// ═══════════════════════════════════════════════════════════════════════════════
+// 문서번호 중복 체크 함수 (SHI_DOC_NO / OWN_DOC_NO 각각 중복 방지)
+// ═══════════════════════════════════════════════════════════════════════════════
+interface CheckDuplicateResult {
+ isDuplicate: boolean
+ duplicateType?: 'SHI_DOC_NO' | 'OWN_DOC_NO' | 'BOTH'
+ existingDocNumbers?: {
+ shiDocNo?: string
+ ownDocNo?: string
+ }
+ message?: string
+}
+
+/**
+ * 프로젝트 내에서 SHI_DOC_NO (docNumber)와 OWN_DOC_NO (vendorDocNumber) 중복 체크
+ * @param contractId 계약 ID (프로젝트 ID를 가져오기 위함)
+ * @param shiDocNo SHI 문서번호 (docNumber)
+ * @param ownDocNo CPY 문서번호 (vendorDocNumber)
+ * @param excludeDocumentId 수정 시 제외할 문서 ID (선택)
+ */
+export async function checkDuplicateDocuments(
+ contractId: number,
+ shiDocNo?: string,
+ ownDocNo?: string,
+ excludeDocumentId?: number
+): Promise<CheckDuplicateResult> {
+ try {
+ // 1. 계약에서 프로젝트 ID 가져오기
+ const contract = await db.query.contracts.findFirst({
+ where: eq(contracts.id, contractId),
+ columns: { projectId: true },
+ })
+
+ if (!contract) {
+ return { isDuplicate: false, message: "유효하지 않은 계약입니다." }
+ }
+
+ const { projectId } = contract
+ let shiDuplicate = false
+ let ownDuplicate = false
+ const existingDocNumbers: { shiDocNo?: string; ownDocNo?: string } = {}
+
+ // 2. SHI_DOC_NO 중복 체크 (docNumber)
+ if (shiDocNo && shiDocNo.trim() !== '') {
+ const shiConditions = [
+ eq(stageDocuments.projectId, projectId),
+ eq(stageDocuments.docNumber, shiDocNo.trim()),
+ eq(stageDocuments.status, "ACTIVE"),
+ ]
+
+ if (excludeDocumentId) {
+ shiConditions.push(ne(stageDocuments.id, excludeDocumentId))
+ }
+
+ const existingShiDoc = await db
+ .select({ id: stageDocuments.id, docNumber: stageDocuments.docNumber })
+ .from(stageDocuments)
+ .where(and(...shiConditions))
+ .limit(1)
+
+ if (existingShiDoc.length > 0) {
+ shiDuplicate = true
+ existingDocNumbers.shiDocNo = existingShiDoc[0].docNumber
+ }
+ }
+
+ // 3. OWN_DOC_NO 중복 체크 (vendorDocNumber)
+ if (ownDocNo && ownDocNo.trim() !== '') {
+ const ownConditions = [
+ eq(stageDocuments.projectId, projectId),
+ eq(stageDocuments.vendorDocNumber, ownDocNo.trim()),
+ eq(stageDocuments.status, "ACTIVE"),
+ ]
+
+ if (excludeDocumentId) {
+ ownConditions.push(ne(stageDocuments.id, excludeDocumentId))
+ }
+
+ const existingOwnDoc = await db
+ .select({ id: stageDocuments.id, vendorDocNumber: stageDocuments.vendorDocNumber })
+ .from(stageDocuments)
+ .where(and(...ownConditions))
+ .limit(1)
+
+ if (existingOwnDoc.length > 0) {
+ ownDuplicate = true
+ existingDocNumbers.ownDocNo = existingOwnDoc[0].vendorDocNumber || undefined
+ }
+ }
+
+ // 4. 결과 반환
+ if (shiDuplicate && ownDuplicate) {
+ return {
+ isDuplicate: true,
+ duplicateType: 'BOTH',
+ existingDocNumbers,
+ message: `SHI Document Number '${shiDocNo}' and CPY Document Number '${ownDocNo}' already exist in this project.`
+ }
+ } else if (shiDuplicate) {
+ return {
+ isDuplicate: true,
+ duplicateType: 'SHI_DOC_NO',
+ existingDocNumbers,
+ message: `SHI Document Number '${shiDocNo}' already exists in this project.`
+ }
+ } else if (ownDuplicate) {
+ return {
+ isDuplicate: true,
+ duplicateType: 'OWN_DOC_NO',
+ existingDocNumbers,
+ message: `CPY Document Number '${ownDocNo}' already exists in this project.`
+ }
+ }
+
+ return { isDuplicate: false }
+ } catch (error) {
+ console.error("중복 체크 실패:", error)
+ return { isDuplicate: false, message: "중복 체크 중 오류가 발생했습니다." }
+ }
+}
+
// 문서 생성
export async function createDocument(data: CreateDocumentData) {
try {
@@ -907,6 +1028,20 @@ export async function createDocument(data: CreateDocumentData) {
return { success: false, error: configsResult.error }
}
+ /* ──────────────────────────────── 2. 중복 체크 (SHI_DOC_NO & OWN_DOC_NO) ─────────────────────────────── */
+ const duplicateCheck = await checkDuplicateDocuments(
+ data.contractId,
+ data.docNumber,
+ data.vendorDocNumber
+ )
+
+ if (duplicateCheck.isDuplicate) {
+ return {
+ success: false,
+ error: duplicateCheck.message || "Document number already exists in this project.",
+ duplicateType: duplicateCheck.duplicateType,
+ }
+ }
/* ──────────────────────────────── 3. 문서 레코드 삽입 ─────────────────────────────── */
const insertData = {
@@ -1403,7 +1538,7 @@ export async function uploadImportData(data: UploadData) {
try {
// 개별 트랜잭션으로 각 문서 처리
const result = await db.transaction(async (tx) => {
- // 먼저 문서가 이미 존재하는지 확인
+ // 먼저 SHI_DOC_NO (docNumber)가 이미 존재하는지 확인
const [existingDoc] = await tx
.select({ id: stageDocuments.id })
.from(stageDocuments)
@@ -1417,7 +1552,26 @@ export async function uploadImportData(data: UploadData) {
.limit(1)
if (existingDoc) {
- throw new Error(`문서번호 "${doc.docNumber}"가 이미 존재합니다`)
+ throw new Error(`SHI Document Number "${doc.docNumber}" already exists in this project`)
+ }
+
+ // OWN_DOC_NO (vendorDocNumber) 중복 체크
+ if (doc.vendorDocNumber && doc.vendorDocNumber.trim() !== '') {
+ const [existingVendorDoc] = await tx
+ .select({ id: stageDocuments.id, vendorDocNumber: stageDocuments.vendorDocNumber })
+ .from(stageDocuments)
+ .where(
+ and(
+ eq(stageDocuments.projectId, contract.projectId),
+ eq(stageDocuments.vendorDocNumber, doc.vendorDocNumber.trim()),
+ eq(stageDocuments.status, "ACTIVE")
+ )
+ )
+ .limit(1)
+
+ if (existingVendorDoc) {
+ throw new Error(`CPY Document Number "${doc.vendorDocNumber}" already exists in this project`)
+ }
}
// 3-1. 문서 생성
diff --git a/lib/vendor-investigation/service.ts b/lib/vendor-investigation/service.ts
index cf37ad06..5e53d0dd 100644
--- a/lib/vendor-investigation/service.ts
+++ b/lib/vendor-investigation/service.ts
@@ -339,6 +339,9 @@ export async function updateVendorInvestigationResultAction(formData: FormData)
processedEntries.investigationNotes = textEntries.investigationNotes
}
+ // attachments는 별도로 업로드되므로 빈 배열로 설정
+ processedEntries.attachments = []
+
// 3) Zod로 파싱/검증
const parsed = updateVendorInvestigationResultSchema.parse(processedEntries)
diff --git a/lib/vendor-investigation/validations.ts b/lib/vendor-investigation/validations.ts
index 84361ef9..29fb46cb 100644
--- a/lib/vendor-investigation/validations.ts
+++ b/lib/vendor-investigation/validations.ts
@@ -140,7 +140,8 @@ export const updateVendorInvestigationResultSchema = z.object({
.max(100, "평가 점수는 100점 이하여야 합니다."),
evaluationResult: z.enum(["APPROVED", "SUPPLEMENT", "SUPPLEMENT_REINSPECT", "SUPPLEMENT_DOCUMENT", "REJECTED", "RESULT_SENT"]),
investigationNotes: z.string().max(1000, "QM 의견은 1000자 이내로 입력해주세요.").optional(),
- attachments: z.array(z.any()).min(1, "최소 1개의 첨부파일이 필요합니다."),
+ // attachments는 별도의 API로 업로드되므로 이 스키마에서는 optional
+ attachments: z.array(z.any()).optional(),
}).superRefine((data, ctx) => {
// 날짜 검증: 실제 실사일이 실사의뢰일보다 과거가 되지 않도록 검증
if (data.requestedAt && data.completedAt) {
@@ -198,7 +199,7 @@ export const updateVendorInvestigationSchema = z.object({
.optional(),
evaluationResult: z.enum(["APPROVED", "SUPPLEMENT", "SUPPLEMENT_REINSPECT", "SUPPLEMENT_DOCUMENT", "REJECTED", "RESULT_SENT"]).optional(),
investigationNotes: z.string().max(1000, "QM 의견은 1000자 이내로 입력해주세요.").optional(),
- attachments: z.any().optional(), // File 업로드를 위한 필드
+ attachments: z.array(z.any()).min(1, "최소 1개의 첨부파일이 필요합니다."), // File 업로드 필수
}).superRefine((data, ctx) => {
// 날짜 검증: 실사의뢰일(requestedAt)이 있는 경우 다른 날짜들이 실사의뢰일보다 과거가 되지 않도록 검증
if (data.requestedAt) {
diff --git a/lib/vendors/items-table/item-action-dialog.tsx b/lib/vendors/items-table/item-action-dialog.tsx
index 19df27f8..6bbcc436 100644
--- a/lib/vendors/items-table/item-action-dialog.tsx
+++ b/lib/vendors/items-table/item-action-dialog.tsx
@@ -1,248 +1,289 @@
-// components/vendor-items/item-actions-dialogs.tsx
"use client"
import * as React from "react"
-import type { DataTableRowAction } from "@/types/table"
-import { VendorItemsView } from "@/db/schema/vendors"
-import { toast } from "sonner"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Check, ChevronsUpDown } from "lucide-react"
+import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from "@/components/ui/alert-dialog"
-import { Button } from "@/components/ui/button"
-import { Label } from "@/components/ui/label"
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command"
+import { cn } from "@/lib/utils"
-import { updateVendorItem, deleteVendorItem, getItemsForVendor } from "../service"
+import {
+ createVendorItemSchema,
+ type CreateVendorItemSchema,
+} from "../validations"
-interface ItemActionsDialogsProps {
+import { createVendorItem, getItemsForVendor, ItemDropdownOption } from "../service"
+
+interface AddItemDialogProps {
vendorId: number
- rowAction: DataTableRowAction<VendorItemsView> | null
- setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorItemsView> | null>>
}
-export function ItemActionsDialogs({
- vendorId,
- rowAction,
- setRowAction,
-}: ItemActionsDialogsProps) {
- const [isUpdatePending, startUpdateTransition] = React.useTransition()
- const [isDeletePending, startDeleteTransition] = React.useTransition()
- const [availableMaterials, setAvailableMaterials] = React.useState<any[]>([])
- const [selectedItemCode, setSelectedItemCode] = React.useState<string>("")
-
- // 사용 가능한 재료 목록 로드
- React.useEffect(() => {
- if (rowAction?.type === "update") {
- getItemsForVendor(vendorId).then((result) => {
- if (result.data) {
- setAvailableMaterials(result.data)
- }
- })
- }
- }, [rowAction, vendorId])
-
- // Edit Dialog
- const EditDialog = () => {
- if (!rowAction || rowAction.type !== "update") return null
+export function AddItemDialog({ vendorId }: AddItemDialogProps) {
+ const [open, setOpen] = React.useState(false)
+ const [commandOpen, setCommandOpen] = React.useState(false)
+ const [items, setItems] = React.useState<ItemDropdownOption[]>([])
+ const [filteredItems, setFilteredItems] = React.useState<ItemDropdownOption[]>([])
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [searchTerm, setSearchTerm] = React.useState("")
+
+ // 선택된 아이템의 정보를 보여주기 위한 상태
+ const [selectedItem, setSelectedItem] = React.useState<{
+ itemName: string;
+ description: string;
+ } | null>(null)
- const item = rowAction.row.original
+ // react-hook-form 세팅 - 서버로 보낼 값은 vendorId와 itemCode만
+ const form = useForm<CreateVendorItemSchema>({
+ resolver: zodResolver(createVendorItemSchema),
+ defaultValues: {
+ vendorId,
+ itemCode: "",
+ },
+ })
- const handleSubmit = () => {
- if (!selectedItemCode) {
- toast.error("Please select a new item")
- return
- }
+ console.log(vendorId)
- if (!item.itemCode) {
- toast.error("Invalid item code")
- return
+ // 아이템 목록 가져오기 (한 번만 호출)
+ const fetchItems = React.useCallback(async () => {
+ if (items.length > 0) return // 이미 로드된 경우 스킵
+
+ setIsLoading(true)
+ try {
+ const result = await getItemsForVendor(vendorId)
+ if (result.data) {
+ setItems(result.data)
+ setFilteredItems(result.data)
}
+ } catch (error) {
+ console.error("Failed to fetch items:", error)
+ } finally {
+ setIsLoading(false)
+ }
+ }, [items.length])
- startUpdateTransition(async () => {
- const result = await updateVendorItem(vendorId, item.itemCode, selectedItemCode)
-
- if (result.error) {
- toast.error(result.error)
- } else {
- toast.success("Item updated successfully")
- setRowAction(null)
- }
- })
+ // 팝오버 열릴 때 아이템 목록 로드
+ React.useEffect(() => {
+ if (commandOpen) {
+ fetchItems()
}
+ }, [commandOpen, fetchItems])
- return (
- <Dialog
- open={true}
- onOpenChange={(open) => !open && setRowAction(null)}
- >
- <DialogContent className="sm:max-w-[425px]">
- <DialogHeader>
- <DialogTitle>Change Item</DialogTitle>
- <DialogDescription>
- Select a new item to replace "{item.itemName}" (Code: {item.itemCode || 'N/A'}).
- </DialogDescription>
- </DialogHeader>
-
- <div className="space-y-4">
- <div className="space-y-2">
- <Label>Current Item</Label>
- <div className="p-2 bg-muted rounded-md">
- <div className="font-medium">{item.itemName}</div>
- <div className="text-sm text-muted-foreground">Code: {item.itemCode || 'N/A'}</div>
- </div>
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="newItem">New Item</Label>
- <Select value={selectedItemCode} onValueChange={setSelectedItemCode}>
- <SelectTrigger>
- <SelectValue placeholder="Select a new item" />
- </SelectTrigger>
- <SelectContent>
- {availableMaterials.map((material) => (
- <SelectItem key={material.itemCode} value={material.itemCode}>
- <div>
- <div className="font-medium">{material.itemName}</div>
- <div className="text-sm text-muted-foreground">Code: {material.itemCode}</div>
- </div>
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- </div>
- </div>
-
- <DialogFooter>
- <Button
- type="button"
- variant="outline"
- onClick={() => setRowAction(null)}
- disabled={isUpdatePending}
- >
- Cancel
- </Button>
- <Button
- onClick={handleSubmit}
- disabled={isUpdatePending || !selectedItemCode}
- >
- {isUpdatePending ? "Updating..." : "Update Item"}
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
+ // 클라이언트 사이드 필터링
+ React.useEffect(() => {
+ if (!items.length) return
+
+ if (!searchTerm.trim()) {
+ setFilteredItems(items)
+ return
+ }
+
+ const lowerSearch = searchTerm.toLowerCase()
+ const filtered = items.filter(item =>
+ item.itemCode.toLowerCase().includes(lowerSearch) ||
+ item.itemName.toLowerCase().includes(lowerSearch) ||
+ (item.description && item.description.toLowerCase().includes(lowerSearch))
)
- }
-
- // Delete Dialog
- const DeleteDialog = () => {
- if (!rowAction || rowAction.type !== "delete") return null
-
- const item = rowAction.row.original
+
+ setFilteredItems(filtered)
+ }, [searchTerm, items])
- const handleDelete = () => {
- if (!item.itemCode) {
- toast.error("Invalid item code")
- return
- }
+ // 선택된 아이템 데이터로 폼 업데이트
+ const handleSelectItem = (item: ItemDropdownOption) => {
+ // 폼에는 itemCode만 설정
+ form.setValue("itemCode", item.itemCode)
+
+ // 나머지 정보는 표시용 상태에 저장
+ setSelectedItem({
+ itemName: item.itemName,
+ description: item.description || "",
+ })
+
+ setCommandOpen(false)
+ }
- startDeleteTransition(async () => {
- const result = await deleteVendorItem(vendorId, item.itemCode)
-
- if (result.error) {
- toast.error(result.error)
- } else {
- toast.success("Item deleted successfully")
- setRowAction(null)
- }
- })
+ // 폼 제출 - itemCode만 서버로 전송
+ async function onSubmit(data: CreateVendorItemSchema) {
+ // 서버에는 vendorId와 itemCode만 전송됨
+ const result = await createVendorItem(data)
+ console.log(result)
+ if (result.error) {
+ alert(`에러: ${result.error}`)
+ return
}
-
- return (
- <AlertDialog
- open={true}
- onOpenChange={(open) => !open && setRowAction(null)}
- >
- <AlertDialogContent>
- return (
- <AlertDialog
- open={true}
- onOpenChange={(open) => !open && setRowAction(null)}
- >
- <AlertDialogContent>
- <AlertDialogHeader>
- <AlertDialogTitle>Are you sure?</AlertDialogTitle>
- <AlertDialogDescription>
- This will permanently delete the item "{item.itemName}" (Code: {item.itemCode || 'N/A'}).
- This action cannot be undone.
- </AlertDialogDescription>
- </AlertDialogHeader>
- <AlertDialogFooter>
- <AlertDialogCancel disabled={isDeletePending}>
- Cancel
- </AlertDialogCancel>
- <AlertDialogAction
- onClick={handleDelete}
- disabled={isDeletePending}
- className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
- >
- {isDeletePending ? "Deleting..." : "Delete"}
- </AlertDialogAction>
- </AlertDialogFooter>
- </AlertDialogContent>
- </AlertDialog>
- )
+ // 성공 시 모달 닫고 폼 리셋
+ form.reset()
+ setSelectedItem(null)
+ setOpen(false)
}
- return (
- <>
- <EditDialog />
- <DeleteDialog />
- </>
- )
-}
- <AlertDialogFooter>
- <AlertDialogCancel disabled={isDeletePending}>
- Cancel
- </AlertDialogCancel>
- <AlertDialogAction
- onClick={handleDelete}
- disabled={isDeletePending}
- className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
- >
- {isDeletePending ? "Deleting..." : "Delete"}
- </AlertDialogAction>
- </AlertDialogFooter>
- </AlertDialogContent>
- </AlertDialog>
- )
+ // 모달 열림/닫힘 핸들
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ // 닫힐 때 폼 리셋
+ form.reset()
+ setSelectedItem(null)
+ }
+ setOpen(nextOpen)
}
+ // 현재 선택된 아이템 코드
+ const selectedItemCode = form.watch("itemCode")
+
+ // 선택된 아이템 코드가 있으면 상세 정보 표시를 위한 아이템 찾기
+ const displayItemCode = selectedItemCode || "아이템 선택..."
+ const displayItemName = selectedItem?.itemName || ""
+
return (
- <>
- <EditDialog />
- <DeleteDialog />
- </>
+ <Dialog open={open} onOpenChange={handleDialogOpenChange}>
+ {/* 모달 열기 버튼 */}
+ <DialogTrigger asChild>
+ <Button variant="default" size="sm">
+ Add Item
+ </Button>
+ </DialogTrigger>
+
+ <DialogContent className="max-h-[90vh] overflow-hidden flex flex-col">
+ <DialogHeader>
+ <DialogTitle>Create New Item</DialogTitle>
+ <DialogDescription>
+ 아이템을 선택한 후 <b>Create</b> 버튼을 누르세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* shadcn/ui Form + react-hook-form */}
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 overflow-hidden">
+ <div className="space-y-4 py-4 flex-1 overflow-y-auto">
+
+ {/* 아이템 선택 */}
+ <div>
+ <FormLabel className="text-sm font-medium">아이템 선택</FormLabel>
+ <Popover open={commandOpen} onOpenChange={setCommandOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={commandOpen}
+ className="w-full justify-between mt-1"
+ >
+ {selectedItemCode
+ ? `${selectedItemCode} - ${displayItemName}`
+ : "아이템 선택..."}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput
+ placeholder="아이템 코드/이름 검색..."
+ onValueChange={setSearchTerm}
+ />
+ <CommandList className="max-h-[200px]">
+ <CommandEmpty>검색 결과가 없습니다</CommandEmpty>
+ {isLoading ? (
+ <div className="py-6 text-center text-sm">로딩 중...</div>
+ ) : (
+ <CommandGroup>
+ {filteredItems.map((item) => (
+ <CommandItem
+ key={item.itemCode}
+ value={`${item.itemCode} ${item.itemName}`}
+ onSelect={() => handleSelectItem(item)}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ selectedItemCode === item.itemCode
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ <span className="font-medium">{item.itemCode}</span>
+ <span className="ml-2 text-gray-500 truncate">- {item.itemName}</span>
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ )}
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ </div>
+
+ {/* 아이템 정보 영역 - 선택된 경우에만 표시 */}
+ {selectedItem && (
+ <div className="rounded-md border p-3 mt-4 overflow-hidden">
+ <h3 className="font-medium text-sm mb-2">선택된 아이템 정보</h3>
+
+ {/* Item Code - readonly (hidden field) */}
+ <FormField
+ control={form.control}
+ name="itemCode"
+ render={({ field }) => (
+ <FormItem className="hidden">
+ <FormControl>
+ <Input {...field} />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+
+ {/* Item Name (표시용) */}
+ <div className="mb-2">
+ <p className="text-xs font-medium text-gray-500">Item Name</p>
+ <p className="text-sm mt-0.5 break-words">{selectedItem.itemName}</p>
+ </div>
+
+ {/* Description (표시용) */}
+ {selectedItem.description && (
+ <div>
+ <p className="text-xs font-medium text-gray-500">Description</p>
+ <p className="text-sm mt-0.5 break-words max-h-20 overflow-y-auto">{selectedItem.description}</p>
+ </div>
+ )}
+ </div>
+ )}
+
+ </div>
+
+ <DialogFooter className="flex-shrink-0 pt-2">
+ <Button type="button" variant="outline" onClick={() => setOpen(false)}>
+ Cancel
+ </Button>
+ <Button
+ type="submit"
+ disabled={form.formState.isSubmitting || !selectedItemCode}
+ >
+ Create
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
)
} \ No newline at end of file
diff --git a/next.config.ts b/next.config.ts
index 98c58d2e..9202d625 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -2,14 +2,16 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
serverExternalPackages: ['pino', 'pino-pretty', 'node-cron', 'oracledb','sharp', '@pdftron/pdfnet-node'],
-
reactStrictMode: false,
+
eslint: {
ignoreDuringBuilds: true,
},
+
typescript: {
ignoreBuildErrors: true,
},
+
webpack: (config) => {
// [김준회] turbo의 resolveAlias와 동일한 설정을 webpack에 적용
config.resolve.alias = {
@@ -48,34 +50,39 @@ const nextConfig: NextConfig = {
return config;
},
+
experimental: {
serverActions: {
// [김준회] DRM 복호화/암호화 백엔드로 보낼 때 사이즈 제한 변경(기본값: 1MB)
// DDoS 공격을 방지하기 위해 기본값이 1MB로 설정되어 있음. 암호화된 파일 중 큰 파일(도면 등)도 1GB 이하로 가정하여 설정 (파일별로 서버액션 개별 호출)
bodySizeLimit: '1024mb',
},
- turbo: {
- treeShaking: false,
- minify: false,
- unstablePersistentCaching: false,
- // [김준회 프로] 오라클 DB 사용을 위한 라이브러리/nextjs 번들러 호환 문제 해결
- resolveAlias: {
- '@azure/app-configuration': 'data:text/javascript,export default {};',
- '@azure/identity': 'data:text/javascript,export default {};',
- '@azure/keyvault-secrets': 'data:text/javascript,export default {};',
- 'oci-common': 'data:text/javascript,export default {};',
- 'oci-objectstorage': 'data:text/javascript,export default {};',
- 'oci-secrets': 'data:text/javascript,export default {};',
- // knex 관련 데이터베이스 드라이버들
- 'better-sqlite3': 'data:text/javascript,export default {};',
- 'mysql': 'data:text/javascript,export default {};',
- 'mysql2': 'data:text/javascript,export default {};',
- 'pg-query-stream': 'data:text/javascript,export default {};',
- 'sqlite3': 'data:text/javascript,export default {};',
- 'tedious': 'data:text/javascript,export default {};',
- },
- }
+
+ // deprecated options
+ // turbopackTreeShaking: false,
+ // turbopackMinify: false
},
+
+ turbopack: {
+ // unstablePersistentCaching: false,
+
+ // [김준회 프로] 오라클 DB 사용을 위한 라이브러리/nextjs 번들러 호환 문제 해결
+ resolveAlias: {
+ '@azure/app-configuration': 'data:text/javascript,export default {};',
+ '@azure/identity': 'data:text/javascript,export default {};',
+ '@azure/keyvault-secrets': 'data:text/javascript,export default {};',
+ 'oci-common': 'data:text/javascript,export default {};',
+ 'oci-objectstorage': 'data:text/javascript,export default {};',
+ 'oci-secrets': 'data:text/javascript,export default {};',
+ // knex 관련 데이터베이스 드라이버들
+ 'better-sqlite3': 'data:text/javascript,export default {};',
+ 'mysql': 'data:text/javascript,export default {};',
+ 'mysql2': 'data:text/javascript,export default {};',
+ 'pg-query-stream': 'data:text/javascript,export default {};',
+ 'sqlite3': 'data:text/javascript,export default {};',
+ 'tedious': 'data:text/javascript,export default {};',
+ }
+ }
};
export default nextConfig;
diff --git a/package-lock.json b/package-lock.json
index 723e48b7..8cd2160e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -106,8 +106,6 @@
"@tiptap/extension-underline": "^2.23.1",
"@tiptap/react": "^2.23.1",
"@tiptap/starter-kit": "^2.23.1",
- "@toast-ui/editor": "^3.2.2",
- "@toast-ui/react-editor": "^3.2.3",
"@types/docusign-esign": "^5.19.8",
"@types/formidable": "^3.4.5",
"accept-language": "^3.0.20",
@@ -119,7 +117,6 @@
"codemirror": "^6.0.2",
"crypto-js": "^4.2.0",
"date-fns": "^3.6.0",
- "dns": "^0.2.2",
"docusign-esign": "^8.0.1",
"docx": "^9.5.1",
"drizzle-orm": "^0.38.2",
@@ -145,13 +142,13 @@
"libphonenumber-js": "^1.12.10",
"lucide-react": "^0.468.0",
"match-sorter": "^8.2.0",
- "next": "15.1.0",
+ "next": "^15.1.9",
"next-auth": "^4.24.11",
"next-i18n-router": "^5.5.1",
"next-i18next": "^15.4.1",
"next-themes": "^0.4.4",
"node-cron": "^4.1.1",
- "nodemailer": "^6.9.16",
+ "nodemailer": "^7.0.7",
"nuqs": "^2.2.3",
"oracledb": "^6.8.0",
"pg": "^8.13.1",
@@ -190,8 +187,8 @@
"@types/node": "^20",
"@types/nodemailer": "^6.4.17",
"@types/pg": "^8.11.10",
- "@types/react": "^18.3.1",
- "@types/react-dom": "^18.3.1",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
"@types/sharp": "^0.31.1",
"@types/ua-parser-js": "^0.7.39",
"@types/uuid": "^10.0.0",
@@ -598,6 +595,39 @@
"dev": true,
"license": "Apache-2.0"
},
+ "node_modules/@emnapi/core": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.5.tgz",
+ "integrity": "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.0.4",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.5.tgz",
+ "integrity": "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.4.tgz",
+ "integrity": "sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/@emotion/babel-plugin": {
"version": "11.13.5",
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
@@ -756,6 +786,363 @@
"source-map-support": "^0.5.21"
}
},
+ "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz",
+ "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz",
+ "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz",
+ "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz",
+ "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz",
+ "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz",
+ "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz",
+ "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz",
+ "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz",
+ "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz",
+ "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz",
+ "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz",
+ "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz",
+ "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz",
+ "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz",
+ "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz",
+ "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz",
+ "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz",
+ "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz",
+ "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz",
+ "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz",
+ "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz",
@@ -823,6 +1210,431 @@
"get-tsconfig": "^4.7.0"
}
},
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz",
+ "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz",
+ "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz",
+ "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz",
+ "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz",
+ "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz",
+ "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz",
+ "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz",
+ "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz",
+ "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz",
+ "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz",
+ "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz",
+ "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz",
+ "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz",
+ "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz",
+ "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz",
+ "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz",
+ "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz",
+ "integrity": "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz",
+ "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz",
+ "integrity": "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz",
+ "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz",
+ "integrity": "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz",
+ "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz",
+ "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz",
+ "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/@esbuild/win32-x64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz",
@@ -1049,9 +1861,9 @@
}
},
"node_modules/@floating-ui/dom": {
- "version": "1.7.4",
- "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
- "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
+ "version": "1.7.3",
+ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.3.tgz",
+ "integrity": "sha512-uZA413QEpNuhtb3/iIKoYMSK07keHPYeXF02Zhd6e213j+d1NamLix/mCLxBUDW/Gx52sPH2m+chlUsyaBs/Ag==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.3",
@@ -1059,12 +1871,12 @@
}
},
"node_modules/@floating-ui/react-dom": {
- "version": "2.1.6",
- "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz",
- "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==",
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.5.tgz",
+ "integrity": "sha512-HDO/1/1oH9fjj4eLgegrlH3dklZpHtUYYFiVwMUwfGvk9jWDRWqkklA2/NFScknrcNSspbV868WjXORvreDX+Q==",
"license": "MIT",
"dependencies": {
- "@floating-ui/dom": "^1.7.4"
+ "@floating-ui/dom": "^1.7.3"
},
"peerDependencies": {
"react": ">=16.8.0",
@@ -1178,6 +1990,405 @@
"url": "https://github.com/sponsors/nzakas"
}
},
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.34.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.3.tgz",
+ "integrity": "sha512-ryFMfvxxpQRsgZJqBd4wsttYQbCxsJksrv9Lw/v798JcQ8+w84mBWuXwl+TT0WJ/WrYOLaYpwQXi3sA9nTIaIg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.2.0"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.34.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.3.tgz",
+ "integrity": "sha512-yHpJYynROAj12TA6qil58hmPmAwxKKC7reUqtGLzsOHfP7/rniNGTL8tjWX6L3CTV4+5P4ypcS7Pp+7OB+8ihA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.2.0"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.0.tgz",
+ "integrity": "sha512-sBZmpwmxqwlqG9ueWFXtockhsxefaV6O84BMOrhtg/YqbTaRdqDE7hxraVE3y6gVM4eExmfzW4a8el9ArLeEiQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.0.tgz",
+ "integrity": "sha512-M64XVuL94OgiNHa5/m2YvEQI5q2cl9d/wk0qFTDVXcYzi43lxuiFTftMR1tOnFQovVXNZJ5TURSDK2pNe9Yzqg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.0.tgz",
+ "integrity": "sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.0.tgz",
+ "integrity": "sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.0.tgz",
+ "integrity": "sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.0.tgz",
+ "integrity": "sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.0.tgz",
+ "integrity": "sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.0.tgz",
+ "integrity": "sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.0.tgz",
+ "integrity": "sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.34.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.3.tgz",
+ "integrity": "sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.2.0"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.34.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.3.tgz",
+ "integrity": "sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.2.0"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.34.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.3.tgz",
+ "integrity": "sha512-GLtbLQMCNC5nxuImPR2+RgrviwKwVql28FWZIW1zWruy6zLgA5/x2ZXk3mxj58X/tszVF69KK0Is83V8YgWhLA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.2.0"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.34.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.3.tgz",
+ "integrity": "sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.2.0"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.34.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.3.tgz",
+ "integrity": "sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.2.0"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.34.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.3.tgz",
+ "integrity": "sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.0"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.34.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.3.tgz",
+ "integrity": "sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.0"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.34.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.3.tgz",
+ "integrity": "sha512-+CyRcpagHMGteySaWos8IbnXcHgfDn7pO2fiC2slJxvNq9gDipYBN42/RagzctVRKgxATmfqOSulgZv5e1RdMg==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.4.4"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.34.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.3.tgz",
+ "integrity": "sha512-MjnHPnbqMXNC2UgeLJtX4XqoVHHlZNd+nPt1kRPmj63wURegwBhZlApELdtxM2OIZDRv/DFtLcNhVbd1z8GYXQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.34.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.3.tgz",
+ "integrity": "sha512-xuCdhH44WxuXgOM714hn4amodJMZl3OEvf0GVTm0BEyMeA2to+8HEdRPShH0SLYptJY1uBw+SCFP9WVQi1Q/cw==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.3.tgz",
@@ -2159,10 +3370,23 @@
}
}
},
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "0.2.12",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
+ "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.4.3",
+ "@emnapi/runtime": "^1.4.3",
+ "@tybys/wasm-util": "^0.10.0"
+ }
+ },
"node_modules/@next/env": {
- "version": "15.1.0",
- "resolved": "https://registry.npmjs.org/@next/env/-/env-15.1.0.tgz",
- "integrity": "sha512-UcCO481cROsqJuszPPXJnb7GGuLq617ve4xuAyyNG4VSSocJNtMU5Fsx+Lp6mlN8c7W58aZLc5y6D/2xNmaK+w==",
+ "version": "15.5.7",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.7.tgz",
+ "integrity": "sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
@@ -2176,12 +3400,13 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
- "version": "15.1.0",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.1.0.tgz",
- "integrity": "sha512-ZU8d7xxpX14uIaFC3nsr4L++5ZS/AkWDm1PzPO6gD9xWhFkOj2hzSbSIxoncsnlJXB1CbLOfGVN4Zk9tg83PUw==",
+ "version": "15.5.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz",
+ "integrity": "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==",
"cpu": [
"arm64"
],
+ "license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -2191,12 +3416,13 @@
}
},
"node_modules/@next/swc-darwin-x64": {
- "version": "15.1.0",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.1.0.tgz",
- "integrity": "sha512-DQ3RiUoW2XC9FcSM4ffpfndq1EsLV0fj0/UY33i7eklW5akPUCo6OX2qkcLXZ3jyPdo4sf2flwAED3AAq3Om2Q==",
+ "version": "15.5.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz",
+ "integrity": "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==",
"cpu": [
"x64"
],
+ "license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -2206,12 +3432,13 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
- "version": "15.1.0",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.1.0.tgz",
- "integrity": "sha512-M+vhTovRS2F//LMx9KtxbkWk627l5Q7AqXWWWrfIzNIaUFiz2/NkOFkxCFyNyGACi5YbA8aekzCLtbDyfF/v5Q==",
+ "version": "15.5.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz",
+ "integrity": "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==",
"cpu": [
"arm64"
],
+ "license": "MIT",
"optional": true,
"os": [
"linux"
@@ -2221,12 +3448,13 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
- "version": "15.1.0",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.1.0.tgz",
- "integrity": "sha512-Qn6vOuwaTCx3pNwygpSGtdIu0TfS1KiaYLYXLH5zq1scoTXdwYfdZtwvJTpB1WrLgiQE2Ne2kt8MZok3HlFqmg==",
+ "version": "15.5.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz",
+ "integrity": "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==",
"cpu": [
"arm64"
],
+ "license": "MIT",
"optional": true,
"os": [
"linux"
@@ -2236,12 +3464,13 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
- "version": "15.1.0",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.1.0.tgz",
- "integrity": "sha512-yeNh9ofMqzOZ5yTOk+2rwncBzucc6a1lyqtg8xZv0rH5znyjxHOWsoUtSq4cUTeeBIiXXX51QOOe+VoCjdXJRw==",
+ "version": "15.5.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz",
+ "integrity": "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==",
"cpu": [
"x64"
],
+ "license": "MIT",
"optional": true,
"os": [
"linux"
@@ -2251,12 +3480,13 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
- "version": "15.1.0",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.1.0.tgz",
- "integrity": "sha512-t9IfNkHQs/uKgPoyEtU912MG6a1j7Had37cSUyLTKx9MnUpjj+ZDKw9OyqTI9OwIIv0wmkr1pkZy+3T5pxhJPg==",
+ "version": "15.5.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz",
+ "integrity": "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==",
"cpu": [
"x64"
],
+ "license": "MIT",
"optional": true,
"os": [
"linux"
@@ -2266,12 +3496,13 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
- "version": "15.1.0",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.1.0.tgz",
- "integrity": "sha512-WEAoHyG14t5sTavZa1c6BnOIEukll9iqFRTavqRVPfYmfegOAd5MaZfXgOGG6kGo1RduyGdTHD4+YZQSdsNZXg==",
+ "version": "15.5.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz",
+ "integrity": "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==",
"cpu": [
"arm64"
],
+ "license": "MIT",
"optional": true,
"os": [
"win32"
@@ -2281,9 +3512,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
- "version": "15.1.0",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.1.0.tgz",
- "integrity": "sha512-J1YdKuJv9xcixzXR24Dv+4SaDKc2jj31IVUEMdO5xJivMTXuE6MAdIi4qPjSymHuFG8O5wbfWKnhJUcHHpj5CA==",
+ "version": "15.5.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz",
+ "integrity": "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==",
"cpu": [
"x64"
],
@@ -2484,59 +3715,6 @@
}
}
},
- "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-collapsible": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.11.tgz",
- "integrity": "sha512-2qrRsVGSCYasSz1RFOorXwl0H7g7J1frQtgpQgYrt+MOidtPAINHn9CPovQXb83r8ahapdx3Tu0fa/pdFFSdPg==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.2",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-id": "1.1.1",
- "@radix-ui/react-presence": "1.1.4",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-controllable-state": "1.2.2",
- "@radix-ui/react-use-layout-effect": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-primitive": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
- "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-slot": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
"node_modules/@radix-ui/react-alert-dialog": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.14.tgz",
@@ -2565,116 +3743,6 @@
}
}
},
- "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-dialog": {
- "version": "1.1.14",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.14.tgz",
- "integrity": "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.2",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-dismissable-layer": "1.1.10",
- "@radix-ui/react-focus-guards": "1.1.2",
- "@radix-ui/react-focus-scope": "1.1.7",
- "@radix-ui/react-id": "1.1.1",
- "@radix-ui/react-portal": "1.1.9",
- "@radix-ui/react-presence": "1.1.4",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-slot": "1.2.3",
- "@radix-ui/react-use-controllable-state": "1.2.2",
- "aria-hidden": "^1.2.4",
- "react-remove-scroll": "^2.6.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-dismissable-layer": {
- "version": "1.1.10",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz",
- "integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.2",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-callback-ref": "1.1.1",
- "@radix-ui/react-use-escape-keydown": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-portal": {
- "version": "1.1.9",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
- "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-layout-effect": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-primitive": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
- "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-slot": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
"node_modules/@radix-ui/react-arrow": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
@@ -2698,29 +3766,6 @@
}
}
},
- "node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-primitive": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
- "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-slot": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
"node_modules/@radix-ui/react-aspect-ratio": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.7.tgz",
@@ -2744,29 +3789,6 @@
}
}
},
- "node_modules/@radix-ui/react-aspect-ratio/node_modules/@radix-ui/react-primitive": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
- "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-slot": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
"node_modules/@radix-ui/react-avatar": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz",
@@ -2794,29 +3816,6 @@
}
}
},
- "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-primitive": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
- "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-slot": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
"node_modules/@radix-ui/react-checkbox": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.2.tgz",
@@ -2847,40 +3846,17 @@
}
}
},
- "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-primitive": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
- "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-slot": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
"node_modules/@radix-ui/react-collapsible": {
- "version": "1.1.12",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz",
- "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==",
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.11.tgz",
+ "integrity": "sha512-2qrRsVGSCYasSz1RFOorXwl0H7g7J1frQtgpQgYrt+MOidtPAINHn9CPovQXb83r8ahapdx3Tu0fa/pdFFSdPg==",
"license": "MIT",
"dependencies": {
- "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/primitive": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-id": "1.1.1",
- "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-presence": "1.1.4",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
@@ -2900,59 +3876,6 @@
}
}
},
- "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/primitive": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
- "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
- "license": "MIT"
- },
- "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-presence": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
- "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-use-layout-effect": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-primitive": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
- "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-slot": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
"node_modules/@radix-ui/react-collection": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
@@ -2979,29 +3902,6 @@
}
}
},
- "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-primitive": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
- "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-slot": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
@@ -3060,44 +3960,21 @@
}
}
},
- "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-primitive": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
- "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-slot": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
"node_modules/@radix-ui/react-dialog": {
- "version": "1.1.15",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
- "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
+ "version": "1.1.14",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.14.tgz",
+ "integrity": "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==",
"license": "MIT",
"dependencies": {
- "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/primitive": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-dismissable-layer": "1.1.11",
- "@radix-ui/react-focus-guards": "1.1.3",
+ "@radix-ui/react-dismissable-layer": "1.1.10",
+ "@radix-ui/react-focus-guards": "1.1.2",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-portal": "1.1.9",
- "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-presence": "1.1.4",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
@@ -3119,98 +3996,6 @@
}
}
},
- "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/primitive": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
- "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
- "license": "MIT"
- },
- "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-guards": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
- "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
- "license": "MIT",
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-portal": {
- "version": "1.1.9",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
- "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-layout-effect": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-presence": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
- "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-use-layout-effect": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
- "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-slot": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
"node_modules/@radix-ui/react-direction": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
@@ -3227,12 +4012,12 @@
}
},
"node_modules/@radix-ui/react-dismissable-layer": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
- "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
+ "version": "1.1.10",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz",
+ "integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==",
"license": "MIT",
"dependencies": {
- "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/primitive": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
@@ -3253,35 +4038,6 @@
}
}
},
- "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/primitive": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
- "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
- "license": "MIT"
- },
- "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-primitive": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
- "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-slot": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
"node_modules/@radix-ui/react-dropdown-menu": {
"version": "2.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.15.tgz",
@@ -3311,29 +4067,6 @@
}
}
},
- "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-primitive": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
- "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-slot": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
"node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.2.tgz",
@@ -3374,29 +4107,6 @@
}
}
},
- "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-primitive": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
- "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-slot": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
"node_modules/@radix-ui/react-hover-card": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.14.tgz",
@@ -3428,80 +4138,6 @@
}
}
},
- "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-dismissable-layer": {
- "version": "1.1.10",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz",
- "integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.2",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-callback-ref": "1.1.1",
- "@radix-ui/react-use-escape-keydown": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-portal": {
- "version": "1.1.9",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
- "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-layout-effect": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-primitive": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
- "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-slot": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
"node_modules/@radix-ui/react-icons": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.2.tgz",
@@ -3552,29 +4188,6 @@
}
}
},
- "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
- "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-slot": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
"node_modules/@radix-ui/react-menu": {
"version": "2.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.15.tgz",
@@ -3615,80 +4228,6 @@
}
}
},
- "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-dismissable-layer": {
- "version": "1.1.10",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz",
- "integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.2",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-callback-ref": "1.1.1",
- "@radix-ui/react-use-escape-keydown": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-portal": {
- "version": "1.1.9",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
- "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-layout-effect": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-primitive": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
- "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-slot": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
"node_modules/@radix-ui/react-menubar": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.15.tgz",
@@ -3721,29 +4260,6 @@
}
}
},
- "node_modules/@radix-ui/react-menubar/node_modules/@radix-ui/react-primitive": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
- "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-slot": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
"node_modules/@radix-ui/react-navigation-menu": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.13.tgz",
@@ -3780,56 +4296,6 @@
}
}
},
- "node_modules/@radix-ui/react-navigation-menu/node_modules/@radix-ui/react-dismissable-layer": {
- "version": "1.1.10",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz",
- "integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.2",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-callback-ref": "1.1.1",
- "@radix-ui/react-use-escape-keydown": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-navigation-menu/node_modules/@radix-ui/react-primitive": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
- "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-slot": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
"node_modules/@radix-ui/react-popover": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.14.tgz",
@@ -3867,80 +4333,6 @@
}
}
},
- "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-dismissable-layer": {
- "version": "1.1.10",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz",
- "integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.2",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-callback-ref": "1.1.1",
- "@radix-ui/react-use-escape-keydown": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-portal": {
- "version": "1.1.9",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
- "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-layout-effect": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-primitive": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
- "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-slot": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
"node_modules/@radix-ui/react-popper": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.7.tgz",
@@ -3973,36 +4365,13 @@
}
}
},
- "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-primitive": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
- "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-slot": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
"node_modules/@radix-ui/react-portal": {
- "version": "1.1.10",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.10.tgz",
- "integrity": "sha512-4kY9IVa6+9nJPsYmngK5Uk2kUmZnv7ChhHAFeQ5oaj8jrR1bIi3xww8nH71pz1/Ve4d/cXO3YxT8eikt1B0a8w==",
+ "version": "1.1.9",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
+ "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
"license": "MIT",
"dependencies": {
- "@radix-ui/react-primitive": "2.1.4",
+ "@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
@@ -4045,12 +4414,12 @@
}
},
"node_modules/@radix-ui/react-primitive": {
- "version": "2.1.4",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
- "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
+ "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
- "@radix-ui/react-slot": "1.2.4"
+ "@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
@@ -4067,24 +4436,6 @@
}
}
},
- "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
- "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-compose-refs": "1.1.2"
- },
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
"node_modules/@radix-ui/react-progress": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz",
@@ -4109,29 +4460,6 @@
}
}
},
- "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-primitive": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
- "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-slot": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
"node_modules/@radix-ui/react-radio-group": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.7.tgz",
@@ -4164,29 +4492,6 @@
}
}
},
- "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-primitive": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
- "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-slot": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
"node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.10.tgz",
@@ -4218,29 +4523,6 @@
}
}
},
- "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-primitive": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
- "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-slot": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
"node_modules/@radix-ui/react-scroll-area": {
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.9.tgz",
@@ -4272,29 +4554,6 @@
}
}
},
- "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-primitive": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
- "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-slot": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
"node_modules/@radix-ui/react-select": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.5.tgz",
@@ -4338,80 +4597,6 @@
}
}
},
- "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-dismissable-layer": {
- "version": "1.1.10",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz",
- "integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.2",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-callback-ref": "1.1.1",
- "@radix-ui/react-use-escape-keydown": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-portal": {
- "version": "1.1.9",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
- "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-layout-effect": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-primitive": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
- "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-slot": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
"node_modules/@radix-ui/react-separator": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz",
@@ -4435,29 +4620,6 @@
}
}
},
- "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
- "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-slot": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
"node_modules/@radix-ui/react-slider": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.5.tgz",
@@ -4491,29 +4653,6 @@
}
}
},
- "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-primitive": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
- "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-slot": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
"node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
@@ -4561,29 +4700,6 @@
}
}
},
- "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-primitive": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
- "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-slot": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
"node_modules/@radix-ui/react-tabs": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.12.tgz",
@@ -4614,29 +4730,6 @@
}
}
},
- "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
- "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-slot": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
"node_modules/@radix-ui/react-toast": {
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.14.tgz",
@@ -4671,87 +4764,13 @@
}
}
},
- "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-dismissable-layer": {
- "version": "1.1.10",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz",
- "integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.2",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-callback-ref": "1.1.1",
- "@radix-ui/react-use-escape-keydown": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-portal": {
- "version": "1.1.9",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
- "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-layout-effect": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-primitive": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
- "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-slot": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
"node_modules/@radix-ui/react-toggle": {
- "version": "1.1.10",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz",
- "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==",
+ "version": "1.1.9",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.9.tgz",
+ "integrity": "sha512-ZoFkBBz9zv9GWer7wIjvdRxmh2wyc2oKWw6C6CseWd6/yq1DK/l5lJ+wnsmFwJZbBYqr02mrf8A2q/CVCuM3ZA==",
"license": "MIT",
"dependencies": {
- "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/primitive": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
@@ -4799,83 +4818,6 @@
}
}
},
- "node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-primitive": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
- "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-slot": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-toggle": {
- "version": "1.1.9",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.9.tgz",
- "integrity": "sha512-ZoFkBBz9zv9GWer7wIjvdRxmh2wyc2oKWw6C6CseWd6/yq1DK/l5lJ+wnsmFwJZbBYqr02mrf8A2q/CVCuM3ZA==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.2",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-controllable-state": "1.2.2"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/primitive": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
- "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
- "license": "MIT"
- },
- "node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-primitive": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
- "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-slot": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
"node_modules/@radix-ui/react-tooltip": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.7.tgz",
@@ -4910,80 +4852,6 @@
}
}
},
- "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-dismissable-layer": {
- "version": "1.1.10",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz",
- "integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.2",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-callback-ref": "1.1.1",
- "@radix-ui/react-use-escape-keydown": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-portal": {
- "version": "1.1.9",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
- "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-layout-effect": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-primitive": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
- "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-slot": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
@@ -5161,29 +5029,6 @@
}
}
},
- "node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-primitive": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
- "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-slot": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
"node_modules/@radix-ui/rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
@@ -5210,12 +5055,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/@swc/counter": {
- "version": "0.1.3",
- "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
- "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
- "license": "Apache-2.0"
- },
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -5908,34 +5747,6 @@
"url": "https://github.com/sponsors/ueberdosis"
}
},
- "node_modules/@toast-ui/editor": {
- "version": "3.2.2",
- "resolved": "https://registry.npmjs.org/@toast-ui/editor/-/editor-3.2.2.tgz",
- "integrity": "sha512-ASX7LFjN2ZYQJrwmkUajPs7DRr9FsM1+RQ82CfTO0Y5ZXorBk1VZS4C2Dpxinx9kl55V4F8/A2h2QF4QMDtRbA==",
- "license": "MIT",
- "dependencies": {
- "dompurify": "^2.3.3",
- "prosemirror-commands": "^1.1.9",
- "prosemirror-history": "^1.1.3",
- "prosemirror-inputrules": "^1.1.3",
- "prosemirror-keymap": "^1.1.4",
- "prosemirror-model": "^1.14.1",
- "prosemirror-state": "^1.3.4",
- "prosemirror-view": "^1.18.7"
- }
- },
- "node_modules/@toast-ui/react-editor": {
- "version": "3.2.3",
- "resolved": "https://registry.npmjs.org/@toast-ui/react-editor/-/react-editor-3.2.3.tgz",
- "integrity": "sha512-86QdgiOkBeSwRBEUWRKsTpnm6yu5j9HNJ3EfQN8EGcd7kI8k8AhExXyUJ3NNgNTzN7FfSKMw+1VaCDDC+aZ3dw==",
- "license": "MIT",
- "dependencies": {
- "@toast-ui/editor": "^3.2.2"
- },
- "peerDependencies": {
- "react": "^17.0.1"
- }
- },
"node_modules/@tsconfig/node10": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
@@ -5964,6 +5775,17 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.0",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz",
+ "integrity": "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
@@ -6212,23 +6034,22 @@
"license": "MIT"
},
"node_modules/@types/react": {
- "version": "18.3.27",
- "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
- "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
+ "version": "19.1.9",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.9.tgz",
+ "integrity": "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==",
"license": "MIT",
"dependencies": {
- "@types/prop-types": "*",
- "csstype": "^3.2.2"
+ "csstype": "^3.0.2"
}
},
"node_modules/@types/react-dom": {
- "version": "18.3.7",
- "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
- "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
+ "version": "19.1.7",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.7.tgz",
+ "integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==",
"devOptional": true,
"license": "MIT",
"peerDependencies": {
- "@types/react": "^18.0.0"
+ "@types/react": "^19.0.0"
}
},
"node_modules/@types/react-transition-group": {
@@ -6576,6 +6397,261 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
+ "node_modules/@unrs/resolver-binding-android-arm-eabi": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz",
+ "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-android-arm64": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz",
+ "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-darwin-arm64": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz",
+ "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-darwin-x64": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz",
+ "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-freebsd-x64": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz",
+ "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz",
+ "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz",
+ "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-arm64-gnu": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz",
+ "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-arm64-musl": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz",
+ "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz",
+ "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz",
+ "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-riscv64-musl": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz",
+ "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-s390x-gnu": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz",
+ "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-x64-gnu": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz",
+ "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-x64-musl": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz",
+ "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-wasm32-wasi": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz",
+ "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@napi-rs/wasm-runtime": "^0.2.11"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@unrs/resolver-binding-win32-arm64-msvc": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz",
+ "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-win32-ia32-msvc": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz",
+ "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
"node_modules/@unrs/resolver-binding-win32-x64-msvc": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz",
@@ -6635,37 +6711,6 @@
"bcp47": "^1.1.2"
}
},
- "node_modules/accepts": {
- "version": "1.0.7",
- "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.0.7.tgz",
- "integrity": "sha512-iq8ew2zitUlNcUca0wye3fYwQ6sSPItDo38oC0R+XA5KTzeXRN+GF7NjOXs3dVItj4J+gQVdpq4/qbnMb1hMHw==",
- "license": "MIT",
- "dependencies": {
- "mime-types": "~1.0.0",
- "negotiator": "0.4.7"
- },
- "engines": {
- "node": ">= 0.8.0"
- }
- },
- "node_modules/accepts/node_modules/mime-types": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-1.0.2.tgz",
- "integrity": "sha512-echfutj/t5SoTL4WZpqjA1DCud1XO0WQF3/GJ48YBmc4ZMhCK77QA6Z/w6VTQERLKuJ4drze3kw2TUT8xZXVNw==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.8.0"
- }
- },
- "node_modules/accepts/node_modules/negotiator": {
- "version": "0.4.7",
- "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.4.7.tgz",
- "integrity": "sha512-ujxWwyRfZ6udAgHGECQC3JDO9e6UAsuItfUMcqA0Xf2OLNQTveFVFx+fHGIJ5p0MJaJrZyGQqPwzuN0NxJzEKA==",
- "license": "MIT",
- "engines": {
- "node": "*"
- }
- },
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -6702,11 +6747,6 @@
"node": ">=0.4.0"
}
},
- "node_modules/after": {
- "version": "0.8.1",
- "resolved": "https://registry.npmjs.org/after/-/after-0.8.1.tgz",
- "integrity": "sha512-SuI3vWhCFeSmkmmJ3efyuOkrhGyp/AuHthh3F5DinGYh2kR9t/0xUlm3/Vn2qMScfgg+cKho5fW7TUEYUhYeiA=="
- },
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
@@ -6831,9 +6871,9 @@
}
},
"node_modules/archiver-utils/node_modules/glob": {
- "version": "10.4.5",
- "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
- "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
+ "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
@@ -7114,26 +7154,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/arraybuffer.slice": {
- "version": "0.0.6",
- "resolved": "https://registry.npmjs.org/arraybuffer.slice/-/arraybuffer.slice-0.0.6.tgz",
- "integrity": "sha512-6ZjfQaBSy6CuIH0+B0NrxMfDE5VIOCP/5gOqSpEIsaAZx9/giszzrXg6PZ7G51U/n88UmlAgYLNQ9wAnII7PJA=="
- },
"node_modules/asap": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
"license": "MIT"
},
- "node_modules/assert-plus": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
- "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==",
- "license": "MIT",
- "engines": {
- "node": ">=0.8"
- }
- },
"node_modules/ast-types-flow": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
@@ -7197,14 +7223,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/aws-sign": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/aws-sign/-/aws-sign-0.2.0.tgz",
- "integrity": "sha512-6P7/Ls5F6++DsKu7iacris7qq/AZSWaX+gT4dtSyUxM82ePxWxaP7Slo82ZO3ZTx6GSKxQHAQlmFvM8e+Dd8ZA==",
- "engines": {
- "node": "*"
- }
- },
"node_modules/axe-core": {
"version": "4.10.3",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz",
@@ -7216,9 +7234,9 @@
}
},
"node_modules/axios": {
- "version": "1.11.0",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
- "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
+ "version": "1.13.2",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
+ "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
@@ -7275,14 +7293,6 @@
"integrity": "sha512-b3N5eTW1g7vXkw+0CXh/HazGTcO5KYuu/RCNaJbDMPI6LHDi+7qe8EmxKUVe1sUbY2KZOVZFyj62x0OEz9qyAA==",
"license": "Apache-2.0"
},
- "node_modules/base64-arraybuffer": {
- "version": "0.1.2",
- "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.2.tgz",
- "integrity": "sha512-ewBKKVVPIl78B26mYQHYlaxR7NydMiD/GxwLNIwTAfLIE4xhN2Gxcy30//azq5UrejXjzGpWjcBu3NUJxzMMzg==",
- "engines": {
- "node": ">= 0.6.0"
- }
- },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -7303,14 +7313,6 @@
],
"license": "MIT"
},
- "node_modules/base64id": {
- "version": "0.1.0",
- "resolved": "https://registry.npmjs.org/base64id/-/base64id-0.1.0.tgz",
- "integrity": "sha512-DSjtfjhAsHl9J4OJj7e4+toV2zqxJrGwVd3CLlsCp8QmicvOn7irG0Mb8brOc/nur3SdO8lIbNlY1s1ZDJdUKQ==",
- "engines": {
- "node": ">= 0.4.0"
- }
- },
"node_modules/base64url": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz",
@@ -7320,12 +7322,6 @@
"node": ">=6.0.0"
}
},
- "node_modules/basic-auth": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-1.0.0.tgz",
- "integrity": "sha512-qzxS7/bW/LSiKZzdZw3isPjiVmzXbJLM3ImZZ62WMR3oJQAyqy094Nnb0TA2ZZm65xB7nu0acfTQ99z7wwCDCw==",
- "license": "MIT"
- },
"node_modules/bcp47": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/bcp47/-/bcp47-1.1.2.tgz",
@@ -7344,17 +7340,6 @@
"bcrypt": "bin/bcrypt"
}
},
- "node_modules/better-assert": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/better-assert/-/better-assert-1.0.2.tgz",
- "integrity": "sha512-bYeph2DFlpK1XmGs6fvlLRUN29QISM3GBuUwSFsMY2XRx4AvC0WNCS57j4c/xGrK2RS24C1w3YoBOsw9fT46tQ==",
- "dependencies": {
- "callsite": "1.0.0"
- },
- "engines": {
- "node": "*"
- }
- },
"node_modules/big-integer": {
"version": "1.6.52",
"resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz",
@@ -7389,14 +7374,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/binaryheap": {
- "version": "0.0.3",
- "resolved": "https://registry.npmjs.org/binaryheap/-/binaryheap-0.0.3.tgz",
- "integrity": "sha512-9JFb4Yt5R9FZwbJaxOayF+T5sxn5eiU2NA9/LOeI1g2FUFRTdxpdmWppikO4O5AbNze8s0sL6ZuFxB1y4Ay8GA==",
- "engines": {
- "node": ">= 0.6.0"
- }
- },
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
@@ -7432,56 +7409,12 @@
"ieee754": "^1.1.13"
}
},
- "node_modules/blob": {
- "version": "0.0.2",
- "resolved": "https://registry.npmjs.org/blob/-/blob-0.0.2.tgz",
- "integrity": "sha512-BoCcDt8zBGShn6DawAGQw37s9SSs+fEjiZWDzyB+841PbOogcR2X7LGlM4sR3Zsiq/zoyl8MFWDfN6oDSlveBQ=="
- },
"node_modules/bluebird": {
"version": "3.4.7",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz",
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==",
"license": "MIT"
},
- "node_modules/body-parser": {
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.5.0.tgz",
- "integrity": "sha512-UJfZike68QN1mdo0mA+Z0y+0qi10oxOrCPw2CZpP73O/LIfEWHDy9SHhwsME1mdk1WmnltBLddUkfBpuKPH4Ng==",
- "license": "MIT",
- "dependencies": {
- "bytes": "1.0.0",
- "depd": "0.4.2",
- "iconv-lite": "0.4.4",
- "media-typer": "0.2.0",
- "qs": "0.6.6",
- "raw-body": "1.3.0",
- "type-is": "~1.3.2"
- },
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/boom": {
- "version": "0.3.1",
- "resolved": "https://registry.npmjs.org/boom/-/boom-0.3.1.tgz",
- "integrity": "sha512-xWrlXnkK46TjEW7HU5G39AXWuG5aiHz3++zk3bBzF4mfnVCkpcYbwsnLUqMmfZNgPEYS/AI8MH+vmJxH5Kz0PA==",
- "deprecated": "This version has been deprecated in accordance with the hapi support policy (hapi.im/support). Please upgrade to the latest version to get the best features, bug fixes, and security patches. If you are unable to upgrade at this time, paid support is available for older versions (hapi.im/commercial).",
- "dependencies": {
- "hoek": "0.4.x"
- },
- "engines": {
- "node": ">=0.8.0"
- }
- },
- "node_modules/boom/node_modules/hoek": {
- "version": "0.4.2",
- "resolved": "https://registry.npmjs.org/hoek/-/hoek-0.4.2.tgz",
- "integrity": "sha512-Yj/N2TCrS0d8jvZgUpq9sDNt8/ABwTxPJW4+8QT0KXCMxOtRfUCUTEZEYyvMSgfDT3MGvwgO+NHfWPobagAIug==",
- "deprecated": "This version has been deprecated in accordance with the hapi support policy (hapi.im/support). Please upgrade to the latest version to get the best features, bug fixes, and security patches. If you are unable to upgrade at this time, paid support is available for older versions (hapi.im/commercial).",
- "engines": {
- "node": ">=0.8.0"
- }
- },
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -7559,17 +7492,6 @@
"node": ">=0.10"
}
},
- "node_modules/buffercursor": {
- "version": "0.0.12",
- "resolved": "https://registry.npmjs.org/buffercursor/-/buffercursor-0.0.12.tgz",
- "integrity": "sha512-Z+6Jm/eW6ITeqcFQKVXX7LYIGk7rENqCKHJ4CbWfJMeLpQZJj1v70WehkLmp+1kFN/QyCgpQ3Z0dKUHAwSbf9w==",
- "dependencies": {
- "verror": "^1.4.0"
- },
- "engines": {
- "node": ">= 0.5.0"
- }
- },
"node_modules/buffers": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz",
@@ -7578,22 +7500,6 @@
"node": ">=0.2.0"
}
},
- "node_modules/busboy": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
- "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
- "dependencies": {
- "streamsearch": "^1.1.0"
- },
- "engines": {
- "node": ">=10.16.0"
- }
- },
- "node_modules/bytes": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/bytes/-/bytes-1.0.0.tgz",
- "integrity": "sha512-/x68VkHLeTl3/Ll8IvxdwzhrT+IyKc52e/oyHhA2RwqPqswSnjVbSddfPRwAsJtbilMAPSRWwAlpxdYsSWOTKQ=="
- },
"node_modules/call-bind": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
@@ -7643,14 +7549,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/callsite": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/callsite/-/callsite-1.0.0.tgz",
- "integrity": "sha512-0vdNRFXn5q+dtOqjfFtmtlI9N2eVZ7LMyEV2iKC5mEEFvSg/69Ml6b/WU2qF8W1nLRa0wiSrDT3Y5jOHZCwKPQ==",
- "engines": {
- "node": "*"
- }
- },
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -7877,14 +7775,6 @@
"integrity": "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==",
"license": "MIT"
},
- "node_modules/colors": {
- "version": "0.6.2",
- "resolved": "https://registry.npmjs.org/colors/-/colors-0.6.2.tgz",
- "integrity": "sha512-OsSVtHK8Ir8r3+Fxw/b4jS1ZLPXkV6ZxDRJQzeD7qo0SqMXWrHDM71DgYzPMHY8SFJ0Ao+nNU2p1MmwdzKqPrw==",
- "engines": {
- "node": ">=0.1.90"
- }
- },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -7906,21 +7796,6 @@
"node": ">=14"
}
},
- "node_modules/component-bind": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/component-bind/-/component-bind-1.0.0.tgz",
- "integrity": "sha512-WZveuKPeKAG9qY+FkYDeADzdHyTYdIboXS59ixDeRJL5ZhxpqUnxSOwop4FQjMsiYm3/Or8cegVbpAHNA7pHxw=="
- },
- "node_modules/component-emitter": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.1.2.tgz",
- "integrity": "sha512-YhIbp3PJiznERfjlIkK0ue4obZxt2S60+0W8z24ZymOHT8sHloOqWOqZRU2eN5OlY8U08VFsP02letcu26FilA=="
- },
- "node_modules/component-inherit": {
- "version": "0.0.3",
- "resolved": "https://registry.npmjs.org/component-inherit/-/component-inherit-0.0.3.tgz",
- "integrity": "sha512-w+LhYREhatpVqTESyGFg3NlP6Iu0kEKUHETY9GoZP/pQyW4mHFZuFWRUCIqVPZ36ueVLtoOEZaAqbCF2RDndaA=="
- },
"node_modules/compress-commons": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz",
@@ -7959,42 +7834,6 @@
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"license": "MIT"
},
- "node_modules/connect": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/connect/-/connect-3.0.2.tgz",
- "integrity": "sha512-k3kqw6T2Fc5ihvh5VVjwuJHA++qvh0/rPfe2GkJ5QNSQ9tRigQXMjtX2CYf73KZFe4+IV3HfQ3VR3W+nkt0eWQ==",
- "license": "MIT",
- "dependencies": {
- "debug": "1.0.3",
- "finalhandler": "0.0.2",
- "parseurl": "~1.1.3",
- "utils-merge": "1.0.0"
- },
- "engines": {
- "node": ">= 0.10.0"
- }
- },
- "node_modules/connect/node_modules/debug": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/debug/-/debug-1.0.3.tgz",
- "integrity": "sha512-MltK7Ykj/udtD728gD/RrONStwVnDpBNIP1h+CBcnwnJdHqHxfWHI1E8XLootUl7NOPAYTCCXlb8/Qmy7WyB1w==",
- "dependencies": {
- "ms": "0.6.2"
- }
- },
- "node_modules/connect/node_modules/ms": {
- "version": "0.6.2",
- "resolved": "https://registry.npmjs.org/ms/-/ms-0.6.2.tgz",
- "integrity": "sha512-/pc3eh7TWorTtbvXg8je4GvrvEqCfH7PA3P7iW01yL2E53FKixzgMBaQi0NOPbMJqY34cBSvR0tZtmlTkdUG4A=="
- },
- "node_modules/connect/node_modules/utils-merge": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.0.tgz",
- "integrity": "sha512-HwU9SLQEtyo+0uoKXd1nkLqigUWLB+QuNQR4OcmB73eWqksM5ovuqcycks2x043W8XVb75rG1HQ0h93TMXkzQQ==",
- "engines": {
- "node": ">= 0.4.0"
- }
- },
"node_modules/console-control-strings": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
@@ -8016,19 +7855,6 @@
"node": ">= 0.6"
}
},
- "node_modules/cookie-jar": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/cookie-jar/-/cookie-jar-0.2.0.tgz",
- "integrity": "sha512-yImk9AY90xjoUsN2fWHoIhVgveXqiZv7LDqUTZEzVBHyzfay8AjcJITUZpz2fTYLh6rnP+7GogiuRCo/5j2epg==",
- "engines": {
- "node": "*"
- }
- },
- "node_modules/cookie-signature": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.4.tgz",
- "integrity": "sha512-k+lrG38ZC/S7zN6l1/HcF6xF4jMwkIUjnr5afDU7tzFxIfDmKzdqJdXo8HNYaXOuBJ3tPKxSiwCOTA0b3qQfaA=="
- },
"node_modules/core-js": {
"version": "3.45.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.45.0.tgz",
@@ -8139,18 +7965,6 @@
"node": ">= 8"
}
},
- "node_modules/cryptiles": {
- "version": "0.1.0",
- "resolved": "https://registry.npmjs.org/cryptiles/-/cryptiles-0.1.0.tgz",
- "integrity": "sha512-WiOGszxSaVHd8T4hlu5Xcqs2uUYxbvotBP171ag2pLPKSwSKeTJYnzd98/YWV3jQYk/rpMHa3r01cQfN8SZrHQ==",
- "deprecated": "This version has been deprecated in accordance with the hapi support policy (hapi.im/support). Please upgrade to the latest version to get the best features, bug fixes, and security patches. If you are unable to upgrade at this time, paid support is available for older versions (hapi.im/commercial).",
- "dependencies": {
- "boom": "0.3.x"
- },
- "engines": {
- "node": ">=0.8.0"
- }
- },
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
@@ -8179,9 +7993,9 @@
}
},
"node_modules/csstype": {
- "version": "3.2.3",
- "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
- "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
+ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
},
"node_modules/csv-stringify": {
@@ -8193,14 +8007,6 @@
"lodash.get": "~4.4.2"
}
},
- "node_modules/cycle": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/cycle/-/cycle-1.0.3.tgz",
- "integrity": "sha512-TVF6svNzeQCOpjCqsy0/CSy8VgObG3wXusJ73xW2GbG5rGx7lC8zxDSURicsXI2UsGdi2L0QNRCi745/wUDvsA==",
- "engines": {
- "node": ">=0.4.0"
- }
- },
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
@@ -8438,14 +8244,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/defaultable": {
- "version": "0.7.2",
- "resolved": "https://registry.npmjs.org/defaultable/-/defaultable-0.7.2.tgz",
- "integrity": "sha512-UEaHGfefWfbnANtSlCtuAelo7HZhCbdLAQAttRDVJpQplbA1G21t/J70VGznRA4z9py2k70tTW+3ogGs5VgrcQ==",
- "engines": [
- "node"
- ]
- },
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
@@ -8497,15 +8295,6 @@
"integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
"license": "MIT"
},
- "node_modules/depd": {
- "version": "0.4.2",
- "resolved": "https://registry.npmjs.org/depd/-/depd-0.4.2.tgz",
- "integrity": "sha512-tG4S/hTtpA6stvb9Li65vWHrCblQ/oSN/UI90RKIA3wMk3N9lR1k/dCs8NKKNAy7UXD0+1/dUqhiaBuMatVNAQ==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.8.0"
- }
- },
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@@ -8604,25 +8393,6 @@
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
"license": "MIT"
},
- "node_modules/dns": {
- "version": "0.2.2",
- "resolved": "https://registry.npmjs.org/dns/-/dns-0.2.2.tgz",
- "integrity": "sha512-dhCgBk0QglzySl2BVlIkRuk7aTqxlCe+5KhHEX5ULuco7RcB6d1zDnP5iGSs2rLdJaTc+82MxegtJtjFuueWiQ==",
- "dependencies": {
- "hbo-dnsd": "0.9.8",
- "native-dns": "0.6.1",
- "node-options": "0.0.6",
- "tomahawk": "0.1.6",
- "tomahawk-plugin-kv-memory-store": "0.0.3",
- "winston": "0.7.3"
- },
- "bin": {
- "dns": "bin/dns"
- },
- "engines": {
- "node": ">= 0.10.0 < 0.11.0"
- }
- },
"node_modules/doctrine": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@@ -8695,12 +8465,6 @@
"csstype": "^3.0.2"
}
},
- "node_modules/dompurify": {
- "version": "2.5.8",
- "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.8.tgz",
- "integrity": "sha512-o1vSNgrmYMQObbSSvF/1brBYEQPHhV1+gsmrusO7/GXtp1T9rCS8cXFqVxK/9crT1jA6Ccv+5MTSjBNqr7Sovw==",
- "license": "(MPL-2.0 OR Apache-2.0)"
- },
"node_modules/drizzle-kit": {
"version": "0.30.6",
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.30.6.tgz",
@@ -8944,12 +8708,6 @@
"safe-buffer": "^5.0.1"
}
},
- "node_modules/ee-first": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.0.3.tgz",
- "integrity": "sha512-1q/3kz+ZwmrrWpJcCCrBZ3JnBzB1BMA5EVW9nxnIP1LxDZ16Cqs9VdolqLWlExet1vU+bar3WSkAa4/YrA9bIw==",
- "license": "MIT"
- },
"node_modules/embla-carousel": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
@@ -8978,14 +8736,6 @@
"embla-carousel": "8.6.0"
}
},
- "node_modules/emitter": {
- "version": "1.0.1",
- "resolved": "http://github.com/component/emitter/archive/1.0.1.tar.gz",
- "integrity": "sha512-r/UcFj7JS3lRjv9cgYjgpDNbAsGUdqU64n6ZUOgSF7s1UFBbGu7pUDwKEjHu9NBCy6j2AmmjNW4rijR4De65eA==",
- "dependencies": {
- "indexof": "0.0.1"
- }
- },
"node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
@@ -9001,63 +8751,6 @@
"once": "^1.4.0"
}
},
- "node_modules/engine.io": {
- "version": "1.3.1",
- "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-1.3.1.tgz",
- "integrity": "sha512-fjnHWC9SLPoygMp6pqwoxmNkDDdYme4eCRTBTZLmEtGZETCpUEgSwoQjSgyj7IyIjqninKRF+2VeEV2kOniUFQ==",
- "dependencies": {
- "base64id": "0.1.0",
- "debug": "0.6.0",
- "engine.io-parser": "1.0.6",
- "ws": "0.4.31"
- }
- },
- "node_modules/engine.io-client": {
- "version": "1.3.1",
- "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-1.3.1.tgz",
- "integrity": "sha512-bTOZMqAe7HXhyA/2T7Fve04b/ZZruTHSOqa+yn8U4RFSyRAVPePjopOgJOUNciEfuXo1gx850P5LzaQU28/p3w==",
- "dependencies": {
- "component-emitter": "1.1.2",
- "component-inherit": "0.0.3",
- "debug": "0.7.4",
- "engine.io-parser": "1.0.6",
- "has-cors": "1.0.3",
- "indexof": "0.0.1",
- "parsejson": "0.0.1",
- "parseqs": "0.0.2",
- "parseuri": "0.0.2",
- "ws": "0.4.31",
- "xmlhttprequest": "https://github.com/LearnBoost/node-XMLHttpRequest/archive/0f36d0b5ebc03d85f860d42a64ae9791e1daa433.tar.gz"
- }
- },
- "node_modules/engine.io-client/node_modules/debug": {
- "version": "0.7.4",
- "resolved": "https://registry.npmjs.org/debug/-/debug-0.7.4.tgz",
- "integrity": "sha512-EohAb3+DSHSGx8carOSKJe8G0ayV5/i609OD0J2orCkuyae7SyZSz2aoLmQF2s0Pj5gITDebwPH7GFBlqOUQ1Q==",
- "engines": {
- "node": "*"
- }
- },
- "node_modules/engine.io-parser": {
- "version": "1.0.6",
- "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-1.0.6.tgz",
- "integrity": "sha512-ipbmiNj4OfAL9csof0FlI9L2jkU/lgcUphHjnTDo1KABsA21WtsVy/1OjhCj8xxhNIHtxEZ3/t7uB45gEMhD4g==",
- "dependencies": {
- "after": "0.8.1",
- "arraybuffer.slice": "0.0.6",
- "base64-arraybuffer": "0.1.2",
- "blob": "0.0.2",
- "utf8": "2.0.0"
- }
- },
- "node_modules/engine.io/node_modules/debug": {
- "version": "0.6.0",
- "resolved": "https://registry.npmjs.org/debug/-/debug-0.6.0.tgz",
- "integrity": "sha512-2vIZf67+gMicLu8McscD1NNhMWbiTSJkhlByoTA1Gw54zOb/9IlxylYG+Kr9z1X2wZTHh1AMSp+YiMjYtLkVUA==",
- "engines": {
- "node": "*"
- }
- },
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
@@ -9092,19 +8785,6 @@
"is-arrayish": "^0.2.1"
}
},
- "node_modules/errorhandler": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/errorhandler/-/errorhandler-1.1.1.tgz",
- "integrity": "sha512-nqVAii3wDkiowAVKDmcuwKOQ/5vsg9GfCcJxSMHgy8yiZUA3mMDpBcHnCVolDYgQ7wsC2yZQVOavR5fGHhFMkg==",
- "license": "MIT",
- "dependencies": {
- "accepts": "~1.0.4",
- "escape-html": "1.0.1"
- },
- "engines": {
- "node": ">= 0.8"
- }
- },
"node_modules/es-abstract": {
"version": "1.24.0",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz",
@@ -9339,11 +9019,6 @@
"node": ">=6"
}
},
- "node_modules/escape-html": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.1.tgz",
- "integrity": "sha512-z6kAnok8fqVTra7Yu77dZF2Y6ETJlxH58wN38wNyuNQLm8xXdKnfNrlSmfXsTePWP03rRVUKHubtUwanwUi7+g=="
- },
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@@ -10016,115 +9691,6 @@
"node": ">= 10"
}
},
- "node_modules/express": {
- "version": "4.6.1",
- "resolved": "https://registry.npmjs.org/express/-/express-4.6.1.tgz",
- "integrity": "sha512-nG9Y8xfzgrW/9XCr5sv+KDbtY8mZPN9HO3GziltaubpvleI+1RyHxAKvYjmFih3HkQIaPXW9ozxMHBDNf3UXng==",
- "license": "MIT",
- "dependencies": {
- "accepts": "~1.0.7",
- "buffer-crc32": "0.2.3",
- "cookie": "0.1.2",
- "cookie-signature": "1.0.4",
- "debug": "1.0.3",
- "depd": "0.3.0",
- "escape-html": "1.0.1",
- "finalhandler": "0.0.3",
- "fresh": "0.2.2",
- "media-typer": "0.2.0",
- "merge-descriptors": "0.0.2",
- "methods": "1.1.0",
- "parseurl": "~1.1.3",
- "path-to-regexp": "0.1.3",
- "proxy-addr": "1.0.1",
- "qs": "0.6.6",
- "range-parser": "1.0.0",
- "send": "0.6.0",
- "serve-static": "~1.3.2",
- "type-is": "~1.3.2",
- "utils-merge": "1.0.0",
- "vary": "0.1.0"
- },
- "engines": {
- "node": ">= 0.10.0"
- }
- },
- "node_modules/express/node_modules/buffer-crc32": {
- "version": "0.2.3",
- "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.3.tgz",
- "integrity": "sha512-HLvoSqq1z8fJEcT1lUlJZ4OJaXJZ1wsWm0+fBxkz9Bdf/WphA4Da7FtGUguNNyEXL4WB0hNMTaWmdFRFPy8YOQ==",
- "engines": {
- "node": "*"
- }
- },
- "node_modules/express/node_modules/cookie": {
- "version": "0.1.2",
- "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.1.2.tgz",
- "integrity": "sha512-+mHmWbhevLwkiBf7QcbZXHr0v4ZQQ/OgHk3fsQHrsMMiGzuvAmU/YMUR+ZfrO/BLAGIWFfx2Z7Oyso0tZR/wiA==",
- "engines": {
- "node": "*"
- }
- },
- "node_modules/express/node_modules/debug": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/debug/-/debug-1.0.3.tgz",
- "integrity": "sha512-MltK7Ykj/udtD728gD/RrONStwVnDpBNIP1h+CBcnwnJdHqHxfWHI1E8XLootUl7NOPAYTCCXlb8/Qmy7WyB1w==",
- "dependencies": {
- "ms": "0.6.2"
- }
- },
- "node_modules/express/node_modules/depd": {
- "version": "0.3.0",
- "resolved": "https://registry.npmjs.org/depd/-/depd-0.3.0.tgz",
- "integrity": "sha512-Uyx3FgdvEYlpA3W4lf37Ide++2qOsjLlJ7dap0tbM63j/BxTCcxmyIOO6PXbKbOuNSko+fsDHzzx1DUeo1+3fA==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.8.0"
- }
- },
- "node_modules/express/node_modules/finalhandler": {
- "version": "0.0.3",
- "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-0.0.3.tgz",
- "integrity": "sha512-/fqgssseNfnD8Y77HWyJKQ+1xbKu7bZl2LXfhFjkgeGg91WRMMO9GN1KKL53NnIG9g1H2Xq3iKrZkuIcAmjd0A==",
- "license": "MIT",
- "dependencies": {
- "debug": "1.0.3",
- "escape-html": "1.0.1"
- },
- "engines": {
- "node": ">= 0.8.0"
- }
- },
- "node_modules/express/node_modules/ms": {
- "version": "0.6.2",
- "resolved": "https://registry.npmjs.org/ms/-/ms-0.6.2.tgz",
- "integrity": "sha512-/pc3eh7TWorTtbvXg8je4GvrvEqCfH7PA3P7iW01yL2E53FKixzgMBaQi0NOPbMJqY34cBSvR0tZtmlTkdUG4A=="
- },
- "node_modules/express/node_modules/utils-merge": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.0.tgz",
- "integrity": "sha512-HwU9SLQEtyo+0uoKXd1nkLqigUWLB+QuNQR4OcmB73eWqksM5ovuqcycks2x043W8XVb75rG1HQ0h93TMXkzQQ==",
- "engines": {
- "node": ">= 0.4.0"
- }
- },
- "node_modules/extsprintf": {
- "version": "1.4.1",
- "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz",
- "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==",
- "engines": [
- "node >=0.6.0"
- ],
- "license": "MIT"
- },
- "node_modules/eyes": {
- "version": "0.1.8",
- "resolved": "https://registry.npmjs.org/eyes/-/eyes-0.1.8.tgz",
- "integrity": "sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==",
- "engines": {
- "node": "> 0.1.90"
- }
- },
"node_modules/fast-copy": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.2.tgz",
@@ -10294,32 +9860,6 @@
"node": ">=8"
}
},
- "node_modules/finalhandler": {
- "version": "0.0.2",
- "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-0.0.2.tgz",
- "integrity": "sha512-SbpQfvWVwWEBlPTQyaM9gs0D5404ENTC0x2jzbb7t+P+EOD/cBlWjAAvfozIQYtOepUuNkxoLNLCK9/kS29f4w==",
- "license": "MIT",
- "dependencies": {
- "debug": "1.0.2",
- "escape-html": "1.0.1"
- },
- "engines": {
- "node": ">= 0.8.0"
- }
- },
- "node_modules/finalhandler/node_modules/debug": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/debug/-/debug-1.0.2.tgz",
- "integrity": "sha512-T9bufXIzQvCa4VrTIpLvvwdLhH+wuBtvIJJA3xgzVcaVETGmTIWMfEXQEd1K4p8BaRmQJPn6MPut38H7YQ+iIA==",
- "dependencies": {
- "ms": "0.6.2"
- }
- },
- "node_modules/finalhandler/node_modules/ms": {
- "version": "0.6.2",
- "resolved": "https://registry.npmjs.org/ms/-/ms-0.6.2.tgz",
- "integrity": "sha512-/pc3eh7TWorTtbvXg8je4GvrvEqCfH7PA3P7iW01yL2E53FKixzgMBaQi0NOPbMJqY34cBSvR0tZtmlTkdUG4A=="
- },
"node_modules/find-root": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
@@ -10343,15 +9883,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/finished": {
- "version": "1.2.2",
- "resolved": "https://registry.npmjs.org/finished/-/finished-1.2.2.tgz",
- "integrity": "sha512-HPJ8x7Gn1pmTS1zWyMoXmQ1yxHkYHRoFsBI66ONq4PS9iWBJy1iHYXOSqMWNp3ksMXfrBpenkSwBhl9WG4zr4Q==",
- "license": "MIT",
- "dependencies": {
- "ee-first": "1.0.3"
- }
- },
"node_modules/flat-cache": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
@@ -10437,14 +9968,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
- "node_modules/forever-agent": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.2.0.tgz",
- "integrity": "sha512-IasWSRIlfPnBZY1K9jEUK3PwsScR4mrcK+aNBJzGoPnW+S9b6f8I8ScyH4cehEOFNqnjGpP2gCaA22gqSV1xQA==",
- "engines": {
- "node": "*"
- }
- },
"node_modules/form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
@@ -10478,11 +10001,6 @@
"url": "https://ko-fi.com/tunnckoCore/commissions"
}
},
- "node_modules/fresh": {
- "version": "0.2.2",
- "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.2.2.tgz",
- "integrity": "sha512-ZGGi8GROK//ijm2gB33sUuN9TjN1tC/dvG4Bt4j6IWrVGpMmudUBCxx+Ir7qePsdREfkpQC4FL8W0jeSOsgv1w=="
- },
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
@@ -10519,6 +10037,20 @@
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"license": "ISC"
},
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/fstream": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz",
@@ -10792,12 +10324,6 @@
"node": ">=10.13.0"
}
},
- "node_modules/global": {
- "version": "2.0.1",
- "resolved": "https://github.com/component/global/archive/v2.0.1.tar.gz",
- "integrity": "sha512-O91OcV/NbdmQJPHaRu2ekSP7bqFRLWgqSwaJvqHPZHUwmHBagQYTOra29+LnzzG3lZkXH1ANzHzfCxtAPM9HMA==",
- "license": "MIT"
- },
"node_modules/globals": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
@@ -10896,30 +10422,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/has-binary-data": {
- "version": "0.1.1",
- "resolved": "https://registry.npmjs.org/has-binary-data/-/has-binary-data-0.1.1.tgz",
- "integrity": "sha512-XqIrqIgPlA2gxvHKudDsLJt8Xu8B4DvkHyUWGmLWYOAO0rFOL94Ds4NSveSZ1fCjWX22tQgIiRpDKAETex8GCQ==",
- "license": "ISC",
- "dependencies": {
- "isarray": "0.0.1"
- }
- },
- "node_modules/has-binary-data/node_modules/isarray": {
- "version": "0.0.1",
- "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
- "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==",
- "license": "MIT"
- },
- "node_modules/has-cors": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/has-cors/-/has-cors-1.0.3.tgz",
- "integrity": "sha512-Mxk1ba23PNtB3zPigreijApS3uuH9bhgZkqQtLQj7ydWHsGeb9uOtk4gsK6mZj4rYG6VNS/CT9G1XkYfgItpKg==",
- "license": "MIT",
- "dependencies": {
- "global": "https://github.com/component/global/archive/v2.0.1.tar.gz"
- }
- },
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -11014,48 +10516,12 @@
"node": ">= 0.4"
}
},
- "node_modules/hawk": {
- "version": "0.10.2",
- "resolved": "https://registry.npmjs.org/hawk/-/hawk-0.10.2.tgz",
- "integrity": "sha512-BjpmnZ95odv7KOIsydfNTAxfGOGaVc6xbYL4fozWl45PWjDqskix0LHAekmGkpnrCAI6+AZRvJIXNTAllj+e6w==",
- "deprecated": "This module moved to @hapi/hawk. Please make sure to switch over as this distribution is no longer supported and may contain bugs and critical security issues.",
- "dependencies": {
- "boom": "0.3.x",
- "cryptiles": "0.1.x",
- "hoek": "0.7.x",
- "sntp": "0.1.x"
- },
- "engines": {
- "node": "0.8.x"
- }
- },
- "node_modules/hbo-dnsd": {
- "version": "0.9.8",
- "resolved": "https://registry.npmjs.org/hbo-dnsd/-/hbo-dnsd-0.9.8.tgz",
- "integrity": "sha512-mIj4V7OicuAlnSfvTXopd401Ba7eFFSL2L3EmM1NqlIWe1pJ/x9dyHqfnKXnJr5qSbNFLnadXvwNd3kURNy+ug==",
- "dependencies": {
- "defaultable": "~0.7.2",
- "optimist": "~0.3.4"
- },
- "engines": {
- "node": ">= 0.8"
- }
- },
"node_modules/help-me": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
"license": "MIT"
},
- "node_modules/hoek": {
- "version": "0.7.6",
- "resolved": "https://registry.npmjs.org/hoek/-/hoek-0.7.6.tgz",
- "integrity": "sha512-z75muWk69yyjWn6nNzJP0pnfgcewtSTs7uBolGUA7kWNdCYZukzHn3sYqUirhXul7qp9WBUwNT/7ieJZNveJqg==",
- "deprecated": "This version has been deprecated in accordance with the hapi support policy (hapi.im/support). Please upgrade to the latest version to get the best features, bug fixes, and security patches. If you are unable to upgrade at this time, paid support is available for older versions (hapi.im/commercial).",
- "engines": {
- "node": "0.8.x"
- }
- },
"node_modules/hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
@@ -11169,15 +10635,6 @@
"@babel/runtime": "^7.23.2"
}
},
- "node_modules/iconv-lite": {
- "version": "0.4.4",
- "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.4.tgz",
- "integrity": "sha512-BnjNp13aZpK4WBGbmjaNHN2MCp3P850n8zd/JLinQJ8Lsnq2Br4o2467C2waMsY5kr7Z41SL1gEqh8Vbfzg15A==",
- "license": "MIT",
- "engines": {
- "node": ">=0.8.0"
- }
- },
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -11240,11 +10697,6 @@
"node": ">=0.8.19"
}
},
- "node_modules/indexof": {
- "version": "0.0.1",
- "resolved": "https://registry.npmjs.org/indexof/-/indexof-0.0.1.tgz",
- "integrity": "sha512-i0G7hLJ1z0DE8dsqJa2rycj9dBmNKgXBvotXtZYXakU9oivfB9Uj2ZBC27qqef2U58/ZLwalxa1X/RDCdkHtVg=="
- },
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@@ -11305,15 +10757,6 @@
"node": ">= 0.10"
}
},
- "node_modules/ipaddr.js": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz",
- "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==",
- "license": "MIT",
- "engines": {
- "node": ">= 10"
- }
- },
"node_modules/is-array-buffer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -11885,9 +11328,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
- "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
+ "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -11936,18 +11379,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/json-stringify-safe": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-3.0.0.tgz",
- "integrity": "sha512-VSSuxEAawKLYlCabQOR7YDijQ69zPqQBOriUuCgNhlAqtU7RPr41gPpaSs6WkEu+ZOtUequpXWbI51CS+Z/gMQ==",
- "license": "BSD"
- },
- "node_modules/json3": {
- "version": "3.2.6",
- "resolved": "https://registry.npmjs.org/json3/-/json3-3.2.6.tgz",
- "integrity": "sha512-KA+GHhYTLTo7Ri4DyjwUgW8kn98AYtVZtBC94qL5yD0ZSYct8/eF8qBmTNyk+gPE578bKeIL4WBq+MUyd1I26g==",
- "deprecated": "Please use the native JSON object instead of JSON 3"
- },
"node_modules/json5": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
@@ -12059,12 +11490,12 @@
}
},
"node_modules/jws": {
- "version": "3.2.2",
- "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
- "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz",
+ "integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==",
"license": "MIT",
"dependencies": {
- "jwa": "^1.4.1",
+ "jwa": "^1.4.2",
"safe-buffer": "^5.0.1"
}
},
@@ -12548,21 +11979,6 @@
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
"license": "MIT"
},
- "node_modules/media-typer": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.2.0.tgz",
- "integrity": "sha512-TSggxYk75oP4tae7JkT8InpcFGUP4340zg1dOWjcu9qcphaDKtXEuNUv3OD4vJ+gVTvIDK797W0uYeNm8qqsDg==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.8.0"
- }
- },
- "node_modules/merge-descriptors": {
- "version": "0.0.2",
- "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-0.0.2.tgz",
- "integrity": "sha512-dYBT4Ep+t/qnPeJcnMymmhTdd4g8/hn48ciaDqLAkfRf8abzLPS6Rb6EBdz5CZCL8tzZuI5ps9MhGQGxk+EuKg==",
- "license": "MIT"
- },
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -12572,12 +11988,6 @@
"node": ">= 8"
}
},
- "node_modules/methods": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.0.tgz",
- "integrity": "sha512-Th88HxNePtsAmz0WjEhVVyRGv9AQFLv4z6zOj4Dt15PjsKLWB8JXSmxzP+Q27139+AXao0AlCWvonFuJhu4GuA==",
- "license": "MIT"
- },
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
@@ -12591,11 +12001,6 @@
"node": ">=8.6"
}
},
- "node_modules/mime": {
- "version": "1.2.11",
- "resolved": "https://registry.npmjs.org/mime/-/mime-1.2.11.tgz",
- "integrity": "sha512-Ysa2F/nqTNGHhhm9MV8ure4+Hc+Y8AWiqUdHxsO7xu8zc92ND9f3kpALHjaP026Ft17UfxrMt95c50PLUeynBw=="
- },
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
@@ -12696,21 +12101,6 @@
"node": ">=10"
}
},
- "node_modules/morgan": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.2.0.tgz",
- "integrity": "sha512-VrasIzA69dsxJm1+MVWTLTiij3kiG33XPfGiexqstHpcSvSu/Z51W+FGQyIlbc3jZZuF2PFujsjw+YQvpXz3UA==",
- "license": "MIT",
- "dependencies": {
- "basic-auth": "1.0.0",
- "bytes": "1.0.0",
- "depd": "0.4.2",
- "finished": "~1.2.2"
- },
- "engines": {
- "node": ">= 0.8.0"
- }
- },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -12728,12 +12118,6 @@
"thenify-all": "^1.0.0"
}
},
- "node_modules/nan": {
- "version": "0.3.2",
- "resolved": "https://registry.npmjs.org/nan/-/nan-0.3.2.tgz",
- "integrity": "sha512-V9/Pyy5Oelv6vVJP9X+dAzU3IO19j6YXrJnODHxP2h54hTvfFQGahdsQV6Ule/UukiEJk1SkQ/aUyWUm61RBQw==",
- "license": "MIT"
- },
"node_modules/nanoid": {
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.5.tgz",
@@ -12768,44 +12152,6 @@
"url": "https://opencollective.com/napi-postinstall"
}
},
- "node_modules/native-dns": {
- "version": "0.6.1",
- "resolved": "https://registry.npmjs.org/native-dns/-/native-dns-0.6.1.tgz",
- "integrity": "sha512-svX0dstdoFeEO1sD1Kkrrj/Ad7QfHuczp2YpRnBpjJHqh0dpYLZhLERbf76S6LMkLAT5eZ8tJrPwZciIX5pj6Q==",
- "dependencies": {
- "ipaddr.js": ">= 0.1.1",
- "native-dns-cache": ">= 0.0.1",
- "native-dns-packet": ">= 0.0.4"
- },
- "engines": {
- "node": ">= 0.5.0"
- }
- },
- "node_modules/native-dns-cache": {
- "version": "0.0.2",
- "resolved": "https://registry.npmjs.org/native-dns-cache/-/native-dns-cache-0.0.2.tgz",
- "integrity": "sha512-09HXHdb/updxfigaFbR53F8nCKqxM8WuHfTWBsusVlwSSZZ3qwWRdD6Kx2x8HBI1Q5IaycwcJOvBoXZWJNfVEg==",
- "dependencies": {
- "binaryheap": ">= 0.0.3",
- "native-dns-packet": ">= 0.0.1"
- },
- "engines": {
- "node": ">= 0.5.0"
- }
- },
- "node_modules/native-dns-packet": {
- "version": "0.1.1",
- "resolved": "https://registry.npmjs.org/native-dns-packet/-/native-dns-packet-0.1.1.tgz",
- "integrity": "sha512-j1XxnFFTUB7mujma468WyAOmyVtkuuLTelxJF13tSTIPO56X7bHALrG0G4jFQnvyTPCt4VnFiZezWpfKbaHc+g==",
- "license": "MIT",
- "dependencies": {
- "buffercursor": ">= 0.0.12",
- "ipaddr.js": ">= 0.1.1"
- },
- "engines": {
- "node": ">= 0.5.0"
- }
- },
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -12829,15 +12175,13 @@
"license": "MIT"
},
"node_modules/next": {
- "version": "15.1.0",
- "resolved": "https://registry.npmjs.org/next/-/next-15.1.0.tgz",
- "integrity": "sha512-QKhzt6Y8rgLNlj30izdMbxAwjHMFANnLwDwZ+WQh5sMhyt4lEBqDK9QpvWHtIM4rINKPoJ8aiRZKg5ULSybVHw==",
+ "version": "15.5.7",
+ "resolved": "https://registry.npmjs.org/next/-/next-15.5.7.tgz",
+ "integrity": "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==",
"license": "MIT",
"dependencies": {
- "@next/env": "15.1.0",
- "@swc/counter": "0.1.3",
+ "@next/env": "15.5.7",
"@swc/helpers": "0.5.15",
- "busboy": "1.6.0",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
"styled-jsx": "5.1.6"
@@ -12849,19 +12193,19 @@
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
},
"optionalDependencies": {
- "@next/swc-darwin-arm64": "15.1.0",
- "@next/swc-darwin-x64": "15.1.0",
- "@next/swc-linux-arm64-gnu": "15.1.0",
- "@next/swc-linux-arm64-musl": "15.1.0",
- "@next/swc-linux-x64-gnu": "15.1.0",
- "@next/swc-linux-x64-musl": "15.1.0",
- "@next/swc-win32-arm64-msvc": "15.1.0",
- "@next/swc-win32-x64-msvc": "15.1.0",
- "sharp": "^0.33.5"
+ "@next/swc-darwin-arm64": "15.5.7",
+ "@next/swc-darwin-x64": "15.5.7",
+ "@next/swc-linux-arm64-gnu": "15.5.7",
+ "@next/swc-linux-arm64-musl": "15.5.7",
+ "@next/swc-linux-x64-gnu": "15.5.7",
+ "@next/swc-linux-x64-musl": "15.5.7",
+ "@next/swc-win32-arm64-msvc": "15.5.7",
+ "@next/swc-win32-x64-msvc": "15.5.7",
+ "sharp": "^0.34.3"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
- "@playwright/test": "^1.41.2",
+ "@playwright/test": "^1.51.1",
"babel-plugin-react-compiler": "*",
"react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
"react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
@@ -12883,9 +12227,9 @@
}
},
"node_modules/next-auth": {
- "version": "4.24.11",
- "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.11.tgz",
- "integrity": "sha512-pCFXzIDQX7xmHFs4KVH4luCjaCbuPRtZ9oBUjUhOk84mZ9WVPf94n87TxYI4rSRf9HmfHEF8Yep3JrYDVOo3Cw==",
+ "version": "4.24.13",
+ "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.13.tgz",
+ "integrity": "sha512-sgObCfcfL7BzIK76SS5TnQtc3yo2Oifp/yIpfv6fMfeBOiBJkDWF3A2y9+yqnmJ4JKc2C+nMjSjmgDeTwgN1rQ==",
"license": "ISC",
"dependencies": {
"@babel/runtime": "^7.20.13",
@@ -12899,9 +12243,9 @@
"uuid": "^8.3.2"
},
"peerDependencies": {
- "@auth/core": "0.34.2",
- "next": "^12.2.5 || ^13 || ^14 || ^15",
- "nodemailer": "^6.6.5",
+ "@auth/core": "0.34.3",
+ "next": "^12.2.5 || ^13 || ^14 || ^15 || ^16",
+ "nodemailer": "^7.0.7",
"react": "^17.0.2 || ^18 || ^19",
"react-dom": "^17.0.2 || ^18 || ^19"
},
@@ -12979,25 +12323,6 @@
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
- "node_modules/next/node_modules/@img/sharp-win32-x64": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
- "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
- "cpu": [
- "x64"
- ],
- "license": "Apache-2.0 AND LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
"node_modules/next/node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -13044,46 +12369,6 @@
"node": "^10 || ^12 || >=14"
}
},
- "node_modules/next/node_modules/sharp": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
- "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
- "hasInstallScript": true,
- "license": "Apache-2.0",
- "optional": true,
- "dependencies": {
- "color": "^4.2.3",
- "detect-libc": "^2.0.3",
- "semver": "^7.6.3"
- },
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-darwin-arm64": "0.33.5",
- "@img/sharp-darwin-x64": "0.33.5",
- "@img/sharp-libvips-darwin-arm64": "1.0.4",
- "@img/sharp-libvips-darwin-x64": "1.0.4",
- "@img/sharp-libvips-linux-arm": "1.0.5",
- "@img/sharp-libvips-linux-arm64": "1.0.4",
- "@img/sharp-libvips-linux-s390x": "1.0.4",
- "@img/sharp-libvips-linux-x64": "1.0.4",
- "@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
- "@img/sharp-libvips-linuxmusl-x64": "1.0.4",
- "@img/sharp-linux-arm": "0.33.5",
- "@img/sharp-linux-arm64": "0.33.5",
- "@img/sharp-linux-s390x": "0.33.5",
- "@img/sharp-linux-x64": "0.33.5",
- "@img/sharp-linuxmusl-arm64": "0.33.5",
- "@img/sharp-linuxmusl-x64": "0.33.5",
- "@img/sharp-wasm32": "0.33.5",
- "@img/sharp-win32-ia32": "0.33.5",
- "@img/sharp-win32-x64": "0.33.5"
- }
- },
"node_modules/node-cron": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
@@ -13113,27 +12398,10 @@
}
}
},
- "node_modules/node-options": {
- "version": "0.0.6",
- "resolved": "https://registry.npmjs.org/node-options/-/node-options-0.0.6.tgz",
- "integrity": "sha512-OrfY9+LgcLjoo2oyqxjP3gZLBuNDV1IblF69HGLdbE8JUJxSnl2kB561r41KOMc1GWLspjMSfa9L6+iW4fvYrw==",
- "engines": {
- "node": ">= 0.6.0"
- }
- },
- "node_modules/node-uuid": {
- "version": "1.4.8",
- "resolved": "https://registry.npmjs.org/node-uuid/-/node-uuid-1.4.8.tgz",
- "integrity": "sha512-TkCET/3rr9mUuRp+CpO7qfgT++aAxfDRaalQhwPFzI9BY/2rCDn6OfpZOVggi1AXfTPpfkTrg5f5WQx5G1uLxA==",
- "deprecated": "Use uuid module instead",
- "bin": {
- "uuid": "bin/uuid"
- }
- },
"node_modules/nodemailer": {
- "version": "6.10.1",
- "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
- "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
+ "version": "7.0.11",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.11.tgz",
+ "integrity": "sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
@@ -13215,14 +12483,6 @@
"integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==",
"license": "MIT"
},
- "node_modules/oauth-sign": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.2.0.tgz",
- "integrity": "sha512-4DtiD64CwPJ5vZ636j/KtM7DxWbX1KlkqwbqbEAxI3BCpBrQdrKOv8vC/36U6gfm1CVapy6QmcVxPnXPPQApTA==",
- "engines": {
- "node": "*"
- }
- },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -13232,11 +12492,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/object-component": {
- "version": "0.0.3",
- "resolved": "https://registry.npmjs.org/object-component/-/object-component-0.0.3.tgz",
- "integrity": "sha512-S0sN3agnVh2SZNEIGc0N1X4Z5K0JeFbGBrnuZpsxuUh5XLF0BnvWkMjRXo/zGKLd/eghvNIKcx1pQkmUjXIyrA=="
- },
"node_modules/object-hash": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
@@ -13401,24 +12656,6 @@
"url": "https://github.com/sponsors/panva"
}
},
- "node_modules/optimist": {
- "version": "0.3.7",
- "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.3.7.tgz",
- "integrity": "sha512-TCx0dXQzVtSCg2OgY/bO9hjM9cV4XYx09TVK+s3+FhkjT6LovsLe+pPMzpWf+6yXK/hUizs2gUoTw3jHM0VaTQ==",
- "license": "MIT/X11",
- "dependencies": {
- "wordwrap": "~0.0.2"
- }
- },
- "node_modules/optimist/node_modules/wordwrap": {
- "version": "0.0.3",
- "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz",
- "integrity": "sha512-1tMA907+V4QmxV7dbRvb4/8MaRALK6q9Abid3ndMYnbyo8piisCmeONVqVSXqQA3KaP4SLt5b7ud6E2sqP8TFw==",
- "license": "MIT",
- "engines": {
- "node": ">=0.4.0"
- }
- },
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -13437,14 +12674,6 @@
"node": ">= 0.8.0"
}
},
- "node_modules/options": {
- "version": "0.0.6",
- "resolved": "https://registry.npmjs.org/options/-/options-0.0.6.tgz",
- "integrity": "sha512-bOj3L1ypm++N+n7CEbbe473A414AB7z+amKYshRb//iuL3MpdDCLhPnw6aVTdKB9g5ZRVHIEp8eUln6L2NUStg==",
- "engines": {
- "node": ">=0.4.0"
- }
- },
"node_modules/oracledb": {
"version": "6.9.0",
"resolved": "https://registry.npmjs.org/oracledb/-/oracledb-6.9.0.tgz",
@@ -13553,39 +12782,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/parsejson": {
- "version": "0.0.1",
- "resolved": "https://registry.npmjs.org/parsejson/-/parsejson-0.0.1.tgz",
- "integrity": "sha512-W9CRvTfYQY/kbRc5Q6YTWarb/QDxdEGbd6RCP8CLUQDJV89RVHoS2A0dZYNtAcq31fulGNN4ZhAhiQQazwlKJg==",
- "license": "MIT",
- "dependencies": {
- "better-assert": "~1.0.0"
- }
- },
- "node_modules/parseqs": {
- "version": "0.0.2",
- "resolved": "https://registry.npmjs.org/parseqs/-/parseqs-0.0.2.tgz",
- "integrity": "sha512-vyyyfQGUFZnDhgrrdn+hh1JuOfvbXU5oRr6dijfkSIbaFuxGgTSCA/RNVcsADmo0k2NX6wERVTMKkXokjuObJA==",
- "license": "MIT",
- "dependencies": {
- "better-assert": "~1.0.0"
- }
- },
- "node_modules/parseuri": {
- "version": "0.0.2",
- "resolved": "https://registry.npmjs.org/parseuri/-/parseuri-0.0.2.tgz",
- "integrity": "sha512-m0H+R0u5LXOx8sbxufnvgKrRLpkVpvtMf0AyWXYSqLwo2MWrVEgCIbgpaSVa398xl6wTLe0A7CGhiC4hBdEzHQ==",
- "license": "MIT",
- "dependencies": {
- "better-assert": "~1.0.0"
- }
- },
- "node_modules/parseurl": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.1.3.tgz",
- "integrity": "sha512-7y9IL/9x2suvr1uIvoAc3yv3f28hZ55g2OM+ybEtnZqV6Ykeg36sy1PCsTN9rQUZYzb9lTKLzzmJM11jaXSloA==",
- "license": "MIT"
- },
"node_modules/passport-oauth2": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz",
@@ -13676,11 +12872,6 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
- "node_modules/path-to-regexp": {
- "version": "0.1.3",
- "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.3.tgz",
- "integrity": "sha512-sd4vSOW+DCM6A5aRICI1CWaC7nufnzVpZfuh5T0VXshxxzFWuaFcvqKovAFLNGReOc+uZRptpcpPmn7CDvzLuA=="
- },
"node_modules/path-type": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
@@ -13894,15 +13085,6 @@
"node": ">= 6"
}
},
- "node_modules/pkginfo": {
- "version": "0.3.1",
- "resolved": "https://registry.npmjs.org/pkginfo/-/pkginfo-0.3.1.tgz",
- "integrity": "sha512-yO5feByMzAp96LtP58wvPKSbaKAi/1C4kV9XpTctr6EepnP6F33RBNOiVrdz9BrPA98U2BMFsTNHo44TWcbQ2A==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.4.0"
- }
- },
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -14418,26 +13600,6 @@
"prosemirror-transform": "^1.1.0"
}
},
- "node_modules/proxy-addr": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-1.0.1.tgz",
- "integrity": "sha512-rIUGzBlSfkJMWWCgsd4N5wvVSNAcJZg//UwPZumDIbScHRUzuSOjBmIdyICiKkB9yArv+er9qC6RA/NL3AWc6A==",
- "license": "MIT",
- "dependencies": {
- "ipaddr.js": "0.1.2"
- },
- "engines": {
- "node": ">= 0.8.0"
- }
- },
- "node_modules/proxy-addr/node_modules/ipaddr.js": {
- "version": "0.1.2",
- "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-0.1.2.tgz",
- "integrity": "sha512-MGrEjHz4Hk5UVpJXZQ2tHB+bp6xgdRKCAEWdrgFsoAmXCgKAPtj8LqMxgvlWEAj9aN+PpTcvE051uZU3K3kLSQ==",
- "engines": {
- "node": ">= 0.2.5"
- }
- },
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@@ -14489,14 +13651,6 @@
],
"license": "MIT"
},
- "node_modules/qs": {
- "version": "0.6.6",
- "resolved": "https://registry.npmjs.org/qs/-/qs-0.6.6.tgz",
- "integrity": "sha512-kN+yNdAf29Jgp+AYHUmC7X4QdJPR8czuMWLNLc0aRxkQ7tB3vJQEONKKT9ou/rW7EbqVec11srC9q9BiVbcnHA==",
- "engines": {
- "node": "*"
- }
- },
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -14529,24 +13683,6 @@
"integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==",
"license": "MIT"
},
- "node_modules/range-parser": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.0.0.tgz",
- "integrity": "sha512-wOH5LIH2ZHo0P7/bwkR+aNbJ+kv3CHVX4B8qs9GqbtY29fi1bGPV5xczrutN20G+Z4XhRqRMTW3q0S4iyJJPfw=="
- },
- "node_modules/raw-body": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-1.3.0.tgz",
- "integrity": "sha512-iuI1bOSi9tEmVCrXq02ZysXatTrhAu+fSo7XOQHhMo4g87dSy9YB2W/9Udwhz0bPpFk4UcoLhjrHgpPbRD3ktA==",
- "license": "MIT",
- "dependencies": {
- "bytes": "1",
- "iconv-lite": "0.4.4"
- },
- "engines": {
- "node": ">= 0.8.0"
- }
- },
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -14978,73 +14114,6 @@
"integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==",
"license": "MIT"
},
- "node_modules/request": {
- "version": "2.16.6",
- "resolved": "https://registry.npmjs.org/request/-/request-2.16.6.tgz",
- "integrity": "sha512-TfD4kMo40kwuOpO7GYfAZpb2wYdw7yvTIglPNgPPSmp2Fz6MKNvPLla40FQ/ypdhy6B2jRNz3VlCjPD6mnzsmA==",
- "deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142",
- "engines": [
- "node >= 0.8.0"
- ],
- "dependencies": {
- "aws-sign": "~0.2.0",
- "cookie-jar": "~0.2.0",
- "forever-agent": "~0.2.0",
- "form-data": "~0.0.3",
- "hawk": "~0.10.2",
- "json-stringify-safe": "~3.0.0",
- "mime": "~1.2.7",
- "node-uuid": "~1.4.0",
- "oauth-sign": "~0.2.0",
- "qs": "~0.5.4",
- "tunnel-agent": "~0.2.0"
- }
- },
- "node_modules/request/node_modules/async": {
- "version": "0.2.10",
- "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz",
- "integrity": "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ=="
- },
- "node_modules/request/node_modules/combined-stream": {
- "version": "0.0.7",
- "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-0.0.7.tgz",
- "integrity": "sha512-qfexlmLp9MyrkajQVyjEDb0Vj+KhRgR/rxLiVhaihlT+ZkX0lReqtH6Ack40CvMDERR4b5eFp3CreskpBs1Pig==",
- "dependencies": {
- "delayed-stream": "0.0.5"
- },
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/request/node_modules/delayed-stream": {
- "version": "0.0.5",
- "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-0.0.5.tgz",
- "integrity": "sha512-v+7uBd1pqe5YtgPacIIbZ8HuHeLFVNe4mUEyFDXL6KiqzEykjbw+5mXZXpGFgNVasdL4jWKgaKIXrEHiynN1LA==",
- "engines": {
- "node": ">=0.4.0"
- }
- },
- "node_modules/request/node_modules/form-data": {
- "version": "0.0.10",
- "resolved": "https://registry.npmjs.org/form-data/-/form-data-0.0.10.tgz",
- "integrity": "sha512-Z9/PpT/agxXi80nMpOH6GFD7XOr6mwk5aWMxDt/KMY+Nm7e4FnRMjddM4/mLPJhpmp6alY1F/1JQpRE6z07xng==",
- "dependencies": {
- "async": "~0.2.7",
- "combined-stream": "~0.0.4",
- "mime": "~1.2.2"
- },
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/request/node_modules/qs": {
- "version": "0.5.6",
- "resolved": "https://registry.npmjs.org/qs/-/qs-0.5.6.tgz",
- "integrity": "sha512-KbOrQrP5Ye+0gmq+hwxoJwAFRwExACWqwxj1IDFFgqOw9Poxy3wwSbafd9ZqP6T6ykMfnxM573kt/a4i9ybatQ==",
- "engines": {
- "node": "*"
- }
- },
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
@@ -15284,61 +14353,6 @@
"node": ">=10"
}
},
- "node_modules/send": {
- "version": "0.6.0",
- "resolved": "https://registry.npmjs.org/send/-/send-0.6.0.tgz",
- "integrity": "sha512-A3EwHmDwcPcmLxIRNjr2YbXiYWq6M9JyUq4303pLKVFs4m5oeME0a9Cpcu9N22fED5XVepldjPYGo9eJifb7Yg==",
- "license": "MIT",
- "dependencies": {
- "debug": "1.0.3",
- "depd": "0.3.0",
- "escape-html": "1.0.1",
- "finished": "1.2.2",
- "fresh": "0.2.2",
- "mime": "1.2.11",
- "ms": "0.6.2",
- "range-parser": "~1.0.0"
- },
- "engines": {
- "node": ">= 0.8.0"
- }
- },
- "node_modules/send/node_modules/debug": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/debug/-/debug-1.0.3.tgz",
- "integrity": "sha512-MltK7Ykj/udtD728gD/RrONStwVnDpBNIP1h+CBcnwnJdHqHxfWHI1E8XLootUl7NOPAYTCCXlb8/Qmy7WyB1w==",
- "dependencies": {
- "ms": "0.6.2"
- }
- },
- "node_modules/send/node_modules/depd": {
- "version": "0.3.0",
- "resolved": "https://registry.npmjs.org/depd/-/depd-0.3.0.tgz",
- "integrity": "sha512-Uyx3FgdvEYlpA3W4lf37Ide++2qOsjLlJ7dap0tbM63j/BxTCcxmyIOO6PXbKbOuNSko+fsDHzzx1DUeo1+3fA==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.8.0"
- }
- },
- "node_modules/send/node_modules/ms": {
- "version": "0.6.2",
- "resolved": "https://registry.npmjs.org/ms/-/ms-0.6.2.tgz",
- "integrity": "sha512-/pc3eh7TWorTtbvXg8je4GvrvEqCfH7PA3P7iW01yL2E53FKixzgMBaQi0NOPbMJqY34cBSvR0tZtmlTkdUG4A=="
- },
- "node_modules/serve-static": {
- "version": "1.3.2",
- "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.3.2.tgz",
- "integrity": "sha512-KwjCeYUx7IM1neg8/P0+O1DZsl76XcOSuV0ZxrI0r60vwGlcjMjKOYCK/OFLJy/a2CFuIyAa/x0PuQ0yuG+IgQ==",
- "license": "MIT",
- "dependencies": {
- "escape-html": "1.0.1",
- "parseurl": "~1.1.3",
- "send": "0.6.0"
- },
- "engines": {
- "node": ">= 0.8.0"
- }
- },
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
@@ -15573,27 +14587,6 @@
"integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==",
"license": "MIT"
},
- "node_modules/sntp": {
- "version": "0.1.2",
- "resolved": "https://registry.npmjs.org/sntp/-/sntp-0.1.2.tgz",
- "integrity": "sha512-6fsOpJYQAQcO/UeW7T9mJwEenJymdU77o+gNiompGAammlSa+C49Oyt79ta/kgVbT13l4JAuKlo8FNvUnVjvEQ==",
- "deprecated": "This module moved to @hapi/sntp. Please make sure to switch over as this distribution is no longer supported and may contain bugs and critical security issues.",
- "dependencies": {
- "hoek": "0.4.x"
- },
- "engines": {
- "node": ">=0.8.0"
- }
- },
- "node_modules/sntp/node_modules/hoek": {
- "version": "0.4.2",
- "resolved": "https://registry.npmjs.org/hoek/-/hoek-0.4.2.tgz",
- "integrity": "sha512-Yj/N2TCrS0d8jvZgUpq9sDNt8/ABwTxPJW4+8QT0KXCMxOtRfUCUTEZEYyvMSgfDT3MGvwgO+NHfWPobagAIug==",
- "deprecated": "This version has been deprecated in accordance with the hapi support policy (hapi.im/support). Please upgrade to the latest version to get the best features, bug fixes, and security patches. If you are unable to upgrade at this time, paid support is available for older versions (hapi.im/commercial).",
- "engines": {
- "node": ">=0.8.0"
- }
- },
"node_modules/soap": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/soap/-/soap-1.2.1.tgz",
@@ -15615,112 +14608,6 @@
"node": ">=14.17.0"
}
},
- "node_modules/socket.io": {
- "version": "1.0.6",
- "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-1.0.6.tgz",
- "integrity": "sha512-1x7TkMh8aKfLoXuXe5rXnDnv3xfcOFrDM6hR9z15dpZ83tTxt2NUxnpuGL2zMIAJQ4DitKiadEBvBVju5cxcHw==",
- "dependencies": {
- "debug": "0.7.4",
- "engine.io": "1.3.1",
- "has-binary-data": "0.1.1",
- "socket.io-adapter": "0.2.0",
- "socket.io-client": "1.0.6",
- "socket.io-parser": "2.2.0"
- }
- },
- "node_modules/socket.io-adapter": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-0.2.0.tgz",
- "integrity": "sha512-3PlX+MOlpHiY+ZTbKhpE4i+M4u8hFUlVyqFP4K/mH+t+D9bMKATFqUUY3zWQMEo2g/1ckosURXviQw6M8R/y8A==",
- "dependencies": {
- "debug": "0.7.4",
- "socket.io-parser": "2.1.2"
- }
- },
- "node_modules/socket.io-adapter/node_modules/debug": {
- "version": "0.7.4",
- "resolved": "https://registry.npmjs.org/debug/-/debug-0.7.4.tgz",
- "integrity": "sha512-EohAb3+DSHSGx8carOSKJe8G0ayV5/i609OD0J2orCkuyae7SyZSz2aoLmQF2s0Pj5gITDebwPH7GFBlqOUQ1Q==",
- "engines": {
- "node": "*"
- }
- },
- "node_modules/socket.io-adapter/node_modules/isarray": {
- "version": "0.0.1",
- "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
- "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==",
- "license": "MIT"
- },
- "node_modules/socket.io-adapter/node_modules/socket.io-parser": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-2.1.2.tgz",
- "integrity": "sha512-eVkt8prgw20H+4P8Iw6tis/w7leiN5EW/93Vq+KL8w+yNJu+QNgaej2Cgt8FhVCVuN3AHyLU50vXvM8cpUR1JQ==",
- "dependencies": {
- "debug": "0.7.4",
- "emitter": "http://github.com/component/emitter/archive/1.0.1.tar.gz",
- "isarray": "0.0.1",
- "json3": "3.2.6"
- }
- },
- "node_modules/socket.io-client": {
- "version": "1.0.6",
- "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-1.0.6.tgz",
- "integrity": "sha512-itdtz6fQBTFIDBP4+hJox0OlT+SbCVdENjPgjMup3ehu7OsiG6t0FYBXCx+k/upt9lbeyp9BmUNNi5EfnGa5Vw==",
- "license": "MIT",
- "dependencies": {
- "component-bind": "1.0.0",
- "component-emitter": "1.1.2",
- "debug": "0.7.4",
- "engine.io-client": "1.3.1",
- "has-binary-data": "0.1.1",
- "indexof": "0.0.1",
- "object-component": "0.0.3",
- "parseuri": "0.0.2",
- "socket.io-parser": "2.2.0",
- "to-array": "0.1.3"
- }
- },
- "node_modules/socket.io-client/node_modules/debug": {
- "version": "0.7.4",
- "resolved": "https://registry.npmjs.org/debug/-/debug-0.7.4.tgz",
- "integrity": "sha512-EohAb3+DSHSGx8carOSKJe8G0ayV5/i609OD0J2orCkuyae7SyZSz2aoLmQF2s0Pj5gITDebwPH7GFBlqOUQ1Q==",
- "engines": {
- "node": "*"
- }
- },
- "node_modules/socket.io-parser": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-2.2.0.tgz",
- "integrity": "sha512-uW3UiLVibAyleKq8r/yZe1oPO51olhY18T6HtnN0iI6RLqJfYC0YiyAFlsPw1+8I0Z1qFd8jFLTRZo2vr6ISxA==",
- "dependencies": {
- "debug": "0.7.4",
- "emitter": "http://github.com/component/emitter/archive/1.0.1.tar.gz",
- "isarray": "0.0.1",
- "json3": "3.2.6"
- }
- },
- "node_modules/socket.io-parser/node_modules/debug": {
- "version": "0.7.4",
- "resolved": "https://registry.npmjs.org/debug/-/debug-0.7.4.tgz",
- "integrity": "sha512-EohAb3+DSHSGx8carOSKJe8G0ayV5/i609OD0J2orCkuyae7SyZSz2aoLmQF2s0Pj5gITDebwPH7GFBlqOUQ1Q==",
- "engines": {
- "node": "*"
- }
- },
- "node_modules/socket.io-parser/node_modules/isarray": {
- "version": "0.0.1",
- "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
- "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==",
- "license": "MIT"
- },
- "node_modules/socket.io/node_modules/debug": {
- "version": "0.7.4",
- "resolved": "https://registry.npmjs.org/debug/-/debug-0.7.4.tgz",
- "integrity": "sha512-EohAb3+DSHSGx8carOSKJe8G0ayV5/i609OD0J2orCkuyae7SyZSz2aoLmQF2s0Pj5gITDebwPH7GFBlqOUQ1Q==",
- "engines": {
- "node": "*"
- }
- },
"node_modules/sonic-boom": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz",
@@ -15795,15 +14682,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/stack-trace": {
- "version": "0.0.10",
- "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",
- "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==",
- "license": "MIT",
- "engines": {
- "node": "*"
- }
- },
"node_modules/stop-iteration-iterator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@@ -15818,14 +14696,6 @@
"node": ">= 0.4"
}
},
- "node_modules/streamsearch": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
- "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
- "engines": {
- "node": ">=10.0.0"
- }
- },
"node_modules/streamx": {
"version": "2.23.0",
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz",
@@ -16135,9 +15005,9 @@
}
},
"node_modules/sucrase/node_modules/glob": {
- "version": "10.4.5",
- "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
- "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
+ "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
@@ -16428,14 +15298,6 @@
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
- "node_modules/tinycolor": {
- "version": "0.0.1",
- "resolved": "https://registry.npmjs.org/tinycolor/-/tinycolor-0.0.1.tgz",
- "integrity": "sha512-+CorETse1kl98xg0WAzii8DTT4ABF4R3nquhrkIbVGcw1T8JYs5Gfx9xEfGINPUZGDj9C4BmOtuKeaTtuuRolg==",
- "engines": {
- "node": ">=0.4.0"
- }
- },
"node_modules/tinyglobby": {
"version": "0.2.14",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
@@ -16519,11 +15381,6 @@
"tmp": "^0.2.0"
}
},
- "node_modules/to-array": {
- "version": "0.1.3",
- "resolved": "https://registry.npmjs.org/to-array/-/to-array-0.1.3.tgz",
- "integrity": "sha512-JQk/QMS4oHyU2VufVeyjN25dcnZnr1PV1pa1oKSj7l5tVO9WrU62og3fYzB3mrgJZZgBxdrrA/v6iZzMDuyFYw=="
- },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -16536,32 +15393,6 @@
"node": ">=8.0"
}
},
- "node_modules/tomahawk": {
- "version": "0.1.6",
- "resolved": "https://registry.npmjs.org/tomahawk/-/tomahawk-0.1.6.tgz",
- "integrity": "sha512-HFLoewTx2gHD0o2t0tR+EIcDXhqdtakfZCDiYsGjOO93nYQ1i7nbhj3UL7iQdtoBbPAcEbrxeJ0KlfPOvhxFyg==",
- "dependencies": {
- "body-parser": "1.5.0",
- "connect": "3.0.2",
- "errorhandler": "1.1.1",
- "express": "4.6.1",
- "morgan": "1.2.0",
- "node-options": "0.0.6",
- "socket.io": "1.0.6",
- "winston": "0.7.3"
- },
- "bin": {
- "tomahawk": "bin/tomahawk"
- },
- "engines": {
- "node": ">= 0.8.0 < 0.11.0"
- }
- },
- "node_modules/tomahawk-plugin-kv-memory-store": {
- "version": "0.0.3",
- "resolved": "https://registry.npmjs.org/tomahawk-plugin-kv-memory-store/-/tomahawk-plugin-kv-memory-store-0.0.3.tgz",
- "integrity": "sha512-opt82r6s+775jmrREiWruMVTQaGQYgPd6/zYTDRwwHhDGSqpFaZZgCSnI/BAIs8nC88puTK4PyodkSRpUDp/2Q=="
- },
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
@@ -16686,6 +15517,380 @@
"fsevents": "~2.3.3"
}
},
+ "node_modules/tsx/node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz",
+ "integrity": "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/android-arm": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.8.tgz",
+ "integrity": "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/android-arm64": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz",
+ "integrity": "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/android-x64": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.8.tgz",
+ "integrity": "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz",
+ "integrity": "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz",
+ "integrity": "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz",
+ "integrity": "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz",
+ "integrity": "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/linux-arm": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz",
+ "integrity": "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz",
+ "integrity": "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz",
+ "integrity": "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz",
+ "integrity": "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz",
+ "integrity": "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz",
+ "integrity": "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz",
+ "integrity": "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz",
+ "integrity": "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/linux-x64": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz",
+ "integrity": "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz",
+ "integrity": "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz",
+ "integrity": "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz",
+ "integrity": "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz",
+ "integrity": "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz",
+ "integrity": "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/tsx/node_modules/@esbuild/win32-x64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz",
@@ -16745,14 +15950,6 @@
"@esbuild/win32-x64": "0.25.8"
}
},
- "node_modules/tunnel-agent": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.2.0.tgz",
- "integrity": "sha512-PXy4q1PH88BK0pcGOEMXFAslyBuRWz1wxLfPXTlYFd41eyUgjOALaVGbWJN1ymjbnBzjWunVSKmrrMMh8oLaZA==",
- "engines": {
- "node": "*"
- }
- },
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -16766,28 +15963,6 @@
"node": ">= 0.8.0"
}
},
- "node_modules/type-is": {
- "version": "1.3.2",
- "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.3.2.tgz",
- "integrity": "sha512-sdIhnvhWEyIP2DKjj1o9tL31m8vFxDfLPD56KXz2absqY5AF2QYkJC7Wrw2fkzsZA9mv+PCtgyB7EqYOgR+r3Q==",
- "license": "MIT",
- "dependencies": {
- "media-typer": "0.2.0",
- "mime-types": "~1.0.1"
- },
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/type-is/node_modules/mime-types": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-1.0.2.tgz",
- "integrity": "sha512-echfutj/t5SoTL4WZpqjA1DCud1XO0WQF3/GJ48YBmc4ZMhCK77QA6Z/w6VTQERLKuJ4drze3kw2TUT8xZXVNw==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.8.0"
- }
- },
"node_modules/typed-array-buffer": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
@@ -17150,11 +16325,6 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
- "node_modules/utf8": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/utf8/-/utf8-2.0.0.tgz",
- "integrity": "sha512-jWXHr+bQ8RsWazLzVY3V7XACPTbBHYSg/VoDVok+DBQk5ULm0AuBCNb9tGmjq2H+znnkBFwjhzzCbn9G3xlYcA=="
- },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -17190,15 +16360,6 @@
"devOptional": true,
"license": "MIT"
},
- "node_modules/vary": {
- "version": "0.1.0",
- "resolved": "https://registry.npmjs.org/vary/-/vary-0.1.0.tgz",
- "integrity": "sha512-tyyeG46NQdwyVP/RsWLSrT78ouwEuvwk9gK8vQK4jdXmqoXtTXW+vsCfNcnqRhigF8olV34QVZarmAi6wBV2Mw==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.8.0"
- }
- },
"node_modules/vaul": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
@@ -17212,20 +16373,6 @@
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
}
},
- "node_modules/verror": {
- "version": "1.10.1",
- "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz",
- "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==",
- "license": "MIT",
- "dependencies": {
- "assert-plus": "^1.0.0",
- "core-util-is": "1.0.2",
- "extsprintf": "^1.2.0"
- },
- "engines": {
- "node": ">=0.6.0"
- }
- },
"node_modules/victory-vendor": {
"version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
@@ -17401,28 +16548,6 @@
"string-width": "^1.0.2 || 2 || 3 || 4"
}
},
- "node_modules/winston": {
- "version": "0.7.3",
- "resolved": "https://registry.npmjs.org/winston/-/winston-0.7.3.tgz",
- "integrity": "sha512-iVTT8tf9YnTyfZX+aEUj2fl6WBRet7za6vdjMeyF8SA80Vii2rreM5XH+5qmpBV9uJGj8jz8BozvTDcroVq/eA==",
- "dependencies": {
- "async": "0.2.x",
- "colors": "0.6.x",
- "cycle": "1.0.x",
- "eyes": "0.1.x",
- "pkginfo": "0.3.x",
- "request": "2.16.x",
- "stack-trace": "0.0.x"
- },
- "engines": {
- "node": ">= 0.6.0"
- }
- },
- "node_modules/winston/node_modules/async": {
- "version": "0.2.10",
- "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz",
- "integrity": "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ=="
- },
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -17536,32 +16661,6 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
- "node_modules/ws": {
- "version": "0.4.31",
- "resolved": "https://registry.npmjs.org/ws/-/ws-0.4.31.tgz",
- "integrity": "sha512-mWiVQ9qZGPXvLxQ4xGy58Ix5Bw0L99SB+hDT8L59bty4fbnQczaGl4YEWR7AzLQGbvPn/30r9/o41dPiSuUmYw==",
- "hasInstallScript": true,
- "dependencies": {
- "commander": "~0.6.1",
- "nan": "~0.3.0",
- "options": ">=0.0.5",
- "tinycolor": "0.x"
- },
- "bin": {
- "wscat": "bin/wscat"
- },
- "engines": {
- "node": ">=0.4.0"
- }
- },
- "node_modules/ws/node_modules/commander": {
- "version": "0.6.1",
- "resolved": "https://registry.npmjs.org/commander/-/commander-0.6.1.tgz",
- "integrity": "sha512-0fLycpl1UMTGX257hRsu/arL/cUbcvQM4zMKwvLvzXtfdezIV4yotPS2dYtknF+NmEfWSoCEF6+hj9XLm/6hEw==",
- "engines": {
- "node": ">= 0.4.x"
- }
- },
"node_modules/xhr2": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/xhr2/-/xhr2-0.2.1.tgz",
@@ -17675,14 +16774,6 @@
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"license": "MIT"
},
- "node_modules/xmlhttprequest": {
- "version": "1.5.0",
- "resolved": "https://github.com/LearnBoost/node-XMLHttpRequest/archive/0f36d0b5ebc03d85f860d42a64ae9791e1daa433.tar.gz",
- "integrity": "sha512-TVSZwoeUQ7OKhb8jnQdSxGFz+lm4MGWmhG0deeYg85VQT74x5LcSrKeXHE0ZIzEycgqQ5mF8r8e1AykA7TpNAQ==",
- "engines": {
- "node": ">=0.4.0"
- }
- },
"node_modules/xpath": {
"version": "0.0.34",
"resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.34.tgz",
diff --git a/package.json b/package.json
index 2a55791d..92c49e03 100644
--- a/package.json
+++ b/package.json
@@ -108,8 +108,6 @@
"@tiptap/extension-underline": "^2.23.1",
"@tiptap/react": "^2.23.1",
"@tiptap/starter-kit": "^2.23.1",
- "@toast-ui/editor": "^3.2.2",
- "@toast-ui/react-editor": "^3.2.3",
"@types/docusign-esign": "^5.19.8",
"@types/formidable": "^3.4.5",
"accept-language": "^3.0.20",
@@ -121,7 +119,6 @@
"codemirror": "^6.0.2",
"crypto-js": "^4.2.0",
"date-fns": "^3.6.0",
- "dns": "^0.2.2",
"docusign-esign": "^8.0.1",
"docx": "^9.5.1",
"drizzle-orm": "^0.38.2",
@@ -147,7 +144,7 @@
"libphonenumber-js": "^1.12.10",
"lucide-react": "^0.468.0",
"match-sorter": "^8.2.0",
- "next": "15.1.0",
+ "next": "^15.1.9",
"next-auth": "^4.24.11",
"next-i18n-router": "^5.5.1",
"next-i18next": "^15.4.1",
@@ -194,6 +191,8 @@
"@types/pg": "^8.11.10",
"@types/react": "^19",
"@types/react-dom": "^19",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
"@types/sharp": "^0.31.1",
"@types/ua-parser-js": "^0.7.39",
"@types/uuid": "^10.0.0",
@@ -207,9 +206,6 @@
"typescript": "^5.7.2"
},
"overrides": {
- "rimraf": "3.0.2",
- "@toast-ui/react-editor": {
- "react": "^18.3.1"
- }
+ "rimraf": "3.0.2"
}
}
diff --git a/types/table.d.ts b/types/table.d.ts
index 37f687e5..266470e8 100644
--- a/types/table.d.ts
+++ b/types/table.d.ts
@@ -54,7 +54,7 @@ export type Filter<TData> = Prettify<
export interface DataTableRowAction<TData> {
row: Row<TData>
- type:"add_stage"|"specification_meeting"|"clone"|"viewVariables"|"variableSettings"|"addSubClause"|"createRevision"|"duplicate"|"dispose"|"restore"|"download_report"|"submit" |"general_evaluation"| "general_evaluation"|"esg_evaluation" |"schedule"| "view"| "upload" | "addInfo"| "view-series"|"log"| "tbeResult" | "requestInfo"| "esign-detail"| "responseDetail"|"signature"|"update" | "delete" | "user" | "pemission" | "invite" | "items" | "attachment" |"comments" | "open" | "select" | "files" | "vendor-submission"
+ type:"add_stage"|"specification_meeting"|"clone"|"viewVariables"|"variableSettings"|"addSubClause"|"createRevision"|"duplicate"|"dispose"|"restore"|"download_report"|"submit" |"general_evaluation"| "general_evaluation"|"esg_evaluation" |"schedule"| "view"| "upload" | "addInfo"| "view-series"|"log"| "tbeResult" | "requestInfo"| "esign-detail"| "responseDetail"|"signature"|"update" | "delete" | "user" | "pemission" | "invite" | "items" | "attachment" |"comments" | "open" | "select" | "files" | "vendor-submission" | "resend"
}
export interface QueryBuilderOpts {