From ef4c533ebacc2cdc97e518f30e9a9350004fcdfb Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 28 Apr 2025 02:13:30 +0000 Subject: ~20250428 작업사항 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.development | 2 + .env.production | 1 + .gitignore | 2 +- .../evcp/(evcp)/basic-contract-template/page.tsx | 74 + app/[lng]/evcp/(evcp)/basic-contract/page.tsx | 74 + app/[lng]/evcp/(evcp)/bid-projects/page.tsx | 74 + app/[lng]/evcp/(evcp)/bqcbe/page.tsx | 74 + app/[lng]/evcp/(evcp)/bqtbe/page.tsx | 2 +- .../evcp/(evcp)/budgetary-rfq/[id]/cbe/page.tsx | 2 +- .../evcp/(evcp)/budgetary-rfq/[id]/layout.tsx | 30 +- app/[lng]/evcp/(evcp)/budgetary-rfq/[id]/page.tsx | 2 +- .../evcp/(evcp)/budgetary-rfq/[id]/tbe/page.tsx | 2 +- app/[lng]/evcp/(evcp)/budgetary/[id]/cbe/page.tsx | 2 +- app/[lng]/evcp/(evcp)/budgetary/[id]/layout.tsx | 42 +- app/[lng]/evcp/(evcp)/budgetary/[id]/page.tsx | 2 +- app/[lng]/evcp/(evcp)/budgetary/[id]/tbe/page.tsx | 2 +- app/[lng]/evcp/(evcp)/dashboard/page.tsx | 17 + app/[lng]/evcp/(evcp)/equip-class/page.tsx | 4 +- app/[lng]/evcp/(evcp)/form-list/page.tsx | 4 +- app/[lng]/evcp/(evcp)/po/page.tsx | 2 +- app/[lng]/evcp/(evcp)/pq-criteria/[id]/page.tsx | 2 +- app/[lng]/evcp/(evcp)/pq-criteria/page.tsx | 2 +- app/[lng]/evcp/(evcp)/pq/[vendorId]/page.tsx | 5 +- app/[lng]/evcp/(evcp)/project-vendors/page.tsx | 74 + app/[lng]/evcp/(evcp)/report/page.tsx | 53 +- app/[lng]/evcp/(evcp)/rfq/[id]/cbe/page.tsx | 26 +- app/[lng]/evcp/(evcp)/rfq/[id]/layout.tsx | 33 +- app/[lng]/evcp/(evcp)/rfq/[id]/page.tsx | 2 +- app/[lng]/evcp/(evcp)/rfq/[id]/tbe/page.tsx | 2 +- app/[lng]/evcp/(evcp)/tag-numbering/page.tsx | 2 +- app/[lng]/evcp/(evcp)/tasks/page.tsx | 4 +- app/[lng]/evcp/(evcp)/tbe/page.tsx | 4 +- app/[lng]/evcp/(evcp)/vendor-candidates/page.tsx | 30 +- app/[lng]/evcp/(evcp)/vendor-type/page.tsx | 70 + app/[lng]/evcp/(evcp)/vendors/[id]/info/layout.tsx | 16 +- .../(evcp)/vendors/[id]/info/rfq-history/page.tsx | 2 +- app/[lng]/evcp/(evcp)/vendors/page.tsx | 6 +- .../partners/(partners)/basic-contract/page.tsx | 77 + app/[lng]/partners/(partners)/cbe/page.tsx | 86 + app/[lng]/partners/(partners)/dashboard/page.tsx | 53 +- .../partners/(partners)/document-list/layout.tsx | 8 +- app/[lng]/partners/(partners)/documents/layout.tsx | 8 +- app/[lng]/partners/(partners)/report/page.tsx | 17 + .../vendor-data/form/[packageId]/[formId]/page.tsx | 41 +- .../partners/(partners)/vendor-data/layout.tsx | 8 +- app/[lng]/partners/(partners)/vendor-data/page.tsx | 2 +- app/[lng]/partners/pq/page.tsx | 26 +- .../(ECC)/IF_ECC_EVCP_BIDDING_PROJECT/route.ts | 70 + app/api/auth/[...nextauth]/route.ts | 44 +- app/api/basic-contract/status/route.ts | 141 + app/api/cron/form-tags/start/route.ts | 136 + app/api/cron/form-tags/status/route.ts | 46 + app/api/cron/forms/route.ts | 57 +- app/api/cron/forms/start/route.ts | 100 + app/api/cron/forms/status/route.ts | 46 + app/api/cron/object-classes/route.ts | 4 +- app/api/cron/projects/route.ts | 5 +- app/api/cron/tag-types/route.ts | 2 + app/api/cron/tags/start/route.ts | 133 + app/api/cron/tags/status/route.ts | 46 + app/api/upload/basicContract/chunk/route.ts | 71 + app/api/upload/basicContract/complete/route.ts | 37 + app/api/upload/signed-contract/route.ts | 57 + app/api/vendors/attachments/download-all/route.ts | 108 + app/api/vendors/attachments/download/route.ts | 93 + app/api/vendors/erp/route.ts | 4 +- app/globals.css | 132 +- components/BidProjectSelector.tsx | 124 + components/additional-info/join-form.tsx | 130 +- .../client-data-table/data-table-toolbar.tsx | 25 - components/client-data-table/data-table.tsx | 2 +- .../data-table/data-table-advanced-toolbar.tsx | 100 +- .../data-table/data-table-compact-toggle.tsx | 35 + components/data-table/data-table-filter-list.tsx | 2 +- components/data-table/data-table-group-list.tsx | 2 +- components/data-table/data-table-pin-left.tsx | 243 +- components/data-table/data-table-pin-right.tsx | 150 +- components/data-table/data-table-sort-list.tsx | 2 +- components/data-table/data-table-view-options.tsx | 44 +- components/data-table/data-table.tsx | 75 +- components/date-range-picker.tsx | 39 +- components/form-data/add-formTag-dialog.tsx | 957 +++ components/form-data/export-excel-form.tsx | 197 + .../form-data/form-data-report-batch-dialog.tsx | 8 +- components/form-data/form-data-report-dialog.tsx | 153 +- .../form-data-report-temp-upload-dialog.tsx | 45 +- .../form-data/form-data-report-temp-upload-tab.tsx | 2 +- .../form-data-report-temp-uploaded-list-tab.tsx | 2 +- components/form-data/form-data-table copy.tsx | 539 ++ components/form-data/form-data-table-columns.tsx | 19 +- components/form-data/form-data-table.tsx | 948 +-- components/form-data/import-excel-form.tsx | 323 + components/form-data/publish-dialog.tsx | 470 ++ components/form-data/sedp-compare-dialog.tsx | 372 ++ components/form-data/sedp-components.tsx | 173 + components/form-data/sedp-excel-download.tsx | 163 + components/form-data/temp-download-btn.tsx | 11 +- components/form-data/update-form-sheet.tsx | 140 +- components/form-data/var-list-download-btn.tsx | 18 +- components/layout/Header.tsx | 8 +- components/login/login-form copy 2.tsx | 470 ++ components/login/login-form copy.tsx | 468 ++ components/login/login-form-shi.tsx | 34 +- components/login/login-form.tsx | 67 +- components/login/partner-auth-form.tsx | 128 +- components/pq/client-pq-input-wrapper.tsx | 24 +- components/pq/pq-review-detail.tsx | 9 +- components/pq/pq-review-table.tsx | 6 +- components/signup/join-form.tsx | 323 +- components/vendor-data/sidebar.tsx | 121 +- components/vendor-data/vendor-data-container.tsx | 265 +- config/VendorTypesColumnsConfig.ts | 37 + config/basicContractColumnsConfig.ts | 211 + config/bidProjectsColumnsConfig.ts | 122 + config/candidatesColumnsConfig.ts | 122 +- config/faqDataConfig.ts | 32 +- config/formListsColumnsConfig.ts | 12 + config/itemsColumnsConfig.ts | 12 +- config/menuConfig.ts | 79 +- config/projectAVLColumnsConfig.ts | 78 + config/vendorCbeColumnsConfig.ts | 370 +- config/vendorColumnsConfig.ts | 63 +- config/vendorInvestigationsColumnsConfig.ts | 1 + config/vendorItemsColumnsConfig.ts | 12 +- config/vendorRfbColumnsConfig.ts | 10 +- config/vendorTbeColumnsConfig.ts | 49 +- db/migrations/0003_old_misty_knight.sql | 18 + db/migrations/0004_common_warlock.sql | 19 + db/migrations/0005_jittery_may_parker.sql | 23 + db/migrations/0006_chemical_gunslinger.sql | 2 + db/migrations/0007_cuddly_stardust.sql | 3 + db/migrations/0008_stiff_exodus.sql | 6 + db/migrations/0009_wet_joseph.sql | 12 + db/migrations/0010_numerous_ghost_rider.sql | 1 + db/migrations/0011_big_epoch.sql | 1 + db/migrations/0012_cynical_the_twelve.sql | 2 + db/migrations/0013_wakeful_speed_demon.sql | 2 + db/migrations/0014_good_radioactive_man.sql | 2 + db/migrations/0015_wide_mindworm.sql | 2 + db/migrations/0016_charming_mac_gargan.sql | 1 + db/migrations/0017_modern_donald_blake.sql | 33 + db/migrations/0018_broken_clint_barton.sql | 13 + db/migrations/0019_keen_ser_duncan.sql | 1 + db/migrations/0020_lyrical_butterfly.sql | 1 + db/migrations/0021_slimy_bastion.sql | 2 + db/migrations/0022_groovy_vulcan.sql | 2 + db/migrations/0023_clean_clea.sql | 2 + db/migrations/0024_legal_white_tiger.sql | 37 + db/migrations/0025_public_squirrel_girl.sql | 2 + db/migrations/0026_slimy_maddog.sql | 7 + db/migrations/0027_stiff_shadowcat.sql | 13 + db/migrations/0028_bitter_toxin.sql | 45 + db/migrations/0029_sad_jubilee.sql | 5 + db/migrations/0030_warm_wildside.sql | 2 + db/migrations/0031_sour_nighthawk.sql | 2 + db/migrations/0032_fine_lyja.sql | 4 + db/migrations/0033_silly_skrulls.sql | 3 + db/migrations/0034_dashing_corsair.sql | 2 + db/migrations/0035_first_leopardon.sql | 19 + db/migrations/0036_rare_lady_ursula.sql | 2 + db/migrations/0037_pink_black_bolt.sql | 2 + db/migrations/0038_mighty_rictor.sql | 3 + db/migrations/0039_medical_kree.sql | 1 + db/migrations/0040_next_gunslinger.sql | 2 + db/migrations/0041_dazzling_wilson_fisk.sql | 1 + db/migrations/meta/0003_snapshot.json | 5872 ++++++++++++++++ db/migrations/meta/0004_snapshot.json | 5872 ++++++++++++++++ db/migrations/meta/0005_snapshot.json | 5886 ++++++++++++++++ db/migrations/meta/0006_snapshot.json | 5886 ++++++++++++++++ db/migrations/meta/0007_snapshot.json | 5886 ++++++++++++++++ db/migrations/meta/0008_snapshot.json | 5930 ++++++++++++++++ db/migrations/meta/0009_snapshot.json | 6009 +++++++++++++++++ db/migrations/meta/0010_snapshot.json | 6009 +++++++++++++++++ db/migrations/meta/0011_snapshot.json | 6165 +++++++++++++++++ db/migrations/meta/0012_snapshot.json | 6165 +++++++++++++++++ db/migrations/meta/0013_snapshot.json | 6117 +++++++++++++++++ db/migrations/meta/0014_snapshot.json | 6117 +++++++++++++++++ db/migrations/meta/0015_snapshot.json | 6117 +++++++++++++++++ db/migrations/meta/0016_snapshot.json | 6009 +++++++++++++++++ db/migrations/meta/0017_snapshot.json | 6111 +++++++++++++++++ db/migrations/meta/0018_snapshot.json | 6200 +++++++++++++++++ db/migrations/meta/0019_snapshot.json | 6206 +++++++++++++++++ db/migrations/meta/0020_snapshot.json | 6301 +++++++++++++++++ db/migrations/meta/0021_snapshot.json | 6301 +++++++++++++++++ db/migrations/meta/0022_snapshot.json | 6301 +++++++++++++++++ db/migrations/meta/0023_snapshot.json | 6301 +++++++++++++++++ db/migrations/meta/0024_snapshot.json | 6546 ++++++++++++++++++ db/migrations/meta/0025_snapshot.json | 6546 ++++++++++++++++++ db/migrations/meta/0026_snapshot.json | 6708 +++++++++++++++++++ db/migrations/meta/0027_snapshot.json | 6797 +++++++++++++++++++ db/migrations/meta/0028_snapshot.json | 7017 +++++++++++++++++++ db/migrations/meta/0029_snapshot.json | 7011 +++++++++++++++++++ db/migrations/meta/0030_snapshot.json | 7011 +++++++++++++++++++ db/migrations/meta/0031_snapshot.json | 7011 +++++++++++++++++++ db/migrations/meta/0032_snapshot.json | 7023 +++++++++++++++++++ db/migrations/meta/0033_snapshot.json | 7015 +++++++++++++++++++ db/migrations/meta/0034_snapshot.json | 7034 +++++++++++++++++++ db/migrations/meta/0035_snapshot.json | 7023 +++++++++++++++++++ db/migrations/meta/0036_snapshot.json | 7023 +++++++++++++++++++ db/migrations/meta/0037_snapshot.json | 7017 +++++++++++++++++++ db/migrations/meta/0038_snapshot.json | 7032 +++++++++++++++++++ db/migrations/meta/0039_snapshot.json | 7038 +++++++++++++++++++ db/migrations/meta/0040_snapshot.json | 7052 ++++++++++++++++++++ db/migrations/meta/0041_snapshot.json | 7046 +++++++++++++++++++ db/migrations/meta/_journal.json | 273 + db/schema/basicContractDocumnet.ts | 75 + db/schema/contract.ts | 5 +- db/schema/index.ts | 2 + db/schema/logs.ts | 61 + db/schema/pq.ts | 46 +- db/schema/projects.ts | 54 +- db/schema/rfq.ts | 253 +- db/schema/vendorData.ts | 52 +- db/schema/vendors.ts | 181 +- db/seeds/contract.ts | 2 +- db/seeds/create-contract-cli.ts | 260 + db/seeds/rfqSeed.ts | 6 +- db/seeds/vendorSeed.ts | 4 +- i18n/locales/en/login.json | 17 +- i18n/locales/en/translation.json | 2 +- i18n/locales/ko/login.json | 13 +- i18n/locales/ko/translation.json | 2 +- lib/admin-users/service.ts | 7 +- lib/basic-contract/repository.ts | 167 + lib/basic-contract/service.ts | 957 +++ .../status/basic-contract-columns.tsx | 213 + lib/basic-contract/status/basic-contract-table.tsx | 95 + .../status/basicContract-table-toolbar-actions.tsx | 40 + .../add-basic-contract-template-dialog.tsx | 359 + .../template/basic-contract-template-columns.tsx | 245 + .../template/basic-contract-template.tsx | 104 + .../basicContract-table-toolbar-actions.tsx | 53 + .../template/delete-basicContract-dialog.tsx | 149 + .../template/update-basicContract-sheet.tsx | 300 + lib/basic-contract/validations.ts | 87 + .../vendor-table/basic-contract-columns.tsx | 214 + .../vendor-table/basic-contract-sign-dialog.tsx | 318 + .../vendor-table/basic-contract-table.tsx | 94 + .../basicContract-table-toolbar-actions.tsx | 56 + .../viewer/basic-contract-sign-viewer.tsx | 224 + lib/bidding-projects/repository.ts | 44 + lib/bidding-projects/service.ts | 117 + .../table/project-series-dialog.tsx | 133 + .../table/projects-table-columns.tsx | 102 + .../table/projects-table-toolbar-actions.tsx | 89 + lib/bidding-projects/table/projects-table.tsx | 156 + lib/bidding-projects/validation.ts | 32 + lib/cbe/table/cbe-table-columns.tsx | 241 + lib/cbe/table/cbe-table-toolbar-actions.tsx | 72 + lib/cbe/table/cbe-table.tsx | 192 + lib/cbe/table/comments-sheet.tsx | 345 + lib/cbe/table/invite-vendors-dialog.tsx | 428 ++ lib/equip-class/service.ts | 1 - lib/form-list/repository.ts | 2 + lib/form-list/service.ts | 2 + .../table/formLists-table-toolbar-actions.tsx | 129 +- lib/form-list/table/formLists-table.tsx | 7 +- lib/form-list/table/meta-sheet.tsx | 85 +- lib/forms/services.ts | 637 +- lib/items/service.ts | 84 +- lib/items/table/import-excel-button.tsx | 266 + lib/items/table/import-item-handler.tsx | 118 + lib/items/table/item-excel-template.tsx | 94 + lib/items/table/items-table-columns.tsx | 3 - lib/items/table/items-table-toolbar-actions.tsx | 155 +- lib/mail/layouts/base.hbs | 22 + lib/mail/mailer.ts | 39 +- lib/mail/partials/footer.hbs | 8 + lib/mail/partials/header.hbs | 7 + lib/mail/templates/admin-created.hbs | 91 +- lib/mail/templates/admin-email-changed.hbs | 108 +- lib/mail/templates/cbe-invitation.hbs | 108 + lib/mail/templates/contract-sign-request.hbs | 116 + lib/mail/templates/investigation-request.hbs | 31 + lib/mail/templates/otp.hbs | 92 +- lib/mail/templates/pq-submitted-admin.hbs | 84 + lib/mail/templates/pq-submitted-vendor.hbs | 93 + lib/mail/templates/pq.hbs | 86 + lib/mail/templates/project-pq.hbs | 99 + lib/mail/templates/rfq-invite.hbs | 145 +- lib/mail/templates/vendor-active.hbs | 76 +- lib/mail/templates/vendor-additional-info.hbs | 89 +- lib/mail/templates/vendor-invitation.hbs | 104 +- lib/mail/templates/vendor-pq-comment.hbs | 165 +- lib/mail/templates/vendor-pq-status.hbs | 69 +- lib/mail/templates/vendor-project-pq-status.hbs | 42 + lib/poa/table/poa-table.tsx | 2 +- lib/poa/validations.ts | 2 +- lib/pq/service.ts | 77 +- lib/pq/table/import-pq-handler.tsx | 11 +- lib/project-avl/repository.ts | 49 + lib/project-avl/service.ts | 106 + lib/project-avl/table/proejctAVL-table.tsx | 159 + lib/project-avl/table/projectAVL-table-columns.tsx | 104 + lib/project-avl/validations.ts | 41 + lib/rfqs/cbe-table/cbe-table-columns.tsx | 92 +- lib/rfqs/cbe-table/cbe-table-toolbar-actions.tsx | 67 + lib/rfqs/cbe-table/cbe-table.tsx | 123 +- lib/rfqs/cbe-table/comments-sheet.tsx | 328 + lib/rfqs/cbe-table/feature-flags-provider.tsx | 108 - lib/rfqs/cbe-table/invite-vendors-dialog.tsx | 423 ++ lib/rfqs/cbe-table/vendor-contact-dialog.tsx | 71 + lib/rfqs/repository.ts | 10 +- lib/rfqs/service.ts | 1596 ++++- lib/rfqs/table/add-rfq-dialog.tsx | 72 +- lib/rfqs/table/rfqs-table.tsx | 2 +- lib/rfqs/tbe-table/comments-sheet.tsx | 145 +- lib/rfqs/tbe-table/invite-vendors-dialog.tsx | 39 +- lib/rfqs/tbe-table/tbe-result-dialog.tsx | 208 + lib/rfqs/tbe-table/tbe-table-columns.tsx | 99 +- lib/rfqs/tbe-table/tbe-table-toolbar-actions.tsx | 23 +- lib/rfqs/tbe-table/tbe-table.tsx | 66 +- lib/rfqs/tbe-table/vendor-contact-dialog.tsx | 71 + .../vendor-contact/vendor-contact-table-column.tsx | 70 + .../vendor-contact/vendor-contact-table.tsx | 89 + lib/rfqs/validations.ts | 75 +- lib/rfqs/vendor-table/comments-sheet.tsx | 10 +- .../vendor-table/vendor-list/vendor-list-table.tsx | 2 +- .../vendor-table/vendors-table-toolbar-actions.tsx | 12 +- lib/rfqs/vendor-table/vendors-table.tsx | 16 +- lib/sedp/get-form-tags.ts | 380 ++ lib/sedp/get-tags.ts | 263 + lib/sedp/sync-form.ts | 756 ++- lib/sedp/sync-object-class.ts | 257 +- lib/sedp/sync-projects.ts | 7 +- lib/sedp/sync-tag-types.ts | 88 +- lib/tag-numbering/service.ts | 4 +- lib/tag-numbering/table/meta-sheet.tsx | 2 +- .../table/tagNumbering-table-toolbar-actions.tsx | 2 +- lib/tag-numbering/table/tagNumbering-table.tsx | 10 + lib/tags/service.ts | 337 +- lib/tags/table/add-tag-dialog.tsx | 32 +- lib/tags/table/tag-table.tsx | 18 +- lib/tags/table/tags-export.tsx | 5 +- lib/tags/table/tags-table-toolbar-actions.tsx | 178 +- lib/tags/table/update-tag-sheet.tsx | 3 +- lib/tasks/service.ts | 1 - lib/tbe/table/comments-sheet.tsx | 15 +- lib/tbe/table/invite-vendors-dialog.tsx | 8 +- lib/tbe/table/tbe-result-dialog.tsx | 208 + lib/tbe/table/tbe-table-columns.tsx | 103 +- lib/tbe/table/tbe-table-toolbar-actions.tsx | 28 +- lib/tbe/table/tbe-table.tsx | 81 +- lib/tbe/table/vendor-contact-dialog.tsx | 71 + lib/users/repository.ts | 16 +- lib/users/send-otp.ts | 75 +- lib/users/service.ts | 26 + lib/users/verifyOtp.ts | 23 +- lib/vendor-candidates/service.ts | 421 +- .../table/add-candidates-dialog.tsx | 171 +- .../table/candidates-table-columns.tsx | 104 +- .../table/candidates-table-floating-bar.tsx | 416 +- .../table/candidates-table-toolbar-actions.tsx | 4 +- lib/vendor-candidates/table/candidates-table.tsx | 19 +- .../table/delete-candidates-dialog.tsx | 16 +- .../table/excel-template-download.tsx | 44 +- lib/vendor-candidates/table/import-button.tsx | 78 +- .../table/invite-candidates-dialog.tsx | 112 +- .../table/update-candidate-sheet.tsx | 112 +- .../table/view-candidate_logs-dialog.tsx | 246 + lib/vendor-candidates/validations.ts | 50 +- lib/vendor-document/service.ts | 106 + lib/vendor-investigation/service.ts | 85 +- lib/vendor-investigation/table/contract-dialog.tsx | 85 + .../table/investigation-table.tsx | 56 +- lib/vendor-investigation/table/items-dialog.tsx | 73 + lib/vendor-rfq-response/service.ts | 181 +- lib/vendor-rfq-response/types.ts | 2 +- .../vendor-cbe-table/cbe-table-columns.tsx | 365 + .../vendor-cbe-table/cbe-table.tsx | 272 + .../vendor-cbe-table/comments-sheet.tsx | 323 + .../vendor-cbe-table/respond-cbe-sheet.tsx | 427 ++ .../vendor-cbe-table/rfq-detail-dialog.tsx | 89 + .../rfq-items-table/rfq-items-table-column.tsx | 62 + .../rfq-items-table/rfq-items-table.tsx | 86 + .../vendor-tbe-table/comments-sheet.tsx | 14 +- .../vendor-tbe-table/feature-flags-provider.tsx | 108 - .../vendor-tbe-table/rfq-detail-dialog.tsx | 86 + .../vendor-tbe-table/tbe-table-columns.tsx | 65 +- .../vendor-tbe-table/tbe-table.tsx | 72 +- .../vendor-tbe-table/tbeFileHandler.tsx | 4 +- lib/vendor-type/repository.ts | 130 + lib/vendor-type/service.ts | 239 + lib/vendor-type/table/add-vendorTypes-dialog.tsx | 158 + .../table/delete-vendorTypes-dialog.tsx | 149 + lib/vendor-type/table/feature-flags-provider.tsx | 108 + lib/vendor-type/table/feature-flags.tsx | 96 + lib/vendor-type/table/import-excel-button.tsx | 265 + .../table/import-vendorTypes-handler.tsx | 114 + lib/vendor-type/table/update-vendorTypes-sheet.tsx | 151 + .../table/vendorTypes-excel-template.tsx | 78 + .../table/vendorTypes-table-columns.tsx | 179 + .../table/vendorTypes-table-toolbar-actions.tsx | 162 + lib/vendor-type/table/vendorTypes-table.tsx | 129 + lib/vendor-type/validations.ts | 46 + lib/vendors/repository.ts | 34 +- lib/vendors/service.ts | 731 +- lib/vendors/table/approve-vendor-dialog.tsx | 11 +- .../table/request-additional-Info-dialog.tsx | 22 +- lib/vendors/table/request-basicContract-dialog.tsx | 548 ++ lib/vendors/table/request-project-pq-dialog.tsx | 24 +- .../table/request-vendor-investigate-dialog.tsx | 243 +- lib/vendors/table/request-vendor-pg-dialog.tsx | 11 + lib/vendors/table/update-vendor-sheet.tsx | 710 +- lib/vendors/table/vendor-all-export.ts | 486 ++ lib/vendors/table/vendors-table-columns.tsx | 393 +- .../table/vendors-table-toolbar-actions.tsx | 154 +- lib/vendors/table/vendors-table.tsx | 99 +- lib/vendors/table/view-vendors_logs-dialog.tsx | 244 + lib/vendors/validations.ts | 79 +- middleware.ts | 32 +- next.config.ts | 6 +- .../02adad42-62c4-4082-a9e4-caa264ff91ba.pdf | Bin 0 -> 24124 bytes .../2357fe40-691d-44ec-892a-e53a61f75df8.pdf | Bin 0 -> 1426682 bytes .../2e1276ee-6c84-458b-8349-671b3ff27d30.pdf | Bin 0 -> 4275392 bytes .../2f7b320b-5cc0-4869-8496-eb2dd2a09d7d.pdf | Bin 0 -> 22012 bytes .../7ae0ab2b-b2e4-448a-b6a1-6c97f7fa80b5.pdf | Bin 0 -> 1430893 bytes .../7fb08ec7-62ef-49a6-ac88-f0e4889ff976.pdf | Bin 0 -> 4278412 bytes .../9c262529-6ca6-476c-a51f-5aa44c328582.pdf | Bin 0 -> 4273792 bytes .../f6c25fb8-f7a8-4e64-a219-ed1937a9a071.pdf | Bin 0 -> 22533 bytes .../template/1744980872264-c5b5cbec.pdf | Bin 0 -> 206225 bytes .../template/1744980904022-bf5b6255.pdf | Bin 0 -> 206225 bytes .../template/1745120242050-98efb44f.pdf | Bin 0 -> 1426647 bytes .../template/1745120365981-06279704.pdf | Bin 0 -> 16223007 bytes .../template/1745120555940-76317b3d.pdf | Bin 0 -> 1426647 bytes .../template/1745120671798-f13bc764.pdf | Bin 0 -> 4263197 bytes .../template/1745124821673-50799ebf.pdf | Bin 0 -> 19834 bytes .../template/1745219528175-6a27b306.pdf | Bin 0 -> 19834 bytes public/wsdl/IF_ECC_EVCP_BIDDING_PROJECT.wsdl | 115 + rfq/tbe-responses/Test - TBE-1744075624835.txt | 8 - rfq/tbe-responses/Test - TBE-1744076192895.txt | 8 - types/table.d.ts | 2 +- 432 files changed, 287042 insertions(+), 4901 deletions(-) create mode 100644 app/[lng]/evcp/(evcp)/basic-contract-template/page.tsx create mode 100644 app/[lng]/evcp/(evcp)/basic-contract/page.tsx create mode 100644 app/[lng]/evcp/(evcp)/bid-projects/page.tsx create mode 100644 app/[lng]/evcp/(evcp)/bqcbe/page.tsx create mode 100644 app/[lng]/evcp/(evcp)/dashboard/page.tsx create mode 100644 app/[lng]/evcp/(evcp)/project-vendors/page.tsx create mode 100644 app/[lng]/evcp/(evcp)/vendor-type/page.tsx create mode 100644 app/[lng]/partners/(partners)/basic-contract/page.tsx create mode 100644 app/[lng]/partners/(partners)/cbe/page.tsx create mode 100644 app/[lng]/partners/(partners)/report/page.tsx create mode 100644 app/api/(S-ERP)/(ECC)/IF_ECC_EVCP_BIDDING_PROJECT/route.ts create mode 100644 app/api/basic-contract/status/route.ts create mode 100644 app/api/cron/form-tags/start/route.ts create mode 100644 app/api/cron/form-tags/status/route.ts create mode 100644 app/api/cron/forms/start/route.ts create mode 100644 app/api/cron/forms/status/route.ts create mode 100644 app/api/cron/tags/start/route.ts create mode 100644 app/api/cron/tags/status/route.ts create mode 100644 app/api/upload/basicContract/chunk/route.ts create mode 100644 app/api/upload/basicContract/complete/route.ts create mode 100644 app/api/upload/signed-contract/route.ts create mode 100644 app/api/vendors/attachments/download-all/route.ts create mode 100644 app/api/vendors/attachments/download/route.ts create mode 100644 components/BidProjectSelector.tsx create mode 100644 components/data-table/data-table-compact-toggle.tsx create mode 100644 components/form-data/add-formTag-dialog.tsx create mode 100644 components/form-data/export-excel-form.tsx create mode 100644 components/form-data/form-data-table copy.tsx create mode 100644 components/form-data/import-excel-form.tsx create mode 100644 components/form-data/publish-dialog.tsx create mode 100644 components/form-data/sedp-compare-dialog.tsx create mode 100644 components/form-data/sedp-components.tsx create mode 100644 components/form-data/sedp-excel-download.tsx create mode 100644 components/login/login-form copy 2.tsx create mode 100644 components/login/login-form copy.tsx create mode 100644 config/VendorTypesColumnsConfig.ts create mode 100644 config/basicContractColumnsConfig.ts create mode 100644 config/bidProjectsColumnsConfig.ts create mode 100644 config/projectAVLColumnsConfig.ts create mode 100644 db/migrations/0003_old_misty_knight.sql create mode 100644 db/migrations/0004_common_warlock.sql create mode 100644 db/migrations/0005_jittery_may_parker.sql create mode 100644 db/migrations/0006_chemical_gunslinger.sql create mode 100644 db/migrations/0007_cuddly_stardust.sql create mode 100644 db/migrations/0008_stiff_exodus.sql create mode 100644 db/migrations/0009_wet_joseph.sql create mode 100644 db/migrations/0010_numerous_ghost_rider.sql create mode 100644 db/migrations/0011_big_epoch.sql create mode 100644 db/migrations/0012_cynical_the_twelve.sql create mode 100644 db/migrations/0013_wakeful_speed_demon.sql create mode 100644 db/migrations/0014_good_radioactive_man.sql create mode 100644 db/migrations/0015_wide_mindworm.sql create mode 100644 db/migrations/0016_charming_mac_gargan.sql create mode 100644 db/migrations/0017_modern_donald_blake.sql create mode 100644 db/migrations/0018_broken_clint_barton.sql create mode 100644 db/migrations/0019_keen_ser_duncan.sql create mode 100644 db/migrations/0020_lyrical_butterfly.sql create mode 100644 db/migrations/0021_slimy_bastion.sql create mode 100644 db/migrations/0022_groovy_vulcan.sql create mode 100644 db/migrations/0023_clean_clea.sql create mode 100644 db/migrations/0024_legal_white_tiger.sql create mode 100644 db/migrations/0025_public_squirrel_girl.sql create mode 100644 db/migrations/0026_slimy_maddog.sql create mode 100644 db/migrations/0027_stiff_shadowcat.sql create mode 100644 db/migrations/0028_bitter_toxin.sql create mode 100644 db/migrations/0029_sad_jubilee.sql create mode 100644 db/migrations/0030_warm_wildside.sql create mode 100644 db/migrations/0031_sour_nighthawk.sql create mode 100644 db/migrations/0032_fine_lyja.sql create mode 100644 db/migrations/0033_silly_skrulls.sql create mode 100644 db/migrations/0034_dashing_corsair.sql create mode 100644 db/migrations/0035_first_leopardon.sql create mode 100644 db/migrations/0036_rare_lady_ursula.sql create mode 100644 db/migrations/0037_pink_black_bolt.sql create mode 100644 db/migrations/0038_mighty_rictor.sql create mode 100644 db/migrations/0039_medical_kree.sql create mode 100644 db/migrations/0040_next_gunslinger.sql create mode 100644 db/migrations/0041_dazzling_wilson_fisk.sql create mode 100644 db/migrations/meta/0003_snapshot.json create mode 100644 db/migrations/meta/0004_snapshot.json create mode 100644 db/migrations/meta/0005_snapshot.json create mode 100644 db/migrations/meta/0006_snapshot.json create mode 100644 db/migrations/meta/0007_snapshot.json create mode 100644 db/migrations/meta/0008_snapshot.json create mode 100644 db/migrations/meta/0009_snapshot.json create mode 100644 db/migrations/meta/0010_snapshot.json create mode 100644 db/migrations/meta/0011_snapshot.json create mode 100644 db/migrations/meta/0012_snapshot.json create mode 100644 db/migrations/meta/0013_snapshot.json create mode 100644 db/migrations/meta/0014_snapshot.json create mode 100644 db/migrations/meta/0015_snapshot.json create mode 100644 db/migrations/meta/0016_snapshot.json create mode 100644 db/migrations/meta/0017_snapshot.json create mode 100644 db/migrations/meta/0018_snapshot.json create mode 100644 db/migrations/meta/0019_snapshot.json create mode 100644 db/migrations/meta/0020_snapshot.json create mode 100644 db/migrations/meta/0021_snapshot.json create mode 100644 db/migrations/meta/0022_snapshot.json create mode 100644 db/migrations/meta/0023_snapshot.json create mode 100644 db/migrations/meta/0024_snapshot.json create mode 100644 db/migrations/meta/0025_snapshot.json create mode 100644 db/migrations/meta/0026_snapshot.json create mode 100644 db/migrations/meta/0027_snapshot.json create mode 100644 db/migrations/meta/0028_snapshot.json create mode 100644 db/migrations/meta/0029_snapshot.json create mode 100644 db/migrations/meta/0030_snapshot.json create mode 100644 db/migrations/meta/0031_snapshot.json create mode 100644 db/migrations/meta/0032_snapshot.json create mode 100644 db/migrations/meta/0033_snapshot.json create mode 100644 db/migrations/meta/0034_snapshot.json create mode 100644 db/migrations/meta/0035_snapshot.json create mode 100644 db/migrations/meta/0036_snapshot.json create mode 100644 db/migrations/meta/0037_snapshot.json create mode 100644 db/migrations/meta/0038_snapshot.json create mode 100644 db/migrations/meta/0039_snapshot.json create mode 100644 db/migrations/meta/0040_snapshot.json create mode 100644 db/migrations/meta/0041_snapshot.json create mode 100644 db/schema/basicContractDocumnet.ts create mode 100644 db/schema/logs.ts create mode 100644 db/seeds/create-contract-cli.ts create mode 100644 lib/basic-contract/repository.ts create mode 100644 lib/basic-contract/service.ts create mode 100644 lib/basic-contract/status/basic-contract-columns.tsx create mode 100644 lib/basic-contract/status/basic-contract-table.tsx create mode 100644 lib/basic-contract/status/basicContract-table-toolbar-actions.tsx create mode 100644 lib/basic-contract/template/add-basic-contract-template-dialog.tsx create mode 100644 lib/basic-contract/template/basic-contract-template-columns.tsx create mode 100644 lib/basic-contract/template/basic-contract-template.tsx create mode 100644 lib/basic-contract/template/basicContract-table-toolbar-actions.tsx create mode 100644 lib/basic-contract/template/delete-basicContract-dialog.tsx create mode 100644 lib/basic-contract/template/update-basicContract-sheet.tsx create mode 100644 lib/basic-contract/validations.ts create mode 100644 lib/basic-contract/vendor-table/basic-contract-columns.tsx create mode 100644 lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx create mode 100644 lib/basic-contract/vendor-table/basic-contract-table.tsx create mode 100644 lib/basic-contract/vendor-table/basicContract-table-toolbar-actions.tsx create mode 100644 lib/basic-contract/viewer/basic-contract-sign-viewer.tsx create mode 100644 lib/bidding-projects/repository.ts create mode 100644 lib/bidding-projects/service.ts create mode 100644 lib/bidding-projects/table/project-series-dialog.tsx create mode 100644 lib/bidding-projects/table/projects-table-columns.tsx create mode 100644 lib/bidding-projects/table/projects-table-toolbar-actions.tsx create mode 100644 lib/bidding-projects/table/projects-table.tsx create mode 100644 lib/bidding-projects/validation.ts create mode 100644 lib/cbe/table/cbe-table-columns.tsx create mode 100644 lib/cbe/table/cbe-table-toolbar-actions.tsx create mode 100644 lib/cbe/table/cbe-table.tsx create mode 100644 lib/cbe/table/comments-sheet.tsx create mode 100644 lib/cbe/table/invite-vendors-dialog.tsx create mode 100644 lib/items/table/import-excel-button.tsx create mode 100644 lib/items/table/import-item-handler.tsx create mode 100644 lib/items/table/item-excel-template.tsx create mode 100644 lib/mail/layouts/base.hbs create mode 100644 lib/mail/partials/footer.hbs create mode 100644 lib/mail/partials/header.hbs create mode 100644 lib/mail/templates/cbe-invitation.hbs create mode 100644 lib/mail/templates/contract-sign-request.hbs create mode 100644 lib/mail/templates/investigation-request.hbs create mode 100644 lib/mail/templates/pq-submitted-admin.hbs create mode 100644 lib/mail/templates/pq-submitted-vendor.hbs create mode 100644 lib/mail/templates/pq.hbs create mode 100644 lib/mail/templates/project-pq.hbs create mode 100644 lib/mail/templates/vendor-project-pq-status.hbs create mode 100644 lib/project-avl/repository.ts create mode 100644 lib/project-avl/service.ts create mode 100644 lib/project-avl/table/proejctAVL-table.tsx create mode 100644 lib/project-avl/table/projectAVL-table-columns.tsx create mode 100644 lib/project-avl/validations.ts create mode 100644 lib/rfqs/cbe-table/cbe-table-toolbar-actions.tsx create mode 100644 lib/rfqs/cbe-table/comments-sheet.tsx delete mode 100644 lib/rfqs/cbe-table/feature-flags-provider.tsx create mode 100644 lib/rfqs/cbe-table/invite-vendors-dialog.tsx create mode 100644 lib/rfqs/cbe-table/vendor-contact-dialog.tsx create mode 100644 lib/rfqs/tbe-table/tbe-result-dialog.tsx create mode 100644 lib/rfqs/tbe-table/vendor-contact-dialog.tsx create mode 100644 lib/rfqs/tbe-table/vendor-contact/vendor-contact-table-column.tsx create mode 100644 lib/rfqs/tbe-table/vendor-contact/vendor-contact-table.tsx create mode 100644 lib/sedp/get-form-tags.ts create mode 100644 lib/sedp/get-tags.ts create mode 100644 lib/tbe/table/tbe-result-dialog.tsx create mode 100644 lib/tbe/table/vendor-contact-dialog.tsx create mode 100644 lib/vendor-candidates/table/view-candidate_logs-dialog.tsx create mode 100644 lib/vendor-investigation/table/contract-dialog.tsx create mode 100644 lib/vendor-investigation/table/items-dialog.tsx create mode 100644 lib/vendor-rfq-response/vendor-cbe-table/cbe-table-columns.tsx create mode 100644 lib/vendor-rfq-response/vendor-cbe-table/cbe-table.tsx create mode 100644 lib/vendor-rfq-response/vendor-cbe-table/comments-sheet.tsx create mode 100644 lib/vendor-rfq-response/vendor-cbe-table/respond-cbe-sheet.tsx create mode 100644 lib/vendor-rfq-response/vendor-cbe-table/rfq-detail-dialog.tsx create mode 100644 lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table-column.tsx create mode 100644 lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table.tsx delete mode 100644 lib/vendor-rfq-response/vendor-tbe-table/feature-flags-provider.tsx create mode 100644 lib/vendor-rfq-response/vendor-tbe-table/rfq-detail-dialog.tsx create mode 100644 lib/vendor-type/repository.ts create mode 100644 lib/vendor-type/service.ts create mode 100644 lib/vendor-type/table/add-vendorTypes-dialog.tsx create mode 100644 lib/vendor-type/table/delete-vendorTypes-dialog.tsx create mode 100644 lib/vendor-type/table/feature-flags-provider.tsx create mode 100644 lib/vendor-type/table/feature-flags.tsx create mode 100644 lib/vendor-type/table/import-excel-button.tsx create mode 100644 lib/vendor-type/table/import-vendorTypes-handler.tsx create mode 100644 lib/vendor-type/table/update-vendorTypes-sheet.tsx create mode 100644 lib/vendor-type/table/vendorTypes-excel-template.tsx create mode 100644 lib/vendor-type/table/vendorTypes-table-columns.tsx create mode 100644 lib/vendor-type/table/vendorTypes-table-toolbar-actions.tsx create mode 100644 lib/vendor-type/table/vendorTypes-table.tsx create mode 100644 lib/vendor-type/validations.ts create mode 100644 lib/vendors/table/request-basicContract-dialog.tsx create mode 100644 lib/vendors/table/vendor-all-export.ts create mode 100644 lib/vendors/table/view-vendors_logs-dialog.tsx create mode 100644 public/basicContract/02adad42-62c4-4082-a9e4-caa264ff91ba.pdf create mode 100644 public/basicContract/2357fe40-691d-44ec-892a-e53a61f75df8.pdf create mode 100644 public/basicContract/2e1276ee-6c84-458b-8349-671b3ff27d30.pdf create mode 100644 public/basicContract/2f7b320b-5cc0-4869-8496-eb2dd2a09d7d.pdf create mode 100644 public/basicContract/7ae0ab2b-b2e4-448a-b6a1-6c97f7fa80b5.pdf create mode 100644 public/basicContract/7fb08ec7-62ef-49a6-ac88-f0e4889ff976.pdf create mode 100644 public/basicContract/9c262529-6ca6-476c-a51f-5aa44c328582.pdf create mode 100644 public/basicContract/f6c25fb8-f7a8-4e64-a219-ed1937a9a071.pdf create mode 100644 public/basicContract/template/1744980872264-c5b5cbec.pdf create mode 100644 public/basicContract/template/1744980904022-bf5b6255.pdf create mode 100644 public/basicContract/template/1745120242050-98efb44f.pdf create mode 100644 public/basicContract/template/1745120365981-06279704.pdf create mode 100644 public/basicContract/template/1745120555940-76317b3d.pdf create mode 100644 public/basicContract/template/1745120671798-f13bc764.pdf create mode 100644 public/basicContract/template/1745124821673-50799ebf.pdf create mode 100644 public/basicContract/template/1745219528175-6a27b306.pdf create mode 100644 public/wsdl/IF_ECC_EVCP_BIDDING_PROJECT.wsdl delete mode 100644 rfq/tbe-responses/Test - TBE-1744075624835.txt delete mode 100644 rfq/tbe-responses/Test - TBE-1744076192895.txt diff --git a/.env.development b/.env.development index 5f7c3073..0ba6ec3d 100644 --- a/.env.development +++ b/.env.development @@ -13,6 +13,8 @@ NEXT_PUBLIC_MUI_KEY=da30586e1f20b93856a9783012fc9258Tz04ODI0MyxFPTE3NDQ0NTM2Nzgw NEXT_PUBLIC_URL=http://3.36.56.124:3000 NEXT_PUBLIC_BASE_URL=http://3.36.56.124:3001 + NEXTAUTH_URL=http://3.36.56.124:3000 + # PDFTRON KEYS NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY=demo:1739264618684:616161d7030000000091db1c97c6f386d41d3506ab5b507381ef2ee2bd diff --git a/.env.production b/.env.production index 4dbd90b3..78447fac 100644 --- a/.env.production +++ b/.env.production @@ -13,6 +13,7 @@ NEXT_PUBLIC_MUI_KEY=da30586e1f20b93856a9783012fc9258Tz04ODI0MyxFPTE3NDQ0NTM2Nzgw NEXT_PUBLIC_URL=https://evcp.dtsolution.io NEXT_PUBLIC_BASE_URL=https://evcp.dtsolution.io + NEXTAUTH_URL=https://evcp.dtsolution.io # PDFTRON KEYS NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY=demo:1739264618684:616161d7030000000091db1c97c6f386d41d3506ab5b507381ef2ee2bd diff --git a/.gitignore b/.gitignore index de61ac2e..b936fbe2 100644 --- a/.gitignore +++ b/.gitignore @@ -56,4 +56,4 @@ next-env.d.ts # 직접 참조가 불가능해 복사가 필요했던 라이브러리 (pdftrone) # node_modules/@pdftron/public 경로에서 core 및 ui 경로를 복사해 사용 -/public/pdftronWeb \ No newline at end of file +/public/pdftronWeb diff --git a/app/[lng]/evcp/(evcp)/basic-contract-template/page.tsx b/app/[lng]/evcp/(evcp)/basic-contract-template/page.tsx new file mode 100644 index 00000000..adc57ed9 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/basic-contract-template/page.tsx @@ -0,0 +1,74 @@ +import * as React from "react" +import { type SearchParams } from "@/types/table" + +import { getValidFilters } from "@/lib/data-table" +import { Skeleton } from "@/components/ui/skeleton" +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" +import { Shell } from "@/components/shell" +import { getBasicContractTemplates } from "@/lib/basic-contract/service" +import { searchParamsTemplatesCache } from "@/lib/basic-contract/validations" +import { BasicContractTemplateTable } from "@/lib/basic-contract/template/basic-contract-template" + + +interface IndexPageProps { + searchParams: Promise +} + +export default async function IndexPage(props: IndexPageProps) { + const searchParams = await props.searchParams + const search = searchParamsTemplatesCache.parse(searchParams) + + const validFilters = getValidFilters(search.filters) + + const promises = Promise.all([ + getBasicContractTemplates({ + ...search, + filters: validFilters, + }), + + ]) + + return ( + +
+
+
+

+ 기본계약서 템플릿 관리 +

+

+ 기본계약서를 비롯하여 초기 서명이 필요한 문서를 등록하고 편집할 수 있습니다. 활성화된 템플릿이 서명 요청의 리스트에 나타나게 됩니다..{" "} + {/* + + 버튼 + + 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. */} +

+
+
+
+ + }> + {/* */} + + + } + > + + +
+ ) +} diff --git a/app/[lng]/evcp/(evcp)/basic-contract/page.tsx b/app/[lng]/evcp/(evcp)/basic-contract/page.tsx new file mode 100644 index 00000000..a043e530 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/basic-contract/page.tsx @@ -0,0 +1,74 @@ +import * as React from "react" +import { type SearchParams } from "@/types/table" + +import { getValidFilters } from "@/lib/data-table" +import { Skeleton } from "@/components/ui/skeleton" +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" +import { Shell } from "@/components/shell" +import { getBasicContracts } from "@/lib/basic-contract/service" +import { searchParamsCache } from "@/lib/basic-contract/validations" +import { BasicContractsTable } from "@/lib/basic-contract/status/basic-contract-table" + + +interface IndexPageProps { + searchParams: Promise +} + +export default async function IndexPage(props: IndexPageProps) { + const searchParams = await props.searchParams + const search = searchParamsCache.parse(searchParams) + + const validFilters = getValidFilters(search.filters) + + const promises = Promise.all([ + getBasicContracts({ + ...search, + filters: validFilters, + }), + + ]) + + return ( + +
+
+
+

+ 기본계약서 서명 현황 +

+

+ 기본계약서를 비롯하여 초기 서명이 필요한 문서의 서명 현황을 확인할 수 있고 서명된 문서들을 다운로드할 수 있습니다. {" "} + {/* + + 버튼 + + 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. */} +

+
+
+
+ + }> + {/* */} + + + } + > + + +
+ ) +} diff --git a/app/[lng]/evcp/(evcp)/bid-projects/page.tsx b/app/[lng]/evcp/(evcp)/bid-projects/page.tsx new file mode 100644 index 00000000..3390f4f3 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/bid-projects/page.tsx @@ -0,0 +1,74 @@ +import * as React from "react" +import { type SearchParams } from "@/types/table" + +import { getValidFilters } from "@/lib/data-table" +import { Skeleton } from "@/components/ui/skeleton" +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" +import { Shell } from "@/components/shell" +import { getBidProjectLists } from "@/lib/bidding-projects/service" +import { searchParamsBidProjectsCache } from "@/lib/bidding-projects/validation" +import { BidProjectsTable } from "@/lib/bidding-projects/table/projects-table" + + +interface IndexPageProps { + searchParams: Promise +} + +export default async function IndexPage(props: IndexPageProps) { + const searchParams = await props.searchParams + const search = searchParamsBidProjectsCache.parse(searchParams) + + const validFilters = getValidFilters(search.filters) + + const promises = Promise.all([ + getBidProjectLists({ + ...search, + filters: validFilters, + }), + + ]) + + return ( + +
+
+
+

+ 견적 프로젝트 리스트 +

+

+ SAP로부터 수신할 수 있습니다.{" "} + {/* + + 버튼 + + 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. */} +

+
+
+
+ + }> + {/* */} + + + } + > + + +
+ ) +} diff --git a/app/[lng]/evcp/(evcp)/bqcbe/page.tsx b/app/[lng]/evcp/(evcp)/bqcbe/page.tsx new file mode 100644 index 00000000..ae503feb --- /dev/null +++ b/app/[lng]/evcp/(evcp)/bqcbe/page.tsx @@ -0,0 +1,74 @@ +import { type SearchParams } from "@/types/table" +import { getValidFilters } from "@/lib/data-table" +import { getAllCBE } from "@/lib/rfqs/service" +import { searchParamsCBECache } from "@/lib/rfqs/validations" + +import { AllCbeTable } from "@/lib/cbe/table/cbe-table" + +import { RfqType } from "@/lib/rfqs/validations" +import * as React from "react" +import { Shell } from "@/components/shell" +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" + +interface IndexPageProps { + // Next.js 13 App Router에서 기본으로 주어지는 객체들 + params: { + lng: string + } + searchParams: Promise + rfqType: RfqType +} + +export default async function RfqCBEPage(props: IndexPageProps) { + const resolvedParams = await props.params + const lng = resolvedParams.lng + + const rfqType = props.rfqType ?? RfqType.BUDGETARY // rfqType이 없으면 BUDGETARY로 설정 + + // 2) SearchParams 파싱 (Zod) + // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 + const searchParams = await props.searchParams + const search = searchParamsCBECache.parse(searchParams) + const validFilters = getValidFilters(search.filters) + + const promises = Promise.all([ + getAllCBE({ + ...search, + filters: validFilters, + rfqType + } + ) + ]) + + // 4) 렌더링 + return ( + +
+
+
+

+ Commercial Bid Evaluation +

+

+ 초대된 협력업체에게 CBE를 보낼 수 있습니다.
체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. +

+
+
+
+ + + } + > + + +
+ ) +} \ No newline at end of file diff --git a/app/[lng]/evcp/(evcp)/bqtbe/page.tsx b/app/[lng]/evcp/(evcp)/bqtbe/page.tsx index 655bd30a..4989c235 100644 --- a/app/[lng]/evcp/(evcp)/bqtbe/page.tsx +++ b/app/[lng]/evcp/(evcp)/bqtbe/page.tsx @@ -48,7 +48,7 @@ export default async function RfqTBEPage(props: IndexPageProps) { Technical Bid Evaluation

- 초대된 벤더에게 TBE를 보낼 수 있습니다.
체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 벤더가 입력할 수 있게 자동 생성됩니다. + 초대된 협력업체에게 TBE를 보낼 수 있습니다.
체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.

diff --git a/app/[lng]/evcp/(evcp)/budgetary-rfq/[id]/cbe/page.tsx b/app/[lng]/evcp/(evcp)/budgetary-rfq/[id]/cbe/page.tsx index 9a4ae7eb..956facd3 100644 --- a/app/[lng]/evcp/(evcp)/budgetary-rfq/[id]/cbe/page.tsx +++ b/app/[lng]/evcp/(evcp)/budgetary-rfq/[id]/cbe/page.tsx @@ -44,7 +44,7 @@ export default async function RfqTBEPage(props: IndexPageProps) { Commercial Bid Evaluation

- 초대된 벤더에게 CBE를 보낼 수 있습니다.
체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 벤더가 입력할 수 있게 자동 생성됩니다. + 초대된 협력업체에게 CBE를 보낼 수 있습니다.
체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.

diff --git a/app/[lng]/evcp/(evcp)/budgetary-rfq/[id]/layout.tsx b/app/[lng]/evcp/(evcp)/budgetary-rfq/[id]/layout.tsx index 39f045e5..ba7c071c 100644 --- a/app/[lng]/evcp/(evcp)/budgetary-rfq/[id]/layout.tsx +++ b/app/[lng]/evcp/(evcp)/budgetary-rfq/[id]/layout.tsx @@ -1,11 +1,13 @@ import { Metadata } from "next" +import Link from "next/link" +import { ArrowLeft } from "lucide-react" import { Separator } from "@/components/ui/separator" import { SidebarNav } from "@/components/layout/sidebar-nav" -import { findVendorById } from "@/lib/vendors/service" // 가정: 여기에 findVendorById가 있다고 가정 -import { Rfq, RfqWithItems } from "@/db/schema/rfq" +import { RfqViewWithItems } from "@/db/schema/rfq" import { findRfqById } from "@/lib/rfqs/service" import { formatDate } from "@/lib/utils" +import { Button } from "@/components/ui/button" export const metadata: Metadata = { title: "Vendor Detail", @@ -25,8 +27,8 @@ export default async function RfqLayout({ const id = resolvedParams.id const idAsNumber = Number(id) - // 2) DB에서 해당 벤더 정보 조회 - const rfq: RfqWithItems | null = await findRfqById(idAsNumber) + // 2) DB에서 해당 협력업체 정보 조회 + const rfq: RfqViewWithItems | null = await findRfqById(idAsNumber) // 3) 사이드바 메뉴 const sidebarNavItems = [ @@ -50,27 +52,35 @@ export default async function RfqLayout({
+
+ + + +
- {/* 4) 벤더 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */} + {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */}

{rfq - ? `${rfq.rfqCode ?? ""} 관리` + ? `${rfq.projectCode ?? ""} ${rfq.rfqCode ?? ""} 관리` : "Loading RFQ..."}

- +

{rfq ? `${rfq.description ?? ""} ${rfq.lines.map(line => line.itemCode).join(", ")}` : ""}

-

Due Date:{ rfq && {formatDate(rfq?.dueDate)}}

+

Due Date:{rfq && rfq?.dueDate && {formatDate(rfq?.dueDate)}}

-
diff --git a/app/[lng]/evcp/(evcp)/budgetary-rfq/[id]/page.tsx b/app/[lng]/evcp/(evcp)/budgetary-rfq/[id]/page.tsx index f6160574..dd9df563 100644 --- a/app/[lng]/evcp/(evcp)/budgetary-rfq/[id]/page.tsx +++ b/app/[lng]/evcp/(evcp)/budgetary-rfq/[id]/page.tsx @@ -45,7 +45,7 @@ export default async function RfqPage(props: IndexPageProps) { Vendors

- 등록된 벤더 중에서 이 RFQ 아이템에 매칭되는 업체를 보여줍니다.
"발행하기" 버튼을 통해 RFQ를 전송하면 첨부파일과 함께 RFQ 내용이 메일로 전달되고 eVCP에도 벤더가 입력할 수 있게 자동 생성됩니다. + 등록된 협력업체 중에서 이 RFQ 아이템에 매칭되는 업체를 보여줍니다.
"발행하기" 버튼을 통해 RFQ를 전송하면 첨부파일과 함께 RFQ 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.

diff --git a/app/[lng]/evcp/(evcp)/budgetary-rfq/[id]/tbe/page.tsx b/app/[lng]/evcp/(evcp)/budgetary-rfq/[id]/tbe/page.tsx index a6259696..ec894e1c 100644 --- a/app/[lng]/evcp/(evcp)/budgetary-rfq/[id]/tbe/page.tsx +++ b/app/[lng]/evcp/(evcp)/budgetary-rfq/[id]/tbe/page.tsx @@ -43,7 +43,7 @@ export default async function RfqTBEPage(props: IndexPageProps) { Technical Bid Evaluation

- 초대된 벤더에게 TBE를 보낼 수 있습니다.
체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 벤더가 입력할 수 있게 자동 생성됩니다. + 초대된 협력업체에게 TBE를 보낼 수 있습니다.
체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.

diff --git a/app/[lng]/evcp/(evcp)/budgetary/[id]/cbe/page.tsx b/app/[lng]/evcp/(evcp)/budgetary/[id]/cbe/page.tsx index 9a4ae7eb..956facd3 100644 --- a/app/[lng]/evcp/(evcp)/budgetary/[id]/cbe/page.tsx +++ b/app/[lng]/evcp/(evcp)/budgetary/[id]/cbe/page.tsx @@ -44,7 +44,7 @@ export default async function RfqTBEPage(props: IndexPageProps) { Commercial Bid Evaluation

- 초대된 벤더에게 CBE를 보낼 수 있습니다.
체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 벤더가 입력할 수 있게 자동 생성됩니다. + 초대된 협력업체에게 CBE를 보낼 수 있습니다.
체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.

diff --git a/app/[lng]/evcp/(evcp)/budgetary/[id]/layout.tsx b/app/[lng]/evcp/(evcp)/budgetary/[id]/layout.tsx index 39f045e5..b0711c66 100644 --- a/app/[lng]/evcp/(evcp)/budgetary/[id]/layout.tsx +++ b/app/[lng]/evcp/(evcp)/budgetary/[id]/layout.tsx @@ -1,11 +1,12 @@ import { Metadata } from "next" - +import Link from "next/link" +import { ArrowLeft } from "lucide-react" import { Separator } from "@/components/ui/separator" import { SidebarNav } from "@/components/layout/sidebar-nav" -import { findVendorById } from "@/lib/vendors/service" // 가정: 여기에 findVendorById가 있다고 가정 -import { Rfq, RfqWithItems } from "@/db/schema/rfq" +import { RfqViewWithItems } from "@/db/schema/rfq" import { findRfqById } from "@/lib/rfqs/service" import { formatDate } from "@/lib/utils" +import { Button } from "@/components/ui/button" export const metadata: Metadata = { title: "Vendor Detail", @@ -18,16 +19,16 @@ export default async function RfqLayout({ children: React.ReactNode params: { lng: string, id: string } }) { - + // 1) URL 파라미터에서 id 추출, Number로 변환 const resolvedParams = await params const lng = resolvedParams.lng const id = resolvedParams.id - + const idAsNumber = Number(id) - // 2) DB에서 해당 벤더 정보 조회 - const rfq: RfqWithItems | null = await findRfqById(idAsNumber) - + // 2) DB에서 해당 협력업체 정보 조회 + const rfq: RfqViewWithItems | null = await findRfqById(idAsNumber) + // 3) 사이드바 메뉴 const sidebarNavItems = [ { @@ -42,35 +43,44 @@ export default async function RfqLayout({ title: "CBE", href: `/${lng}/evcp/budgetary/${id}/cbe`, }, - ] - + return ( <>
+ {/* RFQ 목록으로 돌아가는 링크 추가 */} +
+ + + +
+
- {/* 4) 벤더 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */} + {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */}

{rfq - ? `${rfq.rfqCode ?? ""} 관리` + ? `${rfq.projectCode ?? ""} ${rfq.rfqCode ?? ""} 관리` : "Loading RFQ..."}

- +

{rfq ? `${rfq.description ?? ""} ${rfq.lines.map(line => line.itemCode).join(", ")}` : ""}

-

Due Date:{ rfq && {formatDate(rfq?.dueDate)}}

+

Due Date:{rfq && rfq?.dueDate && {formatDate(rfq?.dueDate)}}

-
diff --git a/app/[lng]/evcp/(evcp)/budgetary/[id]/page.tsx b/app/[lng]/evcp/(evcp)/budgetary/[id]/page.tsx index f6160574..dd9df563 100644 --- a/app/[lng]/evcp/(evcp)/budgetary/[id]/page.tsx +++ b/app/[lng]/evcp/(evcp)/budgetary/[id]/page.tsx @@ -45,7 +45,7 @@ export default async function RfqPage(props: IndexPageProps) { Vendors

- 등록된 벤더 중에서 이 RFQ 아이템에 매칭되는 업체를 보여줍니다.
"발행하기" 버튼을 통해 RFQ를 전송하면 첨부파일과 함께 RFQ 내용이 메일로 전달되고 eVCP에도 벤더가 입력할 수 있게 자동 생성됩니다. + 등록된 협력업체 중에서 이 RFQ 아이템에 매칭되는 업체를 보여줍니다.
"발행하기" 버튼을 통해 RFQ를 전송하면 첨부파일과 함께 RFQ 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.

diff --git a/app/[lng]/evcp/(evcp)/budgetary/[id]/tbe/page.tsx b/app/[lng]/evcp/(evcp)/budgetary/[id]/tbe/page.tsx index a6259696..ec894e1c 100644 --- a/app/[lng]/evcp/(evcp)/budgetary/[id]/tbe/page.tsx +++ b/app/[lng]/evcp/(evcp)/budgetary/[id]/tbe/page.tsx @@ -43,7 +43,7 @@ export default async function RfqTBEPage(props: IndexPageProps) { Technical Bid Evaluation

- 초대된 벤더에게 TBE를 보낼 수 있습니다.
체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 벤더가 입력할 수 있게 자동 생성됩니다. + 초대된 협력업체에게 TBE를 보낼 수 있습니다.
체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.

diff --git a/app/[lng]/evcp/(evcp)/dashboard/page.tsx b/app/[lng]/evcp/(evcp)/dashboard/page.tsx new file mode 100644 index 00000000..1d61dc16 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/dashboard/page.tsx @@ -0,0 +1,17 @@ +// app/invalid-access/page.tsx + +export default function InvalidAccessPage() { + return ( +
+

부적절한 접근입니다

+

+ 협력업체(Vendor)가 EVCP 화면에 접속하거나
+ SHI 계정이 협력업체 화면에 접속하려고 시도하는 경우입니다. +

+

+ 접근 권한이 없으므로, 다른 화면으로 이동해 주세요. +

+
+ ); + } + \ No newline at end of file diff --git a/app/[lng]/evcp/(evcp)/equip-class/page.tsx b/app/[lng]/evcp/(evcp)/equip-class/page.tsx index 375eb69e..cfa8f133 100644 --- a/app/[lng]/evcp/(evcp)/equip-class/page.tsx +++ b/app/[lng]/evcp/(evcp)/equip-class/page.tsx @@ -35,10 +35,10 @@ export default async function IndexPage(props: IndexPageProps) {

- Object Class List from S-EDP + 객체 클래스 목록 from S-EDP

- Object Class List를 확인할 수 있습니다.{" "} + 객체 클래스 목록을 확인할 수 있습니다.{" "} {/* 버튼 diff --git a/app/[lng]/evcp/(evcp)/form-list/page.tsx b/app/[lng]/evcp/(evcp)/form-list/page.tsx index f96917d6..a6cf7d9e 100644 --- a/app/[lng]/evcp/(evcp)/form-list/page.tsx +++ b/app/[lng]/evcp/(evcp)/form-list/page.tsx @@ -35,10 +35,10 @@ export default async function IndexPage(props: IndexPageProps) {

- Form List from S-EDP + 레지스터 목록 from S-EDP

- 벤더 데이터 입력을 위한 Form 리스트입니다.{" "} + 협력업체 데이터 입력을 위한 레지스터 목록 리스트입니다.{" "} {/* 버튼 diff --git a/app/[lng]/evcp/(evcp)/po/page.tsx b/app/[lng]/evcp/(evcp)/po/page.tsx index fa528df0..7868e231 100644 --- a/app/[lng]/evcp/(evcp)/po/page.tsx +++ b/app/[lng]/evcp/(evcp)/po/page.tsx @@ -37,7 +37,7 @@ export default async function IndexPage(props: IndexPageProps) { PO 확인 및 전자서명

- 기간계 시스템으로부터 PO를 확인하고 벤더에게 전자서명을 요청할 수 있습니다. 요쳥된 전자서명의 이력 또한 확인할 수 있습니다. + 기간계 시스템으로부터 PO를 확인하고 협력업체에게 전자서명을 요청할 수 있습니다. 요쳥된 전자서명의 이력 또한 확인할 수 있습니다.

diff --git a/app/[lng]/evcp/(evcp)/pq-criteria/[id]/page.tsx b/app/[lng]/evcp/(evcp)/pq-criteria/[id]/page.tsx index f040a0ca..55b1e9df 100644 --- a/app/[lng]/evcp/(evcp)/pq-criteria/[id]/page.tsx +++ b/app/[lng]/evcp/(evcp)/pq-criteria/[id]/page.tsx @@ -48,7 +48,7 @@ export default async function ProjectPage(props: ProjectPageProps) { Pre-Qualification Check Sheet

- 벤더 등록을 위한, 벤더가 제출할 PQ 항목을: 프로젝트별로 관리할 수 있습니다. + 협력업체 등록을 위한, 협력업체가 제출할 PQ 항목을: 프로젝트별로 관리할 수 있습니다.

diff --git a/app/[lng]/evcp/(evcp)/pq-criteria/page.tsx b/app/[lng]/evcp/(evcp)/pq-criteria/page.tsx index 778baa93..7785b541 100644 --- a/app/[lng]/evcp/(evcp)/pq-criteria/page.tsx +++ b/app/[lng]/evcp/(evcp)/pq-criteria/page.tsx @@ -37,7 +37,7 @@ export default async function IndexPage(props: IndexPageProps) { Pre-Qualification Check Sheet

- 벤더 등록을 위한, 벤더가 제출할 PQ 항목을 관리할 수 있습니다. + 협력업체 등록을 위한, 협력업체가 제출할 PQ 항목을 관리할 수 있습니다.

diff --git a/app/[lng]/evcp/(evcp)/pq/[vendorId]/page.tsx b/app/[lng]/evcp/(evcp)/pq/[vendorId]/page.tsx index 4c2555a3..76bcfe59 100644 --- a/app/[lng]/evcp/(evcp)/pq/[vendorId]/page.tsx +++ b/app/[lng]/evcp/(evcp)/pq/[vendorId]/page.tsx @@ -1,7 +1,7 @@ import * as React from "react" import { Shell } from "@/components/shell" import { type SearchParams } from "@/types/table" -import { getPQDataByVendorId, getVendorPQsList, loadGeneralPQData, loadProjectPQData } from "@/lib/pq/service" +import { getPQDataByVendorId, getVendorPQsList, loadGeneralPQData, loadProjectPQAction, loadProjectPQData } from "@/lib/pq/service" import { Vendor } from "@/db/schema/vendors" import { findVendorById } from "@/lib/vendors/service" import VendorPQAdminReview from "@/components/pq/pq-review-detail" @@ -92,7 +92,8 @@ export default async function PQReviewPage(props: IndexPageProps) { projectId={project.projectId} projectName={project.projectName} projectStatus={project.status} - loadData={(vendorId, _projectId) => loadProjectPQData(vendorId, project.projectId)} pqType="project" + loadData={loadProjectPQAction} + pqType="project" /> ))} diff --git a/app/[lng]/evcp/(evcp)/project-vendors/page.tsx b/app/[lng]/evcp/(evcp)/project-vendors/page.tsx new file mode 100644 index 00000000..dcc66071 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/project-vendors/page.tsx @@ -0,0 +1,74 @@ +import * as React from "react" +import { type SearchParams } from "@/types/table" + +import { getValidFilters } from "@/lib/data-table" +import { Skeleton } from "@/components/ui/skeleton" +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" +import { Shell } from "@/components/shell" +import { ProjectAVLTable } from "@/lib/project-avl/table/proejctAVL-table" +import { getProjecTAVL } from "@/lib/project-avl/service" +import { searchProjectAVLParamsCache } from "@/lib/project-avl/validations" + + +interface IndexPageProps { + searchParams: Promise +} + +export default async function IndexPage(props: IndexPageProps) { + const searchParams = await props.searchParams + const search = searchProjectAVLParamsCache.parse(searchParams) + + const validFilters = getValidFilters(search.filters) + + const promises = Promise.all([ + getProjecTAVL({ + ...search, + filters: validFilters, + }), + + ]) + + return ( + +
+
+
+

+ 프로젝트 AVL 리스트 +

+

+ 프로젝트 PQ를 통과한 벤더의 리스트를 보여줍니다.{" "} + {/* + + 버튼 + + 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. */} +

+
+
+
+ + }> + {/* */} + + + } + > + + +
+ ) +} diff --git a/app/[lng]/evcp/(evcp)/report/page.tsx b/app/[lng]/evcp/(evcp)/report/page.tsx index a1e9f8be..3efaa7c3 100644 --- a/app/[lng]/evcp/(evcp)/report/page.tsx +++ b/app/[lng]/evcp/(evcp)/report/page.tsx @@ -1,8 +1,47 @@ +import * as React from "react" +import { Skeleton } from "@/components/ui/skeleton" +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" +import { Shell } from "@/components/shell" -export default function Pages() { - return ( - <> - test - - ) - } \ No newline at end of file + + +export default async function IndexPage() { + + + return ( + +
+
+

+ Dashboard +

+

+ 각종 지표 등을 대시보드로 표현하거나 리포트를 출력할 수 있습니다. +

+
+
+ + }> + {/* */} + + + + } + > + +
+ ) +} \ No newline at end of file diff --git a/app/[lng]/evcp/(evcp)/rfq/[id]/cbe/page.tsx b/app/[lng]/evcp/(evcp)/rfq/[id]/cbe/page.tsx index bc32641f..fb288a98 100644 --- a/app/[lng]/evcp/(evcp)/rfq/[id]/cbe/page.tsx +++ b/app/[lng]/evcp/(evcp)/rfq/[id]/cbe/page.tsx @@ -1,7 +1,9 @@ import { Separator } from "@/components/ui/separator" import { type SearchParams } from "@/types/table" import { getValidFilters } from "@/lib/data-table" -import { searchParamsTBECache } from "@/lib/rfqs/validations" +import { searchParamsCBECache } from "@/lib/rfqs/validations" +import { getCBE } from "@/lib/rfqs/service" +import { CbeTable } from "@/lib/rfqs/cbe-table/cbe-table" interface IndexPageProps { // Next.js 13 App Router에서 기본으로 주어지는 객체들 @@ -22,31 +24,31 @@ export default async function RfqCBEPage(props: IndexPageProps) { // 2) SearchParams 파싱 (Zod) // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 const searchParams = await props.searchParams - const search = searchParamsTBECache.parse(searchParams) + const search = searchParamsCBECache.parse(searchParams) const validFilters = getValidFilters(search.filters) - // const promises = Promise.all([ - // getCBE({ - // ...search, - // filters: validFilters, - // }, - // idAsNumber) - // ]) + const promises = Promise.all([ + getCBE({ + ...search, + filters: validFilters, + }, + idAsNumber) + ]) // 4) 렌더링 return (

- Technical Bid Evaluation + Commercial Bid Evaluation

- 초대된 벤더에게 CBE를 보낼 수 있습니다.
"발행하기" 버튼을 통해 CBE를 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 벤더가 입력할 수 있게 자동 생성됩니다. + 초대된 협력업체에게 CBE를 보낼 수 있습니다.
"발행하기" 버튼을 통해 CBE를 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.

- +
) diff --git a/app/[lng]/evcp/(evcp)/rfq/[id]/layout.tsx b/app/[lng]/evcp/(evcp)/rfq/[id]/layout.tsx index 2aac90eb..9a03efa4 100644 --- a/app/[lng]/evcp/(evcp)/rfq/[id]/layout.tsx +++ b/app/[lng]/evcp/(evcp)/rfq/[id]/layout.tsx @@ -1,11 +1,12 @@ import { Metadata } from "next" - +import Link from "next/link" import { Separator } from "@/components/ui/separator" import { SidebarNav } from "@/components/layout/sidebar-nav" -import { findVendorById } from "@/lib/vendors/service" // 가정: 여기에 findVendorById가 있다고 가정 -import { Rfq, RfqWithItems } from "@/db/schema/rfq" +import { RfqViewWithItems } from "@/db/schema/rfq" import { findRfqById } from "@/lib/rfqs/service" import { formatDate } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { ArrowLeft } from "lucide-react" export const metadata: Metadata = { title: "Vendor Detail", @@ -25,8 +26,8 @@ export default async function RfqLayout({ const id = resolvedParams.id const idAsNumber = Number(id) - // 2) DB에서 해당 벤더 정보 조회 - const rfq: RfqWithItems | null = await findRfqById(idAsNumber) + // 2) DB에서 해당 협력업체 정보 조회 + const rfq: RfqViewWithItems | null = await findRfqById(idAsNumber) // 3) 사이드바 메뉴 const sidebarNavItems = [ @@ -50,11 +51,19 @@ export default async function RfqLayout({
+
+ + + +
- {/* 4) 벤더 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */} + {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */}

{rfq - ? `${rfq.rfqCode ?? ""} 관리` + ? `${rfq.projectCode ?? ""} ${rfq.rfqCode ?? ""} 관리` : "Loading RFQ..."}

@@ -63,15 +72,15 @@ export default async function RfqLayout({ ? `${rfq.description ?? ""} ${rfq.lines.map(line => line.itemCode).join(", ")}` : ""}

-

Due Date:{ rfq && {formatDate(rfq?.dueDate)}}

+

Due Date:{rfq && rfq?.dueDate && {formatDate(rfq?.dueDate)}}

-
+
{children}
+
diff --git a/app/[lng]/evcp/(evcp)/rfq/[id]/page.tsx b/app/[lng]/evcp/(evcp)/rfq/[id]/page.tsx index 026ca5ac..1a9f4b18 100644 --- a/app/[lng]/evcp/(evcp)/rfq/[id]/page.tsx +++ b/app/[lng]/evcp/(evcp)/rfq/[id]/page.tsx @@ -43,7 +43,7 @@ export default async function RfqPage(props: IndexPageProps) { Vendors

- 등록된 벤더 중에서 이 RFQ 아이템에 매칭되는 업체를 보여줍니다.
"발행하기" 버튼을 통해 RFQ를 전송하면 첨부파일과 함께 RFQ 내용이 메일로 전달되고 eVCP에도 벤더가 입력할 수 있게 자동 생성됩니다. + 등록된 협력업체 중에서 이 RFQ 아이템에 매칭되는 업체를 보여줍니다.
"발행하기" 버튼을 통해 RFQ를 전송하면 첨부파일과 함께 RFQ 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.

diff --git a/app/[lng]/evcp/(evcp)/rfq/[id]/tbe/page.tsx b/app/[lng]/evcp/(evcp)/rfq/[id]/tbe/page.tsx index 15c5d93c..76eea302 100644 --- a/app/[lng]/evcp/(evcp)/rfq/[id]/tbe/page.tsx +++ b/app/[lng]/evcp/(evcp)/rfq/[id]/tbe/page.tsx @@ -43,7 +43,7 @@ export default async function RfqTBEPage(props: IndexPageProps) { Technical Bid Evaluation

- 초대된 벤더에게 TBE를 보낼 수 있습니다.
"발행하기" 버튼을 통해 TBE를 전송하면 첨부파일과 함께 TBE 내용이 메일로 전달되고 eVCP에도 벤더가 입력할 수 있게 자동 생성됩니다. + 초대된 협력업체에게 TBE를 보낼 수 있습니다.
"발행하기" 버튼을 통해 TBE를 전송하면 첨부파일과 함께 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.

diff --git a/app/[lng]/evcp/(evcp)/tag-numbering/page.tsx b/app/[lng]/evcp/(evcp)/tag-numbering/page.tsx index 9d5b903a..44695259 100644 --- a/app/[lng]/evcp/(evcp)/tag-numbering/page.tsx +++ b/app/[lng]/evcp/(evcp)/tag-numbering/page.tsx @@ -34,7 +34,7 @@ export default async function IndexPage(props: IndexPageProps) {

- Tag Numbering from S-EDP + 태그 타입 목록 from S-EDP

태그 넘버링을 위한 룰셋을 S-EDP로부터 가져오고 확인할 수 있습니다{" "} diff --git a/app/[lng]/evcp/(evcp)/tasks/page.tsx b/app/[lng]/evcp/(evcp)/tasks/page.tsx index f14cc757..91b946fb 100644 --- a/app/[lng]/evcp/(evcp)/tasks/page.tsx +++ b/app/[lng]/evcp/(evcp)/tasks/page.tsx @@ -38,12 +38,12 @@ export default async function IndexPage(props: IndexPageProps) { return ( }> - {/* */} + />

- 초대된 벤더에게 TBE를 보낼 수 있습니다.
- 체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 벤더가 입력할 수 있게 자동 생성됩니다. + 초대된 협력업체에게 TBE를 보낼 수 있습니다.
+ 체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.

diff --git a/app/[lng]/evcp/(evcp)/vendor-candidates/page.tsx b/app/[lng]/evcp/(evcp)/vendor-candidates/page.tsx index 668c0dc6..a6e00b1b 100644 --- a/app/[lng]/evcp/(evcp)/vendor-candidates/page.tsx +++ b/app/[lng]/evcp/(evcp)/vendor-candidates/page.tsx @@ -9,6 +9,7 @@ import { Shell } from "@/components/shell" import { getVendorCandidateCounts, getVendorCandidates } from "@/lib/vendor-candidates/service" import { searchParamsCandidateCache } from "@/lib/vendor-candidates/validations" import { VendorCandidateTable } from "@/lib/vendor-candidates/table/candidates-table" +import { DateRangePicker } from "@/components/date-range-picker" interface IndexPageProps { searchParams: Promise @@ -30,24 +31,35 @@ export default async function IndexPage(props: IndexPageProps) { return ( - +

- Vendor Candidates Management -

+ Vendor Candidates Management +

- 수집한 벤더 후보를 등록하고 초대 메일을 송부할 수 있습니다. - + 수집한 협력업체 후보를 등록하고 초대 메일을 송부할 수 있습니다.

+ + {/* 수집일 라벨과 DateRangePicker를 함께 배치 */} +
+ {/* 수집일 기간 설정: */} + }> + + +
- - }> -
) -} +} \ No newline at end of file diff --git a/app/[lng]/evcp/(evcp)/vendor-type/page.tsx b/app/[lng]/evcp/(evcp)/vendor-type/page.tsx new file mode 100644 index 00000000..997c0f82 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/vendor-type/page.tsx @@ -0,0 +1,70 @@ +import * as React from "react" +import { type SearchParams } from "@/types/table" + +import { getValidFilters } from "@/lib/data-table" +import { Skeleton } from "@/components/ui/skeleton" +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" +import { Shell } from "@/components/shell" +import { searchParamsCache } from "@/lib/vendor-type/validations" +import { VendorTypesTable } from "@/lib/vendor-type/table/vendorTypes-table" +import { getVendorTypes } from "@/lib/vendor-type/service" + + +interface IndexPageProps { + searchParams: Promise +} + +export default async function IndexPage(props: IndexPageProps) { + const searchParams = await props.searchParams + const search = searchParamsCache.parse(searchParams) + + const validFilters = getValidFilters(search.filters) + + const promises = Promise.all([ + getVendorTypes({ + ...search, + filters: validFilters, + }), + + ]) + + return ( + +
+
+
+

+ 업체 유형 +

+

+ 업체 유형을 등록하고 관리할 수 있습니다.{" "} + +

+
+
+
+ + }> + {/* */} + + + } + > + + +
+ ) +} diff --git a/app/[lng]/evcp/(evcp)/vendors/[id]/info/layout.tsx b/app/[lng]/evcp/(evcp)/vendors/[id]/info/layout.tsx index 39e0bac0..4da5af74 100644 --- a/app/[lng]/evcp/(evcp)/vendors/[id]/info/layout.tsx +++ b/app/[lng]/evcp/(evcp)/vendors/[id]/info/layout.tsx @@ -23,29 +23,29 @@ export default async function SettingsLayout({ const id = resolvedParams.id const idAsNumber = Number(id) - // 2) DB에서 해당 벤더 정보 조회 + // 2) DB에서 해당 협력업체 정보 조회 const vendor: Vendor | null = await findVendorById(idAsNumber) // 3) 사이드바 메뉴 const sidebarNavItems = [ { - title: "Contacts", + title: "연락처", href: `/${lng}/evcp/vendors/${id}/info`, }, { - title: "Items", + title: "공급품목", href: `/${lng}/evcp/vendors/${id}/info/items`, }, { - title: "RFQ History", + title: "견적 히스토리", href: `/${lng}/evcp/vendors/${id}/info/rfq-history`, }, { - title: "Bidding History", + title: "입찰 히스토리", href: `/${lng}/evcp/vendors/${id}/info/bid-history`, }, { - title: "Contract History", + title: "계약 히스토리", href: `/${lng}/evcp/vendors/${id}/info/contract-history`, }, ] @@ -56,13 +56,13 @@ export default async function SettingsLayout({
- {/* 4) 벤더 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */} + {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */}

{vendor ? `${vendor.vendorCode ?? ""} - ${vendor.vendorName} 상세 정보` : "Loading Vendor..."}

-

벤더 관련 상세사항을 확인하세요.

+

협력업체 관련 상세사항을 확인하세요.

diff --git a/app/[lng]/evcp/(evcp)/vendors/[id]/info/rfq-history/page.tsx b/app/[lng]/evcp/(evcp)/vendors/[id]/info/rfq-history/page.tsx index 1d2f618c..c7f8f8b6 100644 --- a/app/[lng]/evcp/(evcp)/vendors/[id]/info/rfq-history/page.tsx +++ b/app/[lng]/evcp/(evcp)/vendors/[id]/info/rfq-history/page.tsx @@ -43,7 +43,7 @@ export default async function RfqHistoryPage(props: IndexPageProps) { RFQ History

- 벤더의 RFQ 참여 이력을 확인할 수 있습니다. + 협력업체의 RFQ 참여 이력을 확인할 수 있습니다.

diff --git a/app/[lng]/evcp/(evcp)/vendors/page.tsx b/app/[lng]/evcp/(evcp)/vendors/page.tsx index e3cc7fdc..52af0709 100644 --- a/app/[lng]/evcp/(evcp)/vendors/page.tsx +++ b/app/[lng]/evcp/(evcp)/vendors/page.tsx @@ -37,15 +37,15 @@ export default async function IndexPage(props: IndexPageProps) {

- Vendor Information + 협력업체 리스트

- 벤더에 대한 요약 정보를 확인하고{" "} + 협력업체에 대한 요약 정보를 확인하고{" "} 버튼 - 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다.
벤더의 상태에 따라 가입을 승인해주거나 PQ 요청을 할 수 있고 검토가 완료된 벤더를 기간계 시스템에 전송하여 벤더 코드를 따올 수 있습니다. + 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다.
벤더의 상태에 따라 가입을 승인해주거나 PQ 요청을 할 수 있고 검토가 완료된 벤더를 기간계 시스템에 전송하여 협력업체 코드를 따올 수 있습니다.

diff --git a/app/[lng]/partners/(partners)/basic-contract/page.tsx b/app/[lng]/partners/(partners)/basic-contract/page.tsx new file mode 100644 index 00000000..e63e6a17 --- /dev/null +++ b/app/[lng]/partners/(partners)/basic-contract/page.tsx @@ -0,0 +1,77 @@ +import * as React from "react" +import { type SearchParams } from "@/types/table" +import { getValidFilters } from "@/lib/data-table" +import { Skeleton } from "@/components/ui/skeleton" +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" +import { Shell } from "@/components/shell" +import { getBasicContractsByVendorId } from "@/lib/basic-contract/service" +import { searchParamsCache } from "@/lib/basic-contract/validations" +import { redirect } from "next/navigation" +import { BasicContractsVendorTable } from "@/lib/basic-contract/vendor-table/basic-contract-table" +import { getServerSession } from "next-auth" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" + +interface IndexPageProps { + searchParams: Promise +} + +export default async function IndexPage(props: IndexPageProps) { + + + const session = await getServerSession(authOptions) + const vendorId = session?.user.companyId + + const searchParams = await props.searchParams + const search = searchParamsCache.parse(searchParams) + + const validFilters = getValidFilters(search.filters) + + const promises = Promise.all([ + getBasicContractsByVendorId( + { + ...search, + filters: validFilters, + }, + Number(vendorId) + ), + ]) + + return ( + +
+
+
+

+ 기본계약서 서명 요청현황 +

+

+ 기본계약서를 비롯하여 초기 서명이 필요한 문서의 서명 현황을 확인할 수 있고 서명을 진행할 수 있습니다. {" "} +

+
+
+
+ + }> + {/* */} + + + } + > + + +
+ ) +} \ No newline at end of file diff --git a/app/[lng]/partners/(partners)/cbe/page.tsx b/app/[lng]/partners/(partners)/cbe/page.tsx new file mode 100644 index 00000000..8d03e5f6 --- /dev/null +++ b/app/[lng]/partners/(partners)/cbe/page.tsx @@ -0,0 +1,86 @@ +import { type SearchParams } from "@/types/table" +import { getValidFilters } from "@/lib/data-table" +import { getCBEbyVendorId, } from "@/lib/rfqs/service" +import { searchParamsCBECache } from "@/lib/rfqs/validations" +import { getServerSession } from "next-auth" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import { TbeVendorTable } from "@/lib/vendor-rfq-response/vendor-tbe-table/tbe-table" +import * as React from "react" +import { Skeleton } from "@/components/ui/skeleton" +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" +import { Shell } from "@/components/shell" +import { CbeVendorTable } from "@/lib/vendor-rfq-response/vendor-cbe-table/cbe-table" + +interface IndexPageProps { + // Next.js 13 App Router에서 기본으로 주어지는 객체들 + params: { + lng: string + id: string + } + searchParams: Promise +} + +export default async function CBEPage(props: IndexPageProps) { + const resolvedParams = await props.params + const lng = resolvedParams.lng + + // 2) SearchParams 파싱 (Zod) + // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 + const searchParams = await props.searchParams + const search = searchParamsCBECache.parse(searchParams) + const validFilters = getValidFilters(search.filters) + + const session = await getServerSession(authOptions) + const vendorId = session?.user.companyId + // const vendorId = "17" + + const idAsNumber = Number(vendorId) + + const promises = Promise.all([ + getCBEbyVendorId({ + ...search, + filters: validFilters, + }, + idAsNumber) + ]) + + + return ( + +
+
+
+

+ Commercial Bid Evaluation +

+

+ CBE에 응답하고 커뮤니케이션을 할 수 있습니다.{" "} +

+
+
+
+ + }> + {/* */} + + + } + > + + +
+ ) +} diff --git a/app/[lng]/partners/(partners)/dashboard/page.tsx b/app/[lng]/partners/(partners)/dashboard/page.tsx index a1e9f8be..3efaa7c3 100644 --- a/app/[lng]/partners/(partners)/dashboard/page.tsx +++ b/app/[lng]/partners/(partners)/dashboard/page.tsx @@ -1,8 +1,47 @@ +import * as React from "react" +import { Skeleton } from "@/components/ui/skeleton" +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" +import { Shell } from "@/components/shell" -export default function Pages() { - return ( - <> - test - - ) - } \ No newline at end of file + + +export default async function IndexPage() { + + + return ( + +
+
+

+ Dashboard +

+

+ 각종 지표 등을 대시보드로 표현하거나 리포트를 출력할 수 있습니다. +

+
+
+ + }> + {/* */} + + + + } + > + +
+ ) +} \ No newline at end of file diff --git a/app/[lng]/partners/(partners)/document-list/layout.tsx b/app/[lng]/partners/(partners)/document-list/layout.tsx index a75cdf7d..0eb9d27b 100644 --- a/app/[lng]/partners/(partners)/document-list/layout.tsx +++ b/app/[lng]/partners/(partners)/document-list/layout.tsx @@ -6,6 +6,8 @@ import { getVendorProjectsAndContracts } from "@/lib/vendor-data/services" import { getVendorDocumentLists } from "@/lib/vendor-document/service" import VendorDocumentsClient from "@/components/documents/vendor-docs.client" import VendorDocumentListClient from "@/components/document-lists/vendor-doc-list-client" +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { getServerSession } from "next-auth"; @@ -15,9 +17,9 @@ export default async function VendorDocuments({ }: { children: React.ReactNode }) { - // const session = await getServerSession(authOptions) - // const vendorId = session?.user.companyId - const vendorId = "17" + const session = await getServerSession(authOptions) + const vendorId = session?.user.companyId + // const vendorId = "17" const idAsNumber = Number(vendorId) const projects = await getVendorProjectsAndContracts(idAsNumber) diff --git a/app/[lng]/partners/(partners)/documents/layout.tsx b/app/[lng]/partners/(partners)/documents/layout.tsx index 3ac0c573..dcc2c271 100644 --- a/app/[lng]/partners/(partners)/documents/layout.tsx +++ b/app/[lng]/partners/(partners)/documents/layout.tsx @@ -5,6 +5,8 @@ import DocumentContainer from "@/components/documents/document-container" import { getVendorProjectsAndContracts } from "@/lib/vendor-data/services" import { getVendorDocumentLists } from "@/lib/vendor-document/service" import VendorDocumentsClient from "@/components/documents/vendor-docs.client" +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { getServerSession } from "next-auth"; @@ -14,9 +16,9 @@ export default async function VendorDocuments({ }: { children: React.ReactNode }) { - // const session = await getServerSession(authOptions) - // const vendorId = session?.user.companyId - const vendorId = "17" + const session = await getServerSession(authOptions) + const vendorId = session?.user.companyId + // const vendorId = "17" const idAsNumber = Number(vendorId) const projects = await getVendorProjectsAndContracts(idAsNumber) diff --git a/app/[lng]/partners/(partners)/report/page.tsx b/app/[lng]/partners/(partners)/report/page.tsx new file mode 100644 index 00000000..1d61dc16 --- /dev/null +++ b/app/[lng]/partners/(partners)/report/page.tsx @@ -0,0 +1,17 @@ +// app/invalid-access/page.tsx + +export default function InvalidAccessPage() { + return ( +
+

부적절한 접근입니다

+

+ 협력업체(Vendor)가 EVCP 화면에 접속하거나
+ SHI 계정이 협력업체 화면에 접속하려고 시도하는 경우입니다. +

+

+ 접근 권한이 없으므로, 다른 화면으로 이동해 주세요. +

+
+ ); + } + \ No newline at end of file diff --git a/app/[lng]/partners/(partners)/vendor-data/form/[packageId]/[formId]/page.tsx b/app/[lng]/partners/(partners)/vendor-data/form/[packageId]/[formId]/page.tsx index 01f5b501..dc8df262 100644 --- a/app/[lng]/partners/(partners)/vendor-data/form/[packageId]/[formId]/page.tsx +++ b/app/[lng]/partners/(partners)/vendor-data/form/[packageId]/[formId]/page.tsx @@ -7,32 +7,41 @@ interface IndexPageProps { packageId: string; formId: string; }; + searchParams?: { + mode?: string; + }; } -export default async function FormPage({ params }: IndexPageProps) { +export default async function FormPage({ params, searchParams }: IndexPageProps) { // 1) 구조 분해 할당 const resolvedParams = await params; - - // 2) 구조 분해 할당 + + // 2) searchParams도 await 필요 + const resolvedSearchParams = await searchParams; + + // 3) 구조 분해 할당 const { lng, packageId, formId: formCode } = resolvedParams; - - // 2) 변환 + + // URL 쿼리 파라미터에서 mode 가져오기 (await 해서 사용) + const mode = resolvedSearchParams?.mode === "ENG" ? "ENG" : "IM"; // 기본값은 IM + + // 4) 변환 const packageIdAsNumber = Number(packageId); - - // 3) DB 조회 - const { columns, data } = await getFormData(formCode, packageIdAsNumber); - - // 4) formId 및 report temp file 조회 + + // 5) DB 조회 + const { columns, data, projectId } = await getFormData(formCode, packageIdAsNumber); + + // 6) formId 및 report temp file 조회 const { formId } = await getFormId(packageId, formCode); - - // 5) 예외 처리 + + // 7) 예외 처리 if (!columns) { return (

해당 폼의 메타 정보를 불러올 수 없습니다.

); } - - // 5) 렌더링 + + // 8) 렌더링 return (
); -} +} \ No newline at end of file diff --git a/app/[lng]/partners/(partners)/vendor-data/layout.tsx b/app/[lng]/partners/(partners)/vendor-data/layout.tsx index a8b51c52..29a720de 100644 --- a/app/[lng]/partners/(partners)/vendor-data/layout.tsx +++ b/app/[lng]/partners/(partners)/vendor-data/layout.tsx @@ -4,6 +4,8 @@ import { cookies } from "next/headers" import { Shell } from "@/components/shell" import { getVendorProjectsAndContracts } from "@/lib/vendor-data/services" import { VendorDataContainer } from "@/components/vendor-data/vendor-data-container" +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { getServerSession } from "next-auth"; // Layout 컴포넌트는 서버 컴포넌트입니다 export default async function VendorDataLayout({ @@ -11,9 +13,9 @@ export default async function VendorDataLayout({ }: { children: React.ReactNode }) { - // const session = await getServerSession(authOptions) - // const vendorId = session?.user.companyId - const vendorId = "17" + const session = await getServerSession(authOptions) + const vendorId = session?.user.companyId + // const vendorId = "17" const idAsNumber = Number(vendorId) // 프로젝트 데이터 가져오기 diff --git a/app/[lng]/partners/(partners)/vendor-data/page.tsx b/app/[lng]/partners/(partners)/vendor-data/page.tsx index 3eead226..afc3932c 100644 --- a/app/[lng]/partners/(partners)/vendor-data/page.tsx +++ b/app/[lng]/partners/(partners)/vendor-data/page.tsx @@ -6,7 +6,7 @@ export default async function IndexPage() { return (
-

벤더 데이터 대시보드

+

협력업체 데이터 대시보드

왼쪽 사이드바에서 패키지를 선택하여 태그를 관리하세요.

diff --git a/app/[lng]/partners/pq/page.tsx b/app/[lng]/partners/pq/page.tsx index 08faeebb..71741c6c 100644 --- a/app/[lng]/partners/pq/page.tsx +++ b/app/[lng]/partners/pq/page.tsx @@ -14,28 +14,30 @@ export default async function PQInputPage({ }) { // Opt out of caching for this route noStore() - + // 세션 const session = await getServerSession(authOptions) - // 예: 세션에서 vendorId 가져오기 - // const vendorId = session?.user.companyId - const vendorId = 17 // 임시 + // 세션에서 vendorId 가져오기 + const vendorId = session?.user.companyId + // const vendorId = 17 // 임시 const idAsNumber = Number(vendorId) - // 서버에서는 모든 데이터를 가져오고, 프로젝트 필터링은 클라이언트에서 진행 + // 프로젝트 목록 가져오기 const projectPQs = await getPQProjectsByVendorId(idAsNumber) - // 두 가지 방법으로 수정할 수 있습니다: - - // 방법 1: 먼저 allPQData 데이터를 projectId 없이 가져오기 - const allPQData = await getPQDataByVendorId(idAsNumber, undefined) + // searchParams에서 projectId 파싱 + const projectIdParam = searchParams.projectId + const projectId = projectIdParam ? parseInt(projectIdParam, 10) : undefined - // 방법 2: rawProjectId를 클라이언트로 전달하고, 클라이언트가 필터링을 처리 + // 현재 선택된 프로젝트를 위한 PQ 데이터 가져오기 + const selectedProjectPQData = projectId + ? await getPQDataByVendorId(idAsNumber, projectId) + : await getPQDataByVendorId(idAsNumber, undefined) - // 클라이언트 컴포넌트로 데이터와 원시 searchParams 전달 + // 클라이언트 컴포넌트로 데이터 전달 return ( + + + + success + + +`; + + return new NextResponse(soapResponse, { + headers: { + 'Content-Type': 'application/xml', + }, + }); + } catch (error) { + console.error('SOAP Error:', error); + + // 에러 응답 + const errorResponse = ` + + + + soap:Server + ${error.message} + + +`; + + return new NextResponse(errorResponse, { + status: 500, + headers: { + 'Content-Type': 'application/xml', + }, + }); + } +} + +// SOAP 메시지 파싱 함수 +function parseSoapMessage(soapMessage) { + // XML 파싱 로직 구현 + // 라이브러리 사용 예: fast-xml-parser, xml2js 등 + // 실제 구현은 SAP 메시지 형식에 따라 달라짐 + return { /* 파싱된 데이터 */ }; +} + +// DB 저장 함수 +async function saveToDatabase(data) { + // 데이터베이스 저장 로직 + // Prisma, Mongoose 등 사용 +} \ No newline at end of file diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index cd91774c..5e4da7ed 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -8,7 +8,7 @@ import { JWT } from "next-auth/jwt" import CredentialsProvider from 'next-auth/providers/credentials' -import { verifyExternalCredentials, verifyOtp } from '@/lib/users/verifyOtp' +import { verifyExternalCredentials, verifyOtp, verifyOtpTemp } from '@/lib/users/verifyOtp' // 1) 모듈 보강 선언 declare module "next-auth" { @@ -55,7 +55,7 @@ export const authOptions: NextAuthOptions = { const { email, code } = credentials ?? {} // OTP 검증 - const user = await verifyOtp(email ?? '', code ?? '') + const user = await verifyOtpTemp(email ?? '') if (!user) { return null } @@ -70,6 +70,31 @@ export const authOptions: NextAuthOptions = { } }, }), + // CredentialsProvider({ + // name: 'Credentials', + // credentials: { + // email: { label: 'Email', type: 'text' }, + // code: { label: 'OTP code', type: 'text' }, + // }, + // async authorize(credentials, req) { + // const { email, code } = credentials ?? {} + + // // OTP 검증 + // const user = await verifyOtp(email ?? '', code ?? '') + // if (!user) { + // return null + // } + + // return { + // id: String(user.id ?? email ?? "dts"), + // email: user.email, + // imageUrl: user.imageUrl ?? null, + // name: user.name, // DB에서 가져온 실제 이름 + // companyId: user.companyId, // DB에서 가져온 실제 이름 + // domain: user.domain, // DB에서 가져온 실제 이름 + // } + // }, + // }), // 새로 추가할 ID/비밀번호 provider CredentialsProvider({ id: 'credentials-password', @@ -115,6 +140,7 @@ export const authOptions: NextAuthOptions = { session: { strategy: 'jwt', }, + callbacks: { // (4) 콜백에서 token, user, session 등의 타입을 좀 더 명시해주고 싶다면 아래처럼 destructuring에 제네릭/타입 지정 async jwt({ token, user }: { token: JWT; user?: User }) { @@ -141,6 +167,20 @@ export const authOptions: NextAuthOptions = { } return session }, + // redirect 콜백 추가 + async redirect({ url, baseUrl }) { + // 상대 경로인 경우 baseUrl을 기준으로 함 + if (url.startsWith("/")) { + return `${baseUrl}${url}`; + } + // 같은 도메인인 경우 그대로 사용 + else if (new URL(url).origin === baseUrl) { + return url; + } + // 그 외에는 baseUrl로 리다이렉트 + return baseUrl; + } + }, } diff --git a/app/api/basic-contract/status/route.ts b/app/api/basic-contract/status/route.ts new file mode 100644 index 00000000..f543accd --- /dev/null +++ b/app/api/basic-contract/status/route.ts @@ -0,0 +1,141 @@ +// /app/api/basic-contract/status/route.ts + +import { NextRequest, NextResponse } from "next/server"; +import db from "@/db/db"; +import { basicContract, vendors, basicContractTemplates } from "@/db/schema"; +import { eq, and, inArray, desc } from "drizzle-orm"; +import { differenceInDays } from "date-fns"; + +/** + * 계약 요청 상태 확인 API + */ +export async function POST(request: NextRequest) { + try { + // 요청 본문 파싱 + const body = await request.json(); + const { vendorIds, templateIds } = body; + + // 필수 파라미터 확인 + if (!vendorIds || !templateIds || !Array.isArray(vendorIds) || !Array.isArray(templateIds)) { + return NextResponse.json( + { success: false, error: "유효하지 않은 요청 형식입니다." }, + { status: 400 } + ); + } + + // 각 협력업체-템플릿 조합에 대한 최신 계약 요청 상태 확인 + const requests = await db + .select({ + vendorId: basicContract.vendorId, + templateId: basicContract.templateId, + status: basicContract.status, + createdAt: basicContract.createdAt, + updatedAt: basicContract.updatedAt, + }) + .from(basicContract) + .where( + and( + inArray(basicContract.vendorId, vendorIds), + inArray(basicContract.templateId, templateIds) + ) + ) + .orderBy(desc(basicContract.createdAt)); + + // 협력업체 정보 가져오기 + const vendorData = await db + .select({ + id: vendors.id, + vendorName: vendors.vendorName, + }) + .from(vendors) + .where(inArray(vendors.id, vendorIds)); + + // 템플릿 정보 가져오기 + const templateData = await db + .select({ + id: basicContractTemplates.id, + templateName: basicContractTemplates.templateName, + updatedAt: basicContractTemplates.updatedAt, + }) + .from(basicContractTemplates) + .where(inArray(basicContractTemplates.id, templateIds)); + + // 데이터 가공 - 협력업체별, 템플릿별로 상태 매핑 + const vendorMap = new Map(vendorData.map(v => [v.id, v])); + const templateMap = new Map(templateData.map(t => [t.id, t])); + + const uniqueRequests = new Map(); + + // 각 협력업체-템플릿 조합에 대해 가장 최근 요청만 사용 + requests.forEach(req => { + const key = `${req.vendorId}-${req.templateId}`; + if (!uniqueRequests.has(key)) { + uniqueRequests.set(key, req); + } + }); + + // 상태 정보 생성 + const statusData = []; + + // 요청 만료 기준 - 30일 + const EXPIRATION_DAYS = 30; + + // 모든 협력업체-템플릿 조합에 대해 상태 확인 + vendorIds.forEach(vendorId => { + templateIds.forEach(templateId => { + const key = `${vendorId}-${templateId}`; + const request = uniqueRequests.get(key); + const vendor = vendorMap.get(vendorId); + const template = templateMap.get(templateId); + + if (!vendor || !template) return; + + let status = "NONE"; // 기본 상태: 요청 없음 + let createdAt = new Date(); + let isExpired = false; + let isUpdated = false; + + if (request) { + status = request.status; + createdAt = request.createdAt; + + // 요청이 오래되었는지 확인 (PENDING 상태일 때만 적용) + if (status === "PENDING") { + isExpired = differenceInDays(new Date(), createdAt) > EXPIRATION_DAYS; + } + + // 요청 이후 템플릿이 업데이트되었는지 확인 + if (template.updatedAt && request.createdAt) { + isUpdated = template.updatedAt > request.createdAt; + } + } + + statusData.push({ + vendorId, + vendorName: vendor.vendorName, + templateId, + templateName: template.templateName, + status, + createdAt, + isExpired, + isUpdated, + }); + }); + }); + + // 성공 응답 반환 + return NextResponse.json({ success: true, data: statusData }); + + } catch (error) { + console.error("계약 상태 확인 중 오류:", error); + + // 오류 응답 반환 + return NextResponse.json( + { + success: false, + error: "계약 상태 확인 중 오류가 발생했습니다." + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/cron/form-tags/start/route.ts b/app/api/cron/form-tags/start/route.ts new file mode 100644 index 00000000..6a029c4c --- /dev/null +++ b/app/api/cron/form-tags/start/route.ts @@ -0,0 +1,136 @@ +// app/api/cron/tags/start/route.ts +import { NextRequest } from 'next/server'; +import { v4 as uuidv4 } from 'uuid'; +import { revalidateTag } from 'next/cache'; + +// 동기화 작업의 상태를 저장할 Map +// 실제 프로덕션에서는 Redis 또는 DB에 저장하는 것이 좋습니다 +const syncJobs = new Map(); + +export async function POST(request: NextRequest) { + try { + // 요청 데이터 가져오기 + let projectCode: string | undefined; + let formCode: string | undefined; + let packageId: number | undefined; + + + const body = await request.json(); + projectCode = body.projectCode; + formCode = body.formCode; + packageId = body.contractItemId; + + + // 고유 ID 생성 + const syncId = uuidv4(); + + // 작업 상태 초기화 + syncJobs.set(syncId, { + status: 'queued', + startTime: new Date(), + formCode, + projectCode, + packageId + + }); + + // 비동기 작업 시작 (백그라운드에서 실행) + processTagImport(syncId).catch(error => { + console.error('Background tag import job failed:', error); + syncJobs.set(syncId, { + ...syncJobs.get(syncId)!, + status: 'failed', + endTime: new Date(), + error: error.message || 'Unknown error occurred' + }); + }); + + // 즉시 응답 반환 (작업 ID 포함) + return Response.json({ + success: true, + message: 'Tag import job started', + syncId + }, { status: 200 }); + + } catch (error: any) { + console.error('Failed to start tag import job:', error); + return Response.json({ + success: false, + error: error.message || 'Failed to start tag import job' + }, { status: 500 }); + } +} + +// 백그라운드에서 실행되는 태그 가져오기 작업 +async function processTagImport(syncId: string) { + try { + const jobInfo = syncJobs.get(syncId)!; + const formCode = jobInfo.formCode; + const projectCode = jobInfo.projectCode; + const packageId = jobInfo.packageId || 0; + + // 상태 업데이트: 처리 중 + syncJobs.set(syncId, { + ...jobInfo, + status: 'processing', + progress: 0, + }); + + if (!formCode || !projectCode ) { + throw new Error('formCode,projectCode is required'); + } + + // 여기서 실제 태그 가져오기 로직 import + const { importTagsFromSEDP } = await import('@/lib/sedp/get-form-tags'); + + // 진행 상황 업데이트를 위한 콜백 함수 + const updateProgress = (progress: number) => { + syncJobs.set(syncId, { + ...syncJobs.get(syncId)!, + progress + }); + }; + + // 실제 태그 가져오기 실행 + const result = await importTagsFromSEDP(formCode, projectCode, packageId, updateProgress); + + // 명시적으로 캐시 무효화 + revalidateTag(`forms-${packageId}`); + + // 상태 업데이트: 완료 + syncJobs.set(syncId, { + ...syncJobs.get(syncId)!, + status: 'completed', + endTime: new Date(), + result, + progress: 100, + }); + + return result; + } catch (error: any) { + // 에러 발생 시 상태 업데이트 + syncJobs.set(syncId, { + ...syncJobs.get(syncId)!, + status: 'failed', + endTime: new Date(), + error: error.message || 'Unknown error occurred', + }); + + throw error; // 에러 다시 던지기 + } +} + +// 서버 메모리에 저장된 작업 상태 접근 함수 (다른 API에서 사용) +export function getSyncJobStatus(id: string) { + return syncJobs.get(id); +} \ No newline at end of file diff --git a/app/api/cron/form-tags/status/route.ts b/app/api/cron/form-tags/status/route.ts new file mode 100644 index 00000000..9d288f52 --- /dev/null +++ b/app/api/cron/form-tags/status/route.ts @@ -0,0 +1,46 @@ +// app/api/cron/tags/status/route.ts +import { NextRequest } from 'next/server'; +import { getSyncJobStatus } from '../start/route'; + +export async function GET(request: NextRequest) { + try { + // URL에서 작업 ID 가져오기 + const searchParams = request.nextUrl.searchParams; + const syncId = searchParams.get('id'); + + if (!syncId) { + return Response.json({ + success: false, + error: 'Missing sync ID parameter' + }, { status: 400 }); + } + + // 작업 상태 조회 + const jobStatus = getSyncJobStatus(syncId); + + if (!jobStatus) { + return Response.json({ + success: false, + error: 'Sync job not found' + }, { status: 404 }); + } + + // 작업 상태 반환 + return Response.json({ + success: true, + status: jobStatus.status, + startTime: jobStatus.startTime, + endTime: jobStatus.endTime, + progress: jobStatus.progress, + result: jobStatus.result, + error: jobStatus.error + }, { status: 200 }); + + } catch (error: any) { + console.error('Error retrieving tag import status:', error); + return Response.json({ + success: false, + error: error.message || 'Failed to retrieve tag import status' + }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/cron/forms/route.ts b/app/api/cron/forms/route.ts index f58c146b..abe6753a 100644 --- a/app/api/cron/forms/route.ts +++ b/app/api/cron/forms/route.ts @@ -1,20 +1,65 @@ -// src/app/api/cron/tag-form-mappings/route.ts +// app/api/cron/forms/route.ts import { syncTagFormMappings } from '@/lib/sedp/sync-form'; import { NextRequest } from 'next/server'; +import { revalidateTag } from 'next/cache'; + +// TypeScript에서 global 객체를 확장하기 위한 type 선언 +declare global { + var pendingTasks: Set>; +} + +// 글로벌 태스크 집합 초기화 (서버가 시작될 때만 한 번 실행됨) +if (!global.pendingTasks) { + global.pendingTasks = new Set>(); +} + +// 이 함수는 비동기 작업을 더 안전하게 처리하기 위한 도우미 함수입니다 +function runBackgroundTask(task: Promise, taskName: string): Promise { + // 작업을 추적 세트에 추가 + global.pendingTasks.add(task); + + // finally 블록을 사용하여 작업이 완료될 때 세트에서 제거 + task + .then(result => { + console.log(`Background task '${taskName}' completed successfully`); + return result; + }) + .catch(error => { + console.error(`Background task '${taskName}' failed:`, error); + }) + .finally(() => { + global.pendingTasks.delete(task); + }); + + return task; +} export async function GET(request: NextRequest) { try { console.log('태그 폼 매핑 동기화 API 호출됨:', new Date().toISOString()); - // syncTagFormMappings 함수 호출 - const result = await syncTagFormMappings(); + // 비동기 작업을 생성하고 전역 객체에 저장 + const syncTask = syncTagFormMappings() + .then(result => { + // 작업이 완료되면 캐시 무효화 + revalidateTag('form-lists'); + return result; + }); + + // 백그라운드에서 작업이 계속 실행되도록 보장 + runBackgroundTask(syncTask, 'form-sync'); - // 성공 시 결과와 함께 200 OK 반환 - return Response.json({ success: true, result }, { status: 200 }); + // 먼저 상태를 반환하고, 그 동안 백그라운드에서 작업 계속 + return new Response( + JSON.stringify({ + success: true, + message: 'Form sync started in background. This may take a while.' + }), + { status: 202, headers: { 'Content-Type': 'application/json' } } + ); } catch (error: any) { console.error('태그 폼 매핑 동기화 API 에러:', error); - // 에러 시에는 message를 담아 500 반환 const message = error.message || 'Something went wrong'; return Response.json({ success: false, error: message }, { status: 500 }); } diff --git a/app/api/cron/forms/start/route.ts b/app/api/cron/forms/start/route.ts new file mode 100644 index 00000000..a99c4677 --- /dev/null +++ b/app/api/cron/forms/start/route.ts @@ -0,0 +1,100 @@ +// app/api/cron/forms/start/route.ts +import { NextRequest } from 'next/server'; +import { v4 as uuidv4 } from 'uuid'; +import { revalidateTag } from 'next/cache'; + +// 동기화 작업의 상태를 저장할 Map +// 실제 프로덕션에서는 Redis 또는 DB에 저장하는 것이 좋습니다 +const syncJobs = new Map(); + +export async function POST(request: NextRequest) { + try { + // 고유 ID 생성 + const syncId = uuidv4(); + + // 작업 상태 초기화 + syncJobs.set(syncId, { + status: 'queued', + startTime: new Date(), + }); + + // 비동기 작업 시작 (백그라운드에서 실행) + processSyncJob(syncId).catch(error => { + console.error('Background sync job failed:', error); + syncJobs.set(syncId, { + ...syncJobs.get(syncId)!, + status: 'failed', + endTime: new Date(), + error: error.message || 'Unknown error occurred' + }); + }); + + // 즉시 응답 반환 (작업 ID 포함) + return Response.json({ + success: true, + message: 'Form sync job started', + syncId + }, { status: 200 }); + + } catch (error: any) { + console.error('Failed to start sync job:', error); + return Response.json({ + success: false, + error: error.message || 'Failed to start sync job' + }, { status: 500 }); + } +} + +// 백그라운드에서 실행되는 동기화 작업 +async function processSyncJob(syncId: string) { + try { + // 상태 업데이트: 처리 중 + syncJobs.set(syncId, { + ...syncJobs.get(syncId)!, + status: 'processing', + progress: 0, + }); + + // 여기서 실제 동기화 로직 가져오기 + const { syncTagFormMappings } = await import('@/lib/sedp/sync-form'); + + // 실제 동기화 작업 실행 + const result = await syncTagFormMappings(); + + // 명시적으로 캐시 무효화 (동적 import 대신 상단에서 import) + revalidateTag('form-lists'); + + // 상태 업데이트: 완료 + syncJobs.set(syncId, { + ...syncJobs.get(syncId)!, + status: 'completed', + endTime: new Date(), + result, + progress: 100, + }); + + return result; + } catch (error: any) { + // 에러 발생 시 상태 업데이트 + syncJobs.set(syncId, { + ...syncJobs.get(syncId)!, + status: 'failed', + endTime: new Date(), + error: error.message || 'Unknown error occurred', + }); + + throw error; // 에러 다시 던지기 + } +} + +// 서버 메모리에 저장된 작업 상태 접근 함수 (다른 API에서 사용) +export function getSyncJobStatus(id: string) { + return syncJobs.get(id); +} \ No newline at end of file diff --git a/app/api/cron/forms/status/route.ts b/app/api/cron/forms/status/route.ts new file mode 100644 index 00000000..c0e27b2e --- /dev/null +++ b/app/api/cron/forms/status/route.ts @@ -0,0 +1,46 @@ +// app/api/cron/forms/status/route.ts +import { NextRequest } from 'next/server'; +import { getSyncJobStatus } from '../start/route'; + +export async function GET(request: NextRequest) { + try { + // URL에서 작업 ID 가져오기 + const searchParams = request.nextUrl.searchParams; + const syncId = searchParams.get('id'); + + if (!syncId) { + return Response.json({ + success: false, + error: 'Missing sync ID parameter' + }, { status: 400 }); + } + + // 작업 상태 조회 + const jobStatus = getSyncJobStatus(syncId); + + if (!jobStatus) { + return Response.json({ + success: false, + error: 'Sync job not found' + }, { status: 404 }); + } + + // 작업 상태 반환 + return Response.json({ + success: true, + status: jobStatus.status, + startTime: jobStatus.startTime, + endTime: jobStatus.endTime, + progress: jobStatus.progress, + result: jobStatus.result, + error: jobStatus.error + }, { status: 200 }); + + } catch (error: any) { + console.error('Error retrieving sync status:', error); + return Response.json({ + success: false, + error: error.message || 'Failed to retrieve sync status' + }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/cron/object-classes/route.ts b/app/api/cron/object-classes/route.ts index 9a574b1b..6743da70 100644 --- a/app/api/cron/object-classes/route.ts +++ b/app/api/cron/object-classes/route.ts @@ -1,6 +1,7 @@ // src/app/api/cron/object-classes/route.ts import { syncObjectClasses } from '@/lib/sedp/sync-object-class'; import { NextRequest } from 'next/server'; +import { revalidateTag } from 'next/cache'; export async function GET(request: NextRequest) { try { @@ -8,7 +9,8 @@ export async function GET(request: NextRequest) { // syncObjectClasses 함수 호출 const result = await syncObjectClasses(); - + revalidateTag("equip-class") + // 성공 시 결과와 함께 200 OK 반환 return Response.json({ success: true, result }, { status: 200 }); } catch (error: any) { diff --git a/app/api/cron/projects/route.ts b/app/api/cron/projects/route.ts index d8e6af51..12c89bdb 100644 --- a/app/api/cron/projects/route.ts +++ b/app/api/cron/projects/route.ts @@ -1,6 +1,7 @@ // src/app/api/cron/projects/route.ts import { syncProjects } from '@/lib/sedp/sync-projects'; import { NextRequest } from 'next/server'; +import { revalidateTag } from 'next/cache'; export async function GET(request: NextRequest) { try { @@ -8,7 +9,9 @@ export async function GET(request: NextRequest) { // syncProjects 함수 호출 const result = await syncProjects(); - + + revalidateTag('project-lists') + // 성공 시 결과와 함께 200 OK 반환 return Response.json({ success: true, result }, { status: 200 }); } catch (error: any) { diff --git a/app/api/cron/tag-types/route.ts b/app/api/cron/tag-types/route.ts index 35145984..43644833 100644 --- a/app/api/cron/tag-types/route.ts +++ b/app/api/cron/tag-types/route.ts @@ -1,5 +1,6 @@ import { syncTagSubfields } from '@/lib/sedp/sync-tag-types'; import { NextRequest } from 'next/server'; +import { revalidateTag } from 'next/cache'; export async function GET(request: NextRequest) { try { @@ -7,6 +8,7 @@ export async function GET(request: NextRequest) { // syncTagSubfields 함수 호출 const result = await syncTagSubfields(); + revalidateTag('tag-numbering') // 성공 시 결과와 함께 200 OK 반환 return Response.json({ success: true, result }, { status: 200 }); diff --git a/app/api/cron/tags/start/route.ts b/app/api/cron/tags/start/route.ts new file mode 100644 index 00000000..b506b9a3 --- /dev/null +++ b/app/api/cron/tags/start/route.ts @@ -0,0 +1,133 @@ +// app/api/cron/tags/start/route.ts +import { NextRequest } from 'next/server'; +import { v4 as uuidv4 } from 'uuid'; +import { revalidateTag } from 'next/cache'; + +// 동기화 작업의 상태를 저장할 Map +// 실제 프로덕션에서는 Redis 또는 DB에 저장하는 것이 좋습니다 +const syncJobs = new Map(); + +export async function POST(request: NextRequest) { + try { + // 요청 데이터 가져오기 + let packageId: number | undefined; + + try { + const body = await request.json(); + packageId = body.packageId; + } catch (error) { + // 요청 본문이 없거나 JSON이 아닌 경우, URL 파라미터 확인 + const searchParams = request.nextUrl.searchParams; + const packageIdParam = searchParams.get('packageId'); + if (packageIdParam) { + packageId = parseInt(packageIdParam, 10); + } + } + + // 고유 ID 생성 + const syncId = uuidv4(); + + // 작업 상태 초기화 + syncJobs.set(syncId, { + status: 'queued', + startTime: new Date(), + packageId + }); + + // 비동기 작업 시작 (백그라운드에서 실행) + processTagImport(syncId).catch(error => { + console.error('Background tag import job failed:', error); + syncJobs.set(syncId, { + ...syncJobs.get(syncId)!, + status: 'failed', + endTime: new Date(), + error: error.message || 'Unknown error occurred' + }); + }); + + // 즉시 응답 반환 (작업 ID 포함) + return Response.json({ + success: true, + message: 'Tag import job started', + syncId + }, { status: 200 }); + + } catch (error: any) { + console.error('Failed to start tag import job:', error); + return Response.json({ + success: false, + error: error.message || 'Failed to start tag import job' + }, { status: 500 }); + } +} + +// 백그라운드에서 실행되는 태그 가져오기 작업 +async function processTagImport(syncId: string) { + try { + const jobInfo = syncJobs.get(syncId)!; + const packageId = jobInfo.packageId; + + // 상태 업데이트: 처리 중 + syncJobs.set(syncId, { + ...jobInfo, + status: 'processing', + progress: 0, + }); + + if (!packageId) { + throw new Error('Package ID is required'); + } + + // 여기서 실제 태그 가져오기 로직 import + const { importTagsFromSEDP } = await import('@/lib/sedp/get-tags'); + + // 진행 상황 업데이트를 위한 콜백 함수 + const updateProgress = (progress: number) => { + syncJobs.set(syncId, { + ...syncJobs.get(syncId)!, + progress + }); + }; + + // 실제 태그 가져오기 실행 + const result = await importTagsFromSEDP(packageId, updateProgress); + + // 명시적으로 캐시 무효화 + revalidateTag(`tags-${packageId}`); + revalidateTag(`forms-${packageId}`); + + // 상태 업데이트: 완료 + syncJobs.set(syncId, { + ...syncJobs.get(syncId)!, + status: 'completed', + endTime: new Date(), + result, + progress: 100, + }); + + return result; + } catch (error: any) { + // 에러 발생 시 상태 업데이트 + syncJobs.set(syncId, { + ...syncJobs.get(syncId)!, + status: 'failed', + endTime: new Date(), + error: error.message || 'Unknown error occurred', + }); + + throw error; // 에러 다시 던지기 + } +} + +// 서버 메모리에 저장된 작업 상태 접근 함수 (다른 API에서 사용) +export function getSyncJobStatus(id: string) { + return syncJobs.get(id); +} \ No newline at end of file diff --git a/app/api/cron/tags/status/route.ts b/app/api/cron/tags/status/route.ts new file mode 100644 index 00000000..9d288f52 --- /dev/null +++ b/app/api/cron/tags/status/route.ts @@ -0,0 +1,46 @@ +// app/api/cron/tags/status/route.ts +import { NextRequest } from 'next/server'; +import { getSyncJobStatus } from '../start/route'; + +export async function GET(request: NextRequest) { + try { + // URL에서 작업 ID 가져오기 + const searchParams = request.nextUrl.searchParams; + const syncId = searchParams.get('id'); + + if (!syncId) { + return Response.json({ + success: false, + error: 'Missing sync ID parameter' + }, { status: 400 }); + } + + // 작업 상태 조회 + const jobStatus = getSyncJobStatus(syncId); + + if (!jobStatus) { + return Response.json({ + success: false, + error: 'Sync job not found' + }, { status: 404 }); + } + + // 작업 상태 반환 + return Response.json({ + success: true, + status: jobStatus.status, + startTime: jobStatus.startTime, + endTime: jobStatus.endTime, + progress: jobStatus.progress, + result: jobStatus.result, + error: jobStatus.error + }, { status: 200 }); + + } catch (error: any) { + console.error('Error retrieving tag import status:', error); + return Response.json({ + success: false, + error: error.message || 'Failed to retrieve tag import status' + }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/upload/basicContract/chunk/route.ts b/app/api/upload/basicContract/chunk/route.ts new file mode 100644 index 00000000..7100988b --- /dev/null +++ b/app/api/upload/basicContract/chunk/route.ts @@ -0,0 +1,71 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { mkdir, writeFile, appendFile } from 'fs/promises'; +import path from 'path'; +import crypto from 'crypto'; + +export async function POST(request: NextRequest) { + try { + const formData = await request.formData(); + + const chunk = formData.get('chunk') as File; + const filename = formData.get('filename') as string; + const chunkIndex = parseInt(formData.get('chunkIndex') as string); + const totalChunks = parseInt(formData.get('totalChunks') as string); + const fileId = formData.get('fileId') as string; + + if (!chunk || !filename || isNaN(chunkIndex) || isNaN(totalChunks) || !fileId) { + return NextResponse.json({ success: false, error: '필수 매개변수가 누락되었습니다' }, { status: 400 }); + } + + // 임시 디렉토리 생성 + const tempDir = path.join(process.cwd(), 'temp', fileId); + await mkdir(tempDir, { recursive: true }); + + // 청크 파일 저장 + const chunkPath = path.join(tempDir, `chunk-${chunkIndex}`); + const buffer = Buffer.from(await chunk.arrayBuffer()); + await writeFile(chunkPath, buffer); + + // 마지막 청크인 경우 모든 청크를 합쳐 최종 파일 생성 + if (chunkIndex === totalChunks - 1) { + const uploadDir = path.join(process.cwd(), "public", "basicContract", "template"); + await mkdir(uploadDir, { recursive: true }); + + // 파일명 생성 + const timestamp = Date.now(); + const randomHash = crypto.createHash('md5') + .update(`${filename}-${timestamp}`) + .digest('hex') + .substring(0, 8); + const hashedFileName = `${timestamp}-${randomHash}${path.extname(filename)}`; + const finalPath = path.join(uploadDir, hashedFileName); + + // 모든 청크 병합 + await writeFile(finalPath, Buffer.alloc(0)); // 빈 파일 생성 + for (let i = 0; i < totalChunks; i++) { + const chunkData = await require('fs/promises').readFile(path.join(tempDir, `chunk-${i}`)); + await appendFile(finalPath, chunkData); + } + + // 임시 파일 정리 (비동기로 처리) + require('fs/promises').rm(tempDir, { recursive: true, force: true }) + .catch((e: unknown) => console.error('청크 정리 오류:', e)); + + return NextResponse.json({ + success: true, + fileName: filename, + filePath: `/basicContract/template/${hashedFileName}` + }); + } + + return NextResponse.json({ + success: true, + chunkIndex, + message: `청크 ${chunkIndex + 1}/${totalChunks} 업로드 완료` + }); + + } catch (error) { + console.error('청크 업로드 오류:', error); + return NextResponse.json({ success: false, error: '서버 오류' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/upload/basicContract/complete/route.ts b/app/api/upload/basicContract/complete/route.ts new file mode 100644 index 00000000..6398c5eb --- /dev/null +++ b/app/api/upload/basicContract/complete/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createBasicContractTemplate } from '@/lib/basic-contract/service'; +import { revalidatePath ,revalidateTag} from 'next/cache'; + +export async function POST(request: NextRequest) { + try { + const { templateName,validityPeriod, status, fileName, filePath } = await request.json(); + + if (!templateName || !fileName || !filePath) { + return NextResponse.json({ success: false, error: '필수 정보가 누락되었습니다' }, { status: 400 }); + } + + // DB에 저장 + const { data, error } = await createBasicContractTemplate({ + templateName, + validityPeriod, + status, + fileName, + filePath + }); + + + revalidatePath('/evcp/basic-contract-templates'); + revalidatePath('/'); // 루트 경로 무효화도 시도 + revalidateTag("basic-contract-templates"); + + if (error) { + throw new Error(error); + } + + return NextResponse.json({ success: true, data }); + + } catch (error) { + console.error('템플릿 저장 오류:', error); + return NextResponse.json({ success: false, error: '서버 오류' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/upload/signed-contract/route.ts b/app/api/upload/signed-contract/route.ts new file mode 100644 index 00000000..f26e20ba --- /dev/null +++ b/app/api/upload/signed-contract/route.ts @@ -0,0 +1,57 @@ +// app/api/upload/signed-contract/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import fs from 'fs/promises'; +import path from 'path'; +import { v4 as uuidv4 } from 'uuid'; +import db from "@/db/db"; +import { basicContract } from '@/db/schema'; +import { eq } from 'drizzle-orm'; +import { revalidateTag } from 'next/cache'; + +export async function POST(request: NextRequest) { + try { + const formData = await request.formData(); + const file = formData.get('file') as File; + const tableRowId = parseInt(formData.get('tableRowId') as string); + const templateName = formData.get('templateName') as string; + + if (!file || !tableRowId || !templateName) { + return NextResponse.json({ result: false, error: '필수 파라미터가 누락되었습니다.' }, { status: 400 }); + } + + const originalName = `${tableRowId}_${templateName}`; + const ext = path.extname(originalName); + const uniqueName = uuidv4() + ext; + + const publicDir = path.join(process.cwd(), "public", "basicContract"); + const relativePath = `/basicContract/${uniqueName}`; + const absolutePath = path.join(publicDir, uniqueName); + const buffer = Buffer.from(await file.arrayBuffer()); + + await fs.mkdir(publicDir, { recursive: true }); + await fs.writeFile(absolutePath, buffer); + + await db.transaction(async (tx) => { + await tx + .update(basicContract) + .set({ + status: "COMPLETED", + fileName: originalName, + filePath: relativePath, + updatedAt: new Date(), + completedAt: new Date() + }) + .where(eq(basicContract.id, tableRowId)); + }); + + // 캐시 무효화 + revalidateTag("basic-contract-requests"); + revalidateTag("basicContractView-vendor"); + + return NextResponse.json({ result: true }); + } catch (error) { + console.error('서명된 계약서 저장 오류:', error); + const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류"; + return NextResponse.json({ result: false, error: errorMessage }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/vendors/attachments/download-all/route.ts b/app/api/vendors/attachments/download-all/route.ts new file mode 100644 index 00000000..23f85786 --- /dev/null +++ b/app/api/vendors/attachments/download-all/route.ts @@ -0,0 +1,108 @@ +// /app/api/vendors/attachments/download-all/route.js +import { NextResponse,NextRequest } from 'next/server'; +import fs from 'fs'; +import path from 'path'; +import JSZip from 'jszip'; +import db from '@/db/db'; + +import { eq } from 'drizzle-orm'; +import { vendorAttachments, vendors } from '@/db/schema'; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const vendorId = searchParams.get('vendorId'); + + if (!vendorId) { + return NextResponse.json( + { error: "필수 파라미터가 누락되었습니다." }, + { status: 400 } + ); + } + + // 협력업체 정보 조회 + const vendor = await db.query.vendors.findFirst({ + where: eq(vendors.id, parseInt(vendorId, 10)) + }); + + if (!vendor) { + return NextResponse.json( + { error: `협력업체 정보를 찾을 수 없습니다. (ID: ${vendorId})` }, + { status: 404 } + ); + } + + // 첨부파일 조회 + const attachments = await db.select() + .from(vendorAttachments) + .where(eq(vendorAttachments.vendorId, parseInt(vendorId, 10))); + + if (!attachments.length) { + return NextResponse.json( + { error: '다운로드할 첨부파일이 없습니다.' }, + { status: 404 } + ); + } + + // 업로드 기본 경로 + const basePath = process.env.UPLOAD_DIR || path.join(process.cwd(), 'public'); + + // ZIP 생성 + const zip = new JSZip(); + + // 파일 읽기 및 ZIP에 추가 + await Promise.all( + attachments.map(async (attachment) => { + const filePath = path.join(basePath, attachment.filePath); + + try { + // 파일 존재 확인 + try { + await fs.promises.access(filePath, fs.constants.F_OK); + } catch (e) { + console.warn(`파일이 존재하지 않습니다: ${filePath}`); + return; // 파일이 없으면 건너뜀 + } + + // 파일 읽기 + const fileData = await fs.promises.readFile(filePath); + + // ZIP에 파일 추가 + zip.file(attachment.fileName, fileData); + } catch (error) { + console.warn(`파일을 처리할 수 없습니다: ${filePath}`, error); + // 오류가 있더라도 계속 진행 + } + }) + ); + + // ZIP 생성 + const zipContent = await zip.generateAsync({ + type: 'nodebuffer', + compression: 'DEFLATE', + compressionOptions: { level: 9 } + }); + + // 파일명 생성 + const fileName = `${vendor.vendorName || `vendor-${vendorId}`}-attachments.zip`; + + // 응답 헤더 설정 + const headers = new Headers(); + headers.set('Content-Disposition', `attachment; filename="${fileName}"`); + headers.set('Content-Type', 'application/zip'); + headers.set('Content-Length', zipContent.length.toString()); + + // ZIP 파일 데이터와 함께 응답 + return new Response(zipContent, { + status: 200, + headers + }); + + } catch (error) { + console.error('첨부파일 다운로드 오류:', error); + return NextResponse.json( + { error: "첨부파일 다운로드 준비 중 오류가 발생했습니다." }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/vendors/attachments/download/route.ts b/app/api/vendors/attachments/download/route.ts new file mode 100644 index 00000000..0151a699 --- /dev/null +++ b/app/api/vendors/attachments/download/route.ts @@ -0,0 +1,93 @@ +// /app/api/vendors/attachments/download/route.js (Next.js App Router 기준) +import { NextRequest, NextResponse } from 'next/server'; +import fs from 'fs'; +import path from 'path'; +import { eq } from 'drizzle-orm'; // 쿼리 빌더 +import { vendorAttachments } from '@/db/schema'; +import db from '@/db/db'; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const fileId = searchParams.get('id'); + const vendorId = searchParams.get('vendorId'); + + if (!fileId || !vendorId) { + return NextResponse.json( + { error: "필수 파라미터가 누락되었습니다." }, + { status: 400 } + ); + } + + // 첨부파일 정보 조회 + const attachment = await db.query.vendorAttachments.findFirst({ + where: eq(vendorAttachments.id, parseInt(fileId, 10)) + }); + + if (!attachment) { + return NextResponse.json( + { error: "파일을 찾을 수 없습니다." }, + { status: 404 } + ); + } + + // 파일 경로 구성 + const basePath = process.env.UPLOAD_DIR || path.join(process.cwd(), 'public'); + const filePath = path.join(basePath, attachment.filePath); + + // 파일 존재 확인 + try { + await fs.promises.access(filePath, fs.constants.F_OK); + } catch (e) { + return NextResponse.json( + { error: "파일이 서버에 존재하지 않습니다." }, + { status: 404 } + ); + } + + // 파일 데이터 읽기 + const fileBuffer = await fs.promises.readFile(filePath); + + + + // 파일 MIME 타입 추정 + let contentType = 'application/octet-stream'; + if (attachment.fileName) { + const ext = path.extname(attachment.fileName).toLowerCase(); + switch (ext) { + case '.pdf': contentType = 'application/pdf'; break; + case '.jpg': + case '.jpeg': contentType = 'image/jpeg'; break; + case '.png': contentType = 'image/png'; break; + case '.doc': contentType = 'application/msword'; break; + case '.docx': contentType = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; break; + // 필요에 따라 더 많은 타입 추가 + } + } + + // 응답 헤더 설정 + const headers = new Headers(); + + // 파일명에 non-ASCII 문자가 포함될 수 있으므로 인코딩 처리 + const encodedFileName = encodeURIComponent(attachment.fileName) + .replace(/['()]/g, escape) // 추가 이스케이프 필요한 문자들 + .replace(/\*/g, '%2A'); + + // RFC 5987에 따른 인코딩 방식 적용 + headers.set('Content-Disposition', `attachment; filename*=UTF-8''${encodedFileName}`); + headers.set('Content-Type', contentType); + headers.set('Content-Length', fileBuffer.length.toString()); + // 파일 데이터와 함께 응답 + return new Response(fileBuffer, { + status: 200, + headers + }); + + } catch (error) { + console.error('파일 다운로드 오류:', error); + return NextResponse.json( + { error: "파일 다운로드 중 오류가 발생했습니다." }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/vendors/erp/route.ts b/app/api/vendors/erp/route.ts index 0724eeeb..70573592 100644 --- a/app/api/vendors/erp/route.ts +++ b/app/api/vendors/erp/route.ts @@ -3,7 +3,7 @@ import { headers } from 'next/headers'; import { getErrorMessage } from '@/lib/handle-error'; /** - * 기간계 시스템에 벤더 정보를 전송하는 API 엔드포인트 + * 기간계 시스템에 협력업체 정보를 전송하는 API 엔드포인트 * 서버 액션 내부에서 호출됨 */ export async function POST(request: NextRequest) { @@ -78,7 +78,7 @@ export async function POST(request: NextRequest) { const result = await response.json(); - // 벤더 코드 검증 + // 협력업체 코드 검증 if (!result.vendor_code) { return NextResponse.json( { success: false, message: 'Vendor code not provided in ERP response' }, diff --git a/app/globals.css b/app/globals.css index c427b92f..9cd22397 100644 --- a/app/globals.css +++ b/app/globals.css @@ -8,74 +8,74 @@ body { @layer base { :root { - --background: 0 0% 100%; - --foreground: 222.2 84% 4.9%; - --card: 0 0% 100%; - --card-foreground: 222.2 84% 4.9%; - --popover: 0 0% 100%; - --popover-foreground: 222.2 84% 4.9%; - --primary: 222.2 47.4% 11.2%; - --samsung: 222.2 47.4% 11.2%; - --primary-foreground: 210 40% 98%; - --secondary: 210 40% 96.1%; - --secondary-foreground: 222.2 47.4% 11.2%; - --muted: 210 40% 96.1%; - --muted-foreground: 215.4 16.3% 46.9%; - --accent: 210 40% 96.1%; - --accent-foreground: 222.2 47.4% 11.2%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 210 40% 98%; - --border: 214.3 31.8% 91.4%; - --input: 214.3 31.8% 91.4%; - --ring: 222.2 84% 4.9%; - --chart-1: 12 76% 61%; - --chart-2: 173 58% 39%; - --chart-3: 197 37% 24%; - --chart-4: 43 74% 66%; - --chart-5: 27 87% 67%; - --radius: 0.5rem; - --sidebar-background: 0 0% 98%; - --sidebar-foreground: 240 5.3% 26.1%; - --sidebar-primary: 240 5.9% 10%; - --sidebar-primary-foreground: 0 0% 98%; - --sidebar-accent: 240 4.8% 95.9%; - --sidebar-accent-foreground: 240 5.9% 10%; - --sidebar-border: 220 13% 91%; - --sidebar-ring: 217.2 91.2% 59.8%; + --background: 0 0% 100% !important; + --foreground: 222.2 84% 4.9% !important; + --card: 0 0% 100% !important; + --card-foreground: 222.2 84% 4.9% !important; + --popover: 0 0% 100% !important; + --popover-foreground: 222.2 84% 4.9% !important; + --primary: 222.2 47.4% 11.2% !important; + --samsung: 222.2 47.4% 11.2% !important; + --primary-foreground: 210 40% 98% !important; + --secondary: 210 40% 96.1% !important; + --secondary-foreground: 222.2 47.4% 11.2% !important; + --muted: 210 40% 96.1% !important; + --muted-foreground: 215.4 16.3% 46.9% !important; + --accent: 210 40% 96.1% !important; + --accent-foreground: 222.2 47.4% 11.2% !important; + --destructive: 0 84.2% 60.2% !important; + --destructive-foreground: 210 40% 98% !important; + --border: 214.3 31.8% 91.4% !important; + --input: 214.3 31.8% 91.4% !important; + --ring: 222.2 84% 4.9% !important; + --chart-1: 12 76% 61% !important; + --chart-2: 173 58% 39% !important; + --chart-3: 197 37% 24% !important; + --chart-4: 43 74% 66% !important; + --chart-5: 27 87% 67% !important; + --radius: 0.5rem !important; + --sidebar-background: 0 0% 98% !important; + --sidebar-foreground: 240 5.3% 26.1% !important; + --sidebar-primary: 240 5.9% 10% !important; + --sidebar-primary-foreground: 0 0% 98% !important; + --sidebar-accent: 240 4.8% 95.9% !important; + --sidebar-accent-foreground: 240 5.9% 10% !important; + --sidebar-border: 220 13% 91% !important; + --sidebar-ring: 217.2 91.2% 59.8% !important; } .dark { - --background: 222.2 84% 4.9%; - --foreground: 210 40% 98%; - --card: 222.2 84% 4.9%; - --card-foreground: 210 40% 98%; - --popover: 222.2 84% 4.9%; - --popover-foreground: 210 40% 98%; - --primary: 210 40% 98%; - --primary-foreground: 222.2 47.4% 11.2%; - --secondary: 217.2 32.6% 17.5%; - --secondary-foreground: 210 40% 98%; - --muted: 217.2 32.6% 17.5%; - --muted-foreground: 215 20.2% 65.1%; - --accent: 217.2 32.6% 17.5%; - --accent-foreground: 210 40% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 210 40% 98%; - --border: 217.2 32.6% 17.5%; - --input: 217.2 32.6% 17.5%; - --ring: 212.7 26.8% 83.9%; - --chart-1: 220 70% 50%; - --chart-2: 160 60% 45%; - --chart-3: 30 80% 55%; - --chart-4: 280 65% 60%; - --chart-5: 340 75% 55%; - --sidebar-background: 240 5.9% 10%; - --sidebar-foreground: 240 4.8% 95.9%; - --sidebar-primary: 224.3 76.3% 48%; - --sidebar-primary-foreground: 0 0% 100%; - --sidebar-accent: 240 3.7% 15.9%; - --sidebar-accent-foreground: 240 4.8% 95.9%; - --sidebar-border: 240 3.7% 15.9%; - --sidebar-ring: 217.2 91.2% 59.8%; + --background: 222.2 84% 4.9% !important; + --foreground: 210 40% 98% !important; + --card: 222.2 84% 4.9% !important; + --card-foreground: 210 40% 98% !important; + --popover: 222.2 84% 4.9% !important; + --popover-foreground: 210 40% 98% !important; + --primary: 210 40% 98% !important; + --primary-foreground: 222.2 47.4% 11.2% !important; + --secondary: 217.2 32.6% 17.5% !important; + --secondary-foreground: 210 40% 98% !important; + --muted: 217.2 32.6% 17.5% !important; + --muted-foreground: 215 20.2% 65.1% !important; + --accent: 217.2 32.6% 17.5% !important; + --accent-foreground: 210 40% 98% !important; + --destructive: 0 62.8% 30.6% !important; + --destructive-foreground: 210 40% 98% !important; + --border: 217.2 32.6% 17.5% !important; + --input: 217.2 32.6% 17.5% !important; + --ring: 212.7 26.8% 83.9% !important; + --chart-1: 220 70% 50% !important; + --chart-2: 160 60% 45% !important; + --chart-3: 30 80% 55% !important; + --chart-4: 280 65% 60% !important; + --chart-5: 340 75% 55% !important; + --sidebar-background: 240 5.9% 10% !important; + --sidebar-foreground: 240 4.8% 95.9% !important; + --sidebar-primary: 224.3 76.3% 48% !important; + --sidebar-primary-foreground: 0 0% 100% !important; + --sidebar-accent: 240 3.7% 15.9% !important; + --sidebar-accent-foreground: 240 4.8% 95.9% !important; + --sidebar-border: 240 3.7% 15.9% !important; + --sidebar-ring: 217.2 91.2% 59.8% !important; } } diff --git a/components/BidProjectSelector.tsx b/components/BidProjectSelector.tsx new file mode 100644 index 00000000..8e229b10 --- /dev/null +++ b/components/BidProjectSelector.tsx @@ -0,0 +1,124 @@ +"use client" + +import * as React from "react" +import { Check, ChevronsUpDown } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover" +import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command" +import { cn } from "@/lib/utils" +import { getBidProjects, type Project } from "@/lib/rfqs/service" + +interface ProjectSelectorProps { + selectedProjectId?: number | null; + onProjectSelect: (project: Project) => void; + placeholder?: string; +} + +export function EstimateProjectSelector ({ + selectedProjectId, + onProjectSelect, + placeholder = "프로젝트 선택..." +}: ProjectSelectorProps) { + const [open, setOpen] = React.useState(false) + const [searchTerm, setSearchTerm] = React.useState("") + const [projects, setProjects] = React.useState([]) + const [isLoading, setIsLoading] = React.useState(false) + const [selectedProject, setSelectedProject] = React.useState(null) + + // 모든 프로젝트 데이터 로드 (한 번만) + React.useEffect(() => { + async function loadAllProjects() { + setIsLoading(true); + try { + const allProjects = await getBidProjects(); + setProjects(allProjects); + + // 초기 선택된 프로젝트가 있으면 설정 + if (selectedProjectId) { + const selected = allProjects.find(p => p.id === selectedProjectId); + if (selected) { + setSelectedProject(selected); + } + } + } catch (error) { + console.error("프로젝트 목록 로드 오류:", error); + } finally { + setIsLoading(false); + } + } + + loadAllProjects(); + }, [selectedProjectId]); + + // 클라이언트 측에서 검색어로 필터링 + const filteredProjects = React.useMemo(() => { + if (!searchTerm.trim()) return projects; + + const lowerSearch = searchTerm.toLowerCase(); + return projects.filter( + project => + project.projectCode.toLowerCase().includes(lowerSearch) || + project.projectName.toLowerCase().includes(lowerSearch) + ); + }, [projects, searchTerm]); + + // 프로젝트 선택 처리 + const handleSelectProject = (project: Project) => { + setSelectedProject(project); + onProjectSelect(project); + setOpen(false); + }; + + return ( + + + + + + + + + 검색 결과가 없습니다 + {isLoading ? ( +
로딩 중...
+ ) : ( + + {filteredProjects.map((project) => ( + handleSelectProject(project)} + > + + {project.projectCode} + - {project.projectName} + + ))} + + )} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/components/additional-info/join-form.tsx b/components/additional-info/join-form.tsx index 2cd385c3..4a9a3379 100644 --- a/components/additional-info/join-form.tsx +++ b/components/additional-info/join-form.tsx @@ -41,7 +41,7 @@ import { cn } from "@/lib/utils" import { useTranslation } from "@/i18n/client" import { getVendorDetailById, downloadVendorAttachments, updateVendorInfo } from "@/lib/vendors/service" -import { updateVendorSchema, type UpdateVendorInfoSchema } from "@/lib/vendors/validations" +import { updateVendorSchema, updateVendorSchemaWithConditions, type UpdateVendorInfoSchema } from "@/lib/vendors/validations" import { Select, SelectContent, @@ -104,6 +104,14 @@ const creditRatingScaleMap: Record = { SCI: ["AAA", "AA+", "AA", "AA-", "A+", "A", "A-", "BBB+", "BBB-", "B"], } +const cashFlowRatingScaleMap: Record = { + NICE: ["우수", "양호", "보통", "미흡", "불량"], + KIS: ["A+", "A", "B+", "B", "C", "D"], + KED: ["1등급", "2등급", "3등급", "4등급", "5등급"], + SCI: ["Level 1", "Level 2", "Level 3", "Level 4"], +} + + const MAX_FILE_SIZE = 3e9 // 파일 타입 정의 @@ -124,7 +132,7 @@ export function InfoForm() { const companyId = session?.user?.companyId || "17" - // 벤더 데이터 상태 + // 협력업체 데이터 상태 const [vendor, setVendor] = React.useState(null) const [isLoading, setIsLoading] = React.useState(true) const [isSubmitting, setIsSubmitting] = React.useState(false) @@ -137,10 +145,11 @@ export function InfoForm() { const [selectedFiles, setSelectedFiles] = React.useState([]) const [creditRatingFile, setCreditRatingFile] = React.useState([]) const [cashFlowRatingFile, setCashFlowRatingFile] = React.useState([]) + const [isDownloading, setIsDownloading] = React.useState(false); // React Hook Form const form = useForm({ - resolver: zodResolver(updateVendorSchema), + resolver: zodResolver(updateVendorSchemaWithConditions), defaultValues: { vendorName: "", taxId: "", @@ -181,21 +190,21 @@ export function InfoForm() { name: "contacts", }) - // 벤더 정보 가져오기 + // 협력업체 정보 가져오기 React.useEffect(() => { async function fetchVendorData() { if (!companyId) return try { setIsLoading(true) - // 벤더 상세 정보 가져오기 (view 사용) + // 협력업체 상세 정보 가져오기 (view 사용) const vendorData = await getVendorDetailById(Number(companyId)) if (!vendorData) { toast({ variant: "destructive", title: "오류", - description: "벤더 정보를 찾을 수 없습니다.", + description: "협력업체 정보를 찾을 수 없습니다.", }) return } @@ -258,7 +267,7 @@ export function InfoForm() { toast({ variant: "destructive", title: "데이터 로드 오류", - description: "벤더 정보를 불러오는 중 오류가 발생했습니다.", + description: "협력업체 정보를 불러오는 중 오류가 발생했습니다.", }) } finally { setIsLoading(false) @@ -268,43 +277,82 @@ export function InfoForm() { fetchVendorData() }, [companyId, form, replaceContacts]) - // 파일 다운로드 처리 - const handleDownloadFile = async (fileId: number) => { + const handleDownloadFile = async (file: AttachmentFile) => { try { - const downloadInfo = await downloadVendorAttachments(Number(companyId), fileId) - - if (downloadInfo && downloadInfo.url) { - // 브라우저에서 다운로드 링크 열기 - window.open(downloadInfo.url, '_blank') - } - } catch (error) { - console.error("Error downloading file:", error) + setIsDownloading(true); + + // 파일이 객체인지 ID인지 확인하고 처리 + const fileId = typeof file === 'object' ? file.id : file; + const fileName = typeof file === 'object' ? file.fileName : `file-${fileId}`; + + // 다운로드 링크 생성 (URL 인코딩 적용) + const downloadUrl = `/api/vendors/attachments/download?id=${fileId}&vendorId=${Number(companyId)}`; + + // a 태그를 사용한 다운로드 + const downloadLink = document.createElement('a'); + downloadLink.href = downloadUrl; + downloadLink.download = fileName; + downloadLink.target = '_blank'; // 추가: 새 탭에서 열도록 설정 (일부 브라우저에서 더 안정적) + document.body.appendChild(downloadLink); + downloadLink.click(); + + // 정리 (메모리 누수 방지) + setTimeout(() => { + document.body.removeChild(downloadLink); + }, 100); + toast({ - variant: "destructive", - title: "다운로드 오류", - description: "파일 다운로드 중 오류가 발생했습니다.", - }) - } - } - - // 모든 첨부파일 다운로드 - const handleDownloadAllFiles = async () => { - try { - const downloadInfo = await downloadVendorAttachments(Number(companyId)) - - if (downloadInfo && downloadInfo.url) { - window.open(downloadInfo.url, '_blank') - } + title: "다운로드 시작", + description: "파일 다운로드가 시작되었습니다.", + }); } catch (error) { - console.error("Error downloading files:", error) + console.error("Error downloading file:", error); toast({ variant: "destructive", title: "다운로드 오류", description: "파일 다운로드 중 오류가 발생했습니다.", - }) + }); + } finally { + setIsDownloading(false); } + }; + + // 전체 파일 다운로드 함수 +const handleDownloadAllFiles = async () => { + try { + setIsDownloading(true); + + // 다운로드 URL 생성 + const downloadUrl = `/api/vendors/attachments/download-all?vendorId=${Number(companyId)}`; + + // a 태그를 사용한 다운로드 + const downloadLink = document.createElement('a'); + downloadLink.href = downloadUrl; + downloadLink.download = `vendor-${companyId}-files.zip`; + downloadLink.target = '_blank'; + document.body.appendChild(downloadLink); + downloadLink.click(); + + // 정리 + setTimeout(() => { + document.body.removeChild(downloadLink); + }, 100); + + toast({ + title: "다운로드 시작", + description: "전체 파일 다운로드가 시작되었습니다.", + }); + } catch (error) { + console.error("Error downloading files:", error); + toast({ + variant: "destructive", + title: "다운로드 오류", + description: "파일 다운로드 중 오류가 발생했습니다.", + }); + } finally { + setIsDownloading(false); } - +}; // Dropzone handlers const handleDropAccepted = (acceptedFiles: File[]) => { const newFiles = [...selectedFiles, ...acceptedFiles] @@ -476,7 +524,7 @@ export function InfoForm() { return (
- 벤더 정보를 불러오는 중입니다... + 협력업체 정보를 불러오는 중입니다...
) } @@ -543,7 +591,7 @@ export function InfoForm() {
- handleDownloadFile(file.id)}> + handleDownloadFile(file)}> handleDeleteExistingFile(file.id)}> @@ -574,7 +622,7 @@ export function InfoForm() {
- handleDownloadFile(file.id)}> + handleDownloadFile(file)}> handleDeleteExistingFile(file.id)}> @@ -605,7 +653,7 @@ export function InfoForm() {
- handleDownloadFile(file.id)}> + handleDownloadFile(file)}> handleDeleteExistingFile(file.id)}> @@ -1095,8 +1143,8 @@ export function InfoForm() { render={({ field }) => { const selectedAgency = form.watch("creditAgency") const ratingScale = - creditRatingScaleMap[ - selectedAgency as keyof typeof creditRatingScaleMap + cashFlowRatingScaleMap[ + selectedAgency as keyof typeof cashFlowRatingScaleMap ] || [] return ( diff --git a/components/client-data-table/data-table-toolbar.tsx b/components/client-data-table/data-table-toolbar.tsx index 286cffd6..6c246c1a 100644 --- a/components/client-data-table/data-table-toolbar.tsx +++ b/components/client-data-table/data-table-toolbar.tsx @@ -33,24 +33,6 @@ export function ClientDataTableAdvancedToolbar({ ...props }: DataTableAdvancedToolbarProps) { - // 전체 엑셀 내보내기 - const handleExportAll = async () => { - try { - await exportTableToExcel(table, { - filename: "my-data", - onlySelected: false, - excludeColumns: ["select", "actions", "validation", "requestedAmount", "update"], - useGroupHeader: false, - allPages: true, - - }) - } catch (err) { - console.error("Export error:", err) - // 필요하면 토스트나 알림 처리 - } - } - - return (
({ -
{/* 오른쪽: Export 버튼 + children */} diff --git a/components/client-data-table/data-table.tsx b/components/client-data-table/data-table.tsx index 9336db62..9067a475 100644 --- a/components/client-data-table/data-table.tsx +++ b/components/client-data-table/data-table.tsx @@ -64,7 +64,7 @@ export function ClientDataTable({ const [grouping, setGrouping] = React.useState([]) const [columnSizing, setColumnSizing] = React.useState({}) const [columnPinning, setColumnPinning] = React.useState({ - left: [], + left: ["TAG_NO", "TAG_DESC"], right: ["update"], }) diff --git a/components/data-table/data-table-advanced-toolbar.tsx b/components/data-table/data-table-advanced-toolbar.tsx index 7c126c51..256dc125 100644 --- a/components/data-table/data-table-advanced-toolbar.tsx +++ b/components/data-table/data-table-advanced-toolbar.tsx @@ -3,6 +3,8 @@ import * as React from "react" import type { DataTableAdvancedFilterField } from "@/types/table" import { type Table } from "@tanstack/react-table" +import { LayoutGrid, TableIcon } from "lucide-react" +import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" import { DataTableFilterList } from "@/components/data-table/data-table-filter-list" @@ -14,6 +16,36 @@ import { PinRightButton } from "./data-table-pin-right" import { DataTableGlobalFilter } from "./data-table-grobal-filter" import { DataTableGroupList } from "./data-table-group-list" +// 로컬 스토리지 사용을 위한 훅 +const useLocalStorage = (key: string, initialValue: T): [T, (value: T | ((val: T) => T)) => void] => { + const [storedValue, setStoredValue] = React.useState(() => { + if (typeof window === "undefined") { + return initialValue + } + try { + const item = window.localStorage.getItem(key) + return item ? JSON.parse(item) : initialValue + } catch (error) { + console.error(error) + return initialValue + } + }) + + const setValue = (value: T | ((val: T) => T)) => { + try { + const valueToStore = value instanceof Function ? value(storedValue) : value + setStoredValue(valueToStore) + if (typeof window !== "undefined") { + window.localStorage.setItem(key, JSON.stringify(valueToStore)) + } + } catch (error) { + console.error(error) + } + } + + return [storedValue, setValue] +} + interface DataTableAdvancedToolbarProps extends React.HTMLAttributes { /** @@ -58,6 +90,29 @@ interface DataTableAdvancedToolbarProps * @default true */ shallow?: boolean + + /** + * 컴팩트 모드를 사용할지 여부 (토글 버튼을 숨기려면 null) + * @default true + */ + enableCompactToggle?: boolean | null + + /** + * 초기 컴팩트 모드 상태 + * @default false + */ + initialCompact?: boolean + + /** + * 컴팩트 모드가 변경될 때 호출될 콜백 함수 + */ + onCompactChange?: (isCompact: boolean) => void + + /** + * 컴팩트 모드 상태를 저장할 로컬 스토리지 키 + * @default "dataTableCompact" + */ + compactStorageKey?: string } export function DataTableAdvancedToolbar({ @@ -65,10 +120,30 @@ export function DataTableAdvancedToolbar({ filterFields = [], debounceMs = 300, shallow = true, + enableCompactToggle = true, + initialCompact = false, + onCompactChange, + compactStorageKey = "dataTableCompact", children, className, ...props }: DataTableAdvancedToolbarProps) { + // 컴팩트 모드 상태 관리 + const [isCompact, setIsCompact] = useLocalStorage( + compactStorageKey, + initialCompact + ) + + // 컴팩트 모드 변경 시 콜백 호출 + React.useEffect(() => { + onCompactChange?.(isCompact) + }, [isCompact, onCompactChange]) + + // 컴팩트 모드 토글 핸들러 + const handleToggleCompact = React.useCallback(() => { + setIsCompact(prev => !prev) + }, [setIsCompact]) + return (
({ {...props} >
- + {enableCompactToggle && ( + + )} + ({ debounceMs={debounceMs} shallow={shallow} /> - - - + + +
- {children} + {/* 컴팩트 모드 토글 버튼 */} + {children}
) -} +} \ No newline at end of file diff --git a/components/data-table/data-table-compact-toggle.tsx b/components/data-table/data-table-compact-toggle.tsx new file mode 100644 index 00000000..5c162a03 --- /dev/null +++ b/components/data-table/data-table-compact-toggle.tsx @@ -0,0 +1,35 @@ +"use client" + +import * as React from "react" +import { Button } from "@/components/ui/button" +import { TableIcon, LayoutGrid } from "lucide-react" + +interface DataTableCompactToggleProps { + /** + * 현재 컴팩트 모드 상태 + */ + isCompact: boolean + + /** + * 컴팩트 모드 토글 시 호출될 함수 + */ + onToggleCompact: () => void +} + +export function DataTableCompactToggle({ + isCompact, + onToggleCompact +}: DataTableCompactToggleProps) { + return ( + + ) +} \ No newline at end of file diff --git a/components/data-table/data-table-filter-list.tsx b/components/data-table/data-table-filter-list.tsx index c51d4374..db9f8af9 100644 --- a/components/data-table/data-table-filter-list.tsx +++ b/components/data-table/data-table-filter-list.tsx @@ -82,7 +82,7 @@ export function DataTableFilterList({ }: DataTableFilterListProps) { const params = useParams(); - const lng = params.lng as string; + const lng = params ? (params.lng as string) : 'en'; const { t, i18n } = useTranslation(lng); diff --git a/components/data-table/data-table-group-list.tsx b/components/data-table/data-table-group-list.tsx index cde1cadd..fcae9a79 100644 --- a/components/data-table/data-table-group-list.tsx +++ b/components/data-table/data-table-group-list.tsx @@ -156,7 +156,7 @@ export function DataTableGroupList({ aria-controls={`${id}-group-dialog`} >
); -}; +}; \ No newline at end of file diff --git a/components/form-data/form-data-report-temp-uploaded-list-tab.tsx b/components/form-data/form-data-report-temp-uploaded-list-tab.tsx index 7379a312..a5c3c7a5 100644 --- a/components/form-data/form-data-report-temp-uploaded-list-tab.tsx +++ b/components/form-data/form-data-report-temp-uploaded-list-tab.tsx @@ -208,4 +208,4 @@ const UploadedTempFiles: FC = ({ ); -}; +}; \ No newline at end of file diff --git a/components/form-data/form-data-table copy.tsx b/components/form-data/form-data-table copy.tsx new file mode 100644 index 00000000..aa16513a --- /dev/null +++ b/components/form-data/form-data-table copy.tsx @@ -0,0 +1,539 @@ +"use client"; + +import * as React from "react"; +import { useParams, useRouter } from "next/navigation"; +import { useTranslation } from "@/i18n/client"; + +import { ClientDataTable } from "../client-data-table/data-table"; +import { + getColumns, + DataTableRowAction, + DataTableColumnJSON, + ColumnType, +} from "./form-data-table-columns"; +import type { DataTableAdvancedFilterField } from "@/types/table"; +import { Button } from "../ui/button"; +import { + Download, + Loader, + Save, + Upload, + Plus, + Tag, + TagsIcon, + FileText, + FileSpreadsheet, + FileOutput, + Clipboard, + Send +} from "lucide-react"; +import { toast } from "sonner"; +import { + getReportTempList, + sendFormDataToSEDP, + syncMissingTags, + updateFormDataInDB, +} from "@/lib/forms/services"; +import { UpdateTagSheet } from "./update-form-sheet"; +import { FormDataReportTempUploadDialog } from "./form-data-report-temp-upload-dialog"; +import { FormDataReportDialog } from "./form-data-report-dialog"; +import { FormDataReportBatchDialog } from "./form-data-report-batch-dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu"; +import { AddFormTagDialog } from "./add-formTag-dialog"; +import { importExcelData } from "./import-excel-form"; +import { exportExcelData } from "./export-excel-form"; +import { SEDPConfirmationDialog, SEDPStatusDialog } from "./sedp-components"; + +interface GenericData { + [key: string]: any; +} + +export interface DynamicTableProps { + dataJSON: GenericData[]; + columnsJSON: DataTableColumnJSON[]; + contractItemId: number; + formCode: string; + formId: number; + projectId: number; + formName?: string; + objectCode?: string; +} + +export default function DynamicTable({ + dataJSON, + columnsJSON, + contractItemId, + formCode, + formId, + projectId, + formName = `VD)${formCode}`, // Default form name based on formCode + objectCode = "LO_PT_CLAS", // Default object code +}: DynamicTableProps) { + const params = useParams(); + const router = useRouter(); + const lng = (params?.lng as string) || "ko"; + const { t } = useTranslation(lng, "translation"); + + const [rowAction, setRowAction] = + React.useState | null>(null); + const [tableData, setTableData] = React.useState(dataJSON); + + + console.log(tableData) + console.log(columnsJSON) + + // Update tableData when dataJSON changes + React.useEffect(() => { + setTableData(dataJSON); + }, [dataJSON]); + + // Separate loading states for different operations + const [isSyncingTags, setIsSyncingTags] = React.useState(false); + const [isImporting, setIsImporting] = React.useState(false); + const [isExporting, setIsExporting] = React.useState(false); + const [isSaving, setIsSaving] = React.useState(false); + const [isSendingSEDP, setIsSendingSEDP] = React.useState(false); + + // Any operation in progress + const isAnyOperationPending = isSyncingTags || isImporting || isExporting || isSaving || isSendingSEDP; + + // SEDP dialogs state + const [sedpConfirmOpen, setSedpConfirmOpen] = React.useState(false); + const [sedpStatusOpen, setSedpStatusOpen] = React.useState(false); + const [sedpStatusData, setSedpStatusData] = React.useState({ + status: 'success' as 'success' | 'error' | 'partial', + message: '', + successCount: 0, + errorCount: 0, + totalCount: 0 + }); + + const [tempUpDialog, setTempUpDialog] = React.useState(false); + const [reportData, setReportData] = React.useState([]); + const [batchDownDialog, setBatchDownDialog] = React.useState(false); + const [tempCount, setTempCount] = React.useState(0); + const [addTagDialogOpen, setAddTagDialogOpen] = React.useState(false); + + React.useEffect(() => { + const getTempCount = async () => { + const tempList = await getReportTempList(contractItemId, formId); + setTempCount(tempList.length); + }; + + getTempCount(); + }, [contractItemId, formId, tempUpDialog]); + + const columns = React.useMemo( + () => getColumns({ columnsJSON, setRowAction, setReportData, tempCount }), + [columnsJSON, setRowAction, setReportData, tempCount] + ); + + function mapColumnTypeToAdvancedFilterType( + columnType: ColumnType + ): DataTableAdvancedFilterField["type"] { + switch (columnType) { + case "STRING": + return "text"; + case "NUMBER": + return "number"; + case "LIST": + return "select"; + default: + return "text"; + } + } + + const advancedFilterFields = React.useMemo< + DataTableAdvancedFilterField[] + >(() => { + return columnsJSON.map((col) => ({ + id: col.key, + label: col.label, + type: mapColumnTypeToAdvancedFilterType(col.type), + options: + col.type === "LIST" + ? col.options?.map((v) => ({ label: v, value: v })) + : undefined, + })); + }, [columnsJSON]); + + // 태그 불러오기 + async function handleSyncTags() { + try { + setIsSyncingTags(true); + const result = await syncMissingTags(contractItemId, formCode); + + // Prepare the toast messages based on what changed + const changes = []; + if (result.createdCount > 0) + changes.push(`${result.createdCount}건 태그 생성`); + if (result.updatedCount > 0) + changes.push(`${result.updatedCount}건 태그 업데이트`); + if (result.deletedCount > 0) + changes.push(`${result.deletedCount}건 태그 삭제`); + + if (changes.length > 0) { + // If any changes were made, show success message and reload + toast.success(`동기화 완료: ${changes.join(", ")}`); + router.refresh(); // Use router.refresh instead of location.reload + } else { + // If no changes were made, show an info message + toast.info("변경사항이 없습니다. 모든 태그가 최신 상태입니다."); + } + } catch (err) { + console.error(err); + toast.error("태그 동기화 중 에러가 발생했습니다."); + } finally { + setIsSyncingTags(false); + } + } + + // Excel Import - Modified to directly save to DB + async function handleImportExcel(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + + try { + setIsImporting(true); + + // Call the updated importExcelData function with direct save capability + const result = await importExcelData({ + file, + tableData, + columnsJSON, + formCode, // Pass formCode for direct save + contractItemId, // Pass contractItemId for direct save + onPendingChange: setIsImporting, + onDataUpdate: (newData) => { + // This is called only after successful DB save + setTableData(Array.isArray(newData) ? newData : newData(tableData)); + } + }); + + // If import and save was successful, refresh the page + if (result.success) { + router.refresh(); + } + } catch (error) { + console.error("Import failed:", error); + toast.error("Failed to import Excel data"); + } finally { + // Always clear the file input value + e.target.value = ""; + setIsImporting(false); + } + } + + // SEDP Send handler (with confirmation) + function handleSEDPSendClick() { + if (tableData.length === 0) { + toast.error("No data to send to SEDP"); + return; + } + + // Open confirmation dialog + setSedpConfirmOpen(true); + } + + // Actual SEDP send after confirmation +// In your DynamicTable component, update the handler for SEDP sending + +async function handleSEDPSendConfirmed() { + try { + setIsSendingSEDP(true); + + // Validate data + const invalidData = tableData.filter((item) => !item.TAG_NO?.trim()); + if (invalidData.length > 0) { + toast.error(`태그 번호가 없는 항목이 ${invalidData.length}개 있습니다.`); + setSedpConfirmOpen(false); + return; + } + + // Then send to SEDP - pass formCode instead of formName + const sedpResult = await sendFormDataToSEDP( + formCode, // Send formCode instead of formName + projectId, // Project ID + tableData, // Table data + columnsJSON // Column definitions + ); + + // Close confirmation dialog + setSedpConfirmOpen(false); + + // Set status data based on result + if (sedpResult.success) { + setSedpStatusData({ + status: 'success', + message: "Data successfully sent to SEDP", + successCount: tableData.length, + errorCount: 0, + totalCount: tableData.length + }); + } else { + setSedpStatusData({ + status: 'error', + message: sedpResult.message || "Failed to send data to SEDP", + successCount: 0, + errorCount: tableData.length, + totalCount: tableData.length + }); + } + + // Open status dialog to show result + setSedpStatusOpen(true); + + // Refresh the route to get fresh data + router.refresh(); + + } catch (err: any) { + console.error("SEDP error:", err); + + // Set error status + setSedpStatusData({ + status: 'error', + message: err.message || "An unexpected error occurred", + successCount: 0, + errorCount: tableData.length, + totalCount: tableData.length + }); + + // Close confirmation and open status + setSedpConfirmOpen(false); + setSedpStatusOpen(true); + + } finally { + setIsSendingSEDP(false); + } +} + // Template Export + async function handleExportExcel() { + try { + setIsExporting(true); + await exportExcelData({ + tableData, + columnsJSON, + formCode, + onPendingChange: setIsExporting + }); + } finally { + setIsExporting(false); + } + } + + // Handle batch document check + const handleBatchDocument = () => { + if (tempCount > 0) { + setBatchDownDialog(true); + } else { + toast.error("업로드된 Template File이 없습니다."); + } + }; + + return ( + <> + + {/* 버튼 그룹 */} +
+ {/* 태그 관리 드롭다운 */} + + + + + + + + Sync Tags + + setAddTagDialogOpen(true)} disabled={isAnyOperationPending}> + + Add Tags + + + + + {/* 리포트 관리 드롭다운 */} + + + + + + setTempUpDialog(true)} disabled={isAnyOperationPending}> + + Upload Template + + + + Batch Document + + + + + {/* IMPORT 버튼 (파일 선택) */} + + + {/* EXPORT 버튼 */} + + + + {/* SEDP 전송 버튼 */} + +
+
+ + {/* Modal dialog for tag update */} + { + if (!open) setRowAction(null); + }} + columns={columnsJSON} + rowData={rowAction?.row.original ?? null} + formCode={formCode} + contractItemId={contractItemId} + onUpdateSuccess={(updatedValues) => { + // Update the specific row in tableData when a single row is updated + if (rowAction?.row.original?.TAG_NO) { + const tagNo = rowAction.row.original.TAG_NO; + setTableData(prev => + prev.map(item => + item.TAG_NO === tagNo ? updatedValues : item + ) + ); + } + }} + /> + + {/* Dialog for adding tags */} + + + {/* SEDP Confirmation Dialog */} + setSedpConfirmOpen(false)} + onConfirm={handleSEDPSendConfirmed} + formName={formName} + tagCount={tableData.length} + isLoading={isSendingSEDP} + /> + + {/* SEDP Status Dialog */} + setSedpStatusOpen(false)} + status={sedpStatusData.status} + message={sedpStatusData.message} + successCount={sedpStatusData.successCount} + errorCount={sedpStatusData.errorCount} + totalCount={sedpStatusData.totalCount} + /> + + {/* Other dialogs */} + {tempUpDialog && ( + + )} + + {reportData.length > 0 && ( + + )} + + {batchDownDialog && ( + + )} + + ); +} \ No newline at end of file diff --git a/components/form-data/form-data-table-columns.tsx b/components/form-data/form-data-table-columns.tsx index a136b5d3..4db3a724 100644 --- a/components/form-data/form-data-table-columns.tsx +++ b/components/form-data/form-data-table-columns.tsx @@ -16,6 +16,7 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { toast } from 'sonner'; /** row 액션 관련 타입 */ export interface DataTableRowAction { row: Row; @@ -36,6 +37,7 @@ export interface DataTableColumnJSON { type: ColumnType; options?: string[]; uom?: string; + uomId?: string; } /** * getColumns 함수에 필요한 props @@ -47,6 +49,7 @@ interface GetColumnsProps { React.SetStateAction | null> >; setReportData: React.Dispatch>; + tempCount: number; } /** @@ -58,6 +61,7 @@ export function getColumns({ columnsJSON, setRowAction, setReportData, + tempCount, }: GetColumnsProps): ColumnDef[] { // (1) 기본 컬럼들 const baseColumns: ColumnDef[] = columnsJSON.map((col) => ({ @@ -73,7 +77,7 @@ export function getColumns({ excelHeader: col.label, minWidth: 80, paddingFactor: 1.2, - maxWidth: col.key === "tagNumber" ? 120 : 150, + maxWidth: col.key === "TAG_NO" ? 120 : 150, }, // (2) 실제 셀(cell) 렌더링: type에 따라 분기 가능 cell: ({ row }) => { @@ -129,22 +133,23 @@ export function getColumns({ { + if(tempCount > 0){ const { original } = row; setReportData([original]); + } else { + toast.error("업로드된 Template File이 없습니다."); + } }} > - Create Vendor Document + Create Document ), - size: 40, - meta: { - maxWidth: 40, - }, + minSize: 50, enablePinning: true, }; // (4) 최종 반환 return [...baseColumns, actionColumn]; -} +} \ No newline at end of file diff --git a/components/form-data/form-data-table.tsx b/components/form-data/form-data-table.tsx index 4caee44f..05278375 100644 --- a/components/form-data/form-data-table.tsx +++ b/components/form-data/form-data-table.tsx @@ -1,7 +1,7 @@ "use client"; import * as React from "react"; -import { useParams } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; import { useTranslation } from "@/i18n/client"; import { ClientDataTable } from "../client-data-table/data-table"; @@ -13,20 +13,88 @@ import { } from "./form-data-table-columns"; import type { DataTableAdvancedFilterField } from "@/types/table"; import { Button } from "../ui/button"; -import { Download, Loader, Save, Upload } from "lucide-react"; +import { + Download, + Loader, + Save, + Upload, + Plus, + Tag, + TagsIcon, + FileText, + FileSpreadsheet, + FileOutput, + Clipboard, + Send, + GitCompareIcon, + RefreshCcw +} from "lucide-react"; import { toast } from "sonner"; -import { syncMissingTags, updateFormDataInDB } from "@/lib/forms/services"; +import { + getProjectCodeById, + getReportTempList, + sendFormDataToSEDP, + syncMissingTags, + updateFormDataInDB, +} from "@/lib/forms/services"; import { UpdateTagSheet } from "./update-form-sheet"; -import ExcelJS from "exceljs"; -import { saveAs } from "file-saver"; import { FormDataReportTempUploadDialog } from "./form-data-report-temp-upload-dialog"; import { FormDataReportDialog } from "./form-data-report-dialog"; import { FormDataReportBatchDialog } from "./form-data-report-batch-dialog"; import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu"; +import { AddFormTagDialog } from "./add-formTag-dialog"; +import { importExcelData } from "./import-excel-form"; +import { exportExcelData } from "./export-excel-form"; +import { SEDPConfirmationDialog, SEDPStatusDialog } from "./sedp-components"; +import { SEDPCompareDialog } from "./sedp-compare-dialog"; +import { getSEDPToken } from "@/lib/sedp/sedp-token"; + + +async function fetchTagDataFromSEDP(projectCode: string, formCode: string): Promise { + 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'}`); + } +} interface GenericData { [key: string]: any; @@ -38,6 +106,10 @@ export interface DynamicTableProps { contractItemId: number; formCode: string; formId: number; + projectId: number; + formName?: string; + objectCode?: string; + mode: "IM" | "ENG"; // 모드 속성 } export default function DynamicTable({ @@ -46,28 +118,98 @@ export default function DynamicTable({ contractItemId, formCode, formId, + projectId, + mode = "IM", // 기본값 설정 + formName = `${formCode}`, // Default form name based on formCode }: DynamicTableProps) { const params = useParams(); + const router = useRouter(); const lng = (params?.lng as string) || "ko"; const { t } = useTranslation(lng, "translation"); const [rowAction, setRowAction] = React.useState | null>(null); - const [tableData, setTableData] = React.useState( - () => dataJSON - ); - const [isPending, setIsPending] = React.useState(false); + const [tableData, setTableData] = React.useState(dataJSON); + + // Update tableData when dataJSON changes + React.useEffect(() => { + setTableData(dataJSON); + }, [dataJSON]); + + // 폴링 상태 관리를 위한 ref + const pollingRef = React.useRef(null); + const [syncId, setSyncId] = React.useState(null); + + // Separate loading states for different operations + const [isSyncingTags, setIsSyncingTags] = React.useState(false); + const [isImporting, setIsImporting] = React.useState(false); + const [isExporting, setIsExporting] = React.useState(false); const [isSaving, setIsSaving] = React.useState(false); + const [isSendingSEDP, setIsSendingSEDP] = React.useState(false); + const [isLoadingTags, setIsLoadingTags] = React.useState(false); + + // Any operation in progress + const isAnyOperationPending = isSyncingTags || isImporting || isExporting || isSaving || isSendingSEDP || isLoadingTags; + + // SEDP dialogs state + const [sedpConfirmOpen, setSedpConfirmOpen] = React.useState(false); + const [sedpStatusOpen, setSedpStatusOpen] = React.useState(false); + const [sedpStatusData, setSedpStatusData] = React.useState({ + status: 'success' as 'success' | 'error' | 'partial', + message: '', + successCount: 0, + errorCount: 0, + totalCount: 0 + }); + + // SEDP compare dialog state + const [sedpCompareOpen, setSedpCompareOpen] = React.useState(false); + const [projectCode, setProjectCode] = React.useState(''); + const [tempUpDialog, setTempUpDialog] = React.useState(false); const [reportData, setReportData] = React.useState([]); const [batchDownDialog, setBatchDownDialog] = React.useState(false); + const [tempCount, setTempCount] = React.useState(0); + const [addTagDialogOpen, setAddTagDialogOpen] = React.useState(false); + + // Clean up polling on unmount + React.useEffect(() => { + return () => { + if (pollingRef.current) { + clearInterval(pollingRef.current); + } + }; + }, []); + + React.useEffect(() => { + const getTempCount = async () => { + const tempList = await getReportTempList(contractItemId, formId); + setTempCount(tempList.length); + }; + + getTempCount(); + }, [contractItemId, formId, tempUpDialog]); + + // Get project code when component mounts + React.useEffect(() => { + const getProjectCode = async () => { + try { + const code = await getProjectCodeById(projectId); + setProjectCode(code); + } catch (error) { + console.error("Error fetching project code:", error); + toast.error("Failed to fetch project code"); + } + }; - // Reference to the table instance - const tableRef = React.useRef(null); + if (projectId) { + getProjectCode(); + } + }, [projectId]); const columns = React.useMemo( - () => getColumns({ columnsJSON, setRowAction, setReportData }), - [columnsJSON, setRowAction, setReportData] + () => getColumns({ columnsJSON, setRowAction, setReportData, tempCount }), + [columnsJSON, setRowAction, setReportData, tempCount] ); function mapColumnTypeToAdvancedFilterType( @@ -79,11 +221,8 @@ export default function DynamicTable({ case "NUMBER": return "number"; case "LIST": - // "select"로 하셔도 되고 "multi-select"로 하셔도 됩니다. return "select"; - // 그 외 다른 타입들도 적절히 추가 매핑 default: - // 예: 못 매핑한 경우 기본적으로 "text" 적용 return "text"; } } @@ -102,10 +241,10 @@ export default function DynamicTable({ })); }, [columnsJSON]); - // 1) 태그 불러오기 (기존) + // IM 모드: 태그 동기화 함수 async function handleSyncTags() { try { - setIsPending(true); + setIsSyncingTags(true); const result = await syncMissingTags(contractItemId, formCode); // Prepare the toast messages based on what changed @@ -120,7 +259,7 @@ export default function DynamicTable({ if (changes.length > 0) { // If any changes were made, show success message and reload toast.success(`동기화 완료: ${changes.join(", ")}`); - location.reload(); + router.refresh(); // Use router.refresh instead of location.reload } else { // If no changes were made, show an info message toast.info("변경사항이 없습니다. 모든 태그가 최신 상태입니다."); @@ -129,487 +268,393 @@ export default function DynamicTable({ console.error(err); toast.error("태그 동기화 중 에러가 발생했습니다."); } finally { - setIsPending(false); + setIsSyncingTags(false); } } - // 2) Excel Import (새로운 기능) - async function handleImportExcel(e: React.ChangeEvent) { - const file = e.target.files?.[0]; - if (!file) return; - + + // ENG 모드: 태그 가져오기 함수 + const handleGetTags = async () => { try { - setIsPending(true); - - // 기존 테이블 데이터의 tagNumber 목록 (이미 있는 태그) - const existingTagNumbers = new Set(tableData.map((d) => d.tagNumber)); - - const workbook = new ExcelJS.Workbook(); - const arrayBuffer = await file.arrayBuffer(); - await workbook.xlsx.load(arrayBuffer); - - const worksheet = workbook.worksheets[0]; - - // (A) 헤더 파싱 (Error 열 추가 전에 먼저 체크) - const headerRow = worksheet.getRow(1); - const headerRowValues = headerRow.values as ExcelJS.CellValue[]; - - // 디버깅용 로그 - console.log("원본 헤더 값:", headerRowValues); - - // Excel의 헤더와 columnsJSON의 label 매핑 생성 - // Excel의 행은 1부터 시작하므로 headerRowValues[0]은 undefined - const headerToIndexMap = new Map(); - for (let i = 1; i < headerRowValues.length; i++) { - const headerValue = String(headerRowValues[i] || "").trim(); - if (headerValue) { - headerToIndexMap.set(headerValue, i); - } - } - - // (B) 헤더 검사 - let headerErrorMessage = ""; - - // (1) "columnsJSON에 있는데 엑셀에 없는" 라벨 - columnsJSON.forEach((col) => { - const label = col.label; - if (!headerToIndexMap.has(label)) { - headerErrorMessage += `Column "${label}" is missing. `; - } - }); - - // (2) "엑셀에는 있는데 columnsJSON에 없는" 라벨 검사 - headerToIndexMap.forEach((index, headerLabel) => { - const found = columnsJSON.some((col) => col.label === headerLabel); - if (!found) { - headerErrorMessage += `Unexpected column "${headerLabel}" found in Excel. `; - } + setIsLoadingTags(true); + + // API 엔드포인트 호출 - 작업 시작만 요청 + const response = await fetch('/api/cron/form-tags/start', { + method: 'POST', + body: JSON.stringify({ projectCode ,formCode ,contractItemId }) }); - - // (C) 이제 Error 열 추가 - const lastColIndex = worksheet.columnCount + 1; - worksheet.getRow(1).getCell(lastColIndex).value = "Error"; - - // 헤더 에러가 있으면 기록 후 다운로드하고 중단 - if (headerErrorMessage) { - headerRow.getCell(lastColIndex).value = headerErrorMessage.trim(); - - const outBuffer = await workbook.xlsx.writeBuffer(); - saveAs(new Blob([outBuffer]), `import-check-result_${Date.now()}.xlsx`); - - toast.error(`Header mismatch found. Please check downloaded file.`); - return; + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to start tag import'); } - - // -- 여기까지 왔다면, 헤더는 문제 없음 -- - - // 컬럼 키-인덱스 매핑 생성 (실제 데이터 행 파싱용) - // columnsJSON의 key와 Excel 열 인덱스 간의 매핑 - const keyToIndexMap = new Map(); - columnsJSON.forEach((col) => { - const index = headerToIndexMap.get(col.label); - if (index !== undefined) { - keyToIndexMap.set(col.key, index); + + const data = await response.json(); + + // 작업 ID 저장 + if (data.syncId) { + setSyncId(data.syncId); + toast.info('Tag import started. This may take a while...'); + + // 상태 확인을 위한 폴링 시작 + startPolling(data.syncId); + } else { + throw new Error('No import ID returned from server'); + } + } catch (error) { + console.error('Error starting tag import:', error); + toast.error( + error instanceof Error + ? error.message + : 'An error occurred while starting tag import' + ); + setIsLoadingTags(false); + } + }; + + const startPolling = (id: string) => { + // 이전 폴링이 있다면 제거 + if (pollingRef.current) { + clearInterval(pollingRef.current); + } + + // 5초마다 상태 확인 + pollingRef.current = setInterval(async () => { + try { + const response = await fetch(`/api/cron/form-tags/status?id=${id}`); + + if (!response.ok) { + throw new Error('Failed to get tag import status'); } - }); - - // 데이터 파싱 - const importedData: GenericData[] = []; - const lastRowNumber = worksheet.lastRow?.number || 1; - let errorCount = 0; - - // 실제 데이터 행 파싱 - for (let rowNum = 2; rowNum <= lastRowNumber; rowNum++) { - const row = worksheet.getRow(rowNum); - const rowValues = row.values as ExcelJS.CellValue[]; - if (!rowValues || rowValues.length <= 1) continue; // 빈 행 스킵 - - let errorMessage = ""; - const rowObj: Record = {}; - - // 각 열에 대해 처리 - columnsJSON.forEach((col) => { - const colIndex = keyToIndexMap.get(col.key); - if (colIndex === undefined) return; - - const cellValue = rowValues[colIndex] ?? ""; - let stringVal = String(cellValue).trim(); - - // 타입별 검사 - switch (col.type) { - case "STRING": - if (!stringVal && col.key === "tagNumber") { - errorMessage += `[${col.label}] is empty. `; - } - rowObj[col.key] = stringVal; - break; - - case "NUMBER": - if (stringVal) { - const num = parseFloat(stringVal); - if (isNaN(num)) { - errorMessage += `[${col.label}] '${stringVal}' is not a valid number. `; - } else { - rowObj[col.key] = num; - } - } else { - rowObj[col.key] = null; - } - break; - - case "LIST": - if ( - stringVal && - col.options && - !col.options.includes(stringVal) - ) { - errorMessage += `[${ - col.label - }] '${stringVal}' not in ${col.options.join(", ")}. `; - } - rowObj[col.key] = stringVal; - break; - - default: - rowObj[col.key] = stringVal; - break; + + const data = await response.json(); + + if (data.status === 'completed') { + // 폴링 중지 + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + + router.refresh(); + + // 상태 초기화 + setIsLoadingTags(false); + setSyncId(null); + + // 성공 메시지 표시 + toast.success( + `Tags imported successfully! ${data.result?.processedCount || 0} items processed.` + ); + + } else if (data.status === 'failed') { + // 에러 처리 + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + + setIsLoadingTags(false); + setSyncId(null); + toast.error(data.error || 'Import failed'); + } else if (data.status === 'processing') { + // 진행 상태 업데이트 (선택적) + if (data.progress) { + toast.info(`Import in progress: ${data.progress}%`, { + id: `import-progress-${id}`, + }); } - }); - - // tagNumber 검사 - const tagNum = rowObj["tagNumber"]; - if (!tagNum) { - errorMessage += `No tagNumber found. `; - } else if (!existingTagNumbers.has(tagNum)) { - errorMessage += `TagNumber '${tagNum}' is not in current data. `; - } - - if (errorMessage) { - row.getCell(lastColIndex).value = errorMessage.trim(); - errorCount++; - } else { - importedData.push(rowObj); } + } catch (error) { + console.error('Error checking importing status:', error); } + }, 5000); // 5초마다 체크 + }; + + // Excel Import - Modified to directly save to DB + async function handleImportExcel(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; - // 에러가 있으면 재다운로드 후 import 중단 - if (errorCount > 0) { - const outBuffer = await workbook.xlsx.writeBuffer(); - saveAs(new Blob([outBuffer]), `import-check-result_${Date.now()}.xlsx`); - toast.error( - `There are ${errorCount} error row(s). Please check downloaded file.` - ); - return; - } - - // 에러 없으니 tableData 병합 - setTableData((prev) => { - const newDataMap = new Map(); - - // 기존 데이터를 맵에 추가 - prev.forEach((item) => { - if (item.tagNumber) { - newDataMap.set(item.tagNumber, { ...item }); - } - }); - - // 임포트 데이터로 기존 데이터 업데이트 - importedData.forEach((item) => { - const tag = item.tagNumber; - if (!tag) return; - const oldItem = newDataMap.get(tag) || {}; - newDataMap.set(tag, { ...oldItem, ...item }); - }); - - return Array.from(newDataMap.values()); + try { + setIsImporting(true); + + // Call the updated importExcelData function with direct save capability + const result = await importExcelData({ + file, + tableData, + columnsJSON, + formCode, // Pass formCode for direct save + contractItemId, // Pass contractItemId for direct save + onPendingChange: setIsImporting, + onDataUpdate: (newData) => { + // This is called only after successful DB save + setTableData(Array.isArray(newData) ? newData : newData(tableData)); + } }); - - toast.success(`Imported ${importedData.length} rows successfully.`); - } catch (err) { - console.error("Excel import error:", err); - toast.error("Excel import failed."); + + // If import and save was successful, refresh the page + if (result.success) { + router.refresh(); + } + } catch (error) { + console.error("Import failed:", error); + toast.error("Failed to import Excel data"); } finally { - setIsPending(false); + // Always clear the file input value e.target.value = ""; + setIsImporting(false); + } + } + + // SEDP Send handler (with confirmation) + function handleSEDPSendClick() { + if (tableData.length === 0) { + toast.error("No data to send to SEDP"); + return; } + + // Open confirmation dialog + setSedpConfirmOpen(true); + } + + // Handle SEDP compare button click + function handleSEDPCompareClick() { + if (tableData.length === 0) { + toast.error("No data to compare with SEDP"); + return; + } + + if (!projectCode) { + toast.error("Project code is not available"); + return; + } + + // Open compare dialog + setSedpCompareOpen(true); } - // 3) Save -> 서버에 전체 tableData를 저장 - async function handleSave() { + // Actual SEDP send after confirmation + async function handleSEDPSendConfirmed() { try { - setIsSaving(true); - - // 유효성 검사 - const invalidData = tableData.filter((item) => !item.tagNumber?.trim()); + setIsSendingSEDP(true); + + // Validate data + const invalidData = tableData.filter((item) => !item.TAG_NO?.trim()); if (invalidData.length > 0) { - toast.error( - `태그 번호가 없는 항목이 ${invalidData.length}개 있습니다.` - ); + toast.error(`태그 번호가 없는 항목이 ${invalidData.length}개 있습니다.`); + setSedpConfirmOpen(false); return; } - // 서버 액션 호출 - const result = await updateFormDataInDB( - formCode, - contractItemId, - tableData + // Then send to SEDP - pass formCode instead of formName + const sedpResult = await sendFormDataToSEDP( + formCode, // Send formCode instead of formName + projectId, // Project ID + tableData, // Table data + columnsJSON // Column definitions ); - if (result.success) { - toast.success(result.message); + // Close confirmation dialog + setSedpConfirmOpen(false); + + // Set status data based on result + if (sedpResult.success) { + setSedpStatusData({ + status: 'success', + message: "Data successfully sent to SEDP", + successCount: tableData.length, + errorCount: 0, + totalCount: tableData.length + }); } else { - toast.error(result.message); + setSedpStatusData({ + status: 'error', + message: sedpResult.message || "Failed to send data to SEDP", + successCount: 0, + errorCount: tableData.length, + totalCount: tableData.length + }); } - } catch (err) { - console.error("Save error:", err); - toast.error("데이터 저장 중 오류가 발생했습니다."); + + // Open status dialog to show result + setSedpStatusOpen(true); + + // Refresh the route to get fresh data + router.refresh(); + + } catch (err: any) { + console.error("SEDP error:", err); + + // Set error status + setSedpStatusData({ + status: 'error', + message: err.message || "An unexpected error occurred", + successCount: 0, + errorCount: tableData.length, + totalCount: tableData.length + }); + + // Close confirmation and open status + setSedpConfirmOpen(false); + setSedpStatusOpen(true); + } finally { - setIsSaving(false); + setIsSendingSEDP(false); } } - - // 4) NEW: Excel Export with data validation for select columns using a hidden validation sheet + + // Template Export async function handleExportExcel() { try { - setIsPending(true); - - // Create a new workbook - const workbook = new ExcelJS.Workbook(); - - // 데이터 시트 생성 - const worksheet = workbook.addWorksheet("Data"); - - // 유효성 검사용 숨김 시트 생성 - const validationSheet = workbook.addWorksheet("ValidationData"); - validationSheet.state = "hidden"; // 시트 숨김 처리 - - // 1. 유효성 검사 시트에 select 옵션 추가 - const selectColumns = columnsJSON.filter( - (col) => col.type === "LIST" && col.options && col.options.length > 0 - ); - - // 유효성 검사 범위 저장 맵 (컬럼 키 -> 유효성 검사 범위) - const validationRanges = new Map(); - - selectColumns.forEach((col, idx) => { - const colIndex = idx + 1; - const colLetter = validationSheet.getColumn(colIndex).letter; - - // 헤더 추가 (컬럼 레이블) - validationSheet.getCell(`${colLetter}1`).value = col.label; - - // 옵션 추가 - if (col.options) { - col.options.forEach((option, optIdx) => { - validationSheet.getCell(`${colLetter}${optIdx + 2}`).value = option; - }); - - // 유효성 검사 범위 저장 (ValidationData!$A$2:$A$4 형식) - validationRanges.set( - col.key, - `ValidationData!${colLetter}$2:${colLetter}${ - col.options.length + 1 - }` - ); - } - }); - - // 2. 데이터 시트에 헤더 추가 - const headers = columnsJSON.map((col) => col.label); - worksheet.addRow(headers); - - // 헤더 스타일 적용 - const headerRow = worksheet.getRow(1); - headerRow.font = { bold: true }; - headerRow.alignment = { horizontal: "center" }; - headerRow.eachCell((cell) => { - cell.fill = { - type: "pattern", - pattern: "solid", - fgColor: { argb: "FFCCCCCC" }, - }; - }); - - // 3. 데이터 행 추가 - tableData.forEach((row) => { - const rowValues = columnsJSON.map((col) => { - const value = row[col.key]; - return value !== undefined && value !== null ? value : ""; - }); - worksheet.addRow(rowValues); - }); - - // 4. 데이터 유효성 검사 적용 - const maxRows = 5000; // 데이터 유효성 검사를 적용할 최대 행 수 - - columnsJSON.forEach((col, idx) => { - if (col.type === "LIST" && validationRanges.has(col.key)) { - const colLetter = worksheet.getColumn(idx + 1).letter; - const validationRange = validationRanges.get(col.key)!; - - // 유효성 검사 정의 - const validation = { - type: "list" as const, - allowBlank: true, - formulae: [validationRange], - showErrorMessage: true, - errorStyle: "warning" as const, - errorTitle: "유효하지 않은 값", - error: "목록에서 값을 선택해주세요.", - }; - - // 모든 데이터 행에 유효성 검사 적용 (최대 maxRows까지) - for ( - let rowIdx = 2; - rowIdx <= Math.min(tableData.length + 1, maxRows); - rowIdx++ - ) { - worksheet.getCell(`${colLetter}${rowIdx}`).dataValidation = - validation; - } - - // 빈 행에도 적용 (최대 maxRows까지) - if (tableData.length + 1 < maxRows) { - for ( - let rowIdx = tableData.length + 2; - rowIdx <= maxRows; - rowIdx++ - ) { - worksheet.getCell(`${colLetter}${rowIdx}`).dataValidation = - validation; - } - } - } - }); - - // 5. 컬럼 너비 자동 조정 - columnsJSON.forEach((col, idx) => { - const column = worksheet.getColumn(idx + 1); - - // 최적 너비 계산 - let maxLength = col.label.length; - tableData.forEach((row) => { - const value = row[col.key]; - if (value !== undefined && value !== null) { - const valueLength = String(value).length; - if (valueLength > maxLength) { - maxLength = valueLength; - } - } - }); - - // 너비 설정 (최소 10, 최대 50) - column.width = Math.min(Math.max(maxLength + 2, 10), 50); + setIsExporting(true); + await exportExcelData({ + tableData, + columnsJSON, + formCode, + onPendingChange: setIsExporting }); - - // 6. 파일 다운로드 - const buffer = await workbook.xlsx.writeBuffer(); - saveAs( - new Blob([buffer]), - `${formCode}_data_${new Date().toISOString().slice(0, 10)}.xlsx` - ); - - toast.success("Excel 내보내기 완료!"); - } catch (err) { - console.error("Excel export error:", err); - toast.error("Excel 내보내기 실패."); } finally { - setIsPending(false); + setIsExporting(false); } } + // Handle batch document check + const handleBatchDocument = () => { + if (tempCount > 0) { + setBatchDownDialog(true); + } else { + toast.error("업로드된 Template File이 없습니다."); + } + }; + return ( <> {/* 버튼 그룹 */}
- {/* 태그 불러오기 버튼 */} - - - - - - - - - - + + + setTempUpDialog(true)} disabled={isAnyOperationPending}> + + Upload Template + + + + Batch Document + + + {/* IMPORT 버튼 (파일 선택) */} - - {/* EXPORT 버튼 (새로 추가) */} + {/* EXPORT 버튼 */} - {/* SAVE 버튼 */} + {/* COMPARE WITH SEDP 버튼 */} + + {/* SEDP 전송 버튼 */} +
+ {/* Modal dialog for tag update */} { @@ -619,7 +664,62 @@ export default function DynamicTable({ rowData={rowAction?.row.original ?? null} formCode={formCode} contractItemId={contractItemId} + onUpdateSuccess={(updatedValues) => { + // Update the specific row in tableData when a single row is updated + if (rowAction?.row.original?.TAG_NO) { + const tagNo = rowAction.row.original.TAG_NO; + setTableData(prev => + prev.map(item => + item.TAG_NO === tagNo ? updatedValues : item + ) + ); + } + }} /> + + {/* Dialog for adding tags */} + + + {/* SEDP Confirmation Dialog */} + setSedpConfirmOpen(false)} + onConfirm={handleSEDPSendConfirmed} + formName={formName} + tagCount={tableData.length} + isLoading={isSendingSEDP} + /> + + {/* SEDP Status Dialog */} + setSedpStatusOpen(false)} + status={sedpStatusData.status} + message={sedpStatusData.message} + successCount={sedpStatusData.successCount} + errorCount={sedpStatusData.errorCount} + totalCount={sedpStatusData.totalCount} + /> + + {/* SEDP Compare Dialog */} + setSedpCompareOpen(false)} + tableData={tableData} + columnsJSON={columnsJSON} + projectCode={projectCode} + formCode={formCode} + fetchTagDataFromSEDP={fetchTagDataFromSEDP} + /> + + {/* Other dialogs */} {tempUpDialog && ( ); -} +} \ No newline at end of file diff --git a/components/form-data/import-excel-form.tsx b/components/form-data/import-excel-form.tsx new file mode 100644 index 00000000..45e48312 --- /dev/null +++ b/components/form-data/import-excel-form.tsx @@ -0,0 +1,323 @@ +// lib/excelUtils.ts (continued) +import ExcelJS from "exceljs"; +import { saveAs } from "file-saver"; +import { toast } from "sonner"; +import { DataTableColumnJSON } from "./form-data-table-columns"; +import { updateFormDataInDB } from "@/lib/forms/services"; +// Assuming the previous types are defined above +export interface ImportExcelOptions { + file: File; + tableData: GenericData[]; + columnsJSON: DataTableColumnJSON[]; + formCode?: string; // Optional - provide to enable direct DB save + contractItemId?: number; // Optional - provide to enable direct DB save + onPendingChange?: (isPending: boolean) => void; + onDataUpdate?: (updater: ((prev: GenericData[]) => GenericData[]) | GenericData[]) => void; +} + +export interface ImportExcelResult { + success: boolean; + importedCount?: number; + error?: any; + message?: string; +} + +export interface ExportExcelOptions { + tableData: GenericData[]; + columnsJSON: DataTableColumnJSON[]; + formCode: string; + onPendingChange?: (isPending: boolean) => void; +} + +// For typing consistency +interface GenericData { + [key: string]: any; +} + +export async function importExcelData({ + file, + tableData, + columnsJSON, + formCode, + contractItemId, + onPendingChange, + onDataUpdate +}: ImportExcelOptions): Promise { + if (!file) return { success: false, error: "No file provided" }; + + try { + if (onPendingChange) onPendingChange(true); + + // Get existing tag numbers + const existingTagNumbers = new Set(tableData.map((d) => d.TAG_NO)); + + const workbook = new ExcelJS.Workbook(); + const arrayBuffer = await file.arrayBuffer(); + await workbook.xlsx.load(arrayBuffer); + + const worksheet = workbook.worksheets[0]; + + // Parse headers + const headerRow = worksheet.getRow(1); + const headerRowValues = headerRow.values as ExcelJS.CellValue[]; + + console.log("Original headers:", headerRowValues); + + // Create mappings between Excel headers and column definitions + const headerToIndexMap = new Map(); + for (let i = 1; i < headerRowValues.length; i++) { + const headerValue = String(headerRowValues[i] || "").trim(); + if (headerValue) { + headerToIndexMap.set(headerValue, i); + } + } + + // Validate headers + let headerErrorMessage = ""; + + // Check for missing required columns + columnsJSON.forEach((col) => { + const label = col.label; + if (!headerToIndexMap.has(label)) { + headerErrorMessage += `Column "${label}" is missing. `; + } + }); + + // Check for unexpected columns + headerToIndexMap.forEach((index, headerLabel) => { + const found = columnsJSON.some((col) => col.label === headerLabel); + if (!found) { + headerErrorMessage += `Unexpected column "${headerLabel}" found in Excel. `; + } + }); + + // Add error column + const lastColIndex = worksheet.columnCount + 1; + worksheet.getRow(1).getCell(lastColIndex).value = "Error"; + + // If header validation fails, download error report and exit + if (headerErrorMessage) { + headerRow.getCell(lastColIndex).value = headerErrorMessage.trim(); + + const outBuffer = await workbook.xlsx.writeBuffer(); + saveAs(new Blob([outBuffer]), `import-check-result_${Date.now()}.xlsx`); + + toast.error(`Header mismatch found. Please check downloaded file.`); + return { success: false, error: "Header mismatch" }; + } + + // Create column key to Excel index mapping + const keyToIndexMap = new Map(); + columnsJSON.forEach((col) => { + const index = headerToIndexMap.get(col.label); + if (index !== undefined) { + keyToIndexMap.set(col.key, index); + } + }); + + // Parse and validate data rows + const importedData: GenericData[] = []; + const lastRowNumber = worksheet.lastRow?.number || 1; + let errorCount = 0; + + // Process each data row + for (let rowNum = 2; rowNum <= lastRowNumber; rowNum++) { + const row = worksheet.getRow(rowNum); + const rowValues = row.values as ExcelJS.CellValue[]; + if (!rowValues || rowValues.length <= 1) continue; // Skip empty rows + + let errorMessage = ""; + const rowObj: Record = {}; + + // Process each column + columnsJSON.forEach((col) => { + const colIndex = keyToIndexMap.get(col.key); + if (colIndex === undefined) return; + + const cellValue = rowValues[colIndex] ?? ""; + let stringVal = String(cellValue).trim(); + + // Type-specific validation + switch (col.type) { + case "STRING": + if (!stringVal && col.key === "TAG_NO") { + errorMessage += `[${col.label}] is empty. `; + } + rowObj[col.key] = stringVal; + break; + + case "NUMBER": + if (stringVal) { + const num = parseFloat(stringVal); + if (isNaN(num)) { + errorMessage += `[${col.label}] '${stringVal}' is not a valid number. `; + } else { + rowObj[col.key] = num; + } + } else { + rowObj[col.key] = null; + } + break; + + case "LIST": + if ( + stringVal && + col.options && + !col.options.includes(stringVal) + ) { + errorMessage += `[${ + col.label + }] '${stringVal}' not in ${col.options.join(", ")}. `; + } + rowObj[col.key] = stringVal; + break; + + default: + rowObj[col.key] = stringVal; + break; + } + }); + + // Validate TAG_NO + const tagNum = rowObj["TAG_NO"]; + if (!tagNum) { + errorMessage += `No TAG_NO found. `; + } else if (!existingTagNumbers.has(tagNum)) { + errorMessage += `TagNumber '${tagNum}' is not in current data. `; + } + + // Record errors or add to valid data + if (errorMessage) { + row.getCell(lastColIndex).value = errorMessage.trim(); + errorCount++; + } else { + importedData.push(rowObj); + } + } + + // If there are validation errors, download error report and exit + if (errorCount > 0) { + const outBuffer = await workbook.xlsx.writeBuffer(); + saveAs(new Blob([outBuffer]), `import-check-result_${Date.now()}.xlsx`); + toast.error( + `There are ${errorCount} error row(s). Please check downloaded file.` + ); + return { success: false, error: "Data validation errors" }; + } + + // If we reached here, all data is valid + // Create locally merged data for UI update + const mergedData = [...tableData]; + const dataMap = new Map(); + + // Map existing data by TAG_NO + mergedData.forEach(item => { + if (item.TAG_NO) { + dataMap.set(item.TAG_NO, item); + } + }); + + // Update with imported data + importedData.forEach(item => { + if (item.TAG_NO) { + const existingItem = dataMap.get(item.TAG_NO); + if (existingItem) { + // Update existing item with imported values + Object.assign(existingItem, item); + } + } + }); + + // If formCode and contractItemId are provided, save directly to DB + if (formCode && contractItemId) { + try { + // Process each imported row individually + let successCount = 0; + let errorCount = 0; + const errors = []; + + // Since updateFormDataInDB expects a single row at a time, + // we need to process each imported row individually + for (const importedRow of importedData) { + try { + const result = await updateFormDataInDB( + formCode, + contractItemId, + importedRow + ); + + if (result.success) { + successCount++; + } else { + errorCount++; + errors.push(`Error updating tag ${importedRow.TAG_NO}: ${result.message}`); + } + } catch (rowError) { + errorCount++; + errors.push(`Exception updating tag ${importedRow.TAG_NO}: ${rowError instanceof Error ? rowError.message : 'Unknown error'}`); + } + } + + // If any errors occurred + if (errorCount > 0) { + console.error("Errors during import:", errors); + + if (successCount > 0) { + toast.warning(`Partially successful: ${successCount} rows updated, ${errorCount} errors`); + } else { + toast.error(`Failed to update all ${errorCount} rows`); + } + + // If some rows were updated successfully, update the local state + if (successCount > 0) { + if (onDataUpdate) { + onDataUpdate(() => mergedData); + } + return { + success: true, + importedCount: successCount, + message: `Partially successful: ${successCount} rows updated, ${errorCount} errors` + }; + } else { + return { + success: false, + error: "All updates failed", + message: errors.join("\n") + }; + } + } + + // All rows were updated successfully + if (onDataUpdate) { + onDataUpdate(() => mergedData); + } + + toast.success(`Successfully updated ${successCount} rows`); + return { + success: true, + importedCount: successCount, + message: "All data imported and saved to database" + }; + } catch (saveError) { + console.error("Failed to save imported data:", saveError); + toast.error("Failed to save imported data to database"); + return { success: false, error: saveError }; + } + } else { + // Fall back to just updating local state if DB parameters aren't provided + if (onDataUpdate) { + onDataUpdate(() => mergedData); + } + + toast.success(`Imported ${importedData.length} rows successfully (local only)`); + return { success: true, importedCount: importedData.length }; + } + + } catch (err) { + console.error("Excel import error:", err); + toast.error("Excel import failed."); + return { success: false, error: err }; + } finally { + if (onPendingChange) onPendingChange(false); + } +} \ No newline at end of file diff --git a/components/form-data/publish-dialog.tsx b/components/form-data/publish-dialog.tsx new file mode 100644 index 00000000..a3a2ef0b --- /dev/null +++ b/components/form-data/publish-dialog.tsx @@ -0,0 +1,470 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { useSession } from "next-auth/react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Button } from "@/components/ui/button"; +import { Loader2, Check, ChevronsUpDown } from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { + createRevisionAction, + fetchDocumentsByPackageId, + fetchStagesByDocumentId, + fetchRevisionsByStageParams, + Document, + IssueStage, + Revision +} from "@/lib/vendor-document/service"; + +interface PublishDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + packageId: number; + formCode: string; + fileBlob?: Blob; +} + +export const PublishDialog: React.FC = ({ + open, + onOpenChange, + packageId, + formCode, + fileBlob, +}) => { + // Get current user session from next-auth + const { data: session } = useSession(); + + // State for form data + const [documents, setDocuments] = useState([]); + const [stages, setStages] = useState([]); + const [latestRevision, setLatestRevision] = useState(""); + + // State for document search + const [openDocumentCombobox, setOpenDocumentCombobox] = useState(false); + const [documentSearchValue, setDocumentSearchValue] = useState(""); + + // Selected values + const [selectedDocId, setSelectedDocId] = useState(""); + const [selectedDocumentDisplay, setSelectedDocumentDisplay] = useState(""); + const [selectedStage, setSelectedStage] = useState(""); + const [revisionInput, setRevisionInput] = useState(""); + const [uploaderName, setUploaderName] = useState(""); + const [comment, setComment] = useState(""); + const [customFileName, setCustomFileName] = useState(`${formCode}_document.docx`); + + // Loading states + const [isLoading, setIsLoading] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + // Filter documents by search + const filteredDocuments = documentSearchValue + ? documents.filter(doc => + doc.docNumber.toLowerCase().includes(documentSearchValue.toLowerCase()) || + doc.title.toLowerCase().includes(documentSearchValue.toLowerCase()) + ) + : documents; + + // Set uploader name from session when dialog opens + useEffect(() => { + if (open && session?.user?.name) { + setUploaderName(session.user.name); + } + }, [open, session]); + + // Reset all fields when dialog opens/closes + useEffect(() => { + if (open) { + setSelectedDocId(""); + setSelectedDocumentDisplay(""); + setSelectedStage(""); + setRevisionInput(""); + // Only set uploaderName if not already set from session + if (!session?.user?.name) setUploaderName(""); + setComment(""); + setLatestRevision(""); + setCustomFileName(`${formCode}_document.docx`); + setDocumentSearchValue(""); + } + }, [open, formCode, session]); + + // Fetch documents based on packageId + useEffect(() => { + async function loadDocuments() { + if (packageId && open) { + setIsLoading(true); + + try { + const docs = await fetchDocumentsByPackageId(packageId); + setDocuments(docs); + } catch (error) { + console.error("Error fetching documents:", error); + toast.error("Failed to load documents"); + } finally { + setIsLoading(false); + } + } + } + + loadDocuments(); + }, [packageId, open]); + + // Fetch stages when document is selected + useEffect(() => { + async function loadStages() { + if (selectedDocId) { + setIsLoading(true); + + // Reset dependent fields + setSelectedStage(""); + setRevisionInput(""); + setLatestRevision(""); + + try { + const stagesList = await fetchStagesByDocumentId(parseInt(selectedDocId, 10)); + setStages(stagesList); + } catch (error) { + console.error("Error fetching stages:", error); + toast.error("Failed to load stages"); + } finally { + setIsLoading(false); + } + } else { + setStages([]); + } + } + + loadStages(); + }, [selectedDocId]); + + // Fetch latest revision when stage is selected (for reference) + useEffect(() => { + async function loadLatestRevision() { + if (selectedDocId && selectedStage) { + setIsLoading(true); + + try { + const revsList = await fetchRevisionsByStageParams( + parseInt(selectedDocId, 10), + selectedStage + ); + + // Find the latest revision (assuming revisions are sorted by revision number) + if (revsList.length > 0) { + // Sort revisions if needed + const sortedRevisions = [...revsList].sort((a, b) => { + return b.revision.localeCompare(a.revision, undefined, { numeric: true }); + }); + + setLatestRevision(sortedRevisions[0].revision); + + // Pre-fill the revision input with an incremented value if possible + if (sortedRevisions[0].revision.match(/^\d+$/)) { + // If it's a number, increment it + const nextRevision = String(parseInt(sortedRevisions[0].revision, 10) + 1); + setRevisionInput(nextRevision); + } else if (sortedRevisions[0].revision.match(/^[A-Za-z]$/)) { + // If it's a single letter, get the next letter + const currentChar = sortedRevisions[0].revision.charCodeAt(0); + const nextChar = String.fromCharCode(currentChar + 1); + setRevisionInput(nextChar); + } else { + // For other formats, just show the latest as reference + setRevisionInput(""); + } + } else { + // If no revisions exist, set default values + setLatestRevision(""); + setRevisionInput("0"); + } + } catch (error) { + console.error("Error fetching revisions:", error); + toast.error("Failed to load revision information"); + } finally { + setIsLoading(false); + } + } else { + setLatestRevision(""); + setRevisionInput(""); + } + } + + loadLatestRevision(); + }, [selectedDocId, selectedStage]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!selectedDocId || !selectedStage || !revisionInput || !fileBlob) { + toast.error("Please fill in all required fields"); + return; + } + + setIsSubmitting(true); + + try { + // Create FormData + const formData = new FormData(); + formData.append("documentId", selectedDocId); + formData.append("stage", selectedStage); + formData.append("revision", revisionInput); + formData.append("customFileName", customFileName); + formData.append("uploaderType", "vendor"); // Default value + + if (uploaderName) { + formData.append("uploaderName", uploaderName); + } + + if (comment) { + formData.append("comment", comment); + } + + // Append file as attachment + if (fileBlob) { + const file = new File([fileBlob], customFileName, { + type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + }); + formData.append("attachment", file); + } + + // Call server action directly + const result = await createRevisionAction(formData); + + if (result) { + toast.success("Document published successfully!"); + onOpenChange(false); + } + } catch (error) { + console.error("Error publishing document:", error); + toast.error("Failed to publish document"); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + + + Publish Document + + Select document, stage, and revision to publish the vendor document. + + + +
+
+ {/* Document Selection with Search */} +
+ +
+ + + + + + + + No document found. + + {filteredDocuments.map((doc) => ( + { + setSelectedDocId(String(doc.id)); + setSelectedDocumentDisplay(`${doc.docNumber} - ${doc.title}`); + setOpenDocumentCombobox(false); + }} + className="flex items-center" + > + + {/* Add text-overflow handling for document items */} + {doc.docNumber} - {doc.title} + + ))} + + + + +
+
+ + {/* Stage Selection */} +
+ +
+ +
+
+ + {/* Revision Input */} +
+ +
+ setRevisionInput(e.target.value)} + placeholder="Enter revision" + disabled={isLoading || !selectedStage} + /> + {latestRevision && ( +

+ Latest revision: {latestRevision} +

+ )} +
+
+ +
+ +
+ setCustomFileName(e.target.value)} + placeholder="Custom file name" + /> +
+
+ +
+ +
+ setUploaderName(e.target.value)} + placeholder="Your name" + // Disable input but show a filled style + className={session?.user?.name ? "opacity-70" : ""} + readOnly={!!session?.user?.name} + /> + {session?.user?.name && ( +

+ Using your account name from login +

+ )} +
+
+ +
+ +
+